A Raspberry Pi LoRa MQTT receiver is the simplest way to collect data from sensors that are too far away for Wi-Fi: a field, a farm, a car park or the far end of a large site. In this tutorial you will connect an SX127x-based radio (RFM95W, RFM96W or a LoRa bonnet) to a Pi, receive point-to-point packets from your own senders and republish each one as JSON to an MQTT broker. You will start with broker.connectmq.com and finish with a production-ready setup.
Point-to-Point LoRa vs LoRaWAN
LoRa is a radio modulation; LoRaWAN is a full network protocol built on top of it, with device registration, encryption keys, a network server and multi-channel gateways. If you already run a LoRaWAN network, your network server publishes uplinks to MQTT and you want the ChirpStack MQTT integration instead.
Point-to-point LoRa skips all of that. Your sender (often an ESP32 or Arduino with an SX1276 module) transmits a small packet on a fixed frequency with fixed settings, and a single-channel receiver hears it. It is easy to understand and fully under your control, which makes it a good fit for hobby projects, prototypes and private sensor networks where you own both ends. The trade-off is that you handle addressing, integrity checks and duty cycle limits yourself.
Hardware
| Item | Notes |
|---|---|
| Raspberry Pi | Any model with the 40-pin header; a Zero 2 W is enough |
| SX127x radio | Adafruit LoRa Radio Bonnet, RFM95W breakout or similar SPI module |
| Antenna | Tuned for your band; never transmit without one |
| Sender node | ESP32, Arduino or a second Pi with a matching radio |
| Frequency | 868 MHz in Europe, 915 MHz in North America, 433 MHz in some regions; follow local rules |
Wiring for a breakout follows the SPI pins: SCK to GPIO 11, MOSI to GPIO 10, MISO to GPIO 9, CS to CE1 (GPIO 7), RESET to GPIO 25, plus 3.3 V and ground. The Adafruit bonnet uses exactly those pins, which is why the code below defaults to them.
Install the Libraries
Enable SPI with sudo raspi-config (Interface Options, SPI), then create an environment with Adafruit Blinka, which provides the board and busio modules on Linux, and the RFM9x driver:
sudo raspi-config nonint do_spi 0
ls /dev/spidev*
python3 -m venv ~/lora/.venv
source ~/lora/.venv/bin/activate
pip install adafruit-blinka adafruit-circuitpython-rfm9x "paho-mqtt>=2.0"
The Blinka installation guide on the Adafruit site lists extra steps for some Pi models, for example installing lgpio on newer releases. If import board fails, follow that guide before going further.
Match the Radio Settings
A LoRa receiver only hears packets sent with the same frequency, spreading factor, bandwidth, coding rate, sync word and preamble. A mismatch does not produce errors; it produces silence. Write the settings down once and use them on both sides.
| Setting | Example value | Notes |
|---|---|---|
| Frequency | 868.1 MHz | Must be legal in your region |
| Spreading factor | 7 | Higher means longer range, slower airtime |
| Bandwidth | 125 kHz | Common default |
| Coding rate | 4/5 | Set as 5 in the driver |
| CRC | On | Enable on sender and receiver |
| Header | Depends on sender | See the RadioHead note below |
The RadioHead header pitfall. The Adafruit RFM9x driver follows the RadioHead convention and, by default, strips a 4-byte header (to, from, id, flags) from received packets. Senders using the Arduino LoRa library by Sandeep Mistry do not add that header, so the first four bytes of your payload silently disappear. Call receive(with_header=True) when your sender does not use RadioHead, as the script below does.
The Raspberry Pi LoRa MQTT Script
The sender in this example transmits a short text payload like n1,21.4,63,3.92 (node ID, temperature, humidity, battery volts). The receiver validates it, adds radio metadata and publishes to a per-node topic. Unknown or malformed packets go to a separate raw topic for debugging rather than being dropped silently.
import json
import os
import time
import uuid
import adafruit_rfm9x
import board
import busio
import digitalio
import paho.mqtt.client as mqtt
BROKER = os.getenv("MQTT_HOST", "broker.connectmq.com")
PORT = int(os.getenv("MQTT_PORT", "1883"))
USERNAME = os.getenv("MQTT_USERNAME")
PASSWORD = os.getenv("MQTT_PASSWORD")
CLIENT_ID = os.getenv("MQTT_CLIENT_ID", f"lora-rx-{uuid.uuid4().hex[:8]}")
PREFIX = os.getenv("TOPIC_PREFIX", "connectmq/demo/l9r3x5/lora")
FREQ_MHZ = float(os.getenv("LORA_FREQ", "868.1"))
spi = busio.SPI(board.SCK, MOSI=board.MOSI, MISO=board.MISO)
cs = digitalio.DigitalInOut(board.CE1)
reset = digitalio.DigitalInOut(board.D25)
rfm9x = adafruit_rfm9x.RFM9x(spi, cs, reset, FREQ_MHZ)
rfm9x.spreading_factor = 7
rfm9x.signal_bandwidth = 125000
rfm9x.coding_rate = 5
rfm9x.enable_crc = True
def on_connect(client, userdata, flags, reason_code, properties):
print("MQTT connected:", reason_code)
client.publish(f"{PREFIX}/receiver/status", "online", qos=1, retain=True)
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID)
if USERNAME:
client.username_pw_set(USERNAME, PASSWORD)
if PORT == 8883:
client.tls_set()
client.will_set(f"{PREFIX}/receiver/status", "offline", qos=1, retain=True)
client.reconnect_delay_set(1, 60)
client.on_connect = on_connect
client.connect_async(BROKER, PORT, keepalive=60)
client.loop_start()
def parse(text):
parts = text.split(",")
if len(parts) != 4 or not parts[0].startswith("n"):
return None
node, temp, hum, vbat = parts
return node, {"temperature": float(temp), "humidity": float(hum), "vbat": float(vbat)}
print(f"listening on {FREQ_MHZ} MHz")
try:
while True:
packet = rfm9x.receive(timeout=5.0, with_header=True)
if packet is None:
continue
meta = {"rssi": rfm9x.last_rssi, "snr": rfm9x.last_snr, "ts": int(time.time())}
try:
parsed = parse(packet.decode("ascii").strip())
except (UnicodeDecodeError, ValueError):
parsed = None
if parsed:
node, values = parsed
client.publish(f"{PREFIX}/{node}", json.dumps({**values, **meta}), qos=1, retain=True)
print(node, values, meta)
else:
client.publish(f"{PREFIX}/raw", json.dumps({"hex": packet.hex(), **meta}), qos=0)
except KeyboardInterrupt:
pass
finally:
client.publish(f"{PREFIX}/receiver/status", "offline", qos=1, retain=True)
client.loop_stop()
client.disconnect()
On an ESP32 sender using the Arduino LoRa library, the matching configuration is LoRa.begin(868.1E6), LoRa.setSpreadingFactor(7), LoRa.setSignalBandwidth(125E3), LoRa.setCodingRate4(5) and LoRa.enableCrc(). Then send with LoRa.beginPacket(); LoRa.print("n1,21.4,63,3.92"); LoRa.endPacket();.
Watch the results from any machine:
mosquitto_sub -h broker.connectmq.com -p 1883 -i lora-viewer-l9r3x5 -t "connectmq/demo/l9r3x5/lora/#" -v
Making Packets Robust
Plain text is easy to debug but wastes airtime, and airtime is the scarce resource in LoRa. Once the link works, consider these improvements:
- Binary payloads. Pack values with fixed scaling, for example temperature as int16 in hundredths. A 4-field reading drops from around 16 bytes to 7, which shortens airtime and improves battery life.
- Node ID and sequence number. A sequence counter lets the receiver detect missed packets and duplicates, and it lets you report packet loss per node as its own metric.
- Integrity. The radio CRC catches corruption. If neighbours might transmit on the same settings, add a short identifier or an HMAC so you ignore packets that are not yours.
- Duty cycle. Many regions restrict how long a device may transmit per hour on a sub-band. Long spreading factors and frequent sends can exceed it quickly, so keep payloads small and intervals reasonable.
- Receiver health. Publish a heartbeat from the receiver and track the last-seen time per node, so a dead sender and a dead receiver look different on your dashboard.
The SNR and RSSI values published with every packet are your best antenna-placement tool. Positive SNR means a comfortable link; values approaching the sensitivity limit for your spreading factor mean you are near the edge of range.
Running It Unattended
A receiver on a barn wall or a mast must survive power cuts and network drops. Run the script as a systemd service so it restarts automatically, store readings locally when the uplink is down with an offline MQTT buffer in SQLite, and follow the Raspberry Pi reliability tips for SD card wear and power supplies. For remote sites over cellular, MQTT is a good fit because the connection is outbound only and the protocol overhead is small.
Moving Off the Test Broker
The public MQTT test broker is perfect for getting the radio link right, but everything published there is visible to anyone. For a real deployment, create a broker on ConnectMQ, take the generated username, password and client ID from the portal and start the receiver with MQTT_PORT=8883 and those values in the environment. TLS uses a Let's Encrypt certificate, so tls_set() works with the Pi's default CA store. A cloud MQTT broker also lets several receivers at different sites publish into one topic tree that your dashboards and alert rules can read in one place.
New to MQTT on the Pi? The Raspberry Pi MQTT tutorial covers the fundamentals used here, and the Raspberry Pi IoT gateway guide shows how a LoRa receiver can sit alongside BLE, Modbus and other inputs on one device.
Send Your LoRa Data to ConnectMQ
Create a free Serverless broker at app.connectmq.com, with no credit card required, and you will have TLS credentials for your receiver in minutes. ConnectMQ's live topic explorer shows every node as it reports, and threshold alert rules can flag a temperature or battery value that crosses a limit.
Frequently Asked Questions
Can a single SX127x receiver act as a LoRaWAN gateway?
Not properly. LoRaWAN gateways listen on several channels and spreading factors at once using concentrator chips such as the SX1302. A single SX127x hears one configuration at a time, which suits point-to-point use.
What range can I expect?
Range depends heavily on antenna height, terrain and spreading factor. Line of sight with elevated antennas reaches far further than through buildings, so measure RSSI and SNR on site.
Why does my receiver get packets with missing bytes?
Almost always the RadioHead header mismatch. Use receive(with_header=True) when the sender does not add the 4-byte RadioHead header.
Can the Pi send commands back to the nodes?
Yes, the RFM9x driver can transmit. Subscribe to a command topic and call rfm9x.send(), but remember the nodes must be listening at that moment, which battery-powered senders usually are only briefly after transmitting.



