Building a LoRa Sensor Gateway on a Raspberry Pi
Radio in, JSON out, one reading at a time.
In 2016 I needed something that could hear sensors over LoRa and forward what they said to a server on the internet. The result was Lora_GW, a handful of Python files that run on a Raspberry Pi. Its git history runs from August 2016 to March 2017. This post walks through what the code actually does, including the parts I would write differently today.
What LoRa gives you, and what it doesn't
LoRa is a long-range, low-power radio. A sensor can send a few bytes a long way on very little battery, which is perfect for things like a water tank or an energy meter. What it does not give you is the internet. Something has to receive those packets and pass them on. That something is the gateway.
Two kinds of radio, one config file
The README says the target is a Raspberry Pi running Raspbian, and the code is Python 2. The script supports two very different radios, and you choose between them in lora.conf:
- a DORJI 1276 module, a single LoRa radio that talks to the Pi over a serial port and signals new data on an AUX pin;
- an IMST iC880A concentrator, a proper multi-channel gateway board, which in this setup was configured to send its received packets to the Pi on a network port (port 1680 in the checked-in config).
The config file has three sections the code reads: Connection (the serial device or the port number), Configuration (MODULE, MODE and AUX_PIN) and Testing (how many test packets to send). MODE picks one of three jobs: LISTEN, PING or CONFIG.
Step 1: Talk to the radio
SerialConnection.py wraps the pyserial library. For the DORJI module it opens the port at 9600 baud and offers two ways to read: whole lines for data, and single bytes for command replies.
RF_TestBed.py then watches the AUX pin with RPi.GPIO. Instead of polling the serial port in a loop, it registers a callback on the pin's falling edge. When the module pulls AUX low, the callback reads a line. The Pi does nothing until the radio has something to say.
Step 2: Read the radio's settings
In CONFIG mode the script sends the DORJI module a fixed 25-byte command and reads back a 25-byte reply. Each byte position means something: baud rate, parity, frequency, spreading factor, mode, bandwidth, device ID, network ID and transmit power. The code keeps small lookup tables to turn those codes into readable values, so spreading factor 7 to 12 and bandwidth codes become numbers you can check at a glance.
The frequency is the nice bit. It arrives as three bytes, which the code joins into one number and multiplies by 61.035 Hz. That figure matches the frequency step of Semtech's SX1276 radio chip (a 32 MHz crystal divided by 2^19), so the result is the carrier frequency in MHz.
There is also a setValues function that patches the device ID and network ID into that reply, recomputes a checksum and writes it back as a write command.
Step 3: Listen to the concentrator
With the iC880A, the Pi does not touch the radio at all. SocketListener.py opens a TCP server on the configured port and starts a new thread for every connection. Each thread reads up to 256 bytes and hands them to a cleaning function.
That function pulls out the first {...} block with a regular expression and parses it as JSON. Each packet carries a type field and numbered values D1, D2 and so on. A table maps each type to the names of its readings:
- ENERGY: voltage and current (the table also has slots for frequency, power factor and active, reactive and apparent power and energy, but the count field says to send only the first two);
- WATER: water level and water temperature;
- ENV: NO2, CO2, outside temperature, humidity and pressure (with noise listed but not sent).
Every reading goes out as its own small JSON document, one per sensor stream, through an HTTP PUT in HttpService.py.
Step 4: Turn raw numbers into physics
The water sensor got special treatment, and the code admits it with a log line that calls it a dirty hack. The temperature arrives as a raw ADC count. The code converts it to a voltage on a 3.3 V scale over 16384 counts, works out a thermistor's resistance from a divider with a 10 kΩ resistor, and then aims for the thermistor's beta equation, with B = 3950 and 25 °C as the reference, to get degrees Celsius.
Then it uses that temperature to correct the water level. The speed of sound in air depends on temperature, roughly 20.05 times the square root of the absolute temperature in metres per second, and the code uses exactly that to convert the level sensor's raw reading. It is a small touch, but it is what separates a reading from a measurement.
Step 5: Test the link
PING mode is meant to measure the radio link. It sends a test message, waits up to ten seconds for a reply, and repeats for the number of packets in the config, then logs how many came back and the percentage lost.
What I would fix today
Reading old code honestly is the most useful part of writing about it.
- Secrets in the repository. The server's access token sits in plain text in both the config file and the HTTP code. Today it would live in an environment variable or a secrets file that never gets committed.
- Python 2 integer division. The packet-loss line divides two integers before multiplying by 100. In Python 2 that rounds down to zero, so the loss reads 0% unless every single packet is lost.
- A ping that can't send its text. The transmit function converts the message to an integer before writing it, and the test message is the string "LORA Tx", so as written the ping would fail with an error.
- The wrong logarithm. The beta equation needs a natural logarithm, but the code calls
log10, so the computed water temperature (and the level correction built on it) drifts from the true value away from 25 °C. One function name fixes it. - Per-sensor formulas in the middle of the network code. The water maths lives inside the socket handler. A small table of conversion functions per sensor type would keep the gateway generic.
- Python 2 itself.
except Exception, e, thethreadmodule andConfigParserall date it. A port to Python 3 is mostly mechanical.
None of this stopped it from doing its job: hear a sensor, make sense of its bytes, and get the numbers onto a server. That is most of what a gateway is.