# 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** |