15 Commits
Author SHA1 Message Date
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 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 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 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 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 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
Cort BuffingtonandClaude Sonnet 4.6 09c9258226 Fix bridge activation edge + timer init; add system-level TS activity pills
Bridge fixes:
- Move ON triggers from VOICE_TERM to VOICE_HEAD so bridge activates on
  key-down and the VOICE_HEAD packet itself is forwarded immediately
- Initialize TIMER to time()+TIMEOUT instead of time() so TO_TYPE=ON entries
  that start ACTIVE=True don't get killed by the first rule_timer_loop run

Dashboard:
- Replace per-peer slot pills with system-level TS1/TS2 pills showing RX/TX
  state, source radio, peer, TGID, call type, and elapsed duration
- Self row removed from peer table; pills move to the Peers heading row

Base class (dmrlink.py):
- Move TS_CLEAR_TIME to dmrlink.py as single source of truth
- Add rx_start/tx_start per-TS call boundary tracking to IPSC base class
- Add transmit_group_voice() for TX call tracking with bridge events
- Add send_bridge_event() stub to base ReportServer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 20:51:22 -05:00
Cort BuffingtonandClaude Sonnet 4.6 3dd8500b98 Load bridge rules and ACL by file path instead of module name
Switches from importlib.import_module() to spec_from_file_location() so
-b and -s accept file paths (e.g. rules.py, /etc/dmrlink/sub_acl.py)
matching HBlink3's convention. Default values updated to bridge_rules.py
and sub_acl.py. Error messages now include the actual path attempted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 15:00:14 -05:00
Cort BuffingtonandClaude Sonnet 4.6 a28a9fb431 Improve bridge rules load error messages
ModuleNotFoundError now tells the user to omit .py from -b argument.
Other exceptions (syntax errors, bad imports) now log the full traceback
via logger.critical instead of printing a bare sys.exit string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 14:52:38 -05:00
Cort BuffingtonandClaude Sonnet 4.6 d38ba50720 Protocol correctness pass from ipsc2hbpc/ipsc2hbp consultation
- const.py: rename CALL_MON_NACK→REPEATER_BLOCKED, UNKNOWN_COLLISION→
  CALL_INTERRUPT_REQ; add seven previously-unknown opcodes (SYSTEM_MAP_REQ/
  REPLY, UNKNOWN_9E, WIRELINE, REMOTE_PROG_REQ/REPLY, OPCODE_0xF0); replace
  BURST_DATA_TYPE dict with integer constants; add GV field offset constants
  (GV_BURST_TYPE_OFF, GV_RTP_TS_OFF, GV_BE_FLAG, GV_BE_LC_FLCO_OFF, etc.)

- dmrlink.py: fix _ts calculation bug (was returning 1 or 33; now correctly
  returns 1 or 2); use burst_type for GROUP_VOICE timeslot detection
  (HEAD/TERM read call_info; SLOT_VOICE reads burst_type bit 7 per protocol);
  master now sends PEER_LIST_REPLY immediately after MASTER_REG_REPLY without
  waiting for PEER_LIST_REQ; peer now sends PEER_LIST_REQ immediately on
  receiving MASTER_REG_REPLY; master sends DE_REG_REPLY and broadcasts updated
  peer list on DE_REG_REQ; peer_list_req sends only to requester (not
  broadcast); master watchdog: any packet from a registered peer refreshes
  keepalive timestamp; new opcodes dispatched at DEBUG instead of error

- bridge.py: replace seq_id-based call boundary detection with per-TS
  call_start tracking using VOICE_HEAD/VOICE_TERM burst types; XPR8400
  firmware churns seq_id every superframe when Talker Alias is interleaved,
  making it unreliable as a call boundary indicator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:18:47 -05:00
Cort BuffingtonandClaude Sonnet 4.6 ce191c2b34 Initial port: Python 3 / asyncio reimplementation of dmrlink
Full port of dmrlink (Python 2 / Twisted) to Python 3 with asyncio, aligned
with the HBlink3 2026_cleanup branch style.

- const.py:  merged ipsc_const + ipsc_mask, all bytes literals corrected for Py3
- log.py:    root-logger config matching HBlink3 log.py
- config.py: configparser Py3 port; bytes built with bytes([]) / bytes.fromhex()
- dmrlink.py: full asyncio IPSC protocol engine; ReportServer (NDJSON/TCP)
  replaces Twisted DatagramProtocol, LoopingCall, NetstringReceiver, and pickle
- bridge.py: asyncio port of confbridge.py, renamed to match HBlink3 convention;
  BridgeReportServer adds NDJSON bridge/call-event streaming
- dashboard/: FastAPI + WebSocket dashboard adapted for IPSC data shapes
  (MASTER/PEER mode, PEERS dict, bridge_event call log)
- requirements.txt, pyproject.toml: package metadata

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:41:31 -05:00