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



