Add MeshCore repeater telemetry collector for InfluxDB with Grafana dashboard

Polls MeshCore repeaters over the mesh via a local companion radio
(serial/TCP/BLE): status counters, CayenneLPP telemetry and the
neighbour table, written to InfluxDB along with per-poll health.

Includes a low-battery back-off for solar nodes (hysteresis between
low and recover thresholds, slower interval, optional request skips),
persisted state so --once works from cron, and an InfluxQL Grafana
dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
richandClaude Opus 5.5 committed 2026-10-02 21:48:53 -04:00
commit f2c285f939
6 files changed
+2840

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
config.toml
state.json
.venv/
__pycache__/
+183
View File
@@ -0,0 +1,183 @@
# meshcore-repeater-stat-influx
Polls MeshCore repeaters **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.
```
[this script] --USB/TCP/BLE--> [companion radio] ~~LoRa mesh~~> [repeater(s)]
|
+--> InfluxDB --> Grafana
```
Each poll cycle, for each repeater: **login → status → telemetry → neighbours → logout**.
## Setup
```sh
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 --once --dry-run -v # test, prints line protocol
python meshcore_influx.py -c config.toml # run forever
```
Requires Python 3.11+. 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.
## 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_poll` | this script | per-request success flags, path length, poll duration, `low_power_mode`, `interval_s`, configured battery thresholds |
All points are tagged `repeater` (configured name) and `pubkey` (12 hex chars).
## Does the repeater store telemetry history? No.
I checked this 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 by
`TimeSeriesData`) 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 stats` resets 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.
### 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:
1. **Gauges** (battery, noise floor, temperature): you only see what you sample.
Battery and temperature change slowly, so 15 min is plenty.
2. **Activity across a reboot**: everything since the last poll before the reboot.
Watch `uptime_s` dropping to spot reboots.
3. **`direct_dups` / `flood_dups`** are 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 except `state_file` can be overridden in a
`[[repeater]]` block. For example, use `low_battery_mv = 0` for 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.json` keeps the mode and last poll time across restarts.
This also means `--once` from cron works: run it every few minutes and it only
polls repeaters that are due. `--force` polls 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
Import `grafana/meshcore-repeater-dashboard.json` (Dashboards → New → Import). Then
pick your InfluxDB data source and repeater 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.
- **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), and any extra telemetry sensors.
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 an `Authorization: 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):
```sql
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 %):
```flux
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_db` grouped by `neighbour_name`
- `meshcore_poll.success` as a state timeline, for "is the repeater reachable"
## Running as a service (Linux, systemd)
```ini
# /etc/systemd/system/meshcore-influx.service
[Unit]
Description=MeshCore repeater -> InfluxDB
After=network-online.target
[Service]
WorkingDirectory=/opt/meshcore-repeater-stat-influx
ExecStart=/opt/meshcore-repeater-stat-influx/.venv/bin/python meshcore_influx.py -c config.toml
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target
```
+50
View File
@@ -0,0 +1,50 @@
# Copy to config.toml and edit.
[connection]
# The companion radio attached to the machine running this script.
type = "serial" # "serial", "tcp" or "ble"
port = "/dev/ttyUSB0" # serial: device path (macOS: /dev/cu.usbserial-XXXX or /dev/cu.usbmodemXXXX)
baudrate = 115200
# type = "tcp"
# host = "192.168.1.50"
# port = 5000
# type = "ble"
# address = "AA:BB:CC:DD:EE:FF"
# pin = "123456"
[influxdb]
url = "http://localhost:8086"
token = "my-token" # InfluxDB 1.8: "username:password"
org = "home" # InfluxDB 1.8: "-"
bucket = "meshcore" # InfluxDB 1.8: "database/retention_policy"
[poll]
interval_seconds = 900 # see README: 15 min is a sensible default
retries = 3 # per request; mesh packets get lost
timeout_seconds = 0 # 0 = use the radio's suggested timeout (scales with hop count)
state_file = "state.json" # remembers last poll time + low-power mode across restarts
# Low-battery back-off (solar nodes). Below low_battery_mv, poll every
# low_battery_interval_seconds and skip the requests in low_battery_skip;
# stay there until the battery reaches recover_battery_mv. 0 disables.
# Values below suit a single Li-ion cell; LiFePO4 is flatter (e.g. 3200 / 3300).
low_battery_mv = 3500
recover_battery_mv = 3750
low_battery_interval_seconds = 3600
low_battery_skip = ["neighbours"] # any of "status", "telemetry", "neighbours"
# Every key in this [poll] block from interval_seconds down (except state_file)
# can be overridden per [[repeater]].
# One block per repeater. It must already be in the companion's contact list.
[[repeater]]
name = "My Repeater" # advert name, case-insensitive
password = "guest-or-admin-pw" # guest is enough for status + basic telemetry + neighbours
# pubkey_prefix = "a1b2c3d4" # use instead of name if names collide
status = true
telemetry = true
neighbours = true
# [[repeater]]
# name = "Hilltop"
# password = "..."
# low_battery_mv = 0 # mains powered: never back off
File diff suppressed because it is too large. Load diff
+472
View File
@@ -0,0 +1,472 @@
#!/usr/bin/env python3
"""
Poll MeshCore repeaters over the mesh (via a locally attached companion radio)
and write their stats, sensor telemetry and neighbour table to InfluxDB.
companion radio (USB / TCP / BLE) -> mesh -> repeater(s)
|
this script -> InfluxDB -> Grafana
Usage:
python meshcore_influx.py -c config.toml # run forever
python meshcore_influx.py -c config.toml --once # single poll (cron)
python meshcore_influx.py -c config.toml --once --dry-run # print, don't write
"""
import argparse
import asyncio
import json
import logging
import os
import signal
import sys
import time
import tomllib
from dataclasses import asdict, dataclass, field
from typing import Optional
from meshcore import MeshCore, EventType
from influxdb_client import Point, WritePrecision
from influxdb_client.client.influxdb_client_async import InfluxDBClientAsync
log = logging.getLogger("meshcore_influx")
# Status response key (meshcore lib) -> InfluxDB field name.
# Counters are cumulative since the repeater last booted (they live in RAM),
# so graph them with non_negative_derivative / increase().
STATUS_FIELDS = {
"bat": "battery_mv",
"tx_queue_len": "tx_queue_len",
"noise_floor": "noise_floor_dbm",
"last_rssi": "last_rssi_dbm",
"last_snr": "last_snr_db",
"nb_recv": "packets_recv",
"nb_sent": "packets_sent",
"airtime": "tx_airtime_s",
"rx_airtime": "rx_airtime_s",
"uptime": "uptime_s",
"sent_flood": "sent_flood",
"sent_direct": "sent_direct",
"recv_flood": "recv_flood",
"recv_direct": "recv_direct",
"direct_dups": "direct_dups", # uint16 on the wire: wraps at 65536
"flood_dups": "flood_dups", # uint16 on the wire: wraps at 65536
"full_evts": "err_flags", # sticky bitmask: 1=queue full, 2=CAD timeout, 4=startRX timeout
"recv_errors": "recv_errors", # firmware >= v1.12
}
FLOAT_STATUS_FIELDS = {"last_snr"}
@dataclass
class BatteryPolicy:
"""Low-battery back-off with hysteresis: enter below low_mv, leave at/above recover_mv."""
low_mv: int = 0 # 0 = disabled
recover_mv: int = 0
interval: int = 3600 # poll interval while in low-power mode
skip: tuple[str, ...] = ("neighbours",) # requests to skip while in low-power mode
@property
def enabled(self) -> bool:
return self.low_mv > 0
# Keys that may be set in [poll] and overridden per [[repeater]].
POLICY_KEYS = ("interval_seconds", "low_battery_mv", "recover_battery_mv",
"low_battery_interval_seconds", "low_battery_skip")
@dataclass
class RepeaterCfg:
name: str # contact name as seen by the companion
password: str = "" # admin or guest password ("" = rely on existing ACL entry)
pubkey_prefix: str = "" # optional, used instead of name if set
status: bool = True
telemetry: bool = True
neighbours: bool = True
interval: int = 900
battery: BatteryPolicy = field(default_factory=BatteryPolicy)
def build_repeater(raw: dict, poll: dict) -> RepeaterCfg:
eff = {k: raw.get(k, poll.get(k)) for k in POLICY_KEYS}
low = int(eff["low_battery_mv"] or 0)
recover = int(eff["recover_battery_mv"] or low)
if recover < low:
raise ValueError(f"{raw['name']}: recover_battery_mv ({recover}) must be >= low_battery_mv ({low})")
pol = BatteryPolicy(
low_mv=low,
recover_mv=recover,
interval=int(eff["low_battery_interval_seconds"] or 3600),
skip=tuple(eff["low_battery_skip"] if eff["low_battery_skip"] is not None else ("neighbours",)),
)
rest = {k: v for k, v in raw.items() if k not in POLICY_KEYS}
return RepeaterCfg(**rest, interval=int(eff["interval_seconds"] or 900), battery=pol)
@dataclass
class Config:
connection: dict
influx: dict
retries: int = 3
timeout: float = 0 # 0 = use the radio's suggested timeout
state_file: str = "state.json"
repeaters: list[RepeaterCfg] = field(default_factory=list)
@classmethod
def load(cls, path: str) -> "Config":
with open(path, "rb") as f:
raw = tomllib.load(f)
poll = raw.get("poll", {})
return cls(
connection=raw["connection"],
influx=raw["influxdb"],
retries=int(poll.get("retries", 3)),
timeout=float(poll.get("timeout_seconds", 0)),
state_file=poll.get("state_file", "state.json"),
repeaters=[build_repeater(r, poll) for r in raw["repeater"]],
)
# --------------------------------------------------------------------------
# Per-repeater schedule / low-power state (persisted so --once from cron works)
# --------------------------------------------------------------------------
@dataclass
class RepeaterState:
low_power: Optional[bool] = None # None = no battery reading yet
last_poll: float = 0.0 # wall-clock epoch of last poll attempt
battery_mv: Optional[int] = None
def interval(self, rpt: RepeaterCfg) -> int:
return rpt.battery.interval if self.low_power else rpt.interval
def next_due(self, rpt: RepeaterCfg) -> float:
return self.last_poll + self.interval(rpt)
def update_battery(self, rpt: RepeaterCfg, mv: Optional[int]) -> None:
pol = rpt.battery
if mv is None or mv <= 0: # no reading (failed poll, or board can't measure)
return
self.battery_mv = mv
if not pol.enabled:
self.low_power = False
return
was = self.low_power
if was is None:
# Unknown history (first run): be conservative for solar nodes.
self.low_power = mv < pol.recover_mv
elif was and mv >= pol.recover_mv:
self.low_power = False
elif not was and mv < pol.low_mv:
self.low_power = True
if self.low_power != was:
log.log(logging.INFO if was is None else logging.WARNING,"%s: battery %d mV -> %s mode, polling every %ds", rpt.name, mv,
"LOW-POWER" if self.low_power else "normal", self.interval(rpt))
def load_state(path: str) -> dict[str, RepeaterState]:
try:
with open(path) as f:
return {k: RepeaterState(**v) for k, v in json.load(f).items()}
except FileNotFoundError:
return {}
except Exception as e: # noqa: BLE001 - corrupt state is not fatal
log.warning("ignoring unreadable state file %s: %s", path, e)
return {}
def save_state(path: str, states: dict[str, RepeaterState]) -> None:
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump({k: asdict(v) for k, v in states.items()}, f, indent=2)
os.replace(tmp, path)
# --------------------------------------------------------------------------
# Companion radio
# --------------------------------------------------------------------------
async def connect_companion(cx: dict) -> MeshCore:
kind = cx.get("type", "serial")
if kind == "serial":
mc = await MeshCore.create_serial(cx["port"], int(cx.get("baudrate", 115200)))
elif kind == "tcp":
mc = await MeshCore.create_tcp(cx["host"], int(cx.get("port", 5000)))
elif kind == "ble":
mc = await MeshCore.create_ble(cx.get("address"), pin=cx.get("pin"))
else:
raise ValueError(f"unknown connection type {kind!r}")
if mc is None:
raise ConnectionError(f"could not connect to companion radio ({kind})")
log.info("connected to companion %s", mc.self_info.get("name", "?"))
return mc
def find_contact(mc: MeshCore, rpt: RepeaterCfg) -> Optional[dict]:
if rpt.pubkey_prefix:
return mc.get_contact_by_key_prefix(rpt.pubkey_prefix)
return mc.get_contact_by_name(rpt.name)
async def retry(what: str, attempts: int, coro_fn):
"""Mesh requests are lossy; retry a few times before giving up."""
for i in range(1, attempts + 1):
try:
res = await coro_fn()
if res is not None:
return res
log.warning("%s: no response (attempt %d/%d)", what, i, attempts)
except Exception as e: # noqa: BLE001 - lib raises assorted errors
log.warning("%s: %s (attempt %d/%d)", what, e, i, attempts)
if i < attempts:
await asyncio.sleep(2 * i)
return None
async def login(mc: MeshCore, contact: dict, rpt: RepeaterCfg, cfg: Config) -> bool:
async def _do():
ev = await mc.commands.send_login_sync(contact, rpt.password, timeout=cfg.timeout)
if ev is None or ev.type != EventType.LOGIN_SUCCESS:
return None
return ev
ev = await retry(f"{rpt.name} login", cfg.retries, _do)
if ev:
log.info("%s: logged in (admin=%s)", rpt.name, ev.payload.get("is_admin"))
return ev is not None
# --------------------------------------------------------------------------
# Conversion to InfluxDB points
# --------------------------------------------------------------------------
def status_point(rpt: RepeaterCfg, key: str, st: dict, ts: int) -> Optional[Point]:
p = Point("meshcore_repeater_status").tag("repeater", rpt.name).tag("pubkey", key[:12])
n = 0
for src, dst in STATUS_FIELDS.items():
if src in st and st[src] is not None:
v = float(st[src]) if src in FLOAT_STATUS_FIELDS else int(st[src])
p = p.field(dst, v)
n += 1
if "bat" in st:
p = p.field("battery_v", st["bat"] / 1000.0)
return p.time(ts, WritePrecision.S) if n else None
def telemetry_points(rpt: RepeaterCfg, key: str, lpp: list[dict], ts: int) -> list[Point]:
"""One point per LPP entry: tags channel + sensor type, value(s) as float fields."""
pts = []
for item in lpp or []:
p = (Point("meshcore_repeater_telemetry")
.tag("repeater", rpt.name).tag("pubkey", key[:12])
.tag("channel", str(item.get("channel")))
.tag("sensor", str(item.get("type"))))
val = item.get("value")
if isinstance(val, dict):
for k, v in val.items():
p = p.field(k, float(v))
elif isinstance(val, (list, tuple)):
for i, v in enumerate(val):
p = p.field(f"value_{i}", float(v))
elif val is not None:
p = p.field("value", float(val))
else:
continue
pts.append(p.time(ts, WritePrecision.S))
return pts
def neighbour_points(rpt: RepeaterCfg, key: str, nb: dict, mc: MeshCore, ts: int) -> list[Point]:
pts = [Point("meshcore_repeater_neighbours")
.tag("repeater", rpt.name).tag("pubkey", key[:12])
.field("count", int(nb.get("neighbours_count", 0)))
.time(ts, WritePrecision.S)]
for n in nb.get("neighbours", []):
known = mc.get_contact_by_key_prefix(n["pubkey"])
name = known.get("adv_name", "") if known else ""
pts.append(Point("meshcore_repeater_neighbour")
.tag("repeater", rpt.name).tag("pubkey", key[:12])
.tag("neighbour", n["pubkey"])
.tag("neighbour_name", name or n["pubkey"])
.field("snr_db", float(n["snr"]))
.field("secs_ago", int(n["secs_ago"]))
.field("last_heard", int(ts - n["secs_ago"]))
.time(ts, WritePrecision.S))
return pts
# --------------------------------------------------------------------------
# Polling
# --------------------------------------------------------------------------
def battery_from_telemetry(lpp: Optional[list[dict]]) -> Optional[int]:
"""Fallback battery reading: LPP voltage on the 'self' channel (1)."""
for item in lpp or []:
if item.get("type") == "voltage" and item.get("channel") == 1:
try:
return int(round(float(item["value"]) * 1000))
except (TypeError, ValueError):
return None
return None
async def poll_repeater(mc: MeshCore, rpt: RepeaterCfg, cfg: Config, state: RepeaterState) -> list[Point]:
t0 = time.monotonic()
ts = int(time.time())
state.last_poll = time.time()
pts: list[Point] = []
low = bool(state.low_power) and rpt.battery.enabled
wanted = {k: getattr(rpt, k) and not (low and k in rpt.battery.skip)
for k in ("status", "telemetry", "neighbours")}
ok = {k: False for k in wanted}
battery_mv: Optional[int] = None
await mc.ensure_contacts(follow=True)
contact = find_contact(mc, rpt)
if contact is None:
log.error("%s: not in companion's contact list (wait for its advert, or add it)", rpt.name)
return [Point("meshcore_poll").tag("repeater", rpt.name).field("success", 0)
.field("error", "contact not found").time(ts, WritePrecision.S)]
key = contact["public_key"]
logged_in = await login(mc, contact, rpt, cfg)
if logged_in:
# Status first: it carries the battery reading.
if wanted["status"]:
st = await retry(f"{rpt.name} status", cfg.retries,
lambda: mc.commands.req_status_sync(contact, timeout=cfg.timeout))
if st and (p := status_point(rpt, key, st, ts)):
pts.append(p)
ok["status"] = True
battery_mv = st.get("bat")
if wanted["telemetry"]:
lpp = await retry(f"{rpt.name} telemetry", cfg.retries,
lambda: mc.commands.req_telemetry_sync(contact, timeout=cfg.timeout))
if lpp is not None:
pts += telemetry_points(rpt, key, lpp, ts)
ok["telemetry"] = True
if not battery_mv:
battery_mv = battery_from_telemetry(lpp)
if wanted["neighbours"]:
nb = await retry(f"{rpt.name} neighbours", cfg.retries,
lambda: mc.commands.fetch_all_neighbours(contact, timeout=cfg.timeout))
if nb is not None:
pts += neighbour_points(rpt, key, nb, mc, ts)
ok["neighbours"] = True
try:
await mc.commands.send_logout(contact)
except Exception: # noqa: BLE001 - best effort
pass
state.update_battery(rpt, battery_mv)
# Poll health + power-management state: for alerting and dashboards.
p = (Point("meshcore_poll")
.tag("repeater", rpt.name).tag("pubkey", key[:12])
.field("success", int(logged_in and all(ok[k] for k in ok if wanted[k])))
.field("login_ok", int(logged_in))
.field("status_ok", int(ok["status"]))
.field("telemetry_ok", int(ok["telemetry"]))
.field("neighbours_ok", int(ok["neighbours"]))
.field("path_len", int(contact.get("out_path_len", -1)))
.field("duration_s", round(time.monotonic() - t0, 2))
.field("low_power_mode", int(bool(state.low_power)))
.field("interval_s", int(state.interval(rpt))))
if rpt.battery.enabled:
p = (p.field("low_battery_v", rpt.battery.low_mv / 1000.0)
.field("recover_battery_v", rpt.battery.recover_mv / 1000.0))
pts.append(p.time(ts, WritePrecision.S))
log.info("%s: polled in %.1fs, battery=%s mV, mode=%s, next in %ds %s", rpt.name,
time.monotonic() - t0, battery_mv, "low-power" if state.low_power else "normal",
state.interval(rpt), ok)
return pts
async def write_points(cfg: Config, pts: list[Point], dry_run: bool) -> None:
if dry_run:
for p in pts:
print(p.to_line_protocol())
return
ic = cfg.influx
async with InfluxDBClientAsync(url=ic["url"], token=ic["token"], org=ic.get("org", "-")) as client:
await client.write_api().write(bucket=ic["bucket"], record=pts)
log.info("wrote %d points to InfluxDB", len(pts))
async def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("-c", "--config", default="config.toml")
ap.add_argument("--once", action="store_true",
help="poll repeaters that are due, then exit (for cron/systemd timers)")
ap.add_argument("--force", action="store_true", help="with --once: poll every repeater even if not due")
ap.add_argument("--dry-run", action="store_true", help="print line protocol instead of writing")
ap.add_argument("-v", "--verbose", action="store_true")
args = ap.parse_args()
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
cfg = Config.load(args.config)
states = load_state(cfg.state_file)
for r in cfg.repeaters:
states.setdefault(r.name, RepeaterState())
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop.set)
mc: Optional[MeshCore] = None
rc = 0
try:
while not stop.is_set():
now = time.time()
due = [r for r in cfg.repeaters
if args.force or states[r.name].next_due(r) <= now]
if due:
pts: list[Point] = []
try:
if mc is None or not mc.is_connected:
mc = await connect_companion(cfg.connection)
for rpt in due: # sequential: one radio, one request in flight
pts += await poll_repeater(mc, rpt, cfg, states[rpt.name])
except Exception as e: # noqa: BLE001 - reconnect on next cycle
log.error("poll cycle failed: %s", e)
rc = 1
if mc is not None:
try:
await mc.disconnect()
except Exception: # noqa: BLE001
pass
mc = None
# Don't spin on a dead companion: retry unpolled repeaters in a minute.
for rpt in due:
st = states[rpt.name]
if st.next_due(rpt) <= time.time():
st.last_poll = time.time() - st.interval(rpt) + 60
if pts:
try:
await write_points(cfg, pts, args.dry_run)
except Exception as e: # noqa: BLE001 - keep the daemon alive
log.error("InfluxDB write failed: %s", e)
rc = 1
save_state(cfg.state_file, states)
elif args.once:
log.info("no repeater due yet (use --force to poll anyway)")
if args.once:
break
next_wake = min(states[r.name].next_due(r) for r in cfg.repeaters)
try:
await asyncio.wait_for(stop.wait(), timeout=max(1.0, next_wake - time.time()))
except asyncio.TimeoutError:
pass
finally:
if mc is not None:
await mc.disconnect()
return rc if args.once else 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+2
View File
@@ -0,0 +1,2 @@
meshcore>=2.3
influxdb-client[async]>=1.40