Files
pe1hvh dad42503f4 feat(repeater): poll repeater statistics on a configurable interval
feat(repeater): poll repeater statistics on a configurable interval

Configured repeaters are logged into, queried for their status and
logged out again on a configurable interval. Raw values are archived
with the moment of each poll, so battery voltage can be tracked over
multiple days.

New modules
- services/repeater_config_store.py — per-device configuration in
  ~/.meshcore-gui/repeaters/<device>_repeaters.json (directory 0700,
  file 0600). Holds the full 32-byte public key, display name, login
  password, poll interval and enabled flag. A key that is not 64 hex
  characters is rejected: the meshcore library refuses a prefix for
  login, status and logout requests. An absent password means "not
  configured", an empty string is a valid credential.
- services/repeater_stats_archive.py — append-only JSONL at
  ~/.meshcore-gui/archive/<device>_repeater_stats.jsonl, one record per
  poll, retention through the existing daily cleanup task.
- services/repeater_poller.py — send_login_sync, req_status_sync,
  send_logout. The sync variants are used because plain send_login and
  send_statusreq return as soon as the frame is on the wire and say
  nothing about the outcome.
- gui/panels/repeater_stats_panel.py — read-only REPEATERS panel showing
  the latest values, the age of the last successful poll and the last
  error per repeater.

Changed
- config.py: VERSION 1.23.0, REPEATERS_DIR, REPEATER_POLL_ENABLED,
  REPEATER_POLL_INTERVAL (900 s), REPEATER_POLL_CHECK_INTERVAL,
  REPEATER_LOGIN_TIMEOUT, REPEATER_STATUS_TIMEOUT,
  REPEATER_STATS_RETENTION_DAYS.
- ble/worker.py: _main_loop runs the repeater poll alongside the existing
  periodic tasks, following the same interval-guarded pattern;
  _cleanup_old_data applies retention to the new archive.
- __main__.py, gui/dashboard.py, gui/panels/__init__.py: wiring.

Behaviour
- At most one repeater is polled per check, each with its own start
  offset within the interval, so two repeaters are never queried back to
  back. Two repeaters on the default interval land ~7.5 minutes apart.
- A failed login, an absent status response or an exception is recorded
  as a poll attempt with ok=false and a reason, and blocks neither the
  other repeater nor the application. Logout is always sent once a login
  has been attempted, including after a timeout — a login that returned
  no confirmation may still have succeeded on the far side.
- Values are stored exactly as reported: no scaling, rounding or
  averaging. Battery voltage keeps the unit the firmware returns.
- The password is reachable through a single method that only the poller
  calls. Objects handed to the GUI have no password field, and no
  password reaches the archive, the log or any error message.
- Polling stays idle until a repeater is configured, so an installation
  without a configuration file behaves exactly as before.

No change to the archive JSON schema, the public REST API or any
existing public method signature. Worker and dashboard constructors
gained optional keyword arguments only.
2026-09-01 03:32:40 +02:00

49 lines
1.8 KiB
Python

cat > /tmp/rxanalyse.py << 'EOF'
import json, collections, statistics
F="/home/hans/.meshcore-gui/archive/_dev_ttyUSB1_rxlog.jsonl"
per_dag=collections.Counter(); adv_dag=collections.Counter()
snr_dag=collections.defaultdict(list); rssi_dag=collections.defaultdict(list)
buren=collections.defaultdict(collections.Counter); uniek=collections.defaultdict(set)
types=collections.defaultdict(collections.Counter)
for r in open(F,encoding="utf-8",errors="replace"):
r=r.strip()
if not r: continue
try: o=json.loads(r)
except: continue
ts=o.get("timestamp_utc","")
if len(ts)<10: continue
d=ts[:10]
per_dag[d]+=1
types[d][o.get("payload_type","?")]+=1
if o.get("payload_type")=="Advert": adv_dag[d]+=1
if isinstance(o.get("snr"),(int,float)): snr_dag[d].append(o["snr"])
if isinstance(o.get("rssi"),(int,float)): rssi_dag[d].append(o["rssi"])
pn=o.get("path_names") or []
if pn:
laatste=pn[-1]
buren[laatste][d]+=1
uniek[d].add(laatste)
dagen=sorted(per_dag)
print("datum totaal advert uniek medSNR medRSSI")
for d in dagen:
s=statistics.median(snr_dag[d]) if snr_dag[d] else 0
rs=statistics.median(rssi_dag[d]) if rssi_dag[d] else 0
print(f"{d} {per_dag[d]:6d} {adv_dag[d]:6d} {len(uniek[d]):5d} {s:6.1f} {rs:7.0f}")
print("\n=== per laatste-hop node (top 15) ===")
top=sorted(buren, key=lambda n: -sum(buren[n].values()))[:15]
print(f"{'node':32s} " + " ".join(x[5:] for x in dagen))
for n in top:
print(f"{n[:32]:32s} " + " ".join(f"{buren[n].get(d,0):5d}" for d in dagen))
print("\n=== payload-types per dag ===")
allt=sorted({t for d in types for t in types[d]})
print(f"{'datum':12s} " + " ".join(f"{t[:9]:>9s}" for t in allt))
for d in dagen:
print(f"{d:12s} " + " ".join(f"{types[d].get(t,0):9d}" for t in allt))
EOF
python3 /tmp/rxanalyse.py