ENPLDEPT
Flash
All docs
LoRaWAN gateway

Add your LoRaWAN gateway to Sensmos — passive listener for Semtech packet forwarders

A Helium, TTN or private gateway sees every LoRa frame around it. One passive script reports the radio metadata to Sensmos — emergency broadcasts and sensor frames of nodes nearby get delivered, and your gateway never changes what it does for its own network.

radio · 868 MHz · emergency frame
Heard by a neighbour · 2.1 kmgarage · temp 3.4°C · door OPEN · 21:44

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
0protocol version (1 or 2)
1–2token
3type — 0 PUSH_DATA (frames received), 3 PULL_RESP (gateway asked to transmit)
4–11gateway EUI
restJSON

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

flagmeaning
--ifaceinterface to watch, default lo
--dry-runprint what would be sent, send nothing
--log-txalso log what your gateway transmits
--findscan the LAN for Sensmos nodes and print the pair command
--pair IP --pin PIN --name "…"one-time pairing through a node
--install-servicecopy to /usr/local/bin, write and enable the systemd unit (shown to you first)
--be URLalternative 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 needs CAP_NET_RAW. Use sudo (or setcap cap_net_raw+ep on the Python binary, which grants it to every Python program — sudo is 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.
  • --find finds nothing — the node must be on the same LAN segment and online (its /info must answer with ws_connected: true). Give it the node's IP from the app instead.
  • pair first on --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.

Questions, straight answers

Does this change my gateway's configuration?

No. Nothing is edited, no service is restarted, no server is added to the forwarder. The script only reads packets already flowing over loopback. Kill it and the gateway does not notice.

Can it make my gateway transmit?

No — there is no code path for it. It reports transmissions the gateway makes on its own (audible coverage beacons), but never causes one.

Do I need an account?

No. A Sensmos node on the same LAN vouches for the gateway and the backend issues it a narrow token scoped to reporting frames. The node owner can revoke it from the app at any time.

What does the network get?

Every LoRa frame the gateway hears, as radio metadata plus the raw PHY bytes. Sensmos frames (emergency broadcasts, sensor frames) are recognised and delivered to their owners; foreign LoRaWAN traffic only counts as spectrum statistics — the payloads are encrypted and we have no keys.

Does a gateway earn GALU?

The gateway has no wallet of its own. The node that vouches for it gains the RF data category, which raises that node's reward weight (+10% per data category). On top of that, when a paid sensor-frame receive is credited to the first foreign listener of the day and that listener is your gateway, 70% of the fee goes to the vouching node's owner.

My gateway runs ChirpStack Gateway Bridge, not a bare forwarder

Then there is a local MQTT broker and a different agent (sensmos-gw-mqtt.py, in the same repository) fits better — but it is untested: we have no such gateway to verify it on. Start it with --dry-run and tell us what you see. This page covers the bare packet forwarder case, which is what most Helium-class gateways run.

Last updated: 2026-09-03