Add firmware version tracking and on-device history backfill
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.
This commit is contained in:
1 parent
70f6cd37d6
commit
01c49d7bdd
6 files changed
+419
-24
No files matched your search
@@ -144,7 +144,9 @@ agreement, because it records their GPS position.
|
||||
| `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 | repeater polls: per-request success flags, path length, poll duration, `low_power_mode`, `interval_s`, configured battery thresholds |
|
||||
| `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`) |
|
||||
@@ -159,9 +161,9 @@ tagged `companion`. Both also carry `pubkey` (12 hex characters).
|
||||
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? No.
|
||||
## Does the repeater store telemetry history? Upstream: no. keymind/Cascade: a little.
|
||||
|
||||
I checked this against the firmware source (`examples/simple_repeater`, v1.17.x):
|
||||
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 by
|
||||
@@ -177,6 +179,27 @@ I checked this against the firmware source (`examples/simple_repeater`, v1.17.x)
|
||||
- **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
|
||||
@@ -240,12 +263,14 @@ dropdowns at the top. Sections:
|
||||
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.
|
||||
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), and any extra telemetry sensors.
|
||||
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
|
||||
|
||||
@@ -58,6 +58,8 @@ enabled = true
|
||||
status = true
|
||||
telemetry = true
|
||||
neighbours = true
|
||||
# history = true # keymind/Cascade firmware + admin password: backfill missed
|
||||
# polls from the repeater's 7-day half-hourly battery/temp history
|
||||
|
||||
# [[repeater]]
|
||||
# name = "Hilltop"
|
||||
|
||||
@@ -90,6 +90,20 @@
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
},
|
||||
{
|
||||
"name": "Firmware changes",
|
||||
"enable": true,
|
||||
"hide": false,
|
||||
"iconColor": "purple",
|
||||
"datasource": {
|
||||
"type": "influxdb",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"query": "SELECT \"firmware\" FROM \"meshcore_repeater_info\" WHERE \"repeater\" =~ /^$repeater$/ AND \"changed\" = 1 AND $timeFilter",
|
||||
"textColumn": "firmware",
|
||||
"titleColumn": "",
|
||||
"tagsColumn": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -674,9 +688,20 @@
|
||||
"query": "SELECT \"recover_battery_v\" FROM \"meshcore_poll\" WHERE \"repeater\" =~ /^$repeater$/ AND $timeFilter",
|
||||
"resultFormat": "time_series",
|
||||
"alias": "Recover threshold"
|
||||
},
|
||||
{
|
||||
"refId": "D",
|
||||
"datasource": {
|
||||
"type": "influxdb",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"rawQuery": true,
|
||||
"query": "SELECT \"battery_v\" FROM \"meshcore_repeater_history\" WHERE \"repeater\" =~ /^$repeater$/ AND $timeFilter",
|
||||
"resultFormat": "time_series",
|
||||
"alias": "Battery (on-device history)"
|
||||
}
|
||||
],
|
||||
"description": "Threshold lines come from the collector's config, so they always match what it is using.",
|
||||
"description": "Threshold lines come from the collector's config, so they always match what it is using. Dotted: half-hourly readings the repeater kept itself, backfilled after missed polls (if enabled).",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
@@ -783,6 +808,39 @@
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "Battery (on-device history)"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "color",
|
||||
"value": {
|
||||
"mode": "fixed",
|
||||
"fixedColor": "green"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "custom.lineStyle",
|
||||
"value": {
|
||||
"fill": "dot",
|
||||
"dash": [
|
||||
2,
|
||||
6
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "custom.fillOpacity",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"id": "custom.lineWidth",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -978,6 +1036,17 @@
|
||||
"query": "SELECT \"value\" FROM \"meshcore_repeater_telemetry\" WHERE \"repeater\" =~ /^$repeater$/ AND $timeFilter AND \"sensor\" = 'temperature' GROUP BY \"channel\"",
|
||||
"resultFormat": "time_series",
|
||||
"alias": "Temperature ch$tag_channel"
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"datasource": {
|
||||
"type": "influxdb",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"rawQuery": true,
|
||||
"query": "SELECT \"temperature_c\" FROM \"meshcore_repeater_history\" WHERE \"repeater\" =~ /^$repeater$/ AND $timeFilter",
|
||||
"resultFormat": "time_series",
|
||||
"alias": "MCU temperature (on-device history)"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
@@ -1005,7 +1074,34 @@
|
||||
},
|
||||
"unit": "celsius"
|
||||
},
|
||||
"overrides": []
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "MCU temperature (on-device history)"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "custom.lineStyle",
|
||||
"value": {
|
||||
"fill": "dot",
|
||||
"dash": [
|
||||
2,
|
||||
6
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "custom.fillOpacity",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"id": "custom.lineWidth",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
@@ -1022,7 +1118,8 @@
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dotted: half-hourly MCU temperature the repeater kept itself, backfilled after missed polls (if enabled)."
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
@@ -1881,7 +1978,7 @@
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 58,
|
||||
"w": 8,
|
||||
"w": 6,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
@@ -1950,9 +2047,9 @@
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 8,
|
||||
"x": 6,
|
||||
"y": 58,
|
||||
"w": 8,
|
||||
"w": 6,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
@@ -2022,9 +2119,9 @@
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 16,
|
||||
"x": 12,
|
||||
"y": 58,
|
||||
"w": 8,
|
||||
"w": 6,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
@@ -2085,6 +2182,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"type": "stat",
|
||||
"title": "Firmware",
|
||||
"datasource": {
|
||||
"type": "influxdb",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 18,
|
||||
"y": 58,
|
||||
"w": 6,
|
||||
"h": 8
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "influxdb",
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"rawQuery": true,
|
||||
"query": "SELECT last(\"firmware\") FROM \"meshcore_repeater_info\" WHERE \"repeater\" =~ /^$repeater$/ AND $timeFilter",
|
||||
"resultFormat": "time_series",
|
||||
"alias": "Firmware"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "text",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "/.*/",
|
||||
"values": false
|
||||
},
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"textMode": "value",
|
||||
"justifyMode": "center",
|
||||
"orientation": "auto",
|
||||
"wideLayout": true,
|
||||
"text": {
|
||||
"valueSize": 16
|
||||
}
|
||||
},
|
||||
"description": "Version reported by the repeater, checked daily and after every reboot. Upgrades also show as annotations."
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"type": "timeseries",
|
||||
@@ -2951,7 +3112,7 @@
|
||||
"rawQuery": true,
|
||||
"query": "SELECT \"value\" FROM \"meshcore_companion_telemetry\" WHERE \"companion\" =~ /^$companion$/ AND $timeFilter AND \"sensor\" !~ /^(voltage|temperature|gps)$/ GROUP BY \"companion\", \"sensor\", \"channel\"",
|
||||
"resultFormat": "time_series",
|
||||
"alias": "$tag_companion \u00b7 $tag_sensor ch$tag_channel"
|
||||
"alias": "$tag_companion · $tag_sensor ch$tag_channel"
|
||||
}
|
||||
],
|
||||
"description": "Any other sensors the companion shares, e.g. illuminance, humidity or pressure (environment telemetry).",
|
||||
|
||||
+210
-8
@@ -20,10 +20,12 @@ Usage:
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import collections
|
||||
import getpass
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
@@ -94,7 +96,7 @@ class BatteryPolicy:
|
||||
# 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")
|
||||
REPEATER_KEYS = ("name", "password", "pubkey_prefix", "enabled", *REQUEST_KINDS)
|
||||
REPEATER_KEYS = ("name", "password", "pubkey_prefix", "enabled", *REQUEST_KINDS, "history")
|
||||
COMPANION_KEYS = ("name", "pubkey_prefix", "enabled", "location")
|
||||
NODE_SECTIONS = {"repeater": REPEATER_KEYS, "companion": COMPANION_KEYS} # TOML [[section]] -> keys
|
||||
|
||||
@@ -115,6 +117,7 @@ class NodeCfg:
|
||||
telemetry: bool = True
|
||||
neighbours: bool = True
|
||||
location: bool = False # companion: also request GPS location telemetry
|
||||
history: bool = False # repeater: backfill gaps from its on-device history (admin only)
|
||||
interval: int = 900
|
||||
battery: BatteryPolicy = field(default_factory=BatteryPolicy)
|
||||
|
||||
@@ -155,7 +158,7 @@ def build_node(raw: dict, poll: dict, kind: str = "repeater") -> NodeCfg:
|
||||
raise ValueError(f"{name}: pubkey_prefix must be hex")
|
||||
if kind == "companion":
|
||||
# Companions only answer telemetry requests; there is no login.
|
||||
raw = {**raw, "status": False, "telemetry": True, "neighbours": False}
|
||||
raw = {**raw, "status": False, "telemetry": True, "neighbours": False, "history": False}
|
||||
return NodeCfg(
|
||||
name=name,
|
||||
kind=kind,
|
||||
@@ -166,6 +169,7 @@ def build_node(raw: dict, poll: dict, kind: str = "repeater") -> NodeCfg:
|
||||
telemetry=bool(raw.get("telemetry", True)),
|
||||
neighbours=bool(raw.get("neighbours", True)),
|
||||
location=bool(raw.get("location", False)),
|
||||
history=bool(raw.get("history", False)),
|
||||
interval=_int(eff["interval_seconds"] or 900, f"{name}: interval_seconds", 60),
|
||||
battery=pol,
|
||||
)
|
||||
@@ -266,6 +270,10 @@ class RepeaterState:
|
||||
battery_mv: Optional[int] = None
|
||||
last_success: float = 0.0 # epoch of last fully successful poll
|
||||
last_result: dict = field(default_factory=dict) # summary for the web UI
|
||||
firmware: str = "" # version string from the owner-info request
|
||||
firmware_checked: float = 0.0 # epoch of the last owner-info request
|
||||
uptime_s: Optional[int] = None # last reported uptime, to spot reboots (and possible upgrades)
|
||||
history_through: float = 0.0 # epoch up to which battery/temperature are written (live or backfilled)
|
||||
|
||||
def interval(self, rpt: NodeCfg) -> int:
|
||||
return rpt.battery.interval if self.low_power else rpt.interval
|
||||
@@ -356,16 +364,164 @@ async def retry(what: str, attempts: int, coro_fn):
|
||||
return None
|
||||
|
||||
|
||||
async def login(mc: MeshCore, contact: dict, rpt: NodeCfg, cfg: Config) -> bool:
|
||||
async def login(mc: MeshCore, contact: dict, rpt: NodeCfg, cfg: Config) -> Optional[dict]:
|
||||
"""Returns the LOGIN_SUCCESS payload (is_admin, server_timestamp, ...), or None."""
|
||||
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
|
||||
if ev is None:
|
||||
return None
|
||||
log.info("%s: logged in (admin=%s)", rpt.name, ev.payload.get("is_admin"))
|
||||
return ev.payload or {}
|
||||
|
||||
|
||||
REQ_TYPE_GET_OWNER_INFO = 0x07 # repeater firmware; not in meshcore's BinaryReqType yet
|
||||
|
||||
|
||||
async def req_owner_info(mc: MeshCore, contact: dict, timeout: float) -> Optional[dict]:
|
||||
"""Firmware version, node name and owner info ("ver\\nname\\nowner"). Guests may ask."""
|
||||
res = await mc.commands.send(b"\x32" + bytes.fromhex(contact["public_key"]) + bytes([REQ_TYPE_GET_OWNER_INFO]),
|
||||
[EventType.MSG_SENT, EventType.ERROR])
|
||||
if res is None or res.type == EventType.ERROR:
|
||||
return None
|
||||
wait = timeout or res.payload["suggested_timeout"] / 800
|
||||
ev = await mc.dispatcher.wait_for_event(EventType.BINARY_RESPONSE,
|
||||
attribute_filters={"tag": res.payload["expected_ack"].hex()},
|
||||
timeout=wait)
|
||||
if ev is None:
|
||||
return None
|
||||
text = bytes.fromhex(ev.payload["data"]).decode("utf-8", "replace").strip("\x00")
|
||||
firmware, _, rest = text.partition("\n")
|
||||
_, _, owner = rest.partition("\n")
|
||||
return {"firmware": firmware.strip(), "owner": owner.strip()} if firmware.strip() else None
|
||||
|
||||
|
||||
class CliSession:
|
||||
"""Remote CLI commands to a logged-in repeater. The repeater drops a command whose
|
||||
timestamp isn't newer than the last one it saw from us (including the login, which
|
||||
the companion stamps with its own clock), and replays its cached reply when the same
|
||||
timestamp is resent, so each command gets a fresh, increasing timestamp and retries
|
||||
reuse it."""
|
||||
|
||||
def __init__(self, mc: MeshCore, contact: dict, timeout: float):
|
||||
self.mc, self.contact, self.timeout = mc, contact, timeout
|
||||
self.last_ts = 0
|
||||
|
||||
async def start(self) -> None:
|
||||
dev = 0
|
||||
try:
|
||||
ev = await self.mc.commands.get_time()
|
||||
if ev is not None and ev.type != EventType.ERROR:
|
||||
dev = int(ev.payload.get("time", 0))
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.debug("companion clock read failed: %s", e)
|
||||
self.last_ts = max(int(time.time()), dev)
|
||||
|
||||
def next_ts(self) -> int:
|
||||
self.last_ts = max(int(time.time()), self.last_ts + 1)
|
||||
return self.last_ts
|
||||
|
||||
async def send(self, cmd: str, ts: int) -> Optional[str]:
|
||||
res = await self.mc.commands.send_cmd(self.contact, cmd, timestamp=ts)
|
||||
if res is None or res.type == EventType.ERROR:
|
||||
return None
|
||||
deadline = time.monotonic() + (self.timeout or res.payload["suggested_timeout"] / 800)
|
||||
prefix = self.contact["public_key"][:12]
|
||||
# The reply is queued on the companion like any message; fetch until it turns up.
|
||||
while time.monotonic() < deadline:
|
||||
while True:
|
||||
ev = await self.mc.commands.get_msg()
|
||||
if ev is None or ev.type in (EventType.NO_MORE_MSGS, EventType.ERROR):
|
||||
break
|
||||
p = ev.payload
|
||||
if ev.type == EventType.CONTACT_MSG_RECV and p.get("pubkey_prefix") == prefix and p.get("txt_type") == 1:
|
||||
return p.get("text", "")
|
||||
log.info("discarded a %s message from %s while waiting for a CLI reply",
|
||||
ev.type.value, p.get("pubkey_prefix") or p.get("channel_idx", "?"))
|
||||
await asyncio.sleep(1)
|
||||
return None
|
||||
|
||||
|
||||
# On-device telemetry history (keymind/Cascade repeater firmware): `get telemetry.temp N` and
|
||||
# `get telemetry.volt N` return one day (48 half-hour samples) per page as Base64. Page 1 is
|
||||
# the newest; there are 7. The history lives in RAM, so a reboot clears it.
|
||||
HISTORY_TEMPERATURE, HISTORY_VOLTAGE = 0x11, 0x12
|
||||
HISTORY_PAGES = 7
|
||||
HISTORY_COMMANDS = {"temperature_c": "get telemetry.temp", "battery_v": "get telemetry.volt"}
|
||||
|
||||
|
||||
def decode_history_page(reply: str) -> tuple[str, list[tuple[int, float]]]:
|
||||
"""Decode one history page into (field, [(epoch, value), ...]).
|
||||
Missing and out-of-range samples are dropped. Raises ValueError if it isn't a page."""
|
||||
text = reply.strip()
|
||||
if not text.startswith(">"):
|
||||
raise ValueError(text[:80] or "empty reply")
|
||||
raw = base64.b64decode(text[1:].strip(), validate=True)
|
||||
if len(raw) < 7:
|
||||
raise ValueError("page too short")
|
||||
kind, start, step, count = raw[0], int.from_bytes(raw[1:5], "little"), raw[5] * 60, raw[6]
|
||||
body = raw[7:]
|
||||
out: list[tuple[int, float]] = []
|
||||
if kind == HISTORY_VOLTAGE:
|
||||
if len(body) < count:
|
||||
raise ValueError("voltage page truncated")
|
||||
# 0 = none, 1 = below 1.88 V, 2..254 = 1.88..4.40 V in 10 mV steps, 255 = above 4.40 V
|
||||
for i, code in enumerate(body[:count]):
|
||||
if 2 <= code <= 254:
|
||||
out.append((start + i * step, round((1880 + (code - 2) * 10) / 1000, 2)))
|
||||
return "battery_v", out
|
||||
if kind == HISTORY_TEMPERATURE:
|
||||
# 2-bit status per sample (1 = value), then 7-bit values (0 = -50 C), both MSB first.
|
||||
status_len, data_len = (count * 2 + 7) // 8, (count * 7 + 7) // 8
|
||||
if len(body) < status_len + data_len:
|
||||
raise ValueError("temperature page truncated")
|
||||
status = int.from_bytes(body[:status_len], "big")
|
||||
values = int.from_bytes(body[status_len:status_len + data_len], "big")
|
||||
for i in range(count):
|
||||
if (status >> (status_len * 8 - 2 * (i + 1))) & 0x3 == 1:
|
||||
out.append((start + i * step, float(((values >> (data_len * 8 - 7 * (i + 1))) & 0x7F) - 50)))
|
||||
return "temperature_c", out
|
||||
raise ValueError(f"unknown page type 0x{kind:02x}")
|
||||
|
||||
|
||||
def history_pages_needed(state: "RepeaterState", interval: int, now: float) -> int:
|
||||
"""How many days of history cover the gap since the last data we wrote (0 = no gap)."""
|
||||
if not state.history_through:
|
||||
return 1
|
||||
gap = now - state.history_through
|
||||
if gap < max(3600, 1.5 * interval): # samples are half-hourly; smaller gaps aren't worth the airtime
|
||||
return 0
|
||||
return min(HISTORY_PAGES, math.ceil(gap / 86400))
|
||||
|
||||
|
||||
async def fetch_history(cli: CliSession, rpt: NodeCfg, key: str, pages: int, cfg: Config,
|
||||
now: float) -> Optional[list[Point]]:
|
||||
"""Fetch and decode `pages` days of battery and temperature history. None on failure."""
|
||||
pts: list[Point] = []
|
||||
for fld, cmd in HISTORY_COMMANDS.items():
|
||||
for page in range(1, pages + 1):
|
||||
ts = cli.next_ts()
|
||||
reply = await retry(f"{rpt.name} {cmd} {page}", cfg.retries, lambda: cli.send(f"{cmd} {page}", ts))
|
||||
if reply is None:
|
||||
return None
|
||||
if "history is empty" in reply:
|
||||
break
|
||||
try:
|
||||
got, samples = decode_history_page(reply)
|
||||
except ValueError as e:
|
||||
log.warning("%s: %s %d: unexpected reply: %s", rpt.name, cmd, page, e)
|
||||
return None
|
||||
if got != fld:
|
||||
log.warning("%s: %s %d: got %s page", rpt.name, cmd, page, got)
|
||||
return None
|
||||
for t, v in samples:
|
||||
if SANE_EPOCH <= t <= now + 1800:
|
||||
pts.append(Point("meshcore_repeater_history").tag("repeater", rpt.name)
|
||||
.tag("pubkey", key[:12]).field(fld, v).time(t, WritePrecision.S))
|
||||
return pts
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -464,7 +620,10 @@ async def poll_repeater(mc: MeshCore, rpt: NodeCfg, cfg: Config, state: Repeater
|
||||
.field("error", "contact not found").time(ts, WritePrecision.S)]
|
||||
key = contact["public_key"]
|
||||
|
||||
logged_in = await login(mc, contact, rpt, cfg)
|
||||
session = await login(mc, contact, rpt, cfg)
|
||||
logged_in = session is not None
|
||||
rebooted = False
|
||||
history: Optional[str] = None # backfill outcome for the UI: None = not attempted
|
||||
if logged_in:
|
||||
# Status first: it carries the battery reading.
|
||||
if wanted["status"]:
|
||||
@@ -474,6 +633,9 @@ async def poll_repeater(mc: MeshCore, rpt: NodeCfg, cfg: Config, state: Repeater
|
||||
pts.append(p)
|
||||
ok["status"] = True
|
||||
battery_mv = st.get("bat")
|
||||
if (up := st.get("uptime")) is not None:
|
||||
rebooted = state.uptime_s is not None and up < state.uptime_s
|
||||
state.uptime_s = int(up)
|
||||
|
||||
if wanted["telemetry"]:
|
||||
lpp = await retry(f"{rpt.name} telemetry", cfg.retries,
|
||||
@@ -491,6 +653,44 @@ async def poll_repeater(mc: MeshCore, rpt: NodeCfg, cfg: Config, state: Repeater
|
||||
pts += neighbour_points(rpt, key, nb, mc, ts)
|
||||
ok["neighbours"] = True
|
||||
|
||||
# Firmware version: daily, and after a reboot (which is how an upgrade shows up).
|
||||
if not state.firmware or rebooted or time.time() - state.firmware_checked > 86400:
|
||||
info = await retry(f"{rpt.name} owner info", cfg.retries,
|
||||
lambda: req_owner_info(mc, contact, cfg.timeout))
|
||||
state.firmware_checked = time.time() # also on failure: old firmware may never answer
|
||||
if info:
|
||||
changed = bool(state.firmware) and info["firmware"] != state.firmware
|
||||
if changed:
|
||||
log.warning("%s: firmware changed %s -> %s", rpt.name, state.firmware, info["firmware"])
|
||||
state.firmware = info["firmware"]
|
||||
pts.append(Point("meshcore_repeater_info")
|
||||
.tag("repeater", rpt.name).tag("pubkey", key[:12])
|
||||
.field("firmware", info["firmware"]).field("owner", info["owner"])
|
||||
.field("changed", int(changed))
|
||||
.time(ts, WritePrecision.S))
|
||||
|
||||
# Fill the gap since the last good poll from the repeater's own half-hourly history.
|
||||
live = ok["status"] or ok["telemetry"]
|
||||
if live and rpt.history and (pages := history_pages_needed(state, state.interval(rpt), ts)):
|
||||
clock = session.get("server_timestamp")
|
||||
if not session.get("is_admin"):
|
||||
history = "needs the admin password"
|
||||
elif clock and abs(clock - ts) > 3600:
|
||||
history = f"repeater clock is off by {int(clock - ts)}s"
|
||||
else:
|
||||
cli = CliSession(mc, contact, cfg.timeout)
|
||||
await cli.start()
|
||||
got = await fetch_history(cli, rpt, key, pages, cfg, ts)
|
||||
if got is None:
|
||||
history = "failed (firmware without telemetry history?)"
|
||||
else:
|
||||
pts += got
|
||||
history = f"backfilled {len(got)} samples from {pages} day(s)"
|
||||
state.history_through = ts
|
||||
log.info("%s: history %s", rpt.name, history)
|
||||
if live and history is None: # no gap to fill (or backfill is off)
|
||||
state.history_through = ts
|
||||
|
||||
try:
|
||||
await mc.commands.send_logout(contact)
|
||||
except Exception: # noqa: BLE001 - best effort
|
||||
@@ -505,7 +705,7 @@ async def poll_repeater(mc: MeshCore, rpt: NodeCfg, cfg: Config, state: Repeater
|
||||
state.last_result = {
|
||||
"ts": ts, "success": success, "logged_in": logged_in, "ok": ok, "wanted": wanted,
|
||||
"battery_mv": battery_mv, "duration_s": duration, "path_len": contact.get("out_path_len", -1),
|
||||
"pubkey": key[:12],
|
||||
"pubkey": key[:12], "firmware": state.firmware, "history": history,
|
||||
"error": "" if success else ("login failed (wrong password or no response)" if not logged_in
|
||||
else "no response for: " + ", ".join(missing)),
|
||||
}
|
||||
@@ -522,6 +722,8 @@ async def poll_repeater(mc: MeshCore, rpt: NodeCfg, cfg: Config, state: Repeater
|
||||
.field("duration_s", float(duration))
|
||||
.field("low_power_mode", int(bool(state.low_power)))
|
||||
.field("interval_s", int(state.interval(rpt))))
|
||||
if history is not None:
|
||||
p = p.field("history_ok", int(history.startswith("backfilled")))
|
||||
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))
|
||||
|
||||
+6
-1
@@ -329,6 +329,7 @@ code { font-size: 12.5px; background: var(--bg); padding: 1px 5px; border-radius
|
||||
<label class="cb"><input type="checkbox" name="status" checked> Status</label>
|
||||
<label class="cb"><input type="checkbox" name="telemetry" checked> Telemetry</label>
|
||||
<label class="cb"><input type="checkbox" name="neighbours" checked> Neighbours</label>
|
||||
<label class="cb"><input type="checkbox" name="history"> Backfill gaps from on-device history <span class="muted">— admin password, keymind/Cascade firmware</span></label>
|
||||
</span>
|
||||
<label class="cb" data-only="companion"><input type="checkbox" name="location"> Include location (GPS) <span class="muted">— only with the owner's agreement</span></label>
|
||||
</div>
|
||||
@@ -465,9 +466,11 @@ function renderRepeaters() {
|
||||
const [cls, label] = repStatus(r);
|
||||
const v = r.battery_mv ? (r.battery_mv / 1000).toFixed(2) + " V" : "—";
|
||||
const err = r.enabled && r.result && !r.result.success && r.result.error ? `<div class="err">${esc(r.result.error)}</div>` : "";
|
||||
const fw = r.result?.firmware ? `<div class="muted small">${esc(r.result.firmware)}</div>` : "";
|
||||
const hist = r.result?.history ? `<div class="muted small">History: ${esc(r.result.history)}</div>` : "";
|
||||
const next = !r.enabled ? "—" : r.polling ? "now" : (status.companion.connected ? ago(r.next_due, now) : "waiting for radio");
|
||||
return `<tr>
|
||||
<td data-l="Name"><div class="c"><b>${esc(r.name)}</b>${err}</div></td>
|
||||
<td data-l="Name"><div class="c"><b>${esc(r.name)}</b>${fw}${hist}${err}</div></td>
|
||||
<td data-l="Type"><div class="c"><span class="muted">${r.type === "companion" ? "Companion" : "Repeater"}</span></div></td>
|
||||
<td data-l="Status"><div class="c"><span class="pill ${cls}">${label}</span></div></td>
|
||||
<td data-l="Battery" class="num"><div class="c">${v} ${r.low_power ? '<span class="pill warn">low power</span>' : ""}</div></td>
|
||||
@@ -635,6 +638,7 @@ async function openRepeater(name, fromContact) {
|
||||
setSecret(rf.password, r.password);
|
||||
for (const k of ["enabled", ...KINDS]) rf[k].checked = r[k] ?? true;
|
||||
rf.location.checked = !!r.location;
|
||||
rf.history.checked = !!r.history;
|
||||
const d = config.poll;
|
||||
const dflt = {interval_seconds: d.interval_seconds ?? 900, low_battery_mv: d.low_battery_mv ?? 0,
|
||||
recover_battery_mv: d.recover_battery_mv ?? d.low_battery_mv ?? 0, low_battery_interval_seconds: d.low_battery_interval_seconds ?? 3600};
|
||||
@@ -661,6 +665,7 @@ rf.addEventListener("submit", async e => {
|
||||
if (dlgType === "repeater") {
|
||||
body.password = secretVal(rf.password);
|
||||
for (const k of KINDS) body[k] = rf[k].checked;
|
||||
body.history = rf.history.checked;
|
||||
body.low_battery_skip = rf.skip_override.checked ? KINDS.filter(k => rf["skip_" + k].checked) : null;
|
||||
} else {
|
||||
body.location = rf.location.checked;
|
||||
|
||||
@@ -35,7 +35,7 @@ POLL_KEYS = {"interval_seconds": int, "retries": int, "timeout_seconds": float,
|
||||
"low_battery_interval_seconds": int, "low_battery_skip": list,
|
||||
"local_stats_interval_seconds": int, "location_interval_seconds": int}
|
||||
REPEATER_KEYS = {"name": str, "password": str, "pubkey_prefix": str, "enabled": bool,
|
||||
"status": bool, "telemetry": bool, "neighbours": bool,
|
||||
"status": bool, "telemetry": bool, "neighbours": bool, "history": bool,
|
||||
"interval_seconds": int, "low_battery_mv": int, "recover_battery_mv": int,
|
||||
"low_battery_interval_seconds": int, "low_battery_skip": list}
|
||||
COMPANION_KEYS = {"name": str, "pubkey_prefix": str, "enabled": bool, "location": bool,
|
||||
|
||||
Reference in new issue
Block a user