You dont have javascript enabled! Please enable it!

Caso práctico: Puente LoRa-MQTT con RPi Zero 2 W y Dragino

Caso práctico: Puente LoRa-MQTT con RPi Zero 2 W y Dragino — hero

Objetivo y caso de uso

Qué construirás: Un puente LoRa-MQTT que escucha paquetes LoRa desde un HAT Dragino y los publica en un broker MQTT, incluyendo metadatos relevantes.

Para qué sirve

  • Integrar dispositivos LoRa en una red MQTT para monitoreo en tiempo real.
  • Recibir comandos desde MQTT para enviar datos a dispositivos LoRa.
  • Obtener información de ubicación a través de GPS y transmitirla junto con los datos LoRa.
  • Facilitar la comunicación entre sensores LoRa y aplicaciones basadas en la nube.

Resultado esperado

  • Publicación de datos LoRa en MQTT con metadatos (RSSI, SNR) cada 10 segundos.
  • Latencia de transmisión de comandos LoRa de menos de 2 segundos.
  • Recepción de al menos 100 paquetes LoRa por hora con éxito.
  • Visualización de la posición del gateway en tiempo real en un dashboard MQTT.

Público objetivo: Desarrolladores y entusiastas de IoT; Nivel: Alto

Arquitectura/flujo: Raspberry Pi Zero 2 W con Dragino HAT -> Recepción de datos LoRa -> Publicación en MQTT -> Comandos desde MQTT -> Transmisión de datos LoRa.

Nivel: alto

Prerrequisitos

  • Objetivo del proyecto:
  • Construir un “lora-mqtt-gateway-bridge” que:
  • Escuche paquetes LoRa (SX1276/78 del HAT) y los publique en MQTT con metadatos (RSSI, SNR, frecuencia, SF, BW) y posición del gateway (vía GPS del HAT).
  • Acepte comandos desde MQTT para transmitir paquetes LoRa (downlink).
  • Sistema operativo y toolchain exactos:
  • Raspberry Pi OS Bookworm (Debian 12), 64‑bit, edición Lite.
  • Núcleo Linux 6.6 (línea Bookworm para Raspberry Pi).
  • Python 3.11 (usaremos venv con Python 3.11 y versiones fijadas de librerías).
  • Pip y paquetes Python fijados para reproducibilidad:
  • pip 24.2
  • setuptools 70.0.0
  • wheel 0.44.0
  • paho-mqtt 2.1.0
  • pyserial 3.5
  • pynmea2 1.18.0
  • spidev 3.6
  • RPi.GPIO 0.7.1
  • gpiozero 1.6.2
  • Servicios y utilidades de sistema:
  • Mosquitto (broker MQTT) 2.0.x (paquete de Debian Bookworm)
  • mosquitto-clients (para mosquitto_pub y mosquitto_sub)
  • minicom (para probar el GPS por UART si lo deseas)
  • Conocimientos previos:
  • Familiaridad con SPI y UART en Raspberry Pi.
  • Conocimientos de Python y MQTT.
  • Nociones de radio LoRa (frecuencia, SF, BW, CR, potencia, duty cycle/reglamentación local).

Nota: aunque Bookworm tiende a traer Python 3.11 preinstalado, trabajaremos en un entorno virtual con versiones concretas para asegurar coherencia.

Materiales

  • Raspberry Pi Zero 2 W + Dragino LoRa/GPS HAT (modelo con SX1276/78 y GNSS L80).
  • Tarjeta microSD (≥ 16 GB, clase 10), con Raspberry Pi OS Bookworm 64‑bit (Lite) grabado.
  • Conectividad:
  • Wi‑Fi para el Pi Zero 2 W o adaptador Ethernet–USB si se prefiere.
  • Fuente de alimentación 5 V / 2 A estable.
  • Accesorios:
  • Antena para la banda correspondiente (EU868/US915, etc.). Imprescindible conectar la antena al HAT antes de energizar.
  • Separadores/espaciadores y tornillería para fijar el HAT a la Raspberry Pi.
  • Opcional: adaptador USB‑TTL si quieres inspeccionar el UART externamente (no necesario para este caso práctico).

Preparación y conexión

Montaje mecánico y eléctrico

  • Apaga y desconecta la Raspberry Pi Zero 2 W antes de montar.
  • Inserta el Dragino LoRa/GPS HAT en el conector GPIO de 40 pines de la Raspberry Pi Zero 2 W.
  • Atornilla con separadores para evitar esfuerzos en el conector.
  • Conecta la antena LoRa a la salida RF del HAT.
  • El HAT cablea internamente la mayoría de señales; sin embargo, verifica mapeo lógico de pines para el driver que usaremos.

Mapeo de pines (Raspberry Pi Zero 2 W ↔ Dragino LoRa/GPS HAT)

La siguiente tabla refleja los pines usados por el HAT y los que configuraremos en el código:

Función HAT Chip/Interfaz Pin Raspberry Pi GPIO (BCM) Notas
LoRa NSS/CS SPI0 CE0 Pin 24 GPIO8 Chip select SPI LoRa
LoRa SCK SPI0 SCLK Pin 23 GPIO11 Reloj SPI
LoRa MOSI SPI0 MOSI Pin 19 GPIO10 SPI MOSI
LoRa MISO SPI0 MISO Pin 21 GPIO9 SPI MISO
LoRa DIO0 GPIO a DIO0 Pin 22 GPIO25 RxDone/TxDone IRQ (usado en driver)
LoRa DIO1 GPIO a DIO1 Pin 18 GPIO24 Opcional (no imprescindible)
LoRa RESET GPIO a RST Pin 11 GPIO17 Reset por software del SX1276
GPS TX UART0 TXD Pin 8 GPIO14 Salida GPS hacia Pi (RX del Pi)
GPS RX UART0 RXD Pin 10 GPIO15 Entrada GPS desde Pi (TX del Pi)
GPS PPS GPIO opcional Pin 7 GPIO4 Opción de PPS (no requerido)
3V3 3V3 Pin 1 Alimentación HAT
5V 5V Pin 2 Alimentación HAT
GND GND Pin 6 Referencia

Observación: en la mayoría de HATs Dragino, DIO0→GPIO25 y RESET→GPIO17 son la asignación por defecto, que respetaremos.

Habilitar interfaces (SPI y UART) y deshabilitar consola por serie

Opción A: usando raspi-config (interactivo)
1. sudo raspi-config
2. Interface Options:
– I4 SPI → Enable
– I2 Serial Port:
– Login shell over serial? → No
– Enable serial port hardware? → Yes
3. Finish → Reboot.

Opción B: editando archivos en Bookworm (no interactivo)
– Habilitar SPI y UART en /boot/firmware/config.txt:
– Añade (si no existen):
– dtparam=spi=on
– enable_uart=1
– Quitar la consola serie del kernel en /boot/firmware/cmdline.txt:
– Elimina cualquier trozo “console=serial0,115200” o similar.

Comandos exactos para hacerlo por terminal:

# 1) Habilitar SPI y UART
sudo sed -i '/^dtparam=spi=/d' /boot/firmware/config.txt
echo 'dtparam=spi=on' | sudo tee -a /boot/firmware/config.txt
sudo sed -i '/^enable_uart=/d' /boot/firmware/config.txt
echo 'enable_uart=1' | sudo tee -a /boot/firmware/config.txt

# 2) Deshabilitar consola serie en el arranque
sudo sed -i 's/console=serial0,[0-9]\+ //g' /boot/firmware/cmdline.txt

# 3) Reboot
sudo reboot

Tras el reinicio, verifica:
– SPI: ls -l /dev/spidev0.0 debe existir.
– UART: ls -l /dev/serial0 debe existir (alias a /dev/ttyAMA0 en Zero 2 W).

Grupos de usuario

Para acceder a SPI y UART sin sudo, añade tu usuario a los grupos:

sudo usermod -aG spi,tty,dialout $USER
# Cierra sesión y vuelve a entrar, o reinicia:
sudo reboot

Código y toolchain de Python (entorno reproducible)

Crear entorno virtual y toolchain

# Actualiza el sistema
sudo apt update
sudo apt install -y python3.11 python3.11-venv python3-dev python3-pip \
                    git mosquitto mosquitto-clients minicom

# Activa y habilita Mosquitto
sudo systemctl enable --now mosquitto
sudo systemctl status mosquitto --no-pager

# Crea proyecto y venv
mkdir -p ~/projects/lora-mqtt-gateway-bridge
cd ~/projects/lora-mqtt-gateway-bridge
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip==24.2 setuptools==70.0.0 wheel==0.44.0

# Instala dependencias con versiones exactas
pip install paho-mqtt==2.1.0 pyserial==3.5 pynmea2==1.18.0 spidev==3.6 RPi.GPIO==0.7.1 gpiozero==1.6.2

# Congela versiones para registro
pip freeze > requirements.txt

Verifica versiones:

python -V   # Python 3.11.x
pip -V      # pip 24.2 (python 3.11)
python -c "import paho.mqtt,serial,pynmea2,spidev,RPi.GPIO,gpiozero; \
print('paho-mqtt',paho.mqtt.__version__)"

Preparación y conexión (detallada)

Verificación de GPS por UART (opcional pero recomendado)

  • Sin el gateway corriendo, prueba el GPS:
  • minicom -b 9600 -o -D /dev/serial0
  • Deberías ver sentencias NMEA (p.ej., $GPRMC, $GPGGA). La primera fijación puede tardar varios minutos en exterior con vista de cielo.

Para salir de minicom: Ctrl-A, Z, luego X y confirma.

Configuración regional de LoRa

  • Define tu región y frecuencia:
  • EU868 (Europa): ejemplo 868.1 MHz, BW 125 kHz, SF7, CR 4/5, SyncWord 0x12 (no LoRaWAN).
  • US915 (América): frecuencias distintas (p.ej., 903.9 MHz, etc.). Ajusta en el código.

Este caso práctico usará EU868 por defecto (868.1 MHz). Cambia parámetros si tu HAT y normativa son distintos.

Código completo (Python 3.11)

El siguiente script implementa:
– Driver mínimo para SX1276/78 vía spidev + RPi.GPIO (reset, init, RX continuo, TX).
– Lector GPS por UART con pyserial + pynmea2.
– Cliente MQTT (paho-mqtt) para publicar RX y recibir órdenes de TX.
– Fusión de metadatos (RSSI, SNR, SF, BW, frecuencia, timestamp y posición del gateway).

Guarda como gateway.py:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import sys
import json
import time
import threading
import binascii
import datetime
import signal

import RPi.GPIO as GPIO
import spidev
import serial
import pynmea2
from paho.mqtt.client import Client as MqttClient

# --------------------
# Configuración global
# --------------------
GATEWAY_ID = os.environ.get("GW_ID", "zero2w-dragino")
MQTT_HOST = os.environ.get("MQTT_HOST", "127.0.0.1")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
MQTT_KEEPALIVE = 60

# LoRa (EU868 por defecto)
LORA_FREQ_HZ = int(os.environ.get("LORA_FREQ_HZ", str(868100000)))
LORA_BW = int(os.environ.get("LORA_BW", str(125000)))       # 125 kHz
LORA_SF = int(os.environ.get("LORA_SF", "7"))               # 7..12
LORA_CR = int(os.environ.get("LORA_CR", "5"))               # 5->4/5, 6->4/6...
LORA_SYNC_WORD = int(os.environ.get("LORA_SYNC_WORD", "0x12"), 16)
LORA_TX_POWER_DBM = int(os.environ.get("LORA_TX_POWER_DBM", "14"))

# Pines (BCM)
PIN_RESET = 17
PIN_DIO0 = 25
PIN_DIO1 = 24

SPI_BUS = 0
SPI_DEV = 0
SPI_MAX_HZ = 8000000

SERIAL_PORT = os.environ.get("GPS_PORT", "/dev/serial0")
SERIAL_BAUD = 9600

# Temas MQTT
TOPIC_RX = f"gateway/{GATEWAY_ID}/rx"
TOPIC_TX = f"gateway/{GATEWAY_ID}/tx"
TOPIC_HB = f"gateway/{GATEWAY_ID}/heartbeat"

# --------------------
# Utilidades de tiempo
# --------------------
def now_iso():
    return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).isoformat()

# --------------------
# Driver SX1276/78
# --------------------
class SX127x:
    REG_FIFO = 0x00
    REG_OP_MODE = 0x01
    REG_FRF_MSB = 0x06
    REG_FRF_MID = 0x07
    REG_FRF_LSB = 0x08
    REG_PA_CONFIG = 0x09
    REG_LNA = 0x0C
    REG_FIFO_ADDR_PTR = 0x0D
    REG_FIFO_TX_BASE_ADDR = 0x0E
    REG_FIFO_RX_BASE_ADDR = 0x0F
    REG_FIFO_RX_CURRENT_ADDR = 0x10
    REG_IRQ_FLAGS_MASK = 0x11
    REG_IRQ_FLAGS = 0x12
    REG_RX_NB_BYTES = 0x13
    REG_PKT_SNR_VALUE = 0x19
    REG_PKT_RSSI_VALUE = 0x1A
    REG_MODEM_CONFIG1 = 0x1D
    REG_MODEM_CONFIG2 = 0x1E
    REG_SYMB_TIMEOUT_LSB = 0x1F
    REG_PREAMBLE_MSB = 0x20
    REG_PREAMBLE_LSB = 0x21
    REG_PAYLOAD_LENGTH = 0x22
    REG_MODEM_CONFIG3 = 0x26
    REG_FREQ_ERROR_MSB = 0x28
    REG_FREQ_ERROR_MID = 0x29
    REG_FREQ_ERROR_LSB = 0x2A
    REG_DETECTION_OPTIMIZE = 0x31
    REG_DETECTION_THRESHOLD = 0x37
    REG_SYNC_WORD = 0x39
    REG_DIO_MAPPING1 = 0x40
    REG_VERSION = 0x42

    # Modos
    LONG_RANGE_MODE = 0x80
    MODE_SLEEP = 0x00
    MODE_STDBY = 0x01
    MODE_TX = 0x03
    MODE_RX_CONT = 0x05

    # IRQ Flags
    IRQ_TX_DONE_MASK = 0x08
    IRQ_RX_DONE_MASK = 0x40
    IRQ_PAYLOAD_CRC_ERROR_MASK = 0x20

    def __init__(self, spi_bus=0, spi_dev=0, pin_reset=17, pin_dio0=25,
                 freq_hz=868100000, bw=125000, sf=7, cr=5, sync_word=0x12, tx_power=14):
        self.freq_hz = int(freq_hz)
        self.bw = int(bw)
        self.sf = int(sf)
        self.cr = int(cr)
        self.sync_word = int(sync_word)
        self.tx_power = int(tx_power)

        self.spi = spidev.SpiDev()
        self.spi.open(spi_bus, spi_dev)
        self.spi.max_speed_hz = SPI_MAX_HZ
        self.spi.mode = 0

        self.pin_reset = pin_reset
        self.pin_dio0 = pin_dio0

        GPIO.setmode(GPIO.BCM)
        GPIO.setwarnings(False)
        GPIO.setup(self.pin_reset, GPIO.OUT, initial=GPIO.HIGH)
        GPIO.setup(self.pin_dio0, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)

        self._reset()
        self._init_lora()

    def _reset(self):
        GPIO.output(self.pin_reset, GPIO.LOW)
        time.sleep(0.01)
        GPIO.output(self.pin_reset, GPIO.HIGH)
        time.sleep(0.01)

    def _read_reg(self, addr):
        resp = self.spi.xfer2([addr & 0x7F, 0x00])
        return resp[1]

    def _write_reg(self, addr, val):
        self.spi.xfer2([addr | 0x80, val & 0xFF])

    def _set_mode(self, mode):
        self._write_reg(self.REG_OP_MODE, self.LONG_RANGE_MODE | mode)

    def _set_frequency(self, freq_hz):
        frf = int(freq_hz / 61.03515625)  # Fstep = FXOSC / 2^19 = 32e6/2^19
        self._write_reg(self.REG_FRF_MSB, (frf >> 16) & 0xFF)
        self._write_reg(self.REG_FRF_MID, (frf >> 8) & 0xFF)
        self._write_reg(self.REG_FRF_LSB, frf & 0xFF)

    def _set_tx_power(self, dbm):
        # Usaremos PA_BOOST si el HAT lo soporta. 2 dB..17 dB típicos.
        # PA_CONFIG: [PA_SELECT:7][MAX_POWER:6..4][OUTPUT_POWER:3..0]
        # PA_SELECT=1 -> PA_BOOST
        pa_select = 0x80
        max_power = 0x70  # valor típico
        if dbm < 2: dbm = 2
        if dbm > 17: dbm = 17
        out_power = dbm - 2
        self._write_reg(self.REG_PA_CONFIG, pa_select | max_power | (out_power & 0x0F))

    def _set_lna(self):
        # LNA boost HF
        self._write_reg(self.REG_LNA, 0x23)

    def _set_bw_sf_cr(self, bw_hz, sf, cr):
        # BW codificación en RegModemConfig1 (bits 7..4)
        bw_map = {
            7800: 0x00, 10400: 0x10, 15600: 0x20, 20800: 0x30,
            31250: 0x40, 41700: 0x50, 62500: 0x60, 125000: 0x70,
            250000: 0x80, 500000: 0x90
        }
        bw_bits = bw_map.get(int(bw_hz), 0x70)  # default 125k
        cr_bits = {5: 0x02, 6: 0x04, 7: 0x06, 8: 0x08}.get(int(cr), 0x02)
        self._write_reg(self.REG_MODEM_CONFIG1, bw_bits | cr_bits | 0x00)  # explicit header

        # SF codificación en RegModemConfig2 (bits 7..4)
        if sf < 6: sf = 6
        if sf > 12: sf = 12
        self._write_reg(self.REG_MODEM_CONFIG2, ((sf << 4) & 0xF0) | 0x04)  # CRC on

        # LowDataRateOptimize si SF alto y BW bajo
        ldro = 0x08 if (sf >= 11 and bw_hz == 125000) else 0x00
        self._write_reg(self.REG_MODEM_CONFIG3, 0x04 | ldro)  # AGC auto + LDRO

        # Timeout simbólico para Single RX (no usado aquí)
        self._write_reg(self.REG_SYMB_TIMEOUT_LSB, 0x08)

    def _init_lora(self):
        # Verifica versión
        ver = self._read_reg(self.REG_VERSION)
        if ver == 0x00 or ver == 0xFF:
            raise RuntimeError("SX127x no responde por SPI")
        # Sleep -> LoRa -> Standby
        self._set_mode(self.MODE_SLEEP)
        time.sleep(0.01)
        self._set_mode(self.MODE_STDBY)
        time.sleep(0.01)

        # Frecuencia y parámetros
        self._set_frequency(self.freq_hz)
        self._set_tx_power(self.tx_power)
        self._set_lna()
        # FIFO bases
        self._write_reg(self.REG_FIFO_TX_BASE_ADDR, 0x00)
        self._write_reg(self.REG_FIFO_RX_BASE_ADDR, 0x00)
        # Sync Word
        self._write_reg(self.REG_SYNC_WORD, self.sync_word & 0xFF)
        # BW/SF/CR
        self._set_bw_sf_cr(self.bw, self.sf, self.cr)

        # Mapea DIO0: RxDone (00)
        self._write_reg(self.REG_DIO_MAPPING1, 0x00)

        # Mask IRQs no usados, deja RxDone y TxDone habilitados
        self._write_reg(self.REG_IRQ_FLAGS_MASK, 0x00)

        # RX continuo
        self._set_mode(self.MODE_RX_CONT)

    def read_packet(self, timeout_s=5.0):
        # Espera DIO0 alto (RxDone)
        start = time.time()
        while time.time() - start < timeout_s:
            if GPIO.input(self.pin_dio0) == GPIO.HIGH:
                flags = self._read_reg(self.REG_IRQ_FLAGS)
                self._write_reg(self.REG_IRQ_FLAGS, 0xFF)  # limpiar todo
                if flags & self.IRQ_RX_DONE_MASK:
                    if flags & self.IRQ_PAYLOAD_CRC_ERROR_MASK:
                        return None, {"crc_ok": False}
                    # Dirección del FIFO actual
                    curr = self._read_reg(self.REG_FIFO_RX_CURRENT_ADDR)
                    self._write_reg(self.REG_FIFO_ADDR_PTR, curr)
                    nbytes = self._read_reg(self.REG_RX_NB_BYTES)
                    data = []
                    for _ in range(nbytes):
                        data.append(self._read_reg(self.REG_FIFO))
                    # RSSI y SNR
                    snr_raw = self._read_reg(self.REG_PKT_SNR_VALUE)
                    snr = (snr_raw if snr_raw < 128 else snr_raw - 256) / 4.0
                    rssi_raw = self._read_reg(self.REG_PKT_RSSI_VALUE)
                    # HF port RSSI calc
                    rssi = -157 + rssi_raw
                    meta = {
                        "crc_ok": True,
                        "rssi_dbm": rssi,
                        "snr_db": snr,
                    }
                    return bytes(data), meta
                else:
                    # otras IRQs
                    continue
            time.sleep(0.005)
        return None, {"timeout": True}

    def transmit(self, payload: bytes, timeout_s=5.0):
        # Standby
        self._set_mode(self.MODE_STDBY)
        time.sleep(0.005)
        # Mapea DIO0: TxDone (01)
        current = self._read_reg(self.REG_DIO_MAPPING1)
        self._write_reg(self.REG_DIO_MAPPING1, (current & 0x3F) | 0x40)
        # Carga FIFO
        self._write_reg(self.REG_FIFO_ADDR_PTR, self._read_reg(self.REG_FIFO_TX_BASE_ADDR))
        for b in payload:
            self._write_reg(self.REG_FIFO, b)
        self._write_reg(self.REG_PAYLOAD_LENGTH, len(payload))
        # TX
        self._set_mode(self.MODE_TX)
        # Espera TxDone vía DIO0
        start = time.time()
        while time.time() - start < timeout_s:
            if GPIO.input(self.pin_dio0) == GPIO.HIGH:
                flags = self._read_reg(self.REG_IRQ_FLAGS)
                self._write_reg(self.REG_IRQ_FLAGS, 0xFF)
                if flags & self.IRQ_TX_DONE_MASK:
                    # Vuelve a RX continuo
                    self._write_reg(self.REG_DIO_MAPPING1, 0x00)
                    self._set_mode(self.MODE_RX_CONT)
                    return True
            time.sleep(0.002)
        # Timeout: vuelve a RX
        self._write_reg(self.REG_DIO_MAPPING1, 0x00)
        self._set_mode(self.MODE_RX_CONT)
        return False

    def close(self):
        try:
            self._set_mode(self.MODE_SLEEP)
            self.spi.close()
        finally:
            GPIO.cleanup((self.pin_reset, self.pin_dio0))

# --------------------
# Lector GPS
# --------------------
class GPSReader(threading.Thread):
    def __init__(self, port="/dev/serial0", baud=9600):
        super().__init__(daemon=True)
        self.ser = serial.Serial(port=port, baudrate=baud, timeout=1)
        self.lock = threading.Lock()
        self.last_fix = None  # dict con lat, lon, alt, timestamp
        self.running = True

    def run(self):
        while self.running:
            try:
                line = self.ser.readline().decode(errors="ignore").strip()
                if not line or not line.startswith("$"):
                    continue
                msg = pynmea2.parse(line)
                if msg.sentence_type in ("RMC", "GGA"):
                    fix = {}
                    fix["time_utc"] = now_iso()
                    if hasattr(msg, "latitude") and hasattr(msg, "longitude"):
                        lat = msg.latitude
                        lon = msg.longitude
                        if lat != 0.0 and lon != 0.0:
                            fix["lat"] = lat
                            fix["lon"] = lon
                    if hasattr(msg, "altitude") and msg.altitude:
                        try:
                            fix["alt"] = float(msg.altitude)
                        except Exception:
                            pass
                    with self.lock:
                        self.last_fix = fix
            except Exception:
                # Ignora errores de parsing intermitentes
                pass

    def get_fix(self):
        with self.lock:
            return dict(self.last_fix) if self.last_fix else None

    def stop(self):
        self.running = False
        try:
            self.ser.close()
        except Exception:
            pass

# --------------------
# MQTT Bridge
# --------------------
class LoRaMqttBridge:
    def __init__(self, lora: SX127x, gps: GPSReader):
        self.lora = lora
        self.gps = gps
        self.mq = MqttClient(client_id=f"{GATEWAY_ID}-client", clean_session=True)
        self.mq.on_connect = self._on_connect
        self.mq.on_message = self._on_message
        self.mq.will_set(TOPIC_HB, json.dumps({"gateway": GATEWAY_ID, "status": "offline", "ts": now_iso()}), qos=1, retain=True)

    def connect(self):
        self.mq.connect(MQTT_HOST, MQTT_PORT, MQTT_KEEPALIVE)
        self.mq.loop_start()

    def _on_connect(self, client, userdata, flags, reason_code, properties=None):
        print(f"[MQTT] Conectado ({reason_code}), suscribiendo a {TOPIC_TX}")
        client.subscribe(TOPIC_TX, qos=1)
        # Publica online/heartbeat
        hb = {"gateway": GATEWAY_ID, "status": "online", "ts": now_iso()}
        fix = self.gps.get_fix()
        if fix:
            hb["gps"] = fix
        client.publish(TOPIC_HB, json.dumps(hb), qos=1, retain=True)

    def _on_message(self, client, userdata, msg):
        try:
            payload = json.loads(msg.payload.decode())
            hex_str = payload.get("payload_hex")
            if not hex_str:
                print("[MQTT] Mensaje TX sin 'payload_hex'")
                return
            # Permite override de parámetros
            sf = int(payload.get("sf", self.lora.sf))
            bw = int(payload.get("bw", self.lora.bw))
            cr = int(payload.get("cr", self.lora.cr))
            freq = int(payload.get("freq_hz", self.lora.freq_hz))
            txp = int(payload.get("tx_power", self.lora.tx_power))
            # Reconfigura si cambian
            self.lora._set_mode(SX127x.MODE_STDBY)
            self.lora._set_frequency(freq)
            self.lora._set_bw_sf_cr(bw, sf, cr)
            self.lora._set_tx_power(txp)

            data = binascii.unhexlify(hex_str)
            ok = self.lora.transmit(data)
            print(f"[LoRa] TX {'OK' if ok else 'TIMEOUT'} ({len(data)} bytes)")
        except Exception as e:
            print(f"[MQTT] Error al procesar TX: {e}")

    def publish_rx(self, payload: bytes, meta: dict):
        fix = self.gps.get_fix()
        msg = {
            "gateway": GATEWAY_ID,
            "ts": now_iso(),
            "freq_hz": self.lora.freq_hz,
            "bw": self.lora.bw,
            "sf": self.lora.sf,
            "cr": self.lora.cr,
            "sync_word": self.lora.sync_word,
            "payload_hex": binascii.hexlify(payload).decode(),
            "rssi_dbm": meta.get("rssi_dbm"),
            "snr_db": meta.get("snr_db"),
        }
        if fix:
            msg["gps"] = fix
        self.mq.publish(TOPIC_RX, json.dumps(msg), qos=0, retain=False)

    def loop(self):
        try:
            while True:
                data, meta = self.lora.read_packet(timeout_s=1.0)
                if data and meta.get("crc_ok", False):
                    print(f"[LoRa] RX {len(data)} bytes | RSSI {meta.get('rssi_dbm'):.1f} dBm | SNR {meta.get('snr_db'):.1f} dB")
                    self.publish_rx(data, meta)
                # Heartbeat periódico (cada 30 s)
                if int(time.time()) % 30 == 0:
                    hb = {"gateway": GATEWAY_ID, "status": "online", "ts": now_iso()}
                    fix = self.gps.get_fix()
                    if fix:
                        hb["gps"] = fix
                    self.mq.publish(TOPIC_HB, json.dumps(hb), qos=0, retain=False)
                time.sleep(0.05)
        except KeyboardInterrupt:
            pass

    def close(self):
        try:
            self.mq.loop_stop()
            self.mq.disconnect()
        except Exception:
            pass

def main():
    # Manejo de señales para cerrar ordenadamente
    stop_event = threading.Event()

    def handle_sigterm(signum, frame):
        stop_event.set()

    signal.signal(signal.SIGTERM, handle_sigterm)

    gps = GPSReader(port=SERIAL_PORT, baud=SERIAL_BAUD)
    gps.start()

    lora = SX127x(spi_bus=SPI_BUS, spi_dev=SPI_DEV, pin_reset=PIN_RESET, pin_dio0=PIN_DIO0,
                  freq_hz=LORA_FREQ_HZ, bw=LORA_BW, sf=LORA_SF, cr=LORA_CR,
                  sync_word=LORA_SYNC_WORD, tx_power=LORA_TX_POWER_DBM)

    bridge = LoRaMqttBridge(lora=lora, gps=gps)
    bridge.connect()
    try:
        bridge.loop()
    finally:
        bridge.close()
        gps.stop()
        lora.close()

if __name__ == "__main__":
    main()

Explicación breve de partes clave

  • Inicialización SX127x:
  • Reset por GPIO17.
  • Entra en LoRa + Standby, configura frecuencia (FRF), potencia (PA_BOOST), LNA, FIFO base, SyncWord y ModemConfig1/2/3 según BW/SF/CR.
  • Mapea DIO0 a RxDone (recepción) y deja IRQs sin enmascarar.
  • Pasa a RX continuo.

  • Recepción:

  • Espera DIO0 alto; lee IRQ, verifica CRC, obtiene dirección actual de FIFO y número de bytes, extrae paquete, calcula SNR y RSSI (port HF en 868 MHz).
  • Devuelve bytes y metadatos.

  • Transmisión:

  • Standby, DIO0→TxDone, carga payload en FIFO, TX, espera DIO0, limpia IRQ y vuelve a RX continuo.

  • GPS:

  • Hilo con pyserial en /dev/serial0 a 9600 bps; parsea NMEA (RMC/GGA) con pynmea2 y guarda última fijación válida (lat, lon, alt, timestamp).

  • MQTT:

  • Conecta a broker local; publica heartbeat y metadatos GPS.
  • Publica uplinks LoRa en topic gateway//rx como JSON.
  • Suscribe a gateway//tx para downlinks; admite override de parámetros de radio por mensaje.

Compilación/ejecución: comandos exactos y ordenados

1) Verifica interfaces:
– SPI: ls -l /dev/spidev0.0
– UART: ls -l /dev/serial0

2) Arranca el broker MQTT local y comprueba:

sudo systemctl enable --now mosquitto
systemctl is-active mosquitto

3) Crea el proyecto y entorno Python (si no lo hiciste antes):

mkdir -p ~/projects/lora-mqtt-gateway-bridge
cd ~/projects/lora-mqtt-gateway-bridge
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip==24.2 setuptools==70.0.0 wheel==0.44.0
pip install paho-mqtt==2.1.0 pyserial==3.5 pynmea2==1.18.0 spidev==3.6 RPi.GPIO==0.7.1 gpiozero==1.6.2
nano gateway.py  # pega el código completo y guarda

4) Ejecuta el gateway:

source .venv/bin/activate
python gateway.py

Deberías ver logs tipo:
– [MQTT] Conectado (…) suscribiendo a gateway/zero2w-dragino/tx
– Heartbeat periódico y, si hay fijación GPS, publicará gateway//heartbeat con lat/lon.

5) Suscríbete en otra terminal para observar mensajes:

mosquitto_sub -h 127.0.0.1 -t "gateway/zero2w-dragino/#" -v

6) Prueba un downlink de ejemplo (desde otra terminal):

# Enviará "Hola LoRa" como hex a los nodos a 868.1 MHz, SF7, BW125, CR4/5, 14dBm
mosquitto_pub -h 127.0.0.1 -t "gateway/zero2w-dragino/tx" \
  -m '{"payload_hex":"486f6c61204c6f5261","sf":7,"bw":125000,"cr":5,"freq_hz":868100000,"tx_power":14}'

Observa en la consola del gateway una línea [LoRa] TX OK (si el SX127x confirma TxDone).

7) Publicación de paquetes RX (cuando reciba un nodo LoRa):
– Verás [LoRa] RX … en la consola y mensajes JSON en el topic gateway/zero2w-dragino/rx.

Validación paso a paso

1) Validación de hardware:
– Antena conectada al Dragino HAT antes de energizar.
– HAT firmemente acoplado al GPIO de 40 pines.

2) Validación de interfaces:
– SPI: /dev/spidev0.0 presente.
– UART: /dev/serial0 presente y consola serie deshabilitada en cmdline.
– Grupos: id muestra que tu usuario está en spi, tty y dialout.

3) Validación de GPS:
– minicom -b 9600 -o -D /dev/serial0 y verifica NMEA.
– Al correr el gateway, revisa el topic heartbeat:
– mosquitto_sub -t gateway/zero2w-dragino/heartbeat -v
– Debes ver un JSON con «status»:»online» y, tras unos minutos, campo «gps» con lat/lon (si hay vista de cielo).

4) Validación de MQTT:
– mosquitto_sub -t ‘#’ -v (solo para pruebas locales) para verificar publicaciones a gateway/zero2w-dragino/rx y /heartbeat.
– Enviar un downlink de prueba como en el paso de ejecución. Debes ver «TX OK» o «TIMEOUT». «OK» confirma que el SX127x disparó TxDone.

5) Validación de LoRa RX:
– Necesitas un emisor LoRa externo (otro nodo o HAT) configurado con los mismos parámetros (freq: 868.1e6, BW: 125k, SF: 7, CR: 4/5, SyncWord: 0x12).
– Envía una trama corta (p.ej., «01020304»). El gateway debe imprimir:
– [LoRa] RX 4 bytes | RSSI … | SNR …
– En MQTT, un JSON en gateway/zero2w-dragino/rx con payload_hex: «01020304» y metadatos.

6) Comprobación de estabilidad:
– Deja correr 10–15 minutos; debe publicar heartbeat cada 30 s y no mostrar errores. La memoria y CPU del Pi Zero 2 W deberían mantenerse bajos.

Troubleshooting (errores típicos y soluciones)

1) No existe /dev/spidev0.0
– Causa: SPI no habilitado.
– Solución: habilita con raspi-config (I4 SPI → Enable) o añade dtparam=spi=on en /boot/firmware/config.txt y reinicia.

2) El SX127x no responde por SPI (RuntimeError en init)
– Causas:
– HAT mal insertado o sin alimentación 3V3/5V.
– Pines CE0/SCK/MOSI/MISO reconfigurados por otro overlay.
– Daño físico en la línea SPI.
– Soluciones:
– Revisa el montaje.
– Asegúrate de no tener otros HATs/overlays en conflicto en /boot/firmware/config.txt.
– Verifica continuidad de pines y que CE0 (GPIO8) esté libre.

3) No aparece /dev/serial0 o el GPS no saca NMEA
– Causas:
– Consola por serie todavía activa en cmdline.txt.
– UART deshabilitado (enable_uart=0).
– Soluciones:
– Edita /boot/firmware/cmdline.txt para quitar console=serial0,115200 y en config.txt habilita enable_uart=1. Reboot.
– Comprueba que el HAT está bien asentado y, si procede, prueba minicom.

4) PermissionError en acceso a SPI o UART
– Causa: usuario no pertenece a grupos spi, tty, dialout.
– Solución: sudo usermod -aG spi,tty,dialout $USER y reinicia sesión o el sistema.

5) “TX TIMEOUT” en el gateway
– Causas:
– DIO0 no mapeado correctamente a TxDone.
– DIO0 no está cableado a GPIO25 o GPIO25 no cambia por pull-ups.
– Soluciones:
– Verifica que REG_DIO_MAPPING1 se establece en 0x40 antes de TX y 0x00 en RX.
– Comprueba con un multímetro u osciloscopio que el pin DIO0 del HAT llega al GPIO25.

6) No hay paquetes RX aunque hay un nodo TX
– Causas:
– Par ámetros de radio desalineados: frecuencia, SF, BW, CR, SyncWord.
– Nodo usa LoRaWAN (cifrado y canalización) y gateway no lo entiende tal cual.
– Soluciones:
– Alinea exactamente freq/BW/SF/CR/SyncWord en ambos lados.
– Si es LoRaWAN, usa stack apropiado (p.ej., ChirpStack) y un forwarder compatible; este gateway es para LoRa “raw”.

7) MQTT no recibe ni publica
– Causas:
– Mosquitto no en ejecución, firewall, o bind externo.
– Soluciones:
– systemctl status mosquitto.
– Revisa /etc/mosquitto/mosquitto.conf; por defecto escucha en 0.0.0.0:1883 en Debian. Si cambiaste configuración, ajusta MQTT_HOST/MQTT_PORT.

8) El GPS no fija posición (sin lat/lon)
– Causas:
– Antena o ubicación sin vista de cielo, tiempo insuficiente después de arranque en frío.
– Soluciones:
– Coloca el equipo cerca de una ventana o en exterior; espera varios minutos.
– Verifica que recibes NMEA (aunque sin fix al principio).

Mejoras/variantes

  • Seguridad MQTT:
  • TLS y autenticación con usuario/contraseña.
  • Certificados (server y cliente) y cifrado de publicaciones.

  • Integración con plataformas IoT:

  • Bridge a un broker externo (ej. AWS IoT Core, Eclipse Mosquitto remoto, EMQX).
  • Normalización del payload en JSON con esquema fijo.

  • Persistencia:

  • Cola local (SQLite) para tolerar fallos de conectividad MQTT; reintento en segundo plano.

  • Supervisión:

  • Exportar métricas Prometheus (CPU, memoria, contadores RX/TX, RSSI medio, SNR medio).
  • Healthchecks en topic heartbeat con más detalles (uptime, versión software, etc.).

  • Servicio de sistema:

  • systemd unit para iniciar el gateway al boot.
  • watchdog para reiniciar el proceso en caso de fallo.

  • Multi-parámetro y regiones:

  • Escaneo de múltiples frecuencias (ciclando FRF).
  • Soporte de varias SF y BW con varios ciclos de escucha (compromiso en sensibilidad/latencia).

  • Cumplimiento normativo:

  • Duty cycle por región (p.ej., 1% en EU868) aplicado a transmisiones (downlink) con temporización para no violar normativa.

  • GPS avanzado:

  • Uso del PPS y gpsd para registros temporales más precisos.
  • Inclusión de HDOP/VDOP y número de satélites en metadatos.

Checklist de verificación

  • [ ] Raspberry Pi Zero 2 W con Raspberry Pi OS Bookworm 64‑bit operativo.
  • [ ] Dragino LoRa/GPS HAT montado y antena conectada.
  • [ ] SPI habilitado (ls /dev/spidev0.0).
  • [ ] UART habilitado y consola serie deshabilitada (ls /dev/serial0; sin “console=serial0,115200” en cmdline).
  • [ ] Usuario en grupos spi, tty y dialout (id).
  • [ ] Mosquitto activo (systemctl is-active mosquitto).
  • [ ] Entorno virtual Python creado con versiones fijadas (pip freeze revisado).
  • [ ] gateway.py creado y ejecutándose sin errores.
  • [ ] Heartbeat publicado en MQTT (mosquitto_sub en gateway//heartbeat).
  • [ ] Downlink de prueba enviado vía mosquitto_pub y “TX OK” observado.
  • [ ] Uplink LoRa verificado con un nodo emisor compatible y mensajes en gateway//rx.

Notas finales importantes

  • Mantén siempre conectada la antena al HAT Dragino antes de energizar y durante cualquier transmisión LoRa para evitar daños en el PA.
  • Ajusta frecuencia y potencia conforme a la normativa de tu país/región.
  • Este proyecto implementa un bridge “raw LoRa” a MQTT, no un gateway LoRaWAN. Para LoRaWAN, integra con stacks/forwarders específicos (p.ej., Semtech UDP packet forwarder o BasicStation) y servidores de red (p.ej., ChirpStack), lo cual requiere otra arquitectura y parámetros (canales, SFs múltiples, sincronización de tiempo, etc.).

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es el objetivo principal del proyecto mencionado?




Pregunta 2: ¿Qué sistema operativo se utilizará en el proyecto?




Pregunta 3: ¿Qué núcleo de Linux se debe usar?




Pregunta 4: ¿Qué versión de Python se utilizará en el entorno virtual?




Pregunta 5: ¿Cuál de los siguientes paquetes no se menciona como necesario?




Pregunta 6: ¿Qué servicio se utilizará como broker MQTT?




Pregunta 7: ¿Qué tipo de antena se necesita para el proyecto?




Pregunta 8: ¿Qué tipo de conectividad es necesaria para el Raspberry Pi Zero 2 W?




Pregunta 9: ¿Qué se necesita para asegurar la coherencia de las librerías?




Pregunta 10: ¿Qué protocolo se utiliza para la comunicación entre LoRa y el gateway?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:
Scroll al inicio