817 lines
30 KiB
Markdown
817 lines
30 KiB
Markdown
# ESP32 GPIO Sensor Simulation — DHT22 & HC-SR04
|
|
|
|
> **Scope**: Documentación completa del proceso de investigación, fallos y solución final
|
|
> para hacer funcionar los sensores DHT22 y HC-SR04 en la simulación ESP32 de Velxio.
|
|
> Cubre **todo** lo que se intentó, **por qué falló** cada enfoque, y **por qué funciona**
|
|
> la solución actual.
|
|
>
|
|
> Audiencia: mantenedores que necesiten entender o extender la lógica de sensores GPIO.
|
|
|
|
---
|
|
|
|
## Tabla de contenidos
|
|
|
|
1. [Contexto — cómo funciona la emulación ESP32](#1-contexto)
|
|
2. [El callback `_on_dir_change(-1, -1)` — pieza clave](#2-el-callback-_on_dir_change-1--1)
|
|
3. [DHT22 — Problema, diagnóstico y solución](#3-dht22)
|
|
4. [HC-SR04 — Problema, todos los enfoques fallidos y solución](#4-hc-sr04)
|
|
5. [Arquitectura final — `_sync_handlers`](#5-arquitectura-final)
|
|
6. [Tests end-to-end](#6-tests-end-to-end)
|
|
7. [Cómo añadir un nuevo sensor GPIO-timed](#7-cómo-añadir-un-nuevo-sensor)
|
|
8. [Referencia rápida de constantes y tiempos](#8-referencia-rápida)
|
|
|
|
---
|
|
|
|
## 1. Contexto
|
|
|
|
La simulación ESP32 de Velxio corre sobre el [fork lcgamboa de QEMU](https://github.com/lcgamboa/qemu).
|
|
QEMU expone una serie de hooks C llamados **picsimlab hooks** que el backend Python usa para:
|
|
|
|
- Detectar cambios de estado en pines GPIO → `_on_pin_change(slot, value)`
|
|
- Detectar cambios de dirección (INPUT/OUTPUT) → `_on_dir_change(slot, direction)`
|
|
- Inyectar niveles en pines desde Python → `lib.qemu_picsimlab_set_pin(slot, value)`
|
|
|
|
### Pinmap (identity map)
|
|
|
|
```
|
|
slot = gpio_num + 1
|
|
```
|
|
|
|
Ejemplo: GPIO18 (TRIG) = slot 19, GPIO19 (ECHO) = slot 20, GPIO4 (DHT22 DATA) = slot 5.
|
|
|
|
### Tiempo virtual vs tiempo real
|
|
|
|
Un hallazgo crítico de esta investigación:
|
|
|
|
> **El tiempo virtual de QEMU corre aproximadamente 1:1 con el tiempo de pared (wall-clock).**
|
|
|
|
Confirmado empíricamente: `pulseIn(ECHO_PIN, HIGH, 30000UL)` (timeout de 30 000 µs virtuales)
|
|
expira exactamente a los **30 ms de pared**. Esto significa que "esperar N µs virtuales"
|
|
equivale a esperar N µs reales.
|
|
|
|
Sin embargo, **las instrucciones que leen registros de hardware** son órdenes de magnitud
|
|
más lentas de lo esperado:
|
|
|
|
| Operación | Tiempo virtual esperado | Tiempo real en QEMU |
|
|
|-----------|------------------------|---------------------|
|
|
| `delayMicroseconds(10)` | 10 µs | **~3 ms** |
|
|
| Una lectura de `esp_timer_get_time()` | ~1 µs | **~0.3 ms** |
|
|
| Una llamada a `digitalRead()` / `gpio_get_level()` | ~1 µs | **~0.14 ms** |
|
|
|
|
Esto se debe a que cada acceso a un registro de hardware QEMU genera un I/O trap que
|
|
el host Python debe procesar.
|
|
|
|
---
|
|
|
|
## 2. El callback `_on_dir_change(-1, -1)`
|
|
|
|
Este callback es la **pieza más importante** de toda la arquitectura de sensores GPIO.
|
|
|
|
### Cuándo se dispara
|
|
|
|
```
|
|
_on_dir_change(slot=-1, direction=-1)
|
|
```
|
|
|
|
Se llama **cada vez que el firmware hace una lectura de `GPIO_IN_REG`** — el registro
|
|
del que `gpio_get_level()` lee el estado de los pines. Esto incluye:
|
|
|
|
- `digitalRead(pin)` — Arduino API
|
|
- `gpio_get_level(pin)` — ESP-IDF API (usado internamente por `pulseIn()`)
|
|
|
|
### Por qué es útil
|
|
|
|
Cuando este callback se dispara, la CPU QEMU está **bloqueada en ese trap** — no puede
|
|
continuar ejecutando firmware hasta que el callback Python retorne. Esto nos da una
|
|
ventana para cambiar el estado de un pin **antes de que la CPU lea su valor**.
|
|
|
|
Es decir: si en `_on_dir_change(-1, -1)` llamamos a `qemu_picsimlab_set_pin(slot, 1)`,
|
|
la próxima instrucción de firmware que lea ese pin **verá el valor 1**. Es completamente
|
|
síncrono, sin carreras de datos.
|
|
|
|
### El dispatcher genérico
|
|
|
|
```python
|
|
# en _on_dir_change:
|
|
if slot == -1 and direction == -1:
|
|
if _sync_handlers:
|
|
_sync_handlers[:] = [h for h in _sync_handlers if not h.step()]
|
|
return
|
|
```
|
|
|
|
La lista `_sync_handlers` contiene instancias de clases con método `step() -> bool`.
|
|
Cada vez que el firmware hace un `digitalRead()` o `gpio_get_level()`, se llama `step()`
|
|
en todos los handlers activos. Cuando `step()` devuelve `True`, el handler se elimina.
|
|
|
|
Esta arquitectura fue diseñada para **DHT22** y extendida a **HC-SR04** después de
|
|
descartar varios enfoques alternativos.
|
|
|
|
---
|
|
|
|
## 3. DHT22
|
|
|
|
### 3.1 El problema original
|
|
|
|
El sensor DHT22 usa un protocolo one-wire personalizado:
|
|
1. El firmware pone el pin en LOW durante ~20 ms (señal de inicio)
|
|
2. Suelta el pin (INPUT / pull-up → HIGH)
|
|
3. El sensor responde: ~80 µs LOW, ~80 µs HIGH, luego 40 bits de datos
|
|
4. Cada bit: ~50 µs LOW + (26 µs HIGH = 0, 70 µs HIGH = 1)
|
|
5. El firmware usa `expectPulse()` (Adafruit DHT) que llama a `digitalRead()` en un bucle
|
|
y cuenta iteraciones LOW y HIGH para decodificar bits
|
|
|
|
Sin respuesta del sensor, el firmware imprimía `"DHT22: waiting for sensor..."` o
|
|
`"Failed to read from DHT sensor!"` indefinidamente.
|
|
|
|
### 3.2 Por qué los enfoques basados en tiempo real fallan
|
|
|
|
**Intento 1: Background thread con `time.sleep()`**
|
|
|
|
```python
|
|
# FALLIDO
|
|
def drive_dht22():
|
|
time.sleep(0.00008) # 80 µs
|
|
set_pin(slot, 0)
|
|
time.sleep(0.00008) # 80 µs
|
|
set_pin(slot, 1)
|
|
# ...
|
|
```
|
|
|
|
**Fallo**: En Windows, `time.sleep()` tiene una resolución mínima de ~15.6 ms
|
|
(resolución del timer del OS). Los tiempos DHT22 son de decenas de µs — imposible
|
|
con `time.sleep()`.
|
|
|
|
**Intento 2: Busy-wait con `perf_counter_ns`**
|
|
|
|
```python
|
|
# FALLIDO
|
|
end = time.perf_counter_ns() + 80_000 # 80 µs
|
|
while time.perf_counter_ns() < end:
|
|
pass
|
|
set_pin(slot, 0)
|
|
```
|
|
|
|
**Fallo**: El firmware Adafruit DHT usa `expectPulse()` que llama a `digitalRead()` en
|
|
un bucle contando iteraciones. Si el pin cambia en tiempo de pared "correcto" pero no
|
|
sincronizado con el bucle del firmware, el conteo de iteraciones no tiene sentido para
|
|
decodificar bits. El decodificador compara `highCycles` vs `lowCycles` — necesita que
|
|
los cambios de pin ocurran **en sincronía con las iteraciones del bucle firmware**.
|
|
|
|
### 3.3 La solución correcta: sync handler basado en conteo de syncs
|
|
|
|
La idea clave: **cada llamada a `digitalRead()` en el bucle de `expectPulse()` dispara
|
|
`_on_dir_change(-1, -1)`**. En lugar de usar tiempo real, contamos estas llamadas
|
|
(syncs) y cambiamos el pin cada N syncs.
|
|
|
|
La biblioteca Adafruit DHT decodifica bits comparando `highCycles` vs `lowCycles`.
|
|
Los valores absolutos en µs no importan — **solo importan los ratios**. Así que podemos
|
|
usar los valores de µs del protocolo DHT22 directamente como conteos de syncs:
|
|
|
|
```python
|
|
def _dht22_build_sync_phases(payload: list[int]) -> list[tuple[int, int]]:
|
|
phases = []
|
|
phases.append((1, 0)) # respuesta inicial LOW
|
|
phases.append((80, 1)) # respuesta inicial HIGH (~80 µs → 80 syncs)
|
|
for byte_val in payload:
|
|
for b in range(7, -1, -1):
|
|
bit = (byte_val >> b) & 1
|
|
phases.append((50, 1)) # LOW → drive HIGH
|
|
phases.append((70 if bit else 26, 0)) # HIGH → drive LOW
|
|
return phases
|
|
```
|
|
|
|
Resultado: el bit `1` tiene ratio `HIGH/LOW = 70/50 = 1.4`, el bit `0` tiene `26/50 = 0.52`.
|
|
La biblioteca Adafruit DHT decodifica correctamente porque el ratio es el correcto.
|
|
|
|
### 3.4 Cuándo se arma el handler
|
|
|
|
```
|
|
Firmware: Backend:
|
|
pinMode(4, OUTPUT) _on_dir_change(slot=5, direction=1)
|
|
digitalWrite(4, LOW) _on_pin_change(slot=5, value=0) → saw_low=True
|
|
delay(20ms)
|
|
pinMode(4, INPUT) _on_dir_change(slot=5, direction=0)
|
|
→ saw_low=True && !responding
|
|
→ build payload + phases
|
|
→ set_pin(slot=5, 0) ← primer LOW inmediato
|
|
→ append DHT22SyncHandler(...)
|
|
digitalRead(4) ← 0 _on_dir_change(-1, -1) → handler.step()
|
|
digitalRead(4) ← 0 _on_dir_change(-1, -1) → handler.step()
|
|
... (handler va cambiando el pin según las fases)
|
|
```
|
|
|
|
### 3.5 Resultado
|
|
|
|
✅ **DHT22 funciona al 100%** desde esta implementación. El serial monitor muestra:
|
|
```
|
|
Temp: 28.0 C Humidity: 65.0 %
|
|
```
|
|
Y al enviar `esp32_sensor_update` con nuevos valores, los siguientes ciclos reflejan
|
|
los valores actualizados.
|
|
|
|
---
|
|
|
|
## 4. HC-SR04
|
|
|
|
Esta es la parte compleja. El sensor HC-SR04 tardó muchos intentos fallidos antes de
|
|
encontrar la solución correcta. Se documenta cada intento con exactitud.
|
|
|
|
### 4.1 El protocolo HC-SR04
|
|
|
|
```
|
|
Firmware: Sensor físico:
|
|
digitalWrite(TRIG, HIGH)
|
|
delayMicroseconds(10) ← sensor detecta pulso TRIG
|
|
digitalWrite(TRIG, LOW)
|
|
pulseIn(ECHO, HIGH, 30000) ← espera que ECHO suba
|
|
sensor: ECHO HIGH durante (distance*58) µs
|
|
sensor: ECHO LOW
|
|
← devuelve duración en µs
|
|
cm = duration * 0.0343 / 2
|
|
```
|
|
|
|
Si `pulseIn()` no detecta el pulso ECHO dentro del timeout (30 000 µs), devuelve 0
|
|
y el firmware imprime `"Out of range"`.
|
|
|
|
### 4.2 La función `pulseIn()` en ESP-IDF/QEMU
|
|
|
|
`pulseIn(pin, HIGH, timeout)` en ESP32 tiene 3 fases internas:
|
|
|
|
```
|
|
Fase 1: while (gpio_get_level(pin) == HIGH): // espera a que NO sea HIGH
|
|
if (timeout_exceeded) return 0; // (no aplica si ECHO ya es LOW)
|
|
|
|
Fase 2: while (gpio_get_level(pin) != HIGH): // espera a que sea HIGH
|
|
if (timeout_exceeded) return 0;
|
|
|
|
Fase 3: startMicros = esp_timer_get_time();
|
|
while (gpio_get_level(pin) == HIGH): // mide duración HIGH
|
|
if (timeout_exceeded) return 0;
|
|
return esp_timer_get_time() - startMicros;
|
|
```
|
|
|
|
**Hallazgo crítico sobre los tiempos en QEMU:**
|
|
|
|
- `delayMicroseconds(10)` en el firmware tarda **~3 ms** de pared (10 lecturas de
|
|
`esp_timer_get_time()`, cada una ~0.3 ms)
|
|
- El timeout de `pulseIn(ECHO, HIGH, 30000)` expira exactamente a los **30 ms** de pared
|
|
- Cada iteración de las fases 1/2/3 tarda **~0.14 ms** de pared (una lectura `gpio_get_level`)
|
|
- Los 30 000 µs de timeout = ~214 iteraciones de `gpio_get_level()`
|
|
|
|
### 4.3 Enfoque 0 — HCSR04SyncHandler con conteo de steps (igual que DHT22)
|
|
|
|
**Primer intento**: reutilizar exactamente el mismo patrón que DHT22.
|
|
|
|
```python
|
|
class HCSR04SyncHandler:
|
|
_US_PER_STEP = 300 # µs virtuales estimados por gpio_get_level()
|
|
|
|
def step(self):
|
|
if self._state == 'armed':
|
|
self._total_steps += 1
|
|
if self._total_steps > self._SKIP_COUNT:
|
|
set_pin(echo_slot, 1) # ECHO HIGH
|
|
self._state = 'high'
|
|
elif self._state == 'high':
|
|
self._high_count += 1
|
|
if self._high_count >= self._target_steps: # target = echo_us / 300
|
|
set_pin(echo_slot, 0) # ECHO LOW
|
|
return True
|
|
```
|
|
|
|
**Problema**: Los steps disparan mucho más rápido que 300 µs por step.
|
|
Para 40 cm → echo_us=2320 µs → target_steps=7. Esos 7 steps se completaban en <1 ms
|
|
de pared. La duración real del pulso ECHO era ~0.7 ms, pero `pulseIn()` necesitaba
|
|
medir 2.32 ms. El firmware recibía `"Out of range"` al 100%.
|
|
|
|
**Diagnóstico confirmado**: El log mostraba `echo_high` y `echo_low` separados por
|
|
<1 ms en el timeline de JavaScript, mientras el serial imprimía "Out of range" 30 ms
|
|
después.
|
|
|
|
---
|
|
|
|
### 4.4 Enfoque 1 — Background thread en TRIG HIGH (¡primer éxito parcial!)
|
|
|
|
Abandonando el sync handler, se probó un background thread Python lanzado cuando
|
|
el firmware hace TRIG HIGH:
|
|
|
|
```python
|
|
elif stype == 'hc-sr04':
|
|
if value == 1 and not sensor.get('responding', False):
|
|
threading.Thread(target=_hcsr04_drive_echo, ...).start()
|
|
```
|
|
|
|
Con `time.sleep(0.001)` en el thread (esperar 1 ms antes de ECHO HIGH):
|
|
|
|
```python
|
|
def _hcsr04_drive_echo(trig_gpio, echo_slot, echo_us):
|
|
time.sleep(0.001) # 1 ms nominal, ~15.6 ms real en Windows
|
|
set_pin(echo_slot, 1)
|
|
# busy-wait echo_us µs
|
|
end = perf_counter_ns() + echo_us * 1000
|
|
while perf_counter_ns() < end:
|
|
pass
|
|
set_pin(echo_slot, 0)
|
|
```
|
|
|
|
**Resultado**: **6/7 lecturas correctas** en el primer test. ¡Funcionó la mayoría de veces!
|
|
|
|
**Por qué funcionaba**: `time.sleep(0.001)` en Windows duerme **~15.6 ms reales**
|
|
(resolución del timer OS). Esto colocaba ECHO HIGH ~12.6 ms después de que el firmware
|
|
hacía TRIG LOW. En ese momento `pulseIn()` llevaba ~12 ms en la fase 2 y el ECHO HIGH
|
|
era detectado correctamente.
|
|
|
|
**Por qué no era fiable**: El 15.6 ms de Windows sleep tiene varianza de ±2-3 ms
|
|
dependiendo del scheduler. Además, al lanzar el thread desde TRIG HIGH, el `delayMicroseconds(10)` del firmware (que tarda ~3 ms) ocurría DESPUÉS del thread start,
|
|
lo que significaba que a veces el ECHO HIGH llegaba antes de TRIG LOW.
|
|
|
|
**Falla**: No era determinístico y dependía de los detalles del scheduler de Windows.
|
|
|
|
---
|
|
|
|
### 4.5 Enfoque 2 — Background thread en TRIG LOW, 200 µs busy-wait
|
|
|
|
Para evitar que ECHO llegara antes de TRIG LOW, se movió el trigger al momento de TRIG LOW
|
|
y se redujo el delay a 200 µs con busy-wait:
|
|
|
|
```python
|
|
elif value == 0 and sensor.get('_trig_armed'):
|
|
# TRIG LOW: pulseIn() está a punto de empezar
|
|
threading.Thread(target=_hcsr04_drive_echo, ...).start()
|
|
|
|
def _hcsr04_drive_echo(...):
|
|
# Busy-wait 200 µs
|
|
end = perf_counter_ns() + 200_000 # 200 µs
|
|
while perf_counter_ns() < end:
|
|
pass
|
|
set_pin(echo_slot, 1) # ECHO HIGH
|
|
```
|
|
|
|
**Resultado**: **100% "Out of range"** — peor que el enfoque anterior.
|
|
|
|
**Por qué falló**: `delayMicroseconds(10)` en el firmware tarda ~3 ms de pared.
|
|
La secuencia temporal era:
|
|
|
|
```
|
|
T+0 ms: firmware: digitalWrite(TRIG, HIGH)
|
|
T+0 ms: → _on_pin_change: TRIG HIGH, thread armado
|
|
T+3 ms: firmware: delayMicroseconds(10) termina
|
|
T+3 ms: firmware: digitalWrite(TRIG, LOW)
|
|
T+3 ms: → _on_pin_change: TRIG LOW, thread lanzado
|
|
T+3 ms: thread start + 200 µs busy-wait
|
|
T+3.2 ms: set_pin(echo_slot, 1) ← ECHO HIGH ya en T+3.2 ms
|
|
T+3.2 ms: firmware: pulseIn() todavía inicializándose...
|
|
```
|
|
|
|
El problema: `pulseIn()` empieza **después** de TRIG LOW, pero en QEMU cada instrucción
|
|
del setup de `pulseIn()` tarda ~0.3 ms. Con 200 µs de busy-wait, ECHO HIGH llegaba
|
|
cuando `pulseIn()` aún no había llegado a la fase 2. La fase 1 de `pulseIn()` (espera
|
|
a que ECHO NO sea HIGH) detectaba ECHO=1 y entraba en un bucle esperando que bajara,
|
|
consumiendo el pulso completo antes de que la fase 2 pudiera medirlo.
|
|
|
|
---
|
|
|
|
### 4.6 Enfoque 3 — QEMU thread en TRIG LOW, 0 ms delay
|
|
|
|
Para eliminar la latencia de thread start, se movió toda la lógica al propio callback
|
|
`_on_pin_change` cuando detecta TRIG LOW:
|
|
|
|
```python
|
|
elif value == 0:
|
|
# Directo desde el QEMU thread: ECHO HIGH inmediatamente
|
|
set_pin(echo_slot, 1)
|
|
# Busy-wait echo_us µs
|
|
...
|
|
set_pin(echo_slot, 0)
|
|
```
|
|
|
|
**Resultado**: **100% "Out of range"**.
|
|
|
|
**Por qué falló**: Exactamente el mismo problema que el enfoque anterior pero peor.
|
|
ECHO HIGH se ponía **en el mismo instante** que TRIG LOW, absolutamente antes de que
|
|
`pulseIn()` empezara. La fase 1 consumía el pulso completo.
|
|
|
|
**Diagrama del fallo**:
|
|
```
|
|
TRIG LOW → _on_pin_change → set_pin(ECHO, 1) inmediatamente
|
|
↓
|
|
pulseIn() inicia:
|
|
Fase 1: while(gpio_get_level() == HIGH) ← ECHO ya es HIGH, entra aquí
|
|
... espera 2.3 ms a que ECHO baje
|
|
Fase 2: while(gpio_get_level() != HIGH) ← ECHO ya bajó, espera HIGH
|
|
... timeout 30 ms → return 0
|
|
```
|
|
|
|
---
|
|
|
|
### 4.7 Enfoque 4 — Background thread en TRIG LOW, 3 ms busy-wait
|
|
|
|
Hipótesis: si `delayMicroseconds(10)` tarda ~3 ms y hay ~1-2 ms adicionales de setup
|
|
de `pulseIn()`, necesitamos esperar ~4-5 ms después de TRIG LOW para que `pulseIn()`
|
|
llegue a la fase 2.
|
|
|
|
```python
|
|
def _hcsr04_drive_echo(...):
|
|
end = perf_counter_ns() + 3_000_000 # 3 ms busy-wait
|
|
while perf_counter_ns() < end:
|
|
pass
|
|
set_pin(echo_slot, 1)
|
|
```
|
|
|
|
**Resultado**: ECHO HIGH llegaba ~4 ms después de TRIG LOW (3 ms busy-wait + ~1 ms
|
|
thread start). **Aún 100% "Out of range"**.
|
|
|
|
**Diagnóstico con el test JS**:
|
|
```
|
|
GPIO18 (TRIG) → LOW @ +54069ms
|
|
echo_high @ +54073ms ← 4ms después
|
|
echo_low @ +54075ms ← 2ms duración (correcto para 40cm)
|
|
UART: Out of range @ +54104ms ← 35ms después de TRIG
|
|
```
|
|
|
|
El ECHO HIGH llegaba en T+4 ms, dentro de la ventana de 30 ms. Pero `pulseIn()` aún
|
|
devolvía 0. ¿Por qué?
|
|
|
|
**Hipótesis**: Cross-thread pin propagation latency. `qemu_picsimlab_set_pin()` llamado
|
|
desde un thread Python no-QEMU podría tener latencia de visibilidad antes de que la CPU
|
|
QEMU leyera el valor. El background thread no está sincronizado con el loop principal
|
|
de QEMU.
|
|
|
|
---
|
|
|
|
### 4.8 Enfoque 5 — Background thread en TRIG LOW, 10 ms busy-wait
|
|
|
|
Basándose en que el enfoque exitoso anterior (enfoque 1) funcionaba con ~12.6 ms de
|
|
delay después de TRIG LOW, se aumentó a 10 ms:
|
|
|
|
```python
|
|
_after_trig_low = time.perf_counter_ns() + 10_000_000 # 10 ms
|
|
while time.perf_counter_ns() < _after_trig_low:
|
|
pass
|
|
set_pin(echo_slot, 1)
|
|
```
|
|
|
|
**Resultado**: **~33% de éxito** (aprox. 1/3 de las lecturas eran correctas, 2/3 "Out of range").
|
|
|
|
**Por qué era inconsistente**: El problema fundamental del cross-thread visibility
|
|
seguía existiendo. A veces el scheduler de Windows corría el thread Python justo en
|
|
el momento correcto (cuando la CPU QEMU estaba leyendo el GPIO), otras veces no.
|
|
El 33% de éxito era básicamente ruido estadístico del scheduler del OS.
|
|
|
|
**Conclusión clave**: **Cualquier enfoque basado en background threads es fundamentalmente
|
|
no determinístico** en este contexto. `qemu_picsimlab_set_pin()` no tiene garantías de
|
|
visibilidad inmediata cuando se llama desde threads no-QEMU.
|
|
|
|
---
|
|
|
|
### 4.9 ¿`_on_dir_change(-1, -1)` se dispara para `pulseIn()`?
|
|
|
|
Antes de la solución final, había incertidumbre sobre si `_on_dir_change(-1,-1)` se
|
|
dispara para las lecturas `gpio_get_level()` dentro de `pulseIn()`.
|
|
|
|
**Evidencia empírica que confirmó que SÍ se dispara**:
|
|
|
|
Al reimplementar `HCSR04SyncHandler` con `_SKIP_COUNT=2`, el test JS mostraba:
|
|
```
|
|
GPIO18 (TRIG) → LOW @ +46824ms
|
|
echo_high @ +46824ms ← mismo ms → se dispara inmediatamente
|
|
echo_low @ +46824ms ← mismo ms → duración casi cero
|
|
UART: Out of range
|
|
```
|
|
|
|
El handler se disparaba, pero el pulso duraba <1 ms. Esto confirmaba que
|
|
`_on_dir_change(-1,-1)` SÍ se dispara para `pulseIn()`.
|
|
|
|
El nuevo problema: con `_MAX_GUARD = 300 steps` y steps disparando a ~0.14 ms/step,
|
|
300 steps = ~42 ms > 30 ms timeout. Parecía suficiente, pero el error era más sutil:
|
|
la fase 'high' se medía con `self._high_count >= self._target_steps` donde
|
|
`target_steps = echo_us // 300`. Para 40 cm: `2320 // 300 = 7 steps`. Esos 7 steps
|
|
terminaban en <1 ms, mucho menos que los 2.32 ms reales necesarios.
|
|
|
|
---
|
|
|
|
### 4.10 La solución correcta — `HCSR04SyncHandler` con guards por tiempo de pared
|
|
|
|
**Insight final**: Para el comportamiento HIGH, no debemos contar steps — debemos medir
|
|
tiempo de pared, igual que haría el firmware midiendo tiempo virtual. Dado que
|
|
virtual ≈ wall-clock (confirmado), esperar `echo_us` µs de pared es equivalente a
|
|
esperar `echo_us` µs virtuales.
|
|
|
|
```python
|
|
class HCSR04SyncHandler:
|
|
_SKIP_COUNT = 2 # callbacks pre-fase2 a ignorar
|
|
_ARMED_TIMEOUT_US = 40_000 # 40 ms: guard si nunca entramos en 'high'
|
|
_HIGH_TIMEOUT_US = 32_000 # 32 ms: guard si ECHO > timeout de pulseIn()
|
|
|
|
def step(self) -> bool:
|
|
self._total_steps += 1
|
|
|
|
if self._state == 'armed':
|
|
if self._total_steps <= self._SKIP_COUNT:
|
|
return False # skip fase-1 + micros() pre-read
|
|
|
|
arm_us = (perf_counter_ns() - self._arm_start_ns) // 1000
|
|
if arm_us > self._ARMED_TIMEOUT_US:
|
|
# Nunca llegamos a fase 2 → liberar
|
|
return True
|
|
|
|
# Fase 2 de pulseIn() activa → ECHO HIGH
|
|
set_pin(self._echo_slot, 1)
|
|
self._echo_start_ns = perf_counter_ns()
|
|
self._state = 'high'
|
|
return False
|
|
|
|
elif self._state == 'high':
|
|
elapsed_us = (perf_counter_ns() - self._echo_start_ns) // 1000
|
|
if elapsed_us >= self._echo_us:
|
|
return self._finish(elapsed_us) # ECHO LOW
|
|
if elapsed_us >= self._HIGH_TIMEOUT_US:
|
|
set_pin(self._echo_slot, 0) # safety: nunca bloquear más que pulseIn timeout
|
|
return True
|
|
|
|
return False
|
|
```
|
|
|
|
**Por qué funciona esta vez**:
|
|
|
|
1. **Skip de 2 callbacks**: La fase 1 de `pulseIn()` hace 1 llamada `gpio_get_level()`
|
|
(ECHO es LOW → sale inmediatamente). Puede haber 1 lectura adicional de `micros()`.
|
|
Saltamos esas 2 iteraciones para no poner ECHO HIGH demasiado pronto.
|
|
|
|
2. **ECHO HIGH en el 3er callback**: Ese es el primer `gpio_get_level()` de la fase 2.
|
|
`qemu_picsimlab_set_pin()` es **síncrono con el QEMU thread** (estamos EN el
|
|
QEMU thread, no en un thread externo). La CPU QEMU lee el valor inmediatamente.
|
|
`pulseIn()` ve ECHO=1 y transiciona a la fase 3.
|
|
|
|
3. **Duración medida en wall-clock**: La fase 3 llama `gpio_get_level()` repetidamente.
|
|
Cada llamada dispara `step()`. Simplemente esperamos `echo_us` µs de pared.
|
|
Como virtual ≈ wall-clock, `pulseIn()` mide exactamente `echo_us` µs virtuales.
|
|
|
|
4. **Guards por tiempo, no por steps**: Los guards usan `perf_counter_ns()`, no conteos
|
|
de steps. Esto funciona correctamente para cualquier distancia
|
|
(10 cm = 580 µs, 200 cm = 11 600 µs).
|
|
|
|
---
|
|
|
|
### 4.11 Resultados de la solución final
|
|
|
|
Test end-to-end con 4 distancias:
|
|
|
|
```
|
|
sent=40 cm → received=40 cm ✓ (delta=0)
|
|
sent=40 cm → received=39 cm ✓ (delta=1)
|
|
sent=40 cm → received=40 cm ✓ (delta=0)
|
|
sent=100 cm → received=100 cm ✓ (delta=1)
|
|
sent=100 cm → received=101 cm ✓ (delta=2)
|
|
sent=100 cm → received=100 cm ✓ (delta=1)
|
|
sent=10 cm → received=10 cm ✓ (delta=0)
|
|
sent=10 cm → received=11 cm ✓ (delta=1)
|
|
sent=10 cm → received=10 cm ✓ (delta=0)
|
|
sent=200 cm → received=200 cm ✓ (delta=1)
|
|
sent=200 cm → received=199 cm ✓ (delta=1)
|
|
sent=200 cm → received=200 cm ✓ (delta=1)
|
|
|
|
✓ PASS — 12/12 lecturas dentro de ±15 cm, 4 distancias, miss rate 0%
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Arquitectura final
|
|
|
|
### 5.1 Registro de handlers
|
|
|
|
```python
|
|
_sync_handlers: list = []
|
|
```
|
|
|
|
Lista mutable compartida. Todas las mutaciones ocurren en el QEMU thread
|
|
(dentro de `_on_dir_change`). No se necesitan locks.
|
|
|
|
### 5.2 Dispatcher
|
|
|
|
```python
|
|
# En _on_dir_change(slot=-1, direction=-1):
|
|
if _sync_handlers:
|
|
_sync_handlers[:] = [h for h in _sync_handlers if not h.step()]
|
|
return
|
|
```
|
|
|
|
La asignación in-place `[:]` muta el mismo objeto lista (seguro para appends
|
|
concurrentes desde código de armado). La list comprehension filtra handlers terminados.
|
|
|
|
### 5.3 DHT22SyncHandler
|
|
|
|
Maneja la señal one-wire del DHT22 contando syncs por fase:
|
|
|
|
- `step()` incrementa un contador
|
|
- Cuando el contador alcanza el target de la fase actual, cambia el pin y avanza a la siguiente fase
|
|
- Los ratios de syncs preservan correctamente la codificación de bits Adafruit DHT
|
|
|
|
**Armado**: en `_on_dir_change` cuando el pin pasa a INPUT (firmware soltó el bus).
|
|
|
|
### 5.4 HCSR04SyncHandler
|
|
|
|
Maneja el pulso ECHO del HC-SR04 usando wall-clock para duración:
|
|
|
|
- `'armed'`: primeros `_SKIP_COUNT` steps ignorados, luego ECHO HIGH
|
|
- `'high'`: ECHO HIGH hasta que `elapsed_us >= echo_us`
|
|
- Guards: `_ARMED_TIMEOUT_US` y `_HIGH_TIMEOUT_US` en µs de pared
|
|
|
|
**Armado**: en `_on_pin_change` cuando TRIG baja (TRIG LOW).
|
|
|
|
### 5.5 Diagrama de flujo completo HC-SR04
|
|
|
|
```
|
|
Firmware Backend (_on_pin_change / _on_dir_change)
|
|
|
|
digitalWrite(TRIG, HIGH) → TRIG HIGH: guarda echo_slot, echo_us en sensor dict
|
|
delayMicroseconds(10) (3ms wall-clock)
|
|
digitalWrite(TRIG, LOW) → TRIG LOW: append HCSR04SyncHandler → _sync_handlers
|
|
sensor['responding'] = True
|
|
|
|
pulseIn(ECHO, HIGH, 30000):
|
|
Fase 1:
|
|
gpio_get_level(ECHO) → _on_dir_change(-1,-1) → handler.step()
|
|
step 1: total_steps=1 ≤ SKIP_COUNT=2 → skip
|
|
(ECHO=0, sale)
|
|
|
|
Fase 2:
|
|
gpio_get_level(ECHO) → _on_dir_change(-1,-1) → handler.step()
|
|
step 2: total_steps=2 ≤ SKIP_COUNT=2 → skip
|
|
gpio_get_level(ECHO) → _on_dir_change(-1,-1) → handler.step()
|
|
step 3: total_steps=3 > SKIP_COUNT
|
|
→ qemu_picsimlab_set_pin(echo_slot, 1) ← ECHO HIGH
|
|
→ state='high', echo_start_ns=now
|
|
(ECHO=1, sale)
|
|
|
|
Fase 3 (mide duración HIGH):
|
|
gpio_get_level(ECHO) → _on_dir_change(-1,-1) → handler.step()
|
|
elapsed_us < echo_us → continuar
|
|
gpio_get_level(ECHO) → ... (repite)
|
|
...
|
|
gpio_get_level(ECHO) → elapsed_us >= echo_us
|
|
→ qemu_picsimlab_set_pin(echo_slot, 0) ← ECHO LOW
|
|
→ sensor['responding']=False
|
|
→ step() returns True → handler eliminado
|
|
(ECHO=0, sale)
|
|
return (now - startMicros) ← duración medida correctamente
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Tests end-to-end
|
|
|
|
### 6.1 DHT22 — `backend/test_dht22_simulation.mjs`
|
|
|
|
```bash
|
|
cd backend
|
|
node test_dht22_simulation.mjs [--timeout=45] [--backend=http://localhost:8001]
|
|
```
|
|
|
|
**Qué verifica**:
|
|
1. Compila el sketch DHT22 vía `POST /api/compile/`
|
|
2. Conecta WebSocket y envía `start_esp32` con `sensors: [{sensor_type:'dht22', pin:4, temperature:28, humidity:65}]`
|
|
3. Espera líneas `"Temp: 28.0 C Humidity: 65.0 %"` en serial
|
|
4. Envía `esp32_sensor_update` con `{pin:4, temperature:35, humidity:80}`
|
|
5. Verifica que las siguientes lecturas muestren 35°C
|
|
|
|
**Fix importante en el test**: Serial output llega fragmentado (chunked). La primera
|
|
implementación del test usaba `text.split('\n')` sobre cada mensaje WebSocket, lo que
|
|
nunca encontraba líneas completas. La solución fue acumular en un buffer:
|
|
|
|
```javascript
|
|
let _lineBuf = '';
|
|
// En handler de serial_output:
|
|
_lineBuf += data?.data ?? '';
|
|
let nl;
|
|
while ((nl = _lineBuf.indexOf('\n')) !== -1) {
|
|
const line = _lineBuf.slice(0, nl).replace(/\r$/, '');
|
|
_lineBuf = _lineBuf.slice(nl + 1);
|
|
// procesar línea completa...
|
|
}
|
|
```
|
|
|
|
### 6.2 HC-SR04 — `backend/test_hcsr04_simulation.mjs`
|
|
|
|
```bash
|
|
cd backend
|
|
node test_hcsr04_simulation.mjs [--timeout=60] [--backend=http://localhost:8001]
|
|
```
|
|
|
|
**Qué verifica**:
|
|
1. Compila el sketch HC-SR04 vía `POST /api/compile/`
|
|
2. Conecta WebSocket y envía `start_esp32` con `sensors: [{sensor_type:'hc-sr04', pin:18, echo_pin:19, distance:40}]`
|
|
3. Espera lecturas `"Distance: N cm"` en serial
|
|
4. Cicla por 4 distancias: 40 cm, 100 cm, 10 cm, 200 cm
|
|
5. Envía `esp32_sensor_update` con `{pin:18, distance:X}` para cada una
|
|
6. Verifica que los valores sean correctos (±15 cm de tolerancia)
|
|
|
|
**Criterio de PASS**: ≥3 lecturas correctas, ≥2 distancias únicas, miss rate ≤30%.
|
|
|
|
**Fix en el test (bug del timer)**: La primera versión usaba `if (readingsAtCurrent >= 2) scheduleAdvance(800)`.
|
|
Como las lecturas llegan cada 500 ms, `scheduleAdvance` se llamaba en cada lectura después
|
|
de la 2ª, reiniciando el timer 800 ms continuamente → el avance nunca ocurría.
|
|
Fix: `if (readingsAtCurrent === 2) scheduleAdvance(800)` (solo en exactamente la 2ª lectura).
|
|
|
|
---
|
|
|
|
## 7. Cómo añadir un nuevo sensor GPIO-timed
|
|
|
|
Un sensor "GPIO-timed" es cualquier sensor cuya comunicación consiste en cambios de pin
|
|
que el firmware detecta con `digitalRead()`, `pulseIn()`, o similar.
|
|
|
|
### Pasos
|
|
|
|
**1. Crear la clase handler** (dentro de `main()` en `esp32_worker.py`):
|
|
|
|
```python
|
|
class MiSensorSyncHandler:
|
|
def __init__(self, gpio: int, slot: int, ...params...) -> None:
|
|
self._gpio = gpio
|
|
self._slot = slot
|
|
# ... inicializar estado
|
|
|
|
def step(self) -> bool:
|
|
"""
|
|
Llamado en cada gpio_get_level() del firmware.
|
|
Retorna True cuando el handler ha terminado (se elimina de _sync_handlers).
|
|
"""
|
|
# ... lógica de estado
|
|
# Usar lib.qemu_picsimlab_set_pin(self._slot, 0/1) para cambiar el pin
|
|
# Usar time.perf_counter_ns() para medir tiempo de pared
|
|
# Retornar True cuando terminado, False para continuar
|
|
return False
|
|
```
|
|
|
|
**2. Armar el handler** desde `_on_pin_change` o `_on_dir_change`:
|
|
|
|
```python
|
|
elif stype == 'mi-sensor':
|
|
if value == CONDICION_TRIGGER:
|
|
_sync_handlers.append(MiSensorSyncHandler(gpio, slot, ...params...))
|
|
sensor['responding'] = True
|
|
```
|
|
|
|
**3. No tocar `_on_dir_change`**: El dispatcher genérico ya maneja todos los handlers
|
|
automáticamente. No hay que cambiar nada más.
|
|
|
|
### Reglas importantes
|
|
|
|
- **Siempre usar `time.perf_counter_ns()`** para medir duración, no conteo de steps
|
|
(los steps tienen velocidad variable según carga de QEMU)
|
|
- **Siempre tener un guard de timeout** para evitar que el sensor quede bloqueado
|
|
si el firmware no hace más `digitalRead()`
|
|
- **`sensor['responding'] = False`** al terminar, para que el siguiente ciclo se procese
|
|
- **Toda la lógica de pin-driving es síncrona** con el QEMU thread — no necesita locks
|
|
|
|
---
|
|
|
|
## 8. Referencia rápida
|
|
|
|
### Tiempos empíricos en QEMU (ESP32, lcgamboa fork)
|
|
|
|
| Operación firmware | Tiempo real (wall-clock) |
|
|
|--------------------|--------------------------|
|
|
| `delayMicroseconds(10)` | ~3 ms |
|
|
| Un `digitalRead()` / `gpio_get_level()` | ~0.14 ms |
|
|
| Una lectura `esp_timer_get_time()` | ~0.3 ms |
|
|
| `pulseIn(pin, HIGH, 30000)` timeout | exactamente 30 ms |
|
|
| Virtual time : wall-clock ratio | ≈ 1:1 |
|
|
|
|
### Constantes de los handlers
|
|
|
|
| Handler | Constante | Valor | Significado |
|
|
|---------|-----------|-------|-------------|
|
|
| HCSR04SyncHandler | `_SKIP_COUNT` | 2 | Callbacks iniciales a ignorar |
|
|
| HCSR04SyncHandler | `_ARMED_TIMEOUT_US` | 40 000 µs | Guard: máx espera en estado 'armed' |
|
|
| HCSR04SyncHandler | `_HIGH_TIMEOUT_US` | 32 000 µs | Guard: máx duración ECHO HIGH |
|
|
| DHT22SyncHandler | — | — | No usa timeouts, solo conteo de syncs |
|
|
|
|
### Fórmula echo_us
|
|
|
|
```python
|
|
echo_us = max(100, int(distance_cm * 58))
|
|
# Ejemplo: 40 cm → 2320 µs, 100 cm → 5800 µs, 200 cm → 11600 µs
|
|
```
|
|
|
|
### Pinmap (slot ↔ GPIO)
|
|
|
|
```python
|
|
slot = gpio_num + 1
|
|
# GPIO4 → slot 5 (DHT22 DATA)
|
|
# GPIO18 → slot 19 (HC-SR04 TRIG)
|
|
# GPIO19 → slot 20 (HC-SR04 ECHO)
|
|
```
|
|
|
|
### Resumen de enfoques HC-SR04 y su resultado
|
|
|
|
| Enfoque | Resultado | Razón del fallo |
|
|
|---------|-----------|-----------------|
|
|
| Sync handler + conteo de steps | 0% | Steps demasiado rápidos, pulso dura <1 ms |
|
|
| Background thread en TRIG HIGH + 1ms sleep | 6/7 (85%) | `time.sleep(0.001)` → 15.6 ms en Windows, no determinístico |
|
|
| QEMU thread en TRIG LOW, 0 ms | 0% | ECHO HIGH antes de que pulseIn() empiece fase 2 |
|
|
| Background thread en TRIG LOW + 200 µs busy | 0% | Igual: ECHO HIGH antes de fase 2 (delayMicroseconds dura 3ms) |
|
|
| Background thread en TRIG LOW + 3 ms busy | 0% | Cross-thread pin visibility, ECHO no visto por QEMU |
|
|
| Background thread en TRIG LOW + 10 ms busy | ~33% | Cross-thread pin visibility, no determinístico |
|
|
| **Sync handler + wall-clock en `_sync_handlers`** | **100%** | **Síncrono con QEMU thread, timing preciso** |
|