Who this is for
Any Linux box running a plain Semtech packet forwarder (UDP, port 1700-style) pointed at a remote server: Helium hotspots and their multi-mining stacks, RAK, Dragino, Mikrotik, a Raspberry Pi with a concentrator HAT. If your gateway runs ChirpStack Gateway Bridge instead, there is a local MQTT broker and a different agent applies.
You also need one Sensmos node on the same LAN as the gateway (any ESP32 with the firmware, no radio required) and its PIN. The node vouches for the gateway; without it the backend will not issue a token.
Why passive, not a relay
The obvious way to tap a forwarder is to put something in the middle — point the forwarder at us and forward onwards. We deliberately do not. On hardware that earns, our outage would become your outage. The forwarder already sends every frame to each configured server separately over loopback; the script just reads along on lo. It is nobody's target, relays nothing and delays nothing.
Quick start
Run all of this on the gateway, not on your laptop. Python 3 only — no pip packages, nothing to build. Root is needed because reading raw packets requires CAP_NET_RAW.
# 1 — install the script
sudo curl -fsSL -o /usr/local/bin/sensmos-gw-udp.py https://raw.githubusercontent.com/Galusz/sensmos-gateway/main/sensmos-gw-udp.py
sudo chmod +x /usr/local/bin/sensmos-gw-udp.py
# 2 — see what would leave your box (sends nothing, needs no pairing)
sudo sensmos-gw-udp.py --dry-run
# 3 — find a Sensmos node on this network (prints the exact pair command)
sudo sensmos-gw-udp.py --find
# 4 — pair with it (address from step 3, PIN = the node's PIN)
sudo sensmos-gw-udp.py --pair NODE_IP --pin YOUR_PIN --name "rooftop RAK"
# 5 — install & start it as a boot-persistent service
sudo sensmos-gw-udp.py --install-service
# 6 — confirm
systemctl status sensmos-gw-udp --no-pager
journalctl -u sensmos-gw-udp -f
Step 6 should show active (running), and the log prints connection works — first batch accepted once frames flow, then a status line every minute. In the app, the gateway appears under the node's Attachments; on the live map the node gains the RF layer.
Already paired, no service? The token survives; re-run steps 1 and 5. Only if --install-service says pair first repeat step 4.
Look before you trust us
--dry-run prints, as JSON, exactly the frames it would forward and stops there. Add --log-tx to also see when your gateway transmits. The script is a single readable file; the source is on GitHub.
Pairing — how the token works
The script has no credentials of its own. It asks the Sensmos node on your LAN (with the node's PIN) to vouch for it; the node relays the request to the backend over its own link, and the backend issues a token scoped to radio.frames — reporting received frames, nothing else. The token is stored in /etc/sensmos-gw-udp.json (mode 0600). From then on the script talks to the backend directly; the node is out of the loop.
- One node can vouch for up to four attachments.
- The owner can revoke any of them from the app (node → Attachments); the script becomes inert immediately.
- A token lives 30 days from first use and the script renews it by itself.
The full contract (endpoints, scopes, formats) is on Attachments & the EXT API.
What it reads
Semtech GWMP over UDP, recognised by packet header, not by port, so it works whatever ports your stack uses:
| byte | |
|---|---|
| 0 | protocol version (1 or 2) |
| 1–2 | token |
| 3 | type — 0 PUSH_DATA (frames received), 3 PULL_RESP (gateway asked to transmit) |
| 4–11 | gateway EUI |
| rest | JSON |
From PUSH_DATA it takes the rxpk entries: frequency, spreading factor, RSSI, SNR, size, CRC result and the raw PHY payload. Application payloads are encrypted with keys we do not have; what the network uses is the radio metadata and recognising Sensmos frames among the traffic.
PULL_RESP — a transmission your gateway is about to make — is reported only when it is audible to others (ipol = false, e.g. coverage beacons some networks transmit). A normal LoRaWAN downlink uses inverted IQ that nobody else can hear, proves nothing, and is skipped. Reported ones are proof the gateway is real: their payload is unpredictable, so a Sensmos node that hears the same bytes independently confirms the gateway actually transmitted.
A multi-server forwarder sends the identical frame to every configured server, so the same transmission shows up several times on loopback (RF packets received: 1 / PUSH_DATA datagrams sent: 2 in your forwarder log). The script deduplicates; one transmission is reported once. Frames are batched (once a second or every 50 frames) and sent to https://sensmos.com/v1/ext/radio/frames.
Flags
| flag | meaning |
|---|---|
--iface | interface to watch, default lo |
--dry-run | print what would be sent, send nothing |
--log-tx | also log what your gateway transmits |
--find | scan the LAN for Sensmos nodes and print the pair command |
--pair IP --pin PIN --name "…" | one-time pairing through a node |
--install-service | copy to /usr/local/bin, write and enable the systemd unit (shown to you first) |
--be URL | alternative backend, for testing |
The unit it writes, if you would rather do it by hand:
# /etc/systemd/system/sensmos-gw-udp.service
[Unit]
Description=Sensmos passive gateway listener
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/python3 /usr/local/bin/sensmos-gw-udp.py
Restart=always
RestartSec=10
User=root
[Install]
WantedBy=multi-user.target
Docker and containers
The script sniffs AF_PACKET on the host's loopback. Loopback is private per network namespace, so an isolated container sees an empty lo and hears nothing. Running it in a container needs --network host and --cap-add NET_RAW. The supported way is the systemd service on the host; a container build is yours to keep working.
Updates
The script never updates itself — by design. It runs as root on your gateway, in your LAN; auto-pulling code from a server would make that server a way into your network. To update, repeat step 1 and restart the service:
sudo curl -fsSL -o /usr/local/bin/sensmos-gw-udp.py https://raw.githubusercontent.com/Galusz/sensmos-gateway/main/sensmos-gw-udp.py
sudo systemctl restart sensmos-gw-udp
Troubleshooting
root required: run with sudo— raw capture needsCAP_NET_RAW. Usesudo(orsetcap cap_net_raw+epon the Python binary, which grants it to every Python program —sudois cleaner).- Connected but no frames — check that traffic really is on loopback:
sudo tcpdump -i lo -n -c 10 udp. If your forwarder talks to a remote server over the network rather than to a local bridge, point the script at that interface:--iface eth0. AF_PACKET is Linux only— it is. This runs on the gateway, not on a Mac.--findfinds nothing — the node must be on the same LAN segment and online (its/infomust answer withws_connected: true). Give it the node's IP from the app instead.pair firston--install-service— pairing did not complete; check the PIN and that the node is online.
Uninstall
sudo systemctl disable --now sensmos-gw-udp
sudo rm /etc/systemd/system/sensmos-gw-udp.service /usr/local/bin/sensmos-gw-udp.py /etc/sensmos-gw-udp.json
Revoke the attachment in the app too. Nothing else was ever changed, so there is nothing else to undo.
In the network today
The first foreign gateway joined in August 2026 (Germany), vouched for by a node without a radio of its own. Frames it hears — emergency broadcasts and sensor frames of nodes in range — are delivered like frames heard by any node. Details of what LoRa does for a node owner: LoRa.