An XLX system is a MODE: OUTBOUND system with an XLX_MODULE letter set. XLX's
DMR interface is the homebrew protocol unmodified -- DMRGateway uses the same
CDMRNetwork class for XLX and BrandMeister, and there are no XLX conditionals in
its network layer -- so this adds no new mode, subclass, or data path. The only
protocol difference is one in-band private call at connect that selects the
module, plus pinning the connection to TS2/TG9.
Module binding is static by design. Runtime or end-user module selection would
break the nailed-up systems/bridges model; DMRGateway is the right tool for
user-selectable modules at a repeater or hotspot.
TS2/TG9 is injected by expand_xlx_bridges rather than read from rules.py, so
XLX_BRIDGES is a flat {system: bridge} map with no TS or TGID column. This is
not a default -- it makes a wrong value inexpressible. That matters more here
than elsewhere because every XLX failure is silent: the reflector never
acknowledges a link, and no frame on the wire carries a module identity, so a
mistyped talkgroup would leave HBlink looking healthy while traffic vanished.
The existing conference-bridge retarget path already rewrites the header dst,
flips the slot bit, and regenerates the full and embedded LC, so no XLX-specific
rewrite code is needed -- an XLX system is just a bridge member with a fixed
TS/TGID.
An XLX system in UNIT is a startup error. Reflector module selection *is* a
private call to 4001-4026, so forwarding a unit call to one could silently move
the reflector into a different room for every user connected to it.
Behaviour verified against xlxd source (src/cdmrmmdvmprotocol.cpp):
* Five acceptance gates at :636-655, each of which silently drops the packet:
exactly 55 bytes, DATA_SYNC frame type, TS2, slot type VOICE_LC_HEADER, and
a valid BS/MS data sync. The 55-byte requirement is why the packet carries
two trailing pad bytes; a bare 53-byte DMRD is rejected.
* Only the three header frames are parsed for the link command; the two
terminators have slot type 2 and are ignored, but are sent to frame the call.
* Unlink-then-link is required, not defensive. At :297-325 a link naming a
different module while already linked is discarded and rewritten to the
module already held, so a reconnect that skipped the unlink would silently
stay in the previous room.
* xlxd reads the destination from DMRD header bytes 8-10 and never decodes the
LC (its BPTC decode is commented out at :657-660). The LC is still built
honestly from the same src/dst: a packet whose payload contradicts its header
is a debugging trap for anything that does decode it.
Note that a stock xlxd is built with NB_OF_MODULES = 10, so only modules A-J
exist unless the operator rebuilt it. A-Z is accepted here since the protocol
allows it, but an absent module produces no link and no error.
tests/test_xlx_link.py adds 35 tests: the acceptance gates transcribed from
xlxd's parser, packet structure, XLX_BRIDGES expansion and its guards, and
XLX_MODULE validation. One test reproduces the canned bursts from the 2019
reference implementation byte for byte, from their own decoded src/dst and
colour code, which pins the burst builder against six years of field-proven
behaviour.
No new dependencies: bitarray and dmr_utils3 were already required.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Points at the separate C companion repos: dmr-talkback (voice test
endpoint), ipsc2hbpc (IPSC<->HBP translator), and cc2obp (c-Bridge
CC-CC<->OpenBridge translator).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
KS-DMR*.png are the operator's own branding (referenced by the gitignored
dashboard/config.py LOGO_FILE), not project assets -- they were swept in by a
`git add -A` in the previous commit. Untrack them and gitignore dashboard/*.png
so they stay local.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Nothing in the asyncio core imports any of these; they are moved to archive/
(history preserved) and dropped from pyproject.toml py-modules:
* bridge_all.py -- forward-everything proxy, never ported to the asyncio core
(imports the Twisted-era reportFactory -> ImportError today).
* playback.py -- talkback/parrot app (connects like a repeater, plays a stream
back). Twisted-based; earmarked to return as an OmniLink adapter.
* play_ambe.py / mk_voice.py / voice_lib.py -- Twisted-era voice test utilities.
* reporting_const.py -- REPORT_OPCODES for the old pickle/opcode reporting
protocol, superseded by the NDJSON feed; it was imported but never used.
Also removes the now-dead `from reporting_const import *` (hblink.py) and the
unused REPORT_OPCODES name from bridge.py's hblink import. Docs/service updated
(README app table, INSTALL run + legacy note, hblink3.service comment,
dashboard/FIELDS.md); archive/README.md explains what's there and why. Suite
still 74/74.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Documentation audit for the recent changes:
* MODE terminology MASTER->SERVER, PEER/CLIENT->OUTBOUND corrected across
README, INSTALL, CONFIGURING, ACLS, and the dashboard docs (the code only
accepts SERVER/OUTBOUND/OPENBRIDGE, so the old names in docs were wrong).
* README gains a top-of-file breaking-change warning (OBP routing moved to
OBP_BRIDGES; system-mode rename) with a pointer to the migration tool.
* CONFIGURING documents REPORT_TRANSPORT/REPORT_SOCKET (Unix-socket feed) and
a "Migrating from the old inline form" section for tools/migrate_obp_rules.py.
* dashboard/README documents HBLINK_TRANSPORT/HBLINK_SOCKET and corrects the
stale "changes appear on the next snapshot" claim (connect/disconnect are
granular pushes now).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tools/migrate_obp_rules.py reads hblink.cfg (to learn which systems are
OPENBRIDGE) and an old-form rules.py, lifts every inline OBP member into an
OBP_BRIDGES table, drops any bridge left empty (OBP_BRIDGES recreates it at
load), flags ingress-fork ERRORs and renumber WARNINGs, prints a summary, and
writes a new file (never overwrites rules.py). Comments are not preserved.
tests/test_obp_bridges.py gains a round-trip proof: an old inline config
migrated then loaded the new way yields identical routing indexes, plus
fork/renumber/TS-override/empty-bridge cases (74 tests total, all green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OpenBridge systems are point-to-point trunks that pass talkgroups by TGID: no RF
user to key ON/OFF/TIMEOUT triggers, no meaningful timeslot. Configuring them as
inline BRIDGES members was verbose and made every trunk TGID carry dead trigger
machinery. They now live in a compact per-OBP table in rules.py:
OBP_BRIDGES = { <OPENBRIDGE system> : { <bridge name> : <TGID> } } # TS 1, or (TGID, TS)
expand_obp_bridges() (bridge.py) expands each row into a synthetic bridge member
with triggers hard-wired inert (TO_TYPE='NONE') and appends it to the named
bridge, so the forwarding path and indexes run UNCHANGED. Because an unmapped
TGID is never a routing key, the fail-closed ingress/egress filter falls out for
free -- no separate ACL. A TGID-heavy trunk now costs zero rule-timer work.
Load-time validation (design spec §3.3/§3.5):
* OBP system left as an inline BRIDGES member -> hard ERROR
* OBP_BRIDGES entry that isn't an enabled OPENBRIDGE system -> hard ERROR
* one TGID -> two bridges on a single OBP (ingress fork) -> hard ERROR
(the mirror, a bridge with two TGIDs on one OBP, is unrepresentable in the
{bridge: tgid} dict, so it needs no check)
* a bridge carrying different TGIDs across two OBPs (renumber-in-transit)
-> non-blocking WARNING
OBP_BRIDGES is optional (getattr fallback), so non-OBP configs are unaffected.
tests/test_obp_bridges.py covers expansion, TS default/override, and every
validation branch (10 tests). rules_SAMPLE.py + CONFIGURING.md document the model.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consistency fix: hblink3 calls this entity a REPEATER everywhere -- the
REPEATERS dict, json_repeater(), and the dashboard UI -- and so does hblink4
(repeater_connected). The lone peer_* event name was the odd one out. Rename
the wire vocabulary to repeater_connected / repeater_disconnected so the same
thing isn't called "peer" in one place and "repeater" in another.
dmrlink3 keeps peer_* deliberately: IPSC genuinely has "peers" (PEERS dict,
_peer_snapshot, UI says Peer). The internal send_peer/report_peer_deltas method
names stay as-is in both daemons -- shared ReportServer plumbing kept symmetric
for the deferred Step-4 extraction; only the wire/UI vocabulary changed here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Logs into a running SERVER as a HomeBrew repeater and sends keepalive pings
with a deliberate, bounded drop rate (clean warmup first so the server
calibrates the cadence; never more than --max-consec dropped in a row so it
can't trip MAX_MISSED). Lets you watch the per-repeater ping-loss % climb and
the callsign turn gold without a genuinely marginal RF link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Canonical event names across the reporting feed, matching hblink4's emit()
vocabulary (the OmniLink north star):
* peer -> peer_connected / peer_disconnected
* stream -> stream_start / stream_end
The lifecycle now rides the message `type`; the `action` field is retained
where consumers still use it (log label, END duration/hang handling).
Unix-socket transport for a same-box dashboard, alongside the existing TCP:
* daemon: REPORT_TRANSPORT=unix binds start_unix_server on REPORT_SOCKET
(stale socket file cleared first); TCP stays the default for remote dashboards.
* dashboard: HBLINK_TRANSPORT=unix connects via open_unix_connection(HBLINK_SOCKET).
A local Unix socket can't NIC-flap or be conntrack-evicted, retiring the whole
silent-severance class for local dashboards (filesystem perms replace the IP ACL;
TCP keepalive is moot).
Also: ping-cadence calibration now takes the MEDIAN of the first PING_WARMUP
gaps (robust to an odd first interval), counting loss only once the cadence
is known.
Tests updated for the new vocabulary; fixes an unbounded-loop test bug in
test_loss_registers_then_ages_out (the window-fill loop's bound tracked the
list it was appending to -> infinite append -> OOM).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The exponential-decay (tau) metric was hard to explain. Replace it with a simple,
exact 'ping loss % over the last PING_LOSS_WINDOW minutes' -- easy to reason about
('3% loss over the last 5 minutes'). The gap-based, self-calibrated miss detection
is unchanged (a repeater that just pings slower still isn't read as lossy); only
the aggregation is now a pruned event window instead of a decaying counter.
Two [GLOBAL] knobs (optional, sane defaults keep existing configs working):
- PING_LOSS_WINDOW: minutes, default 5, clamped 1-60.
- PING_LOSS_WARN: gold-threshold %, default 5, advertised to the dashboard.
Documented in hblink-SAMPLE.cfg and CONFIGURING.md. Dashboard now reads the gold
threshold from config (was hard-coded 5). Dropped PING_QUALITY_TAU and the math.exp
import. Tests: clean/slower-cadence -> 0, dropped -> loss, and old loss ages out of
the window -> 0. 59 tests pass. Verified live at a picky 1min/1% (local hblink.cfg):
healthy repeaters read 0%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
W0ABC=0% (clean), KD0XYZ=3% (loss tag, below gold threshold), N0MJS=24%
(gold callsign + loss tag) so the chronic-connectivity visuals are visible in
demo mode without simulating a lossy link.
Surfaces the case the retired warn light was a poor proxy for: a repeater that
stays connected but loses pings over time (lossy link -> choppy/dropped audio).
Server-side, at each received RPTPING we infer how many pings were lost in the
gap since the previous one, against a per-repeater self-calibrated cadence (so a
repeater that simply pings slower is not read as lossy). Received and missed
counts decay exponentially (PING_QUALITY_TAU, ~15 min) VU-meter style, so the
value reflects recent/chronic loss and rises/falls slowly. Exposed as PING_LOSS%
in each repeater's snapshot (rides the 60s resync -- it moves slowly, so that
cadence is fine); the dashboard shows '· N% ping loss' and ambers the callsign
past a threshold. History is keyed by radio id and survives reconnects, so a
flapping lossy repeater still accumulates a meaningful figure.
Tests: clean cadence -> 0%, slower cadence -> 0% (not mistaken for loss),
dropped pings -> clear loss. Verified live: real repeaters read 0%. 58 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Now that peer connect/disconnect travel as granular events (Step 1) and bridge
changes propagate on-change via rule_timer_loop, the full config+bridge push no
longer needs to run every REPORT_INTERVAL. It sent ~3.3 KB every 10s of mostly-
unchanged state -- the redundancy N0MJS flagged.
- periodic_push now sends a full resync only every REPORT_RESYNC_SECONDS (60s) as
a reconciliation safety net; between resyncs it sends a tiny {'type':'ping'}
heartbeat so the dashboard's link-liveness timeout stays fed. _resync_due() in
the base ReportServer; BridgeReportServer resyncs config+bridge together.
- Dashboard treats 'ping' as a no-op (liveness only).
- Retired the point-in-time 'missed ping' warn light: it was a poor proxy and
would false-fire on stale last_ping between resyncs. The genuinely useful thing
it hinted at -- chronic connectivity offenders -- will be a cumulative
indicator built from missed/disconnect events (next).
Verified live: full config dropped from ~every-10s to on-connect + ~60s; pings
every 10s; peer events immediate; dashboard clean, no false timeouts. 55 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dashboard learned about repeater connect/disconnect only via the periodic
full-config push (up to REPORT_INTERVAL late), because there were no granular
peer events -- the full push WAS the change-propagation mechanism. Add proper
deltas so the dashboard updates immediately, as the first step toward demoting
that redundant full push.
- json_repeater() extracted (shared by the snapshot and the new event).
- ReportServer.send_peer() emits {'type':'peer', system, radio_id, action, info}.
- HBSYSTEM.report_peer_deltas(): centralized reconciliation sweep -- diffs
currently-connected peers vs last reported and emits only deltas, so it can't
miss any of the scattered add/remove paths and never floods. Called from the
server maintenance loop (every PING_TIME) and immediately on config completion
for snappy connect feedback.
- Dashboard backend applies the delta and re-broadcasts config (no browser-JS
change yet). Full push kept as fallback this step; Step 2 demotes it.
- Tests: connect emits one 'connected' w/ info, no duplicate on re-sweep,
timeout emits 'disconnected'. 55 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same latent bug dmrlink3 hit: a fixed 60s feed read-timeout false-trips if
REPORT_INTERVAL is raised to >=~20s (hblink3's default 10s masked it). The daemon
now advertises report_interval in its config push, and the dashboard sizes the
'link dead' read timeout to 3x that (min 30s), so it can't race the periodic push
at any interval. TCP keepalive remains the primary detector of a dead socket.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The test harness still used the old config MODE vocabulary (MASTER/PEER) that the
current schema no longer accepts, so build_config() silently dropped those
systems and ~33 tests failed (config not loaded). Migrate the fixtures and test
code to the current terms; the whole suite (52 tests) now passes.
- harness.cfg: MODE MASTER->SERVER, PEER->OUTBOUND; MAX_PEERS->MAX_REPEATERS;
MASTER_IP/PORT->SERVER_IP/PORT. System labels MASTER-1/-2 -> SERVER-1/-2
(REPEATER-1 kept; 'repeater' is not a dropped term).
- test code: _peers->_repeaters, send_master->send_server,
MASTER_SOCKADDR->SERVER_SOCKADDR, send_bridgeEvent->send_bridge_event mocks.
- harness.py: the 'mark connected' gate keyed on mode=='PEER' -> 'OUTBOUND', so
the router again forwards to outbound systems (fixes the routing/baseline
scenarios).
- Comments/identifiers describing our system roles moved off master/peer;
genuine HBP wire-protocol terms (peer-id, the DMRD peer field, the homebrew
login handshake) are left as-is.
- baseline_emissions.json regenerated consistently for the renamed systems.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reporting tests referenced the removed Twisted reportFactory/send_clients/
bridgeReportFactory API. Rewrite them against the current ReportServer /
BridgeReportServer, and add coverage for the dead-client shedding added in the
keepalive fix (is_closing() and over-limit write buffer). Self-contained mocks,
no dependency on config fixtures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hblink3<->dashboard reporting link (hblink3 = TCP server on REPORT_PORT,
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. Neither end could detect it:
- hblink3's _send_json() used StreamWriter.write() with no drain(), which
practically never raises for a dead peer, so the client was never shed and
no disconnect was logged. It now sheds writers that are is_closing() or whose
write buffer has grown past a limit (unresponsive/stuck peer), and closes them.
- hblink3 now sets SO_KEEPALIVE (idle 60s / intvl 15s / cnt 4) on accepted
reporting sockets so the OS errors a dead flow in ~2 min, unblocking the read
loop that removes the client.
- The dashboard feed client blocked forever on readline() with no timeout, so
its otherwise-correct 3s reconnect loop never ran. It now bounds readline()
with a 60s timeout (hblink3 pushes config at least every REPORT_INTERVAL) and
sets SO_KEEPALIVE on its side too.
Only affects non-loopback deployments (dashboard on a different host, or pointed
at the LAN IP); loopback connections were never exposed to this.
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 (servers, outbounds, openbridges,
conference bridges, call log) via a shared initCollapse helper,
persisted per-browser
- Outbound systems: fold the system name into the header row and move
TS1/TS2 into the slot-column headers, dropping the redundant name line
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.
- Server systems: a Show/Hide Connections button collapses the
connection table; when collapsed, system-level TS1/TS2 activity pills
replace it so live calls stay visible. Default via SERVER_REPEATERS.
- Add a server timestamp to logged stream events so Last Heard 'time ago'
is accurate after a page reload.
- Docs updated (dashboard/README.md, dashboard/FIELDS.md).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OBP per-stream STATUS record did not retain the source peer, so the
timeout-cleanup END report substituted the system NETWORK_ID. Store the
peer at stream start and use it (falling back to NETWORK_ID) so the
timeout END matches the START event -- the true source repeater is now
reported when PRESERVE_SOURCE_PEER is enabled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dashboard sample cited ~150 MB and CONFIGURING.md cited ~500 MB for
the same subscriber DB; they describe different states. Make both say so:
~150 MB with FILTER_COUNTRIES applied, ~500 MB unfiltered global (and that
again if a second process loads it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New CONFIGURING.md is the full field reference for hblink.cfg (GLOBAL,
REPORTS, LOGGER, ALIASES, and the SERVER/OUTBOUND/OPENBRIDGE system
modes) and rules.py (BRIDGES/UNIT), matching the cc2obp CONFIGURING.md
house style. hblink-SAMPLE.cfg and rules_SAMPLE.py comments are trimmed
to terse one-liners that point here.
Notable content:
- Server/Client terminology throughout (a "master" is a Server; Clients
are repeaters/hotspots, or an OUTBOUND HBlink masquerading as one).
- Hard warning that a rules.py SYSTEM with no matching hblink.cfg system
stops bridge.py from starting.
- ALIASES memory warning: full subscriber DB ~500 MB per process, >1 GB
if the dashboard loads it too; use dashboard-only or filter via
FILTER_COUNTRIES.
- BrandMeister prohibits OUTBOUND/Client links and requires OpenBridge.
- BOTH_SLOTS is HBlink-to-HBlink only; no other server accepts it.
- systemd/journal logging note; pointer to ACLS.md for the ACL model.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
By OpenBridge convention, send_system overwrites the DMRD "Repeater ID"
field (bytes 11-14) with this server's own NETWORK_ID. That field is not
validated by the reference implementation -- authentication is the HMAC
(keyed by PASSPHRASE) plus the source socket -- and in the OBP path it is
used only for logging/reporting. Overwriting it therefore discards the
originating repeater/peer ID that a plain HBP/IPSC path would carry.
PRESERVE_SOURCE_PEER (default False, i.e. spec-conventional behavior)
skips the overwrite so the true source peer propagates end-to-end,
improving provenance when isolating a call's original RF source across
the network. Parsed with fallback=False so existing configs are
unaffected. Verified _peer_id is logging/reporting-only throughout the
OBP path (routerOBP/bridge.py): routing keys on (system,slot,dst_id),
loop-prevention on system name, dedup on stream_id -- none touch it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the long ACL preamble with a concise summary that points to ACLS.md,
and drop the outdated 'ACLs consume packet processing time' warning that the
current bisect/set implementation makes moot. Tighten the OpenBridge, Server,
and Outbound section comments. Update ACLS.md to stop referencing the removed
caution.
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, the four ACL types (REG/SUB/TGID_TS1/TGID_TS2), GLOBAL+SYSTEM layering,
the USE_ACL switch (and why REG_ACL is always enforced), the OpenBridge TS1
case, complex worked examples, and honest performance guidance verified
against config.py/hblink.py.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dashboard dropped any active stream 30 s after its START event, since
HBlink3 emits only START/END (no mid-call keepalive) and the reaper anchored
to START without refreshing. A transmission longer than 30 s therefore had all
its pills reaped mid-call, 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 all active streams on disconnect,
and HBlink3 synthesizes an END a couple seconds after audio stops. Raise both the
server (STREAM_STALE) and browser (STALE) thresholds to 300 s, well above the
usual 3-minute amateur time-out timer, so legitimate long calls are never clipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
.call has width:100% for use in fixed-width slot columns, but in the
OpenBridge table multiple spans share one <td>. Override with width:auto
so pills size to their content and flow inline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Loads a static pre-populated state (SERVER + OUTBOUND + OPENBRIDGE,
three bridge groups, live calls bridged across systems, a TX hang slot,
and a call log) without requiring a live HBlink3 connection. Skips
WebSocket connect() in demo mode so the state is never overwritten.
Documents ?demo in dashboard/README.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Server and outbound renderers now use a flex sys-row with system info
on the left and TS1/TS2 labels on the right, on the same line
- Labels are left-justified at padding-left:10px, matching the left edge
of the call pill in the table column below
- TS1/TS2 text removed from table <th> (columns kept for structure);
outbounds restructured into per-system sections to match servers layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The dashboard hang-time pill reads this field; without it the value
was always undefined/0 and hang state was never recorded.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Bottom line of active call now shows RX/TX · call type · src ID · elapsed
- Active call bottom line inherits the rx/tx color (opacity 0.85) instead
of reverting to muted grey
- Hang-time state tracked in ST.hang on call END; displayed as a blue
'hang' pill with countdown until the next call can interrupt
- Hang entries reaped by the 1-second ticker; callCell accepts hang as
third arg so server and outbound renderers both show it
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Source → destination on top, call type · ID · elapsed on bottom,
matching DMRlink3 convention.
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 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>
make_bridges() now converts ON, OFF, and RESET trigger lists to frozensets
instead of mutating list entries in place. Membership tests fired on every
voice packet are now O(1) instead of O(n).
Also fixes a latent bug: RESET entries were not being converted to bytes_3
in make_bridges(), so any non-empty RESET list would silently never match
(bytes vs int comparison). frozenset conversion applies bytes_3 to all
three trigger sets uniformly.
RAM delta: negligible (~9 KB at typical config sizes).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the protocol/transport factory pair (report + reportFactory /
bridgeReportFactory) with a single ReportServer class using
asyncio.start_server (streams API), matching what dmrlink3 already uses.
hblink.py:
- Remove report(asyncio.Protocol) and reportFactory classes
- Add ReportServer: start() via asyncio.start_server, _client_connected()
coroutine handles ACL, initial state push, read loop, and disconnect
cleanup; _send_json() broadcasts; periodic_push() drives the timer
- Simplify config_reports() — no inner function, calls periodic_push
bridge.py:
- Remove duplicate config_reports() — now imported from hblink
- Replace bridgeReportFactory(reportFactory) with BridgeReportServer(ReportServer)
- send_bridge() and send_bridge_event() use _send_json() directly
- periodic_push() overrides base to push both config and bridge state
- send_initial() removed — _client_connected() in base class calls
send_config() + send_bridge() on every new connection
- Rename send_bridgeEvent → send_bridge_event (snake_case consistency)
- Update import: reportFactory → ReportServer, add config_reports
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>