From ad4e0ed13aea1fb7945518eb7c9a16b98e9beafd Mon Sep 17 00:00:00 2001 From: Cort Buffington Date: Sun, 12 Jul 2026 10:00:33 -0500 Subject: [PATCH] reporting: mirror dashboard normalization to dmrlink3 (Step 3) Ports hblink3's dashboard-reporting overhaul to dmrlink3's IPSC path so the two converge (plan: ~/development/dashboard-reporting-normalization.md): - Granular peer events: a centralized reconciliation sweep in both the master and peer maintenance loops (report_maintenance) diffs currently-connected peers vs the last-reported set and emits send_peer connected/disconnected deltas only on change -- the dashboard reflects membership immediately instead of waiting for the periodic full push. (The single master link-state, in PEER mode, continues to ride the snapshot.) - Demote the full push: reporting_loop now calls periodic_push, which re-pushes the full config+bridge snapshot only every REPORT_RESYNC_SECONDS (60s) as a reconciliation safety net and sends a tiny {'type':'ping'} heartbeat on the other ticks. Idle feed stops re-serializing full state every REPORT_INTERVAL. - Ping-loss quality metric (PING_LOSS): detects a peer/master that STAYS CONNECTED but loses keepalives (lossy link -> choppy audio). One uniform gap-inference path serves both roles -- MASTER mode observes peers' MASTER_ALIVE_REQs, PEER mode observes the master's/peers' *_ALIVE_REPLYs to our requests. IPSC's cadence is known (ALIVE_TIMER) so no self-calibration is needed (unlike hblink3). A gap beyond the keepalive-timeout horizon is treated as a session lapse (reconnect), not loss. Sliding window pruned to PING_LOSS_WINDOW minutes; dashboard flags the row gold at >= PING_LOSS_WARN%. Config: PING_LOSS_WINDOW / PING_LOSS_WARN added to GLOBAL (optional, defaults). Dashboard: backend applies peer deltas + ignores the ping heartbeat; frontend renders "N% ping loss" and golds the radio id. Tests: TestPeerEvents + TestPingLoss (73 pass). Verified live over real sockets (peer delta fired, heartbeat/resync split confirmed, PING_LOSS climbed to 30% under a lossy peer) and deployed to the live dmrlink3 services clean. Co-Authored-By: Claude Opus 4.8 --- config.py | 6 ++ dashboard/server.py | 26 ++++- dashboard/static/dashboard.html | 15 ++- dmrlink.py | 177 +++++++++++++++++++++++++++++--- tests/helpers.py | 3 + tests/test_protocol.py | 147 ++++++++++++++++++++++++++ 6 files changed, 355 insertions(+), 19 deletions(-) diff --git a/config.py b/config.py index 44c0c46..4b84795 100644 --- a/config.py +++ b/config.py @@ -134,6 +134,12 @@ def build_config(_config_file): 'PATH': config.get(section, 'PATH'), 'USE_ACL': config.get(section, 'USE_ACL'), 'SUB_ACL': config.get(section, 'SUB_ACL'), + # Per-peer keepalive-loss quality metric (dashboard PING_LOSS): + # window is the sliding span it is measured over (minutes, + # clamped 1..60); warn is the % at/above which the dashboard + # flags the peer/master gold. Optional -- default if absent. + 'PING_LOSS_WINDOW': config.getint(section, 'PING_LOSS_WINDOW', fallback=5), + 'PING_LOSS_WARN': config.getint(section, 'PING_LOSS_WARN', fallback=5), }) elif section == 'REPORTS': diff --git a/dashboard/server.py b/dashboard/server.py index fd34d4d..842c23c 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -178,6 +178,7 @@ class State: self.log = deque(maxlen=LOG_LINES) self.dmrlink = False self.clients = set() # connected WebSockets + self.ping_loss_warn = 5 # PING_LOSS_WARN %: dashboard flags peer/master gold at/above this STATE = State() @@ -231,8 +232,30 @@ async def handle_event(evt): interval = evt.get('report_interval') if interval: FEED_READ_TIMEOUT = max(float(interval) * 3, 30.0) + STATE.ping_loss_warn = evt.get('ping_loss_warn', STATE.ping_loss_warn) STATE.systems = enrich_systems(evt.get('systems', {})) - await broadcast({'type': 'config', 'systems': STATE.systems}) + await broadcast({'type': 'config', 'systems': STATE.systems, + 'ping_loss_warn': STATE.ping_loss_warn}) + + elif t == 'peer': + # Granular IPSC peer connect/disconnect delta. Apply it to the in-memory + # systems view and re-broadcast the (enriched) config so the browser + # renders it without waiting for the next slow resync. Ignored if the + # system isn't known yet -- the on-connect snapshot will carry it. + sysview = STATE.systems.get(evt.get('system')) + if sysview is not None: + peers = sysview.setdefault('PEERS', {}) + rid = str(evt.get('radio_id')) + if evt.get('action') == 'connected' and evt.get('info') is not None: + peers[rid] = evt['info'] + elif evt.get('action') == 'disconnected': + peers.pop(rid, None) + STATE.systems = enrich_systems(STATE.systems) + await broadcast({'type': 'config', 'systems': STATE.systems, + 'ping_loss_warn': STATE.ping_loss_warn}) + + elif t == 'ping': + pass # liveness heartbeat; receiving it already reset the feed read timeout elif t == 'bridge': STATE.bridges = enrich_bridges(evt.get('bridges', {})) @@ -380,6 +403,7 @@ def current_state(): 'bridges': STATE.bridges, 'active': list(STATE.active.values()), 'log': list(STATE.log), + 'ping_loss_warn': STATE.ping_loss_warn, } @app.websocket('/ws') diff --git a/dashboard/static/dashboard.html b/dashboard/static/dashboard.html index cc532c5..db3b685 100644 --- a/dashboard/static/dashboard.html +++ b/dashboard/static/dashboard.html @@ -90,7 +90,7 @@