* README/INSTALL: REPORT_TRANSPORT/REPORT_SOCKET for a same-host dashboard over
a Unix socket (immune to NIC flaps / conntrack eviction), and the matching
DMRLINK_TRANSPORT/DMRLINK_SOCKET on the dashboard side.
* README: note the per-repeater ping-loss figure (PING_LOSS_WINDOW /
PING_LOSS_WARN) the dashboard now shows.
* dashboard/config_sample.py: add DMRLINK_TRANSPORT / DMRLINK_SOCKET.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the hblink3 Step 5 work for the IPSC side:
* peer -> peer_connected / peer_disconnected (the message type now carries
the lifecycle; the dashboard consumes the new names). dmrlink3 has no
'stream' event -- its bridge_event/ping/bridge are IPSC-specific and are
deliberately left as-is (see the hblink/dmrlink dashboard-parity note:
IPSC ping is a firewall timer, not an app-level keepalive).
* Unix-socket transport for a same-box dashboard: REPORT_TRANSPORT=unix binds
start_unix_server on REPORT_SOCKET (stale socket file cleared first); the
dashboard connects via open_unix_connection(DMRLINK_SOCKET) when
DMRLINK_TRANSPORT=unix. TCP stays the default for remote dashboards. A local
socket can't NIC-flap or be conntrack-evicted, retiring the silent-severance
class for local dashboards (filesystem perms replace the IP ACL).
Both REPORT_TRANSPORT/REPORT_SOCKET and DMRLINK_TRANSPORT/DMRLINK_SOCKET are
optional (fallback to tcp), so existing configs keep working unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The 60s feed read-timeout equalled dmrlink3's default REPORT_INTERVAL of 60s, so
on an idle network the 'link dead' timeout raced the periodic config push and
won by ~1s, forcing a needless reconnect every ~2 minutes. (hblink3 pushes every
10s so it never tripped.)
Fix: the daemon now advertises report_interval in its config push, and the
dashboard sizes the read timeout to 3x that (min 30s), so it can never false-trip
regardless of the configured interval. TCP keepalive remains the primary detector
of a genuinely dead socket.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same fix as hblink3: the dmrlink3<->dashboard reporting link (dmrlink3 = TCP
server, dashboard = client) could wedge for hours after any event that drops the
flow without a clean FIN/RST -- NIC/interface flap, DHCP renew, firewall/conntrack
eviction, router reboot -- leaving both processes running but delinked and
requiring a manual dashboard restart.
- dmrlink3 _send_json() now sheds writers that are is_closing() or whose write
buffer has grown past a limit, and sets SO_KEEPALIVE (idle 60s/intvl 15s/cnt 4)
on accepted reporting sockets so the OS errors a dead flow in ~2 min.
- The dashboard feed client now bounds readline() with a 60s timeout so its 3s
reconnect loop actually runs, and sets SO_KEEPALIVE on its side too.
Only affects non-loopback deployments; loopback was never exposed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Last Heard now collapses to an "Active Calls" view (live streams only)
and retitles the header, instead of hiding the section
- Every section is collapsible (systems, conference bridges, call log)
via a shared initCollapse helper, persisted per-browser
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Last Heard: most-recent unique callers derived from the call log
(User / Type / Source / TS / Talkgroup / When), collapsible with
per-browser state; configurable via LAST_HEARD / LAST_HEARD_COUNT.
- Systems: a Show/Hide Peers button collapses each system's peer table;
the system header and TS1/TS2 activity pills stay visible so live
traffic is always in view. Default via SYSTEM_PEERS.
- Docs updated (README.md, INSTALL.md).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tighten the GLOBAL ACL comment (add whitelist/blacklist one-liner and an
ACLS.md pointer) and cut the TRUNK historical-note/wire-format prose down to
the operationally relevant facts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the Python source excerpts, return-value structure, and the
'how matching works' section that would confuse most operators; keep the
config grammar, examples, performance guidance, and warnings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Explains the PERMIT/DENY grammar, the whitelist-vs-blacklist default-action
rule, and how DMRlink3 wires ACLs: per-system REG_ACL for peer registration
(only when MASTER_PEER: True) plus one global SUB_ACL on the bridge. Includes
complex worked examples and honest performance guidance verified against
config.py/dmrlink.py/bridge.py.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the hblink3 dashboard fix. The reaper dropped any active call 60 s after
its START, clipping legitimate transmissions longer than that mid-call and
leaving the dashboard looking idle until the next key-up.
The reaper is only a backstop for a lost/never-sent terminator on a still-
connected feed; a dropped feed already clears active calls on disconnect. Raise
both the server (CALL_STALE) and browser (STALE) thresholds to 300 s, well above
the usual 3-minute amateur time-out timer, so long calls are never clipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a paragraph under "Reporting and Dashboard" describing the ?demo
URL parameter that shows all visual elements without a live dmrlink3
connection.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three systems: KS-MASTER (master IPSC, 3 peers), MO-PEER (peer IPSC,
2 peers), TRUNK-EAST (trunk to 198.51.100.42).
Three bridge groups:
KS-STATEWIDE TGID 9 TS1: KS-MASTER ↔ MO-PEER ↔ TRUNK-EAST
US-NATIONWIDE TGID 31 TS2: KS-MASTER ↔ TRUNK-EAST
MO-LOCAL TGID 3100 TS2: MO-PEER only
Active traffic is internally consistent:
W0ABC keyed up 23s ago on KS Statewide →
KS-MASTER TS1 RX, MO-PEER TS1 TX, TRUNK-EAST TX stream
N0CAL transmitting via trunk on US Nationwide for 11s →
TRUNK-EAST RX stream, KS-MASTER TS2 TX
MO-PEER TS2 in RX hang (W0DEF ended 3s ago on MO Local)
Call log shows the current calls plus the previous KD0XYZ transmission.
setConn(true) so the header shows connected, not "connecting…".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When ?demo is in the URL, skip connect() entirely so the injected
fake trunk data is never overwritten by the server's empty state.
The setInterval ticker continues to run so elapsed timers count up.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TRUNK systems appear in the Systems section (renamed from IPSC Systems)
with a purple TRUNK pill and peer IP:port in the header. Where IPSC
systems show TS1/TS2 slot pills, trunk systems show a flex-wrap row of
the identical spill cards — one per active stream, green for RX and
orange for TX — each showing subscriber → TGID, abbreviated stream ID,
and elapsed time. Empty state shows a single idle card.
?demo URL parameter injects four fake streams (2 RX, 2 TX) and a fake
DEMO-TRUNK system so the layout can be previewed without a live dmrlink3
connection.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two independent gaps in test infrastructure:
1. helpers.make_config() was missing GLOBAL.SUB_ACL and
helpers.make_system() was missing LOCAL.REG_ACL.
In production, process_acls() populates both at startup; the test
helpers never called it. Added PERMIT:ALL defaults matching what
acl_build('PERMIT:ALL', ID_MAX/PEER_MAX) returns.
2. BridgeBase.setUp set bridge.BRIDGES but never called index_bridges()
to populate BRIDGE_SRC_INDEX / BRIDGE_BY_SYSTEM. The routing loop
iterates those indexes (not BRIDGES directly), so all forwarding tests
silently found nothing to forward. Added a _set_bridges() helper that
sets BRIDGES and rebuilds the indexes atomically; updated every
callsite that was assigning bridge.BRIDGES directly. TestCallBoundary
now saves/restores the indexes alongside BRIDGES.
Also removed the _PERMIT_ALL workaround from test_trunk_status.py now
that make_config() supplies SUB_ACL by default.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A TRUNK system carries an arbitrary number of simultaneous streams, so
the previous TS-keyed STATUS dict was semantically wrong and never
actually read by any code (contention checks are bypassed for trunk
targets via TRUNKS list).
- trunk.py TRUNK.__init__: self.STATUS = {} with explanatory comment
- bridge.py bridgeIPSC.group_voice: wrap post-transmit TX state writes
in `if _target['SYSTEM'] not in TRUNKS` — trunk targets have no TS
slots to write into, and the values would never be consulted
- bridge.py bridgeTRUNK.group_voice: same TX guard + remove the final
two RX state writes (self.STATUS[_ts]['RX_TGID/RX_TIME']) that would
KeyError against the now-empty STATUS dict
13 new tests in tests/test_trunk_status.py all pass. The 33
pre-existing failures in test_bridge.py / test_protocol.py (missing
SUB_ACL in test helpers) are unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces a new TRUNK system type that carries full IPSC GROUP_VOICE
payloads between two DMRlink3 instances without the IPSC registration/
keepalive machinery or the two-timeslot repeater restriction.
Wire format: 6-byte prefix (4-byte random stream ID + 2-byte reserved
flags) prepended to the unmodified IPSC payload from the routing core.
Stream IDs are generated at VOICE_HEAD boundaries, eliminating reliance
on the IPSC sequence number (which breaks monotonicity when Talker Alias
alternates between LC and TA fragments). Loop prevention uses a bounded
deque of locally-generated stream IDs. Authentication is by source
IP+port validation only, consistent with the amateur radio context.
Fast TGID ingress filter: built from bridge rules at startup so packets
for TGIDs with no route on this server are dropped before the routing
engine is invoked. TRUNK systems are automatically added to the TRUNKS
contention-bypass list by bridge.py.
New: trunk.py (TRUNK base class, wire protocol, stream tracking)
Modified: config.py (TRUNK section parsing, non-breaking fallback)
Modified: dmrlink.py (_systems_snapshot TRUNK guard, mk_ipsc_systems skip)
Modified: bridge.py (bridgeTRUNK, mk_trunk_systems, TGID filter, auto-TRUNKS)
Modified: dmrlink-SAMPLE.cfg (TRUNK system type documentation)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Merges the slot-pills into the sys-hdr flex row so they sit on the
same line as the system name/mode/address. TS labels are now
left-aligned with the pill edge (align-items:flex-start).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move TS1/TS2 labels out of pill body into a labeled column header above
- Source callsign/ID now on top line, RX/TX · call type · via peer on bottom
- callType() now returns explicit strings: Group Voice, Unit Voice, Group Data, Unit Data
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Apply table-layout:fixed with explicit column widths so Radio ID,
Address, and Status columns line up vertically table-to-table.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Radio ID, address, and status now share the same three-column table
as the master and peer rows, so all columns line up vertically.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
self._config is the system-level dict; REG_ACL lives in the LOCAL
sub-dict, already aliased as self._local.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace try_download calls with _stream_id_file() which uses ijson to
stream-parse radioid.net JSON without ever loading the full document into
memory, and filters to a configurable FILTER_COUNTRIES list on the fly
- Strip unused fields (only id+callsign written to disk) regardless of filter
- Atomic write via .tmp + os.replace() so an interrupted download never
leaves a corrupt file that passes the staleness check
- Add ijson>=3.2 to requirements.txt; drop now-unused try_download import
- Add FILTER_COUNTRIES = ['United States', 'France'] to config_sample.py
- Move talkgroup_ids_SAMPLE.json and local_peer_ids_SAMPLE.json into
dashboard/ where PATH points
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without this, omitting alias file paths produced log noise suggesting
an incomplete configuration. Setting USE_ALIASES: False skips all
alias loading and emits a single clean log line instead.
Defaults to False so new deployments are quiet by default.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ON/OFF/RESET converted from list to frozenset in make_bridge_config():
membership tests on every voice packet are now O(1) instead of O(n).
These fields are pure config and never mutated at runtime, so frozenset
is safe and correct.
- Added index_bridges() and BRIDGE_SRC_INDEX / BRIDGE_BY_SYSTEM indexes,
mirroring the same optimisation already present in hblink3. The per-packet
forwarding loop now does a single O(1) dict lookup on (system, ts, tgid)
instead of iterating all bridges and systems. The VOICE_HEAD trigger loop
uses BRIDGE_BY_SYSTEM for the same benefit. Index is built once in
async_main after make_bridge_config() and holds live references to the
BRIDGES dicts, so ACTIVE/TIMER mutations are visible through it.
RAM delta: negligible (~9 KB total for both changes at typical config sizes).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- const.py: add ID_MIN, ID_MAX, PEER_MAX constants
- config.py: port merge_ranges(), acl_build(), acl_check(), process_acls()
from hblink3; parse USE_ACL/SUB_ACL in [GLOBAL] and REG_ACL per system
- dmrlink.py: import acl_check; gate master_reg_req() with per-system REG_ACL
- bridge.py: remove old file-based build_acl() and allow_sub closure; remove
-s/--sub-acl CLI arg; call process_acls(CONFIG); use acl_check() with
global SUB_ACL in group_voice()
- dmrlink-SAMPLE.cfg: document and add USE_ACL, SUB_ACL, REG_ACL entries
ACL semantics: REG_ACL is per-master-system (ignored on peers, which cannot
control who registers on their upstream master); SUB_ACL is global and gates
source IDs from propagating through the bridge. No TGID ACLs — bridge rules
already control TGID propagation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- enumerate loops: e → _ (variable was never read)
- Remove unused _seq_id assignment in group_voice() (comment noted it
was unreliable with TA and it was never referenced downstream)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signature is (path, filename, url, stale) not (path, url, filename, stale);
the swap caused startup to fail with 'unknown url type: peer_ids.json'.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The dashboard now owns downloading, storing, and refreshing all alias files
(peer_ids.json, subscriber_ids.json). Loading subscriber_ids.json in the bridge
costs ~42 MB RSS per process; defaulting TRY_DOWNLOAD: False eliminates that
overhead. Bridge config PATH now points at dashboard/ so the bridge can still
read dashboard-downloaded files if the operator opts into log callsigns.
Dashboard server.py: adds _download_aliases(), _reload_aliases(), and a daily
_alias_refresh_loop(); downloads run in a thread pool so the event loop
doesn't block on large file fetches. Startup now downloads-then-loads rather
than load-only. New config keys (TRY_DOWNLOAD, PEER_URL, SUBSCRIBER_URL,
STALE_DAYS) added to config_sample.py with safe ImportError fallbacks.
bridge.py: removes refresh_aliases() periodic task (dashboard owns refresh now).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Protocol (dmrlink.py / config.py):
- Add CONNECT_TIME to both master STATUS and peer STATUS, initialised to 0.
- Set CONNECT_TIME = time() at each connection event: master_reg_reply (we
connect to a master), peer_reg_reply (P2P peer confirms registration),
master_reg_req new-peer branch (peer registers with us as master).
- Clear CONNECT_TIME = 0 at all three master-disconnect paths (de_register,
max-missed keepalives, unknown-state branch).
- Expose CONNECT_TIME in _systems_snapshot for both MASTER and PEERS entries.
Dashboard server (dashboard/server.py):
- New enrich_systems(): converts CONNECT_TIME to connected_secs (seconds
since connection, server-side epoch delta) for master and every peer entry.
- Apply enrich_systems() in the config event handler.
Dashboard HTML (dashboard/static/dashboard.html):
- Add js-since handler to the 1s tick so peer uptime counters count live.
- Render "up Xh Ym" as a .sub line under the Address cell for any peer or
master row with a valid connected_secs — both PEER-mode and MASTER-mode
system tables get the same treatment.
- Fix pre-existing bug: snapshot flattens STATUS to top level, so CONNECTED
is at p.CONNECTED, not p.STATUS.CONNECTED; corrected the JS access and
the dependent uptime gate.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Operator-facing sample showing the expected talkgroup alias format;
*.json ignore exists only for large radioid.net database files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
*.json is ignored to keep large radioid.net database files out of the repo;
this sample is small and operator-facing so it belongs tracked.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- dmrlink.py: load local_peer_ids.json from ALIASES PATH after the downloaded
peer_ids file, merging entries in (local wins on conflicts); silently skipped
if the file is absent. Add local_peer_ids_SAMPLE.json as a usage example.
- bridge.py / dmrlink.py: capture build_aliases() return into module-level
globals (peer_ids, subscriber_ids, talkgroup_ids, local_ids); add daily
run_periodic(86400) task that calls build_aliases() and rebinds the globals
so aliases refresh without a restart.
- dashboard: add configurable logo support — new LOGO_FILE key in
config_sample.py; server injects {{LOGO_HTML}} (img tag or empty) so the
brand div renders only when a file is configured and exists. Add .brand /
.logo CSS; update header to use .brand div matching HBlink3 layout.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bridge_rules_SAMPLE.py:
- Document that ON and OFF triggers both fire on key-down (VOICE_HEAD)
- Clarify per-system activation: each system manages its own entry state
- Note ACTIVE=True + TO_TYPE=ON starts the timeout countdown on load;
recommend TO_TYPE=NONE for permanently-active entries
README.md:
- Expand bridge.py key features to describe edge triggering and per-system
activation behavior
- Note ACTIVE=True/TO_TYPE=ON timer-on-load behavior
- Add dashboard features section documenting TS pills (RX/TX/hang/idle),
bridge table timer countdown, and immediate push on state change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows the configured timer duration (e.g. '2m 0s') between Status and
Timer so users can see how long the timer runs regardless of whether
it is currently counting. N/A for TO_TYPE=NONE entries.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Subdued sky-blue (bg #0d2137, text #7dd3fc, border #075985) matches
the accent/system-name color family and feels more passive than the
amber that was there before.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
send_bridge() was sending TIMEOUT (full duration) but not TIMER (the
absolute expiry unix timestamp), so m.remaining was always null on the
client and data-rem was set to 0.
Fix:
- bridge.py: include TIMER in the bridge push payload
- dashboard: derive remaining as TIMER - Date.now()/1000 at render time
so the js-countdown ticks correctly between pushes
- Only show countdown when the timer is actually running (TO_TYPE=ON
and active, or TO_TYPE=OFF and inactive); everything else shows N/A
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After a call ends the pill transitions from RX/TX active to a yellow
'hang' state showing the TGID and a countdown to when the slot is
free again. Handled entirely client-side:
- On GROUP VOICE END / TX END, compute _expires = _arrived + GROUP_HANGTIME
(GROUP_HANGTIME already available in ST.systems from config push)
- New ST.hang{} dict keyed by system|ts, cleared on new call start or
disconnect/reconnect
- Tick loop reaps expired entries (triggers re-render)
- sysTsPill() checks ST.hang between the TX branch and idle branch
- Countdown reuses the existing js-countdown / data-rem / data-arr
mechanism already in the tick loop
No server changes — zero new events or protocol fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both activate and deactivate now fire on key-down so a TGID that turns
off another bridge (e.g. keying 2 to deactivate KS-1/9) takes effect
immediately rather than waiting for the transmission to end.
Removes the VOICE_TERM block entirely; all in-band signaling is now
handled in the single VOICE_HEAD pass before the forwarding loop.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the dashboard could be up to 60s stale on bridge
activation/deactivation because state was only pushed by the periodic
reporting loop. Now send_bridge() fires the moment ACTIVE flips:
- group_voice(): send_bridge() after ON activation (VOICE_HEAD) or
OFF deactivation (VOICE_TERM), guarded by a _bridge_changed flag so
it only sends when state actually changed
- rule_timer_loop(): accepts _report arg (passed via run_periodic) and
calls send_bridge() when a timeout-triggered activate/deactivate fires
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>