61 Commits
Author SHA1 Message Date
Cort BuffingtonandClaude Opus 4.8 32fca3d6c6 changelog: v3.0.0 baseline
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v3.0.0
2026-07-12 13:43:36 -05:00
Cort BuffingtonandClaude Opus 4.8 c6cb4cae00 docs: document the Unix-socket feed and per-repeater ping-loss
* 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>
2026-07-12 13:42:17 -05:00
Cort BuffingtonandClaude Opus 4.8 d727041259 reporting: normalize peer vocabulary + add Unix-socket transport (Step 5)
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>
2026-07-12 12:19:02 -05:00
Cort BuffingtonandClaude Opus 4.8 ad4e0ed13a 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 <noreply@anthropic.com>
2026-07-12 10:00:33 -05:00
Cort BuffingtonandClaude Opus 4.8 671e3fc8fd dashboard: size feed read-timeout to the advertised push interval (fixes reconnect loop)
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>
2026-07-11 18:12:05 -05:00
Cort Buffington 0cdcbc2ab2 Merge fix/reporting-link-keepalive: detect silently-severed dashboard link 2026-07-11 17:53:41 -05:00
Cort BuffingtonandClaude Opus 4.8 e366ad74b8 reporting: detect silently-severed dashboard link (TCP keepalive + read timeout)
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>
2026-07-11 17:44:28 -05:00
Cort BuffingtonandClaude Opus 4.8 db1835d0f8 dashboard: collapsible sections + Active Calls view
- 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>
2026-07-08 09:12:08 -05:00
Cort BuffingtonandClaude Opus 4.8 6abb437fa4 dashboard: add Last Heard table and collapsible peer lists
- 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>
2026-07-06 08:32:17 -05:00
Cort Buffington 9df3ceb90c Fix heading level for GitHub Issues section
Correct the heading level for 'GitHub Issues' in README.
2026-07-03 08:25:34 -05:00
Cort Buffington 64966325b0 Add guidelines for using GitHub issues
Clarify the use of GitHub issues for support and bug reporting.
2026-07-03 08:23:43 -05:00
Cort Buffington 2165fcce71 Revise README for clarity and grammar
Updated README.md for clarity and grammar improvements.
2026-07-03 08:15:11 -05:00
Cort BuffingtonandClaude Opus 4.8 2af0e5c9b2 Trim sample config verbosity; point ACL comments at ACLS.md
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>
2026-07-03 07:13:37 -05:00
Cort BuffingtonandClaude Opus 4.8 011f2ce501 ACLS.md: drop code snippets and internals for a config-focused read
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>
2026-07-03 07:08:09 -05:00
Cort BuffingtonandClaude Opus 4.8 19fc850c6f Add ACLS.md: full documentation of the ACL system
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>
2026-07-03 06:59:59 -05:00
Cort BuffingtonandClaude Opus 4.8 0e6f717c73 dashboard: raise active-call stale timeout to 5 min
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>
2026-07-02 16:32:42 -05:00
Cort Buffington c32e7094ea Modify PATH and update memory usage note in config
Updated the PATH configuration and adjusted memory usage comment.
2026-07-02 15:23:31 -05:00
Cort BuffingtonandClaude Sonnet 4.6 78817f4a11 Document ?demo dashboard mode in README
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>
2026-06-28 18:24:57 -05:00
Cort BuffingtonandClaude Sonnet 4.6 fb97bef45a dashboard: expand demo mode to full coherent traffic scenario
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>
2026-06-28 18:11:43 -05:00
Cort BuffingtonandClaude Sonnet 4.6 f222130149 dashboard: fix demo mode wiped by WebSocket initial event
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>
2026-06-28 18:04:35 -05:00
Cort BuffingtonandClaude Sonnet 4.6 cfe1873ad0 dashboard: add Trunk system display with stream pills
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>
2026-06-28 18:02:49 -05:00
Cort BuffingtonandClaude Sonnet 4.6 2a99e359b7 tests: fix 33 pre-existing failures caused by missing ACL keys and stale routing indexes
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>
2026-06-28 17:55:33 -05:00
Cort BuffingtonandClaude Sonnet 4.6 94e145ec7c trunk: STATUS is {}; routing core skips TX/RX writes for trunk systems
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>
2026-06-28 17:42:24 -05:00
Cort BuffingtonandClaude Sonnet 4.6 8d5307f4c3 Add Trunk v2: server-to-server IPSC bridge with stream ID and fast TGID filter
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>
2026-06-28 16:57:59 -05:00
Cort BuffingtonandClaude Sonnet 4.6 74e2275e3e Dashboard: move TS1/TS2 labels and pills to system info row
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>
2026-06-28 12:55:08 -05:00
Cort BuffingtonandClaude Sonnet 4.6 38f65e3ab2 Dashboard: normalize slot pills — TS label above, src on top, explicit call types
- 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>
2026-06-28 11:39:35 -05:00
Cort BuffingtonandClaude Sonnet 4.6 078dfa076d Dashboard: remove P2P checkmark from peer rows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 11:27:44 -05:00
Cort BuffingtonandClaude Sonnet 4.6 cd73c056cd Dashboard: fix column alignment across system tables
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>
2026-06-28 11:21:40 -05:00
Cort Buffington 2ef602d50e Revert "Dashboard: move local system info into peer table as a self row"
This reverts commit 67971803d2.
2026-06-28 11:18:58 -05:00
Cort BuffingtonandClaude Sonnet 4.6 67971803d2 Dashboard: move local system info into peer table as a self row
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>
2026-06-28 11:16:46 -05:00
Cort BuffingtonandClaude Sonnet 4.6 f292166070 Fix KeyError: look up REG_ACL under self._local, not self._config
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>
2026-06-28 11:11:33 -05:00
Cort BuffingtonandClaude Sonnet 4.6 a432521bb1 Stream-filter subscriber/peer downloads by country; move ID files to dashboard/
- 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>
2026-06-27 21:20:14 -05:00
Cort BuffingtonandClaude Sonnet 4.6 8011467500 Add USE_ALIASES flag to opt out of alias loading cleanly
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>
2026-06-27 20:22:25 -05:00
Cort BuffingtonandClaude Sonnet 4.6 cca3d3701c Hot-path data structure optimisations in bridge.py
- 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>
2026-06-27 19:46:24 -05:00
Cort BuffingtonandClaude Sonnet 4.6 e511b4992c Port hblink3 ACL system to dmrlink3
- 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>
2026-06-27 18:03:14 -05:00
Cort BuffingtonandClaude Sonnet 4.6 845158925b Code cleanup: unused loop vars, dead _seq_id assignment
- 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>
2026-06-27 15:22:28 -05:00
Cort BuffingtonandClaude Sonnet 4.6 e13db6a030 Fix try_download argument order in dashboard server
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>
2026-06-27 15:14:09 -05:00
Cort BuffingtonandClaude Sonnet 4.6 e4830a91bf Move alias file ownership to dashboard; default TRY_DOWNLOAD off in bridge
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>
2026-06-27 15:04:47 -05:00
Cort BuffingtonandClaude Sonnet 4.6 8987453c96 feat: peer uptime counters in systems table; fix P2P status access bug
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>
2026-06-27 14:06:32 -05:00
Cort BuffingtonandClaude Sonnet 4.6 d826807325 chore: add and track talkgroup_ids_SAMPLE.json
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>
2026-06-27 13:57:52 -05:00
Cort BuffingtonandClaude Sonnet 4.6 d5077820f9 chore: track local_peer_ids_SAMPLE.json despite .gitignore
*.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>
2026-06-27 13:56:05 -05:00
Cort BuffingtonandClaude Sonnet 4.6 b8734a9eca feat: alias auto-refresh, local_peer_ids, and dashboard parity with HBlink3
- 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>
2026-06-27 13:53:06 -05:00
Cort BuffingtonandClaude Sonnet 4.6 adc1712058 Update docs: bridge trigger timing, per-system activation, dashboard features
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>
2026-06-26 21:25:15 -05:00
Cort BuffingtonandClaude Sonnet 4.6 f86027fbd2 Show bridge timeout as whole minutes ('2m' not '2m 0s')
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 21:21:26 -05:00
Cort BuffingtonandClaude Sonnet 4.6 e2435a5abd Add Timeout column to bridge table
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>
2026-06-26 21:19:18 -05:00
Cort BuffingtonandClaude Sonnet 4.6 5b9270f87a Change hang time pill to blue instead of yellow
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>
2026-06-26 21:16:28 -05:00
Cort BuffingtonandClaude Sonnet 4.6 2d40929638 Fix bridge table timer countdown — was always showing 'expired'
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>
2026-06-26 21:14:56 -05:00
Cort BuffingtonandClaude Sonnet 4.6 4c297cdb9f Add group hang time state to TS slot pills
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>
2026-06-26 21:13:04 -05:00
Cort BuffingtonandClaude Sonnet 4.6 15d5b22680 Move OFF triggers to VOICE_HEAD alongside ON triggers
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>
2026-06-26 21:04:59 -05:00
Cort BuffingtonandClaude Sonnet 4.6 8ebde1aa55 Push bridge table to dashboard immediately on state change
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>
2026-06-26 20:58:16 -05:00