Repeaters now report their firmware version (REQ_TYPE_GET_OWNER_INFO), checked daily and after a reboot, into meshcore_repeater_info. Firmware changes show as Grafana annotations and in the web UI node list. Repeaters on keymind/Cascade firmware keep 7 days of half-hourly battery voltage and MCU temperature. With history = true and an admin login, the collector fetches enough days via the remote CLI to fill gaps after missed polls, writing them to meshcore_repeater_history.
meshcore-repeater-stat-influx
Polls MeshCore nodes over the mesh through a companion radio attached to this machine (USB serial, TCP/WiFi or BLE), and writes the results to InfluxDB for Grafana:
- Repeaters, any number: status counters, telemetry and the neighbour table.
- Remote companions (other people's chat nodes): telemetry, but only if their owners share it.
- The local companion (the radio on this server): its own radio and traffic stats. This uses no mesh airtime.
A built-in web UI manages the nodes, the companion connection and the InfluxDB target.
[this service] --USB/TCP/BLE--> [companion radio] ~~LoRa mesh~~> [repeater 1..n]
| |
| +--> InfluxDB --> Grafana
+--> web UI (:8080)
Each node has its own schedule. When a repeater is due, the collector does login → status → telemetry → neighbours → logout. When a remote companion is due, it sends a single telemetry request. With one radio, nodes are polled one at a time.
Install on a Linux server (systemd)
git clone https://git.rickelobe.com/rich/meshcore-repeaters-stat-influx.git
cd meshcore-repeaters-stat-influx
sudo ./install.sh # or: sudo ./install.sh --port 9090
The installer:
- creates a
meshcoresystem user, with serial and Bluetooth access - installs to
/opt/meshcore-influx, in its own Python virtualenv - writes a starter config to
/etc/meshcore-influx/config.toml, picking up the first/dev/serial/by-iddevice it finds - asks for the web UI password
- starts
meshcore-influx.service - prints the URL
Then open http://<server>:8080/ and set things up:
- Companion radio: check the serial port (or switch to TCP/BLE).
- InfluxDB: enter the URL, org, bucket and token, then click Test connection.
- Repeaters: click Add repeater and pick from the repeaters the companion has heard. This fills in the key prefix, so the name is just a label you can change.
Upgrading: git pull && sudo ./install.sh. This keeps your config and state,
and restarts the service.
Uninstalling: sudo ./install.sh --uninstall. This keeps /etc/meshcore-influx and
/var/lib/meshcore-influx.
| Logs | journalctl -u meshcore-influx -f (the most recent lines are also on the UI's Logs tab) |
| Reset the web password | sudo runuser -u meshcore -- /opt/meshcore-influx/.venv/bin/python /opt/meshcore-influx/meshcore_influx.py -c /etc/meshcore-influx/config.toml --set-password |
| Firewall | sudo ufw allow 8080/tcp if ufw is active |
Web UI
- Nodes (repeaters and remote companions):
- status of each node: OK, failed (with the reason), polling or queued
- battery and low-power badge, last success, next poll
- Poll now, Edit, Add node
- in the editor: enable or disable the node, choose which requests to make (repeaters), opt in to location (companions), and override the interval or battery thresholds
- Companion radio:
- live stats for this server's radio: battery, uptime, noise floor, RSSI and SNR, packets, airtime, contacts heard
- serial, TCP or BLE settings, with a picker for detected serial ports
- every contact the companion has heard, with a one-click Add for repeaters and companions
- InfluxDB: URL, org, bucket and token, plus a Test connection button that does a real test write. Also shows the last write and the last error.
- Polling defaults: interval, retries, timeout and low-battery back-off.
- Logs: the last 300 log lines. Account: change the password.
Changes apply immediately, with no restart. They're saved back to config.toml,
which you can still edit by hand; the service notices and reloads it. An invalid
hand edit is rejected and logged, and the service keeps its current settings.
Security. There is one admin password, stored as a scrypt hash. Sessions are an
HttpOnly, SameSite=Strict cookie, and all changes must be sent as JSON, which blocks
cross-site form posts. Secrets (InfluxDB token, repeater passwords, BLE PIN) are never
sent to the browser. The UI uses plain HTTP, so keep it on your LAN or put it behind
a reverse proxy with TLS (set [web] listen = "127.0.0.1").
Run by hand (any OS)
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
cp config.example.toml config.toml # edit it
python meshcore_influx.py -c config.toml --set-password # for the web UI
python meshcore_influx.py -c config.toml --once --force --dry-run -v # test, prints line protocol
python meshcore_influx.py -c config.toml # run forever (+ web UI)
Requires Python 3.10+. The companion must run companion radio firmware, and the repeater must already be in its contact list (it gets added when the companion hears the repeater's advert). A guest password gets you status, base telemetry and the neighbour table. Use the admin password if you want the extra sensor channels.
While this script is connected over USB/BLE, the phone app can't use the same companion at the same time.
Remote companions: what to expect
Companions answer only one kind of mesh request: telemetry (battery, chip temperature, and any sensors). There's no login, and no status counters or neighbour lists. A companion answers only if all of these are true:
- Its owner shares telemetry, either with everyone or with contacts they've marked. Basic, location and environment can each be turned on separately; off is the usual setting.
- It has this server's companion in its contact list. Requests from unknown senders are ignored.
- It's switched on and in range. Phones and handhelds come and go, so expect some failed polls. The Companion reachability panel shows when it answered.
Getting them to add you. On the Companion radio tab, under Let others add this companion, you can:
- Send an advert. Zero-hop reaches only nodes in direct range and costs almost nothing. Flood is repeated across the whole mesh, so use it sparingly.
- Send adverts automatically. Off by default; when on, every 3–168 hours. That's the range the repeater firmware allows; repeaters default to 47 hours. A successful manual advert of the same kind restarts the timer.
- Show the radio's contact card as a QR code and
meshcore://link that the other owner can scan or paste.
Being a contact isn't enough on its own. The owner must also allow telemetry for your contact, or for all contacts. Marking you as a favourite doesn't do it: the companion firmware keeps the favourite flag and the telemetry permissions separate.
The collector requests basic and environment telemetry. It requests location only if you tick "Include location" for that companion. Only do that with the owner's agreement, because it records their GPS position.
What gets collected
| Measurement | Source | Contents |
|---|---|---|
meshcore_repeater_status |
REQ_TYPE_GET_STATUS |
battery mV/V, TX queue, noise floor, last RSSI/SNR, packets rx/tx, flood/direct rx/tx, dup counts, TX/RX airtime, uptime, recv errors, error flags |
meshcore_repeater_telemetry |
REQ_TYPE_GET_TELEMETRY_DATA (CayenneLPP) |
one point per sensor, tagged channel + sensor (voltage, temperature, humidity, gps…). Usually at least battery voltage and MCU temperature |
meshcore_repeater_neighbours |
REQ_TYPE_GET_NEIGHBOURS |
count |
meshcore_repeater_neighbour |
same | per zero-hop neighbouring repeater: snr_db, secs_ago, last_heard |
meshcore_repeater_info |
REQ_TYPE_GET_OWNER_INFO |
firmware (version string), owner, changed (1 when the version differs from the last one seen). Checked daily and after a reboot |
meshcore_repeater_history |
repeater CLI get telemetry.temp / get telemetry.volt (keymind/Cascade firmware) |
battery_v, temperature_c (MCU), half-hourly, backfilled after missed polls. Only with history = true |
meshcore_poll |
this script | repeater polls: per-request success flags, path length, poll duration, low_power_mode, interval_s, configured battery thresholds, history_ok (when a backfill was attempted) |
meshcore_companion_telemetry |
remote companion, telemetry request | one point per sensor, tagged companion, channel, sensor |
meshcore_companion_poll |
this script | remote companion polls: success, path length, duration, low_power_mode, interval_s |
meshcore_local_stats |
local companion, CMD_GET_STATS |
battery, uptime, TX queue, noise floor, last RSSI/SNR, TX/RX airtime, packet counters, contact counts (contacts, contacts_repeater, contacts_chat, …, heard_1h) |
meshcore_local_telemetry |
local companion, self telemetry | its own sensors, same shape as the other telemetry measurements |
meshcore_node_location |
node positions from heard adverts, the radio's own position, and companion GPS telemetry | lat, lon, hops, last_advert, seen, from_gps, polled; tagged name, pubkey, type (repeater/chat/room/sensor/self), source (advert/gps). The point time is when the position was observed |
Repeater points are tagged repeater, and companion points (local or remote) are
tagged companion. Both also carry pubkey (12 hex characters).
Local stats are collected every 60 seconds by default
([poll] local_stats_interval_seconds; 0 turns it off). The packet counters need
recent companion firmware. On older firmware the Companion radio tab lists what isn't
supported, and the rest is still collected.
Does the repeater store telemetry history? Upstream: no. keymind/Cascade: a little.
I checked upstream MeshCore against the firmware source (examples/simple_repeater, v1.17.x):
- Nothing is buffered for later. Every reply is built from live values when the
request arrives. The min/max/avg history request (
REQ_TYPE_GET_AVG_MIN_MAX, backed byTimeSeriesData) is only in the sensor firmware (simple_sensor). The repeater firmware doesn't implement it. - The status counters are cumulative since boot, and they live in RAM. A reboot,
power loss, or
clear statsresets them to zero. - Gauges are instantaneous. Battery, noise floor, TX queue, last RSSI/SNR and temperature are only valid when sampled. Anything between two polls is never seen.
- The neighbour table holds the last-heard time and SNR for up to
MAX_NEIGHBOURS(50 on nearly every board) zero-hop repeaters. It lives in RAM, and the oldest entry gets overwritten. - The on-flash packet log (
log start) can only be dumped over the local serial console (sender_timestamp == 0), not over the mesh.
On-device history (keymind/Cascade firmware)
The keymind/Cascade fork (mikecarper/MeshCore, v1.17.1.x-halo-keymind-cascade) keeps
battery voltage and MCU temperature every 30 minutes for 7 days in RAM, and an admin
can read it back one day at a time with get telemetry.volt N and get telemetry.temp N.
STM32 builds leave it out.
Set history = true on a repeater (or tick Backfill gaps in the editor) to use it.
After a successful poll, if the last data written is more than an hour and 1.5 intervals
old, the collector fetches enough days to cover the gap (at most 7). It writes them
to meshcore_repeater_history. The first poll with it enabled fetches one day. Notes:
- It needs the admin password; guests get "not permitted". The node list shows why a backfill was skipped or failed.
- Each day is two CLI round-trips, so a long gap costs some airtime once.
- A reboot clears the history, so gaps across a reboot stay gaps.
- It's skipped when the repeater's clock (reported at login) is more than an hour off, because the sample times come from that clock.
- CLI replies arrive as messages on the companion. While it waits for one, the collector discards (and logs) any other queued messages.
So how often should you poll?
Because the counters are cumulative, a missed poll costs resolution, not data. The next successful poll still gives correct totals and rates. What you lose between polls:
- Gauges (battery, noise floor, temperature): you only see what you sample. Battery and temperature change slowly, so 15 min is plenty.
- Activity across a reboot: everything since the last poll before the reboot.
Watch
uptime_sdropping to spot reboots. direct_dups/flood_dupsare sent as uint16 and wrap at 65,536. A busy repeater could wrap these between infrequent polls.
The trade-off is airtime. One cycle is about 4–6 request/response exchanges, and each one is repeated on every hop between your companion and the repeater. That airtime is shared with everyone else on the mesh.
Recommendation: interval_seconds = 900 (15 min). Go to 300 s if the repeater is
1 hop away and you want finer traffic graphs. Go to 1800–3600 s if it's several hops
out or the mesh is busy. You can also turn neighbours = false to cut the largest
reply.
Low-battery back-off (solar nodes)
Every poll costs the repeater some power: it has to receive and transmit the replies. So when its battery is low, the collector polls less often:
battery < low_battery_mv -> low-power mode: poll every low_battery_interval_seconds,
skip the requests in low_battery_skip
battery >= recover_battery_mv -> back to normal interval
in between -> stay in whichever mode you're in (hysteresis)
The defaults are 3500 / 3750 mV, 1 hour, and skip neighbours. They suit a single Li-ion cell. LiFePO4 has a much flatter curve, so use something like 3200 / 3300.
- Where the battery reading comes from: the status reply. If status is disabled or fails, it falls back to the voltage reading in telemetry. A board that reports 0 mV can't measure its battery, so the back-off never triggers for it.
- Per-repeater settings: any
[poll]setting exceptstate_filecan be overridden in a[[repeater]]block. For example, uselow_battery_mv = 0for a mains-powered repeater. - First run: with no history, the collector starts in low-power mode if the battery is below the recover level. This is the safe choice for solar nodes.
- State file:
state.jsonkeeps the mode and last poll time across restarts. This also means--oncefrom cron works: run it every few minutes and it only polls repeaters that are due.--forcepolls everything regardless. - Unreachable repeater: a repeater with a flat battery usually doesn't answer. The collector stays in its current mode and tries again at the current interval.
Grafana dashboard
There are two dashboards. Import each one under Dashboards → New → Import; they link to each other.
Mesh dashboard (grafana/meshcore-repeater-dashboard.json)
Pick your InfluxDB data source, a Repeater and one or more Companions from the dropdowns at the top. Sections:
- Overview stats: battery, polling mode, poll interval, poll success %, uptime, noise floor, temperature, neighbour count.
- Power: battery voltage with the low/recover thresholds drawn as dashed lines. The thresholds are read from the data, so they always match your config. There's also a polling-mode / reachability timeline, temperature, and poll interval. Backfilled on-device history is drawn dotted on the battery and temperature graphs.
- Radio: noise floor and RSSI, SNR, TX/RX duty cycle %, packets per hour, flood vs direct, duplicates and receive errors, TX queue and error flags.
- Neighbours: SNR history and latest SNR for each neighbouring repeater.
- Collector & link health: poll duration, hop count, uptime (reboots show as drops), firmware version, and any extra telemetry sensors. Firmware changes are also marked as annotations across all graphs.
- This server's companion: noise floor, RSSI, duty cycle, packets per hour, contacts heard, battery and SNR.
- Remote companions (filtered by the Companion selector): battery, latest battery, reachability, temperature, and any other shared sensors (e.g. illuminance).
Node map (grafana/meshcore-map-dashboard.json)
A Geomap with one point per node, with a table of them underneath. The layers are repeaters (green), companions (blue), rooms and sensors (purple), and this server's radio (red star). The map zooms to fit the nodes.
A node's position can come from two places:
- Its adverts, as heard by your radio. These are checked every 15 minutes
(
[poll] location_interval_seconds; 0 turns it off) and cost no airtime. - GPS telemetry, for companions you've set to Include location. This arrives every time the companion is polled.
Each position is stored with the time it was observed, and the map shows the newest one for each node. The table's Source and Position from columns say where it came from and when. Only positions observed within the dashboard's time range are shown (7 days by default), so widen the range to include nodes that advertise rarely. Nodes that don't advertise a position aren't shown. The map tiles load from Grafana's default basemap service, so the browser viewing the dashboard needs internet access.
The queries are InfluxQL, which works with InfluxDB 1.x, 2.x and 3.x:
- InfluxDB 1.8: add an InfluxDB data source with query language InfluxQL.
- InfluxDB 2.x: create a DBRP mapping for the bucket first:
influx v1 dbrp create --db meshcore --rp autogen --bucket-id <id> --default. Then add the data source as InfluxQL, with the token in anAuthorization: Token <token>header. - InfluxDB 3: use the InfluxQL query language option.
The Battery stat's colour thresholds are static, set to 3.50 / 3.75 V. Edit them if your config differs.
I tested the dashboard on Grafana 13.2 against InfluxDB 1.8, using simulated data written by this collector.
Grafana queries
Counters reset on reboot, so always use a non-negative derivative.
InfluxQL (packets/min received):
SELECT non_negative_derivative(last("packets_recv"), 1m) FROM "meshcore_repeater_status"
WHERE $timeFilter AND "repeater" = 'My Repeater' GROUP BY time($__interval)
Flux (TX duty cycle %):
from(bucket: "meshcore")
|> range(start: v.timeRangeStart, stop: v.timeRangeStop)
|> filter(fn: (r) => r._measurement == "meshcore_repeater_status" and r._field == "tx_airtime_s")
|> derivative(unit: 1s, nonNegative: true)
|> map(fn: (r) => ({r with _value: r._value * 100.0}))
Other useful panels:
- battery_v
- noise_floor_dbm
- neighbour
snr_dbgrouped byneighbour_name meshcore_poll.successas a state timeline, for "is the repeater reachable"