WorkDone Smiley — Arquitectura LoRaWAN por planta

LaEmpresa · Actualizado 22/09/2026

Resumen

Cada planta de cliente lleva un gateway LoRaWAN indoor con 4G propio, y todos los Smiley del sitio le hablan por radio. El gateway reenvía por 4G a un ChirpStack central de LaEmpresa, que publica los eventos por MQTT al backend de WorkDone.

Por SmileyMódulo LoRaWAN AU915 (~US$ 20–30). Sin SIM ni WiFi del cliente.
Por planta1 gateway 4G + 1 SIM IoT: el único abono mensual del sitio.
Central1 ChirpStack para todos los clientes, en Docker.
IT del clienteNo se toca su red; solo energía o PoE para el gateway.

WiFi y 4G por Smiley quedan como opcionales para actualizar la app; los votos y fichajes NFC viajan siempre por LoRaWAN, con cola local offline-first.

Arquitectura

La radio solo cubre el tramo Smiley → gateway dentro de la planta (50–500 m); desde el gateway todo es IP por 4G.

El mismo ChirpStack atiende a todas las plantas: cada gateway se registra con su EUI y cada Smiley con su DevEUI y claves OTAA. Sumar un cliente es sumar un gateway y dar de alta sus nodos.

Equipos

Recomendado para producción: RAK7268CV2 como gateway y Wio-E5 mini o RAK3172 como módulo en cada Smiley. Todos operan en AU915 y hablan con ChirpStack. Con la tablet Samsung, el módulo es el puente BLE.

Gateway por planta

EquipoRolSpecs clavePrecio orientativo
RAK7268CV2 WisGate Edge Lite 2 (LTE)RecomendadoSX1302, 8 canales, LTE Cat4 (EC25-AU para LATAM), PoE o 12 V, SD para buffer si cae el 4G~AUD 463 en Australia; cotizar distribuidor LATAM
Milesight UG65 (4G)Alternativa robustaSX1302, 8 canales, 4G opcional, carcasa IP65, >2000 nodos, ~2 km urbanoCotizar
SenseCAP M2 AU915Local, sin 4G integradoSX1302, WiFi, PoE; el 4G iría por router externoARS 310.800 (sin stock)
HAT LoRaWAN SX1303 + Raspberry PiPiloto / DIYConcentrador SX1303 915 MHz sobre Pi; 4G por HAT o módem USBARS 246.750 + Pi

Módulo en cada Smiley

EquipoInterfaz con el panelSpecs clavePrecio orientativo
Kit XIAO nRF52840 + Wio-SX1262BLE (puente, firmware propio)SX1262 862–930 MHz, BLE 5.0; para la tablet SamsungARS 44.900
Wio-E5 miniUSB-C directo (USB-UART integrado)STM32WLE5JC, firmware AT LoRaWAN Clase A/B/C, AU915~US$ 20–25
RAK3172UART (o breakout + USB-TTL)STM32WLE5, LoRaWAN 1.0.3, AU915, AT; para placa propia~US$ 10–15
USB a LoRa SX1262 industrialUSBProbablemente solo P2P: confirmar LoRaWAN antes de comprarARS 44.625

Accesorios por planta

Precios en US$/AUD aproximados, de tiendas del exterior, sin envío ni impuestos de importación.

Backend e integración

ChirpStack v4 corre en Docker (network server + application server + PostgreSQL + Redis + broker MQTT). El backend WorkDone se suscribe al MQTT y nunca habla con los gateways.

Formato de payload (binario, ≤ 11 bytes)

Entra en el peor data rate de AU915 (11 bytes de carga útil), así ningún mensaje se pierde por tamaño.

Tipo (byte 0)EventoCamposBytes
0x01Votopuntaje (1 B) · timestamp epoch (4 B) · secuencia (2 B)8
0x02Fichaje NFCUID tarjeta (4–7 B) · timestamp comprimido (3 B)8–11
0x03Latidovotos en cola (2 B) · versión app (1 B) · flags (1 B)5
0x04Lote de votoshasta 3 votos compactos (3 B c/u)10

Offline-first

  1. El Smiley guarda cada evento en una cola local (SQLite) con número de secuencia.
  2. Envía por LoRaWAN sin confirmación; el backend deduplica por (DevEUI, secuencia).
  3. Si hay huecos en la secuencia, el backend pide reenvío por downlink o los recupera en el próximo sync por WiFi/4G.
  4. La configuración chica viaja por downlink Clase A; las actualizaciones de app nunca por LoRa.

Radio y cobertura

Argentina usa el plan AU915 (915–928 MHz, regulado por ENACOM); gateways y módulos se compran en esa variante (TTN Argentina).

Relevamiento de sitio (antes de cotizar)

Costos por planta

Con 10 Smiley por planta, LoRaWAN cuesta en hardware parecido a poner 4G en cada equipo, pero paga 1 abono mensual en vez de 10. Montos en US$ aproximados, antes de importación; a validar con cotización.

ConceptoLoRaWAN + gateway 4G4G en cada Smiley
Gateway RAK7268CV2 + antena + PoE1 × ~US$ 350
Radio en cada Smiley10 × ~US$ 25 = US$ 25010 × ~US$ 40–60 = US$ 400–600
Hardware por planta~US$ 600~US$ 400–600
SIM IoT (S = abono mensual de 1 línea)1 × S10 × S
Dependencia de señal 4GUn solo punto, elegido y con antenaCada baño; muchos sin señal
ChirpStack centralCompartido entre clientesNo aplica

Punto de equilibrio: desde unos 4–5 Smiley por sitio, LoRaWAN es más barato en el primer año. Con 1–2 Smiley conviene 4G directo en el equipo.

Plan de piloto

Validar toda la cadena en banco con hardware local, y después en una planta real, antes de comprar en volumen.

FaseQué se haceHardwareCriterio de éxito
1. Banco (semanas 1–2)ChirpStack en Docker; gateway Pi + HAT SX1303; 2–3 puentes enviando votos simuladosHAT SX1303 + Pi de la flota + kits XIAO nRF52840 + Wio-SX1262Votos llegan al backend por MQTT; dedup por secuencia funciona
2. Integración (semana 3)Puente BLE emparejado con la tablet; la app encola y envíaGalaxy Tab A11 + puenteVoto en pantalla → dashboard en < 10 s
3. Campo (semanas 4–6)Gateway con 4G en planta piloto; Smiley en 3–5 bañosRAK7268CV2 + SIM IoT> 99% de eventos entregados en 2 semanas; RSSI/SNR en rango
4. CierreInforme de cobertura y costos reales; decisión de compraCosto por planta confirmado

Candidata a planta piloto: la de Coca-Cola sobre el camino a Montecristo, o el cliente ya confirmado si tiene un sitio con varios baños.

Smiley con tablet Samsung: puente BLE ↔ LoRaWAN

La Galaxy Tab A11 (8,7") no trae LoRa y su único USB-C queda ocupado por la alimentación, así que la radio va en un módulo aparte que la tablet maneja por Bluetooth LE. El USB-C sigue siendo solo carga y, tras un corte de luz, ambos arrancan y se reconectan solos.

Hardware y proveedor

ItemDetalle
ProductoKit XIAO nRF52840 + Wio-SX1262 (Seeed SKU 102010710)
RadioSemtech SX1262, 862–930 MHz (cubre AU915); BLE 5.0 en el nRF52840
PrecioARS 44.900 lista; ARS 38.165 por transferencia (15% off)
StockLa ficha indicaba 4 unidades pero mostraba aviso de sin stock: confirmar antes de pagar
VendedorInnoMakeBright, La Plata (Buenos Aires); distribuidor oficial Seeed Studio; Factura A y B
ContactoWhatsApp +54 9 221 200-2344; despacho en 24 h hábiles por Andreani o Correo Argentino
Garantía15 días por defecto de fábrica en la tienda; la descripción menciona 12 meses de garantía Seeed
AlternativaMódulo Wio-SX1262 suelto ARS 15.697 + XIAO nRF52840 por separado

Tamaño y montaje en el gabinete

Huella21 × 17,8 mm
Apilado con radio8 × 22 × 23 mm · 3,1 g
Espacio a reservar~40 × 30 × 15 mm + antena

Configuración de la tablet

Firmware del puente (v1.0.0)

El puente recibe comandos de texto por BLE (Nordic UART Service con PIN), los encola y los envía como uplinks LoRaWAN AU915; los downlinks vuelven a la tablet. La cola persistente sigue en la tablet: borra un evento solo cuando recibe SENT.

Compilar y cargar

  1. Arduino IDE → gestor de placas → URL https://files.seeedstudio.com/arduino/package_seeeduino_boards_index.json → instalar Seeed nRF52 Boards (no la versión mbed).
  2. Placa: Seeed XIAO nRF52840. Librería: RadioLib 7.1 o superior.
  3. Doble toque en RESET para entrar al bootloader y subir el sketch.
  4. Por USB serie (115200) o BLE enviar INFO y copiar el DevEUI (ID único del chip).
  5. En ChirpStack: device profile LoRaWAN 1.0.3, AU915, sub-banda 2, OTAA; alta del device con ese DevEUI y una AppKey nueva.
  6. Enviar KEYS,<JoinEUI>,<AppKey> una vez; queda en flash y el puente hace join solo.

Pines (según el variant oficial del kit en Meshtastic): NSS D4, DIO1 D1, RESET D2, BUSY D3, RXEN D5, DIO2 como switch de TX, TCXO 1,8 V.

Protocolo tablet ↔ puente

ComandoRespuestaUso
TX,<seq>,<port>,<hex>ACK,<seq> y luego SENT,<seq> o FAIL,<seq>,<código>Voto o fichaje, sin confirmar
TXC,<seq>,<port>,<hex>SENT,<seq>,ACK o SENT,<seq>,NOACKLatido confirmado; 3 NOACK seguidos fuerzan rejoin
INFOINFO,<fw>,<devEUI>,<joinEUI>,<claves>,<unido>,<subbanda>Alta y diagnóstico
STATSTAT,<unido>,<cola>,<rssi>,<snr>,<uptime>,<uplinks>Monitoreo desde la app
KEYS,<joinEUI>,<appKey>OK,KEYSAprovisionamiento
SUBBAND,<1..8>OK,SUBBAND y reinicioCambiar sub-banda
REJOIN / REBOOT / PINGOK,REJOIN / OK,REBOOT / PONGMantenimiento
(asíncrono)BOOT, JOINED, JOINFAIL,<c>, DL,<port>,<hex>Eventos del puente

Join OTAA nuevo en cada arranque (solo se guardan los DevNonce, para no gastar flash), cola en RAM de 32 mensajes, 3 s mínimo entre uplinks, watchdog de 60 s y LED rojo/azul/verde. El código no se compiló todavía contra el hardware: validarlo en la fase de banco.

Código completo

wd_lora_bridge.ino
/*
 * WorkDone Smiley - Puente BLE <-> LoRaWAN AU915
 * ------------------------------------------------
 * Hardware : Seeed XIAO nRF52840 + Wio-SX1262 for XIAO (kit SKU 102010710)
 * Core     : "Seeed nRF52 Boards" (NO el core mbed) -> placa "Seeed XIAO nRF52840"
 *            URL gestor de placas: https://files.seeedstudio.com/arduino/package_seeeduino_boards_index.json
 * Librerias: RadioLib >= 7.1 (Library Manager). Bluefruit y LittleFS vienen con el core.
 * Flasheo  : doble toque en RESET -> aparece unidad XIAO-SENSE/XIAO-BOOT -> Upload desde Arduino IDE
 *
 * La tablet (app Smiley) se conecta por BLE (Nordic UART Service, con PIN) y manda
 * comandos de texto, una linea por comando terminada en '\n'. Los mismos comandos
 * se aceptan por USB serie (115200) para pruebas de banco.
 *
 * Tablet -> puente
 *   PING                                -> PONG
 *   INFO                                -> INFO,<fw>,<devEUI>,<joinEUI>,<keys 0|1>,<joined 0|1>,<subband>
 *   KEYS,<joinEUI 16 hex>,<appKey 32 hex> -> OK,KEYS   (guarda en flash y hace join de nuevo)
 *   SUBBAND,<1..8>                      -> OK,SUBBAND (guarda y reinicia)
 *   TX,<seq>,<port>,<hex>               -> ACK,<seq> | ERR,<seq>,<motivo>   (uplink sin confirmar)
 *   TXC,<seq>,<port>,<hex>              -> ACK,<seq> | ERR,<seq>,<motivo>   (uplink confirmado)
 *   STAT                                -> STAT,<joined>,<cola>,<rssi>,<snr>,<uptime_s>,<uplinks>
 *   REJOIN                              -> OK,REJOIN
 *   REBOOT                              -> OK,REBOOT
 *
 * Puente -> tablet (asincronico)
 *   BOOT,<fw>
 *   JOINED | JOINFAIL,<codigo> | REJOINING
 *   SENT,<seq> | SENT,<seq>,ACK | SENT,<seq>,NOACK | FAIL,<seq>,<codigo>
 *   DL,<port>,<hex>
 *   ERR,RADIO,<codigo> | ERR,UNKNOWN | ERR,BADARGS
 *
 * Diseno:
 *  - La cola persistente (fuente de verdad) vive en la tablet. El puente tiene una cola
 *    en RAM de QUEUE_SIZE mensajes; la tablet borra un evento de su cola al recibir SENT.
 *  - Si se corta el BLE, la tablet reenvia lo pendiente al reconectar; el backend
 *    deduplica por (DevEUI, secuencia de aplicacion dentro del payload).
 *  - En cada arranque se hace un join OTAA nuevo. Solo se persisten los DevNonce
 *    (una escritura de flash por intento de join), no la sesion: asi no se gasta la flash.
 *  - DevEUI = ID unico de fabrica del nRF52840 (FICR). Se lee con INFO y se da de alta en ChirpStack.
 *  - Cada MAX_MISSED_ACKS uplinks confirmados sin ACK seguidos, se fuerza un rejoin.
 *  - Watchdog de hardware de WDT_TIMEOUT_S segundos.
 *
 * LEDs (activos en bajo): rojo = sin claves o radio en error, azul = uniendose, verde = unido.
 */

#include <Arduino.h>
#include <bluefruit.h>
#include <Adafruit_LittleFS.h>
#include <InternalFileSystem.h>
#include <RadioLib.h>

using namespace Adafruit_LittleFS_Namespace;

#define FW_VERSION "1.0.0"

// ---------- Pines Wio-SX1262 para XIAO (kit 102010710) ----------
#define PIN_LORA_NSS    D4
#define PIN_LORA_DIO1   D1
#define PIN_LORA_RESET  D2
#define PIN_LORA_BUSY   D3
#define PIN_LORA_RXEN   D5
#define LORA_TCXO_V     1.8f

// ---------- Parametros ----------
#define BLE_PIN          "123456"   // Cambiar por lote o por cliente
#define DEFAULT_SUBBAND  2          // AU915 FSB2 (canales 8-15 + 65); debe coincidir con gateway y ChirpStack
#define QUEUE_SIZE       32
#define MAX_PAYLOAD      51
#define LINE_MAX         200
#define MIN_TX_GAP_MS    3000UL
#define MAX_TX_TRIES     3
#define WDT_TIMEOUT_S    60
#define MAX_MISSED_ACKS  3
#define JOIN_BACKOFF_MIN 15000UL
#define JOIN_BACKOFF_MAX 900000UL

static const char*    CFG_PATH    = "/wd_cfg.bin";
static const char*    NONCES_PATH = "/wd_nonces.bin";
static const uint32_t CFG_MAGIC   = 0x57444C31;  // "WDL1"

struct Config {
  uint32_t magic;
  uint64_t joinEUI;
  uint8_t  appKey[16];
  uint8_t  subband;
  uint8_t  hasKeys;
};

struct TxItem {
  uint32_t seq;
  uint8_t  port;
  uint8_t  len;
  uint8_t  tries;
  bool     confirmed;
  uint8_t  data[MAX_PAYLOAD];
};

struct LineReader {
  char     buf[LINE_MAX];
  uint16_t len;
};

enum LedState { LED_ERROR, LED_JOINING, LED_JOINED };

// ---------- Objetos globales ----------
SX1262        radio = new Module(PIN_LORA_NSS, PIN_LORA_DIO1, PIN_LORA_RESET, PIN_LORA_BUSY);
LoRaWANNode*  node  = nullptr;
BLEUart       bleuart(1024);
BLEDis        bledis;

Config        cfg;
uint64_t      devEUI        = 0;
bool          radioOk       = false;
bool          joined        = false;
uint32_t      nextJoinAt    = 0;
uint32_t      joinBackoffMs = JOIN_BACKOFF_MIN;
uint32_t      lastTxAt      = 0;
uint32_t      uplinkCount   = 0;
uint8_t       missedAcks    = 0;
float         lastRssi      = 0.0f;
float         lastSnr       = 0.0f;

TxItem        txQueue[QUEUE_SIZE];
uint8_t       qHead  = 0;
uint8_t       qTail  = 0;
uint8_t       qCount = 0;

LineReader    bleReader    = { {0}, 0 };
LineReader    serialReader = { {0}, 0 };

// ---------- Salida ----------
void out(const String& s) {
  Serial.println(s);
  if (Bluefruit.connected()) {
    bleuart.print(s);
    bleuart.print("\n");
  }
}

// ---------- LEDs ----------
void setLed(LedState st) {
  digitalWrite(LED_RED,   st == LED_ERROR   ? LOW : HIGH);
  digitalWrite(LED_BLUE,  st == LED_JOINING ? LOW : HIGH);
  digitalWrite(LED_GREEN, st == LED_JOINED  ? LOW : HIGH);
}

void refreshLed() {
  if (!radioOk || !cfg.hasKeys) setLed(LED_ERROR);
  else if (!joined)             setLed(LED_JOINING);
  else                          setLed(LED_JOINED);
}

// ---------- Hex ----------
int hexNibble(char c) {
  if (c >= '0' && c <= '9') return c - '0';
  if (c >= 'a' && c <= 'f') return c - 'a' + 10;
  if (c >= 'A' && c <= 'F') return c - 'A' + 10;
  return -1;
}

// Devuelve bytes decodificados, -1 si el hex es invalido, -2 si excede maxLen
int parseHex(const char* s, uint8_t* dst, int maxLen) {
  int n = strlen(s);
  if (n == 0 || (n % 2) != 0) return -1;
  if (n / 2 > maxLen) return -2;
  for (int i = 0; i < n / 2; i++) {
    int h = hexNibble(s[2 * i]);
    int l = hexNibble(s[2 * i + 1]);
    if (h < 0 || l < 0) return -1;
    dst[i] = (uint8_t)((h << 4) | l);
  }
  return n / 2;
}

String toHex(const uint8_t* b, size_t n) {
  static const char* digits = "0123456789ABCDEF";
  String s;
  s.reserve(n * 2);
  for (size_t i = 0; i < n; i++) {
    s += digits[b[i] >> 4];
    s += digits[b[i] & 0x0F];
  }
  return s;
}

String u64Hex(uint64_t v) {
  uint8_t b[8];
  for (int i = 0; i < 8; i++) b[i] = (uint8_t)((v >> (56 - 8 * i)) & 0xFF);
  return toHex(b, 8);
}

bool parseU64(const char* s, uint64_t& v) {
  uint8_t b[8];
  if (strlen(s) != 16) return false;
  if (parseHex(s, b, 8) != 8) return false;
  v = 0;
  for (int i = 0; i < 8; i++) v = (v << 8) | b[i];
  return true;
}

// ---------- Flash (LittleFS interno) ----------
bool readFileBytes(const char* path, void* buf, size_t len) {
  File f(InternalFS);
  if (!f.open(path, FILE_O_READ)) return false;
  int r = f.read(buf, (uint16_t)len);
  f.close();
  return r == (int)len;
}

bool writeFileBytes(const char* path, const void* buf, size_t len) {
  InternalFS.remove(path);
  File f(InternalFS);
  if (!f.open(path, FILE_O_WRITE)) return false;
  size_t w = f.write((const uint8_t*)buf, len);
  f.close();
  return w == len;
}

void loadConfig() {
  if (!readFileBytes(CFG_PATH, &cfg, sizeof(cfg)) || cfg.magic != CFG_MAGIC) {
    memset(&cfg, 0, sizeof(cfg));
    cfg.magic   = CFG_MAGIC;
    cfg.subband = DEFAULT_SUBBAND;
    cfg.hasKeys = 0;
  }
  if (cfg.subband < 1 || cfg.subband > 8) cfg.subband = DEFAULT_SUBBAND;
}

bool saveConfig() {
  return writeFileBytes(CFG_PATH, &cfg, sizeof(cfg));
}

// ---------- Watchdog ----------
void wdtStart() {
  NRF_WDT->CONFIG = (WDT_CONFIG_HALT_Pause << WDT_CONFIG_HALT_Pos) |
                    (WDT_CONFIG_SLEEP_Run  << WDT_CONFIG_SLEEP_Pos);
  NRF_WDT->CRV = WDT_TIMEOUT_S * 32768UL;
  NRF_WDT->RREN = WDT_RREN_RR0_Msk;
  NRF_WDT->TASKS_START = 1;
}

void wdtFeed() {
  NRF_WDT->RR[0] = WDT_RR_RR_Reload;
}

// ---------- Cola ----------
bool enqueue(const TxItem& it) {
  if (qCount >= QUEUE_SIZE) return false;
  txQueue[qTail] = it;
  qTail = (qTail + 1) % QUEUE_SIZE;
  qCount++;
  return true;
}

void dequeue() {
  if (qCount == 0) return;
  qHead = (qHead + 1) % QUEUE_SIZE;
  qCount--;
}

// ---------- Radio / LoRaWAN ----------
bool radioInit() {
  radio.setRfSwitchPins(PIN_LORA_RXEN, RADIOLIB_NC);
  int16_t st = radio.begin(915.0, 125.0, 9, 7, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, 10, 8, LORA_TCXO_V, false);
  if (st != RADIOLIB_ERR_NONE) {
    out("ERR,RADIO," + String(st));
    return false;
  }
  radio.setDio2AsRfSwitch(true);
  return true;
}

void scheduleRejoin(bool immediate) {
  joined = false;
  missedAcks = 0;
  if (immediate) {
    joinBackoffMs = JOIN_BACKOFF_MIN;
    nextJoinAt = millis();
  }
  refreshLed();
}

void tryJoin() {
  if (!radioOk || !cfg.hasKeys) return;

  if (node) {
    delete node;
    node = nullptr;
  }
  node = new LoRaWANNode(&radio, &AU915, cfg.subband);
  node->beginOTAA(cfg.joinEUI, devEUI, cfg.appKey, cfg.appKey);  // LoRaWAN 1.0.x: misma clave

  uint8_t nonces[RADIOLIB_LORAWAN_NONCES_BUF_SIZE];
  if (readFileBytes(NONCES_PATH, nonces, sizeof(nonces))) {
    node->setBufferNonces(nonces);  // si no corresponde a estas claves, RadioLib lo descarta
  }

  refreshLed();
  wdtFeed();
  int16_t st = node->activateOTAA();
  wdtFeed();

  memcpy(nonces, node->getBufferNonces(), sizeof(nonces));
  writeFileBytes(NONCES_PATH, nonces, sizeof(nonces));

  if (st == RADIOLIB_LORAWAN_NEW_SESSION || st == RADIOLIB_LORAWAN_SESSION_RESTORED) {
    joined = true;
    missedAcks = 0;
    joinBackoffMs = JOIN_BACKOFF_MIN;
    out("JOINED");
  } else {
    joined = false;
    out("JOINFAIL," + String(st));
    nextJoinAt = millis() + joinBackoffMs;
    joinBackoffMs = min(joinBackoffMs * 2, JOIN_BACKOFF_MAX);
  }
  refreshLed();
}

void processQueue() {
  if (!joined || qCount == 0 || node == nullptr) return;
  if (millis() - lastTxAt < MIN_TX_GAP_MS) return;
  if (node->timeUntilUplink() > 0) return;

  TxItem& it = txQueue[qHead];
  uint8_t down[256];
  size_t downLen = 0;
  LoRaWANEvent_t evUp;
  LoRaWANEvent_t evDown;

  wdtFeed();
  int16_t st = node->sendReceive(it.data, it.len, it.port, down, &downLen, it.confirmed, &evUp, &evDown);
  wdtFeed();
  lastTxAt = millis();

  if (st < RADIOLIB_ERR_NONE) {
    it.tries++;
    if (st == RADIOLIB_ERR_PACKET_TOO_LONG || it.tries >= MAX_TX_TRIES) {
      out("FAIL," + String(it.seq) + "," + String(st));
      dequeue();
    }
    return;
  }

  uplinkCount++;

  if (st > 0) {
    lastRssi = radio.getRSSI();
    lastSnr  = radio.getSNR();
    if (downLen > 0) {
      out("DL," + String(evDown.fPort) + "," + toHex(down, downLen));
    }
  }

  if (it.confirmed) {
    if (st > 0 && evDown.confirming) {
      missedAcks = 0;
      out("SENT," + String(it.seq) + ",ACK");
    } else {
      missedAcks++;
      out("SENT," + String(it.seq) + ",NOACK");
      if (missedAcks >= MAX_MISSED_ACKS) {
        out("REJOINING");
        scheduleRejoin(true);
      }
    }
  } else {
    out("SENT," + String(it.seq));
  }

  dequeue();
}

// ---------- Comandos ----------
void handleTx(bool confirmed) {
  char* seqS  = strtok(NULL, ",");
  char* portS = strtok(NULL, ",");
  char* hexS  = strtok(NULL, ",");
  if (!seqS || !portS || !hexS) {
    out("ERR,BADARGS");
    return;
  }

  uint32_t seq = strtoul(seqS, NULL, 10);
  int port = atoi(portS);
  if (port < 1 || port > 223) {
    out("ERR," + String(seq) + ",BADPORT");
    return;
  }

  TxItem it;
  memset(&it, 0, sizeof(it));
  int len = parseHex(hexS, it.data, MAX_PAYLOAD);
  if (len == -2) {
    out("ERR," + String(seq) + ",TOOLONG");
    return;
  }
  if (len < 1) {
    out("ERR," + String(seq) + ",BADHEX");
    return;
  }

  it.seq = seq;
  it.port = (uint8_t)port;
  it.len = (uint8_t)len;
  it.tries = 0;
  it.confirmed = confirmed;

  if (!enqueue(it)) {
    out("ERR," + String(seq) + ",FULL");
    return;
  }
  out("ACK," + String(seq));
}

void handleKeys() {
  char* joinS = strtok(NULL, ",");
  char* keyS  = strtok(NULL, ",");
  if (!joinS || !keyS) {
    out("ERR,BADARGS");
    return;
  }

  uint64_t j = 0;
  uint8_t key[16];
  if (!parseU64(joinS, j) || strlen(keyS) != 32 || parseHex(keyS, key, 16) != 16) {
    out("ERR,BADARGS");
    return;
  }

  cfg.joinEUI = j;
  memcpy(cfg.appKey, key, 16);
  cfg.hasKeys = 1;
  if (!saveConfig()) {
    out("ERR,FLASH");
    return;
  }
  out("OK,KEYS");
  scheduleRejoin(true);
}

void handleSubband() {
  char* nS = strtok(NULL, ",");
  if (!nS) {
    out("ERR,BADARGS");
    return;
  }
  int n = atoi(nS);
  if (n < 1 || n > 8) {
    out("ERR,BADARGS");
    return;
  }
  cfg.subband = (uint8_t)n;
  if (!saveConfig()) {
    out("ERR,FLASH");
    return;
  }
  out("OK,SUBBAND");
  delay(300);
  NVIC_SystemReset();
}

void handleLine(char* line) {
  char* cmd = strtok(line, ",");
  if (!cmd) return;

  if (!strcmp(cmd, "PING")) {
    out("PONG");
  } else if (!strcmp(cmd, "INFO")) {
    out(String("INFO,") + FW_VERSION + "," + u64Hex(devEUI) + "," + u64Hex(cfg.joinEUI) + "," +
        (cfg.hasKeys ? "1" : "0") + "," + (joined ? "1" : "0") + "," + String(cfg.subband));
  } else if (!strcmp(cmd, "STAT")) {
    out("STAT," + String(joined ? 1 : 0) + "," + String(qCount) + "," + String(lastRssi, 1) + "," +
        String(lastSnr, 1) + "," + String(millis() / 1000) + "," + String(uplinkCount));
  } else if (!strcmp(cmd, "TX")) {
    handleTx(false);
  } else if (!strcmp(cmd, "TXC")) {
    handleTx(true);
  } else if (!strcmp(cmd, "KEYS")) {
    handleKeys();
  } else if (!strcmp(cmd, "SUBBAND")) {
    handleSubband();
  } else if (!strcmp(cmd, "REJOIN")) {
    out("OK,REJOIN");
    scheduleRejoin(true);
  } else if (!strcmp(cmd, "REBOOT")) {
    out("OK,REBOOT");
    delay(300);
    NVIC_SystemReset();
  } else {
    out("ERR,UNKNOWN");
  }
}

void feedReader(LineReader& r, char c) {
  if (c == '\r') return;
  if (c == '\n') {
    r.buf[r.len] = 0;
    if (r.len > 0) handleLine(r.buf);
    r.len = 0;
    return;
  }
  if (r.len < LINE_MAX - 1) {
    r.buf[r.len++] = c;
  } else {
    r.len = 0;  // linea demasiado larga: se descarta
  }
}

void pollInputs() {
  while (bleuart.available()) feedReader(bleReader, (char)bleuart.read());
  while (Serial.available())  feedReader(serialReader, (char)Serial.read());
}

// ---------- BLE ----------
void connectCallback(uint16_t connHandle) {
  (void)connHandle;
  Serial.println("BLE conectado");
}

void disconnectCallback(uint16_t connHandle, uint8_t reason) {
  (void)connHandle;
  Serial.println("BLE desconectado, motivo 0x" + String(reason, HEX));
}

void bleInit() {
  Bluefruit.configPrphBandwidth(BANDWIDTH_MAX);
  Bluefruit.begin();
  Bluefruit.setTxPower(4);

  String name = "WD-LORA-" + u64Hex(devEUI).substring(12);
  Bluefruit.setName(name.c_str());

  Bluefruit.Security.setPIN(BLE_PIN);
  Bluefruit.Periph.setConnectCallback(connectCallback);
  Bluefruit.Periph.setDisconnectCallback(disconnectCallback);

  bledis.setManufacturer("LaEmpresa");
  bledis.setModel("WorkDone Smiley LoRa Bridge");
  bledis.setFirmwareRev(FW_VERSION);
  bledis.begin();

  bleuart.setPermission(SECMODE_ENC_WITH_MITM, SECMODE_ENC_WITH_MITM);
  bleuart.begin();

  Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
  Bluefruit.Advertising.addTxPower();
  Bluefruit.Advertising.addService(bleuart);
  Bluefruit.ScanResponse.addName();
  Bluefruit.Advertising.restartOnDisconnect(true);
  Bluefruit.Advertising.setInterval(32, 244);
  Bluefruit.Advertising.setFastTimeout(30);
  Bluefruit.Advertising.start(0);
}

// ---------- Arduino ----------
void setup() {
  pinMode(LED_RED, OUTPUT);
  pinMode(LED_GREEN, OUTPUT);
  pinMode(LED_BLUE, OUTPUT);
  setLed(LED_ERROR);

  Serial.begin(115200);
  uint32_t t0 = millis();
  while (!Serial && millis() - t0 < 1500) delay(10);

  devEUI = ((uint64_t)NRF_FICR->DEVICEID[1] << 32) | (uint64_t)NRF_FICR->DEVICEID[0];

  InternalFS.begin();
  loadConfig();

  bleInit();
  radioOk = radioInit();

  wdtStart();
  out(String("BOOT,") + FW_VERSION);
  out("INFO," + String(FW_VERSION) + "," + u64Hex(devEUI) + "," + u64Hex(cfg.joinEUI) + "," +
      (cfg.hasKeys ? "1" : "0") + ",0," + String(cfg.subband));

  nextJoinAt = millis() + 2000;
  refreshLed();
}

void loop() {
  wdtFeed();
  pollInputs();

  if (!joined && radioOk && cfg.hasKeys && (int32_t)(millis() - nextJoinAt) >= 0) {
    tryJoin();
  }

  processQueue();
  delay(5);
}

Riesgos y próximos pasos

RiesgoImpactoMitigación
Mala señal 4G en el punto del gatewaySin backhaul, eventos demoradosMedir antes; antena LTE externa; buffer SD del gateway; cola local en cada Smiley
Cobertura LoRa insuficiente en naves grandesBaños sin enlacePrueba de campo; segundo gateway
Corte de energíaGateway caídoUPS chica o PoE desde un switch con UPS
Sub-banda mal configuradaNodos que no se unenPlantilla única para gateway, ChirpStack y firmware
Módulo USB comprado que resulta solo P2PNo habla con el gatewayUsar puente XIAO, Wio-E5 mini o RAK3172 (LoRaWAN confirmado)

Decisiones abiertas

Próximos pasos

Fuentes