Skip to content

Repository files navigation

Willow — red de nodos LoRa + ESP-NOW de bajo consumo

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.


Hardware

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.


Radio y normativa (ETSI EN 300 220, EU868)

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).

Protocolo: difusión epidémica con ventanas ESP-NOW

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:

  1. 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_FACTOR la espacia opcionalmente con la red local convergida).
  2. 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 o POST /beacon.
  3. 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_WINDOW y 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 por hops (tope HOPS_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 /message mete 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).

Energía

  • 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 trae CONFIG_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, poner WAKE_ON_LAN = false y volver al light sleep.

Estudio de consumo y autonomía (batería de 3500 mAh)

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).

Mejoras de eficiencia identificadas (sin perder funcionalidad)

Por orden de impacto (el reloj dinámico 80/240 ya está implementado):

  1. Wake-on-LAN condicional a la alimentación: usar usbHostActive() (ya existe en PowerManager) 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.
  2. listen_interval mayor (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.
  3. 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.

API web local

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.


Estructura del proyecto

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.


Compilar y flashear

pio run -t upload --upload-port /dev/ttyACM0   # flashear UN nodo

Antes: 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:

  1. 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.
  2. 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()
  3. Para depurar largo por serie: Proto::ENABLE_SLEEP = false en config/Protocol.hpp (el protocolo sigue igual, sin dormir).

Comandos útiles de depuración

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 nodo
pio device monitor -p /dev/ttyACM0 
pio device monitor -p /dev/ttyACM1
pio device monitor -p /dev/ttyACM2
pio device monitor -p /dev/ttyACM3

Despertar un nodo dormido por USB: abrir el puerto genera actividad en el bus

(D-) que lo despierta; al detectar el host (SOF) se queda despierto y vuelca

la serie mientras el cable siga en el PC.

python3 -c " import serial, time s = serial.Serial('/dev/ttyACM0', 115200, timeout=1) time.sleep(2) print(s.read(500).decode(errors='ignore')) s.close()"

(o simplemente: pio device monitor -p /dev/ttyACM0 y esperar unos segundos)


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).

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages