Field Automation System
Introduction
Built a mesh network using 920 MHz LoRa, integrating battery-powered sensors and relay controllers into a single network, with centralized monitoring and control through OpenRemote.
This article explains the roles of each device, communication paths throughout the network, and how the LoRa network connects to OpenRemote, including the source code.
Every device in the system uses the same STM32F103C8 (Blue Pill) and DX-LR30, so the firmware is shared across all of them. Each device’s role is determined automatically at boot, based on jumper wiring, additional components, and whether an ESP32 is present.
Relay commands (Gateway → Actuator) are authenticated with a SipHash-2-4 signature and a single-use nonce issued by the Actuator. Details are in the “Command Authentication” section.
System Overview
- Uplink: Sensor data reaches the Gateway, via a Repeater if needed, and the ESP32-C6 publishes it to the broker over MQTTS.
- Downlink: The Gateway receives relay commands published to the broker and sends them to the Actuator over LoRa.
Common Hardware
The following parts are common to all devices.
| Part | Role |
|---|---|
| STM32F103C8 (Blue Pill) | Mesh control, sleep management |
| DX-LR30 (SX1262) | 920 MHz LoRa transmit/receive |
The RF settings are 920 MHz, SF9, BW 125 kHz, CR 4/5, and +13 dBm, and are identical on all devices.
Device Types and Roles
A device’s role is determined by the components and wiring added to it.
| Role | Added hardware | How it is determined | Purpose |
|---|---|---|---|
| Node | BMI160 (accelerometer/gyroscope) | No jumper (default) | Battery-powered sensor |
| Repeater | None | PB0 → GND | Relay/forwarding |
| Actuator | Relay, feedback input | PB1 → GND | Relay output and status reporting |
| Gateway | ESP32-C6 | ESP32 responds to the UART PING at boot | Bridge between the mesh and MQTT |
Node (Sensor Node)
Attach a sensor module that supports I2C and is suited to battery operation. This time I connected a BMI160 accelerometer module, which lets me raise the data acquisition rate manually. Rewrite the code to match whichever sensor you use. In normal operation the node waits in STOP mode. It wakes on the BMI160 Any-Motion interrupt (INT1) or on a periodic heartbeat, then transmits with ACK and retries. The default heartbeat interval is 3600 seconds. Because a sensor node can only receive immediately after it transmits, it does not support downlink commands.
Repeater
The Repeater listens continuously and forwards sensor packets (MeshPacket) with a hop limit and duplicate suppression. It does not forward relay commands or their status responses.
The Actuator therefore must be placed within direct communication range of the Gateway.
Actuator (Relay Output)
The Actuator listens continuously and has a relay output (PB4) and a feedback input (PB5) that reads the physical state. It reports two independent values in its status:
commandedState: The output level instructed by the GatewayfeedbackPinState: The actual HIGH/LOW level of the feedback pin
The Actuator verifies the tag and nonce of each received command and operates the relay only if both are correct. Each status message also carries the single-use nonce required for the next command.
Gateway
The Gateway is an STM32 + DX-LR30 with an ESP32-C6 attached. The STM32 receives mesh packets, returns ACKs, and forwards the packets to the ESP32-C6 over UART (frames with CRC8). The ESP32-C6 side is implemented with ESP-IDF and is responsible for the following:
- Wi-Fi connection and time synchronization via SNTP
- Publish/Subscribe over MQTTS (TLS)
- UART bridge to the STM32
- Holding the Actuator’s nonce and signing relay commands (SipHash-2-4)
For wiring, connect the STM32’s PB10 (TX)/PB11 (RX) crosswise to the ESP32-C6’s UART and share GND. The reset line is not connected.
For a relay command, the ESP32-C6 receives it from MQTT, builds a frame with the Actuator’s nonce and tag, and passes it to the STM32 over UART. The STM32 neither signs nor verifies anything; it simply puts the frame on LoRa and forwards it.
Connecting to OpenRemote
I will cover the operation of OpenRemote using Docker containers in a separate blog post.
MQTT Topic Design
<prefix> is the topic prefix configured in the setup portal.
| Direction | Topic | Content |
|---|---|---|
| Publish | <prefix>/<node_id>/data |
Heartbeat/motion JSON (RSSI, SNR, acceleration, gyro, etc.) |
| Publish | <prefix>/<node_id>/status |
commanded_on, feedback_pin_high, last_cmd_seq_applied, etc. |
| Subscribe | <prefix>/+/cmd/relay |
ON or 1 turns the relay ON; anything else turns it OFF |
Messages are published with QoS 0.
last_cmd_seq_applied is the nonce of the command that was applied. Its value changes every time a command is applied.
Broker Configuration
The setup uses a Mosquitto instance on the same host as OpenRemote, with TLS terminated by HAProxy (all running as Docker containers).
ESP32-C6 --TLS 8884–> HAProxy (TLS termination) --plaintext 1883–> Mosquitto (password auth + ACL)
I leave OpenRemote’s standard port 8883 untouched and add a listen block for 8884 to haproxy.cfg.
With a certificate from a public CA (such as Let’s Encrypt), the ESP32’s built-in CA bundle can verify it, so no certificate file needs to be placed on the device, and no firmware update is needed when the certificate is renewed.
Points to note:
- TLS verification includes checking the expiry date, so synchronize the time via SNTP before starting MQTT.
- The broker’s hostname must match the certificate’s CN/SAN (verification does not work with an IP address).
- Do not expose Mosquitto’s port 1883 on the host; make it reachable only from the proxy within the Docker network.
Create a dedicated Mosquitto user for the Gateway and restrict it to the minimum required using an ACL.
user gw01
topic write lora/+/data
topic write lora/+/status
topic read lora/+/cmd/relay
Command Authentication (SipHash-2-4 + nonce)
The LoRa link itself provides neither encryption nor authentication. Without countermeasures, a third party within radio range could forge relay commands or replay commands they have intercepted. To address this, I added authentication using a signature and a nonce to the Gateway → Actuator relay commands.
How It Works
- The Actuator holds a single-use nonce (4 bytes) and includes it in every status message (
StatusPacket). - The ESP32-C6 remembers the latest nonce for each Actuator from its status messages.
- When it receives a
cmd/relaymessage over MQTT, the ESP32-C6 inserts the stored nonce, signs the command with SipHash-2-4 (producing an 8-byte tag), and passes it to the STM32 over UART. - The STM32 Gateway neither signs nor verifies the command; it sends it over LoRa as-is.
- The Actuator operates the relay only if the tag is correct and the nonce matches its current one. Immediately afterward it renews the nonce and reports it together with the resulting status.
The signature covers the 10 bytes from magic0 to cmdValue (little-endian). Because this includes the destination, the nonce, and the command value, altering any of them invalidates the tag.
Packet Sizes
| Segment | Size |
|---|---|
| UART downlink (ESP32→STM32) | 19 bytes: CC 33 target(2) type value nonce(4) tag(8) CRC8 (CRC8 covers target through tag) |
LoRa CmdPacket |
19 bytes: adds tag[8]. The cmdSeq field carries the nonce issued by the Actuator |
LoRa StatusPacket |
19 bytes: adds nonce (4 bytes) |
Nonce Renewal
The Actuator renews its nonce at the following three points:
- After applying a command (at the same time as the resulting status)
- Just before each periodic status message, sent every 60 seconds
- Just before the first status message at boot
The nonce is generated by hashing the previous nonce, a counter, micros(), the RSSI, and the ADC value with the shared key.
Effects
- If an intercepted “ON” is replayed after the relay has been turned OFF, it is not applied, because the nonce has already been renewed.
- If the same packet is resent (retried) right after a command was applied, it is not applied again. Only the status is returned, so the Gateway can confirm the result.
- A signed command that never arrived expires after at most 60 seconds.
- Any alteration of the value, destination, or tag, as well as a mismatched key, is rejected.
Key Management
Signing uses a 16-byte shared key. The ESP32-C6 and the Actuator must be set to the same value.
- STM32 side: Embedded at build time via
stm32_dx-lr30/src/mesh_secret.h. Do not commit this file (add it to.gitignore). - ESP32-C6 side: Enter a 32-character hex string in the “Relay command key” field of the setup portal; it is stored in NVS. It is not included in the firmware or in
sdkconfig. - Key generation:
gen_mesh_key.pygenerates a random key, writesmesh_secret.h, and prints the hex string to enter on the ESP32-C6.
$ python3 gen_mesh_key.py # Generate a new key (does not overwrite an existing one)
$ python3 gen_mesh_key.py --force # Regenerate the key (the ESP32 and all STM32 devices must be updated again)
If the key is not set on the ESP32-C6 or is invalid, relay commands are disabled and a guidance message is logged. Sensor data forwarding continues to work as usual.
Gateway Setup
Wi-Fi and MQTT settings are not embedded in the firmware. They are entered through a web form served by the Gateway in AP mode and saved to NVS.
- The Gateway enters setup mode on first boot, when the Wi-Fi connection at boot fails for 30 seconds, or when the BOOT button is held for 5 seconds while running.
- Connect a smartphone or PC to
LoRa-GW-XXXX(the password is the value set in Kconfig; change the default). - Open
http://192.168.4.1/. There is no captive portal DNS, so enter the address manually. - Enter the SSID, Wi-Fi password, broker URI starting with
mqtts://, MQTT username and password, topic prefix, and relay command key (32-character hex), then save and reboot.
As with the passwords, leaving the key field blank keeps the stored value. Anything other than a 32-character hex string cannot be saved. The portal is plain HTTP over a WPA2 AP, so perform the setup when no one else is nearby.
If Wi-Fi drops while running, the Gateway does not enter setup mode; it keeps trying to reconnect.
Current Limitations and Cautions
- The radio is half-duplex, so packets may be missed while the Gateway is transmitting or waiting for the Actuator’s response (recovered by the Node’s ACK/retry).
- MQTT uses QoS 0, and data is discarded while disconnected (no store-and-forward). Commands published while the Gateway is offline are lost, and there is no Last Will-based liveness topic.
- Authentication covers only the Gateway → Actuator relay commands. Sensor data (uplink) and the Actuator’s status are unsigned and can be forged. Nothing is encrypted either, so the content can be read by anyone intercepting the radio signal.
- The key is stored in plaintext in NVS (anyone who obtains the board can read it unless NVS encryption is enabled). On the STM32 side, all devices run the same firmware, so the same key is in the Flash of every device. If one is obtained, the key for the entire system is exposed.
- Right after an ESP32-C6 reboot or right after a command fails, the Actuator’s nonce is unknown, so commands are held until the next status message (up to 60 seconds). Normally the next nonce arrives with the resulting status, so consecutive operations go through immediately.
- Even with authentication, do not use this for door locks, safety-critical loads, or unattended switching of mains power.
- The 920 MHz band is subject to regulation (in Japan, ARIB STD-T108: certified modules, carrier sense, and transmission time limits). Check the rules in your region.
More Details
Check the below URL
https://forum.ficusonline.com/t/topic/568/14