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>
- 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>
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>
- 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>
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>
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>
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>
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>
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>
- 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>