Build your own LoRa-APRS dashboard
Turn the syslog stream from your LoRa-APRS iGate into a live, self-hosted Grafana dashboard — coverage maps, signal quality, DX records, packet rates and telemetry — using Node-RED, InfluxDB and Grafana. Everything runs on your own LAN. No cloud, no accounts, no FQDN required.
§What you'll build
If you run a LoRa-APRS iGate (for example the popular
CA2RXU_LoRa_iGate ESP32 firmware), it can emit a syslog
feed: one UDP line for every packet it hears, transmits, or forwards. That feed is a goldmine —
it contains the heard callsign, signal strength, SNR, frequency offset, decoded position,
digipeat path and more.
This guide captures that feed and builds a per-iGate analytics dashboard, similar in spirit to lora-aprs.live but entirely under your control. You'll get:
- Coverage & distance maps — every station you've heard, plotted, with reception lines back to your gateway colour-coded by SNR.
- Link quality — RSSI / SNR per station, packet rate, CRC error rate.
- DX records — your furthest direct and via-digipeater contacts.
- Telemetry — battery of heard mobiles, SNR-vs-distance scatter, a raw packet log.
Get the files
Three sanitised files power this build. Every credential, hostname and callsign in them is a placeholder — you'll replace them as you go.
⬇ parser.js — the Node-RED function ⬇ nodered-flow.json — importable flow ⬇ dashboard.json — the Grafana dashboard
§How it works
Four moving parts. Each does one job and hands off to the next:
Why this stack?
| Component | Job | Why this one |
|---|---|---|
| Node-RED | Receive UDP, parse the messy syslog text into clean fields. | A UDP listener + a JavaScript function node is all you need. Visual, easy to tweak, no compiling. |
| InfluxDB 2.x | Store every packet as a timestamped point. | Purpose-built time-series DB: fast range queries, retention policies, and Flux for the map/DX maths. |
| Grafana | Query InfluxDB and render maps, charts, tables. | Native geomap panels + a first-class InfluxDB/Flux datasource. The dashboard is just JSON you import. |
The data model (learn this — everything downstream depends on it)
InfluxDB stores points. Each point has a measurement, some tags (indexed labels you filter/group by), some fields (the actual values), and a timestamp. Our model:
| Part | Value(s) | Meaning |
|---|---|---|
| measurement | aprs_syslog | Everything lives here. |
tag callsign | e.g. G7XYZ-7 | The station that was heard. |
tag gateway | e.g. N0CALL-10 | The iGate that received it (= your station). |
tag category | RX / TX / TCPIP / SYSTEM | Heard on RF / sent by you / via internet / housekeeping. |
| fields | rssi snr freq_offset battery lat lon distance_km digipeated message raw tocall path altitude | The measured values for that packet. |
G7XYZ-7
heard by your iGate stores callsign=G7XYZ-7, gateway=N0CALL-10. Keeping
them as separate tags is what lets the dashboard say "show me everything my gate heard"
and later, if you add more gates, compare coverage between them.A raw syslog line looks like this
…which the parser turns into this InfluxDB line
count() therefore over-counts
packets ~13×. Throughout the dashboard we count distinct timestamps instead — remember
this when you write your own queries.§Prerequisites
This guide picks up after the three services are installed. Each project has good install docs (Docker images exist for all three) — follow those, then come back. You need:
| You have | Reachable at (example) | You can… |
|---|---|---|
| InfluxDB 2.x | http://INFLUX_HOST:8086 | log into its web UI as a user that can create buckets & tokens |
| Node-RED | http://NODERED_HOST:1880 | open the flow editor and install nodes |
| Grafana 11+ | http://GRAFANA_HOST:3000 | log in as an admin/editor |
| A LoRa-APRS iGate | on your LAN | change its syslog target IP/port in its web config |
INFLUX_HOST, NODERED_HOST
and GRAFANA_HOST are usually LAN IPs like
192.168.1.50. N0CALL-10 is a stand-in for
your iGate's callsign-SSID.1InfluxDB — somewhere to put the data
We create an organisation, one bucket, and an API token Node-RED will use to write.
1.1 Create the organisation & bucket
In the InfluxDB UI:
- If this is a fresh install, the setup wizard creates your first organisation
and user. Name the org
aprs(or anything — just be consistent). - Go to Load Data → Buckets → Create Bucket.
- Name it after your iGate's callsign-SSID, e.g.
N0CALL-10. Set retention to whatever you like — 30 days is a sensible start.
1.2 Create an API token
- Go to Load Data → API Tokens → Generate API Token → Custom API Token.
- Give it Read + Write on your
N0CALL-10bucket. - Copy the token now — InfluxDB shows it only once.
1.3 Confirm it works
From any machine on the LAN, write one test point. Substitute your host, bucket, org and token:
A 204 No Content response means success. You can delete the
TEST-1 point later, or just let retention expire it.
2Node-RED — receive & parse the syslog
Node-RED listens on a UDP port, runs each line through a parser function, and POSTs the result to InfluxDB. You can import the ready-made flow, or build it node-by-node.
2.1 Point your iGate at Node-RED
In your iGate's web configuration, find the syslog settings and set the
destination to your Node-RED host and a port of your choice (this guide uses
1234):
| iGate syslog field | Set to |
|---|---|
| Server / Host | NODERED_HOST (the LAN IP running Node-RED) |
| Port | 1234 |
| Protocol | UDP |
2.2 Import the flow
- In Node-RED, top-right ☰ menu → Import.
- Paste the contents of
nodered-flow.json(or "select a file to import"). - Click Import. You'll get a tab with four nodes:
udp in→SYSLOG PARSER→InfluxDB write(+ two debug nodes).
The importable flow:
2.3 Edit the two things you must change
- The parser function — double-click the
SYSLOG PARSERnode. At the very top are three values. Set them to your token, org and bucket:
const INFLUX_TOKEN = "YOUR_INFLUX_TOKEN"; // from Step 1.2
const ORG = "aprs"; // your org name
const BUCKET = "N0CALL-10"; // your gateway callsign = bucket name
- The write node URL — double-click
InfluxDB writeand changeINFLUX_HOSTin the URL to your InfluxDB IP. Leave the{{org}}/{{bucket}}mustaches as-is — the parser fills them in per message.
2.4 The parser, explained
This is the heart of the system. It receives one raw syslog line in
msg.payload and returns InfluxDB line protocol. Read the comments —
every block earns its place:
The parts worth understanding
- Category & heard callsign — read from the line prefix
(
- RX /,- TX /,,TCPIP,). This is who was heard and how. - Coordinates are anchored to
LATN / LONE.Why the " / " separator matters: APRS frames often relay other stations' or objects' positions. A loose "grab any number followed by N/E" regex will happily pin your fixed iGate wherever some passing relayed object happened to be. Real first-party positions in this firmware are wrapped as52.34N / 0.12W; relayed ones lack that ` / ` structure, so anchoring to it skips them. This one detail is the difference between a clean map and your gateway scattered across the country. - Everything numeric is written as a FLOAT (no
isuffix in line protocol).Why: InfluxDB refuses to store an integer and a float in the same field — you'd get a "field type conflict" the first time a value happened to be whole. The Grafana queries also dofloat()maths on these. Keep them all floats and you never hit it. - The gateway is the syslog HOSTNAME (3rd token of the RFC5424 line). We
sanity-check it looks like
CALLSIGN-SSIDandreturn nullotherwise — junk and obviously-spoofed lines never reach the database.
2.5 Verify data is flowing
- In the parser flow, enable the
line protocoldebug node (click its green button) and open the debug sidebar (🐞 top-right). - Wait for your iGate to hear a packet (or beacon its own position). You should see
aprs_syslog,...lines appear. - The
write resultdebug should stay quiet (a 204 has no body). Now check InfluxDB: Data Explorer → your bucket → aprs_syslog — points should be arriving.
3Grafana — the dashboard
3.1 Add the InfluxDB datasource
Go to Connections → Add new connection → InfluxDB and configure it for Flux (not InfluxQL):
aprs_influxdb.
The shipped dashboard.json references its datasource by the UID
aprs_influxdb. After saving the datasource, you can set/confirm its UID
on the datasource page (Grafana lets you choose a custom UID). Match it and the dashboard imports
with zero edits; otherwise you'd have to find-and-replace the UID in the JSON.Click Save & test — Grafana should report it can reach the bucket.
3.2 Create a service-account token (for the API import)
This dashboard uses Grafana's newer dashboard schema, which is imported via the API rather than the classic "paste JSON" box. You need a short-lived token:
- Administration → Users and access → Service accounts → Add service account.
- Role: Editor. Then Add service account token and copy it.
3.3 Push the dashboard
From the folder containing dashboard.json, run (substitute host + token):
A 201 Created means it's in. Open Grafana — you'll find the
LoRa-APRS iGate Monitor dashboard. If your datasource UID and bucket name match,
panels populate immediately.
3.4 The one variable that matters: ${bucket}
The dashboard has a hidden variable called bucket, preset to
N0CALL-10. Every panel reads from
from(bucket: "${bucket}"), and the gateway's own identity is
derived from it (so "my own beacons" filters target the right station automatically).
If your bucket isn't literally N0CALL-10, edit the variable:
Dashboard settings → Variables → bucket.
N0CALL-10, your selection isn't a valid option
and the dashboard silently falls back to the default. Setting the bucket name in all three keeps
them consistent. (Easiest: just name your bucket N0CALL-10 and skip
this.)§The dashboard, panel by panel
The dashboard is organised into sections — Maps & Coverage, Activity, Signal & RF, Telemetry, Logs. Here's what each panel tells you and the clever bits behind a few of them.
| Panel | What it shows |
|---|---|
| Latest Distance & RSSI Map | Geomap of each station's most recent position, sized/coloured by signal. |
| Coverage (heard positions) | Every position you've received in the window — your real-world footprint. |
| Reception Lines (SNR) | Lines from your gateway to each heard station, colour-coded by SNR. |
| Location over Time | Recency trail of moving stations within the selected time window. |
| Station Activity / Packet Rate | Who's busiest; packets-per-window over time. |
| Link Quality / Avg Link Quality | RSSI & SNR per station (latest, and averaged as bar gauges). |
| Packet Category / CRC Error Rate | RF vs internet vs system mix; proportion of corrupt receptions. |
| Furthest Station (DX) | Your record distance, split direct vs via-digipeater. |
| SNR vs Distance | Scatter showing how signal degrades with range — your propagation fingerprint. |
| Last Heard / Recent Packets / Battery | Freshness table, raw packet log, and battery of heard mobiles. |
Counting packets correctly
Remember one packet writes ~13 field-rows. The activity query counts unique timestamps per callsign, not rows:
Finding "my own" position safely (the map cold-start guard)
Map panels need to know where your gateway is. Since the gateway's callsign equals the
bucket name, that's callsign == "${bucket}". But on a brand-new bucket,
before your iGate has beaconed its position, naïvely calling
findRecord() on an empty table errors the whole panel
red. The fix is to union in a harmless sentinel row so there's always something to find:
Defensive coordinate fixing
You'll notice fixLat/fixLon helpers in
the map queries that convert a value to degrees if |lat| > 90.
§Troubleshooting
| Symptom | Likely cause & fix |
|---|---|
| No debug output in Node-RED at all | iGate isn't sending, or sending to the wrong host/port, or it's using TCP not UDP. Confirm the iGate's syslog config; check a firewall isn't blocking UDP/1234 on the Node-RED host. |
| Parser shows lines, but InfluxDB is empty | Wrong token/org/bucket, or the write URL still says INFLUX_HOST. Run the Step 1.3 curl test directly. A 401 = bad token; 404 = bad bucket/org. |
| "field type conflict" on write | A field was written as int once and float another time. Don't add i-suffixed integers; keep all numerics float (the supplied parser already does). |
| Grafana panels say "No data" | Datasource UID ≠ aprs_influxdb, the ${bucket} variable doesn't match your bucket, or the selected time range predates your data. Check all three. |
| Map panels are red / errored | Empty bucket before the first position beacon. The guarded query (above) avoids this; wait for your iGate to beacon, or widen the time range. |
| Your fixed iGate appears all over the map | You're using a looser coordinate regex than the supplied parser. Use the anchored LATN / LONE match so relayed positions are skipped. |
| Distances of ~18,000 km | A DDMM coordinate was read as decimal degrees. The fixLat/fixLon helpers and the parser's |lat|>90 guard handle this. |
§Going further
- Keep your lora-aprs.live feed too. Many iGates only send syslog to one place.
If yours points at a public aggregator and you don't want to lose that, add a second
udp outnode in Node-RED wired off the sameudp in, forwarding the untouched packet to the aggregator's host:port. You then keep both feeds. - Tune retention. Bump the bucket's retention in InfluxDB if you want longer history (storage permitting). 30–90 days is plenty for trend analysis.
- Add a second iGate. Give it its own bucket (named after its callsign), point
its syslog at the same Node-RED, and clone the dashboard — changing only the
${bucket}variable. The same parser handles any number of gates, because it routes by the syslog hostname. - Terrain profiles. A natural next project: feed gateway↔station great-circle paths through an elevation API (e.g. a self-hosted OpenTopoData) and plot the terrain cross-section to explain why some links work and others don't.