Firmware C++ (PlatformIO/Arduino) para nodos de mensajería off-grid sobre Seeed XIAO ESP32-S3 + Wio-SX1262 (kit Meshtastic). Red resiliente de difusión epidémica: cada nodo mantiene un pool de mensajes (propios y ajenos) que retransmite por LoRa (alcanza a los nodos lejanos) y por ESP-NOW broadcast Long Range (satura el grupo local sin gastar el ciclo de trabajo que exige la normativa europea), midiendo la difusión con ACKs para priorizar siempre el mensaje "menos visto". Los nodos duermen casi todo el tiempo y se despiertan entre sí por radio LoRa.
| Componente | Detalle |
|---|---|
| MCU | Seeed XIAO ESP32-S3 (USB-C, antena WiFi integrada) |
| Radio LoRa | Wio-SX1262 (Semtech SX1262), conector B2B "XIAO", antena externa |
| Alimentación | USB-C (o batería en el futuro) |
Mapeo de pines del SX1262 (por el conector B2B, sin soldaduras):
| Señal | GPIO | Señal | GPIO | |
|---|---|---|---|---|
| NSS (CS) | 41 | SCK | 7 | |
| DIO1 (IRQ) | 39 | MISO | 8 | |
| RST | 42 | MOSI | 9 | |
| BUSY | 40 | ANT_SW (RF switch) | 38 |
El TCXO va alimentado por DIO3 a 1,8 V y la dirección TX/RX la gestiona el propio SX1262 por DIO2.
Se usa la sub-banda g3: 869,40–869,65 MHz (la de mayor potencia permitida: 500 mW ERP, 10 % de ciclo de trabajo), pegados al borde inferior:
| Parámetro | Valor | Por qué |
|---|---|---|
| Frecuencia | 869,43125 MHz | centro mínimo que mantiene el canal de 62,5 kHz dentro de la sub-banda |
| Ancho de banda | 62,5 kHz | +3 dB de sensibilidad frente a 125 kHz (más alcance) |
| SF / CR | SF9 / 4-5 | equilibrio alcance ↔ tiempo en aire |
| Potencia TX | +22 dBm | máximo del SX1262, muy por debajo del límite legal |
| ToA datos (250 B) | ~2460 ms | |
| ToA beacon (6 B) | ~248 ms |
Cumplimiento de acceso al medio (en net/LoRaManager):
- Listen Before Talk (LBT): antes de cada emisión se mide la energía del canal durante 6 ms (umbral −90 dBm) sin interrumpir la recepción en curso.
- Ciclo de trabajo del 10 %: presupuesto de tiempo en aire tipo leaky bucket (se rellena al 10 % del tiempo transcurrido, cada emisión consume su ToA, tope de ráfaga de 6 s).
Objetivo: que todo mensaje alcance toda la red. Los nodos a alcance WiFi se sincronizan por ESP-NOW broadcast (gratis: sin duty cycle); los lejanos reciben por la inundación LoRa. Tres mecanismos coordinados, sin reloj compartido:
- Slot LoRa (cada 2 min, jitter ±10 %,
LORA_RETRY_PERIOD_MS): se retransmite el mensaje del pool con la difusión obligatoria pendiente (TODO mensaje sale al menos una vez por LoRa de cada nodo) o, si no queda ninguno, el carrusel del menos visto: menos ACKs → menos emisiones → emisión más antigua. Sin tope de reintentos: la rotación es indefinida (~2 % de duty) para que nodos reiniciados o recién llegados acaben recibiéndolo todo (LORA_CAROUSEL_COVERED_FACTORla espacia opcionalmente con la red local convergida). - Beacon de presencia (cada 15 min + jitter,
BEACON_PERIOD_MS, configurable): uptime, contadores y digest del pool (msgIds). Alimenta el pool de dispositivos cercanos y propaga ACKs. Forzable con el botón del hat oPOST /beacon. - Rendezvous implícito (
WINDOW_LINGER_MS, 3 s): tras CADA emisión LoRa propia, el emisor escucha ESP-NOW. Quien la oye (DIO1 despierta a todos) sabe que puede empujarle los mensajes que le falten y sus ACKs sin gastar un REQ. El REQ (6 B por LoRa) solo se usa para la inmediatez: al encolar un mensaje propio se abre una ventana al momento.
Nodo A (abre ventana: REQ, slot o beacon) Vecinos (despiertan por DIO1)
───────────────────────────────────────── ─────────────────────────────
trama por LoRa ────────────────────────────► viabilidad ESP-NOW por RSSI
enciende ESP-NOW y emite su digest (LinkEstimator, p >= 70 %)
┌─ viable ── se une: digest propio en su
recibe digests ◄────────────────┤ ranura (jitter por nodeId)
└─ no ────── RESP_LORA (solo presencia)
ráfaga de DATA que faltan a alguien ───────► entran al pool + ACK batch
◄──────────────── ráfagas/ACKs de los vecinos (intercambio bidireccional)
Anti-entropía y ACKs. Cada entrada del pool lleva embebido el set de nodos
que se sabe que tienen el mensaje. Los ACKs llegan por tres vías: trama ACK
por ESP-NOW (batch tras un silencio), digest en presencia/respuestas, y ACK
implícito (oír a X retransmitir M ⇒ X tiene M, gracias al campo relayId).
Reconciliación. Un pool puede vaciarse (flasheo, borrado de la NVS) y los ACKs registrados antes quedan rancios. Como los digests de presencia/RESP_WIFI llevan el pool COMPLETO, su ausencia es autoritativa: si un msgId con ACK de X ya no está en el digest de X, ese ACK se retira y el mensaje vuelve al carrusel y al push. El reinicio también se detecta por la regresión del uptime del presence (purga inmediata de sus ACKs). Y al oír un digest que anuncia mensajes que faltan, el nodo se une a la ventana del emisor para pedirlos (pull): su propio digest hace que el emisor le empuje lo que no tiene. ESP-NOW carga así con la resincronización del grupo local sin gastar duty cycle.
Persistencia. El pool se vuelca a la NVS como un blob versionado
(PoolStore) y se rehidrata al arrancar: el nodo reaparece con sus mensajes,
contadores y ACKs — incluidos los PROPIOS, que el anti-eco impedía recuperar de
la red. Política anti-desgaste de la flash (~100k borrados/sector): los cambios
de contenido (alta/expulsión) se vuelcan al instante; los de metadatos
(ACKs, contadores) esperan al checkpoint alineado con el beacon (15 min) — lo
perdido entre checkpoints lo repara la reconciliación. La tabla de particiones
propia (partitions_willow.csv) mapea los 8 MB reales del XIAO ESP32-S3: NVS
de 84 KB, app de 3 MB y ~4,8 MB de SPIFFS reservados. Al cambiar de la tabla
antigua, la primera grabación de cada nodo requiere un borrado completo
(pio run -t erase).
Control del caos en el broadcast (ESP-NOW no tiene ACK ni reintento MAC):
-
Jitter ranurado determinista: cada nodo habla en su ranura (
nodeId % 8 × 25 ms) al unirse a una ventana o emitir ACKs. -
Ventana con dueño: el que la abre emite primero; tope
MAX_FRAMES_PER_WINDOWy pacing entre tramas. -
Supresión de duplicados (estilo Trickle): oír el msgId M en la ventana lo retira de la propia cola de ráfaga.
-
Supresión de aperturas: con un REQ/ventana reciente no se abre otra (
WINDOW_MIN_INTERVAL_MS); anti-eco porhops(topeHOPS_MAX) y dedup. -
Opcional
LORA_SUPPRESS_K(apagado por defecto): dar por cumplida la difusión LoRa propia si ya se oyó el mensaje por LoRa de >= K nodos. -
ESP-NOW en modo Long Range (
WIFI_PROTOCOL_LR), potencia máxima, canal fijo, difusión sin emparejamiento. -
Botón del hat (GPIO21): fuerza el beacon de presencia (y su ventana). La pulsación también despierta del light sleep.
-
Emisión por la API web:
POST /messagemete el texto en el pool y dispara la emisión inmediata (ventana ESP-NOW + difusión LoRa, esperando el crédito ETSI con tope; si no llega, queda priorizada para el slot).
- Light sleep entre eventos: RAM retenida, USB-CDC suspendido, despertar por timer (próxima emisión), por DIO1 (LoRa entrante), por USB o por el botón del hat.
- El SX1262 no se duerme: queda en RX continuo (~5 mA) vigilando el canal.
- Wake por USB (
Proto::WAKE_ON_USB): si hay un PC al otro lado del cable (emite tramas SOF cada 1 ms), el nodo no duerme — espera despierto con la serie disponible. Además, durante el sueño la línea D− del USB (GPIO19) se arma como fuente de despertar: conectar el cable a un PC o abrir el puerto despierta al nodo. Un cargador o batería no genera tráfico USB y no impide el sueño. - La ISR del radio se suspende antes de dormir y se restaura al despertar
(hooks de
PowerManager): el despertar por GPIO requiere interrupción por nivel, y conviviendo con la ISR por flanco de RadioLib provoca una tormenta de interrupciones y pánico por watchdog (aprendido por las malas, dos veces). - Wake on LAN (
Proto::WAKE_ON_LAN, por defecto): mientras está activo sustituye al light sleep — el nodo espera entre emisiones asociado al AP doméstico con el modem WiFi en ahorro (WIFI_PS_MAX_MODEM: el radio duerme entre beacons DTIM y el tráfico entrante lo despierta), sirviendo la API web en todo momento. Es el equivalente práctico al WoL clásico: el ESP32-S3 no puede mantener la asociación WiFi durante el light sleep (el radio se apaga y el framework Arduino no traeCONFIG_PM_ENABLE), así que un paquete mágico nunca llegaría a un nodo dormido. Coste: la CPU no duerme — para nodos a batería, ponerWAKE_ON_LAN = falsey volver al light sleep.
Estimaciones a partir de los datasheets (ESP32-S3, SX1262, XIAO S3) aplicadas al comportamiento real del firmware; pendiente de validar con un medidor USB. Consumos del conjunto XIAO + hat a 3,7 V:
| Estado | Consumo aprox. | Cuándo |
|---|---|---|
| SX1262 en RX continuo (+ TCXO) | ~6 mA | siempre (es la base del wake-on-LoRa) |
| ESP32-S3 en light sleep | ~0,3–1 mA | entre eventos, si WAKE_ON_LAN = false |
| CPU despierta en reposo (80 MHz, modem en ahorro) | ~27 mA | esperas despierto (wake-on-LAN, host USB) |
| CPU despierta trabajando (240 MHz) | ~66 mA | handshakes, emisión, cálculos |
| WiFi asociado escuchando / ESP-NOW activo | ~95 mA | handshakes y API web atendiendo |
| TX LoRa +22 dBm | ~118 mA extra | beacon 0,25 s; datos 2,5 s (solo si cae a LoRa) |
Presupuesto típico por ciclo de 2 min (un slot LoRa con datos + rendezvous de 3 s, más el beacon cada 15 min): ~2,5 s de TX LoRa + ~3–6 s de WiFi activo.
| Configuración | Consumo medio | Autonomía (3500 mAh, ~90 % útil) |
|---|---|---|
WAKE_ON_LAN = true, CPU fija a 240 MHz (anterior) |
~75–85 mA | ~1,6–2 días |
Actual: WAKE_ON_LAN = true, reloj dinámico 80/240 |
~35–45 mA | ~3–4 días |
WAKE_ON_LAN = false (light sleep) |
~9–13 mA | ~10–14 días |
WAKE_ON_LAN = false + RX duty cycle del SX1262 |
~4–6 mA | ~20–30 días |
El reloj de CPU es dinámico (PowerManager::cpuIdle()/cpuActive()):
80 MHz en las esperas despierto —el mínimo compatible con WiFi, la API web
sigue respondiendo— y 240 MHz en cuanto hay trabajo real (handshakes, emisión
y los futuros cálculos sobre el pool de mensajes). El cambio cuesta
microsegundos y el bus APB se mantiene a 80 MHz, así que SPI/USB-CDC/WiFi no
se ven afectados. Durante el light sleep el reloj está parado y la frecuencia
no influye en el consumo dormido.
Lecturas clave: con wake-on-LAN el coste lo domina la CPU que nunca duerme
(~27 mA incluso en reposo a 80 MHz), no el WiFi (el modem sí duerme entre
beacons DTIM); en light sleep lo dominan el SX1262 en RX continuo (~6 mA
de suelo, ~145 mAh/día) y las ventanas de rendezvous (~3 s por slot de
2 min a ~95 mA ≈ 2,4 mA medios; WINDOW_LINGER_MS = 0 las elimina para
nodos a batería).
Por orden de impacto (el reloj dinámico 80/240 ya está implementado):
- Wake-on-LAN condicional a la alimentación: usar
usbHostActive()(ya existe enPowerManager) para mantener la API web siempre disponible cuando hay un PC/cargador detrás y caer a light sleep a batería. En la práctica no se pierde nada: la API se usa cuando hay alguien en la LAN. listen_intervalmayor (escuchar 1 de cada N beacons DTIM en modem sleep): más ahorro asociado al AP a cambio de unos ms de latencia en la API.- RX con ciclo de trabajo del SX1262 (
setRxDutyCycle, como Meshtastic): la radio duerme y solo abre ventanas de detección de preámbulo; baja el suelo de ~6 a ~1 mA. Requiere cuidado: el preámbulo actual (8 símbolos, ~66 ms) debe seguir siendo detectable — es el siguiente gran salto para nodos a batería.
Cada nodo sirve una API HTTP (puerto 80) en la red doméstica para enviarle mensajes desde la LAN. La IP se publica en los logs (puerto serie) en cada conexión al AP — buscar la línea:
[WIFI] conectado a "asus", IP local: 192.168.1.xx
Rutas:
# Enviar un mensaje (texto plano): entra al pool y se emite de inmediato
# (ventana ESP-NOW + difusión LoRa, respetando el ciclo de trabajo ETSI).
curl -X POST http://<ip-del-nodo>/message -d 'hola willow'
# -> {"queued":true} (503 si el pool está lleno y nada es expulsable)
# Forzar el beacon de presencia (se emite en el próximo ciclo del orquestador):
curl -X POST http://<ip-del-nodo>/beacon
# -> {"beacon":"scheduled"}
# Pool de dispositivos cercanos (alimentado por beacons y tramas oídas):
curl http://<ip-del-nodo>/devices
# -> {"devices":[{"id":"0x90C0","last_seen_ms":1234,"rssi":-62,"snr":9,
# "espnow_likely":true,"uptime_s":872,"pool_count":3},...],"count":1}
# Pool de mensajes con sus ACKs embebidos (qué nodos tienen cada mensaje):
curl http://<ip-del-nodo>/messages
# -> {"messages":[{"id":"0x1A2B3C4D","src":"0x8D4C","ts":45,"hops":0,
# "text":"hola willow","origin":"propio","age_ms":60000,"tx_lora":1,
# "tx_espnow":2,"acks":["0x90C0"]}],"count":1,"capacity":16}
# Logs del dispositivo: las últimas 100 líneas CLOG retenidas en RAM, en texto
# plano cronológico con su marca de tiempo. Útil sin PC al USB (con light
# sleep la serie se suspende, el buffer no). ?n= acota (def. 50, máx. 100):
curl 'http://<ip-del-nodo>/logs?n=20'
# -> [123.456] [SLOT] === retransmision LoRa id=0x1A2B3C4D (menos visto...) ===
# [125.901] [RDV] ventana propia: escucha ESP-NOW 3000 ms
# ...
# Estado del nodo (identidad + contadores del planificador):
curl http://<ip-del-nodo>/status
# -> {"id":"0x8D4C","ip":"192.168.1.xx","uptime_ms":123456,"pool":3,
# "pool_cap":16,"neighbors":2,"next_slot_in_ms":81234,
# "next_beacon_in_ms":523456,"espnow_rx_dropped":0}Disponibilidad: con Proto::WAKE_ON_LAN (por defecto) el nodo permanece
asociado al AP entre emisiones y la API responde siempre (salvo los segundos
de cada ventana ESP-NOW, que requieren soltar el AP; reintentar basta). Con
WAKE_ON_LAN = false el nodo duerme y la API solo responde en las ventanas
despierto.
src/ e include/
├── AppOrchestrator.* Composición y bucle de eventos (roles A/B). Sin lógica propia.
├── config/
│ ├── DeviceConfig.* Identidad (MAC) y parámetros runtime. Futuro: NVS + app móvil.
│ ├── Protocol.hpp Valores por defecto del protocolo (tiempos, reintentos).
│ └── Secrets.hpp Credenciales WiFi (gitignored; ver Secrets.example.hpp).
├── power/
│ └── PowerManager.* Light sleep, despertar por timer/GPIO/USB/botón, hooks de ISR.
├── input/
│ └── ButtonManager.* Botón de usuario del hat (GPIO21): beacon manual.
├── net/
│ ├── LoRaManager.* SX1262 (RadioLib), LBT, duty cycle, RX por interrupción.
│ ├── EspNowManager.* ESP-NOW broadcast Long Range, pause/resume.
│ ├── WifiManager.* Estación WiFi a la red local (convive con ESP-NOW).
│ ├── ApiServer.* API web (POST /message|/beacon, GET /devices|/messages|/logs|/status).
│ └── LinkEstimator.hpp RSSI LoRa -> probabilidad de éxito ESP-NOW.
├── msg/
│ ├── Packets.hpp Tipología de TODAS las tramas (cabecera común, digests).
│ ├── DataMessage.hpp Tipología: mensaje de datos (250 B, cabecera de 16 B).
│ ├── MessagePool.* Pool de mensajes con ACKs embebidos, selección y expulsión.
│ ├── PoolStore.* Persistencia del pool en NVS (volcado por niveles, rehidratación).
│ ├── NeighborPool.* Pool de dispositivos cercanos (señal, rendezvous, TTL).
│ ├── BeaconService.* Tramas de control: REQ/RESP, presencia y ACK batch.
│ └── MessageService.* Fachada TX/RX de datos sobre el pool. Futuro: crypto e2e.
└── log/
├── Log.hpp Log unificado (macro CLOG): serie + buffer circular.
└── LogBuffer.* Ring de las últimas 100 líneas CLOG (GET /logs).
Regla de dependencias: AppOrchestrator → msg → net → config, con power y
log como transversales. Los módulos no se conocen entre sí salvo hacia abajo.
pio run -t upload --upload-port /dev/ttyACM0 # flashear UN nodoAntes: copiar include/config/Secrets.example.hpp a Secrets.hpp y rellenar.
Con light sleep activo el USB-CDC se suspende y esptool falla (busy /
"No serial data received" / Errno 71). No es un problema de alimentación ni
hace falta quitar el hat LoRa. Con Proto::WAKE_ON_USB (por defecto) el nodo
conectado a un PC se despierta por la actividad del bus y se mantiene
despierto mientras detecte al host, así que basta abrir el puerto (ver
comando más abajo) y flashear con normalidad. Si aun así se resiste:
- Bootloader manual (infalible): mantener pulsado B (BOOT) y dar un toque a R (RESET) — microbotones del XIAO junto al USB-C. En modo descarga el firmware no corre y el upload entra a la primera.
- Touch 1200 bps durante una ventana despierto (~5–10 s/min):
import serial, time s = serial.Serial(PORT, 1200); s.dtr = False; s.rts = False time.sleep(0.3); s.close()
- Para depurar largo por serie:
Proto::ENABLE_SLEEP = falseenconfig/Protocol.hpp(el protocolo sigue igual, sin dormir).
ls /dev/ttyACM* # puertos (¡renumeran tras sleep/reset!)udevadm info -q property /dev/ttyACM0 | grep ID_SERIAL_SHORT # ¿qué nodo es? # puertos (¡renumeran tras sleep/reset!)pio device list # listado con VID/PID # puertos (¡renumeran tras sleep/reset!)pio run -t upload --upload-port /dev/ttyACM0 # flashear UN nodopio device monitor -p /dev/ttyACM0 pio device monitor -p /dev/ttyACM1pio device monitor -p /dev/ttyACM2pio device monitor -p /dev/ttyACM3python3 -c " import serial, time s = serial.Serial('/dev/ttyACM0', 115200, timeout=1) time.sleep(2) print(s.read(500).decode(errors='ignore')) s.close()"
Nodos actuales: 5 unidades; identificados `0x8D4C` (SER …FC:8D:4C) y `0x90C0`
(SER …FC:90:C0). Topología de pruebas recomendada: grupo {N1,N2,N3} a alcance
ESP-NOW, N4 puente (solo LoRa con el grupo) y N5 lejano — las distancias se
emulan bajando `LORA_TX_POWER_DBM` sin mover hardware.
---
## Hoja de ruta
Funcionalidades previstas (la arquitectura ya les reserva sitio):
- [x] **Pool de mensajes para retransmisión** — `msg/MessagePool`, cabecera con
saltos/relayId, expulsión protegida, tamaño configurable.
- [x] **Pool de dispositivos cercanos** — `msg/NeighborPool`, alimentado por
todas las tramas (beacons de presencia incluidos), expuesto en `GET /devices`.
- [x] **ACKs de difusión** — embebidos en cada entrada del pool; tres vías
(ACK batch ESP-NOW, digests, ACK implícito por `relayId`).
- [x] **Retransmisión priorizada** — slot LoRa de 2 min: difusión obligatoria
primero, después el "menos visto" (menos ACKs).
- [x] **Soporte de >2 nodos** — jitter ranurado, supresión Trickle, ventanas
con dueño (pendiente de validar en campo con 5 nodos).
- [ ] **Sincronización blanda de ventanas (fase 2)** — anunciar `nextSlotInMs`
en el presence para que los vecinos anticipen rendezvous sin REQ.
- [ ] **Criptografía extremo a extremo** — los nodos intermedios retransmiten
sin poder leer el contenido; capa entre `MessageService` y los transportes.
- [ ] **Comunicación con app móvil: mensajería** — el móvil como origen/destino
de mensajes del usuario (BLE o SoftAP). La API web local (`net/ApiServer`,
`POST /message`) ya permite inyectar mensajes desde la LAN como primer paso.
- [ ] **Comunicación con app móvil: configuración** — leer/escribir
`DeviceConfig` desde el móvil (periodos, pool, canal, credenciales…).
Otras mejoras en el radar:
- [x] Persistencia del pool en NVS (`PoolStore`: sobrevive reinicios). Falta la
de configuración (`DeviceConfig`).
- [ ] Timestamp real (RTC/NTP al subir logs) en lugar de `millis()`.
- [ ] Medición de batería y telemetría de consumo (exponerla en `GET /status`).
- [ ] Deep sleep opcional para nodos a batería (despertar solo por timer).
- [ ] OTA (actualización de firmware sin USB, aprovechando el WiFi de la API web).
- [ ] Tests en host (lógica de duty cycle, estimador de enlace, pools) con `pio test`.
---
## Histórico breve
1. Prototipo ESP32 clásico + RFM95 (RadioHead), luego MicroPython.
2. Migración a XIAO ESP32-S3 + SX1262 con RadioLib, BW 62,5 kHz, LBT y duty
cycle ETSI (rama `sx1262-lbt-normativa-eu`).
3. Handshake híbrido dirigido por eventos con wake-on-LoRa y light sleep.
4. Logs en la nube (Adafruit IO) y refactor modular por dominios.
5. API web local con wake-on-LAN; los logs en la nube se retiraron (la API y
`WAKE_ON_USB` cubren su papel con mucho menos consumo).
6. Difusión epidémica: pool de mensajes con ACKs embebidos, pool de vecinos,
beacon de presencia con digest, slot LoRa del "menos visto", ventanas
ESP-NOW con rendezvous implícito y control del caos (jitter ranurado,
supresión Trickle). Los mensajes aleatorios de prueba se retiraron.
## TODO:
- En caso de que el nodo no pueda conectarse a la red local, debe exponsese como AP para consultas de serivicios (analizar si esto interfiere con el comportamiento normal de esp-now).