Compare commits

..
106 Commits
Author SHA1 Message Date
Cort BuffingtonandClaude Opus 4.8 f21eca42b0 changelog: v3.0.0 baseline
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 13:43:25 -05:00
Cort BuffingtonandClaude Opus 4.8 a3e99a2da9 docs: refresh for the SERVER/OUTBOUND rename, OBP_BRIDGES, and Unix-socket feed
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>
2026-07-12 13:42:08 -05:00
Cort BuffingtonandClaude Opus 4.8 b43ea79c48 tools: OBP config migration tool (inline members -> OBP_BRIDGES)
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>
2026-07-12 13:42:08 -05:00
Cort BuffingtonandClaude Opus 4.8 cbe405c879 rules: per-OBP TGID<->bridge table (OBP_BRIDGES) replacing inline OBP members
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>
2026-07-12 12:56:56 -05:00
Cort BuffingtonandClaude Opus 4.8 405aad466b reporting: emit repeater_connected/_disconnected (not peer_*) in hblink3
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>
2026-07-12 12:32:39 -05:00
Cort BuffingtonandClaude Opus 4.8 8a11af25ed tests: add lossy-repeater simulator for the ping-loss visuals
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>
2026-07-12 12:14:30 -05:00
Cort BuffingtonandClaude Opus 4.8 1c463fb52b reporting: normalize event vocabulary + add Unix-socket transport (Step 5)
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>
2026-07-12 12:14:23 -05:00
Cort BuffingtonandClaude Opus 4.8 5d9ae6b19f ping-loss: switch to a plain sliding-window % over the last N minutes; make it configurable
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>
2026-07-12 07:51:00 -05:00
Cort Buffington 9826937487 dashboard demo: seed PING_LOSS on demo repeaters to show the ping-loss visuals
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.
2026-07-11 21:29:55 -05:00
Cort Buffington eb7f4d61a7 ping-quality: set decay time constant to 5 min (was 15) 2026-07-11 21:23:44 -05:00
Cort BuffingtonandClaude Opus 4.8 8b0f8906b1 reporting: per-repeater ping-loss quality metric (chronic connectivity)
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>
2026-07-11 21:18:36 -05:00
Cort BuffingtonandClaude Opus 4.8 8c59c5e1d8 reporting: demote the full push to a slow resync + heartbeat (Step 2)
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>
2026-07-11 21:02:19 -05:00
Cort BuffingtonandClaude Opus 4.8 deb1d55f04 reporting: granular 'peer' connect/disconnect events (Step 1 of dashboard normalization)
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>
2026-07-11 20:27:53 -05:00
Cort BuffingtonandClaude Opus 4.8 4731cabbf7 dashboard: size feed read-timeout to the advertised push interval
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>
2026-07-11 18:13:14 -05:00
Cort BuffingtonandClaude Opus 4.8 313cd1de8e tests: migrate harness off the dropped MASTER/PEER terminology
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>
2026-07-11 18:07:00 -05:00
Cort Buffington b8e43c1474 Merge fix/reporting-link-keepalive: detect silently-severed dashboard link 2026-07-11 17:53:31 -05:00
Cort BuffingtonandClaude Opus 4.8 8d18436aab tests: rewrite stale reporting tests for the asyncio ReportServer
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>
2026-07-11 17:53:31 -05:00
Cort BuffingtonandClaude Opus 4.8 a41572e89d reporting: detect silently-severed dashboard link (TCP keepalive + read timeout)
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>
2026-07-11 17:39:22 -05:00
Cort BuffingtonandClaude Opus 4.8 7eea4558d8 dashboard: collapsible sections + Active Calls view + compact outbound
- 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>
2026-07-08 09:11:56 -05:00
Cort BuffingtonandClaude Opus 4.8 01cd53456a dashboard: add Last Heard table and collapsible connection 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.
- 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>
2026-07-06 08:31:42 -05:00
Cort BuffingtonandClaude Opus 4.8 40fda71210 OpenBridge: report the real source peer on timeout-cleanup stream ends
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>
2026-07-06 08:31:35 -05:00
Cort BuffingtonandClaude Opus 4.8 654730de9f Clarify alias RAM figures: ~150 MB filtered vs ~500 MB unfiltered
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>
2026-07-05 17:12:50 -05:00
Cort BuffingtonandClaude Opus 4.8 c9e2166aab Add CONFIGURING.md; slim sample-file comments to point to it
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>
2026-07-05 17:08:54 -05:00
Cort BuffingtonandClaude Opus 4.8 6c9c1be5bb Add PRESERVE_SOURCE_PEER option to OpenBridge systems
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>
2026-07-05 15:30:41 -05:00
Cort Buffington 0a9f87ff52 Change GitHub Issues section header to H3
Corrected heading level for GitHub Issues section in README.
2026-07-03 08:25:10 -05:00
Cort Buffington 42ac87cb24 Add guidelines for using GitHub issues
Clarify the use of GitHub issues for support requests.
2026-07-03 08:23:24 -05:00
Cort Buffington ade5e78c76 Update support and contributing section in README
Clarify support policy and emphasize lack of commercial support.
2026-07-03 08:15:43 -05:00
Cort Buffington 5962fa032b Revise README for clarity and terminology updates
Updated terminology and clarified features in the README.
2026-07-03 07:54:43 -05:00
Cort BuffingtonandClaude Opus 4.8 5d36915fee Trim sample config verbosity; fix stale ACL performance caution
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>
2026-07-03 07:13:35 -05:00
Cort BuffingtonandClaude Opus 4.8 0529830090 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:07 -05:00
Cort BuffingtonandClaude Opus 4.8 bd90a8bb70 Add ACLS.md: full documentation of the ACL system
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>
2026-07-03 06:59:48 -05:00
Cort BuffingtonandClaude Opus 4.8 173bd5402a dashboard: raise active-stream stale timeout to 5 min
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>
2026-07-02 16:32:25 -05:00
Cort Buffington bd7fbd1854 Correct memory usage comment in hblink-SAMPLE.cfg
Updated memory usage information for subscriber_ids.json.
2026-07-02 15:21:54 -05:00
Cort BuffingtonandClaude Sonnet 4.6 bebb3b1900 Fix OpenBridge stream pills stretching full cell width
.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>
2026-06-28 18:28:01 -05:00
Cort BuffingtonandClaude Sonnet 4.6 6c1c810b42 Add ?demo mode to dashboard with full coherent scenario
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>
2026-06-28 18:24:53 -05:00
Cort BuffingtonandClaude Sonnet 4.6 982ff253e0 Dashboard: move TS1/TS2 labels to system name row, left-align with pill edge
- 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>
2026-06-28 12:47:49 -05:00
Cort BuffingtonandClaude Sonnet 4.6 894434014d hblink.py: include GROUP_HANGTIME in json_systems for SERVER and OUTBOUND
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>
2026-06-28 12:39:58 -05:00
Cort BuffingtonandClaude Sonnet 4.6 23d985a1a9 Dashboard: add TX/RX label, inherited color, and hang-time to slot pills
- 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>
2026-06-28 11:57:56 -05:00
Cort BuffingtonandClaude Sonnet 4.6 99341676f9 Dashboard: move call type to bottom line of slot pill
Source → destination on top, call type · ID · elapsed on bottom,
matching DMRlink3 convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 11:52:53 -05:00
Cort BuffingtonandClaude Sonnet 4.6 255b803f00 Dashboard: use explicit call type labels — Group Voice, Unit Voice, etc.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 11:39:37 -05:00
Cort BuffingtonandClaude Sonnet 4.6 630f0dccc3 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 into dashboard/ where PATH points

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 21:20:13 -05:00
Cort BuffingtonandClaude Sonnet 4.6 5e0d433670 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:22 -05:00
Cort BuffingtonandClaude Sonnet 4.6 b4c71b56c3 Hot-path data structure optimisation: ON/OFF/RESET lists → frozensets
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>
2026-06-27 19:46:31 -05:00
Cort BuffingtonandClaude Sonnet 4.6 c36ad14c47 Modernize reporting server to asyncio streams (match dmrlink3 style)
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>
2026-06-27 17:26:44 -05:00
Cort BuffingtonandClaude Sonnet 4.6 5ea20623d7 Code cleanup: dead code, unused loop vars, debug scaffolding, stubs
bridge.py:
- enumerate loops: e → _ (variable was never read)
- Fix comment typo: dicationry/temporarly/entrys

hblink.py:
- Remove ~12 commented-out #logger.debug / #print lines and their
  "KEEP COMMENTED OUT" guard comments throughout OPENBRIDGE,
  HBSYSTEM server/outbound datagram receive paths and send paths
- Remove commented-out old-style byte concatenation alternatives
  (superseded by b''.join() lines immediately below them)
- Remove #_call_type one-liners superseded by if/elif/else blocks
- dmrd_received() stubs: replace bare pass with docstring clarifying
  the override contract for subclasses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 15:22:33 -05:00
Cort BuffingtonandClaude Sonnet 4.6 e5a9c197a5 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:06 -05:00
Cort BuffingtonandClaude Sonnet 4.6 a31daf7962 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:49 -05:00
Cort BuffingtonandClaude Sonnet 4.6 ddfad60a0f feat: alias auto-refresh, configurable logo, and dashboard parity with DMRlink3
- bridge.py: add refresh_aliases() that rebinds peer_ids/subscriber_ids/
  talkgroup_ids module globals; hooked into run_periodic(86400) in async_main
  so aliases reload daily without a restart.

- dashboard: add configurable logo support — new LOGO_FILE key in
  config_sample.py; server injects {{LOGO_HTML}} (img tag or empty string)
  replacing the hardcoded /logo.png route and HBlink.png reference.

- dashboard: bridge table now matches DMRlink3 exactly — 9 columns
  (System/TS/TGID/Status/Timeout/Timer/Timeout Action/Activate TGIDs/
  Deactivate TGIDs); Status shows Active/Inactive; Timer column only counts
  down when the timer is actually running (ON-type while active, OFF-type
  while inactive); Timeout Action reads "Disconnect after timer" /
  "Reconnect after timer".

- dashboard: outbound systems table — column renamed Connection→Status,
  values changed from YES/NO to Connected/Disconnected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 13:53:19 -05:00
Cort BuffingtonandClaude Sonnet 4.6 f583207fac dashboard: widen slot columns and lock call pill width
Slot columns widened from 230px to 255px (~10%) to accommodate longer
TGID alias names. Added width:100%/overflow:hidden to .call so the pill
fills its td and cannot push the column wider when content changes between
idle and active states.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:56:57 -05:00
Cort BuffingtonandClaude Sonnet 4.6 6f5f084471 fix: remove _comment key from talkgroup_ids_SAMPLE.json
mk_id_dict takes the value of the FIRST key in the JSON object. A
_comment key as the first entry causes it to get a string instead of
the talkgroup list and silently return an empty dict. Remove the comment
and clarify in the README that the outer key must be first and only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:52:32 -05:00
Cort BuffingtonandClaude Sonnet 4.6 a827b9d134 docs: explain 'callsign' field name in talkgroup alias file
The field name is a dmr_utils3 library constraint — the same parser is
used for peer, subscriber, and talkgroup files. Note this in both the
README and the sample file so operators aren't confused by the mismatch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:44:43 -05:00
Cort BuffingtonandClaude Sonnet 4.6 6551b8381c docs: document talkgroup alias file format and add sample
talkgroup_ids.json has no automatic download source and was completely
undocumented — operators had no guidance on format or location. Add a
README section explaining alias files, distinguishing the auto-downloaded
peer/subscriber files from the manually maintained talkgroup file, and
document the JSON format. Add talkgroup_ids_SAMPLE.json as a starting point.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:43:01 -05:00
Cort BuffingtonandClaude Sonnet 4.6 de3c495bae bridge: demote timer-running log lines from info to debug
The rule_timer_loop fires every 10s and was logging an info line for every
bridge member with an active timer, creating significant log noise. Only
state changes (activate, deactivate, deferred) warrant info-level logging.
The "timer still running" lines are debug-only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:23:29 -05:00
Cort BuffingtonandClaude Sonnet 4.6 0c53cd0c50 docs: subscriber cache update is unconditional — handles roaming between systems
A subscriber can move from system A to system B between calls while the
cache entry for system A is still valid. The cache update on voice header
must always overwrite the stored system, not just refresh the expiry, so
that routing follows the subscriber's current location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 09:58:41 -05:00
Cort BuffingtonandClaude Sonnet 4.6 bde7bae15d docs: update subscriber cache on voice header only, not every DMRD
Subscriber location cannot change mid-call. Updating the cache entry on
every frame is redundant and adds unnecessary work to the hot path. The
correct point is the voice header (first DMRD of a new stream_id), which
is already identified during new stream detection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 09:57:18 -05:00
Cort BuffingtonandClaude Sonnet 4.6 8216b1a364 docs: correct and expand packet routing flow document
Three areas updated:

- Talker alias: reframe the reason for LC overwrite as protocol interoperability, not
  handling complexity. TA could be preserved in pure HBP-to-HBP paths but different
  protocols (IPSC, OpenBridge, future adapters) each carry TA differently, making
  uniform LC overwrite the safe and interoperable choice.

- Stale stream cleanup: a DMR superframe is ~360ms so 1-2 seconds is fast enough to
  detect a call that ended without a terminator, given human PTT timescales. Remove the
  overly conservative 30-second figure. Cleanup interval can be every few seconds; it
  is a backstop, not a timing-critical path.

- Unit call routing: subscriber location cache must have a timeout/expiry or it grows
  forever. Each transmission resets the expiry. Pruning runs at a minutes interval.
  Cache miss behavior: flood to all systems that permit unit call flooding (per-system
  config flag). Conceptually based on PIM Sparse Mode for IPv4 multicast. Reference
  HBlink4's implementation for detailed behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 09:57:00 -05:00
Cort BuffingtonandClaude Sonnet 4.6 2952fbc013 docs: correct bridge state machine — TO_TYPE OFF semantics, timer units, RESET behavior
Four corrections from review:

- TO_TYPE OFF is the logical inverse of ON: member is ACTIVE by default, OFF TGID
  deactivates it and starts the timer, timer expiry reactivates it. Previous description
  was wrong about what OFF means.
- RESET is valid for TO_TYPE OFF (extends the deactivation period, keeping the member
  INACTIVE longer).
- TIMER is application-relative, not necessarily Unix epoch. The distinction between
  internal time source and the wire format (Unix epoch in telemetry) is now explicit.
- TIMEOUT is configured in minutes, not seconds. The 10-second rule timer loop interval
  is appropriate because minute-resolution expiry needs no finer precision.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 09:40:19 -05:00
Cort BuffingtonandClaude Sonnet 4.6 37c30185f9 docs: C reimplementation design documents — telemetry, bridge state machine, packet routing
Three design documents capturing what would otherwise require deciphering the Python source:

- telemetry-schema: NDJSON event types, field names, units, epoch conventions, and the
  design principle that the emitter must never stall the routing hot path
- bridge-state-machine: ACTIVE/INACTIVE transitions, ON/OFF/RESET trigger semantics,
  TO_TYPE timer behavior, and why activation fires at call START not call END
- packet-routing-flow: DMRD packet structure, arrival validation, new stream detection,
  routing loop, LC rewriting policy, why talker alias is intentionally destroyed,
  stream end handling, stale stream cleanup, and unit call subscriber map

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 09:12:22 -05:00
Cort Buffington edf5401f90 fixed typos 2026-06-26 08:57:35 -05:00
Cort BuffingtonandClaude Sonnet 4.6 0fd8050a1c docs: C reimplementation rules configuration design
Captures the design rationale for keeping rules.py as the operator-facing
configuration format while using a Python helper to validate and export JSON
for consumption by a C protocol core. Documents live reload behavior, why
the single-threaded model makes the swap safe, and how the JSON schema as a
stable interface enables future configuration front-ends (web UI, REST, etc.).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 08:54:25 -05:00
Cort BuffingtonandClaude Sonnet 4.6 ab5945fe01 dashboard: raise ping-warning threshold to 1.5× ping_time
With ping_time=5s and report_interval=10s, last_ping_secs at config-event
time is legitimately up to 5s even during healthy operation. Timer jitter
could push it slightly above 5, causing false gold that sticks between
config events and looks like it never clears.

Raising to 1.5× (7.5s) gives a reliable margin above normal jitter while
still warning well before the max_missed disconnection at 3× (15s).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 08:17:40 -05:00
Cort BuffingtonandClaude Sonnet 4.6 503e9d2217 dashboard: revert live-tick ping warning to static server-side computation
Live-tick approach was broken: data-last-ping/data-last-ack attributes
only refresh every 10s (REPORT_INTERVAL) but PING_TIME is 5s, so the
1s ticker computed stale deltas that crossed the threshold and showed
gold constantly from startup.

Restore last_ping_secs in server.py enrich_config() so the dashboard
renders the warning color statically at config-event time (when the
delta is at most PING_TIME, so > PING_TIME never false-triggers).
Remove LAST_PING_ACK_TIME tracking from hblink.py — unused now that
outbound warning is based on NUM_OUTSTANDING instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 08:08:37 -05:00
Cort BuffingtonandClaude Sonnet 4.6 a186eb7c29 docs: fix IPSC2 vs IPSC/ipsc2hbp terminology in TA limitation note
IPSC2 is a separate software bridge; IPSC is the protocol and ipsc2hbp
is the author's own IPSC-to-HBP protocol translator. Also corrected
'LC' to 'TA' in the adapter sentence for clarity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 07:57:24 -05:00
Cort BuffingtonandClaude Sonnet 4.6 38afdeb15b docs: document talker alias limitation in bridge routing
Explain why embedded LC is unconditionally rewritten during TGID/TS
translation and why that necessarily destroys embedded talker alias data.
Covers the late-entry correctness argument, the LC mismatch risk, and the
additional complication when using IPSC2/ipsc2hbp adapters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 07:52:52 -05:00
Cort BuffingtonandClaude Sonnet 4.6 a94bee4502 hblink: fix UnboundLocalError in DMRA handler
_rf_src was referenced in the DMRA branch but only ever assigned in the
DMRD branch, which never runs when the command is DMRA. Extract it from
bytes 8-10 of the DMRA packet (the subscriber field, matching the layout
of all other repeater-initiated packets where bytes 4-7 carry the peer
ID). Also add the standard repeater-validation guard before indexing into
self._repeaters to prevent a secondary KeyError on unauthenticated senders.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 07:32:15 -05:00
Cort BuffingtonandClaude Sonnet 4.6 56c3962a5e bridge: reduce rule_timer_loop interval 60s→10s; only send on state change
The browser countdown correctly hit 0 but ACTIVE wasn't flipped until
the next rule_timer_loop fire, which could be up to 60 seconds later.
Tightening to 10s caps the lag between the displayed timer expiring and
the status switching to Disconnected.

Also only call send_bridge() when a state actually changed during the
pass, so the more frequent loop doesn't push redundant bridge events on
every no-op iteration (the periodic reporting loop still covers baseline
refresh).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 07:24:58 -05:00
Cort BuffingtonandClaude Sonnet 4.6 53223f3b53 dashboard: live-tick ping warnings; fix idle pill border height
Warning colors now update every second via DOM data attributes instead of
waiting for the next config event. Repeater callsigns carry data-last-ping
(epoch from LAST_PING) and outbound names carry data-last-ack (epoch from
new LAST_PING_ACK_TIME stat). The setInterval tick compares live Date.now()
against each epoch so gold clears within one second of pings recovering.

Add LAST_PING_ACK_TIME to outbound STATS -- set on each MSTPONG received
and exported through json_systems() to the dashboard.

Idle slot pills now have border:1px solid transparent to match the 1px
border on rx/tx pills, eliminating the 2px height difference that caused
row height to jump when a call became active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 07:20:46 -05:00
Cort BuffingtonandClaude Sonnet 4.6 e5988c4329 bridge: fire ACTIVATION trigger on call start, not call end
Move the ON/RESET in-band signalling from the voice terminator to the
new-stream detection block, just before the routing loop. The bridge is
now ACTIVE when the first frame (including the voice header) routes, so
it passes through to target systems immediately instead of being silently
dropped and only connecting for the next call.

DEACTIVATION (OFF trigger) stays at call end -- letting a disconnect
message play out before the bridge drops is the right UX.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 21:02:28 -05:00
Cort BuffingtonandClaude Sonnet 4.6 d6e157cee9 dashboard: white bridge names; immediate bridge state push on ACTIVE change
Bridge header rows are now white text instead of accent-blue so bridge
names like "KS-DMR 1/2" stand out clearly against the dark panel.

Fire send_bridge() immediately when an in-band signalling TGID flips a
bridge ACTIVE state during call-end processing, rather than waiting for
the next periodic report interval. The flag ensures at most one send per
call-end even if multiple bridges change simultaneously.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 20:53:06 -05:00
Cort BuffingtonandClaude Sonnet 4.6 525097e67a dashboard: health warnings, slot display, remove CC column
- Repeater callsign turns gold (#fbbf24) when last_ping_secs > PING_TIME
- Outbound system name turns gold when NUM_OUTSTANDING > 0 (unacked ping)
- Slots column replaces CC/Slots on server table (CC is air-interface only)
- Slots column replaces Pings S/A/L on outbound table
- fmtSlots() renders 3→Both, 1→TS1, 2→TS2 in both tables
- PING_TIME and MAX_MISSED forwarded from hblink global config to dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 20:36:36 -05:00
Cort BuffingtonandClaude Sonnet 4.6 7670caa341 bridge: tighten timeout-deferral TGID check
The deferral condition now matches the same criteria as the voice-
terminator timer reset: slot AND TGID must both match the bridge rule.
A call on the right slot but a different TGID would not reset the timer
on completion, so deferring for it was wrong.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 20:15:22 -05:00
Cort BuffingtonandClaude Sonnet 4.6 1812bac85b bridge: defer conference bridge deactivation if call in progress
rule_timer_loop fires every 60s and could deactivate a bridge mid-
transmission if the timer expired since the last call ended but a new
call started before the loop ran. When that happens the remaining frames
(including the voice terminator) are silently dropped, leaving downstream
repeaters with a dangling open slot.

Fix: before setting ACTIVE=False on an ON-type timeout, check whether the
system's slot has a live RX stream (RX_TERMINATED==False). If so, extend
the timer by one full TIMEOUT period and log a deferral -- the loop will
re-evaluate on the next tick after the call ends cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 20:11:52 -05:00
Cort BuffingtonandClaude Sonnet 4.6 60009d7367 dashboard: fix slot cell height jitter on idle→active transition
Idle call cells now use the same two-line inner structure as active
cells, so slot columns don't resize when a call starts or ends.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:09:01 -05:00
Cort BuffingtonandClaude Sonnet 4.6 1bd9b41442 update copyright years to 2021, 2026
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 15:51:34 -05:00
Cort BuffingtonandClaude Sonnet 4.6 cac5db7363 rename: master/peer → server/repeater/outbound terminology
Replace inherited IPSC terminology with clearer server/client language
that aligns with HBlink4:

- Mode strings: 'MASTER'→'SERVER', 'PEER'→'OUTBOUND'
- Connected clients in server mode: self._peers→self._repeaters,
  'PEERS'→'REPEATERS' dict key, MAX_PEERS→MAX_REPEATERS config key
- Method names: master_*/peer_* → server_*/outbound_*
- send_peer/send_peers/send_master → send_repeater/send_repeaters/send_server
- Config keys: MASTER_IP/PORT/SOCKADDR → SERVER_IP/PORT/SOCKADDR
- hblink.cfg: [MASTER-1]→[SERVER-1], [REPEATER-1]→[OUTBOUND-1]
- Dashboard: PEERS→REPEATERS dict key, "Peer Systems"→"Repeater Systems"

Wire protocol constants (RPTL, MSTPONG, etc.) and DMR-level _peer_id
fields are unchanged — those are on-wire bytes, not internal naming.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 15:49:00 -05:00
Cort BuffingtonandClaude Opus 4.8 b95ab5bd10 dashboard: align Conference Bridges columns across all bridges
Render every conference bridge in one shared table with the bridge name as a
full-width sub-header row, instead of a separate table per bridge. Per-bridge
tables auto-sized their columns independently, so a wider Action value
(Disconnect vs None) or a wide (24-bit) TGID in one bridge misaligned the
columns relative to another. A single table sizes each column to the widest
cell across all bridges, so they line up. TGID cells get tabular-figure mono.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:08:54 -05:00
Cort BuffingtonandClaude Opus 4.8 2ac1437ca5 Don't forward bridged traffic to a peer not logged into its master
When HBlink3 acts as a PEER, the router forwarded bridged call traffic to it
regardless of whether it had completed login to its upstream master. The frames
were fired at the master's address and silently dropped (we're not registered),
and the call falsely appeared as an active forward on the dashboard. This is
long-standing behavior (master/original hblink3 did the same) -- the new
event-sourced dashboard just made it visible.

Add HBSYSTEM.egress_ready() (True for masters/OpenBridges; for a PEER, True only
once CONNECTION == 'YES') and skip un-ready targets at the top of routerHBP
bridge_group / bridge_unit, so a disconnected peer gets no forwarded frames, no
TX report event, and no TX status churn. send_master also drops DMRD when not
connected as defense-in-depth, while still letting login/keepalive packets flow.

The routing harness models connected, participating systems, so it now marks its
PEER systems CONNECTION == 'YES' -- the 15-scenario byte baseline is unchanged.
Tests: tests/test_peer_egress.py (connected forwards, disconnected skipped) plus
a send_master drop/allow test; full suite 51 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:00:55 -05:00
Cort BuffingtonandClaude Opus 4.8 561704b1b3 dashboard: add HBlink logo to header + field reference doc
Serve the HBlink logo at /logo.png (FileResponse) and place it beside the title
in the header. Add dashboard/FIELDS.md documenting every section and field on
the screen -- header/connection badge, Master/Peer/OpenBridge systems, the call
cell, Conference Bridges, and the Call Log -- plus the RX/TX colour convention
and update-latency notes. Link it from the dashboard README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:37:23 -05:00
Cort BuffingtonandClaude Opus 4.8 3d157cfa99 dashboard: unit-call dst alias + two-line call cell
Resolve a unit call's destination against the subscriber aliases (it's a radio,
not a talkgroup). Redesign the call cell into two lines -- type/source/dest names
on top, radio id + elapsed below -- and colour purely by direction (rx green,
tx red), dropping the separate "hear" state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:35:21 -05:00
Cort BuffingtonandClaude Opus 4.8 f58f30e157 tests: add live OpenBridge (OBP) traffic harness
Sends DMRD frames over the OpenBridge wire format (53-byte payload + 20-byte
HMAC-SHA1(passphrase, payload)) from a socket bound to the OBP's TARGET_PORT, so
the receiver's TARGET_SOCK source-address check passes. OBP is not timeslot-
bound, so --streams N fires N overlapping calls at once to exercise many
concurrent streams (and, via bridging, the peer OBP sending them all out
concurrently) -- the multi-stream contrast to the slot-bound HBP side.

Verified live against a two-OpenBridge demo bridge: 4 streams into each OBP
plus an HBP peer produced 20 concurrent active streams (9 per OBP, send+receive)
with MASTER-1 carrying one per slot, and clean teardown (no leaked streams).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:27:12 -05:00
Cort BuffingtonandClaude Opus 4.8 fdf1b33ee8 bridge.py: log slot collisions once per stream, not per frame
The source-side "collided with existing call" warning fired on every dropped
frame, flooding the log when streams contend for a slot (e.g. several peers on
one master keying the same talkgroup). Gate it on a per-slot RX_COLLISION_SID so
each contending stream is reported once, matching the once-per-stream behavior
of the target-side contention log. Routing/emissions are unchanged; this is
logging volume only. The dashboard's stale-stream reaper stays as-is for
defense-in-depth (non-local dashboard, hblink failure, etc.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:13:59 -05:00
Cort BuffingtonandClaude Opus 4.8 59c5072613 bridge.py: track stream termination with explicit flags, not RX_TYPE
A slot's "stream has ended" state was inferred from RX_TYPE/TX_TYPE ==
HBPF_SLT_VTERM. But those fields hold the last frame's _dtype_vseq, which for
voice bursts cycles 0..5 and so collides with HBPF_SLT_VTERM (== 2). Any stream
whose last received burst was vseq 2 (burst "C") looked already-terminated, so:

- a dropped-RF "lost tail" ending on vseq 2 was never ended -- not by the
  stream-timeout trimmer, nor by new-stream supersession -- leaking a stuck
  call until a downstream consumer timed it out; and
- a live holder transmitting its vseq-2 burst momentarily looked terminated, so
  a competing stream could steal the slot, producing spurious CALL STARTs and a
  flood of per-frame collision warnings.

A properly terminated call always finishes a full superframe (last voice burst
F/vseq 5) then sends the terminator, so the clean-END path was usually fine --
this only bit lost tails and mid-call contention. The bug is long-standing
(present in master); the new event-sourced dashboard just made it visible.

Replace the RX_TYPE/TX_TYPE == VTERM inference with explicit RX_TERMINATED /
TX_TERMINATED flags, set False when a stream starts and True on a real
terminator (or synthesized end), across the group and unit, source and target
paths. RX_TYPE/TX_TYPE remain only as informational last-frame-type.

Verified live: lost-tail@vseq2 now times out cleanly; 3 overlapping peers x 4
calls yield 12 STARTs / 12 ENDs (was 72 / 4) with no stuck streams. All 47
unit tests pass, including the 15-scenario byte-identical routing baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:33:54 -05:00
Cort BuffingtonandClaude Opus 4.8 de3ab0cab3 tests: add live UDP traffic harness for HBlink3
Logs into a running MASTER as a HomeBrew peer over a real socket and injects
DMRD voice streams, exercising the full server path (socket -> handshake ->
ACL -> bridge routing -> contention -> reporting). Complements the in-process
tests/harness.py and is the tool used to drive the dashboard with live traffic.

DMR-realistic call generation:
- properly terminated calls finish a complete superframe (last burst F/vseq 5)
  then send the terminator;
- --no-term produces a "lost tail" that stops mid-superframe with no terminator
  (--tail-vseq forces the last burst, e.g. 2 to reproduce the END-loss case);
- distinct random per-process stream-ids so concurrent instances model separate
  repeaters instead of aliasing into one call;
- --concurrent overlaps N streams on one TGID/slot to characterize contention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:26:43 -05:00
Cort BuffingtonandClaude Opus 4.8 8ec23fe02b bridge.py: silence per-pass stream-trimmer debug log
The trimmer now runs every second; logging on every pass is too noisy even at
debug level. It stays silent per-pass and only logs when it actually times out a
stream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:53:39 -05:00
Cort BuffingtonandClaude Opus 4.8 c9f628a9ba HBlink3: emit END on stream supersession + 2s no-audio timeout
Fix the root cause of the dashboard's phantom "active" streams: HBlink3 did not
guarantee a matching END for every START. When a new stream took over a timeslot
whose previous stream never sent a terminator, the slot state was simply
overwritten, and the trimmer only ever emitted an END for whichever stream
currently owned the slot -- superseded streams were dropped silently.

- routerHBP._end_slot_stream(slot): if a slot holds an un-terminated stream,
  declare it ended and emit a matching END (with the correct GROUP/UNIT call
  type, now tracked per slot as RX_CT). Idempotent.
- Call it at the new-stream adoption point in group_received and unit_received,
  so a superseding stream first ends the one it replaces (restores the original
  pre-supersession behavior; the overwrite-only version kept routing correct but
  orphaned the call logs).
- Stream timeout dropped from 5s (polled every 5s) to STREAM_TIMEOUT=2s polled
  every 1s: on RF, ~2s of no audio means sync is lost and any resumption is a new
  stream (late entry), so the stream is over. The trimmer's RX timeout now routes
  through _end_slot_stream too.

Report-only behavior; routing is unchanged (byte baseline still green). The
dashboard's stream reaper stays as a backstop for feed-disconnect gaps HBlink3
can't help with. Suite now 42 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:25:39 -05:00
Cort BuffingtonandClaude Opus 4.8 8efa73d9b3 dashboard: reap stale active streams (self-heal from missed END events)
The dashboard tracked active calls purely by START/END events, so any START
without a matching END accumulated forever. Under heavy same-timeslot contention
this is expected: HBlink3's per-slot state turns over rapidly, and its stream
trimmer only emits an END for whichever stream currently owns the slot -- a
stream that was superseded never gets a matching END. Real RF calls also drop
mid-stream (loss of sync, hotspot reboot), with no terminator.

Add a stream reaper on both sides: the backend drops active streams >30s old
(STREAM_STALE), and the browser ages out ST.active entries on the same threshold
in its 1s tick. Normal calls still clear instantly on END; only phantoms are
swept. Found via the repeater simulator, which left 57 phantom "active" streams
after a 172-call run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:39:20 -05:00
Cort BuffingtonandClaude Opus 4.8 983b0f5ab9 hblink-SAMPLE.cfg: refresh [REPORTS] for the dashboard feed
The reporting socket is no longer "not yet working" -- it's the NDJSON feed the
dashboard consumes. Update the comments accordingly, clarify that live calls are
instant while REPORT_INTERVAL only sets the system/peer snapshot cadence, and set
REPORT_INTERVAL to 10 (a good value for dashboard use).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:45:25 -05:00
Cort BuffingtonandClaude Opus 4.8 a8c98cd468 Document dashboard as an optional shared-venv extra
The dashboard can run in the core's virtualenv (it only adds FastAPI + Uvicorn).
Add a `dashboard` optional-dependencies extra to pyproject.toml
(`pip install -e ".[dashboard]"`) and a note in INSTALL.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:09:15 -05:00
Cort BuffingtonandClaude Opus 4.8 2836f71ec7 Add HBlink3 dashboard (FastAPI + Uvicorn, modern UI)
A real-time web dashboard for HBlink3 -- a separate program that replaces the old
HBmonitor. It keeps HBmonitor's information and table layout (master systems with
their repeaters, peer systems, OpenBridge systems, conference-bridge state, and a
call log) but borrows HBlink4's modern dark aesthetic and an efficient,
event-driven data path.

How it works:
- dashboard/server.py connects to HBlink3's NDJSON reporting feed, maintains the
  authoritative display state, resolves DMR IDs to callsigns/talkgroup names via
  the shared alias files, and serves a single-page UI plus a WebSocket that pushes
  JSON deltas (and a small REST API at /api/state).
- dashboard/static/dashboard.html renders the tables client-side and ticks the
  uptime / call-duration / bridge-timeout counters in the browser, so there are no
  full-table re-renders and the duration figures update live. Live calls are
  pushed instantly; systems/peer state refreshes on the periodic config snapshot
  (REPORT_INTERVAL).

Efficiency vs HBmonitor: JSON deltas instead of whole-table HTML pushed every
tick; client-side rendering and live counters; no pickle on the wire; server-
computed time deltas so the browser needs no clock sync.

Includes config_sample.py, requirements.txt (FastAPI/Uvicorn/dmr_utils3 -- the
core stays stdlib-only), README, run_dashboard.py launcher, and an
hblink3-dash.service systemd unit. dashboard/config.py is git-ignored.

Verified end-to-end against a live bridge.py: the dashboard ingests the config
and bridge snapshots, resolves a talkgroup alias, and serves the branded SPA;
frontend JS parses clean; the HBlink3 core test suite is unaffected (39 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:54:28 -05:00
Cort BuffingtonandClaude Opus 4.8 48c35c20bd Overhaul HBlink3 reporting feed: pickle/netstring -> NDJSON
Replace the old reporting wire format (pickled CONFIG/BRIDGES + CSV events,
netstring-framed) with newline-delimited JSON, in preparation for the new
dashboard. A consumer connects to the reporting socket and receives:

- a `config` snapshot on connect (and periodically): a JSON-safe, trimmed view
  of the systems (masters with their connected peers, peers with stats,
  openbridges) built by json_systems() -- bytes decoded, DMR IDs as integers,
  passphrases and internal ACL structures omitted.
- a `bridges` snapshot: each conference bridge's members (system/ts/tgid/active/
  timer/triggers) via json_bridges().
- `stream` events on call start/end (the send_bridgeEvent CSV is parsed centrally
  into JSON, so the routing call sites are unchanged).

The report protocol is now plain NDJSON (one json object per line); consumers are
passive and get the initial snapshot pushed on connect (no CONFIG_REQ handshake).
Dropped pickle from hblink.py/bridge.py and a leftover `send_clients(b'bridge
updated')` debug line.

Note: this changes the reporting wire format, so the old HBmonitor (which this
new dashboard replaces) will no longer parse it. Verified live: a consumer
connecting to a running bridge.py receives the config snapshot (with the
connected peer) and bridge state as NDJSON. Tests updated; suite now 39.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:42:18 -05:00
Cort BuffingtonandClaude Opus 4.8 9d43e56644 Rewrite README and add INSTALL.md
Replace the old README (deprecation notice + stale legacy references) with a
clean, concise overview: what HBlink3 is (an asyncio HBP master/peer and
transit/conference-bridge call router), how it differs from HBlink4 (endpoint
server), the three applications, key features, a quick start, and the
configuration/rules overview.

Add a companion INSTALL.md with step-by-step setup: virtualenv, configuring
hblink.cfg and rules.py from the samples, running each app, running as a systemd
service, and updating. Intentionally lighter than HBlink4's docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:57:44 -05:00
Cort BuffingtonandClaude Opus 4.8 1864459e78 Add protocol regression tests for the asyncio migration
Pin the protocol mechanics the routing harness doesn't reach, against mock
transports (no real sockets):

- tests/test_protocol.py:
  - master login handshake: RPTL -> challenge -> RPTK -> RPTC drives the master
    state machine to CONNECTION='YES'; wrong passphrase is NAK'd and the peer
    removed. Exercises datagram_received + transport.sendto.
  - reporting: netstring framing round-trip, disallowed client is closed, and a
    CONFIG_REQ (split across reads) triggers a CONFIG_SND push.
  - transport send: OPENBRIDGE.send_system and peer send_master use
    transport.sendto with the right address.

Full suite now 36 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:51:35 -05:00
Cort BuffingtonandClaude Opus 4.8 88ea502382 Migrate the networking core from Twisted to asyncio
Replace Twisted with Python's stdlib asyncio across the three core apps
(hblink.py, bridge.py, bridge_all.py), dropping a heavy dependency.

Networking:
- HBSYSTEM / OPENBRIDGE now subclass asyncio.DatagramProtocol:
  datagramReceived -> datagram_received, startProtocol -> connection_made
  (stores the transport and starts the maintenance task), and
  transport.write(data, addr) -> transport.sendto(data, addr).
- task.LoopingCall -> a generic run_periodic() coroutine (asyncio.create_task).
  Unlike a bare LoopingCall it logs and continues on a per-iteration error, so a
  transient fault can't silently kill a timer (this supersedes the old
  "stop the reactor to avoid a memory leak" errback).
- reactor.listenUDP -> loop.create_datagram_endpoint; reactor.run -> asyncio.run
  with an async_main that installs SIGINT/SIGTERM via loop.add_signal_handler and
  waits on a stop Event (graceful de-registration preserved).

Reporting server:
- The twisted NetstringReceiver/Factory become an asyncio.Protocol (report) plus
  a callable manager (reportFactory) used as the create_server protocol factory.
  Netstring framing ("<len>:<data>,") and the pickled payloads are preserved, so
  the dashboard wire protocol is unchanged. The client IP allowlist moved into
  connection_made. Fixed a latent bug where a CONFIG_REQ called self.send_config
  (which did not exist) instead of the manager's send_config.

Dependencies / packaging:
- Drop Twisted from requirements.txt and pyproject.toml; the core is now
  stdlib-only (plus bitarray, dmr_utils3). requires-python stays >=3.8.
- playback.py and play_ambe.py are marked LEGACY (not migrated): they still use
  Twisted and subclass the now-asyncio HBSYSTEM, so they need their own port to
  run against the current core. Left untouched otherwise, per scope.

Verified: the 15-scenario routing byte-baseline is unchanged (routing is
transport-agnostic), and a live local master+peer completes the full login
handshake over real sockets and shuts down cleanly on SIGTERM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:51:21 -05:00
Cort BuffingtonandClaude Opus 4.8 f9f2652757 Modernize packaging: clean requirements, add pyproject and systemd unit
Tier-1 dependency/packaging cleanup (no code changes):

- requirements.txt: drop configparser (stdlib since Python 3, the pin was a
  no-op) and bitstring (only used by the optional voice utilities, not the core
  routing apps). Keep the three real deps with conservative-but-modern floors
  and note the tested versions (bitarray 3.8.1, Twisted 26.4.0, dmr_utils3 0.1.30).
- pyproject.toml: standard project metadata + dependencies + requires-python.
  Floor set to >=3.8 (the code uses no 3.9+ syntax); a bump to >=3.11 is planned
  alongside the asyncio migration to use TaskGroup/asyncio.timeout.
- hblink3.service: sample systemd unit running bridge.py, modeled on hblink4's,
  with restart and basic hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:26:06 -05:00
Cort BuffingtonandClaude Opus 4.8 49399790d8 Unify unit-call target forwarding into target.bridge_unit()
Mirror the group-call refactor for unit (private) calls: move the per-target
forwarding (OBP-target vs HBP-target) onto the target system as bridge_unit().
routerOBP.bridge_unit clears the slot bit unless BOTH_SLOTS and sends a 53-byte
frame; routerHBP.bridge_unit applies the (unit-specific, two-check) contention
policy and sends the frame as-is. Both source unit_received methods collapse to
a loop that calls target.bridge_unit(self, ...), reusing the should_log_contention
hook from the group refactor.

Behavior note: the original OBP-target branch reassigned the shared _data, so in
a multi-target broadcast (destination not in UNIT_MAP) an HBP target sequenced
after an OBP target would transmit the OBP-mutated frame. Each target now frames
the original payload independently, which fixes that order-dependent case. The
normal single-target unit path is byte-identical (verified against the unit
baseline scenarios).

bridge.py is down to 971 lines from 1174 at the start of the cleanup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:51:23 -05:00
Cort BuffingtonandClaude Opus 4.8 a32e34a25d Add unit-call scenarios to the routing baseline
Extend the harness to drive unit calls (seed UNIT_MAP, instantiate non-bridge
systems) and add single-target unit scenarios covering HBP<->HBP, HBP<->OBP
(including a TS2->OBP case that must clear the slot bit), OBP->HBP and OBP->OBP.
Frozen from current code so the unit-forwarding refactor can be proven
byte-identical for the normal single-target case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:48:38 -05:00
Cort BuffingtonandClaude Opus 4.8 d3b4fb6e36 Unify group-call target forwarding into target.bridge_group()
The per-target forwarding body (the OBP-target vs HBP-target if/else, ~120
lines) was copy-pasted in both routerOBP.group_received and
routerHBP.group_received. Move it onto the TARGET system as a polymorphic
method: routerOBP.bridge_group applies OpenBridge semantics (unlimited
concurrent streams keyed by stream id, effectively TS1, no BER/RSSI trailer),
routerHBP.bridge_group applies repeater semantics (one stream per timeslot via
the contention/group-hangtime policy, BER/RSSI preserved).

Each source group_received now just loops the target index and calls
target.bridge_group(self, ...), passing the source-specific bits explicitly:
the source LC, the BER/RSSI trailer (\x00\x00 for an OBP source, _data[53:55]
for an HBP source), and a should_log_contention() hook (OBP logs once per source
stream via its CONTENTION flag; HBP logs on the new stream's voice header). The
spooky cross-target mutation of dmrpkt is gone -- each target now embeds the LC
into the original payload independently (byte-identical, since embed_lc only
rewrites LC regions).

This is the step-3 target-polymorphism (the target owns admission + egress)
arrived at via the step-1 extraction. The two source state models (OBP keyed by
stream id, HBP keyed by slot) are unchanged -- that difference is inherent.

Verified byte-for-byte identical against the 10-scenario routing baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:44:50 -05:00
Cort BuffingtonandClaude Opus 4.8 7d1ff2be5b Expand routing baseline with synthesized-LC scenarios
Add HBP->HBP and OBP->OBP scenarios whose first frame is a burst (no voice
header), so the synthesized-LC path (src_lc = LC_OPT + dst + src, vs decoded
from the header) is also locked before the routing refactor. Regenerated from
current code; existing scenarios unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:39:03 -05:00
Cort BuffingtonandClaude Opus 4.8 34a82d672a Freeze a byte-level routing baseline before refactoring
Establish a golden master of the current routing core's emitted packets so the
forward_group extraction (and the later OBP/HBP unification) can be proven to
change nothing on the wire.

- tests/harness.cfg: self-contained topology (2 HBP masters, 1 HBP peer, 2 OBPs)
  so scenarios can cover every source/target combination including OBP<->OBP.
- tests/baseline_scenarios.py: deterministic full-call scenarios -- HBP<->HBP
  (same slot, cross slot with TS-bit flip, TGID rewrite), HBP<->OBP, OBP->HBP,
  OBP->OBP, multi-target fan-out, and a contention case where the second stream
  is correctly blocked.
- tests/baseline_emissions.json: frozen emissions captured from current code.
- tests/test_baseline.py: replays the scenarios and asserts byte-identical match.

Verified the baseline distinguishes real semantics (cross-slot flips the TS bit,
TGID rewrite applies, HBP targets get 55-byte frames with BER/RSSI while OBP
targets get 53-byte frames without) and that a single changed byte fails the test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:30:03 -05:00
Cort BuffingtonandClaude Opus 4.8 f990c61126 Add frame-replay test harness for the routing core
Scaffolding for the planned OBP/HBP routing unification. It stands up the real
routerHBP/routerOBP instances against the sample config, drives synthetic DMRD
frames through their routing entry point (dmrd_received), and captures the
packets each system would emit -- with a manually advanced clock so contention
and group-hangtime are deterministic.

The point: during the unification we can replay identical frame sequences
through the old and new code and assert byte-identical emissions, proving OBP
multi-stream behavior and HBP slot locking are preserved without a live
over-the-air network.

- tests/harness.py: World builder, controllable Clock, mk_dmrd() frame builder,
  per-system send_system capture.
- tests/test_harness_smoke.py: characterization tests proving the harness drives
  the real core -- HBP->HBP and HBP->OBP forwarding, and two concurrent OBP
  source streams reaching two different systems (the case the naive "OBP as TS1"
  unification would break).

No production code changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:22:13 -05:00
Cort BuffingtonandClaude Opus 4.8 c0458164b5 Index bridge routing rules for O(1) per-frame lookup
group_received ran a full O(bridges x members) scan on every voice frame to
find which bridges a (system, TS, TGID) source feeds, then scanned the
members again for targets; the in-band signalling pass did yet another full
scan per terminator. Cost scaled with the size of the rule file, per frame,
per concurrent stream.

Build two indexes once at startup from the processed BRIDGES (index_bridges):
- BRIDGE_SRC_INDEX: (system, ts, tgid) -> [(bridge, source, members), ...]
- BRIDGE_BY_SYSTEM: system -> [(bridge, member), ...]

The match keys are immutable per member and ACTIVE is read live at packet
time, so the indexes stay valid and only need rebuilding if bridge membership
changes (today: never after load). Per frame is now a dict lookup over only
the relevant sources/targets. Selection is identical to the original scan,
verified against a brute-force reference over randomized rule sets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:26:12 -05:00
Cort BuffingtonandClaude Opus 4.8 ba7de14edd Optimize ACL representation: set + bisected ranges
acl_check was a linear scan over a list of (start, end) ranges, so large
enumerated ACLs (e.g. DENY: hundreds of individual IDs) cost O(n) on every
ingress frame. Rework the structure produced by acl_build into
(action, singles_frozenset, range_starts, range_ends):

- single IDs -> frozenset membership, O(1)
- ranges -> merged into disjoint sorted intervals, located by one bisect, O(log n)

acl_check is updated to match; behavior is identical to the original linear
semantics (verified against a reference implementation across permit/deny,
overlapping/adjacent ranges, ALL, and an 800-entry enumerated ACL).

Also fixes the empty-ACL branch, which previously returned a malformed
set((ID_MIN, max)) that acl_check would have crashed on (unreachable today
since the config always supplies an ACL string).

Note: the ACL tuple shape changed. It is internal to acl_build/acl_check,
but it is also carried in the pickled CONFIG sent to reporting clients, so a
dashboard that introspects ACL internals (most don't) would need updating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:19:36 -05:00
Cort BuffingtonandClaude Opus 4.8 84c96f7032 Add regression tests for the extracted helpers
Pin the behavior of the refactored helpers, since a live OBP/HBP network
with real DMR audio can't be reproduced in a dev environment:

- tests/test_acl.py: HBSYSTEM.dmrd_acl_check() -- PERMIT/DENY matching,
  per-timeslot scoping, global-vs-system precedence, and once-per-stream
  drop logging.
- tests/test_lc.py: gen_lcs() and embed_lc() -- LC generation plus the
  payload rewrite's inject/preserve/passthrough/purity contract.

Stdlib unittest, no new deps:
  venv/bin/python -m unittest discover -s tests -v

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:41:48 -05:00
Cort BuffingtonandClaude Opus 4.8 696e1a1d13 bridge.py: extract gen_lcs/embed_lc Link Control helpers
The BPTC Link Control generation and the DMR-payload LC bit-rewrite were
copy-pasted across all four group-routing paths (routerOBP/routerHBP x
group/unit) -- the exact duplication that let the _dst_group bug survive
in only one copy. Extract two pure module-level helpers, gen_lcs() and
embed_lc(), and use them at all eight call sites. Output is byte-identical
to the inline logic (verified across an 8.5k-case matrix); the only
restructure is lifting the voice-terminator reporting out of the LC
elif-chain, under the same (VTERM and REPORT) condition as before.

The four routing method bodies themselves are intentionally left separate:
OBP has no timeslot concept and keys stream state by stream-id, while HBP
keys per-slot, so merging them would be a risky change on the audio hot
path with no live-traffic test rig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:41:41 -05:00
Cort BuffingtonandClaude Opus 4.8 467c00a739 hblink.py: extract shared ACL helper for master and peer paths
The master and peer DMRD receive paths carried byte-for-byte identical
~30-line ACL blocks. Factor them into HBSYSTEM.dmrd_acl_check(), which
returns True when a call must be dropped and logs each dropped stream
only once per slot. Log strings and the per-slot _laststrid dedup
behavior are preserved exactly (verified against the original logic
across a 20k-case matrix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:41:32 -05:00
Cort BuffingtonandClaude Opus 4.8 06c3207909 Fix latent bugs and remove dead code
Behavior-preserving cleanups across the three in-scope apps:

- bridge.py: fix NameError crash in the group-call de-activation trigger
  (_dst_group -> _dst_id); this reference is the lone surviving copy of a
  fix that never propagated through the duplicated routing code, and would
  stop the reactor via the looping-call errback when hit.
- bridge.py: fix send_bridgeEvent str guard (.decode/error= -> .encode/
  errors=); previously latent since callers pre-encode to bytes.
- hblink.py: drop a debug print() left in the DMRC login handler.
- bridge_all.py: fix 'RX:LOSS' -> 'RX_LOSS' key typo (slot 2) and remove
  unused/shadowed imports (bitarray, decode, bptc, dmr_utils3.const).
- config.py: remove unused PEER SOCK_ADDR value.
- const.py: remove duplicate RPTL definition.
- hblink-SAMPLE.cfg: remove orphaned EXPORT_AMBE keys (never parsed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:41:26 -05:00
50 changed files with 7359 additions and 1223 deletions

No files matched your search

+4
View File
@@ -98,3 +98,7 @@ local_subscriber_ids.*
peer_ids.*
local_peer_ids.*
talkgroup_ids.*
# Dashboard local config (copy from dashboard/config_sample.py)
dashboard/config.py
dashboard/__pycache__/
+271
View File
@@ -0,0 +1,271 @@
# HBlink3 Access Control Lists (ACLs)
Access Control Lists are one of the most powerful — and most under-documented —
features in HBlink3. They let you decide, packet by packet, **who** may use your
system, **which subscribers** may talk through it, and **which talkgroups** are
allowed on each timeslot. This document explains the ACL grammar, the exact
matching rules (verified against the code in `config.py` and `hblink.py`), a set
of worked examples from simple to advanced, and honest guidance on performance.
ACLs are cheap and safe to use. Read the [Performance](#performance) section and
you will see there is almost no reason *not* to use them.
---
## 1. The two-part grammar
Every ACL is a single string of the form:
```
ACTION:entry,entry,entry,...
```
* **ACTION** is either `PERMIT` or `DENY`.
* **entries** are comma-separated, and each entry is one of:
* a **single ID** — e.g. `3120101`
* a **range** — two IDs joined by a hyphen, low first — e.g. `1000-2000`
* the literal keyword **`ALL`** — matches every possible ID.
Examples of valid ACLs:
```
PERMIT:ALL
DENY:1
DENY:1,1000-2000,4500-60000,17
PERMIT:3120000-3120999,3123456
```
An **empty** value is treated as `PERMIT:ALL` (permit everything).
> There is exactly **one** ACTION per ACL. You cannot mix `PERMIT` and `DENY`
> entries in the same list. If you need "allow these but block those," you layer
> two ACLs — see [Section 6](#6-layering-global-and-system-acls).
---
## 2. The single most important rule: what happens to IDs you *didn't* list
This is the part that trips everyone up, so read it twice.
An ACL does two things at once. It defines an ACTION for the IDs you **list**,
and it *implies the opposite ACTION* for every ID you **don't** list.
| You write | A **listed** ID is… | An **unlisted** ID is… | Mental model |
|-----------|--------------------|------------------------|--------------|
| `PERMIT:…` | permitted | **denied** | **whitelist** — only these get in |
| `DENY:…` | denied | **permitted** | **blacklist** — everyone except these |
So:
* `PERMIT:3120100-3120199` means *"allow only IDs 3120100–3120199, block the
rest of the world."*
* `DENY:3120100-3120199` means *"block IDs 3120100–3120199, allow everyone
else."*
A listed ID gets the ACTION; an unlisted ID gets the opposite.
**Rule of thumb:** use `DENY:` when you want to block a small, known set and let
everyone else through. Use `PERMIT:` when you want a closed system where only an
explicit list is allowed.
`PERMIT:ALL` and `DENY:1` (deny the reserved ID 1, permit all real IDs) are the
two most common "effectively open" settings.
---
## 3. The four ACL types
HBlink3 applies four kinds of ACL. Each is a separate config key.
| Key | Gates | Value domain | Notes |
|-----|-------|--------------|-------|
| `REG_ACL` | **Repeater/peer registration** (login) | peer IDs, `1 … 4294967295` | Only meaningful on **SERVER** systems. Outbound/OpenBridge links don't accept registrations. **Always enforced** — see §5. |
| `SUB_ACL` | **Subscriber** (the source radio ID of a call) | subscriber IDs, `1 … 16776415` | Applied to every voice/data stream. |
| `TGID_TS1_ACL` | **Destination talkgroup on Timeslot 1** | talkgroup IDs, `1 … 16776415` | |
| `TGID_TS2_ACL` | **Destination talkgroup on Timeslot 2** | talkgroup IDs, `1 … 16776415` | |
The value domains matter: subscriber and talkgroup IDs are checked against a
maximum of **16776415** (`const.ID_MAX`), while registration (peer) IDs go up to
**4294967295** (`const.PEER_MAX`). An entry outside the valid range causes
HBlink3 to **exit at startup** with an `ACL CREATION ERROR` — this is a
guardrail, not a bug (see [Warnings](#warnings)).
---
## 4. Where each ACL is enforced
For an inbound **DMRD voice/data** frame, HBlink3 checks (in order):
1. GLOBAL `SUB_ACL` — source subscriber
2. GLOBAL `TGID_TS1_ACL` or `TGID_TS2_ACL` — destination TG, by slot
3. SYSTEM `SUB_ACL` — source subscriber
4. SYSTEM `TGID_TS1_ACL` or `TGID_TS2_ACL` — destination TG, by slot
The call is dropped at the **first** ACL that denies it. It must pass **all
four** to be forwarded.
For **registration** (a repeater logging into a master), both the GLOBAL and the
SYSTEM `REG_ACL` must permit the peer — if either one denies it, the login is
refused.
### OpenBridge special case
OpenBridge systems do not register (no `REG_ACL`), and **all OpenBridge traffic
is carried on Timeslot 1**. That means the **global `TGID_TS1_ACL`** is your
talkgroup filter for OpenBridge peers. TS2 is forced to `PERMIT:ALL` internally
for OpenBridge systems.
---
## 5. The `USE_ACL` switch — and the one ACL it can't turn off
Each stanza has a `USE_ACL` boolean.
* `USE_ACL: True` — enforce this stanza's `SUB_ACL` and TGID ACLs.
* `USE_ACL: False` — skip the subscriber and talkgroup checks for this stanza
(traffic passes freely).
**Registration ACLs are always enforced.** `REG_ACL` is *not* governed by
`USE_ACL`. Even with `USE_ACL: False`, a master will still refuse a repeater that
its `REG_ACL` denies. This is by design: registration is your front door.
> Note: the stanzas still require the ACL keys to be present even when
> `USE_ACL: False`. Leave them at `PERMIT:ALL` if unused.
---
## 6. Layering GLOBAL and SYSTEM ACLs
Because GLOBAL is checked first and SYSTEM second, and **both must pass**, you
can compose behaviors that a single `PERMIT`/`DENY` line cannot express. Think of
GLOBAL as your site-wide policy and SYSTEM as per-connection refinement.
**Example — a global blacklist plus a per-system whitelist.**
```ini
[GLOBAL]
USE_ACL: True
SUB_ACL: DENY:1,2,3 # site-wide: block a few abusive IDs, allow the rest
[LOCAL-CLUB-REPEATER]
USE_ACL: True
SUB_ACL: PERMIT:3120000-3120999 # this repeater: ONLY our club's ID block
```
A subscriber must (a) not be one of the globally blocked IDs **and** (b) fall
inside the club's block. The intersection is exactly what you want, and neither
ACL alone could express it.
---
## 7. Worked examples
All examples assume `USE_ACL: True` in the relevant stanza.
**Open system, tidy.** Allow every real subscriber, block only the reserved
ID 1:
```ini
SUB_ACL: DENY:1
TGID_TS1_ACL: PERMIT:ALL
TGID_TS2_ACL: PERMIT:ALL
```
**Closed talkgroup policy on TS1.** Permit only a curated set of statewide and
local talkgroups on Timeslot 1; leave TS2 open:
```ini
TGID_TS1_ACL: PERMIT:8,9,3100-3199,31201,31203
TGID_TS2_ACL: DENY:1
```
`3100-3199` covers the whole regional block in one range; `8`, `9`, `31201`,
`31203` are individual talkgroups. Everything else on TS1 is dropped.
**Ban a handful of subscribers network-wide** while permitting everyone else:
```ini
[GLOBAL]
SUB_ACL: DENY:1,2606234,3141592,3120555
```
**Members-only repeater.** Only your membership ID range may key up, and only on
approved talkgroups:
```ini
[MEMBERS-ONLY]
USE_ACL: True
SUB_ACL: PERMIT:3120000-3120499
TGID_TS1_ACL: PERMIT:2,9,3120
TGID_TS2_ACL: PERMIT:3120,31205
```
**Registration control on a server.** Accept only two specific repeater IDs onto
this server; reject all other login attempts:
```ini
[SERVER-1]
MODE: SERVER
REG_ACL: PERMIT:311111,311222
```
**Large but efficient range list.** Contiguous and adjacent ranges are merged at
load time, so this…
```ini
SUB_ACL: PERMIT:1000-1999,2000-2999,3000-3999,3120101
```
…collapses internally to a single span `1000–3999` plus the single ID `3120101`.
Write ranges however is clearest to *you*; the engine tidies them up.
---
## Performance
**Short version: turn ACLs on. They are fast.**
* All of the work of reading and organizing your ACLs happens **once, when
HBlink3 starts** — not while traffic is flowing.
* Checking a packet is near-instant, and it stays fast no matter how long your
lists get. A list with a thousand entries is checked essentially as quickly as
a list with ten. You will not see the difference on real traffic.
* Runs of consecutive IDs are **combined** internally, so a config that looks
huge often takes very little memory.
Where cost actually comes from — and it is small:
* **Every enabled ACL type adds one check per packet.** With GLOBAL + SYSTEM
layering that's up to four checks per frame. Constant, tiny, predictable.
* **Very large lists of thousands of individual (non-consecutive) IDs** use a
little more memory and startup time, but checking them stays fast.
Practical guidance:
* Use ranges (`1000-2000`) instead of listing many consecutive IDs — it's less to
type and smaller in memory.
* If a stanza genuinely uses no ACLs, `USE_ACL: False` skips the subscriber/TGID
checks entirely (registration is still enforced).
* Don't hesitate to write expressive ACLs. The design specifically optimizes for
exactly this; in practice the cost is negligible.
---
## Warnings
* **`PERMIT` is a whitelist — it denies everyone you didn't list.** The most
common mistake is writing `PERMIT:3120101` intending "also allow 3120101" and
accidentally locking out the entire rest of the world. Re-read [Section 2](#2-the-single-most-important-rule-what-happens-to-ids-you-didnt-list).
* **Out-of-range entries stop HBlink3 at startup.** A subscriber/TGID entry above
16776415, or a peer entry above 4294967295, triggers
`ACL CREATION ERROR, VALUE OUT OF RANGE` and the program exits. Check your
numbers if HBlink3 won't start after an ACL edit.
* **Write ranges low-to-high** (`1000-2000`, not `2000-1000`). A reversed range
will silently never match anything.
* **`REG_ACL` ignores `USE_ACL`.** You cannot "disable" registration filtering by
setting `USE_ACL: False`; set `REG_ACL: PERMIT:ALL` if you want it open.
* **GLOBAL and SYSTEM ACLs are ANDed.** If a call is mysteriously dropped, check
the GLOBAL stanza too — a global ACL can veto traffic a permissive system ACL
would otherwise allow.
* **The keys must exist even when unused.** Leave unused ACLs at `PERMIT:ALL`
rather than deleting them.
+44
View File
@@ -0,0 +1,44 @@
# Changelog
Notable changes to HBlink3. This is the first tagged release; it establishes a
baseline of the current state rather than enumerating the project's full history.
## [3.0.0] — 2026-07-12
First tagged release. Highlights of the current state:
### Reporting & dashboard (event-driven overhaul)
- The daemon→dashboard feed is event-driven newline-delimited JSON. Repeater
connect/disconnect and live call events are pushed **as they happen**; the full
config/bridge state is resent only as a slow periodic resync + heartbeat (it used
to be a full push every interval).
- Per-repeater **ping-loss** quality metric — surfaces a repeater that stays
connected but drops keepalive pings (a lossy link → choppy audio), self-calibrated
to each repeater's own ping cadence. Configured with `PING_LOSS_WINDOW` /
`PING_LOSS_WARN` in `[GLOBAL]`; the dashboard golds a repeater's callsign at/above
the warn threshold.
- Canonical event vocabulary: `repeater_connected` / `repeater_disconnected`,
`stream_start` / `stream_end`.
- **Unix-socket transport** for a same-host dashboard (`REPORT_TRANSPORT=unix` +
`REPORT_SOCKET`; dashboard side `HBLINK_TRANSPORT` / `HBLINK_SOCKET`), which
retires the silently-severed-link failure class for local dashboards. Remote (TCP)
dashboards gain TCP keepalive + a feed read-timeout so a dead link is detected.
### OpenBridge configuration model — **breaking**
- OpenBridge systems are configured in a per-OBP table
`OBP_BRIDGES = { <obp system> : { <bridge> : <TGID> } }` in `rules.py`, **not** as
inline `BRIDGES` members. OBP entries carry no ON/OFF/TIMEOUT triggers or real
timeslot (a trunk has no RF user to key them). The table doubles as the
fail-closed ingress/egress filter; a one-TGID-to-two-bridges fork is a startup
**ERROR**, a cross-OpenBridge renumber a **WARNING**.
- Migration tool `tools/migrate_obp_rules.py` converts an old inline `rules.py`.
### System-mode terminology — **breaking**
- `MASTER` → **`SERVER`**, `PEER` / `CLIENT` → **`OUTBOUND`** (`OPENBRIDGE`
unchanged). Update the `MODE:` line of each stanza in `hblink.cfg`.
### Documentation
- README (with an upgrade warning), INSTALL, CONFIGURING, ACLS, and the dashboard
docs refreshed for all of the above.
[3.0.0]: https://github.com/n0mjs710/hblink3/releases/tag/v3.0.0
+272
View File
@@ -0,0 +1,272 @@
# Configuring HBlink3
HBlink3's conference bridge (`bridge.py`) is driven by two files:
| File | What it defines |
|---|---|
| `hblink.cfg` | The program itself: global timers, reporting, logging, alias lookups, and one stanza per **system** (a Server you host, an Outbound system you dial out as, or an OpenBridge link). |
| `rules.py` | The **routing**: which systems talk to which, on what timeslot/talkgroup, and the talkgroup triggers/timers that turn bridges on and off. |
This document is the full field reference. The sample files (`hblink-SAMPLE.cfg`, `rules_SAMPLE.py`) carry only terse inline notes and point back here.
Both are read at startup; there is no live reload — change a file, restart the service.
---
# `hblink.cfg`
INI format (`configparser`). Section names in `[BRACKETS]`; `KEY: value` pairs. The four fixed sections (`[GLOBAL]`, `[REPORTS]`, `[LOGGER]`, `[ALIASES]`) must each appear once. Every **other** section is a *system*, named by you and identified by its `MODE`.
## `[GLOBAL]`
| Field | Meaning |
|---|---|
| `PATH` | Working directory for runtime files. `./` (the program's own directory) is normal. |
| `PING_TIME` | Seconds between keepalive pings on Server and Outbound connections. |
| `MAX_MISSED` | Missed pings before a connection is declared dead and torn down. |
| `PING_LOSS_WINDOW` | Optional (default `5`, range `1`–`60`). Minutes over which the dashboard measures each repeater's **ping loss** — the share of expected keepalive pings that went missing. Larger = smoother / longer memory. Surfaces a repeater that stays connected but has a lossy link (choppy audio). A repeater that merely pings less often is not counted as lossy. |
| `PING_LOSS_WARN` | Optional (default `5`). Ping-loss **percent** at or above which the dashboard turns a repeater's callsign gold. `0` disables the flag. |
| `USE_ACL` | `True`/`False` top-level on/off switch for the **global** ACLs below. System-level ACLs are independent of this. |
| `REG_ACL` | Registration ACL — which **repeater radio IDs** may log in to any Server you host. ACL syntax below. |
| `SUB_ACL` | Subscriber ACL — which **subscriber IDs** (the person keying up) may pass traffic, globally. |
| `TGID_TS1_ACL` / `TGID_TS2_ACL` | Talkgroup ACLs applied to timeslot 1 / timeslot 2 traffic, globally. |
### ACL syntax
Every ACL is one string: `ACTION:list`, where `ACTION` is `PERMIT` or `DENY` and `list` is comma-separated IDs and ranges, or the word `ALL`.
```
PERMIT:ALL # allow everything
DENY:1 # block ID 1, allow the rest
DENY:1-5,3120101 # block the range 1–5 and the single ID 3120101
PERMIT:3100-3199,9 # allow only this range and ID 9; deny everything else
```
A global ACL and a system ACL both apply — traffic must pass **both**. Registration ACLs are only meaningful for `SERVER` systems (an Outbound or OpenBridge link never accepts registrations). See **[ACLS.md](ACLS.md)** for the full ACL model — PERMIT/DENY precedence, how ranges are matched, and worked examples.
## `[REPORTS]`
Feeds the real-time dashboard. See [`dashboard/`](dashboard/).
| Field | Meaning |
|---|---|
| `REPORT` | `True`/`False` — emit the event stream at all. |
| `REPORT_INTERVAL` | Seconds between periodic status reports. |
| `REPORT_TRANSPORT` | Optional (default `tcp`). `tcp` = listen on a TCP port (dashboard may be remote); `unix` = listen on a local Unix-domain socket instead. A same-box dashboard on a Unix socket can't be broken by a NIC flap or connection-tracking eviction, so use `unix` when the dashboard runs on the same host. |
| `REPORT_PORT` | TCP port the report service listens on (when `REPORT_TRANSPORT` = `tcp`). |
| `REPORT_SOCKET` | Filesystem path for the listening socket (when `REPORT_TRANSPORT` = `unix`), e.g. `/tmp/hblink3-report.sock`. Access is governed by filesystem permissions; `REPORT_CLIENTS` is not used on a Unix socket. |
| `REPORT_CLIENTS` | Comma-separated client IPs allowed to connect (e.g. `127.0.0.1`) — TCP only. |
> The dashboard has matching `HBLINK_TRANSPORT` (`tcp`/`unix`) and `HBLINK_SOCKET` settings — see [dashboard/README.md](dashboard/README.md). Point the dashboard at the same transport the daemon serves.
## `[LOGGER]`
| Field | Meaning |
|---|---|
| `LOG_FILE` | Path to the log file. Empty ⇒ `/dev/null`. |
| `LOG_HANDLERS` | Handler set, e.g. `console-timed`, `file-timed`, or both comma-joined. |
| `LOG_LEVEL` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. |
| `LOG_NAME` | Name tag prefixed to log lines. |
> Running under **systemd**? You can skip the log file entirely and rely on the journal:
> set `LOG_FILE` to `/dev/null` (or leave it empty) and use a `console` handler — systemd
> captures stdout into the journal (`journalctl -u hblink3`). Most installs are perfectly
> fine doing exactly this.
## `[ALIASES]`
Maps DMR IDs to callsigns/names so logs read as `N0MJS` instead of `3120001`. The files come from radioid.net.
| Field | Meaning |
|---|---|
| `USE_ALIASES` | `True`/`False`. When `False`, no alias files are loaded and logs show raw numeric IDs. Default `False`. |
| `TRY_DOWNLOAD` | `True` fetches fresh files from the URLs below on startup and refreshes them past `STALE_DAYS`. `False` uses whatever is already on disk. |
| `PATH` | Directory holding the alias JSON files. Must end in `/`. Point this at the dashboard's directory to share one copy. |
| `PEER_FILE` / `SUBSCRIBER_FILE` / `TGID_FILE` | Filenames for the peer, subscriber, and talkgroup maps. `TGID_FILE` is optional. |
| `PEER_URL` / `SUBSCRIBER_URL` | radioid.net download URLs used when `TRY_DOWNLOAD` is `True`. |
| `STALE_DAYS` | Re-download after this many days. |
> ⚠️ **Memory warning — read before enabling aliases here.**
> The full radioid.net subscriber database, expanded into HBlink's in-memory Python
> dictionary, balloons to roughly **500 MB of RAM**. `bridge.py` (and `dmrlink`) *can*
> load these files just to print callsigns in the log — but if you also run the
> **dashboard** with aliases, you load the whole thing **twice: over 1 GB of RAM, purely
> for prettier logs.**
>
> **Recommendation:** enable aliases in the **dashboard only**, and leave
> `USE_ALIASES: False` here (or omit the files) for `bridge.py` / `dmrlink`. Turn them on
> in the main program **only** if the subscriber file has been **heavily filtered** to a
> much smaller size first — use the dashboard's `FILTER_COUNTRIES` (in `dashboard/config.py`),
> which writes only the selected countries to disk; both programs then load that smaller
> file. Filtered to a country or two, the footprint drops to roughly **150 MB**; it's the
> *unfiltered* global load in two processes that costs the gigabyte and is almost never
> worth it.
## System stanzas
Any section that isn't one of the four above is a *system*. It is loaded only if `ENABLED: True`, and its `MODE` selects one of three types:
| `MODE` | Role |
|---|---|
| `SERVER` | You are the **Server**: Clients — repeaters, hotspots, or another HBlink acting as a Client — connect *in* to you. (Older DMR docs call a Server a "master"; same thing — a master **is** a Server.) |
| `OUTBOUND` | You are a **Client**: HBlink masquerades as a repeater/hotspot and dials *out* to someone else's Server. |
| `OPENBRIDGE` | A **Server-to-Server** OpenBridge link — no Client role, no login handshake. |
> 🛑 **The section name is load-bearing — get it exactly right.** The name you give a
> system (e.g. `[SERVER-1]`) is the exact string `rules.py` references in its `SYSTEM`
> field. **If any `SYSTEM` in `rules.py` does not match a system name in `hblink.cfg`,
> `bridge.py` will not start at all.** Keep the two files in sync, character for character.
### `MODE: SERVER` (be a Server)
You are the Server; Clients (repeaters, hotspots, or another HBlink acting as a Client) connect in.
| Field | Meaning |
|---|---|
| `ENABLED` | `False` ⇒ the stanza is skipped entirely. |
| `REPEAT` | `True` repeats traffic between Clients connected to *this* Server (normal). `False` makes it a pure bridge feed. |
| `MAX_REPEATERS` | Cap on simultaneously logged-in Clients. |
| `IP` | Local bind address (`0.0.0.0` = all interfaces). |
| `PORT` | Local UDP port Clients connect to. |
| `PASSPHRASE` | Shared login secret Clients must present. |
| `GROUP_HANGTIME` | Seconds a slot stays reserved to the current talkgroup after a transmission, so quick replies aren't lost to contention. |
| `USE_ACL` | Enable this system's own ACLs. |
| `REG_ACL` | Which radio IDs may register to this Server. |
| `SUB_ACL` | Subscriber ACL for this system. |
| `TGID_TS1_ACL` / `TGID_TS2_ACL` | Per-timeslot talkgroup ACLs for this system. |
### `MODE: OUTBOUND` (be a Client)
HBlink masquerades as a repeater/hotspot and dials out to another Server. Most fields are the homebrew configuration/login packet this Client presents to the far Server — cosmetic to HBlink, but visible on the far end.
> 🛑 **BrandMeister will not accept this.** BrandMeister prohibits inbound Client
> (`OUTBOUND`) connections for bridging and **requires OpenBridge** instead. To link
> HBlink to BrandMeister, use `MODE: OPENBRIDGE`, not `OUTBOUND`.
| Field | Meaning |
|---|---|
| `ENABLED` | `False` ⇒ skipped. |
| `LOOSE` | `True` relaxes inbound validation: frames are accepted even if the source radio ID in the header doesn't match this connection's `RADIO_ID`. Needed for some Servers; leave `False` when the far end is strict. |
| `IP` / `PORT` | Our own local bind address/port. |
| `SERVER_IP` / `SERVER_PORT` | The far Server's address/port we dial. |
| `PASSPHRASE` | Login secret for the far Server. |
| `RADIO_ID` | The DMR ID we log in as. |
| `CALLSIGN` | Our callsign (8 chars, space-padded). |
| `RX_FREQ` / `TX_FREQ` | Reported RX/TX frequencies (Hz, 9 digits). |
| `TX_POWER` | Reported TX power. |
| `COLORCODE` | Reported DMR color code. |
| `LATITUDE` / `LONGITUDE` / `HEIGHT` / `LOCATION` / `DESCRIPTION` / `URL` | Reported location/metadata. |
| `SLOTS` | Slot capability reported to the Server. |
| `SOFTWARE_ID` / `PACKAGE_ID` | Reported software/package identifiers. |
| `OPTIONS` | Free-form options string some Servers parse for per-connection settings. Leave empty if unused. |
| `GROUP_HANGTIME` | As above. |
| `USE_ACL` / `SUB_ACL` / `TGID_TS1_ACL` / `TGID_TS2_ACL` | This system's ACLs. (No `REG_ACL` — a Client doesn't accept registrations.) |
### `MODE: OPENBRIDGE` (server-to-server link)
OpenBridge is a Server-to-Server (both ends equal), always-TS1 link authenticated by a shared HMAC key and the source socket — no login/registration handshake.
| Field | Meaning |
|---|---|
| `ENABLED` | `False` ⇒ skipped. |
| `IP` / `PORT` | Our own local bind address/port. |
| `TARGET_IP` / `TARGET_PORT` | The far OpenBridge server's address/port. |
| `PASSPHRASE` | Shared HMAC-SHA1 key. **Must match exactly on both ends** — this plus the source socket *is* the authentication. |
| `NETWORK_ID` | A DMR-ID-shaped number identifying this server. By convention it is stamped into every outgoing frame's "Repeater ID" field. |
| `PRESERVE_SOURCE_PEER` | `True` forwards the **originating** peer ID in that Repeater-ID field instead of overwriting it with `NETWORK_ID`. The field is unvalidated (auth is the HMAC + source socket) and used only for logging/reporting, so this simply preserves a call's true source across the link. Default `False` (spec-conventional). Most useful when **both** ends enable it. |
| `BOTH_SLOTS` | `True` lets unit (private) calls use both slots; group traffic is always TS1. **🛑 Only HBlink is known to accept this. No other OpenBridge server (BrandMeister, DMR+, etc.) accepts both-slots traffic — set `True` only on HBlink-to-HBlink links, and leave it `False` everywhere else.** |
| `USE_ACL` / `SUB_ACL` | This link's subscriber ACL. |
| `TGID_ACL` | Talkgroup ACL (TS1 only — note the single-slot name, unlike the `SERVER`/`OUTBOUND` `TGID_TS1_ACL`/`TGID_TS2_ACL`). |
---
# `rules.py`
Defines the conference bridges — the actual routing. Think "bridge groups" or "reflectors": every system marked active on a bridge exchanges traffic with every other active system on that same bridge. It is **not** end-to-end; each system is activated on each bridge independently.
## `BRIDGES`
A dict. Each **key** is an arbitrary bridge name (`'WORLDWIDE'`, `'STATEWIDE'`, …). Each **value** is a list of system entries — one per participating system — each a dict of:
| Key | Meaning |
|---|---|
| `SYSTEM` | The system's section name from `hblink.cfg`. **Must match exactly.** |
| `TS` | Timeslot (`1` or `2`) this rule matches. (XLX links should always use TS 2.) |
| `TGID` | Talkgroup ID this rule matches. (XLX links should always use TG 9.) |
| `ACTIVE` | `True`/`False` — is this system currently passing traffic on this bridge? (Triggers below can flip it at runtime.) |
| `TO_TYPE` | Timeout behavior: `'ON'` = auto-turn-**off** after `TIMEOUT`; `'OFF'` = auto-turn-**on** after `TIMEOUT`; anything else (use `'NONE'`) = no timer. |
| `TIMEOUT` | Timer length in **minutes**. (Minutes only — timers cost performance.) |
| `ON` | List of talkgroup IDs that, when keyed, **activate** this system on the bridge. Always a list, even for one (`[2]`); use `[]` for none. |
| `OFF` | List of talkgroup IDs that **deactivate** it. |
| `RESET` | List of talkgroup IDs that reset a running timer without otherwise changing state. Only needed when your voice TGID differs from your trigger TGIDs; otherwise `[]`. |
### Example
```python
BRIDGES = {
'WORLDWIDE': [
{'SYSTEM': 'SERVER-1', 'TS': 1, 'TGID': 1, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'ON', 'ON': [2], 'OFF': [9, 10], 'RESET': []},
{'SYSTEM': 'CLIENT-1', 'TS': 1, 'TGID': 3100, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'ON', 'ON': [2], 'OFF': [9, 10], 'RESET': []},
],
'STATEWIDE': [
{'SYSTEM': 'SERVER-1', 'TS': 2, 'TGID': 3129, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [4], 'OFF': [7, 10], 'RESET': []},
{'SYSTEM': 'CLIENT-2', 'TS': 2, 'TGID': 3129, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [4], 'OFF': [7, 10], 'RESET': []},
],
}
```
Here anything on `SERVER-1` TS1/TG1 reaches `CLIENT-1` TS1/TG3100 and vice-versa (talkgroups are rewritten per entry). On `WORLDWIDE`, keying TG2 turns a system on for 2 minutes; TG9 or TG10 turns it off. `STATEWIDE` has no timers.
## `OBP_BRIDGES`
**OpenBridge (`MODE: OPENBRIDGE`) systems are configured here, not as inline `BRIDGES` members.** An OpenBridge is a point-to-point trunk that passes talkgroups by TGID — it has no RF user to key `ON`/`OFF`/`TIMEOUT` triggers and no meaningful timeslot — so it declares a compact per-OBP table mapping **bridge → TGID** instead:
```python
OBP_BRIDGES = {
'VESTA_OBP': { # an OPENBRIDGE system from hblink.cfg
'WORLDWIDE': 1,
'STATEWIDE': 3129,
'ENGLISH': (13, 2), # optional (TGID, TS) form; TS defaults to 1 otherwise
},
}
```
Read one row as **"on this OpenBridge, this TGID *is* this bridge."** That single fact is the route in **both** directions and the filter: at startup `bridge.py` expands each row into a bridge member, so a TGID **not** listed for an OBP is simply never routed — dropped **fail-closed**, in and out. There is no separate ACL.
| Rule | Behavior |
|---|---|
| TS | Defaults to `1` (OpenBridge's "no timeslot" placeholder). Override with the `(TGID, TS)` tuple form. |
| Triggers | None — `TO_TYPE`/`TIMEOUT`/`ON`/`OFF` don't apply to a trunk and are omitted. |
| OBP system left as an inline `BRIDGES` member | **Startup ERROR** (move it into `OBP_BRIDGES`). |
| Same OBP maps one TGID → two bridges (**ingress fork**) | **Startup ERROR** — it would duplicate the stream. |
| A bridge carrying **different** TGIDs on two OBPs (**renumber in transit**) | Allowed, logs a **WARNING** — usually a typo, occasionally intentional at a network boundary. |
`OBP_BRIDGES` is optional — omit it (or leave it `{}`) if you run no OpenBridges.
### Migrating from the old inline form
Earlier HBlink3 configured OpenBridge systems as inline `BRIDGES` members. To convert an existing `rules.py`, run the migration tool from the repo root:
```bash
python tools/migrate_obp_rules.py --config hblink.cfg --rules rules.py --out rules.py.migrated
```
It reads `hblink.cfg` to learn which systems are `OPENBRIDGE`, lifts each inline OBP member into `OBP_BRIDGES`, prints a summary of what moved, flags any ingress-fork **ERROR** or renumber **WARNING**, and writes a new file (it never overwrites your `rules.py`). Review the output — comments from the original are not carried over — then replace `rules.py` with it.
## `UNIT`
```python
UNIT = ['SERVER-1', 'CLIENT-2']
```
A flat list of system names that should bridge **unit-to-unit (private) calls** to each other. Systems not listed here pass group calls per `BRIDGES` but ignore private calls.
## Validating
`rules.py` is executable Python. Run it directly to syntax-check before restarting the service:
```
python3 rules.py
```
It pretty-prints `BRIDGES` and `UNIT` if the file parses; a traceback means a syntax error to fix.
+99
View File
@@ -0,0 +1,99 @@
# Installing HBlink3
## Prerequisites
- Python **3.8** or newer
- Linux (developed and run on Linux; other platforms are untested)
- Install and run as the **same user account**
## 1. Clone and create a virtual environment
```bash
git clone https://github.com/n0mjs710/hblink3.git
cd hblink3
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
The optional web [dashboard](dashboard/) can share this same virtualenv — it only
adds FastAPI and Uvicorn on top of the core. To include it:
```bash
pip install -r dashboard/requirements.txt # or: pip install -e ".[dashboard]"
```
## 2. Configure
Copy the sample config (the live name is git-ignored, so updates won't overwrite your edits):
```bash
cp hblink-SAMPLE.cfg hblink.cfg
```
Edit `hblink.cfg`:
- **[GLOBAL]** — ping timing and the default access-control lists.
- **[REPORTS]** — enable the TCP feed for a dashboard (listen port and allowed client IPs).
- **[LOGGER]** — log file, handlers, and level.
- **[ALIASES]** — optional download of repeater/subscriber ID files from radioid.net.
- **System stanzas** — one per connection. Set `MODE` to:
- `SERVER` — accept incoming repeaters/hotspots (formerly `MASTER`)
- `OUTBOUND` — connect outward to another server (formerly `PEER`/`CLIENT`)
- `OPENBRIDGE` — link to Brandmeister / DMR+ (IPSC2)
Duplicate a stanza for each system; the stanza name (e.g. `[SERVER-1]`) must be unique.
If you are running `bridge.py`, also create the rules file:
```bash
cp rules_SAMPLE.py rules.py
```
`rules.py` defines:
- **`BRIDGES`** — each conference bridge and the system / timeslot / talkgroup that belongs to it, with optional ON/OFF/RESET trigger talkgroups and timeouts.
- **`OBP_BRIDGES`** — OpenBridge (`OPENBRIDGE`) systems only: a per-OBP `{bridge: TGID}` table (not inline `BRIDGES` members). See [CONFIGURING.md](CONFIGURING.md#obp_bridges).
- **`UNIT`** — the systems permitted to exchange private (unit) calls.
Every system named in `rules.py` must exist and be enabled in `hblink.cfg`. **Upgrading from an older HBlink3?** OpenBridge members used to live inline in `BRIDGES`; convert your file with `python tools/migrate_obp_rules.py` (see [CONFIGURING.md](CONFIGURING.md#obp_bridges)).
## 3. Run
```bash
source venv/bin/activate
python bridge.py -c hblink.cfg -r rules.py # conference-bridge router (main app)
# or
python bridge_all.py -c hblink.cfg # forward-everything proxy
# or
python hblink.py -c hblink.cfg # protocol core, standalone server/outbound
```
`-c` / `-r` default to `hblink.cfg` and `rules.py` in the program directory. `-l LEVEL` overrides the configured log level.
## 4. Run as a service (systemd)
A sample unit, `hblink3.service`, is provided (it targets `bridge.py`):
```bash
sudo cp hblink3.service /etc/systemd/system/
sudoedit /etc/systemd/system/hblink3.service # set User/Group and the install paths
sudo systemctl daemon-reload
sudo systemctl enable --now hblink3
journalctl -u hblink3 -f # follow the log
```
## Updating
```bash
git pull
source venv/bin/activate
pip install -r requirements.txt # in case dependencies changed
sudo systemctl restart hblink3
```
`hblink.cfg` and `rules.py` are git-ignored and are not touched by `git pull`.
## Notes
- The legacy voice utilities (`playback.py`, `play_ambe.py`) are **not** migrated to asyncio and will not run against the current core.
+134 -18
View File
@@ -1,30 +1,146 @@
## PROJECT: Open Source HomeBrew Repeater Proctol Client/Master. ##
# HBlink3
**UPDATES:**
HBlink3 is an open-source implementation of the DMR **HomeBrew Repeater Protocol (HBP)** in Python 3. It acts as an HBP server and/or client and routes calls between MMDVM-based DMR systems, working as a **transit / conference-bridge router** — traffic is selectively routed from ingress systems to egress systems according to a rules file. It can also connect to other HBlink3 and similar systems via **OpenBridge**.
No longer updated. This project is depreciated. HBlink4 is the current version that is in active development
> **Which HBlink?** HBlink3 is "system-oriented" and built for **transit routing and conference bridging** between networks. Its companion, **HBlink4** (by the same author), is a repeater-oriented **endpoint server** for running a single end-point network with no granudlar transit routing. Pick the one that matches your role.
**PURPOSE:** Thanks to the work of Jonathan Naylor, G4KLX; Hans Barthen, DL5DI; Torsten Shultze, DG1HT we have an open protocol for internetworking DMR repeaters. Unfortunately, there's no generic client and/or master stacks. This project is to build an open-source, python-based implementation. You are free to use this software however you want, however we ask that you provide attribution in some public venue (such as project, club, organization web site). This helps us see where the software is in use and track how it is used.
> ⚠️ **Upgrading? Two breaking config changes to know about.**
> 1. **OpenBridge routing moved** — `MODE: OPENBRIDGE` systems are no longer inline `BRIDGES` members in `rules.py`; they now live in a dedicated **`OBP_BRIDGES`** table. Old configs stop with a clear startup error. Run **`python tools/migrate_obp_rules.py`** to convert your `rules.py` automatically, or see [CONFIGURING.md](CONFIGURING.md#obp_bridges).
> 2. **System modes renamed** — `MASTER`→**`SERVER`** and `PEER`/`CLIENT`→**`OUTBOUND`** (`OPENBRIDGE` unchanged). Update the `MODE:` line of each stanza in `hblink.cfg`.
>
> Your `hblink.cfg`/`rules.py` are git-ignored, so a `git pull` won't touch them — but the new code won't accept the old values. Non-OpenBridge configs need only change (2).
For those who will ask: This is a piece of software that implements an open-source, amateur radio networking protocol. It is not a network. It is not intended to be a network. It is not intended to replace or circumvent a network. People do those things, code doesn't.
**PROPERTY:**
This work represents the author's interpretation of the HomeBrew Repeater Protocol, based on the 2015-07-26 documents from DMRplus, "IPSC Protocol Specs for homebrew DMR repeater" as written by Jonathan Naylor, G4KLX; Hans Barthen, DL5DI; Torsten Shultze, DG1HT, also licenced under Creative Commons BY-NC-SA license.
## Applications
**WARRANTY**
None. The owners of this work make absolutely no warranty, express or implied. Use this software at your own risk.
| Program | Purpose |
|---|---|
| `bridge.py` | The main application: a configurable conference-bridge call router with dynamic on/off triggering, timeouts, and private (unit) call routing. |
| `bridge_all.py` | A simple proxy that forwards all traffic between every configured system — makes several repeaters appear as one. |
| `hblink.py` | The protocol core. Runs standalone as a server/outbound system for testing, and is the module the applications are built on. |
**PRE-REQUISITE KNOWLEDGE:**
This document assumes the reader is familiar with Linux/UNIX, the Python programming language and DMR.
## Features
**MORE DOCUMENTATION TO COME**
- HBP **server** and **outbound** (client) modes for MMDVM repeaters and hotspots
- **OpenBridge** connectivity to other transit call routers, including Brandmeister / DMR+ (IPSC2)
- Rules-based **conference-bridge routing** with dynamic ON/OFF/RESET talkgroup triggers and timeouts, including the ability to use TGIDs as in-band signalling to trigger dynamic connections -- sometimes referred to as "dial-a-talkgroup".
- **Private (unit) call** routing using a learned subscriber-to-system map that only floods systems configured for unit calls, and only until the target unit's system is located. It then prunes re-transmission to the system with the target unit.
- Layered **access control lists** — registration, subscriber, and per-timeslot talkgroup
- TCP **reporting feed** for external dashboards
- Built on Python **`asyncio`** — no external networking framework
***0x49 DE N0MJS***
## Quick start
Copyright (C) 2016-2020 Cortney T. Buffington, N0MJS n0mjs@me.com
```bash
git clone https://github.com/n0mjs710/hblink3.git
cd hblink3
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
cp hblink-SAMPLE.cfg hblink.cfg # edit for your systems
cp rules_SAMPLE.py rules.py # edit your bridges (bridge.py only)
python bridge.py -c hblink.cfg -r rules.py
```
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
See **[INSTALL.md](INSTALL.md)** for full setup, configuration, and running as a systemd service.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
## Configuration
You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
- **`hblink.cfg`** — global settings, reporting, logging, alias downloads, and one stanza per system (`SERVER`, `OUTBOUND`, or `OPENBRIDGE`). Start from `hblink-SAMPLE.cfg`.
- **`rules.py`** — (`bridge.py` only) defines the conference bridges, the systems/talkgroups/timeslots that belong to each, and the systems permitted to pass unit calls. Start from `rules_SAMPLE.py`.
Your `hblink.cfg` and `rules.py` are git-ignored, so `git pull` won't overwrite them.
### Alias files
HBlink3 resolves numeric IDs to human-readable names in logs and in the dashboard. The
`[ALIASES]` section of `hblink.cfg` controls where these files live and how they are obtained.
**Peer and subscriber aliases** (`peer_ids.json`, `subscriber_ids.json`) are downloaded
automatically from RadioID.net when `TRY_DOWNLOAD: True` is set and the files are older
than `STALE_DAYS`. No manual action is needed for these.
**Talkgroup aliases** (`talkgroup_ids.json`) have no automatic download source. This file
must be created and maintained by the operator. Copy `talkgroup_ids_SAMPLE.json` to the
path configured in `[ALIASES]` (default `./`) and rename it to match `TGID_FILE`
(default `talkgroup_ids.json`), then edit it to reflect the talkgroups used on your network.
The format is a JSON object with a single key containing a list of records, each with an
`id` field (integer talkgroup number) and a `callsign` field (display name). The field is
named `callsign` because the same parser in `dmr_utils3` handles peer, subscriber, and
talkgroup files alike — for talkgroups it simply holds the human-readable name. The outer
key name can be anything, but it must be the first and only key in the object — the parser
takes the value of the first key it finds:
```json
{
"talkgroups": [
{"id": 3100, "callsign": "Nationwide"},
{"id": 3170001, "callsign": "KS Statewide"}
]
}
```
The outer key name (`"talkgroups"` above) can be anything — only the list it contains is
used. Talkgroup IDs not present in the file are displayed as their raw numeric value.
## Dashboard
A real-time web dashboard lives in [`dashboard/`](dashboard/) — a separate program
with a modern dark UI showing server/outbound/OpenBridge systems, conference-bridge
state, and a live call log. Enable HBlink3's reporting feed (`[REPORTS]` in
`hblink.cfg`) and see [dashboard/README.md](dashboard/README.md) to run it. the dashboard
is kept separate to keep performance of the forwarding hot path free from unecessary
loads.
## DMR Talker Alias is not preserved across bridges
When `bridge.py` routes a call to a system with a different TGID or timeslot, it
rewrites the Link Control (LC) word in every forwarded DMR frame — voice header,
voice terminator, and the embedded LC carried in voice bursts B–E.
The consequence is that **DMR Talker Alias data embedded in voice bursts is
destroyed during bridging.** Talker Alias occupies the same embedded LC slots
(FLCO 0x04–0x07 in bursts B–E) that the bridge overwrites with the translated
call LC.
This was a consious design decision to ensure every superframe carries full LC
information -- preferencing voice quality and integrity over all else.
**Future:** Currently, most hams appear to prefer/additinoally use a static
database for ID to callsign mapping. Inclusion of Talker Alias may be
considered in the future.
## Requirements
- Python, at least, **3.8+** (Linux recommended)
- `bitarray`, `dmr_utils3` — see `requirements.txt`
## License
Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS — n0mjs@me.com
Licensed under the **GNU GPLv3**; see [LICENSE.txt](LICENSE.txt). You may use this software freely, but please credit the project somewhere public (club, organization, or project site) so we can see where it's in use.
### No Support Is Provided
This is not commercial software. It is provided free of charge. The author(s)
received no compensation for creating and maintaining it. Countless hours over
many years have gone into the this. If you have problems, the author will try
to help if possible, please have no expectations for support. There is no online
group, such as DVSwitch or groups.io that is an "official" outlet for information.
The only definitive source of information is me. Beware of others claiming to
be authoritative. User-based mutual support is great, and I'm all for it. But
please understand, this is what they are, and I have not sanctioned anyone to be
the "home" of my software packages.
### GitHub "Issues"
Do not use GitHub issues for support. Genuine bugs are accepted as issues. Before
opening one, make sure that it is a true problem with the software and not merely
a misconfiguration, or contention around a feature that was not supported. Isssues
should never be used to ask for or recommend features. Issues that do not include
complete details, relevent tracebacks, error messages, configuration snippets,
operatrional conditions surrounding the event, etc. will be closed without action.
## Acknowledgments
The HomeBrew Repeater Protocol is the work of Jonathan Naylor (G4KLX), Hans Barthen (DL5DI), and Torsten Schultze (DG1HT). This project is the author's clean-room interpretation of that protocol.
+596 -626
View File
File diff suppressed because it is too large. Load diff
+34 -33
View File
@@ -35,20 +35,14 @@ a lot of sense, and has the potential to do bad things.
# Python modules we need
import sys
from bitarray import bitarray
from time import time
from importlib import import_module
from types import ModuleType
# Twisted is pretty important, so I keep it separate
from twisted.internet.protocol import Factory, Protocol
from twisted.protocols.basic import NetstringReceiver
from twisted.internet import reactor, task
import asyncio
# Things we import from the main hblink module
from hblink import HBSYSTEM, OPENBRIDGE, systems, hblink_handler, reportFactory, REPORT_OPCODES, config_reports, mk_aliases, acl_check
from dmr_utils3.utils import bytes_3, int_id, get_alias
from dmr_utils3 import decode, bptc, const
import config
import log
import const
@@ -95,7 +89,7 @@ class bridgeallSYSTEM(HBSYSTEM):
},
2: {
'RX_START': time(),
'RX:LOSS': 0,
'RX_LOSS': 0,
'RX_SEQ': 0,
'RX_RFS': b'\x00',
'TX_RFS': b'\x00',
@@ -263,33 +257,40 @@ if __name__ == '__main__':
logger.info('\n\nCopyright (c) 2013, 2014, 2015, 2016, 2018, 2019\n\tThe Regents of the K0USY Group. All rights reserved.\n')
logger.debug('Logging system started, anything from here on gets logged')
# Set up the signal handler
def sig_handler(_signal, _frame):
logger.info('SHUTDOWN: BRIDGE_ALL IS TERMINATING WITH SIGNAL %s', str(_signal))
hblink_handler(_signal, _frame)
logger.info('SHUTDOWN: ALL SYSTEM HANDLERS EXECUTED - STOPPING REACTOR')
reactor.stop()
# Set signal handers so that we can gracefully exit if need be
for sig in [signal.SIGTERM, signal.SIGINT]:
signal.signal(sig, sig_handler)
# Create the name-number mapping dictionaries
peer_ids, subscriber_ids, talkgroup_ids = mk_aliases(CONFIG)
# INITIALIZE THE REPORTING LOOP
report_server = config_reports(CONFIG, reportFactory)
# The asyncio entry point: signal handling, reporting, and a UDP endpoint for
# each enabled system, then wait for shutdown.
async def async_main():
loop = asyncio.get_running_loop()
stop_event = asyncio.Event()
# HBlink instance creation
logger.info('HBlink \'bridge_all.py\' -- SYSTEM STARTING...')
for system in CONFIG['SYSTEMS']:
if CONFIG['SYSTEMS'][system]['ENABLED']:
if CONFIG['SYSTEMS'][system]['MODE'] == 'OPENBRIDGE':
logger.critical('%s FATAL: Instance is mode \'OPENBRIDGE\', \n\t\t...Which would be tragic for Bridge All, since it carries multiple call\n\t\tstreams simultaneously. bridge_all.py onlyl works with MMDVM-based systems', system)
sys.exit('bridge_all.py cannot function with systems that are not MMDVM devices. System {} is configured as an OPENBRIDGE'.format(system))
else:
systems[system] = bridgeallSYSTEM(system, CONFIG, report_server)
reactor.listenUDP(CONFIG['SYSTEMS'][system]['PORT'], systems[system], interface=CONFIG['SYSTEMS'][system]['IP'])
logger.debug('%s instance created: %s, %s', CONFIG['SYSTEMS'][system]['MODE'], system, systems[system])
def shutdown(signum):
logger.info('SHUTDOWN: BRIDGE_ALL IS TERMINATING WITH SIGNAL %s', signum)
hblink_handler(signum, None)
logger.info('SHUTDOWN: ALL SYSTEM HANDLERS EXECUTED - STOPPING')
stop_event.set()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, shutdown, sig)
reactor.run()
# INITIALIZE THE REPORTING LOOP
report_server = config_reports(CONFIG, reportFactory)
# HBlink instance creation
logger.info('HBlink \'bridge_all.py\' -- SYSTEM STARTING...')
for system in CONFIG['SYSTEMS']:
if CONFIG['SYSTEMS'][system]['ENABLED']:
if CONFIG['SYSTEMS'][system]['MODE'] == 'OPENBRIDGE':
logger.critical('%s FATAL: Instance is mode \'OPENBRIDGE\', \n\t\t...Which would be tragic for Bridge All, since it carries multiple call\n\t\tstreams simultaneously. bridge_all.py onlyl works with MMDVM-based systems', system)
sys.exit('bridge_all.py cannot function with systems that are not MMDVM devices. System {} is configured as an OPENBRIDGE'.format(system))
else:
systems[system] = bridgeallSYSTEM(system, CONFIG, report_server)
await loop.create_datagram_endpoint(
lambda s=systems[system]: s,
local_addr=(CONFIG['SYSTEMS'][system]['IP'], CONFIG['SYSTEMS'][system]['PORT']))
logger.debug('%s instance created: %s, %s', CONFIG['SYSTEMS'][system]['MODE'], system, systems[system])
await stop_event.wait()
asyncio.run(async_main())
+57 -27
View File
@@ -54,49 +54,64 @@ def process_acls(_config):
# System level ACLs
for system in _config['SYSTEMS']:
# Registration ACLs (which make no sense for peer systems)
if _config['SYSTEMS'][system]['MODE'] == 'MASTER':
# Registration ACLs (which make no sense for outbound systems)
if _config['SYSTEMS'][system]['MODE'] == 'SERVER':
_config['SYSTEMS'][system]['REG_ACL'] = acl_build(_config['SYSTEMS'][system]['REG_ACL'], const.PEER_MAX)
# Subscriber and TGID ACLs (valid for all system types)
for acl in ['SUB_ACL', 'TG1_ACL', 'TG2_ACL']:
_config['SYSTEMS'][system][acl] = acl_build(_config['SYSTEMS'][system][acl], const.ID_MAX)
# Create an access control list that is programatically useable from human readable:
# Merge a list of (start, end) ranges into a minimal sorted list of disjoint
# ranges. Required so acl_check can locate a match with a single bisect.
def merge_ranges(_ranges):
merged = []
for start, end in sorted(_ranges):
if merged and start <= merged[-1][1] + 1:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
return merged
# Create an access control list that is programatically usable from human readable.
# Single IDs go in a set (O(1) membership); ranges are merged and split into
# parallel sorted (starts, ends) tuples so acl_check can bisect them (O(log n)).
# ORIGINAL: 'DENY:1-5,3120101,3120124'
# PROCESSED: (False, set([(1, 5), (3120124, 3120124), (3120101, 3120101)]))
# PROCESSED: (False, frozenset({3120101, 3120124}), (1,), (5,))
def acl_build(_acl, _max):
if not _acl:
return(True, set((const.ID_MIN, _max)))
return (True, frozenset(), (const.ID_MIN,), (_max,))
acl = [] #set()
singles = set()
ranges = []
sections = _acl.split(':')
if sections[0] == 'PERMIT':
action = True
else:
action = False
action = (sections[0] == 'PERMIT')
for entry in sections[1].split(','):
if entry == 'ALL':
acl.append((const.ID_MIN, _max))
ranges = [(const.ID_MIN, _max)]
singles = set()
break
elif '-' in entry:
start,end = entry.split('-')
start,end = int(start), int(end)
if (const.ID_MIN <= start <= _max) or (const.ID_MIN <= end <= _max):
acl.append((start, end))
ranges.append((start, end))
else:
sys.exit('ACL CREATION ERROR, VALUE OUT OF RANGE ({} - {})IN RANGE-BASED ENTRY: {}'.format(const.ID_MIN, _max, entry))
else:
id = int(entry)
if (const.ID_MIN <= id <= _max):
acl.append((id, id))
singles.add(id)
else:
sys.exit('ACL CREATION ERROR, VALUE OUT OF RANGE ({} - {}) IN SINGLE ID ENTRY: {}'.format(const.ID_MIN, _max, entry))
return (action, acl)
merged = merge_ranges(ranges)
starts = tuple(r[0] for r in merged)
ends = tuple(r[1] for r in merged)
return (action, frozenset(singles), starts, ends)
def build_config(_config_file):
config = configparser.ConfigParser()
@@ -118,6 +133,12 @@ def build_config(_config_file):
'PATH': config.get(section, 'PATH'),
'PING_TIME': config.getint(section, 'PING_TIME'),
'MAX_MISSED': config.getint(section, 'MAX_MISSED'),
# Chronic connectivity: report a repeater's ping loss over the
# last PING_LOSS_WINDOW minutes (clamped 1..60), and flag it on
# the dashboard at/above PING_LOSS_WARN percent. Optional; sane
# defaults keep existing configs working.
'PING_LOSS_WINDOW': min(60, max(1, config.getint(section, 'PING_LOSS_WINDOW', fallback=5))),
'PING_LOSS_WARN': max(0, config.getint(section, 'PING_LOSS_WARN', fallback=5)),
'USE_ACL': config.get(section, 'USE_ACL'),
'REG_ACL': config.get(section, 'REG_ACL'),
'SUB_ACL': config.get(section, 'SUB_ACL'),
@@ -130,7 +151,11 @@ def build_config(_config_file):
'REPORT': config.getboolean(section, 'REPORT'),
'REPORT_INTERVAL': config.getint(section, 'REPORT_INTERVAL'),
'REPORT_PORT': config.getint(section, 'REPORT_PORT'),
'REPORT_CLIENTS': config.get(section, 'REPORT_CLIENTS').split(',')
'REPORT_CLIENTS': config.get(section, 'REPORT_CLIENTS').split(','),
# Reporting transport: 'tcp' (default, dashboard may be remote)
# or 'unix' (same-box dashboard via REPORT_SOCKET path). Optional.
'REPORT_TRANSPORT': config.get(section, 'REPORT_TRANSPORT', fallback='tcp').strip().lower(),
'REPORT_SOCKET': config.get(section, 'REPORT_SOCKET', fallback='').strip(),
})
elif section == 'LOGGER':
@@ -145,6 +170,7 @@ def build_config(_config_file):
elif section == 'ALIASES':
CONFIG['ALIASES'].update({
'USE_ALIASES': config.getboolean(section, 'USE_ALIASES', fallback=False),
'TRY_DOWNLOAD': config.getboolean(section, 'TRY_DOWNLOAD'),
'PATH': config.get(section, 'PATH'),
'PEER_FILE': config.get(section, 'PEER_FILE'),
@@ -156,17 +182,16 @@ def build_config(_config_file):
})
elif config.getboolean(section, 'ENABLED'):
if config.get(section, 'MODE') == 'PEER':
if config.get(section, 'MODE') == 'OUTBOUND':
CONFIG['SYSTEMS'].update({section: {
'MODE': config.get(section, 'MODE'),
'ENABLED': config.getboolean(section, 'ENABLED'),
'LOOSE': config.getboolean(section, 'LOOSE'),
'SOCK_ADDR': getaddrinfo(config.get(section, 'IP'), config.getint(section, 'PORT'), proto=17)[0][4],
'IP': getaddrinfo(config.get(section, 'IP'), 0)[0][4][0],
'PORT': config.getint(section, 'PORT'),
'MASTER_SOCKADDR': getaddrinfo(config.get(section, 'MASTER_IP'), config.getint(section, 'MASTER_PORT'), proto=17)[0][4],
'MASTER_IP': getaddrinfo(config.get(section, 'MASTER_IP'), 0)[0][4][0],
'MASTER_PORT': config.getint(section, 'MASTER_PORT'),
'SERVER_SOCKADDR': getaddrinfo(config.get(section, 'SERVER_IP'), config.getint(section, 'SERVER_PORT'), proto=17)[0][4],
'SERVER_IP': getaddrinfo(config.get(section, 'SERVER_IP'), 0)[0][4][0],
'SERVER_PORT': config.getint(section, 'SERVER_PORT'),
'PASSPHRASE': bytes(config.get(section, 'PASSPHRASE'), 'utf-8'),
'CALLSIGN': bytes(config.get(section, 'CALLSIGN').ljust(8)[:8], 'utf-8'),
'RADIO_ID': config.getint(section, 'RADIO_ID').to_bytes(4, 'big'),
@@ -201,12 +226,12 @@ def build_config(_config_file):
'LAST_PING_ACK_TIME': 0,
}})
elif config.get(section, 'MODE') == 'MASTER':
elif config.get(section, 'MODE') == 'SERVER':
CONFIG['SYSTEMS'].update({section: {
'MODE': config.get(section, 'MODE'),
'ENABLED': config.getboolean(section, 'ENABLED'),
'REPEAT': config.getboolean(section, 'REPEAT'),
'MAX_PEERS': config.getint(section, 'MAX_PEERS'),
'MAX_REPEATERS': config.getint(section, 'MAX_REPEATERS'),
'IP': getaddrinfo(config.get(section, 'IP'), 0)[0][4][0],
'PORT': config.getint(section, 'PORT'),
'PASSPHRASE': bytes(config.get(section, 'PASSPHRASE'), 'utf-8'),
@@ -217,7 +242,7 @@ def build_config(_config_file):
'TG1_ACL': config.get(section, 'TGID_TS1_ACL'),
'TG2_ACL': config.get(section, 'TGID_TS2_ACL')
}})
CONFIG['SYSTEMS'][section].update({'PEERS': {}})
CONFIG['SYSTEMS'][section].update({'REPEATERS': {}})
elif config.get(section, 'MODE') == 'OPENBRIDGE':
CONFIG['SYSTEMS'].update({section: {
@@ -231,6 +256,7 @@ def build_config(_config_file):
'TARGET_IP': getaddrinfo(config.get(section, 'TARGET_IP'), 0)[0][4][0],
'TARGET_PORT': config.getint(section, 'TARGET_PORT'),
'BOTH_SLOTS': config.getboolean(section, 'BOTH_SLOTS'),
'PRESERVE_SOURCE_PEER': config.getboolean(section, 'PRESERVE_SOURCE_PEER', fallback=False),
'USE_ACL': config.getboolean(section, 'USE_ACL'),
'SUB_ACL': config.get(section, 'SUB_ACL'),
'TG1_ACL': config.get(section, 'TGID_ACL'),
@@ -270,11 +296,15 @@ if __name__ == '__main__':
CONFIG = build_config(cli_args.CONFIG_FILE)
pprint(CONFIG)
from bisect import bisect_right
def acl_check(_id, _acl):
id = int_id(_id)
for entry in _acl[1]:
if entry[0] <= id <= entry[1]:
return _acl[0]
return not _acl[0]
action, singles, starts, ends = _acl
if id in singles:
return action
i = bisect_right(starts, id) - 1
if i >= 0 and id <= ends[i]:
return action
return not action
print(acl_check(b'\x00\x01\x37', CONFIG['GLOBAL']['TG1_ACL']))
+4 -1
View File
@@ -38,6 +38,10 @@ ID_MAX = 16776415
# Timers
STREAM_TO = .360
# Seconds of no audio after which a stream is declared ended. On RF, a gap this
# long means sync is lost and any resumption is a new stream (late entry), so the
# stream is over -- we time it out and emit an END.
STREAM_TIMEOUT = 2
# Options from the LC - used for late entry
LC_OPT = b'\x00\x00\x20'
@@ -62,7 +66,6 @@ MSTC = b'MSTC'
RPTL = b'RPTL'
RPTPING = b'RPTPING'
RPTCL = b'RPTCL'
RPTL = b'RPTL'
RPTACK = b'RPTACK'
RPTK = b'RPTK'
RPTC = b'RPTC'
+158
View File
@@ -0,0 +1,158 @@
# HBlink3 Dashboard — Field Reference
What every element on the dashboard means, section by section. The page updates
live: it receives a full snapshot when it connects, then per-call deltas over a
WebSocket, and it ticks all timers (durations, uptimes, countdowns) in the
browser once per second.
A note on **direction**, used throughout: directions are from HBlink3's point of
view. **RX** (green) is traffic coming *into* HBlink3 from a system; **TX** (red)
is traffic HBlink3 is sending *out* to a system. So the radio/peer that keyed up
shows green, and everyone hearing the repeated copy shows red.
---
## Header
| Element | Meaning |
|---|---|
| Logo / title | The HBlink logo and your `REPORT_NAME` (set in `dashboard/config.py`), with the "HBlink3 Monitor" subtitle. |
| Connection badge | The link between **this dashboard and the HBlink3 reporting feed** — *not* the health of any radio system. **connected** (green) = receiving live data; **connecting… / disconnected** (red) = the feed at `HBLINK_IP:HBLINK_PORT` is unreachable (HBlink3 down or `[REPORTS]` not enabled); **dashboard offline** = your browser lost the dashboard itself. |
---
## Last Heard
The most-recent unique callers, newest first, derived live from the call log
(no separate database). Deduplicated by source radio ID. Configure with
`LAST_HEARD` / `LAST_HEARD_COUNT`; the **Show/Hide** button collapses the table
and its state is remembered in your browser.
| Column | Meaning |
|---|---|
| User | The caller's callsign and radio ID (DMR ID). |
| Type | Call type — Group Voice, Unit Voice, Group Data, or Unit Data. |
| Source | The source repeater/peer the call entered on (callsign and ID if known). |
| TS | The timeslot the call used. |
| Talkgroup | The destination talkgroup (name and ID), or subscriber for a unit call. |
| When | **Active** while the call is in progress, otherwise how long ago it was heard. |
---
## Server Systems
One block per HBlink3 **SERVER** instance (repeaters/hotspots connect *to* it).
**Block heading**
| Element | Meaning |
|---|---|
| System name | The server's name from `hblink.cfg` (e.g. `SERVER-1`). |
| `repeat` / `isolate` pill | `repeat` if the server forwards traffic between its own repeaters (`REPEAT: True`), `isolate` if it does not. |
| *N* peer(s) | How many repeaters/hotspots are currently logged in. |
| Show/Hide Connections | Collapses this system's connection table. When collapsed, system-level TS1/TS2 activity pills replace it so live calls stay visible. Default from `SERVER_REPEATERS`; per-system state is remembered in your browser. |
**Per-peer row**
| Column | Meaning |
|---|---|
| Repeater | The peer's callsign and radio ID (DMR ID), with its location underneath. |
| Connection | The peer's source `IP:PORT`, and how long it has been logged in (`up …`). |
| Freq | The peer's RX / TX frequencies in MHz, or `N/A` if it didn't report them. |
| CC / Slots | The peer's color code and the number of timeslots it advertises. |
| TS1 / TS2 | The live call on timeslot 1 / timeslot 2 (see **Call cell** below), or `— idle —`. |
---
## Outbound Systems
One row per HBlink3 **OUTBOUND** instance (HBlink3 connecting *out* to an upstream
server, e.g. another HBlink or a BrandMeister/IPSC2 server).
| Column | Meaning |
|---|---|
| System | The peer instance name, with its mode (`PEER`) underneath. |
| Identity | The callsign and radio ID HBlink3 logs in with, plus its location and the upstream `master IP:PORT`. |
| Connection | A status dot and the login state — **YES** (green) when fully connected, otherwise the current handshake state (red). `up …` is time since connect. |
| Pings S/A/L | Keepalive pings **S**ent / **A**cknowledged / **L**ost (sent − acked). Growing "Lost" means the link to the master is unhealthy. |
| TS1 / TS2 | The live call on each timeslot (see **Call cell**), or `— idle —`. |
---
## OpenBridge Systems
One row per **OPENBRIDGE** instance. OpenBridge is a trunk between two networks
and is **not timeslot-bound** — it carries any number of concurrent streams at
once, so this section lists them side by side rather than in TS1/TS2 columns.
| Column | Meaning |
|---|---|
| System | The OpenBridge instance name. |
| Network / Target | The local `NETWORK_ID`, and the far end's `TARGET_IP:TARGET_PORT`. |
| Active Streams | A chip per concurrent call: direction (**RX** inbound / **TX** outbound), source, and destination. `— idle —` when none are active. Seeing several chips here at once is normal and expected for OpenBridge. |
---
## Call cell
The colored box shown in the TS1/TS2 columns (and, as chips, under OpenBridge).
| Part | Meaning |
|---|---|
| Top line | Call type (**Group** or **Unit**) · source · destination, using callsigns / talkgroup names where known. For a group call the destination is a talkgroup; for a unit (private) call it's another subscriber. |
| Bottom line | The source's raw radio ID · elapsed call time (ticking live). |
| Color | **Green** = RX (this system/peer is the one transmitting in). **Red** = TX (the call is going out to this system — i.e. it's hearing repeated traffic). |
| `— idle —` | No active call on that slot. |
A call that ends normally clears immediately. If a terminator is lost, the
dashboard drops the stale call after ~30 s so it can't linger forever (HBlink3
itself also times streams out at ~2 s and emits the end — this is just a backstop
for a non-local dashboard or a feed interruption).
---
## Conference Bridges
One table per conference bridge defined in `rules.py`, with a row per member
system. (Empty if you're running `bridge_all` or standalone `hblink.py`, which
have no conference-bridge rules.)
| Column | Meaning |
|---|---|
| System | The member system's name. |
| TS | The timeslot this rule matches on. |
| TGID | The talkgroup this rule matches, with its name if known. |
| Status | **Connected** (green) = this member is currently bridged (`ACTIVE`); **Disconnected** (red) = traffic is not flowing for this member. |
| Timeout | For timed rules (`TO_TYPE` ON/OFF), the live countdown until the state flips; `N/A` for rules without a timer. |
| Action | What the timeout will do: **Disconnect** (an `ON` timer), **Connect** (an `OFF` timer), or **None**. |
| Connect TGIDs | Talkgroup IDs that, when keyed, turn this member **on** (the rule's `ON` list). |
| Disconnect TGIDs | Talkgroup IDs that turn this member **off** (the rule's `OFF` list). |
---
## Call Log
A reverse-chronological feed of call activity. Only **ingress (RX)** legs are
logged — one line when a call starts and one when it ends — matching the classic
HBmonitor behavior, so each conversation appears once regardless of how many
systems it was bridged to.
| Field | Meaning |
|---|---|
| Timestamp | Local time the event was received. |
| Event | Call type plus **START** (blue) or **END** (green). |
| System | The system the call came in on. |
| Source → Dest | Source subscriber (callsign/ID), the timeslot (`TS1`/`TS2`), and the destination talkgroup or subscriber (name/ID). |
| Duration | On an **END** line, how long the call lasted. |
---
## How the data arrives (and update latency)
- **Calls** are event-driven and appear **instantly** as START/END events.
- **System and peer state** (a peer connecting or dropping, ping counts, bridge
on/off state) comes from a periodic snapshot every `REPORT_INTERVAL` seconds
(`[REPORTS]` in `hblink.cfg`; ~10 s is a good value for dashboard use). So a
newly connected peer can take up to one interval to appear.
- All elapsed/uptime/countdown timers advance in the browser; the server sends
time *deltas*, so the dashboard needs no clock synchronization with the server.
+89
View File
@@ -0,0 +1,89 @@
# HBlink3 Dashboard
A real-time web dashboard for HBlink3, in the spirit of the old HBmonitor (same
tables and information) but with a modern dark UI and a far more efficient data
path. It is a **separate program** from the HBlink3 core.
It connects to HBlink3's reporting feed, keeps the display state, and pushes
live **JSON deltas** to the browser over a WebSocket. The browser renders the
tables and ticks the duration/uptime counters itself — no full-page or
full-table refreshes, and no pickle on the wire.
## What it shows
- **Last Heard** — the most-recent unique callers (user, call type, source
repeater, timeslot, talkgroup, and when), derived live from the call log. The
table is collapsible; its open/closed state is remembered in your browser.
- **Server Systems** — each server and its connected repeaters (ID, callsign,
location, IP, uptime, frequency, colorcode, slots) with the live call on each
timeslot (source → destination, color-coded by direction). Each server's
connection list can be collapsed with the **Show/Hide Connections** button;
when collapsed, system-level TS1/TS2 activity pills replace the table so you
still see what's talking. State is remembered per-browser.
- **Outbound Systems** — outbound connections with health (connection state,
pings sent/ackd/lost) and live per-timeslot calls.
- **OpenBridge Systems** — network ID, target, and active streams.
- **Conference Bridges** — each bridge's members: timeslot, talkgroup,
connected/disconnected state, timeout countdown, and on/off trigger talkgroups.
- **Call Log** — a running log of call starts and ends.
For a field-by-field explanation of everything on the screen, see
[FIELDS.md](FIELDS.md).
## Install
```bash
cd dashboard
python3 -m venv venv && source venv/bin/activate # or reuse the HBlink3 venv
pip install -r requirements.txt
cp config_sample.py config.py # then edit config.py
python server.py # or, from the repo root: python run_dashboard.py
```
Open `http://<host>:8080`.
## Configuration (`config.py`)
- `HBLINK_IP` / `HBLINK_PORT` — HBlink3's reporting feed (the `[REPORTS]` section
of `hblink.cfg`: `REPORT_PORT`, default 4321). Used when `HBLINK_TRANSPORT` is `tcp`.
- `HBLINK_TRANSPORT` / `HBLINK_SOCKET` — optional. Set `HBLINK_TRANSPORT = 'unix'`
and `HBLINK_SOCKET` to the daemon's `REPORT_SOCKET` path to connect over a local
Unix socket instead of TCP (same-box dashboard; immune to NIC flaps / conntrack
eviction). Defaults to `tcp`. Must match the daemon's `REPORT_TRANSPORT`.
- `WEB_HOST` / `WEB_PORT` — where the dashboard listens.
- `PATH` / `*_FILE` — alias files mapping DMR IDs to callsigns/talkgroup names
(the same files HBlink3 uses; point `PATH` at HBlink3's directory to share them).
- `REPORT_NAME`, `LOG_LINES` — branding and call-log length.
- `LAST_HEARD` — default state of the Last Heard table: `'open'`, `'closed'`, or
`'off'` (omit it entirely). Users can still toggle it in their own browser.
- `LAST_HEARD_COUNT` — number of recent callers to list (default 10).
- `SERVER_REPEATERS` — default state of each server system's connection list:
`'open'` or `'closed'`. Use `'closed'` for a compact view on many-repeater
servers; per-system browser choices override it.
## HBlink3 side
In `hblink.cfg`, under `[REPORTS]`:
- `REPORT: True`
- `REPORT_PORT` must match the dashboard's `HBLINK_PORT`
- `REPORT_CLIENTS` must include the dashboard host's IP (or `*`)
- `REPORT_INTERVAL` sets how often the full systems/bridge snapshot is resent as a
slow resync. **Live calls and repeater connect/disconnect are pushed as they
happen** (granular events); the periodic snapshot is just a safety-net resync and
also carries the per-repeater ping-quality figures, so a larger value like `60`
is fine.
## Notes
- This replaces the old **HBmonitor**. HBlink3's reporting feed is now
newline-delimited JSON, which HBmonitor does not understand.
- The dashboard depends on FastAPI + Uvicorn; the HBlink3 core itself remains
stdlib-only.
- **Demo mode** — append `?demo` to the dashboard URL (e.g.
`http://localhost:8080/?demo`) to load a static pre-populated scenario showing
all visual elements: a SERVER with three repeaters, an OUTBOUND peer, an
OpenBridge connection, active calls bridged coherently across systems, a slot
in group-hangtime, and a populated call log. No HBlink3 connection is required.
This is useful for understanding what the dashboard looks like under normal
operating conditions before any traffic has been seen.
+61
View File
@@ -0,0 +1,61 @@
###############################################################################
# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS <n0mjs@me.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
###############################################################################
# Copy this file to config.py and edit for your install.
# Display / branding
REPORT_NAME = 'My HBlink3 System' # Shown in the dashboard header
LOGO_FILE = '' # Path to a logo image (png/jpg/svg/gif); '' = no logo
# Connection to HBlink3's reporting feed (the [REPORTS] section of hblink.cfg)
HBLINK_IP = '127.0.0.1' # HBlink3 reporting host
HBLINK_PORT = 4321 # HBlink3 reporting TCP port (REPORT_PORT)
# Web server
WEB_HOST = '0.0.0.0' # Interface to bind the dashboard web server
WEB_PORT = 8080 # Port (must be > 1024 if not running as root)
# Call log
LOG_LINES = 300 # Number of recent call-log entries to retain
# Last Heard table — most-recent unique callers, shown at the top of the dashboard.
LAST_HEARD = 'open' # 'open' | 'closed' | 'off' (default state; 'off' hides it entirely)
LAST_HEARD_COUNT = 10 # Number of recent callers to list
# Server systems — default state for each server system's collapsible repeater list.
# Users can toggle each system open/closed in their own browser (remembered locally).
# When collapsed, system-level TS1/TS2 activity pills replace the repeater table.
SERVER_REPEATERS = 'open' # 'open' | 'closed' ('closed' gives a compact view for many-repeater servers)
# Alias files — the dashboard owns downloading, storing, and refreshing these.
# Files live in the dashboard's own subdirectory (PATH = './') by default.
# Set TRY_DOWNLOAD = True to fetch fresh files from radioid.net on startup and
# refresh them daily. Set False to manage files manually or skip alias lookups.
#
# NOTE: HBlink3/bridge.py can also load these files for callsign display in
# logs, but doing so costs RAM — roughly 150 MB with the country filter below,
# ~500 MB unfiltered, and that again in the dashboard process if it loads them
# too — so it is STRONGLY DISCOURAGED. If you want log callsigns, point
# bridge.py's [ALIASES] PATH at this directory and keep TRY_DOWNLOAD: False
# there — the dashboard handles the downloads.
TRY_DOWNLOAD = True
PATH = './' # MUST END IN '/' — dashboard's own directory
PEER_FILE = 'peer_ids.json'
SUBSCRIBER_FILE = 'subscriber_ids.json'
TGID_FILE = 'talkgroup_ids.json' # optional {id: name}; ok if missing
LOCAL_SUB_FILE = '' # optional local subscriber override, '' to disable
LOCAL_PEER_FILE = '' # optional local peer override, '' to disable
PEER_URL = 'https://www.radioid.net/static/rptrs.json'
SUBSCRIBER_URL = 'https://www.radioid.net/static/users.json'
STALE_DAYS = 7 # re-download after this many days
# Country filter for subscriber downloads. Only records matching these country
# names (as they appear in the radioid.net data) are written to disk, which
# keeps memory use and load time low. Set to [] or None to load all countries.
FILTER_COUNTRIES = ['United States', 'France']
+5
View File
@@ -0,0 +1,5 @@
# HBlink3 dashboard dependencies (a separate program from the core).
# The dashboard uses FastAPI + Uvicorn; the HBlink3 core itself stays stdlib-only.
fastapi>=0.110
uvicorn[standard]>=0.27
dmr_utils3>=0.1.30 # DMR ID -> callsign / talkgroup-name resolution
+448
View File
@@ -0,0 +1,448 @@
#!/usr/bin/env python
#
###############################################################################
# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS <n0mjs@me.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
###############################################################################
'''
HBlink3 dashboard backend.
Connects to HBlink3's NDJSON reporting feed (one JSON object per line), keeps the
authoritative display state, and serves a single-page UI that receives live JSON
deltas over a WebSocket. Run: python server.py (or via run_dashboard.py).
'''
import asyncio
import json
import logging
import os
import socket
import ssl
import sys
import time
from collections import deque
from contextlib import asynccontextmanager
from urllib.request import urlopen
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
import ijson
import uvicorn
from dmr_utils3.utils import mk_id_dict, get_alias
# Ensure THIS directory's config.py wins over HBlink3's top-level config.py.
HERE = os.path.dirname(os.path.abspath(__file__))
STATIC = os.path.join(HERE, 'static')
sys.path.insert(0, HERE)
# ---- configuration -----------------------------------------------------------
try:
from config import (REPORT_NAME, HBLINK_IP, HBLINK_PORT, WEB_HOST, WEB_PORT,
LOG_LINES, PATH, PEER_FILE, SUBSCRIBER_FILE, TGID_FILE,
LOCAL_SUB_FILE, LOCAL_PEER_FILE)
except ImportError:
sys.exit('No config.py found -- copy config_sample.py to config.py and edit it.')
try:
from config import TRY_DOWNLOAD, PEER_URL, SUBSCRIBER_URL, STALE_DAYS
except ImportError:
TRY_DOWNLOAD = False
PEER_URL = 'https://www.radioid.net/static/rptrs.json'
SUBSCRIBER_URL = 'https://www.radioid.net/static/users.json'
STALE_DAYS = 7
try:
from config import FILTER_COUNTRIES
except ImportError:
FILTER_COUNTRIES = None
try:
from config import LAST_HEARD, LAST_HEARD_COUNT
except ImportError:
LAST_HEARD = 'open'
LAST_HEARD_COUNT = 10
try:
from config import SERVER_REPEATERS
except ImportError:
SERVER_REPEATERS = 'open'
# Feed transport: 'tcp' (default, connect to HBLINK_IP:HBLINK_PORT) or 'unix'
# (connect to the daemon's local Unix socket HBLINK_SOCKET). Optional in config.
try:
from config import HBLINK_TRANSPORT
except ImportError:
HBLINK_TRANSPORT = 'tcp'
try:
from config import HBLINK_SOCKET
except ImportError:
HBLINK_SOCKET = ''
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger('hbdash')
# Drop an "active" stream this many seconds after its START if no END arrives.
# This is only a backstop for a lost/never-sent terminator while the feed stays
# connected -- a dropped feed already clears all active streams on disconnect,
# and HBlink3 itself synthesizes an END a couple seconds after audio stops
# (bridge.py STREAM_TIMEOUT). Keep it well above any real transmission (amateur
# TOT is ~3 min) so legitimate long calls are never clipped mid-stream.
STREAM_STALE = 300
# ---- alias resolution --------------------------------------------------------
def _abs(p):
return p if os.path.isabs(p) else os.path.join(HERE, p)
def _stream_id_file(url, path, json_key, countries, stale_secs):
now = time.time()
if os.path.isfile(path) and (os.path.getmtime(path) + stale_secs) >= now:
logger.info('ID ALIAS MAPPER: %s is current, not downloaded', os.path.basename(path))
return
no_verify = ssl._create_unverified_context()
tmp = path + '.tmp'
try:
with urlopen(url, context=no_verify) as response, \
open(tmp, 'w', encoding='utf-8') as out:
out.write('{"%s":[' % json_key)
first = True
for record in ijson.items(response, json_key + '.item'):
if not countries or record.get('country') in countries:
if not first:
out.write(',')
json.dump({'id': record['id'], 'callsign': record['callsign']}, out)
first = False
out.write(']}')
os.replace(tmp, path)
label = ', '.join(sorted(countries)) if countries else 'all countries'
logger.info('ID ALIAS MAPPER: %s downloaded (%s)', os.path.basename(path), label)
except IOError as e:
logger.error('ID ALIAS MAPPER: download of %s failed: %s', os.path.basename(path), e)
if os.path.exists(tmp):
os.remove(tmp)
def _download_aliases():
if not TRY_DOWNLOAD:
return
base = _abs(PATH)
stale_secs = int(STALE_DAYS) * 86400
countries = set(FILTER_COUNTRIES) if FILTER_COUNTRIES else None
_stream_id_file(PEER_URL, base + PEER_FILE, 'rptrs', countries, stale_secs)
_stream_id_file(SUBSCRIBER_URL, base + SUBSCRIBER_FILE, 'users', countries, stale_secs)
def _reload_aliases():
global PEER_IDS, SUBSCRIBER_IDS, TALKGROUP_IDS
base = _abs(PATH)
PEER_IDS = mk_id_dict(base, PEER_FILE)
SUBSCRIBER_IDS = mk_id_dict(base, SUBSCRIBER_FILE)
TALKGROUP_IDS = mk_id_dict(base, TGID_FILE)
if LOCAL_PEER_FILE:
PEER_IDS.update(mk_id_dict(base, LOCAL_PEER_FILE))
if LOCAL_SUB_FILE:
SUBSCRIBER_IDS.update(mk_id_dict(base, LOCAL_SUB_FILE))
logger.info('aliases loaded: %d peers, %d subscribers, %d talkgroups',
len(PEER_IDS), len(SUBSCRIBER_IDS), len(TALKGROUP_IDS))
PEER_IDS = {}
SUBSCRIBER_IDS = {}
TALKGROUP_IDS = {}
async def _alias_refresh_loop():
while True:
await asyncio.sleep(86400)
logger.info('aliases: starting daily refresh')
await asyncio.to_thread(_download_aliases)
_reload_aliases()
# Logo
try:
from config import LOGO_FILE
except ImportError:
LOGO_FILE = ''
_logo_path = _abs(LOGO_FILE) if LOGO_FILE else ''
_logo_exists = bool(_logo_path and os.path.isfile(_logo_path))
_logo_media_type = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
}.get(os.path.splitext(_logo_path)[1].lower(), 'image/png') if _logo_exists else 'image/png'
LOGO_HTML = '<img src="/logo" alt="" class="logo">' if _logo_exists else ''
def alias(_id, _dict):
a = get_alias(_id, _dict)
return None if a == _id else a
# ---- shared state ------------------------------------------------------------
class State:
def __init__(self):
self.systems = {} # last 'config' event payload
self.bridges = {} # last 'bridges' event payload (enriched)
self.streams = {} # key -> last START stream event (active calls)
self.log = deque(maxlen=LOG_LINES)
self.hblink = False
self.clients = set() # connected dashboard WebSockets
self.ping_time = 5 # PING_TIME from hblink global config
self.max_missed = 3 # MAX_MISSED from hblink global config
self.ping_loss_warn = 5 # PING_LOSS_WARN %: gold callsign at/above this
STATE = State()
def stream_key(evt):
return '{}|{}|{}'.format(evt['system'], evt['slot'], evt['stream_id'])
def enrich_stream(evt):
evt['src_alias'] = alias(evt['src'], SUBSCRIBER_IDS)
evt['peer_alias'] = alias(evt['peer'], PEER_IDS)
# A unit call's destination is a subscriber; a group call's is a talkgroup.
if evt.get('call_type') == 'UNIT VOICE':
evt['dst_alias'] = alias(evt['dst'], SUBSCRIBER_IDS)
else:
evt['dst_alias'] = alias(evt['dst'], TALKGROUP_IDS)
return evt
# Convert server-side absolute epochs to "seconds since/until" deltas so the
# browser can tick live counters without depending on the client's clock.
def enrich_config(systems):
now = time.time()
for sysview in systems.values():
if sysview['MODE'] == 'SERVER':
for p in sysview.get('REPEATERS', {}).values():
p['connected_secs'] = int(max(0, now - p.get('CONNECTED', now)))
last_ping = p.get('LAST_PING', 0)
p['last_ping_secs'] = int(max(0, now - last_ping)) if last_ping else None
elif sysview['MODE'] == 'OUTBOUND':
c = sysview.get('STATS', {}).get('CONNECTED')
sysview['STATS']['connected_secs'] = int(max(0, now - c)) if c else None
return systems
def enrich_bridges(bridges):
now = time.time()
for members in bridges.values():
for m in members:
m['TGID_NAME'] = alias(m['TGID'], TALKGROUP_IDS)
if m['TO_TYPE'] in ('ON', 'OFF'):
m['remaining'] = int(m['TIMER'] - now)
else:
m['remaining'] = None
return bridges
async def broadcast(obj):
dead = set()
for ws in STATE.clients:
try:
await ws.send_json(obj)
except Exception:
dead.add(ws)
STATE.clients -= dead
async def handle_event(evt):
global FEED_READ_TIMEOUT
t = evt.get('type')
if t == 'config':
interval = evt.get('report_interval')
if interval:
FEED_READ_TIMEOUT = max(float(interval) * 3, 30.0)
STATE.ping_time = evt.get('ping_time', STATE.ping_time)
STATE.max_missed = evt.get('max_missed', STATE.max_missed)
STATE.ping_loss_warn = evt.get('ping_loss_warn', STATE.ping_loss_warn)
STATE.systems = enrich_config(evt['systems'])
await broadcast({'type': 'config', 'systems': STATE.systems,
'ping_time': STATE.ping_time, 'max_missed': STATE.max_missed,
'ping_loss_warn': STATE.ping_loss_warn})
elif t in ('repeater_connected', 'repeater_disconnected'):
# Granular repeater connect/disconnect delta. Apply it to the in-memory
# systems view and re-broadcast the (enriched) config so the browser
# renders it without waiting for the next full push. Ignored if the
# system isn't known yet -- the on-connect snapshot will carry it.
sysview = STATE.systems.get(evt.get('system'))
if sysview is not None and sysview.get('MODE') == 'SERVER':
reps = sysview.setdefault('REPEATERS', {})
rid = str(evt.get('radio_id'))
if t == 'repeater_connected' and evt.get('info') is not None:
reps[rid] = evt['info']
elif t == 'repeater_disconnected':
reps.pop(rid, None)
STATE.systems = enrich_config(STATE.systems)
await broadcast({'type': 'config', 'systems': STATE.systems,
'ping_time': STATE.ping_time, 'max_missed': STATE.max_missed,
'ping_loss_warn': STATE.ping_loss_warn})
elif t == 'bridges':
STATE.bridges = enrich_bridges(evt['bridges'])
await broadcast({'type': 'bridges', 'bridges': STATE.bridges})
elif t in ('stream_start', 'stream_end'):
enrich_stream(evt)
evt['_ts'] = time.time() # authoritative server time so "Last Heard" age is correct across reloads
key = stream_key(evt)
if evt['action'] == 'START':
evt['_seen'] = time.time()
STATE.streams[key] = evt
else:
STATE.streams.pop(key, None)
# Log ingress (RX) legs only, matching the original monitor's behavior.
if evt['trx'] == 'RX':
STATE.log.appendleft(evt)
await broadcast(evt)
elif t == 'ping':
pass # liveness heartbeat; receiving it already reset the feed read timeout
else:
logger.debug('ignoring unknown event type: %s', t)
# hblink3 pushes a config snapshot every REPORT_INTERVAL seconds -- that push is
# the de-facto heartbeat. If we go long enough with no line at all, the link is
# dead (whether or not a clean FIN arrived); without this, a silently-severed
# connection leaves readline() blocked forever and the reconnect loop never runs.
# The timeout must be safely LARGER than the push interval or it false-trips on a
# healthy idle link -- so we size it to 3x the interval the daemon advertises (see
# handle_event), not a fixed value. TCP keepalive (above) stays the primary
# detector of a truly dead socket. The default applies only until the first
# config arrives (which the daemon sends immediately on connect).
FEED_READ_TIMEOUT = 180.0
def _enable_tcp_keepalive(writer):
sock = writer.get_extra_info('socket')
if sock is None:
return
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
if hasattr(socket, 'TCP_KEEPIDLE'):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 15)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4)
except OSError as e:
logger.warning('could not set TCP keepalive on HBlink3 feed: %s', e)
# ---- HBlink3 feed client (with reconnect) ------------------------------------
async def hblink_feed():
while True:
writer = None
try:
if HBLINK_TRANSPORT == 'unix':
reader, writer = await asyncio.open_unix_connection(HBLINK_SOCKET)
logger.info('connected to HBlink3 feed at unix socket %s', HBLINK_SOCKET)
else:
reader, writer = await asyncio.open_connection(HBLINK_IP, HBLINK_PORT)
_enable_tcp_keepalive(writer)
logger.info('connected to HBlink3 feed at %s:%s', HBLINK_IP, HBLINK_PORT)
STATE.hblink = True
await broadcast({'type': 'hblink', 'connected': True})
while True:
try:
line = await asyncio.wait_for(reader.readline(), FEED_READ_TIMEOUT)
except asyncio.TimeoutError:
logger.warning('no data from HBlink3 for %ss; assuming link dead, reconnecting',
FEED_READ_TIMEOUT)
break
if not line:
break # connection closed
try:
await handle_event(json.loads(line))
except json.JSONDecodeError:
logger.warning('bad JSON line from HBlink3: %r', line[:120])
except (ConnectionRefusedError, OSError) as e:
logger.warning('HBlink3 feed unavailable (%s); retrying', e)
finally:
if writer is not None:
try:
writer.close()
except Exception:
pass
if STATE.hblink:
STATE.hblink = False
STATE.systems = {}
STATE.bridges = {}
STATE.streams = {}
await broadcast({'type': 'hblink', 'connected': False})
await asyncio.sleep(3) # reconnect delay
async def reap_streams():
while True:
await asyncio.sleep(5)
now = time.time()
stale = [k for k, e in STATE.streams.items() if now - e.get('_seen', now) > STREAM_STALE]
for k in stale:
STATE.streams.pop(k, None)
if stale:
logger.debug('reaped %d stale stream(s)', len(stale))
@asynccontextmanager
async def lifespan(app):
await asyncio.to_thread(_download_aliases)
_reload_aliases()
refresher = asyncio.create_task(_alias_refresh_loop())
feed = asyncio.create_task(hblink_feed())
reaper = asyncio.create_task(reap_streams())
yield
feed.cancel()
reaper.cancel()
refresher.cancel()
app = FastAPI(lifespan=lifespan)
# ---- HTTP + WebSocket --------------------------------------------------------
@app.get('/', response_class=HTMLResponse)
async def index():
with open(os.path.join(STATIC, 'dashboard.html'), encoding='utf-8') as f:
html = f.read()
return (html.replace('{{REPORT_NAME}}', REPORT_NAME)
.replace('{{LOGO_HTML}}', LOGO_HTML)
.replace('{{LAST_HEARD}}', str(LAST_HEARD))
.replace('{{LAST_HEARD_COUNT}}', str(LAST_HEARD_COUNT))
.replace('{{SERVER_REPEATERS}}', str(SERVER_REPEATERS)))
@app.get('/logo')
async def serve_logo():
if not _logo_exists:
raise HTTPException(status_code=404)
return FileResponse(_logo_path, media_type=_logo_media_type)
@app.get('/api/state')
async def api_state():
return JSONResponse(current_state())
def current_state():
return {
'report_name': REPORT_NAME,
'hblink': STATE.hblink,
'systems': STATE.systems,
'bridges': STATE.bridges,
'streams': list(STATE.streams.values()),
'log': list(STATE.log),
'ping_time': STATE.ping_time,
'max_missed': STATE.max_missed,
'ping_loss_warn': STATE.ping_loss_warn,
}
@app.websocket('/ws')
async def ws_endpoint(ws: WebSocket):
await ws.accept()
STATE.clients.add(ws)
try:
await ws.send_json({'type': 'initial', **current_state()})
while True:
await ws.receive_text() # ignore; keep the socket open
except WebSocketDisconnect:
pass
finally:
STATE.clients.discard(ws)
def main():
uvicorn.run(app, host=WEB_HOST, port=WEB_PORT, log_level='warning')
if __name__ == '__main__':
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+486
View File
@@ -0,0 +1,486 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{REPORT_NAME}} — HBlink3</title>
<style>
:root{
--bg:#0f172a; --panel:#1e293b; --panel2:#172033; --border:#334155;
--text:#e2e8f0; --muted:#94a3b8; --accent:#38bdf8;
--green:#16a34a; --green-bg:#16351f; --red:#dc2626; --red-bg:#3a1620;
--orange:#ea580c; --orange-bg:#3a2412; --warn:#fbbf24;
}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--text);padding:14px;font-size:13px}
.wrap{max-width:1600px;margin:0 auto}
header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:10px}
.brand{display:flex;align-items:center;gap:14px}
.brand .logo{height:46px;width:auto;display:block}
h1{color:var(--accent);font-size:24px;font-weight:600}
h1 small{color:var(--muted);font-size:14px;font-weight:400;margin-left:8px}
.badge{padding:6px 14px;border-radius:6px;font-weight:600;font-size:13px;border:1px solid var(--border)}
.badge.up{background:var(--green-bg);color:#4ade80;border-color:var(--green)}
.badge.down{background:var(--red-bg);color:#f87171;border-color:var(--red)}
section{margin-bottom:22px}
h2{font-size:15px;color:var(--accent);margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px}
h3{font-size:13px;color:var(--muted);margin:12px 0 6px}
table{width:100%;border-collapse:collapse;background:var(--panel);border-radius:8px;overflow:hidden;margin-bottom:12px}
th{background:var(--panel2);color:var(--muted);text-align:left;padding:8px 10px;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--border)}
td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:middle}
tr:last-child td{border-bottom:none}
.sub{color:var(--muted);font-size:11px}
.mono{font-variant-numeric:tabular-nums}
.sysname{font-weight:600;color:var(--accent)}
.bridge-head td{background:var(--panel2);color:#fff;font-weight:600;font-size:12px;letter-spacing:.02em;padding-top:9px;padding-bottom:9px}
.pill{display:inline-block;padding:2px 7px;border-radius:10px;font-size:10px;border:1px solid var(--border);color:var(--muted)}
.call{border-radius:5px;padding:5px 8px;min-height:30px;width:100%;box-sizing:border-box;overflow:hidden}
.call.idle{color:#475569;background:var(--panel2);border:1px solid transparent}
.call.rx{background:var(--green-bg);color:#86efac;border:1px solid var(--green)} /* receive */
.call.tx{background:var(--red-bg);color:#fca5a5;border:1px solid var(--red)} /* transmit */
.call.hang{background:#0d2137;color:#7dd3fc;border:1px solid #075985}
.call.rx .sub,.call.tx .sub,.call.hang .sub{color:inherit;opacity:.85}
.call .call-top{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.call b{color:var(--text)}
.status-ok{color:#4ade80;font-weight:600}
.status-bad{color:#f87171;font-weight:600}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:middle}
.dot.ok{background:#22c55e}.dot.bad{background:#ef4444}
.empty{color:var(--muted);padding:10px;background:var(--panel);border-radius:8px;text-align:center}
#log{background:#0b1220;border:1px solid var(--border);border-radius:8px;height:230px;overflow-y:auto;padding:8px 10px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:1.5}
#log .start{color:#7dd3fc}#log .end{color:#86efac}#log .ts{color:#64748b}
.slotcol{width:255px}
.sys-row{display:flex;justify-content:space-between;align-items:center;margin:12px 0 4px}
.sys-row h3{margin:0}
.ts-hdrs{display:flex;flex-shrink:0}
.ts-hdr{width:255px;padding-left:10px;font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;box-sizing:border-box}
.sys-block .sys-pills{display:none} /* system pills hidden while expanded */
.sys-block.collapsed .sys-pills{display:flex;justify-content:flex-end;margin:0 0 12px}
.sys-block.collapsed table{display:none} /* hide repeater table when collapsed */
.sys-pills .slotcol{padding-left:10px;box-sizing:border-box}
.lh-head{display:flex;align-items:center;gap:10px}
.toggle-btn{cursor:pointer;user-select:none;display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--accent);background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:3px 10px;text-transform:none;letter-spacing:0}
.toggle-btn:hover{border-color:var(--accent)}
.toggle-btn .caret{font-size:11px;line-height:1}
.sec-body.hidden{display:none}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
{{LOGO_HTML}}
<h1>{{REPORT_NAME}}<small>HBlink3 Monitor</small></h1>
</div>
<div id="conn" class="badge down">HBlink: connecting…</div>
</header>
<section id="lastHeardSection"><h2 class="lh-head"><span id="lastHeardTitle">Last Heard</span> <span id="lastHeardToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Active only</span></span></h2><div id="lastHeardBody"><div id="lastHeard"></div></div></section>
<section id="serversSection"><h2 class="lh-head">Server Systems <span id="serversToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="serversBody" class="sec-body"><div id="servers"></div></div></section>
<section id="outboundsSection"><h2 class="lh-head">Outbound Systems <span id="outboundsToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="outboundsBody" class="sec-body"><div id="outbounds"></div></div></section>
<section id="openbridgesSection"><h2 class="lh-head">OpenBridge Systems <span id="openbridgesToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="openbridgesBody" class="sec-body"><div id="openbridges"></div></div></section>
<section id="bridgesSection"><h2 class="lh-head">Conference Bridges <span id="bridgesToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="bridgesBody" class="sec-body"><div id="bridges"></div></div></section>
<section id="logSection"><h2 class="lh-head">Call Log <span id="logToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="logBody" class="sec-body"><div id="log"></div></div></section>
</div>
<script>
const ST = { systems:{}, bridges:{}, active:{}, hang:{}, log:[], hblink:false, ping_time:5, max_missed:3, ping_loss_warn:5 };
const STALE = 300000; // backstop: drop an active call this many ms after START if no END arrives (well above amateur TOT)
const LAST_HEARD_CFG = '{{LAST_HEARD}}'; // 'open' | 'closed' | 'off'
const LAST_HEARD_COUNT = parseInt('{{LAST_HEARD_COUNT}}', 10) || 10;
const SERVER_REPEATERS_CFG = '{{SERVER_REPEATERS}}'; // 'open' | 'closed' — default state for server-system repeater lists
// ---------- helpers ----------
const el = id => document.getElementById(id);
const esc = s => (s==null?'':String(s)).replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
function dur(secs){
secs=Math.max(0,Math.floor(secs));
const d=Math.floor(secs/86400), h=Math.floor(secs%86400/3600), m=Math.floor(secs%3600/60), s=secs%60;
if(d) return d+'d '+h+'h'; if(h) return h+'h '+m+'m'; if(m) return m+'m '+s+'s'; return s+'s';
}
const skey = e => e.system+'|'+e.slot+'|'+e.stream_id;
function fmtSlots(s){ return s==='3'||s===3?'Both':s==='1'||s===1?'TS1':s==='2'||s===2?'TS2':esc(String(s||'')); }
const idName = (name,id)=> name ? esc(name)+' <span class="sub mono">('+id+')</span>' : '<span class="mono">'+id+'</span>';
const callShort = ct => ct==='GROUP VOICE'?'Group Voice':ct==='UNIT VOICE'?'Unit Voice':ct==='GROUP DATA'?'Group Data':ct==='UNIT DATA'?'Unit Data':esc(ct);
// active call(s) for a system, by slot (most recent wins)
function activeBySlot(sys){ const r={1:null,2:null}; for(const k in ST.active){const e=ST.active[k]; if(e.system===sys) r[e.slot]=e;} return r; }
function activeList(sys){ const a=[]; for(const k in ST.active){const e=ST.active[k]; if(e.system===sys) a.push(e);} return a; }
function callCell(e, klass, hang){
if(!e && hang){
const tg = hang.dst_alias ? esc(hang.dst_alias) : esc(String(hang.dst||''));
const left = Math.max(0, hang._expires - Date.now()/1000);
return '<div class="call hang">'
+ '<div class="call-top">'+esc(hang.trx)+' hang → '+tg+'</div>'
+ '<div class="sub mono"><span class="js-countdown" data-rem="'+left.toFixed(1)+'" data-arr="'+Date.now()+'">'+dur(left)+'</span> remaining</div>'
+ '</div>';
}
if(!e) return '<div class="call idle"><div class="call-top">— idle —</div><div class="sub mono">&nbsp;</div></div>';
const src = e.src_alias ? esc(e.src_alias) : e.src;
const dst = e.dst_alias ? esc(e.dst_alias) : e.dst;
const d='<span class="js-dur" data-start="'+e._arrived+'">'+dur((Date.now()-e._arrived)/1000)+'</span>';
return '<div class="call '+klass+'">'
+ '<div class="call-top">'+src+' → '+dst+'</div>'
+ '<div class="sub mono">'+esc(e.trx)+' · '+callShort(e.call_type)+' · '+e.src+' · '+d+'</div>'
+ '</div>';
}
// ---------- renderers ----------
// ---- per-server-system repeater-list collapse (persisted per browser; SERVER_REPEATERS sets the default) ----
function srvCollapsedStore(){ try{ return JSON.parse(localStorage.getItem('hbl_srv_collapsed')||'{}'); }catch(e){ return {}; } }
function srvIsCollapsed(n){
const st = srvCollapsedStore();
return Object.prototype.hasOwnProperty.call(st, n) ? !!st[n] : (SERVER_REPEATERS_CFG==='closed');
}
function srvSetCollapsed(n, val){
const st = srvCollapsedStore(); st[n] = val;
localStorage.setItem('hbl_srv_collapsed', JSON.stringify(st));
}
function initServersCollapse(){
// Delegated so it survives renderServers() rebuilding #servers on every event.
el('servers').addEventListener('click', ev=>{
const btn = ev.target.closest('.srv-toggle'); if(!btn) return;
const block = btn.closest('.sys-block'); if(!block) return;
const n = block.dataset.sys;
srvSetCollapsed(n, !srvIsCollapsed(n));
renderServers();
});
}
function renderServers(){
const names = Object.keys(ST.systems).filter(n=>ST.systems[n].MODE==='SERVER');
if(!names.length){ el('servers').innerHTML='<div class="empty">No server systems</div>'; return; }
let h='';
for(const n of names){
const m=ST.systems[n], repeaters=m.REPEATERS||{}, act=activeBySlot(n);
const pids=Object.keys(repeaters);
// System-level slot pill, shown only when the repeater table is collapsed.
// Coloured by the call's own direction (system view has no per-repeater source).
const sysPill=(slot)=>{ const e=act[slot], hang=ST.hang[n+'|'+slot]||null;
return callCell(e||null, e?(e.trx==='TX'?'tx':'rx'):null, hang); };
const collapsed=srvIsCollapsed(n);
h+='<div class="sys-block'+(collapsed?' collapsed':'')+'" data-sys="'+esc(n)+'">';
h+='<div class="sys-row"><h3><span class="sysname">'+esc(n)+'</span> <span class="pill">'+(m.REPEAT?'repeat':'isolate')+'</span> <span class="sub">'+pids.length+' repeater(s)</span> '
+'<span class="toggle-btn srv-toggle" title="Show or hide this system’s connections"><span class="caret">'+(collapsed?'▸':'▾')+'</span>'+(collapsed?'Show Connections':'Hide Connections')+'</span></h3>'
+'<div class="ts-hdrs"><div class="ts-hdr">TS1</div><div class="ts-hdr">TS2</div></div></div>'
+'<div class="sys-pills"><div class="slotcol">'+sysPill(1)+'</div><div class="slotcol">'+sysPill(2)+'</div></div>';
h+='<table><tr><th>Repeater</th><th>Connection</th><th>Freq</th><th>Slots</th><th class="slotcol"></th><th class="slotcol"></th></tr>';
if(!pids.length) h+='<tr><td colspan="6" class="sub">No repeaters connected</td></tr>';
for(const pid of pids){
const p=repeaters[pid];
const cell=(slot)=>{ const e=act[slot], hang=ST.hang[n+'|'+slot]||null;
if(!e) return callCell(null, null, hang);
// Colour purely by direction: the call's own RX/TX for the source repeater,
// TX (red) for the others, which are hearing the repeated traffic.
return callCell(e, String(e.peer)===String(p.RADIO_ID) ? (e.trx==='TX'?'tx':'rx') : 'tx', hang); };
const freq = (p.RX_FREQ&&p.RX_FREQ.slice(0,3)!=='000') ? 'RX '+fmtFreq(p.RX_FREQ)+'<br>TX '+fmtFreq(p.TX_FREQ) : '<span class="sub">N/A</span>';
// Chronic connectivity: PING_LOSS is a slow, decaying ping-loss % computed
// server-side. A repeater that stays connected but loses pings over time
// (choppy/lossy link) shows a rising loss %; a clean one sits at 0.
const loss = p.PING_LOSS || 0;
const csStyle = loss >= ST.ping_loss_warn ? ' style="color:var(--warn)"' : '';
const lossTag = loss > 0 ? ' <span class="sub">· '+loss+'% ping loss</span>' : '';
h+='<tr><td class="sysname"'+csStyle+'>'+idName(p.CALLSIGN,p.RADIO_ID)+'<div class="sub">'+esc(p.LOCATION||'')+lossTag+'</div></td>'+
'<td class="mono">'+esc(p.IP)+':'+esc(p.PORT)+'<div class="sub">up <span class="js-since" data-base="'+(p.connected_secs||0)+'" data-arr="'+Date.now()+'">'+dur(p.connected_secs||0)+'</span></div></td>'+
'<td class="mono sub">'+freq+'</td><td class="mono">'+fmtSlots(p.SLOTS)+'</td>'+
'<td>'+cell(1)+'</td><td>'+cell(2)+'</td></tr>';
}
h+='</table>';
h+='</div>'; // close .sys-block (collapsible repeater list)
}
el('servers').innerHTML=h;
}
function fmtFreq(f){ return f && f.length>=7 ? (f.slice(0,3)+'.'+f.slice(3,7)) : esc(f); }
function renderOutbounds(){
const names=Object.keys(ST.systems).filter(n=>ST.systems[n].MODE==='OUTBOUND');
if(!names.length){ el('outbounds').innerHTML='<div class="empty">No outbound systems</div>'; return; }
let h='';
for(const n of names){
const s=ST.systems[n], st=s.STATS||{}, act=activeBySlot(n);
const up = st.CONNECTION==='YES';
const warn = (st.NUM_OUTSTANDING||0) > 0;
const nameStyle = warn ? ' style="color:var(--warn)"' : '';
const since = st.connected_secs!=null ? '<span class="js-since" data-base="'+st.connected_secs+'" data-arr="'+Date.now()+'">'+dur(st.connected_secs)+'</span>' : '—';
// Single-entry system: fold the name into the header row (col 1) and the
// TS1/TS2 labels into the slot-column headers, dropping the separate sys-row.
h+='<table><tr><th class="sysname"'+nameStyle+'>'+esc(n)+'</th><th>Status</th><th>Slots</th><th class="slotcol">TS1</th><th class="slotcol">TS2</th></tr>';
h+='<tr><td>'+idName(s.CALLSIGN,s.RADIO_ID)+'<div class="sub">'+esc(s.LOCATION||'')+' · server '+esc(s.SERVER_IP)+':'+esc(s.SERVER_PORT)+'</div></td>'+
'<td><span class="dot '+(up?'ok':'bad')+'"></span><span class="'+(up?'status-ok':'status-bad')+'">'+(up?'Connected':'Disconnected')+'</span><div class="sub">up '+since+'</div></td>'+
'<td class="mono">'+fmtSlots(s.SLOTS)+'</td>'+
'<td>'+callCell(act[1], act[1]&&act[1].trx==='TX'?'tx':'rx', ST.hang[n+'|1']||null)+'</td>'+
'<td>'+callCell(act[2], act[2]&&act[2].trx==='TX'?'tx':'rx', ST.hang[n+'|2']||null)+'</td></tr>';
h+='</table>';
}
el('outbounds').innerHTML=h;
}
function renderOpenbridges(){
const names=Object.keys(ST.systems).filter(n=>ST.systems[n].MODE==='OPENBRIDGE');
if(!names.length){ el('openbridges').innerHTML='<div class="empty">No OpenBridge systems</div>'; return; }
let h='<table><tr><th>System</th><th>Network / Target</th><th>Active Streams</th></tr>';
for(const n of names){
const o=ST.systems[n], streams=activeList(n);
const cells = streams.length ? streams.map(e=>'<span class="call '+(e.trx==='TX'?'tx':'rx')+'" style="display:inline-block;width:auto;margin:2px">'+e.trx+' · '+idName(e.src_alias,e.src)+' → '+idName(e.dst_alias,e.dst)+'</span>').join(' ') : '<span class="call idle" style="display:inline-block;width:auto">— idle —</span>';
h+='<tr><td class="sysname">'+esc(n)+'</td><td class="mono">ID '+esc(o.NETWORK_ID)+'<div class="sub">'+esc(o.TARGET_IP)+':'+esc(o.TARGET_PORT)+'</div></td><td>'+cells+'</td></tr>';
}
el('openbridges').innerHTML=h+'</table>';
}
function renderBridges(){
const names=Object.keys(ST.bridges);
if(!names.length){ el('bridges').innerHTML='<div class="empty">No conference bridges (or running bridge_all / hblink standalone)</div>'; return; }
// One shared table for all bridges so every column lines up across them
// (each bridge gets a full-width sub-header row). Per-bridge tables auto-size
// their columns independently, which misaligns Action width and wide TGIDs.
let h='<table class="bridges"><tr><th>System</th><th>TS</th><th>TGID</th><th>Status</th><th>Timeout</th><th>Timer</th><th>Timeout Action</th><th>Activate TGIDs</th><th>Deactivate TGIDs</th></tr>';
for(const b of names){
h+='<tr class="bridge-head"><td colspan="9">'+esc(b)+'</td></tr>';
for(const m of ST.bridges[b]){
const on=m.ACTIVE;
const timerRunning = (m.TO_TYPE==='ON' && m.ACTIVE) || (m.TO_TYPE==='OFF' && !m.ACTIVE);
const remS = timerRunning ? (m.remaining==null ? 0 : m.remaining) : null;
const rem = remS!=null ? '<span class="js-countdown mono" data-rem="'+remS.toFixed(1)+'" data-arr="'+Date.now()+'">'+(remS<=0?'expired':dur(remS))+'</span>'
: '<span class="sub">N/A</span>';
const act = m.TO_TYPE==='ON'?'Disconnect after timer':m.TO_TYPE==='OFF'?'Reconnect after timer':'<span class="sub">None</span>';
h+='<tr><td class="sysname">'+esc(m.SYSTEM)+'</td><td class="mono">'+m.TS+'</td><td class="mono">'+idName(m.TGID_NAME,m.TGID)+'</td>'+
'<td><span class="'+(on?'status-ok':'status-bad')+'">'+(on?'Active':'Inactive')+'</span></td>'+
'<td class="mono sub">'+(m.TO_TYPE!=='NONE'?Math.round(m.TIMEOUT/60)+'m':'<span class="sub">N/A</span>')+'</td>'+
'<td>'+rem+'</td><td class="sub">'+act+'</td><td class="mono sub">'+(m.ON||[]).join(', ')+'</td><td class="mono sub">'+(m.OFF||[]).join(', ')+'</td></tr>';
}
}
el('bridges').innerHTML=h+'</table>';
}
function logRow(e){
const t=new Date(e._arrived||Date.now()).toLocaleTimeString();
const cls=e.action==='START'?'start':'end';
const d=e.duration!=null?(' · '+e.duration.toFixed(1)+'s'):'';
return '<div><span class="ts">'+t+'</span> <span class="'+cls+'">'+callShort(e.call_type)+' '+e.action+'</span> · '+
esc(e.system)+' · '+idName(e.src_alias,e.src)+' TS'+e.slot+' → '+idName(e.dst_alias,e.dst)+d+'</div>';
}
function renderLog(){ el('log').innerHTML = ST.log.map(logRow).join(''); }
// ---------- last heard ----------
// Most-recent unique callers, derived from the RX (ingress) call log — no server
// state of its own. Deduped by source radio ID, newest first.
function lhActiveSrc(src){ for(const k in ST.active){ if(String(ST.active[k].src)===String(src)) return true; } return false; }
function renderLastHeard(){
const box = el('lastHeard'); if(!box) return;
const seen = {}, rows = [];
for(const e of ST.log){
const id = e.src==null ? '' : String(e.src);
if(!id || seen[id]) continue;
if(lastHeardCollapsed && !lhActiveSrc(e.src)) continue; // "Active Calls" view: live streams only
seen[id] = 1; rows.push(e);
if(rows.length >= LAST_HEARD_COUNT) break;
}
if(!rows.length){ box.innerHTML = '<div class="empty">'+(lastHeardCollapsed?'No active calls':'No calls heard yet')+'</div>'; return; }
let h = '<table><tr><th>User</th><th>Type</th><th>Source</th><th>TS</th><th>Talkgroup</th><th>When</th></tr>';
for(const e of rows){
const src = e.peer!=null ? idName(e.peer_alias, e.peer) : esc(e.system||'');
const t = e._ts ? e._ts*1000 : (e._arrived||Date.now());
const when = lhActiveSrc(e.src)
? '<span class="status-ok">Active</span>'
: '<span class="js-lh mono" data-t="'+t+'">'+dur((Date.now()-t)/1000)+' ago</span>';
h += '<tr><td>'+idName(e.src_alias,e.src)+'</td>'
+ '<td class="sub">'+callShort(e.call_type)+'</td>'
+ '<td class="mono sub">'+src+'</td>'
+ '<td class="mono">TS'+esc(e.slot||'?')+'</td>'
+ '<td>'+idName(e.dst_alias,e.dst)+'</td>'
+ '<td>'+when+'</td></tr>';
}
box.innerHTML = h+'</table>';
}
// Collapsing Last Heard doesn't hide it — it filters to an "Active Calls" view
// (live streams only) and retitles the header. Expanded shows the full list.
let lastHeardCollapsed = false;
function applyLastHeard(collapsed){
lastHeardCollapsed = collapsed;
el('lastHeardTitle').textContent = collapsed ? 'Active Calls' : 'Last Heard';
const t = el('lastHeardToggle');
t.querySelector('.caret').textContent = collapsed ? '▸' : '▾';
t.querySelector('.toggle-label').textContent = collapsed ? 'Show all' : 'Active only';
renderLastHeard();
}
function initLastHeard(){
const sec = el('lastHeardSection'); if(!sec) return;
if(LAST_HEARD_CFG==='off'){ sec.style.display='none'; return; }
const saved = localStorage.getItem('lastHeardOpen'); // '1' = full list, '0' = active-only; else config default
applyLastHeard(saved==null ? (LAST_HEARD_CFG==='closed') : (saved==='0'));
el('lastHeardToggle').addEventListener('click', ()=>{
const collapsed = !lastHeardCollapsed;
applyLastHeard(collapsed);
localStorage.setItem('lastHeardOpen', collapsed?'0':'1');
});
}
// Simple collapsible sections (hide the body). Persisted per browser.
function initCollapse(key, bodyId, toggleId, defCollapsed){
const body = el(bodyId), btn = el(toggleId);
if(!body || !btn) return;
const apply = c => {
body.classList.toggle('hidden', c);
btn.querySelector('.caret').textContent = c ? '▸' : '▾';
btn.querySelector('.toggle-label').textContent = c ? 'Show' : 'Hide';
};
const saved = localStorage.getItem('collapse:'+key); // '1' = collapsed, '0' = shown; else default
apply(saved==null ? !!defCollapsed : saved==='1');
btn.addEventListener('click', ()=>{
const collapsed = !body.classList.contains('hidden'); // currently shown → we're collapsing
localStorage.setItem('collapse:'+key, collapsed?'1':'0');
apply(collapsed);
});
}
// ---------- live tick ----------
setInterval(()=>{
const now=Date.now();
// reap stale active streams whose END never arrived (interrupted/lost calls)
let reaped=false;
for(const k in ST.active){ if(now-ST.active[k]._arrived>STALE){ delete ST.active[k]; reaped=true; } }
for(const k in ST.hang) { if(now/1000 > ST.hang[k]._expires) { delete ST.hang[k]; reaped=true; } }
if(reaped) renderAll();
document.querySelectorAll('.js-since').forEach(s=>{ s.textContent=dur((+s.dataset.base)+(now-(+s.dataset.arr))/1000); });
document.querySelectorAll('.js-dur').forEach(s=>{ s.textContent=dur((now-(+s.dataset.start))/1000); });
document.querySelectorAll('.js-countdown').forEach(s=>{ const r=(+s.dataset.rem)-(now-(+s.dataset.arr))/1000; s.textContent=r<=0?'expired':dur(r); });
document.querySelectorAll('.js-lh').forEach(s=>{ s.textContent=dur((now-(+s.dataset.t))/1000)+' ago'; });
},1000);
// ---------- websocket ----------
function setConn(up){ ST.hblink=up; const c=el('conn'); c.className='badge '+(up?'up':'down'); c.textContent='HBlink: '+(up?'connected':'disconnected'); }
function renderAll(){ renderServers(); renderOutbounds(); renderOpenbridges(); renderBridges(); renderLog(); renderLastHeard(); }
function applyStream(e){
e._arrived = e._arrived || Date.now();
const hkey = e.system+'|'+e.slot;
if(e.action==='START'){
delete ST.hang[hkey];
ST.active[skey(e)]=e;
} else {
delete ST.active[skey(e)];
const ht = (ST.systems[e.system]||{}).GROUP_HANGTIME||0;
if(ht>0) ST.hang[hkey] = Object.assign({}, e, {_expires: Date.now()/1000 + ht});
}
if(e.trx==='RX'){ ST.log.unshift(e); if(ST.log.length>400) ST.log.pop(); renderLog(); renderLastHeard(); }
// re-render the affected system class
const sys=ST.systems[e.system];
if(sys&&sys.MODE==='SERVER') renderServers();
else if(sys&&sys.MODE==='OUTBOUND') renderOutbounds();
else if(sys&&sys.MODE==='OPENBRIDGE') renderOpenbridges();
else renderServers();
}
let sock=null;
function connect(){
sock=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
sock.onmessage=ev=>{
const m=JSON.parse(ev.data);
if(m.type==='initial'){ setConn(m.hblink); ST.systems=m.systems||{}; ST.bridges=m.bridges||{};
if(m.ping_time) ST.ping_time=m.ping_time; if(m.max_missed) ST.max_missed=m.max_missed; if(m.ping_loss_warn!=null) ST.ping_loss_warn=m.ping_loss_warn;
ST.active={}; (m.streams||[]).forEach(s=>{s._arrived=Date.now(); ST.active[skey(s)]=s;});
ST.log=(m.log||[]).map(e=>{e._arrived=e._arrived||Date.now();return e;}); renderAll(); }
else if(m.type==='hblink'){ setConn(m.connected); if(!m.connected){ST.systems={};ST.bridges={};ST.active={};renderAll();} }
else if(m.type==='config'){ ST.systems=m.systems||{};
if(m.ping_time) ST.ping_time=m.ping_time; if(m.max_missed) ST.max_missed=m.max_missed; if(m.ping_loss_warn!=null) ST.ping_loss_warn=m.ping_loss_warn;
renderServers(); renderOutbounds(); renderOpenbridges(); }
else if(m.type==='bridges'){ ST.bridges=m.bridges||{}; renderBridges(); }
else if(m.type==='stream_start'||m.type==='stream_end'){ applyStream(m); }
};
sock.onclose=()=>{ setConn(false); el('conn').textContent='HBlink: dashboard offline'; setTimeout(connect,2000); };
}
// ---------------------------------------------------------------------------
// Demo mode (?demo in URL) — coherent static scenario, no WebSocket.
// Traffic paradigm:
// US-NATIONWIDE (TGID 31, TS1): W0ABC keyed up on KS-SERVER →
// KS-SERVER TS1 RX, KC-OUTBOUND TS1 TX, BRANDMEISTER TS1 TX
// KS-STATEWIDE (TGID 9, TS2): N0CAL transmitting via BrandMeister →
// BRANDMEISTER TS2 RX, KS-SERVER TS2 TX to all repeaters
// MO-LOCAL (TGID 3100, TS2): W0DEF just finished →
// KC-OUTBOUND TS2 in TX hang
// ---------------------------------------------------------------------------
if(new URLSearchParams(location.search).has('demo')){
const _t = Date.now(); // ms (for _arrived, durations)
const _ts = _t / 1000; // seconds (for hang _expires)
ST.systems = {
'KS-SERVER': {
MODE:'SERVER', GROUP_HANGTIME:5, REPEAT:true,
REPEATERS:{
'3112001':{ RADIO_ID:'3112001', CALLSIGN:'W0ABC', LOCATION:'Topeka, KS', RX_FREQ:'448800000', TX_FREQ:'443800000', SLOTS:'3', IP:'10.1.1.1', PORT:50000, connected_secs:3672, last_ping_secs:2.1, PING_LOSS:0 },
'3112002':{ RADIO_ID:'3112002', CALLSIGN:'KD0XYZ', LOCATION:'Lawrence, KS', RX_FREQ:'446525000', TX_FREQ:'441525000', SLOTS:'3', IP:'10.1.1.2', PORT:50000, connected_secs:1245, last_ping_secs:3.8, PING_LOSS:3 },
'3112003':{ RADIO_ID:'3112003', CALLSIGN:'N0MJS', LOCATION:'Wichita, KS', RX_FREQ:'449500000', TX_FREQ:'444500000', SLOTS:'3', IP:'10.1.1.3', PORT:50000, connected_secs:412, last_ping_secs:8.3, PING_LOSS:24 },
},
},
'KC-OUTBOUND': {
MODE:'OUTBOUND', GROUP_HANGTIME:5,
CALLSIGN:'K0ABD', RADIO_ID:'3129200', LOCATION:'Kansas City, MO',
SERVER_IP:'10.5.0.1', SERVER_PORT:54001, SLOTS:'3',
STATS:{ CONNECTION:'YES', NUM_OUTSTANDING:0, connected_secs:5412 },
},
'BRANDMEISTER': {
MODE:'OPENBRIDGE',
NETWORK_ID:'3129100', TARGET_IP:'bm.brandmeister.network', TARGET_PORT:62031,
},
};
ST.bridges = {
'US-NATIONWIDE':[
{ SYSTEM:'KS-SERVER', TS:1, TGID:31, TGID_NAME:'US Nationwide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
{ SYSTEM:'KC-OUTBOUND', TS:1, TGID:31, TGID_NAME:'US Nationwide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
{ SYSTEM:'BRANDMEISTER', TS:1, TGID:31, TGID_NAME:'US Nationwide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
],
'KS-STATEWIDE':[
{ SYSTEM:'KS-SERVER', TS:2, TGID:9, TGID_NAME:'KS Statewide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
{ SYSTEM:'BRANDMEISTER', TS:2, TGID:9, TGID_NAME:'KS Statewide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
],
'MO-LOCAL':[
{ SYSTEM:'KS-SERVER', TS:2, TGID:3100, TGID_NAME:'MO Local', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
{ SYSTEM:'KC-OUTBOUND', TS:2, TGID:3100, TGID_NAME:'MO Local', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
],
};
// Active streams — keyed by system|slot|stream_id
// W0ABC on US Nationwide TS1 (started 5s ago); N0CAL on KS Statewide TS2 (started 3s ago).
// _arrived in ms; keepalive window is 30s so setting recently to prevent demo-mode reaping.
ST.active = {
'KS-SERVER|1|aabb0011': { system:'KS-SERVER', slot:1, stream_id:'aabb0011', trx:'RX', action:'START', call_type:'GROUP VOICE', src:3112001, src_alias:'W0ABC', dst:31, dst_alias:'US Nationwide', peer:3112001, _arrived:_t-5000 },
'KS-SERVER|2|ccdd0022': { system:'KS-SERVER', slot:2, stream_id:'ccdd0022', trx:'TX', action:'START', call_type:'GROUP VOICE', src:3188765, src_alias:'N0CAL', dst:9, dst_alias:'KS Statewide', peer:9990001, _arrived:_t-3000 },
'KC-OUTBOUND|1|aabb0011': { system:'KC-OUTBOUND', slot:1, stream_id:'aabb0011', trx:'TX', action:'START', call_type:'GROUP VOICE', src:3112001, src_alias:'W0ABC', dst:31, dst_alias:'US Nationwide', peer:3112001, _arrived:_t-5000 },
'BRANDMEISTER|1|aabb0011': { system:'BRANDMEISTER', slot:1, stream_id:'aabb0011', trx:'TX', action:'START', call_type:'GROUP VOICE', src:3112001, src_alias:'W0ABC', dst:31, dst_alias:'US Nationwide', peer:3112001, _arrived:_t-5000 },
'BRANDMEISTER|2|ccdd0022': { system:'BRANDMEISTER', slot:2, stream_id:'ccdd0022', trx:'RX', action:'START', call_type:'GROUP VOICE', src:3188765, src_alias:'N0CAL', dst:9, dst_alias:'KS Statewide', peer:3188765, _arrived:_t-3000 },
};
// KC-OUTBOUND TS2 in TX hang — W0DEF's MO Local call ended 4s ago, 5s hangtime
ST.hang = {
'KC-OUTBOUND|2':{ trx:'TX', dst:3100, dst_alias:'MO Local', _expires:_ts+26,
system:'KC-OUTBOUND', slot:2, call_type:'GROUP VOICE', src:3161001, _arrived:_t-4000 },
};
// Call log, most-recent first; _arrived in ms
ST.log = [
{ _arrived:_t-3000, action:'START', call_type:'GROUP VOICE', system:'BRANDMEISTER', slot:2, src:3188765, src_alias:'N0CAL', dst:9, dst_alias:'KS Statewide' },
{ _arrived:_t-5000, action:'START', call_type:'GROUP VOICE', system:'KS-SERVER', slot:1, src:3112001, src_alias:'W0ABC', dst:31, dst_alias:'US Nationwide' },
{ _arrived:_t-4000, action:'END', call_type:'GROUP VOICE', system:'KC-OUTBOUND', slot:2, src:3161001, src_alias:'W0DEF', dst:3100, dst_alias:'MO Local', duration:14.2 },
{ _arrived:_t-4000, action:'END', call_type:'GROUP VOICE', system:'KS-SERVER', slot:2, src:3161001, src_alias:'W0DEF', dst:3100, dst_alias:'MO Local', duration:14.2 },
{ _arrived:_t-18200, action:'START', call_type:'GROUP VOICE', system:'KS-SERVER', slot:2, src:3161001, src_alias:'W0DEF', dst:3100, dst_alias:'MO Local' },
{ _arrived:_t-129000, action:'END', call_type:'GROUP VOICE', system:'KS-SERVER', slot:1, src:3112002, src_alias:'KD0XYZ', dst:31, dst_alias:'US Nationwide', duration:8.7 },
{ _arrived:_t-137700, action:'START', call_type:'GROUP VOICE', system:'KS-SERVER', slot:1, src:3112002, src_alias:'KD0XYZ', dst:31, dst_alias:'US Nationwide' },
];
setConn(true);
renderAll();
// No WebSocket in demo mode — setInterval ticker still runs for live timers.
} else {
connect();
}
initLastHeard();
initServersCollapse();
initCollapse('servers','serversBody','serversToggle', false);
initCollapse('outbounds','outboundsBody','outboundsToggle', false);
initCollapse('openbridges','openbridgesBody','openbridgesToggle', false);
initCollapse('bridges','bridgesBody','bridgesToggle', false);
initCollapse('log','logBody','logToggle', false);
</script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
{
"talkgroups": [
{"id": 91, "callsign": "Worldwide"},
{"id": 93, "callsign": "NA"},
{"id": 3100, "callsign": "Nationwide"},
{"id": 3101, "callsign": "TAC 310"},
{"id": 3102, "callsign": "TAC 311"},
{"id": 3174, "callsign": "Kansas City"},
{"id": 3170001, "callsign": "KS Statewide"},
{"id": 3170009, "callsign": "KS West"},
{"id": 9, "callsign": "Local 9"},
{"id": 99, "callsign": "PARROT"}
]
}
@@ -0,0 +1,168 @@
# Conference Bridge State Machine for C Reimplementation
## Overview
Each conference bridge is a named group of system members. Each member independently
tracks whether it is ACTIVE or INACTIVE. When a member is ACTIVE, traffic received on
its configured system, timeslot, and TGID is eligible to be forwarded to all other ACTIVE
members of the same bridge.
The state machine described here governs how individual members transition between ACTIVE
and INACTIVE, and how timers interact with those transitions.
---
## Data model
A bridge member holds the following state:
- **SYSTEM**: the name of the HBP system this member belongs to
- **TS**: the timeslot (1 or 2) on that system
- **TGID**: the talkgroup ID that traffic on this member carries
- **ACTIVE**: boolean, whether this member is currently passing traffic
- **TO_TYPE**: one of ON, OFF, or NONE — controls timer behavior (see below)
- **TIMEOUT**: integer minutes — the duration of the timer, as configured. Converted to
seconds internally for comparison against the running timer.
- **TIMER**: an absolute time value in the same units as the application's time source.
Represents the moment at which the timer expires. The C implementation may use any
consistent time source (Unix epoch via time(), monotonic clock, or other); what matters
is that TIMER and the current time are always compared using the same source. When
emitted in the telemetry feed, TIMER is expressed as a Unix epoch value so that external
consumers can compute a countdown without needing to know the application's internal time
reference.
- **ON**: list of TGIDs — receiving any of these on the matching TS triggers the ON action
for this member (see TO_TYPE behavior below)
- **OFF**: list of TGIDs — receiving any of these on the matching TS triggers the OFF action
for this member (see TO_TYPE behavior below)
- **RESET**: list of TGIDs — receiving any of these on the matching TS resets the timer
for this member regardless of TO_TYPE
---
## TO_TYPE semantics
TO_TYPE controls the default state of a bridge member and what the timer expiry does.
TO_TYPE ON and TO_TYPE OFF are logical inverses of each other.
**TO_TYPE ON**: the member is INACTIVE by default. Receiving an ON TGID activates it and
starts the timer. The member stays ACTIVE until the timer expires, at which point it returns
to INACTIVE. This is the "turn on for N minutes" behavior.
**TO_TYPE OFF**: the member is ACTIVE by default. Receiving an OFF TGID deactivates it and
starts the timer. The member stays INACTIVE until the timer expires, at which point it
returns to ACTIVE. This is the "turn off for N minutes" behavior — the logical inverse of
TO_TYPE ON.
**TO_TYPE NONE**: no automatic timer. The member only changes state in response to ON and
OFF TGID triggers. It stays in whatever state it is in indefinitely until a trigger arrives.
RESET always extends the timer by setting it to current_time + TIMEOUT_seconds regardless
of TO_TYPE. For TO_TYPE ON this extends the active period. For TO_TYPE OFF this extends the
deactivation period — keeping the member INACTIVE longer. This is valid behavior even if
its usefulness in the OFF case is limited.
---
## State transitions
### ON trigger — fires at call START (new stream, voice header)
When a new stream is detected on a system (a stream_id not previously seen on that
system/slot combination), the incoming TGID is checked against the ON list of every bridge
member associated with that system and slot.
If the incoming TGID is in a member's ON list and the slot matches:
- For TO_TYPE ON: if INACTIVE, set ACTIVE to True and set TIMER to
current_time + (TIMEOUT * 60). If already ACTIVE, reset TIMER to extend the countdown.
- For TO_TYPE OFF: no effect from ON list while ACTIVE (already active). If somehow
INACTIVE when an ON TGID arrives, treat it as a reset — set ACTIVE to True. The ON list
is not the primary trigger for TO_TYPE OFF members.
- For TO_TYPE NONE: set ACTIVE to True. No timer is set.
If the incoming TGID is in a member's RESET list and the slot matches: set TIMER to
current_time + (TIMEOUT * 60) regardless of current ACTIVE state or TO_TYPE.
If any member's ACTIVE state changed, emit an immediate bridges snapshot.
**Why the ON trigger fires at call START, not call END**: the voice header frame must route
through to target systems before any voice frames arrive. If the trigger were deferred to
call END, the header and all voice frames would be lost and target radios would never hear
the call. Activating at the first packet of a new stream allows the header and all
subsequent frames to be forwarded in the same pass as the triggering call.
### OFF trigger — fires at call END (voice terminator received)
When a voice terminator is received for an active stream on a system, the incoming TGID is
checked against the OFF list of every bridge member associated with that system and slot.
If the incoming TGID is in a member's OFF list and the slot matches:
- For TO_TYPE OFF: if ACTIVE, set ACTIVE to False and set TIMER to
current_time + (TIMEOUT * 60). This begins the deactivation period.
- For TO_TYPE ON: set ACTIVE to False immediately. No timer restart.
- For TO_TYPE NONE: set ACTIVE to False. No timer.
If any member's ACTIVE state changed, emit an immediate bridges snapshot.
**Why the OFF trigger fires at call END**: the terminator frame must also route to target
systems so they can cleanly close the call. Deactivating at the terminator ensures the full
call — header through terminator — reaches all targets before routing stops.
### Timer expiry — fires in the rule timer loop
The rule timer loop runs every 10 seconds. The 10-second interval is sufficient because
TIMEOUT is configured in whole minutes, making 10-second resolution more than adequate for
correct expiry detection. The loop does not need sub-minute precision.
For each bridge member where TO_TYPE is ON or OFF:
- If TO_TYPE is ON and ACTIVE is True and TIMER is less than current_time: set ACTIVE to
False. The member has exceeded its active timeout.
- If TO_TYPE is OFF and ACTIVE is False and TIMER is less than current_time: set ACTIVE to
True. The deactivation period has ended; the member returns to its default active state.
If any member changed state, emit a bridges snapshot. The loop does not emit a snapshot if
no state changed, avoiding unnecessary traffic to reporting clients.
---
## Timer behavior summary
TO_TYPE ON: default INACTIVE. ON TGID activates, timer runs, expiry deactivates. RESET
extends active period. Logical reading: "this bridge is on for N minutes after the last
activity."
TO_TYPE OFF: default ACTIVE. OFF TGID deactivates, timer runs, expiry reactivates. RESET
extends deactivation period. Logical reading: "this bridge is off for N minutes after the
blocking call, then comes back."
TO_TYPE NONE: no timer. ON TGID activates, OFF TGID deactivates. Stays in either state
indefinitely until triggered.
---
## Routing when ACTIVE
A packet received on a system/slot/TGID that matches an ACTIVE bridge member is forwarded
to every other ACTIVE member of the same bridge, subject to the packet routing rules (see
the routing flow document). The source system is never a forwarding target for its own
traffic.
If a bridge has only one ACTIVE member, there are no targets and the packet is not forwarded
anywhere (but it is still received and processed normally by the ingress system).
---
## Reporting bridge state
The bridge state is reported as a snapshot (the full state of all bridges) rather than as
individual transitions. This means:
- Consumers always have a consistent view — they cannot observe a partial update
- The core does not need to track which clients have seen which transitions
- On new client connect, the current snapshot is sent immediately
Immediate snapshots on ACTIVE state change are important for PTT systems: a user keys up
expecting to see the bridge connect, and a 10-second lag would be confusing. The immediate
snapshot is surgical — it only fires when ACTIVE actually changed, not on every packet.
@@ -0,0 +1,247 @@
# Packet Routing Flow for C Reimplementation
## Overview
This document describes the path a DMRD (DMR data) packet travels from arrival at a UDP
socket through the routing decision to forwarding on one or more outbound UDP sockets. It
also covers the LC rewriting policy and why it is designed the way it is.
---
## DMRD packet structure
All field offsets are zero-indexed bytes within the UDP payload.
- Bytes 0-3: command identifier, the ASCII string "DMRD"
- Byte 4: sequence number (wraps 0-255)
- Bytes 5-7: rf_src, the radio ID of the subscriber (transmitting radio), 3 bytes big-endian
- Bytes 8-10: dst_id, the destination TGID for group calls or subscriber ID for unit calls,
3 bytes big-endian
- Bytes 11-14: peer_id, the radio ID of the repeater or hotspot that received the call,
4 bytes big-endian
- Byte 15: flags byte. Bit 7 (0x80): timeslot (0 = TS1, 1 = TS2). Bits 6-4: frame type.
Bits 3-0: dtype_vseq, encodes the voice frame sequence position within a superframe.
- Bytes 16-19: stream_id, a 4-byte identifier that is constant for the duration of one call
and unique per call. Used to detect new streams and track active calls.
- Bytes 20-52: 33 bytes of DMR payload (the actual over-the-air frame content, including
the embedded LC or SYNC pattern depending on frame type)
- Bytes 53-54: BER (bit error rate) and RSSI values from the repeater
Total: 55 bytes minimum. Some implementations may include additional trailing data.
---
## Arrival and validation
When a DMRD packet arrives on a SERVER system's UDP socket:
1. Confirm the command bytes (0-3) are "DMRD".
2. Extract peer_id from bytes 11-14. Look up the peer_id in the system's repeater table.
3. Validate that the repeater exists, its CONNECTION state is "YES" (fully registered), and
its stored socket address matches the packet's source address. Drop the packet silently
if any check fails. This prevents spoofed or stale packets from being routed.
4. Extract rf_src (bytes 5-7), dst_id (bytes 8-10), the timeslot from bit 7 of byte 15,
dtype_vseq from bits 3-0 of byte 15, and stream_id (bytes 16-19).
5. Determine call type (group or unit) from the flags byte.
For OUTBOUND (peer) systems, the peer_id in the packet is the remote master's ID, not the
local repeater's. Validation is against the outbound system's configured server address and
connection state.
---
## New stream detection
Before the routing loop, check whether this stream_id has been seen before on this
system/slot combination. The active stream table maps (system, slot) to the current
stream_id.
If the stream_id is new (not in the active stream table):
1. Record the new stream_id in the active stream table for this system/slot.
2. Run the ACTIVATION check: consult the bridge membership table for this system and slot.
For each bridge member this system/slot belongs to, check whether dst_id is in the
member's ON list. If it is, apply the activation logic described in the bridge state
machine document. Do this before the routing loop so the triggering header frame itself
is forwarded to newly activated targets.
3. If any bridge member's ACTIVE state changed, emit an immediate bridges snapshot via the
reporting feed.
---
## Routing loop
For each bridge that this system/slot/TGID combination belongs to (looked up via a
precomputed BRIDGE_BY_SYSTEM index), and for each other ACTIVE member of that bridge
(excluding the source system):
1. Determine the target: the other member's SYSTEM, TS, and TGID.
2. Build the outbound packet by copying the inbound packet and rewriting:
- Bytes 8-10 (dst_id): replace with the target member's TGID
- Byte 15 (flags): set or clear bit 7 to reflect the target's TS
- Bytes 11-14 (peer_id): replace with the target system's own configured radio ID
(the remote master expects its own ID as the peer_id from a peer, and repeaters
expect the master's ID as the peer_id from a server)
3. Rewrite the LC word in the DMR payload (see LC rewriting section below).
4. Transmit the modified packet to the target system's registered socket address.
If the source system is a SERVER, the packet goes to each target repeater's socket address
on the target server's UDP port. If the source is an OUTBOUND peer, the packet goes to
the remote master's address.
---
## LC rewriting
The Link Control (LC) word encodes the call parameters — specifically the TGID, the
rf_src, and the call type flag. Every forwarded frame must carry an LC word that matches
the target's TGID and the original subscriber's rf_src. If this is not done, radios that
join the call after it has started (late entry) will decode the wrong TGID from the
embedded LC and may route or display the call incorrectly, or route traffic to the wrong
destination.
LC rewriting is applied to every forwarded frame, not just headers:
**Voice header frame**: the LC word occupies a known position in the 33-byte DMR payload
and is protected by a full rate BPTC 19,96 code. Decode the existing LC, replace the FLCO
(Full LC Opcode), rf_src, and dst_id fields with the correct values for this forwarding
path, and re-encode with BPTC 19,96.
**Voice burst frames A-F (dtype_vseq 0-5)**: each voice burst carries an embedded LC
fragment and a Golay-protected SYNC pattern. For bursts B through E (dtype_vseq 1 through
4), bits 116 through 147 of the 264-bit over-the-air representation carry an embedded LC
chunk. These bits are overwritten with the translated LC for this forwarding path.
**Voice terminator frame**: the LC word is present in the same position as the header and
is rewritten identically.
---
## Why talker alias is destroyed during bridging
Talker Alias (TA) is an extended LC feature (FLCO values 0x04 through 0x07) that occupies
exactly the same embedded LC slots in voice bursts B through E that the bridge uses to
carry the translated call LC. These two uses of the same bits are mutually exclusive.
If the bridge were to preserve the talker alias embedded LC in bursts B through E, the
embedded LC in those frames would describe a different FLCO than the header LC, which
carries the standard call LC (FLCO 0x00). A radio performing late entry would read the
burst embedded LC, expect a standard call LC, and instead find talker alias data. Depending
on the radio's implementation, this results in incorrect TGID display, incorrect routing,
or audio artifacts.
The only consistent behavior is to overwrite the burst embedded LC with the translated call
LC on every forwarded frame. This destroys any talker alias that was in those bits.
This is a deliberate design decision driven by protocol interoperability, not a limitation
of the LC handling logic. Preserving talker alias embedded LC in voice bursts B through E
is technically possible in a pure HBP-to-HBP bridging path. The reason it is not done is
that the bridge must operate correctly across multiple protocols — HBP, IPSC via an adapter
such as ipsc2hbp, OpenBridge, and potentially future protocols — each of which carries
talker alias through a different mechanism. IPSC, for example, carries TA entirely outside
the DMR frame structure. Frames reconstructed by an IPSC-to-HBP adapter carry no valid
embedded TA LC. Other future protocol adapters may be similarly constrained.
Attempting to selectively preserve TA LC only on HBP-to-HBP paths while overwriting it on
all other paths introduces conditional logic that must be maintained as new protocol adapters
are added. Overwriting with the call LC on every forwarded frame is the safe, uniform, and
most interoperable choice across the full range of protocols the bridge may interface with.
Radios that resolve talker alias from an external database (RadioID.net or a locally loaded
DMR ID file) are unaffected because they do not depend on the embedded TA LC at all.
---
## Stream end handling
When a voice terminator frame is received for the current active stream on a system/slot:
1. Forward the terminator to all routing targets as described above (the terminator must
reach target radios so they can cleanly close the call).
2. Run the DEACTIVATION check: for each bridge member this system/slot belongs to, check
whether dst_id is in the member's OFF list. If it is, apply the deactivation logic from
the bridge state machine document.
3. If any bridge member's ACTIVE state changed, emit an immediate bridges snapshot.
4. Remove the stream_id from the active stream table.
---
## Stale stream cleanup
If a call is interrupted (repeater loses power, network drop, lost terminator) the
stream_id may remain in the active stream table indefinitely. A background cleanup pass
removes stream entries older than a stale threshold and runs at a periodic interval.
A DMR voice superframe is approximately 360 milliseconds. A call that ends without a
terminator becomes detectable within one or two superframes of the last received voice
frame — roughly 1 to 2 seconds. Given the timescales of human PTT interaction, a cleanup
threshold of 1 to 2 seconds is fast enough to prevent phantom streams from blocking new
calls without requiring any sub-second precision in the cleanup interval. The cleanup pass
itself does not need to run faster than every few seconds; it is a backstop for an
uncommon failure case, not a timing-critical path.
This prevents a lost terminator from leaving a phantom active stream that blocks new calls
on the same system/slot from being detected as new streams.
---
## BRIDGE_BY_SYSTEM index
The routing loop above requires efficiently finding all bridges a given system/slot/TGID
belongs to. Iterating every bridge and every member on every packet is too slow.
At startup (and on every rules reload), build a secondary index: a mapping from
(system_name, slot) to a list of (bridge_pointer, member_pointer) pairs. When a packet
arrives, look up (system, slot) in the index to get the candidate bridge members, then
check whether dst_id is in each member's TGID. Because TGID lists per member are small,
this inner scan is fast.
This index is rebuilt from scratch on every rules reload. The old index is replaced
atomically alongside the bridge table.
---
## Unit call routing
Private (unit) calls between two subscribers are routed differently from group calls. Rather
than consulting the bridge table, the router consults a learned subscriber location cache: a
table that records which system last heard a given subscriber ID transmit. When a unit call
arrives addressed to subscriber X, the router looks up X in the cache and forwards the call
to the system where X was last heard.
The conceptual model is analogous to PIM Sparse Mode for IPv4 multicast: the router does
not know where every subscriber is at all times, but it learns subscriber location from
observed traffic and uses that information to make a forwarding decision. HBlink4's
implementation of this mechanism is the reference for detailed behavior.
The cache is updated once per call stream, on the voice header frame (the first DMRD packet
of a new stream_id). The update is unconditional: the rf_src ID is recorded as having been
last seen on the ingress system, and that entry's expiry timestamp is reset to
current_time + TIMEOUT. If an entry for that rf_src already exists pointing to a different
system, it is overwritten with the new system. This handles subscriber roaming — a unit
that moves from system A to system B between calls will have its cache entry updated to
system B on its next transmission, regardless of whether the old entry has expired.
Updating on subsequent frames of the same call is redundant — a subscriber's location
cannot change mid-call — and would add unnecessary work to every packet on the hot path.
This requires no explicit configuration; the cache builds itself from observed traffic.
The cache must be pruned periodically or it will grow without bound as subscribers come and
go. A pruning pass runs at a configured interval measured in minutes (not seconds), removing
entries whose expiry timestamp has passed. The interval does not need to be fine-grained
because subscriber location changes on human timescales, not millisecond timescales. Each
time a subscriber is heard, its entry is refreshed, so active subscribers are never pruned.
When a unit call arrives and the destination subscriber is not in the cache (a cache miss),
the call must be forwarded to all systems rather than to a single known destination. This is
the equivalent of a PIM Sparse Mode register/flood before the join tree is established. In
practice this is a small volume of additional traffic, as cache misses occur only for
subscribers not recently heard. Each system has a per-system configuration flag that
specifies whether it accepts unit call flooding (forwarding of unit calls destined for
unknown subscribers). Systems that do not permit flooding are skipped during a cache miss
forward. This allows operators to isolate systems that should only receive unit calls with
a confirmed destination.
The subscriber cache is lossy by design: last-seen wins. It does not handle the case where
a subscriber is simultaneously reachable on multiple systems. It is the correct and
sufficient mechanism for the common case of routing a private reply back to the originating
repeater.
+137
View File
@@ -0,0 +1,137 @@
# Rules Configuration Design for C Reimplementation
## Background
HBLink3's conference bridge rules have been defined in a Python file (`rules.py`) since
the original DMRlink `confbridge.py` in 2013. Thirteen years of operator familiarity with
this format is a real asset. A C reimplementation of the protocol core should preserve that
format completely — not as a compromise, but because it is actually the right tool for what
the rules file does.
## What the rules file actually is
`rules.py` is not application logic. It is a **data definition file that uses Python as a
macro language**. Its only meaningful output is `BRIDGE_CONF` — a dictionary of bridge
definitions, each containing a list of system members with their associated parameters.
The Python feature operators used in practice are straightforward: named lists defined once
and referenced in multiple bridge members, variables holding common parameter sets,
occasional conditional expressions for environment-specific tweaks. None of this is
algorithmic; it is all in service of expressing structured data without repetition.
This means the rules file is already doing what Python is best suited for: readable,
expressive configuration with reuse. The C runtime does not need to understand Python to
benefit from it.
## The separation of concerns
The architecture draws a clean line between two roles:
**Configuration authoring** — the operator's domain. Expressed in `rules.py` using Python.
Validated by a helper tool before the runtime ever sees it. Can be edited with any text
editor, generated by a web UI, or constructed programmatically. The format is stable and
human-readable.
**Protocol runtime** — the C core's domain. Operates entirely on a resolved, validated JSON
representation of the rules. Has no Python dependency. Loads the JSON at startup and can
reload it at any time without restarting.
These two roles communicate through a single, well-defined JSON schema. Anything that can
produce conforming JSON can author rules — the Python file is one such producer, a web-based
admin UI would be another, a script generating rules from a database would be another.
## The helper tool
A small Python script (call it `rules_export.py`) bridges the two roles. Its job is:
1. **Execute** `rules.py` as normal Python. All variable assignments, list references, and
any other Python constructs run to completion. By the time execution finishes,
`BRIDGE_CONF` is a fully resolved plain data structure with no remaining references or
indirection.
2. **Validate** the resolved structure against the expected schema. Check that required
fields are present, that types are correct, that system names referenced in rules
correspond to known systems, and so on. Emit clear, actionable error messages if
anything is wrong. This is where the "missing trailing comma" class of errors gets
caught, with a Python traceback that points directly at the offending line.
3. **Emit** the validated structure as JSON to stdout (or to a file). The output contains
only concrete values — no variable names, no Python expressions, no references. It is
the fully evaluated result.
The helper is intentionally a separate tool rather than a startup step inside the C binary.
This means an operator can run it manually to validate a rules change before applying it,
a deployment script can run it as a preflight check, and a web-based admin UI can invoke
it server-side to validate rules before saving them.
## How the C core loads rules
At startup, the C core reads the JSON rules file from a configured path. It parses the JSON
into its internal bridge table — an array of bridge structs, each containing an array of
member structs with their parameters. This is the only representation the runtime uses.
The JSON file is not kept open; it is read once, parsed, and the file handle is closed.
The JSON schema is documented separately and is the stable interface between configuration
tooling and the runtime. The Python rules file format is one representation that maps to
this schema; it is not the schema itself.
## Live reload without restart
When an operator modifies `rules.py`, the workflow is:
1. Run the helper tool to validate and export the new JSON. If validation fails, no change
is applied; the running daemon continues on the existing rules.
2. Signal the running daemon (SIGHUP is the Unix convention for configuration reload).
3. The daemon, at the top of its next event loop iteration, detects the signal flag and
reads the new JSON file. It constructs a new bridge table in fresh memory alongside the
existing one.
4. If the new table is valid, the daemon atomically replaces the pointer to the active
bridge table with the pointer to the new one, then frees the old table. From this point
forward all routing decisions use the new rules.
5. If the new table is invalid (malformed JSON, missing fields, etc.), the daemon logs the
error and continues running on the existing rules. No traffic is disrupted.
Any call in progress at the moment of the swap completes under the old rules. The swap
itself is instantaneous from the event loop's perspective.
## Why the single-threaded model makes this safe
In a multi-threaded runtime, swapping the bridge table under live traffic requires locks,
reference counting, or other synchronization to ensure no thread is mid-read on the old
table when it is freed. This is non-trivial to get right.
In a single-threaded event loop model, the swap happens at a well-defined point — the top
of a loop iteration, before any packet dispatch — at which moment by definition nothing
else is touching the bridge table. There are no concurrent readers. The correctness argument
is trivial: the old pointer is live until the new one replaces it, and exactly one thing
holds the pointer at any time.
The brief pause while the JSON file is read and parsed is acceptable for this use case.
Amateur radio PTT traffic is tolerant of a reload pause measured in tens of milliseconds.
If future requirements demanded zero-pause reloads, the parsing work could be moved to a
background thread that hands the completed table back to the event loop via a pipe, keeping
the actual pointer swap on the main thread — but this is an optimization, not a requirement
for correctness.
## What this enables beyond the rules file
Once the JSON schema is the stable interface, multiple configuration front-ends become
possible independently of one another:
- The existing `rules.py` Python format, unchanged, for operators familiar with it
- A web-based admin UI with authentication that constructs and validates the JSON directly,
with no Python file involved
- A REST endpoint on the admin service that accepts JSON rules and triggers a reload,
enabling external orchestration systems to manage bridge configuration
- Automated rule generation from a database, a planning tool, or a coordination system
None of these require changes to the C runtime. The runtime speaks JSON; whatever produces
that JSON is an independent concern.
This is the natural endpoint of what the Python rules file was always doing — expressing
structured data in a readable, reusable way. The JSON schema makes that structure explicit
and opens it to any tooling that can speak it.
+209
View File
@@ -0,0 +1,209 @@
# Telemetry Event Schema for C Reimplementation
## Design Philosophy
The reporting feed is a secondary concern. The C core is a DMR call router first. The
telemetry emitter must never slow, stall, or complicate the packet routing path. Concretely:
- All TCP writes to reporting clients are non-blocking. If a client's socket buffer is full,
the write is dropped for that client. The routing path does not wait.
- Stream events (call START/END) are emitted inline in the routing path because they are
time-sensitive, but they must be cheap — a single formatted write to each client fd.
- Config and bridges events are periodic snapshots, not live state. They are assembled and
sent on a timer, not on every state change. Exception: bridge ACTIVE state changes may
trigger an immediate bridges snapshot because operators observe bridge state directly and
a 10-second lag is confusing on a PTT system.
- If a reporting client disconnects or is slow, it is removed from the client list and
forgotten. It does not affect other clients or the routing core.
A C implementer should treat the reporting feed as a UDP-style "best effort" service that
happens to run over TCP for reliability with slow consumers. The core routes calls regardless
of whether any clients are connected.
---
## Transport
The core listens on a configured TCP port (default 4321). Dashboard and admin clients
connect and receive a stream of newline-delimited JSON objects (NDJSON) — one complete JSON
object per line, terminated by `\n`. Clients are read-only; the core ignores any data sent
by clients.
On client connect, the core immediately sends the current `config` event and the current
`bridges` event so the client has a complete picture before any deltas arrive.
---
## Event Types
### `config` — System snapshot
Sent periodically (every `REPORT_INTERVAL` seconds, typically 10). Contains the full state
of all configured systems and their connected repeaters/peers. This is a complete snapshot,
not a delta — receivers replace their entire system state on each `config` event.
```
{
"type": "config",
"ping_time": <integer, seconds, from GLOBAL config>,
"max_missed": <integer, from GLOBAL config>,
"systems": {
"<SYSTEM_NAME>": <system object, see below>
}
}
```
**System object — SERVER mode** (HBP master, repeaters connect to it):
```
{
"MODE": "SERVER",
"REPEAT": <boolean>,
"CALLSIGN": <string>,
"RADIO_ID": <integer>,
"SLOTS": <integer, bitmask>,
"LOCATION": <string>,
"REPEATERS": {
"<peer_id_hex>": {
"CALLSIGN": <string>,
"RADIO_ID": <integer>,
"IP": <string>,
"PORT": <integer>,
"RX_FREQ": <string, 9-digit>,
"TX_FREQ": <string, 9-digit>,
"SLOTS": <integer>,
"CONNECTION": <string, "YES" | "NO" | handshake state>,
"CONNECTED": <float, Unix epoch of connection time>,
"LAST_PING": <float, Unix epoch of last RPTP received from repeater>
}
}
}
```
**System object — OUTBOUND mode** (HBP peer, connects to a remote master):
```
{
"MODE": "OUTBOUND",
"CALLSIGN": <string>,
"RADIO_ID": <integer>,
"SLOTS": <integer>,
"LOCATION": <string>,
"SERVER_IP": <string>,
"SERVER_PORT": <integer>,
"STATS": {
"CONNECTION": <string, "YES" | "NO" | handshake state>,
"CONNECTED": <float, Unix epoch, null if not connected>,
"NUM_OUTSTANDING": <integer, unacknowledged pings, 0 in normal operation>
}
}
```
**System object — OPENBRIDGE mode**:
```
{
"MODE": "OPENBRIDGE",
"CALLSIGN": <string>,
"RADIO_ID": <integer>,
"SLOTS": <integer>,
"STATS": {
"CONNECTION": <string>
}
}
```
**Notes on epoch fields**: `CONNECTED` and `LAST_PING` are Unix epoch floats (seconds since
1970-01-01 UTC) as returned by C's `time()` or Python's `time.time()`. Dashboard consumers
subtract from the current wall clock to get elapsed seconds. The dashboard server enriches
these to `connected_secs` and `last_ping_secs` before forwarding to browser clients, so
browser code never sees raw epochs.
---
### `bridges` — Bridge state snapshot
Sent periodically (every `REPORT_INTERVAL`) and immediately when any bridge member's ACTIVE
state changes. Contains the full state of all configured bridges. Receivers replace their
entire bridge state on each `bridges` event.
```
{
"type": "bridges",
"bridges": {
"<BRIDGE_NAME>": [
{
"SYSTEM": <string, system name>,
"TGID": <integer>,
"TS": <integer, 1 or 2>,
"ACTIVE": <boolean>,
"TO_TYPE": <string, "ON" | "OFF" | "NONE">,
"TIMEOUT": <integer, seconds>,
"TIMER": <float, Unix epoch when this member expires, 0 if TO_TYPE is NONE>,
"ON": [<integer TGID>, ...],
"OFF": [<integer TGID>, ...],
"RESET": [<integer TGID>, ...]
},
...
]
}
}
```
`TIMER` is an absolute epoch. Consumers compute `remaining = TIMER - now` to display a
countdown. When `remaining <= 0` the member has expired (ACTIVE will become False on the
next rule timer evaluation). Dashboard consumers must handle the case where `TIMER` is in
the past before the next `bridges` snapshot arrives.
---
### `stream` — Call event
Emitted once at call START (first packet of a new stream) and once at call END (voice
terminator received, or stream reaped as stale). These are the only events emitted in the
routing hot path and must be assembled and written with minimal overhead.
```
{
"type": "stream",
"action": "START" | "END",
"system": <string, ingress system name>,
"slot": <integer, 1 or 2>,
"stream_id": <string, 4-byte stream ID as hex>,
"src": <integer, rf_src subscriber radio ID>,
"dst": <integer, dst_id TGID for group calls, subscriber ID for unit calls>,
"peer": <integer, repeater radio ID that originated the call>,
"trx": "RX" | "TX",
"call_type": "GROUP VOICE" | "UNIT VOICE",
"pkt_time": <float, Unix epoch of the packet that triggered this event>
}
```
`trx` is `RX` for the ingress leg (received from a repeater or peer) and `TX` for egress
legs (forwarded to target systems). Dashboard call logs typically show only `RX` legs to
avoid duplicating each call for every forwarded copy.
---
### `hblink` — Feed connectivity
Emitted by the **dashboard server** (Python), not by the C core itself, to notify browser
clients that the connection to the C core's reporting port was lost or restored. The C core
does not emit this event type. It is included here for completeness so dashboard implementers
understand the full event space.
```
{ "type": "hblink", "connected": <boolean> }
```
---
## What is deliberately excluded
The following are not emitted because they would add overhead to the hot path or are
derivable by consumers from the data above:
- Per-packet statistics (byte counts, packet counts beyond what is in STATS)
- Talker alias data (destroyed during LC rewriting; see routing flow document)
- Per-system call history (consumers build this from stream events)
- Internal timing metrics (ping round-trip times, etc.)
The schema is intentionally minimal. Adding fields is cheap; removing them once consumers
depend on them is expensive. When in doubt, leave it out.
+57 -128
View File
@@ -1,45 +1,24 @@
# PROGRAM-WIDE PARAMETERS GO HERE
# PATH - working path for files, leave it alone unless you NEED to change it
# PING_TIME - the interval that peers will ping the master, and re-try registraion
# - how often the Master maintenance loop runs
# MAX_MISSED - how many pings are missed before we give up and re-register
# - number of times the master maintenance loop runs before de-registering a peer
# HBlink3 main configuration. Full field reference: CONFIGURING.md ACL guide: ACLS.md
#
# ACLs:
# NOTE: every [SECTION] name below is referenced by rules.py. If a name in rules.py has
# no matching system here, bridge.py will NOT start. Keep the two files in sync.
#
# Access Control Lists are a very powerful tool for administering your system.
# But they consume packet processing time. Disable them if you are not using them.
# But be aware that, as of now, the configuration stanzas still need the ACL
# sections configured even if you're not using them.
#
# REGISTRATION ACLS ARE ALWAYS USED, ONLY SUBSCRIBER AND TGID MAY BE DISABLED!!!
#
# The 'action' May be PERMIT|DENY
# Each entry may be a single radio id, or a hypenated range (e.g. 1-2999)
# Format:
# ACL = 'action:id|start-end|,id|start-end,....'
# --for example--
# SUB_ACL: DENY:1,1000-2000,4500-60000,17
#
# ACL Types:
# REG_ACL: peer radio IDs for registration (only used on HBP master systems)
# SUB_ACL: subscriber IDs for end-users
# TGID_TS1_ACL: destination talkgroup IDs on Timeslot 1
# TGID_TS2_ACL: destination talkgroup IDs on Timeslot 2
#
# ACLs may be repeated for individual systems if needed for granularity
# Global ACLs will be processed BEFORE the system level ACLs
# Packets will be matched against all ACLs, GLOBAL first. If a packet 'passes'
# All elements, processing continues. Packets are discarded at the first
# negative match, or 'reject' from an ACL element.
#
# If you do not wish to use ACLs, set them to 'PERMIT:ALL'
# TGID_TS1_ACL in the global stanza is used for OPENBRIDGE systems, since all
# traffic is passed as TS 1 between OpenBridges
# [GLOBAL] — program-wide timers plus the global ACLs (run before per-system ACLs;
# both must pass). ACL format: ACTION:list, e.g. PERMIT:ALL, DENY:1, DENY:1-2000,17.
# Leave PATH alone unless you must change it.
[GLOBAL]
PATH: ./
PING_TIME: 5
MAX_MISSED: 3
# Chronic connectivity monitoring (optional; these defaults apply if omitted).
# The dashboard reports each repeater's ping loss -- the percentage of its
# expected keepalive pings that went missing -- over the last PING_LOSS_WINDOW
# minutes (1-60; larger = smoother/longer memory), and turns a repeater's
# callsign gold at or above PING_LOSS_WARN percent. This catches a repeater that
# stays connected but has a lossy link (choppy/dropped audio). 0 disables the
# gold flag. A repeater that simply pings less often is not counted as lossy.
PING_LOSS_WINDOW: 5
PING_LOSS_WARN: 5
USE_ACL: True
REG_ACL: PERMIT:ALL
SUB_ACL: DENY:1
@@ -47,57 +26,36 @@ TGID_TS1_ACL: PERMIT:ALL
TGID_TS2_ACL: PERMIT:ALL
# NOT YET WORKING: NETWORK REPORTING CONFIGURATION
# Enabling "REPORT" will configure a socket-based reporting
# system that will send the configuration and other items
# to a another process (local or remote) that may process
# the information for some useful purpose, like a web dashboard.
#
# REPORT - True to enable, False to disable
# REPORT_INTERVAL - Seconds between reports
# REPORT_PORT - TCP port to listen on if "REPORT_NETWORKS" = NETWORK
# REPORT_CLIENTS - comma separated list of IPs you will allow clients
# to connect on. Entering a * will allow all.
#
# ****FOR NOW MUST BE TRUE - USE THE LOOPBACK IF YOU DON'T USE THIS!!!****
# [REPORTS] — newline-delimited JSON event/stat feed for the dashboard (dashboard/).
# Leave REPORT True; set REPORT_PORT to match the dashboard's HBLINK_PORT and point
# REPORT_CLIENTS at the dashboard host (comma-separated IPs, or a single * for all).
# Full reference: CONFIGURING.md.
[REPORTS]
REPORT: True
REPORT_INTERVAL: 60
REPORT_INTERVAL: 10
REPORT_PORT: 4321
REPORT_CLIENTS: 127.0.0.1
# SYSTEM LOGGER CONFIGURAITON
# This allows the logger to be configured without chaning the individual
# python logger stuff. LOG_FILE should be a complete path/filename for *your*
# system -- use /dev/null for non-file handlers.
# LOG_HANDLERS may be any of the following, please, no spaces in the
# list if you use several:
# null
# console
# console-timed
# file
# file-timed
# syslog
# LOG_LEVEL may be any of the standard syslog logging levels, though
# as of now, DEBUG, INFO, WARNING and CRITICAL are the only ones
# used.
#
# [LOGGER] — LOG_HANDLERS: null|console|console-timed|file|file-timed|syslog
# (comma-join, no spaces). LOG_LEVEL: DEBUG|INFO|WARNING|CRITICAL. Under systemd,
# set LOG_FILE: /dev/null with a console handler and just rely on the journal.
# Full reference: CONFIGURING.md.
[LOGGER]
LOG_FILE: /tmp/hblink.log
LOG_HANDLERS: console-timed
LOG_LEVEL: DEBUG
LOG_NAME: HBlink
# DOWNLOAD AND IMPORT SUBSCRIBER, PEER and TGID ALIASES
# Ok, not the TGID, there's no master list I know of to download
# This is intended as a facility for other applcations built on top of
# HBlink to use, and will NOT be used in HBlink directly.
# STALE_DAYS is the number of days since the last download before we
# download again. Don't be an ass and change this to less than a few days.
# [ALIASES] — DMR-ID -> callsign lookups. The DASHBOARD owns these files: set
# TRY_DOWNLOAD: False here and point PATH at the dashboard's directory. Loading the full
# subscriber DB here costs ~500 MB RAM per process (>1 GB if the dashboard loads it too),
# so keep USE_ALIASES: False unless the DB has been filtered small (dashboard
# FILTER_COUNTRIES). Full reference incl. the memory warning: CONFIGURING.md.
[ALIASES]
TRY_DOWNLOAD: True
PATH: ./
USE_ALIASES: False
TRY_DOWNLOAD: False
PATH: dashboard/
PEER_FILE: peer_ids.json
SUBSCRIBER_FILE: subscriber_ids.json
TGID_FILE: talkgroup_ids.json
@@ -105,25 +63,13 @@ PEER_URL: https://www.radioid.net/static/rptrs.json
SUBSCRIBER_URL: https://www.radioid.net/static/users.json
STALE_DAYS: 7
# OPENBRIDGE INSTANCES - DUPLICATE SECTION FOR MULTIPLE CONNECTIONS
# OpenBridge is a protocol originall created by DMR+ for connection between an
# IPSC2 server and Brandmeister. It has been implemented here at the suggestion
# of the Brandmeister team as a way to legitimately connect HBlink to the
# Brandemiester network.
# It is recommended to name the system the ID of the Brandmeister server that
# it connects to, but is not necessary. TARGET_IP and TARGET_PORT are of the
# Brandmeister or IPSC2 server you are connecting to. PASSPHRASE is the password
# that must be agreed upon between you and the operator of the server you are
# connecting to. NETWORK_ID is a number in the format of a DMR Radio ID that
# will be sent to the other server to identify this connection.
# other parameters follow the other system types.
#
# ACLs:
# OpenBridge does not 'register', so registration ACL is meaningless.
# Proper OpenBridge passes all traffic on TS1.
# HBlink can extend OPB to use both slots for unit calls only.
# Setting "BOTH_SLOTS" True ONLY affects unit traffic!
# Otherwise ACLs work as described in the global stanza
# [OPENBRIDGE] — Server-to-Server link (e.g. BrandMeister, IPSC2). Duplicate for more.
# TARGET_IP/PORT = far server; PASSPHRASE = shared HMAC key (must match both ends);
# NETWORK_ID identifies this server. All traffic is TS1 (no registration).
# BOTH_SLOTS: True is HBlink-to-HBlink ONLY -- no other server is known to accept it.
# PRESERVE_SOURCE_PEER: True forwards the originating peer ID instead of NETWORK_ID
# (unvalidated field; improves call provenance; best when both ends enable it).
# Full reference: CONFIGURING.md.
[OBP-1]
MODE: OPENBRIDGE
ENABLED: True
@@ -134,29 +80,20 @@ PASSPHRASE: password
TARGET_IP: 1.2.3.4
TARGET_PORT: 62035
BOTH_SLOTS: True
PRESERVE_SOURCE_PEER: False
USE_ACL: True
SUB_ACL: DENY:1
TGID_ACL: PERMIT:ALL
# MASTER INSTANCES - DUPLICATE SECTION FOR MULTIPLE MASTERS
# HomeBrew Protocol Master instances go here.
# IP may NOT be left blank. To listen on all interfaces use 0.0.0.0 or ::
# Port should be the port you want this master to listen on. It must be unique
# and unused by anything else.
# Repeat - if True, the master repeats traffic to peers, False, it does nothing.
#
# MAX_PEERS -- maximun number of peers that may be connect to this master
# at any given time. This is very handy if you're allowing hotspots to
# connect, or using a limited computer like a Raspberry Pi.
#
# ACLs:
# See comments in the GLOBAL stanza
[MASTER-1]
MODE: MASTER
# [SERVER] — you are the Server; Clients (repeaters/hotspots) connect in. Duplicate for
# more. IP may NOT be blank (0.0.0.0 or :: = all interfaces); PORT must be unique/unused.
# REPEAT: True repeats between connected Clients; MAX_REPEATERS caps connections.
# Full reference: CONFIGURING.md.
[SERVER-1]
MODE: SERVER
ENABLED: True
REPEAT: True
MAX_PEERS: 10
EXPORT_AMBE: False
MAX_REPEATERS: 10
IP: 0.0.0.0
PORT: 54000
PASSPHRASE: s3cr37w0rd
@@ -167,27 +104,19 @@ SUB_ACL: DENY:1
TGID_TS1_ACL: PERMIT:ALL
TGID_TS2_ACL: PERMIT:ALL
# PEER INSTANCES - DUPLICATE SECTION FOR MULTIPLE PEERS
# There are a LOT of errors in the HB Protocol specifications on this one!
# MOST of these items are just strings and will be properly dealt with by the program
# The TX & RX Frequencies are 9-digit numbers, and are the frequency in Hz.
# Latitude is an 8-digit unsigned floating point number.
# Longitude is a 9-digit signed floating point number.
# Height is in meters
# Setting Loose to True relaxes the validation on packets received from the master.
# This will allow HBlink to connect to a non-compliant system such as XLXD, DMR+ etc.
#
# ACLs:
# See comments in the GLOBAL stanza
[REPEATER-1]
MODE: PEER
# [OUTBOUND] — you are a Client: HBlink masquerades as a repeater/hotspot and dials out
# to a Server. Duplicate for more. RX/TX_FREQ in Hz; LAT/LONG in decimal degrees; HEIGHT
# in meters. LOOSE: True relaxes validation for non-compliant Servers (XLXD, DMR+).
# NOTE: BrandMeister does NOT accept OUTBOUND/Client connections -- it requires OpenBridge.
# Full reference: CONFIGURING.md.
[OUTBOUND-1]
MODE: OUTBOUND
ENABLED: True
LOOSE: False
EXPORT_AMBE: False
IP: 0.0.0.0
PORT: 54001
MASTER_IP: 172.16.1.1
MASTER_PORT: 54000
SERVER_IP: 172.16.1.1
SERVER_PORT: 54000
PASSPHRASE: homebrew
CALLSIGN: W1ABC
RADIO_ID: 312000
@@ -209,4 +138,4 @@ OPTIONS:
USE_ACL: True
SUB_ACL: DENY:1
TGID_TS1_ACL: PERMIT:ALL
TGID_TS2_ACL: PERMIT:ALL
TGID_TS2_ACL: PERMIT:ALL
+650 -352
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
# systemd unit for the HBlink3 dashboard (dashboard/server.py).
#
# Install:
# sudo cp hblink3-dash.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now hblink3-dash
#
# Adjust User/Group and the paths below to your install. The dashboard reads
# dashboard/config.py for its settings (copy it from dashboard/config_sample.py).
[Unit]
Description=HBlink3 Dashboard
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=cort
Group=cort
WorkingDirectory=/home/cort/hblink3/dashboard
ExecStart=/home/cort/hblink3/venv/bin/python /home/cort/hblink3/dashboard/server.py
Restart=always
RestartSec=10
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
# Logging to the journal (journalctl -u hblink3-dash)
StandardOutput=journal
StandardError=journal
SyslogIdentifier=hblink3-dash
[Install]
WantedBy=multi-user.target
+37
View File
@@ -0,0 +1,37 @@
# systemd unit for the HBlink3 conference-bridge router (bridge.py).
#
# Install:
# sudo cp hblink3.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now hblink3
#
# Adjust User/Group and the paths below to your install. bridge.py defaults to
# hblink.cfg and rules.py in its own directory; pass -c/-r in ExecStart to use
# other files. To run a different app (e.g. the plain proxy), point ExecStart at
# bridge_all.py or hblink.py instead.
[Unit]
Description=HBlink3 DMR Conference Bridge Router
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=cort
Group=cort
WorkingDirectory=/home/cort/hblink3
ExecStart=/home/cort/hblink3/venv/bin/python /home/cort/hblink3/bridge.py
Restart=always
RestartSec=10
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
# Logging to the journal (journalctl -u hblink3)
StandardOutput=journal
StandardError=journal
SyslogIdentifier=hblink3
[Install]
WantedBy=multi-user.target
+8
View File
@@ -18,6 +18,14 @@
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
# ============================================================================
# LEGACY -- NOT MIGRATED TO ASYNCIO.
# The core (hblink.py) moved from Twisted to asyncio in the 2026 cleanup. This
# utility still uses Twisted (reactor) and subclasses the now-asyncio HBSYSTEM,
# so it will NOT run against the current core without being migrated. It is kept
# for reference; install Twisted separately if you intend to revive/port it.
# ============================================================================
# Python modules we need
import sys
from bitarray import bitarray
+7
View File
@@ -18,6 +18,13 @@
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
# ============================================================================
# LEGACY -- NOT MIGRATED TO ASYNCIO.
# The core (hblink.py) moved from Twisted to asyncio in the 2026 cleanup. This
# utility still uses Twisted (reactor) and subclasses the now-asyncio HBSYSTEM,
# so it will NOT run against the current core without being migrated. It is kept
# for reference; install Twisted separately if you intend to revive/port it.
# ============================================================================
# Python modules we need
import sys
+47
View File
@@ -0,0 +1,47 @@
# Packaging / project metadata for HBlink3.
#
# HBlink3 is normally run from a clone (e.g. `python bridge.py -c hblink.cfg`),
# not pip-installed, but keeping dependency and Python-version metadata in the
# standard location lets tooling (pip, uv, IDEs) discover it correctly.
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hblink3"
version = "1.0.0"
description = "Open-source HomeBrew Repeater Protocol (HBP) client/master and conference-bridge router for DMR"
readme = "README.md"
license = { text = "GPL-3.0-or-later" }
authors = [
{ name = "Cortney T. Buffington, N0MJS", email = "n0mjs@me.com" },
]
keywords = ["dmr", "hbp", "homebrew", "repeater", "amateur-radio", "mmdvm"]
# The networking core is stdlib asyncio (asyncio.run / create_datagram_endpoint /
# create_task / Event), all available on 3.8+. A future bump to >=3.11 would let
# the maintenance/rule-timer loops use asyncio.TaskGroup / asyncio.timeout, but
# nothing requires it today.
requires-python = ">=3.8"
dependencies = [
"bitarray>=2.0.0",
"dmr_utils3>=0.1.30",
]
# Optional extras. The dashboard (dashboard/) is a separate program but can share
# the core's virtualenv: pip install -e ".[dashboard]"
[project.optional-dependencies]
dashboard = [
"fastapi>=0.110",
"uvicorn[standard]>=0.27",
]
[project.urls]
Homepage = "https://github.com/n0mjs710/hblink3"
# The core routing modules. Listed explicitly because they are top-level modules
# rather than a package, so a `pip install .` resolves them correctly.
[tool.setuptools]
py-modules = ["hblink", "bridge", "bridge_all", "config", "const", "log", "reporting_const"]
+15 -5
View File
@@ -1,5 +1,15 @@
bitstring>=3.1.5
bitarray>=0.8.1
Twisted>=16.3.0
dmr_utils3>=0.1.19
configparser>=3.0.0
# Core runtime dependencies for the HBlink3 routing applications
# (hblink.py, bridge.py, bridge_all.py).
#
# The networking core uses Python's stdlib asyncio -- no Twisted required.
#
# Removed in the 2026 cleanup:
# configparser -- part of the Python 3 standard library; the pin was a no-op
# bitstring -- only used by the optional voice utilities (mk_voice.py)
# Twisted -- replaced by asyncio; only the legacy, unmigrated voice
# utilities (playback.py, play_ambe.py) still import it
#
# Floors are conservative; the versions these were last tested against are noted.
bitarray>=2.0.0 # tested: 3.8.1
dmr_utils3>=0.1.31 # tested: 0.1.31
ijson>=3.2 # streaming JSON parse for filtered subscriber downloads
+47 -33
View File
@@ -1,54 +1,67 @@
'''
THIS EXAMPLE WILL NOT WORK AS IT IS - YOU MUST SPECIFY YOUR OWN VALUES!!!
THIS EXAMPLE WILL NOT WORK AS-IS - YOU MUST SPECIFY YOUR OWN VALUES!
Full reference: CONFIGURING.md (rules.py section).
This file is organized around the "Conference Bridges" that you wish to use. If you're a c-Bridge
person, think of these as "bridge groups". You might also liken them to a "reflector". If a particular
system is "ACTIVE" on a particular conference bridge, any traffid from that system will be sent
to any other system that is active on the bridge as well. This is not an "end to end" method, because
each system must independently be activated on the bridge.
BRIDGES defines "conference bridges" (think bridge groups / reflectors): every system
marked ACTIVE on a bridge exchanges traffic with every other ACTIVE system on that same
bridge. Not end-to-end -- each system is activated on each bridge independently.
The first level (e.g. "WORLDWIDE" or "STATEWIDE" in the examples) is the name of the conference
bridge. This is any arbitrary ASCII text string you want to use. Under each conference bridge
definition are the following items -- one line for each HBSystem as defined in the main HBlink
configuration file.
* SYSTEM - The name of the sytem as listed in the main hblink configuration file (e.g. hblink.cfg)
This MUST be the exact same name as in the main config file!!!
* TS - Timeslot used for matching traffic to this confernce bridge
XLX connections should *ALWAYS* use TS 2 only.
* TGID - Talkgroup ID used for matching traffic to this conference bridge
XLX connections should *ALWAYS* use TG 9 only.
* ON and OFF are LISTS of Talkgroup IDs used to trigger this system off and on. Even if you
only want one (as shown in the ON example), it has to be in list format. None can be
handled with an empty list, such as " 'ON': [] ".
* TO_TYPE is timeout type. If you want to use timers, ON means when it's turned on, it will
turn off afer the timout period and OFF means it will turn back on after the timout
period. If you don't want to use timers, set it to anything else, but 'NONE' might be
a good value for documentation!
* TIMOUT is a value in minutes for the timout timer. No, I won't make it 'seconds', so don't
ask. Timers are performance "expense".
* RESET is a list of Talkgroup IDs that, in addition to the ON and OFF lists will cause a running
timer to be reset. This is useful if you are using different TGIDs for voice traffic than
triggering. If you are not, there is NO NEED to use this feature.
Each bridge key is an arbitrary name; its value is one entry per participating system:
SYSTEM - system name from hblink.cfg -- MUST match EXACTLY, or bridge.py won't start
TS - timeslot 1 or 2 to match (XLX: always TS 2)
TGID - talkgroup ID to match (XLX: always TG 9)
ACTIVE - True/False: passing traffic now? (ON/OFF triggers below can flip it)
TO_TYPE - 'ON' = auto-off after TIMEOUT; 'OFF' = auto-on after TIMEOUT; else 'NONE'
TIMEOUT - timer length in MINUTES (minutes only; timers cost performance)
ON/OFF - lists of TGIDs that activate/deactivate this system (always lists; [] = none)
RESET - list of TGIDs that reset a running timer (only when voice TGID != trigger TGIDs)
'''
BRIDGES = {
'WORLDWIDE': [
{'SYSTEM': 'MASTER-1', 'TS': 1, 'TGID': 1, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'ON', 'ON': [2,], 'OFF': [9,10], 'RESET': []},
{'SYSTEM': 'SERVER-1', 'TS': 1, 'TGID': 1, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'ON', 'ON': [2,], 'OFF': [9,10], 'RESET': []},
{'SYSTEM': 'CLIENT-1', 'TS': 1, 'TGID': 3100, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'ON', 'ON': [2,], 'OFF': [9,10], 'RESET': []},
],
'ENGLISH': [
{'SYSTEM': 'MASTER-1', 'TS': 1, 'TGID': 13, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [3,], 'OFF': [8,10], 'RESET': []},
{'SYSTEM': 'SERVER-1', 'TS': 1, 'TGID': 13, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [3,], 'OFF': [8,10], 'RESET': []},
{'SYSTEM': 'CLIENT-2', 'TS': 1, 'TGID': 13, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [3,], 'OFF': [8,10], 'RESET': []},
],
'STATEWIDE': [
{'SYSTEM': 'MASTER-1', 'TS': 2, 'TGID': 3129, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [4,], 'OFF': [7,10], 'RESET': []},
{'SYSTEM': 'SERVER-1', 'TS': 2, 'TGID': 3129, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [4,], 'OFF': [7,10], 'RESET': []},
{'SYSTEM': 'CLIENT-2', 'TS': 2, 'TGID': 3129, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [4,], 'OFF': [7,10], 'RESET': []},
]
}
'''
list the names of each system that should bridge unit to unit (individual) calls.
OpenBridge (OBP) systems are NOT configured as inline BRIDGES members above.
An OBP is a point-to-point trunk that passes talkgroups by TGID -- it has no RF
user to key ON/OFF timers and no real timeslot -- so it gets its own table:
OBP_BRIDGES = { <OBP system from hblink.cfg> : { <bridge name> : <TGID> } }
Read a row as "on THIS OpenBridge, this TGID *is* this bridge." That one fact is
the route in BOTH directions and the filter: a TGID not listed for an OBP is
dropped (fail-closed). At startup bridge.py expands each row into a bridge member
for you. Notes:
* TS defaults to 1 (OBP "no timeslot" placeholder). To override: (TGID, TS).
* The OBP system name must be an enabled MODE: OPENBRIDGE system in hblink.cfg.
* Leaving an OBP system as an inline BRIDGES member above is a startup ERROR.
* On one OBP, a TGID may map to only one bridge (a fork duplicates the stream)
-> startup ERROR. A bridge carrying different TGIDs on two OBPs (renumber in
transit) is allowed but logs a WARNING.
* OBP_BRIDGES is optional -- omit it or leave it {} if you run no OpenBridges.
'''
OBP_BRIDGES = {
# 'BACKBONE-OBP': {
# 'WORLDWIDE': 1,
# 'STATEWIDE': 3129,
# 'ENGLISH': (13, 2), # TGID 13 pinned to TS 2 (override)
# },
}
'''
UNIT: system names that should bridge unit-to-unit (individual/private) calls to each other.
'''
UNIT = ['ONE', 'TWO']
@@ -61,4 +74,5 @@ like it were a Python program itself will tell you if the syntax is correct!
if __name__ == '__main__':
from pprint import pprint
pprint(BRIDGES)
pprint(OBP_BRIDGES)
print(UNIT)
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env python
# Convenience launcher for the HBlink3 dashboard. Equivalent to running
# `python dashboard/server.py`.
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dashboard'))
import server
if __name__ == '__main__':
server.main()
+23
View File
@@ -0,0 +1,23 @@
# Tests
Regression tests pinning the behavior of helpers extracted during the 2026
cleanup. They exist because a live OBP/HBP network with real DMR audio cannot be
reproduced in a dev environment, so the routing helpers are validated in
isolation instead.
- `test_acl.py` — `HBSYSTEM.dmrd_acl_check()` (the shared master/peer ACL logic):
PERMIT/DENY matching, per-timeslot scoping, and the once-per-stream drop logging.
- `test_lc.py` — `gen_lcs()` and `embed_lc()` from `bridge.py`: Link Control
generation and the DMR payload LC rewrite used by all four group-routing paths.
## Running
From the repo root, using the project virtualenv (which has `twisted`,
`dmr_utils3`, and `bitarray`):
```
venv/bin/python -m unittest discover -s tests -v
```
Pure stdlib `unittest` — no extra dependencies. They also run under `pytest` if
you have it installed (`venv/bin/python -m pytest tests`).
+396
View File
@@ -0,0 +1,396 @@
{
"hbp_contention_second_stream_blocked": [
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c021000000184ce00f1015ee05fc2ffe6b993d42474c51565b60644e21b4c2ef08aa5150d0b4ca0000"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0010000001801060b10151a1f24292e33383d424a3ccca8bb60656a6f74797e83888d92979ca10000"
]
],
"hbp_multi_target": [
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c021000000174ce00f1015ee05fc2ffe6b993d42474c51565b60644e21b4c2ef08aa5150d0b4ca0000"
],
[
"SERVER-2",
"444d52440004c2c0000c1c0004c2c021000000174ce00f1015ee05fc2ffe6b993d42474c51565b60644e21b4c2ef08aa5150d0b4ca0000"
],
[
"OBP-1",
"444d52440004c2c0000c1c0004c2c021000000174ce00f1015ee05fc2ffe6b993d42474c51565b60644e21b4c2ef08aa5150d0b4ca"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0010000001701060b10151a1f24292e33383d424a3ccca8bb60656a6f74797e83888d92979ca10000"
],
[
"SERVER-2",
"444d52440004c2c0000c1c0004c2c0010000001701060b10151a1f24292e33383d424a3ccca8bb60656a6f74797e83888d92979ca10000"
],
[
"OBP-1",
"444d52440004c2c0000c1c0004c2c0010000001701060b10151a1f24292e33383d424a3ccca8bb60656a6f74797e83888d92979ca1"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0020000001701060b10151a1f24292e33383d42495958100b60656a6f74797e83888d92979ca10000"
],
[
"SERVER-2",
"444d52440004c2c0000c1c0004c2c0020000001701060b10151a1f24292e33383d42495958100b60656a6f74797e83888d92979ca10000"
],
[
"OBP-1",
"444d52440004c2c0000c1c0004c2c0020000001701060b10151a1f24292e33383d42495958100b60656a6f74797e83888d92979ca1"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0030000001701060b10151a1f24292e33383d42481812d3ab60656a6f74797e83888d92979ca10000"
],
[
"SERVER-2",
"444d52440004c2c0000c1c0004c2c0030000001701060b10151a1f24292e33383d42481812d3ab60656a6f74797e83888d92979ca10000"
],
[
"OBP-1",
"444d52440004c2c0000c1c0004c2c0030000001701060b10151a1f24292e33383d42481812d3ab60656a6f74797e83888d92979ca1"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0040000001701060b10151a1f24292e33383d42499ed9c50b60656a6f74797e83888d92979ca10000"
],
[
"SERVER-2",
"444d52440004c2c0000c1c0004c2c0040000001701060b10151a1f24292e33383d42499ed9c50b60656a6f74797e83888d92979ca10000"
],
[
"OBP-1",
"444d52440004c2c0000c1c0004c2c0040000001701060b10151a1f24292e33383d42499ed9c50b60656a6f74797e83888d92979ca1"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c022000000174c8f0fc4155e05842f8e6b393d42474c51565b60655a228cc58f04ea5950c9b4f90000"
],
[
"SERVER-2",
"444d52440004c2c0000c1c0004c2c022000000174c8f0fc4155e05842f8e6b393d42474c51565b60655a228cc58f04ea5950c9b4f90000"
],
[
"OBP-1",
"444d52440004c2c0000c1c0004c2c022000000174c8f0fc4155e05842f8e6b393d42474c51565b60655a228cc58f04ea5950c9b4f9"
]
],
"hbp_to_hbp_cross_slot": [
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0a1000000124ce00f1015ee05fc2ffe6b993d42474c51565b60644e21b4c2ef08aa5150d0b4ca0000"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0810000001201060b10151a1f24292e33383d424a3ccca8bb60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0820000001201060b10151a1f24292e33383d42495958100b60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0830000001201060b10151a1f24292e33383d42481812d3ab60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0840000001201060b10151a1f24292e33383d42499ed9c50b60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0a2000000124c8f0fc4155e05842f8e6b393d42474c51565b60655a228cc58f04ea5950c9b4f90000"
]
],
"hbp_to_hbp_no_voice_header": [
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0010000001a01060b10151a1f24292e33383d424030c0a0ab60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0020000001a01060b10151a1f24292e33383d42414140041b60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0220000001a2c5a0fe4147400e82c806ac13d42474c51565b606514236041220da44800de84b40000"
]
],
"hbp_to_hbp_same_slot": [
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c021000000114ce00f1015ee05fc2ffe6b993d42474c51565b60644e21b4c2ef08aa5150d0b4ca0000"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0010000001101060b10151a1f24292e33383d424a3ccca8bb60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0020000001101060b10151a1f24292e33383d42495958100b60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0030000001101060b10151a1f24292e33383d42481812d3ab60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c0040000001101060b10151a1f24292e33383d42499ed9c50b60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c0000c1c0004c2c022000000114c8f0fc4155e05842f8e6b393d42474c51565b60655a228cc58f04ea5950c9b4f90000"
]
],
"hbp_to_hbp_tgid_rewrite": [
[
"REPEATER-1",
"444d52440004c2c0007bb10004c2c021000000134834170434a6707c2caee6593d42474c51565b6064ce21d4c58f8c4b4953f4b0920000"
],
[
"REPEATER-1",
"444d52440004c2c0007bb10004c2c0010000001301060b10151a1f24292e33383d424aaddd293b60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c0007bb10004c2c0020000001301060b10151a1f24292e33383d424958d9011b60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c0007bb10004c2c0030000001301060b10151a1f24292e33383d4249081063ab60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c0007bb10004c2c0040000001301060b10151a1f24292e33383d42488cfb772b60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c0007bb10004c2c02200000013485b17d0341670042cdee6f93d42474c51565b6065da22ecc2ef800b4153edb0a10000"
]
],
"hbp_to_obp": [
[
"OBP-1",
"444d52440004c2c0000c1c0004c2c021000000144ce00f1015ee05fc2ffe6b993d42474c51565b60644e21b4c2ef08aa5150d0b4ca"
],
[
"OBP-1",
"444d52440004c2c0000c1c0004c2c0010000001401060b10151a1f24292e33383d424a3ccca8bb60656a6f74797e83888d92979ca1"
],
[
"OBP-1",
"444d52440004c2c0000c1c0004c2c0020000001401060b10151a1f24292e33383d42495958100b60656a6f74797e83888d92979ca1"
],
[
"OBP-1",
"444d52440004c2c0000c1c0004c2c0030000001401060b10151a1f24292e33383d42481812d3ab60656a6f74797e83888d92979ca1"
],
[
"OBP-1",
"444d52440004c2c0000c1c0004c2c0040000001401060b10151a1f24292e33383d42499ed9c50b60656a6f74797e83888d92979ca1"
],
[
"OBP-1",
"444d52440004c2c0000c1c0004c2c022000000144c8f0fc4155e05842f8e6b393d42474c51565b60655a228cc58f04ea5950c9b4f9"
]
],
"obp_to_hbp": [
[
"SERVER-1",
"444d5244000004d2000c1c002fbf0c21000000154c1f081816aa0bdc2b6e46597d42474c51565b60678622e4c28f194a08d07434ad0000"
],
[
"SERVER-1",
"444d5244000004d2000c1c002fbf0c010000001501060b10151a1f24292e33383d424a0c9ca8bb60656a6f74797e83888d92979ca10000"
],
[
"SERVER-1",
"444d5244000004d2000c1c002fbf0c020000001501060b10151a1f24292e33383d42490938100b60656a6f74797e83888d92979ca10000"
],
[
"SERVER-1",
"444d5244000004d2000c1c002fbf0c030000001501060b10151a1f24292e33383d42482840339b60656a6f74797e83888d92979ca10000"
],
[
"SERVER-1",
"444d5244000004d2000c1c002fbf0c040000001501060b10151a1f24292e33383d4249fc5b17db60656a6f74797e83888d92979ca10000"
],
[
"SERVER-1",
"444d5244000004d2000c1c002fbf0c22000000154c7008cc161a0ba42b1e46f97d42474c51565b60669221dcc5ef150a00d06d349e0000"
]
],
"obp_to_obp": [
[
"OBP-2",
"444d5244000004d2000c1c002fbf0c21000000164c1f081816aa0bdc2b6e46597d42474c51565b60678622e4c28f194a08d07434ad"
],
[
"OBP-2",
"444d5244000004d2000c1c002fbf0c010000001601060b10151a1f24292e33383d424a0c9ca8bb60656a6f74797e83888d92979ca1"
],
[
"OBP-2",
"444d5244000004d2000c1c002fbf0c020000001601060b10151a1f24292e33383d42490938100b60656a6f74797e83888d92979ca1"
],
[
"OBP-2",
"444d5244000004d2000c1c002fbf0c030000001601060b10151a1f24292e33383d42482840339b60656a6f74797e83888d92979ca1"
],
[
"OBP-2",
"444d5244000004d2000c1c002fbf0c040000001601060b10151a1f24292e33383d4249fc5b17db60656a6f74797e83888d92979ca1"
],
[
"OBP-2",
"444d5244000004d2000c1c002fbf0c22000000164c7008cc161a0ba42b1e46f97d42474c51565b60669221dcc5ef150a00d06d349e"
]
],
"obp_to_obp_no_voice_header": [
[
"OBP-2",
"444d5244000004d2000c1c002fbf0c010000001b01060b10151a1f24292e33383d42400090a0ab60656a6f74797e83888d92979ca1"
],
[
"OBP-2",
"444d5244000004d2000c1c002fbf0c220000001b2ca508ec17300ec8281047017d42474c51565b6066dc203041421c4411807a04d3"
]
],
"unit_hbp_to_hbp": [
[
"REPEATER-1",
"444d52440004c2c00008200004c2c0610000002101060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c00008200004c2c0410000002101060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c00008200004c2c0420000002101060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c00008200004c2c0430000002101060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c00008200004c2c0440000002101060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
],
[
"REPEATER-1",
"444d52440004c2c00008200004c2c0620000002101060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
]
],
"unit_hbp_to_obp": [
[
"OBP-1",
"444d52440004c2c00008200004c2c0610000002201060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-1",
"444d52440004c2c00008200004c2c0410000002201060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-1",
"444d52440004c2c00008200004c2c0420000002201060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-1",
"444d52440004c2c00008200004c2c0430000002201060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-1",
"444d52440004c2c00008200004c2c0440000002201060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-1",
"444d52440004c2c00008200004c2c0620000002201060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
]
],
"unit_hbp_to_obp_ts2": [
[
"OBP-1",
"444d52440004c2c00008200004c2c0610000002301060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-1",
"444d52440004c2c00008200004c2c0410000002301060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-1",
"444d52440004c2c00008200004c2c0420000002301060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-1",
"444d52440004c2c00008200004c2c0430000002301060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-1",
"444d52440004c2c00008200004c2c0440000002301060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-1",
"444d52440004c2c00008200004c2c0620000002301060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
]
],
"unit_obp_to_hbp": [
[
"SERVER-1",
"444d5244000004d2000820002fbf0c610000002401060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
],
[
"SERVER-1",
"444d5244000004d2000820002fbf0c410000002401060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
],
[
"SERVER-1",
"444d5244000004d2000820002fbf0c420000002401060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
],
[
"SERVER-1",
"444d5244000004d2000820002fbf0c430000002401060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
],
[
"SERVER-1",
"444d5244000004d2000820002fbf0c440000002401060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
],
[
"SERVER-1",
"444d5244000004d2000820002fbf0c620000002401060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
]
],
"unit_obp_to_obp": [
[
"OBP-2",
"444d5244000004d2000820002fbf0c610000002501060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-2",
"444d5244000004d2000820002fbf0c410000002501060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-2",
"444d5244000004d2000820002fbf0c420000002501060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-2",
"444d5244000004d2000820002fbf0c430000002501060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-2",
"444d5244000004d2000820002fbf0c440000002501060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
],
[
"OBP-2",
"444d5244000004d2000820002fbf0c620000002501060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
]
]
}
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python
#
# Deterministic routing scenarios used to build a byte-level golden baseline of
# the current routing core, so the planned forward_group extraction (and later
# the full unification) can be proven to emit identical packets.
#
# Each scenario builds its own World, replays a fixed frame sequence, and returns
# the captured emissions as a list of [system, hex]. Emissions are deterministic:
# packet bytes depend only on the input frames and the (fixed) BPTC-encoded LCs,
# never on wall-clock time.
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE))
sys.path.insert(0, _HERE)
from dmr_utils3.utils import bytes_3, bytes_4
import harness
def _member(system, ts, tgid):
return {'SYSTEM': system, 'TS': ts, 'TGID': tgid, 'ACTIVE': True,
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []}
def _full_call(w, src, *, rf_src, dst, peer, slot, stream_id):
"""A complete group call: voice header, bursts B-E, voice terminator."""
w.feed_group_header(src, rf_src=rf_src, dst=dst, peer=peer, slot=slot, stream_id=stream_id)
for burst in (1, 2, 3, 4):
w.feed_group_burst(src, burst, rf_src=rf_src, dst=dst, peer=peer, slot=slot, stream_id=stream_id)
w.feed_group_terminator(src, rf_src=rf_src, dst=dst, peer=peer, slot=slot, stream_id=stream_id)
def _full_unit_call(w, src, *, rf_src, dst, peer, slot, stream_id):
w.feed_unit_header(src, rf_src=rf_src, dst=dst, peer=peer, slot=slot, stream_id=stream_id)
for burst in (1, 2, 3, 4):
w.feed_unit_burst(src, burst, rf_src=rf_src, dst=dst, peer=peer, slot=slot, stream_id=stream_id)
w.feed_unit_terminator(src, rf_src=rf_src, dst=dst, peer=peer, slot=slot, stream_id=stream_id)
def _hex(captures):
return [[name, pkt.hex()] for (name, pkt) in captures]
# ---- scenarios ----
def hbp_to_hbp_same_slot():
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('REPEATER-1', 1, 3100)]})
_full_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(3100),
peer=bytes_4(312000), slot=1, stream_id=b'\x00\x00\x00\x11')
return _hex(w.captures)
def hbp_to_hbp_cross_slot():
# Source on TS1, target subscribed on TS2 -> exercises the TS-bit flip.
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('REPEATER-1', 2, 3100)]})
_full_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(3100),
peer=bytes_4(312000), slot=1, stream_id=b'\x00\x00\x00\x12')
return _hex(w.captures)
def hbp_to_hbp_tgid_rewrite():
# Target subscribed on a different TGID -> destination TGID is rewritten.
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('REPEATER-1', 1, 31665)]})
_full_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(3100),
peer=bytes_4(312000), slot=1, stream_id=b'\x00\x00\x00\x13')
return _hex(w.captures)
def hbp_to_obp():
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('OBP-1', 1, 3100)]})
_full_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(3100),
peer=bytes_4(312000), slot=1, stream_id=b'\x00\x00\x00\x14')
return _hex(w.captures)
def obp_to_hbp():
w = harness.World({'B': [_member('OBP-1', 1, 3100), _member('SERVER-1', 1, 3100)]})
_full_call(w, 'OBP-1', rf_src=bytes_3(1234), dst=bytes_3(3100),
peer=bytes_4(3129100), slot=1, stream_id=b'\x00\x00\x00\x15')
return _hex(w.captures)
def obp_to_obp():
w = harness.World({'B': [_member('OBP-1', 1, 3100), _member('OBP-2', 1, 3100)]})
_full_call(w, 'OBP-1', rf_src=bytes_3(1234), dst=bytes_3(3100),
peer=bytes_4(3129100), slot=1, stream_id=b'\x00\x00\x00\x16')
return _hex(w.captures)
def hbp_multi_target():
# One source bridged to an HBP outbound system, a second HBP server, and an OBP.
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('REPEATER-1', 1, 3100),
_member('SERVER-2', 1, 3100), _member('OBP-1', 1, 3100)]})
_full_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(3100),
peer=bytes_4(312000), slot=1, stream_id=b'\x00\x00\x00\x17')
return _hex(w.captures)
def hbp_to_hbp_no_voice_header():
# First frame is a burst (no voice header) -> the LC is synthesized from the
# header fields rather than decoded, exercising the other src_lc path.
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('REPEATER-1', 1, 3100)]})
kw = dict(rf_src=bytes_3(312000), dst=bytes_3(3100), peer=bytes_4(312000),
slot=1, stream_id=b'\x00\x00\x00\x1a')
w.feed_group_burst('SERVER-1', 1, **kw)
w.feed_group_burst('SERVER-1', 2, **kw)
w.feed_group_terminator('SERVER-1', **kw)
return _hex(w.captures)
def obp_to_obp_no_voice_header():
w = harness.World({'B': [_member('OBP-1', 1, 3100), _member('OBP-2', 1, 3100)]})
kw = dict(rf_src=bytes_3(1234), dst=bytes_3(3100), peer=bytes_4(3129100),
slot=1, stream_id=b'\x00\x00\x00\x1b')
w.feed_group_burst('OBP-1', 1, **kw)
w.feed_group_terminator('OBP-1', **kw)
return _hex(w.captures)
def hbp_contention_second_stream_blocked():
# Two streams on the same TGID/slot; the second must not produce a duplicate
# forwarded call while the first is active.
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('REPEATER-1', 1, 3100)]})
a = dict(rf_src=bytes_3(312000), dst=bytes_3(3100), peer=bytes_4(312000),
slot=1, stream_id=b'\x00\x00\x00\x18')
b = dict(rf_src=bytes_3(312999), dst=bytes_3(3100), peer=bytes_4(312000),
slot=1, stream_id=b'\x00\x00\x00\x19')
w.feed_group_header('SERVER-1', **a)
w.feed_group_burst('SERVER-1', 1, **a)
w.feed_group_header('SERVER-1', **b) # contends with the active call
w.feed_group_burst('SERVER-1', 1, **b)
return _hex(w.captures)
def unit_hbp_to_hbp():
w = harness.World({}, unit_systems=['SERVER-1'], extra_systems=['REPEATER-1'])
w.seed_unit_map(bytes_3(2080), 'REPEATER-1')
_full_unit_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(2080),
peer=bytes_4(312000), slot=1, stream_id=b'\x00\x00\x00\x21')
return _hex(w.captures)
def unit_hbp_to_obp():
w = harness.World({}, unit_systems=['SERVER-1'], extra_systems=['OBP-1'])
w.seed_unit_map(bytes_3(2080), 'OBP-1')
_full_unit_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(2080),
peer=bytes_4(312000), slot=1, stream_id=b'\x00\x00\x00\x22')
return _hex(w.captures)
def unit_hbp_to_obp_ts2():
# Unit on TS2 to an OBP (BOTH_SLOTS False) -> slot bit cleared on egress.
w = harness.World({}, unit_systems=['SERVER-1'], extra_systems=['OBP-1'])
w.seed_unit_map(bytes_3(2080), 'OBP-1')
_full_unit_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(2080),
peer=bytes_4(312000), slot=2, stream_id=b'\x00\x00\x00\x23')
return _hex(w.captures)
def unit_obp_to_hbp():
w = harness.World({}, unit_systems=[], extra_systems=['OBP-1', 'SERVER-1'])
w.seed_unit_map(bytes_3(2080), 'SERVER-1')
_full_unit_call(w, 'OBP-1', rf_src=bytes_3(1234), dst=bytes_3(2080),
peer=bytes_4(3129100), slot=1, stream_id=b'\x00\x00\x00\x24')
return _hex(w.captures)
def unit_obp_to_obp():
w = harness.World({}, unit_systems=[], extra_systems=['OBP-1', 'OBP-2'])
w.seed_unit_map(bytes_3(2080), 'OBP-2')
_full_unit_call(w, 'OBP-1', rf_src=bytes_3(1234), dst=bytes_3(2080),
peer=bytes_4(3129100), slot=1, stream_id=b'\x00\x00\x00\x25')
return _hex(w.captures)
SCENARIOS = {
'hbp_to_hbp_same_slot': hbp_to_hbp_same_slot,
'hbp_to_hbp_cross_slot': hbp_to_hbp_cross_slot,
'hbp_to_hbp_tgid_rewrite': hbp_to_hbp_tgid_rewrite,
'hbp_to_obp': hbp_to_obp,
'obp_to_hbp': obp_to_hbp,
'obp_to_obp': obp_to_obp,
'hbp_to_hbp_no_voice_header': hbp_to_hbp_no_voice_header,
'obp_to_obp_no_voice_header': obp_to_obp_no_voice_header,
'hbp_multi_target': hbp_multi_target,
'hbp_contention_second_stream_blocked': hbp_contention_second_stream_blocked,
'unit_hbp_to_hbp': unit_hbp_to_hbp,
'unit_hbp_to_obp': unit_hbp_to_obp,
'unit_hbp_to_obp_ts2': unit_hbp_to_obp_ts2,
'unit_obp_to_hbp': unit_obp_to_hbp,
'unit_obp_to_obp': unit_obp_to_obp,
}
def run_all():
return {name: fn() for name, fn in sorted(SCENARIOS.items())}
if __name__ == '__main__':
import json
print(json.dumps(run_all(), indent=2))
+126
View File
@@ -0,0 +1,126 @@
# Self-contained config for the routing test harness. Defines two HBP servers,
# one HBP outbound peer, and two OpenBridges so scenarios can exercise every
# source/target combination (HBP<->HBP, HBP<->OBP, OBP<->OBP). Ports/IPs are
# never bound -- the harness drives dmrd_received directly and captures
# send_system.
[GLOBAL]
PATH: ./
PING_TIME: 5
MAX_MISSED: 3
USE_ACL: False
REG_ACL: PERMIT:ALL
SUB_ACL: PERMIT:ALL
TGID_TS1_ACL: PERMIT:ALL
TGID_TS2_ACL: PERMIT:ALL
[REPORTS]
REPORT: False
REPORT_INTERVAL: 60
REPORT_PORT: 4321
REPORT_CLIENTS: 127.0.0.1
[LOGGER]
LOG_FILE: /dev/null
LOG_HANDLERS: null
LOG_LEVEL: CRITICAL
LOG_NAME: HBlink
[ALIASES]
TRY_DOWNLOAD: False
PATH: ./
PEER_FILE: peer_ids.json
SUBSCRIBER_FILE: subscriber_ids.json
TGID_FILE: talkgroup_ids.json
PEER_URL: https://www.radioid.net/static/rptrs.json
SUBSCRIBER_URL: https://www.radioid.net/static/users.json
STALE_DAYS: 7
[SERVER-1]
MODE: SERVER
ENABLED: True
REPEAT: True
MAX_REPEATERS: 10
IP: 0.0.0.0
PORT: 54001
PASSPHRASE: passw0rd
GROUP_HANGTIME: 5
USE_ACL: False
REG_ACL: PERMIT:ALL
SUB_ACL: PERMIT:ALL
TGID_TS1_ACL: PERMIT:ALL
TGID_TS2_ACL: PERMIT:ALL
[SERVER-2]
MODE: SERVER
ENABLED: True
REPEAT: True
MAX_REPEATERS: 10
IP: 0.0.0.0
PORT: 54002
PASSPHRASE: passw0rd
GROUP_HANGTIME: 5
USE_ACL: False
REG_ACL: PERMIT:ALL
SUB_ACL: PERMIT:ALL
TGID_TS1_ACL: PERMIT:ALL
TGID_TS2_ACL: PERMIT:ALL
[REPEATER-1]
MODE: OUTBOUND
ENABLED: True
LOOSE: False
IP: 0.0.0.0
PORT: 54003
SERVER_IP: 127.0.0.1
SERVER_PORT: 54001
PASSPHRASE: homebrew
CALLSIGN: W1ABC
RADIO_ID: 312000
RX_FREQ: 449000000
TX_FREQ: 444000000
TX_POWER: 25
COLORCODE: 1
SLOTS: 1
LATITUDE: 38.0000
LONGITUDE: -095.0000
HEIGHT: 75
LOCATION: Anywhere, USA
DESCRIPTION: harness peer
URL: www.w1abc.org
SOFTWARE_ID: 20170620
PACKAGE_ID: MMDVM_HBlink
GROUP_HANGTIME: 5
OPTIONS:
USE_ACL: False
SUB_ACL: PERMIT:ALL
TGID_TS1_ACL: PERMIT:ALL
TGID_TS2_ACL: PERMIT:ALL
[OBP-1]
MODE: OPENBRIDGE
ENABLED: True
IP: 0.0.0.0
PORT: 62031
NETWORK_ID: 3129100
PASSPHRASE: password
TARGET_IP: 127.0.0.1
TARGET_PORT: 62041
BOTH_SLOTS: False
USE_ACL: False
SUB_ACL: PERMIT:ALL
TGID_ACL: PERMIT:ALL
[OBP-2]
MODE: OPENBRIDGE
ENABLED: True
IP: 0.0.0.0
PORT: 62032
NETWORK_ID: 3129200
PASSPHRASE: password
TARGET_IP: 127.0.0.1
TARGET_PORT: 62042
BOTH_SLOTS: False
USE_ACL: False
SUB_ACL: PERMIT:ALL
TGID_ACL: PERMIT:ALL
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python
#
# Frame-replay test harness for the bridge routing core.
#
# It stands up the REAL routerHBP / routerOBP instances against a small config,
# drives synthetic DMRD frames through their routing entry point (dmrd_received),
# and captures the packets each system would emit (send_system). A controllable
# clock makes contention/hangtime deterministic.
#
# Purpose: characterize the current routing behavior and, during the planned
# unification, run identical frame sequences through the old and new code and
# assert byte-identical emissions -- so OBP multi-stream behavior and HBP slot
# locking can be proven preserved without a live over-the-air network.
#
# This module is scaffolding, not a test itself; see test_harness_smoke.py.
import os
import sys
import logging
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import bridge
import config
# Keep the routers quiet during replay (they log heavily at INFO). Raise only
# their own logger levels rather than logging.disable() -- a global disable would
# also defeat assertLogs() checks in other test modules. setLevel is fine because
# assertLogs temporarily lowers the level within its own context.
for _name in ('bridge', 'hblink'):
logging.getLogger(_name).setLevel(logging.CRITICAL)
# Self-contained test topology: two HBP masters, one HBP peer, two OpenBridges.
TEST_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'harness.cfg')
# Bit-field constants for assembling the DMRD flags byte (see datagramReceived).
_FT_VOICE = 0 # HBPF_VOICE
_FT_DATA_SYNC = 2 # HBPF_DATA_SYNC
_VHEAD = 1 # HBPF_SLT_VHEAD
_VTERM = 2 # HBPF_SLT_VTERM
class Clock:
"""A manually advanced stand-in for time.time()."""
def __init__(self, start=1_000_000.0):
self.t = start
def __call__(self):
return self.t
def tick(self, seconds):
self.t += seconds
return self.t
def mk_dmrd(seq, rf_src, dst, peer, slot, call_type, frame_type, dtype_vseq,
stream_id, dmrpkt=None):
"""Assemble a 55-byte DMRD packet (33-byte payload + 2 BER/RSSI bytes)."""
if dmrpkt is None:
dmrpkt = bytes((i * 5 + 1) & 0xFF for i in range(33))
assert len(rf_src) == 3 and len(dst) == 3 and len(peer) == 4 and len(stream_id) == 4
assert len(dmrpkt) == 33
bits = 0
if slot == 2:
bits |= 0x80
if call_type == 'unit':
bits |= 0x40
bits |= (frame_type & 0x3) << 4
bits |= (dtype_vseq & 0xF)
return b''.join([b'DMRD', bytes([seq]), rf_src, dst, peer,
bytes([bits]), stream_id, dmrpkt, b'\x00\x00'])
class World:
"""A small routing world: a set of systems joined to bridges, with a clock
and an emission-capture log."""
def __init__(self, bridges, unit_systems=None, extra_systems=None):
# Fresh, isolated module state for the bridge module.
self.clock = Clock()
bridge.time = self.clock # monkeypatch the clock the routers read
self.CONFIG = config.build_config(TEST_CFG)
self.CONFIG['REPORTS']['REPORT'] = False
bridge.CONFIG = self.CONFIG
# Aliases used only for logging; empty is fine (get_alias returns the id).
bridge.subscriber_ids = {}
bridge.peer_ids = {}
bridge.talkgroup_ids = {}
bridge.UNIT_MAP = {}
bridge.UNIT = list(unit_systems or [])
# Build the BRIDGES structure and indexes from the supplied spec.
bridge.BRIDGES = bridge.make_bridges(bridges)
bridge.BRIDGE_SRC_INDEX, bridge.BRIDGE_BY_SYSTEM = bridge.index_bridges(bridge.BRIDGES)
# Instantiate the real router objects for every system referenced.
self.captures = []
bridge.systems.clear()
names = set(unit_systems or []) | set(extra_systems or [])
for b in bridges:
for member in bridges[b]:
names.add(member['SYSTEM'])
for name in names:
mode = self.CONFIG['SYSTEMS'][name]['MODE']
if mode == 'OPENBRIDGE':
obj = bridge.routerOBP(name, self.CONFIG, None)
else:
obj = bridge.routerHBP(name, self.CONFIG, None)
# The router only forwards to an OUTBOUND system once it has
# logged into its upstream server; the harness models connected,
# participating systems, so mark outbound systems as connected.
if mode == 'OUTBOUND':
obj._stats['CONNECTION'] = 'YES'
# Capture what the routing core hands to each system to transmit,
# instead of writing to a real UDP transport.
obj.send_system = self._make_capture(name)
bridge.systems[name] = obj
# The STATUS tables were initialized with the current clock value. Advance
# well past any hangtime/stream window so the first real frame isn't seen
# as colliding with construction-time state (matches "started, then idle").
self.clock.tick(3600.0)
def _make_capture(self, name):
def _capture(_packet):
self.captures.append((name, bytes(_packet)))
return _capture
def feed(self, src, *, rf_src, dst, peer, slot, stream_id, call_type,
frame_type, dtype_vseq, seq=0):
"""Drive one DMRD frame into src's routing entry point."""
data = mk_dmrd(seq, rf_src, dst, peer, slot, call_type,
frame_type, dtype_vseq, stream_id)
bridge.systems[src].dmrd_received(
peer, rf_src, dst, seq, slot, call_type,
frame_type, dtype_vseq, stream_id, data)
def feed_group_header(self, src, **kw):
self.feed(src, call_type='group', frame_type=_FT_DATA_SYNC,
dtype_vseq=_VHEAD, **kw)
def feed_group_terminator(self, src, **kw):
self.feed(src, call_type='group', frame_type=_FT_DATA_SYNC,
dtype_vseq=_VTERM, **kw)
def feed_group_burst(self, src, burst, **kw):
self.feed(src, call_type='group', frame_type=_FT_VOICE,
dtype_vseq=burst, **kw)
def feed_unit_header(self, src, **kw):
self.feed(src, call_type='unit', frame_type=_FT_DATA_SYNC,
dtype_vseq=_VHEAD, **kw)
def feed_unit_terminator(self, src, **kw):
self.feed(src, call_type='unit', frame_type=_FT_DATA_SYNC,
dtype_vseq=_VTERM, **kw)
def feed_unit_burst(self, src, burst, **kw):
self.feed(src, call_type='unit', frame_type=_FT_VOICE,
dtype_vseq=burst, **kw)
def seed_unit_map(self, subscriber, system):
"""Register that `subscriber` (3 bytes) was last heard on `system`, so a
unit call to it routes there."""
bridge.UNIT_MAP[subscriber] = (system, self.clock())
def drain(self):
"""Return and clear captured emissions as a list of (system, bytes)."""
out = self.captures[:]
self.captures.clear()
return out
def emitted_to(self, system):
return [pkt for (name, pkt) in self.captures if name == system]
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python
#
# Live UDP traffic harness: logs into a running HBlink3 SERVER as a HomeBrew
# peer and injects DMRD voice streams over a real socket. Unlike tests/harness.py
# (which drives dmrd_received in-process), this exercises the full server path --
# socket -> server handshake -> ACL -> bridge routing -> contention -> reporting --
# so it can drive the dashboard with realistic traffic.
#
# It is deliberately CONTROLLABLE so we can tell expected contention from a bug:
#
# default one clean, fully-terminated call at a time -> expect ZERO
# collisions in the bridge log.
# --concurrent K fire K overlapping streams (distinct stream-ids) on the
# same TGID/slot at once -> SHOULD collide (correct DMR).
# --no-term omit the voice terminator (simulate a dropped tail) ->
# leaves the target slot "busy"; the NEXT call should then
# collide until the stream times out. This is the stuck-
# stream condition the supersession / 2s-timeout fix targets.
#
# Usage (against the sample hblink.cfg SERVER-1):
# venv/bin/python tests/live_harness.py --calls 5
# venv/bin/python tests/live_harness.py --concurrent 3 --calls 3
# venv/bin/python tests/live_harness.py --no-term --calls 3 --gap 1
import argparse
import random
import socket
import sys
import time
from binascii import a2b_hex as bhex
from hashlib import sha256
# --- wire constants (mirror const.py) ---
DMRD = b'DMRD'
RPTL = b'RPTL'
RPTK = b'RPTK'
RPTC = b'RPTC'
RPTACK = b'RPTACK'
MSTNAK = b'MSTNAK'
RPTPING = b'RPTPING'
_FT_VOICE = 0 # HBPF_VOICE
_FT_DATA_SYNC = 2 # HBPF_DATA_SYNC
_VHEAD = 1 # HBPF_SLT_VHEAD
_VTERM = 2 # HBPF_SLT_VTERM
def mk_dmrd(seq, rf_src, dst, peer, slot, call_type, frame_type, dtype_vseq, stream_id):
"""Assemble a 55-byte DMRD packet (33-byte payload + 2 BER/RSSI bytes)."""
dmrpkt = bytes((i * 5 + 1) & 0xFF for i in range(33))
bits = 0
if slot == 2:
bits |= 0x80
if call_type == 'unit':
bits |= 0x40
bits |= (frame_type & 0x3) << 4
bits |= (dtype_vseq & 0xF)
return b''.join([DMRD, bytes([seq & 0xFF]), rf_src, dst, peer,
bytes([bits]), stream_id, dmrpkt, b'\x00\x00'])
def b3(n): return n.to_bytes(3, 'big')
def b4(n): return n.to_bytes(4, 'big')
def login(sock, addr, peer_id, passphrase):
"""Run the HBP login handshake. Returns when CONNECTED or raises."""
def expect(prefix):
sock.settimeout(5.0)
data, _ = sock.recvfrom(1024)
if data[:len(MSTNAK)] == MSTNAK:
raise RuntimeError('server sent MSTNAK (login refused) -- check ACL / peer id')
if data[:len(prefix)] != prefix:
raise RuntimeError('expected %r, got %r' % (prefix, data[:10]))
return data
sock.sendto(RPTL + b4(peer_id), addr)
ack = expect(RPTACK)
salt = ack[6:10]
h = bhex(sha256(salt + passphrase).hexdigest())
sock.sendto(RPTK + b4(peer_id) + h, addr)
expect(RPTACK)
# Repeater config blob (302 bytes total incl. RPTC + peer_id).
cfg = b''.join([
b'HRNS '[:8], # callsign [8:16]
b'449000000', # rx_freq [16:25]
b'444000000', # tx_freq [25:34]
b'25', # tx_power [34:36]
b'01', # colorcode [36:38]
b'38.00000', # lat [38:46]
b'-095.0000', # lon [46:55]
b'075', # height [55:58]
b'Test Harness '[:20], # location [58:78]
b'live_harness.py '[:19], # description [78:97]
b'1', # slots [97:98]
b' ' * 124, # url [98:222]
b'hblink-harness'.ljust(40), # software_id [222:262]
b''.ljust(40), # package_id [262:302]
])
sock.sendto(RPTC + b4(peer_id) + cfg, addr)
expect(RPTACK)
print('[harness] logged in as peer %d' % peer_id, flush=True)
def send_stream(sock, addr, peer_id, src, dst, slot, call_type, stream_id,
superframes, frame_ms, terminate, tail_vseq=None):
"""Send one DMR call and return the vseq of its last voice burst.
A *properly terminated* call always finishes a complete superframe (voice
bursts vseq 0..5) and then sends the terminator, so the last voice burst
before VTERM is always F (vseq 5) -- this is how real radios end a call.
A *lost tail* (terminate=False) stops mid-superframe with no terminator,
leaving the last burst at `tail_vseq` (random 0..5 if None). This models an
RF drop and is the case the stream-timeout / supersession END logic must
still catch -- including when the last burst lands on vseq 2 (== VTERM's
numeric value), which historically defeated the "already terminated" check.
"""
seq = [0]
def send(ft, vseq):
sock.sendto(mk_dmrd(seq[0], b3(src), b3(dst), b4(peer_id), slot, call_type,
ft, vseq, stream_id), addr)
seq[0] = (seq[0] + 1) & 0xFF
send(_FT_DATA_SYNC, _VHEAD)
if terminate:
for _ in range(superframes):
for vseq in range(6):
send(_FT_VOICE, vseq)
time.sleep(frame_ms / 1000.0)
send(_FT_DATA_SYNC, _VTERM)
return 5
# Lost tail: whole superframes, then a partial one ending at tail_vseq.
if tail_vseq is None:
tail_vseq = random.randint(0, 5)
for _ in range(max(0, superframes - 1)):
for vseq in range(6):
send(_FT_VOICE, vseq)
time.sleep(frame_ms / 1000.0)
for vseq in range(tail_vseq + 1):
send(_FT_VOICE, vseq)
time.sleep(frame_ms / 1000.0)
return tail_vseq
def main():
ap = argparse.ArgumentParser(description='Live DMRD traffic harness for HBlink3')
ap.add_argument('--server-ip', default='127.0.0.1')
ap.add_argument('--port', type=int, default=54000)
ap.add_argument('--passphrase', default='s3cr37w0rd')
ap.add_argument('--peer-id', type=int, default=312100)
ap.add_argument('--src', type=int, default=3121234, help='RF source (subscriber) id')
ap.add_argument('--tgid', type=int, default=1)
ap.add_argument('--slot', type=int, default=1, choices=(1, 2))
ap.add_argument('--call-type', default='group', choices=('group', 'unit'))
ap.add_argument('--calls', type=int, default=3, help='number of calls to place')
ap.add_argument('--duration', type=float, default=2.0, help='seconds of audio per call')
ap.add_argument('--gap', type=float, default=2.0, help='idle seconds between calls')
ap.add_argument('--frame-ms', type=float, default=60.0, help='inter-frame spacing (DMR=60ms)')
ap.add_argument('--concurrent', type=int, default=1,
help='overlapping streams per call on the same TGID/slot (collision test)')
ap.add_argument('--no-term', action='store_true',
help='lost tail: stop mid-superframe with no terminator (dropped-RF test)')
ap.add_argument('--tail-vseq', type=int, default=None, choices=range(6),
help='with --no-term, force the last burst vseq (e.g. 2 to reproduce the END-loss bug); random if unset')
args = ap.parse_args()
addr = (args.server_ip, args.port)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
login(sock, addr, args.peer_id, args.passphrase.encode())
# A superframe is 6 voice bursts (~360 ms). Round the requested audio
# duration to whole superframes so a clean call always ends on a full one.
superframes = max(1, round(args.duration / (6 * args.frame_ms / 1000.0)))
terminate = not args.no_term
# Random per-process stream-id base so concurrent harness instances (each a
# distinct simulated repeater) don't emit colliding stream-ids -- real radios
# pick random stream-ids, and identical ids would alias into one call.
sid = random.randint(0x10000000, 0xF0000000)
for c in range(args.calls):
if args.concurrent <= 1:
sid += 1
tail = send_stream(sock, addr, args.peer_id, args.src, args.tgid, args.slot,
args.call_type, b4(sid), superframes, args.frame_ms,
terminate, args.tail_vseq)
print('[harness] call %d/%d: src=%d tgid=%d ts=%d sid=%08x %s'
% (c + 1, args.calls, args.src, args.tgid, args.slot, sid,
'TERMINATED' if terminate else ('LOST TAIL, last burst vseq=%d' % tail)),
flush=True)
else:
# Interleave K distinct streams superframe-by-superframe so they
# genuinely overlap and contend for the one slot.
streams = []
for k in range(args.concurrent):
sid += 1
streams.append((b4(sid), args.src + k))
print('[harness] call %d/%d: %d OVERLAPPING streams on tgid=%d ts=%d %s'
% (c + 1, args.calls, args.concurrent, args.tgid, args.slot,
'TERMINATED' if terminate else 'LOST TAIL'), flush=True)
tails = {sidb: (args.tail_vseq if args.tail_vseq is not None else random.randint(0, 5))
for sidb, _ in streams}
seqs = {sidb: 0 for sidb, _ in streams}
def tx(sidb, s, ft, vseq):
sock.sendto(mk_dmrd(seqs[sidb], b3(s), b3(args.tgid), b4(args.peer_id),
args.slot, args.call_type, ft, vseq, sidb), addr)
seqs[sidb] = (seqs[sidb] + 1) & 0xFF
for sidb, s in streams:
tx(sidb, s, _FT_DATA_SYNC, _VHEAD)
for sf in range(superframes):
for vseq in range(6):
for sidb, s in streams:
# On a lost tail, a stream goes silent past its tail burst.
if terminate or sf < superframes - 1 or vseq <= tails[sidb]:
tx(sidb, s, _FT_VOICE, vseq)
time.sleep(args.frame_ms / 1000.0)
if terminate:
for sidb, s in streams:
tx(sidb, s, _FT_DATA_SYNC, _VTERM)
if c + 1 < args.calls:
# keep the link alive across the idle gap
sock.sendto(RPTPING + b4(args.peer_id), addr)
time.sleep(args.gap)
print('[harness] done', flush=True)
if __name__ == '__main__':
main()
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python
#
# Lossy-repeater simulator. Logs into a running HBlink3 SERVER as a HomeBrew
# repeater and sends keepalive pings with a deliberate drop rate, so you can watch
# the dashboard's per-repeater ping-loss % climb (and the callsign turn gold)
# WITHOUT needing a genuinely marginal RF link.
#
# How it stays honest:
# * It pings cleanly for a short warmup first, so the server calibrates this
# repeater's ping cadence before any loss is introduced (otherwise the very
# first big gap would be mistaken for the normal interval).
# * It never drops more than --max-consec pings in a row, so it can't trip
# MAX_MISSED and get disconnected -- it stays up and simply shows loss.
# * Keep --interval well under (PING_TIME * MAX_MISSED) seconds so even a run
# of drops stays inside the timeout.
#
# Point --port / --passphrase / --peer-id at a real SERVER the repeater may join
# (--peer-id just has to pass that server's REG_ACL). Use a peer-id that isn't one
# of your real repeaters so it shows up as its own row.
#
# venv/bin/python tests/lossy_repeater.py --port 54000 --passphrase s3cr37w0rd \
# --peer-id 3129999 --interval 5 --loss 0.25
#
# Ctrl-C to stop; the simulated repeater then times out and drops off on its own.
import argparse
import os
import random
import socket
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from live_harness import login, RPTPING, b4
def main():
ap = argparse.ArgumentParser(description='Simulate a lossy repeater (drops keepalive pings)')
ap.add_argument('--server-ip', default='127.0.0.1')
ap.add_argument('--port', type=int, default=54000, help="the SERVER system's port")
ap.add_argument('--passphrase', default='s3cr37w0rd')
ap.add_argument('--peer-id', type=int, default=3129999, help='repeater radio id (must pass REG_ACL)')
ap.add_argument('--interval', type=float, default=5.0,
help='seconds between pings; keep well under PING_TIME*MAX_MISSED')
ap.add_argument('--loss', type=float, default=0.25, help='fraction of pings to drop (0..0.9)')
ap.add_argument('--warmup', type=int, default=6,
help='clean pings first so the server calibrates the cadence')
ap.add_argument('--max-consec', type=int, default=2,
help='never drop more than this many in a row (avoids the MAX_MISSED timeout)')
args = ap.parse_args()
loss = max(0.0, min(0.9, args.loss))
addr = (args.server_ip, args.port)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
login(sock, addr, args.peer_id, args.passphrase.encode())
sock.setblocking(False)
ping = RPTPING + b4(args.peer_id)
sent = dropped = consec = n = 0
print('[lossy] peer %d pinging every %.1fs, offering ~%d%% loss (%d clean warmup pings). Ctrl-C to stop.'
% (args.peer_id, args.interval, int(loss * 100), args.warmup), flush=True)
try:
while True:
try: # drain MSTPONG replies
while True:
sock.recvfrom(1024)
except BlockingIOError:
pass
n += 1
drop = (n > args.warmup) and (consec < args.max_consec) and (random.random() < loss)
if drop:
dropped += 1
consec += 1
else:
sock.sendto(ping, addr)
sent += 1
consec = 0
if n % 6 == 0:
tot = sent + dropped
print('[lossy] sent=%d dropped=%d (~%d%% offered loss)'
% (sent, dropped, round(100 * dropped / tot) if tot else 0), flush=True)
time.sleep(args.interval)
except KeyboardInterrupt:
print('\n[lossy] stopping; the simulated repeater will time out and drop off shortly.', flush=True)
if __name__ == '__main__':
main()
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python
#
# Live OpenBridge (OBP) traffic harness for HBlink3.
#
# Unlike the HomeBrew peer harness (tests/live_harness.py), OpenBridge has no
# login handshake: each DMRD frame is a 53-byte DMR payload followed by a 20-byte
# HMAC-SHA1(passphrase, payload), and the receiver only accepts packets whose
# SOURCE address matches its configured TARGET_SOCK. So this harness BINDS the
# local socket to the OBP's TARGET_PORT and sends to the OBP's listen PORT.
#
# OpenBridge is NOT timeslot-bound: it carries an unlimited number of concurrent
# streams keyed by stream-id. --streams N fires N overlapping calls at once so
# you can see several simultaneous OBP activities (and, via bridging, the peer
# OBP sending them all out concurrently) -- in contrast to the slot-bound HBP
# side where only one call per slot survives.
#
# Example (matches the demo wiring this session set up):
# tests/obp_harness.py --bind-port 62045 --obp-port 62035 --streams 3 --calls 2
# tests/obp_harness.py --bind-port 62046 --obp-port 62036 --streams 3 --calls 2
import argparse
import random
import socket
import time
from hashlib import sha1
from hmac import new as hmac_new
DMRD = b'DMRD'
_FT_VOICE = 0
_FT_DATA_SYNC = 2
_VHEAD = 1
_VTERM = 2
def b3(n): return n.to_bytes(3, 'big')
def b4(n): return n.to_bytes(4, 'big')
def mk_obp(seq, rf_src, dst, peer, ft, vseq, sid, passphrase):
"""A 73-byte OpenBridge frame: 53-byte DMRD payload + 20-byte HMAC-SHA1.
OpenBridge calls are always slot 1 (no slot bit)."""
dmrpkt = bytes((i * 5 + 1) & 0xFF for i in range(33))
bits = (ft & 0x3) << 4 | (vseq & 0xF) # group, slot 1
data = b''.join([DMRD, bytes([seq & 0xFF]), b3(rf_src), b3(dst), b4(peer),
bytes([bits]), b4(sid), dmrpkt]) # 53 bytes
return data + hmac_new(passphrase, data, sha1).digest()
def main():
ap = argparse.ArgumentParser(description='Live OpenBridge traffic harness for HBlink3')
ap.add_argument('--server-ip', default='127.0.0.1')
ap.add_argument('--obp-port', type=int, required=True, help="the OBP system's listen PORT")
ap.add_argument('--bind-port', type=int, required=True, help="must equal that OBP's TARGET_PORT")
ap.add_argument('--passphrase', default='password')
ap.add_argument('--peer', type=int, default=3120, help='source network id (DMRD peer field)')
ap.add_argument('--src', type=int, default=3120001, help='RF source (subscriber) id')
ap.add_argument('--tgid', type=int, default=8)
ap.add_argument('--streams', type=int, default=3, help='concurrent overlapping streams (OBP is not slot-bound)')
ap.add_argument('--calls', type=int, default=3, help='number of call rounds')
ap.add_argument('--duration', type=float, default=2.0, help='seconds of audio per call')
ap.add_argument('--gap', type=float, default=1.5, help='idle seconds between rounds')
ap.add_argument('--frame-ms', type=float, default=60.0)
ap.add_argument('--no-term', action='store_true', help='lost tail: stop with no terminator')
ap.add_argument('--tail-vseq', type=int, default=None, choices=range(6))
args = ap.parse_args()
passphrase = args.passphrase.encode()
dst = (args.server_ip, args.obp_port)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('127.0.0.1', args.bind_port)) # source addr must match OBP TARGET_SOCK
sock.setblocking(False)
print('[obp] sending to OBP %s from :%d (passphrase %r)' % (dst, args.bind_port, args.passphrase), flush=True)
superframes = max(1, round(args.duration / (6 * args.frame_ms / 1000.0)))
terminate = not args.no_term
egress = [0]
def drain():
try:
while True:
sock.recv(2048)
egress[0] += 1
except BlockingIOError:
pass
sid = random.randint(0x10000000, 0xF0000000)
for c in range(args.calls):
streams = []
for k in range(args.streams):
sid += 1
tail = args.tail_vseq if args.tail_vseq is not None else random.randint(0, 5)
streams.append({'sid': sid, 'src': args.src + k, 'seq': 0, 'tail': tail})
print('[obp] round %d/%d: %d concurrent streams on TGID %d %s'
% (c + 1, args.calls, args.streams, args.tgid,
'TERMINATED' if terminate else 'LOST TAIL'), flush=True)
def tx(st, ft, vseq):
sock.sendto(mk_obp(st['seq'], st['src'], args.tgid, args.peer, ft, vseq, st['sid'], passphrase), dst)
st['seq'] = (st['seq'] + 1) & 0xFF
for st in streams:
tx(st, _FT_DATA_SYNC, _VHEAD)
for sf in range(superframes):
for vseq in range(6):
for st in streams:
if terminate or sf < superframes - 1 or vseq <= st['tail']:
tx(st, _FT_VOICE, vseq)
time.sleep(args.frame_ms / 1000.0)
drain()
if terminate:
for st in streams:
tx(st, _FT_DATA_SYNC, _VTERM)
if c + 1 < args.calls:
time.sleep(args.gap)
drain()
drain()
print('[obp] done (received %d egress frames back)' % egress[0], flush=True)
if __name__ == '__main__':
main()
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python
#
# Regression tests for HBSYSTEM.dmrd_acl_check(), the ACL helper extracted from
# the server and outbound receive paths in hblink.py during the 2026 cleanup.
# Pins both the PERMIT/DENY + per-slot matching contract and the "log each
# dropped stream only once per slot" dedup behavior.
#
# Run from the repo root: venv/bin/python -m unittest discover -s tests
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import random
import const
import config
import hblink
from dmr_utils3.utils import bytes_3, bytes_4
# Reference implementation of the ORIGINAL linear ACL semantics, used to prove
# the bisect-based acl_check is behavior-identical.
def _ref_build(_acl, _max):
if not _acl:
return (True, [(const.ID_MIN, _max)])
action = (_acl.split(':')[0] == 'PERMIT')
ranges = []
for entry in _acl.split(':')[1].split(','):
if entry == 'ALL':
return (action, [(const.ID_MIN, _max)])
elif '-' in entry:
s, e = entry.split('-')
ranges.append((int(s), int(e)))
else:
ranges.append((int(entry), int(entry)))
return (action, ranges)
def _ref_check(_id, _ref):
action, ranges = _ref
for s, e in ranges:
if s <= _id <= e:
return action
return not action
def _acl(_str):
return config.acl_build(_str, const.ID_MAX)
def _aclset(use, sub='PERMIT:ALL', tg1='PERMIT:ALL', tg2='PERMIT:ALL'):
return {'USE_ACL': use, 'SUB_ACL': _acl(sub),
'TG1_ACL': _acl(tg1), 'TG2_ACL': _acl(tg2)}
class _FakeSystem:
# Borrow the real method under test, unbound, onto a minimal host object so
# we don't have to stand up a full Twisted DatagramProtocol.
dmrd_acl_check = hblink.HBSYSTEM.dmrd_acl_check
def __init__(self, global_acl, system_acl):
self._CONFIG = {'GLOBAL': global_acl}
self._config = system_acl
self._system = 'TEST'
self._laststrid = {1: b'', 2: b''}
SID = b'\x11\x22\x33\x44'
SID2 = b'\xaa\xbb\xcc\xdd'
class TestAclMatching(unittest.TestCase):
def test_permit_all_allows_both_slots(self):
sysobj = _FakeSystem(_aclset(True), _aclset(True))
self.assertFalse(sysobj.dmrd_acl_check(bytes_3(1234), bytes_3(9), 1, SID))
self.assertFalse(sysobj.dmrd_acl_check(bytes_3(1234), bytes_3(9), 2, SID))
def test_global_subscriber_deny_drops_only_matching_sub(self):
sysobj = _FakeSystem(_aclset(True, sub='DENY:1'), _aclset(True))
self.assertTrue(sysobj.dmrd_acl_check(bytes_3(1), bytes_3(9), 1, SID))
self.assertFalse(sysobj.dmrd_acl_check(bytes_3(2), bytes_3(9), 1, SID))
def test_global_tg1_acl_is_slot1_only(self):
sysobj = _FakeSystem(_aclset(True, tg1='DENY:9'), _aclset(True))
# Denied on TS1...
self.assertTrue(sysobj.dmrd_acl_check(bytes_3(1234), bytes_3(9), 1, SID))
# ...but TS2 is governed by TG2_ACL (PERMIT:ALL here), so it passes
self.assertFalse(sysobj.dmrd_acl_check(bytes_3(1234), bytes_3(9), 2, SID))
def test_global_tg2_acl_is_slot2_only(self):
sysobj = _FakeSystem(_aclset(True, tg2='DENY:9'), _aclset(True))
self.assertTrue(sysobj.dmrd_acl_check(bytes_3(1234), bytes_3(9), 2, SID))
self.assertFalse(sysobj.dmrd_acl_check(bytes_3(1234), bytes_3(9), 1, SID))
def test_system_acl_applies_when_global_disabled(self):
sysobj = _FakeSystem(_aclset(False, sub='DENY:1'),
_aclset(True, sub='DENY:5'))
# Global is off so its DENY:1 is ignored; system DENY:5 still applies
self.assertFalse(sysobj.dmrd_acl_check(bytes_3(1), bytes_3(9), 1, SID))
self.assertTrue(sysobj.dmrd_acl_check(bytes_3(5), bytes_3(9), 1, SID))
def test_both_disabled_allows_everything(self):
sysobj = _FakeSystem(_aclset(False, sub='DENY:1'),
_aclset(False, sub='DENY:1'))
self.assertFalse(sysobj.dmrd_acl_check(bytes_3(1), bytes_3(9), 1, SID))
class TestAclDedup(unittest.TestCase):
def test_allow_does_not_touch_laststrid(self):
sysobj = _FakeSystem(_aclset(True), _aclset(True))
sysobj.dmrd_acl_check(bytes_3(1234), bytes_3(9), 1, SID)
self.assertEqual(sysobj._laststrid[1], b'')
def test_drop_records_stream_id_for_its_slot(self):
sysobj = _FakeSystem(_aclset(True, sub='DENY:1'), _aclset(True))
sysobj.dmrd_acl_check(bytes_3(1), bytes_3(9), 2, SID)
self.assertEqual(sysobj._laststrid[2], SID)
self.assertEqual(sysobj._laststrid[1], b'')
def test_same_stream_logs_once_new_stream_logs_again(self):
sysobj = _FakeSystem(_aclset(True, sub='DENY:1'), _aclset(True))
with self.assertLogs('hblink', level='INFO') as cm:
# Same dropped stream three times -> a single log line
for _ in range(3):
self.assertTrue(sysobj.dmrd_acl_check(bytes_3(1), bytes_3(9), 1, SID))
# A new stream id -> logs again
self.assertTrue(sysobj.dmrd_acl_check(bytes_3(1), bytes_3(9), 1, SID2))
drops = [m for m in cm.output if 'CALL DROPPED' in m]
self.assertEqual(len(drops), 2)
class TestAclCheckEquivalence(unittest.TestCase):
# The bisect-based acl_check must match the original linear semantics for
# every ACL shape: permit/deny, singles, ranges, overlapping/adjacent
# ranges, ALL, and large enumerated lists.
SPECS = [
'PERMIT:ALL',
'DENY:1',
'DENY:1-100',
'PERMIT:3100-3200',
'DENY:9,10,3129',
'PERMIT:1-5,3-10,100', # overlapping ranges
'DENY:1-10,11-20', # adjacent ranges
'PERMIT:50,40,30,20,10', # unsorted singles
'DENY:5-5', # degenerate range == single
]
def test_matches_linear_reference(self):
probe = [0, 1, 2, 5, 9, 10, 11, 20, 50, 100, 101, 3099, 3129, 3200, 3201,
const.ID_MAX, const.ID_MAX + 1]
for spec in self.SPECS:
built = config.acl_build(spec, const.ID_MAX)
ref = _ref_build(spec, const.ID_MAX)
for i in probe:
self.assertEqual(
hblink.acl_check(bytes_4(i), built),
_ref_check(i, ref),
'spec={!r} id={}'.format(spec, i))
def test_large_enumerated_acl_matches_reference(self):
random.seed(42)
ids = random.sample(range(1, const.ID_MAX), 800)
spec = 'DENY:' + ','.join(str(i) for i in ids)
built = config.acl_build(spec, const.ID_MAX)
ref = _ref_build(spec, const.ID_MAX)
denied = set(ids)
for i in ids[:200] + random.sample(range(1, const.ID_MAX), 200):
self.assertEqual(hblink.acl_check(bytes_4(i), built), _ref_check(i, ref))
# spot check the actual intent too: listed ids are denied
self.assertEqual(hblink.acl_check(bytes_4(i), built), i not in denied)
def test_ranges_are_merged_and_disjoint(self):
action, singles, starts, ends = config.acl_build('DENY:1-10,5-20,22', const.ID_MAX)
self.assertEqual(starts, (1,))
self.assertEqual(ends, (20,))
self.assertIn(22, singles)
def test_blank_acl_permits_all(self):
built = config.acl_build('', const.ID_MAX)
self.assertTrue(hblink.acl_check(bytes_4(1), built))
self.assertTrue(hblink.acl_check(bytes_4(const.ID_MAX), built))
if __name__ == '__main__':
unittest.main()
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python
#
# Golden-master test: replays the fixed scenarios in baseline_scenarios.py
# through the current routing core and asserts the emitted packets match the
# frozen baseline in baseline_emissions.json byte-for-byte.
#
# This is the safety net for the routing refactor: regenerate the baseline ONLY
# from known-good code (python tests/baseline_scenarios.py shows current output;
# the file was frozen from the pre-refactor code). After the forward_group
# extraction and the later unification, this test must still pass unchanged.
#
# Run from the repo root: venv/bin/python -m unittest discover -s tests
import json
import os
import sys
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE))
sys.path.insert(0, _HERE)
import baseline_scenarios
BASELINE = os.path.join(_HERE, 'baseline_emissions.json')
class TestRoutingBaseline(unittest.TestCase):
@classmethod
def setUpClass(cls):
with open(BASELINE) as f:
cls.expected = json.load(f)
def test_scenarios_match_frozen_baseline(self):
# Same set of scenarios as when the baseline was frozen.
self.assertEqual(set(baseline_scenarios.SCENARIOS),
set(self.expected),
'scenario set drifted from the frozen baseline')
for name in sorted(baseline_scenarios.SCENARIOS):
with self.subTest(scenario=name):
got = baseline_scenarios.SCENARIOS[name]()
# JSON round-trips tuples to lists; normalize for comparison.
got = [[s, h] for (s, h) in got]
self.assertEqual(got, self.expected[name],
'emissions changed for scenario %r' % name)
if __name__ == '__main__':
unittest.main()
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python
#
# Regression tests for call-END emission on HBP (server/outbound) systems.
#
# Background: a slot's "stream has ended" state used to be inferred from
# RX_TYPE/TX_TYPE == HBPF_SLT_VTERM. But those fields hold the last frame's
# _dtype_vseq, which for voice bursts cycles 0..5 and so collides with
# HBPF_SLT_VTERM (== 2). Any stream whose last received burst was vseq 2 looked
# "already terminated", so its END was never emitted -- not on a clean
# terminator, not by the stream-timeout trimmer, not by new-stream supersession.
# The fix replaces that inference with explicit RX_TERMINATED / TX_TERMINATED
# flags. These tests pin the behavior, with the vseq-2 case as the key guard.
import os
import sys
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE))
sys.path.insert(0, _HERE)
from dmr_utils3.utils import bytes_3, bytes_4
import bridge
import harness
def _member(system, ts, tgid):
return {'SYSTEM': system, 'TS': ts, 'TGID': tgid, 'ACTIVE': True,
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []}
class _Report:
"""Captures the newline-delimited bridge events a system would emit."""
def __init__(self):
self.events = []
def send_bridge_event(self, data):
self.events.append(data.decode())
def _world():
"""A two-HBP-system world with reporting enabled and captured."""
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('REPEATER-1', 1, 3100)]})
w.CONFIG['REPORTS']['REPORT'] = True
rpt = _Report()
for sysobj in bridge.systems.values():
sysobj._report = rpt
return w, rpt
def _ends(rpt, ct='GROUP VOICE', direction='RX'):
prefix = '{},END,{},'.format(ct, direction)
return [e for e in rpt.events if e.startswith(prefix)]
# Common call parameters for SERVER-1 sourcing on TS1/TGID3100.
_KW = dict(rf_src=bytes_3(312000), dst=bytes_3(3100), peer=bytes_4(312000), slot=1)
class TestGroupEnd(unittest.TestCase):
def test_clean_call_emits_end(self):
"""A properly terminated call (ends on a full superframe) emits an END."""
w, rpt = _world()
sid = b'\x00\x00\x00\x01'
w.feed_group_header('SERVER-1', stream_id=sid, **_KW)
for burst in range(6): # full superframe, last burst vseq 5
w.feed_group_burst('SERVER-1', burst, stream_id=sid, **_KW)
w.feed_group_terminator('SERVER-1', stream_id=sid, **_KW)
self.assertEqual(len(_ends(rpt)), 1)
def test_lost_tail_vseq2_times_out_with_end(self):
"""The regression: a dropped-RF call whose last burst is vseq 2 (== VTERM)
must still get an END from the stream-timeout trimmer."""
w, rpt = _world()
sid = b'\x00\x00\x00\x02'
w.feed_group_header('SERVER-1', stream_id=sid, **_KW)
for burst in (0, 1, 2): # lost tail, last burst vseq 2, no VTERM
w.feed_group_burst('SERVER-1', burst, stream_id=sid, **_KW)
self.assertEqual(_ends(rpt), []) # nothing yet -- call still "open"
w.clock.tick(bridge.STREAM_TIMEOUT + 1) # past the no-audio timeout
bridge.stream_trimmer_loop()
self.assertEqual(len(_ends(rpt)), 1)
def test_lost_tail_vseq5_times_out_with_end(self):
"""Control: a lost tail ending on vseq 5 also times out with an END."""
w, rpt = _world()
sid = b'\x00\x00\x00\x03'
w.feed_group_header('SERVER-1', stream_id=sid, **_KW)
for burst in (0, 1, 2, 3, 4, 5):
w.feed_group_burst('SERVER-1', burst, stream_id=sid, **_KW)
w.clock.tick(bridge.STREAM_TIMEOUT + 1)
bridge.stream_trimmer_loop()
self.assertEqual(len(_ends(rpt)), 1)
def test_supersession_emits_end_for_stuck_vseq2_stream(self):
"""A new stream taking the slot while a vseq-2 lost tail is stuck must
synthesize the prior call's END before starting the new one."""
w, rpt = _world()
old = b'\x00\x00\x00\x04'
w.feed_group_header('SERVER-1', stream_id=old, **_KW)
for burst in (0, 1, 2): # stuck lost tail, last burst vseq 2
w.feed_group_burst('SERVER-1', burst, stream_id=old, **_KW)
self.assertEqual(_ends(rpt), [])
# A different subscriber keys up on the same slot -> supersession.
new = b'\x00\x00\x00\x05'
kw2 = dict(_KW, rf_src=bytes_3(312999))
w.clock.tick(bridge.STREAM_TO + 0.1) # past contention window
w.feed_group_header('SERVER-1', stream_id=new, **kw2)
self.assertEqual(len(_ends(rpt)), 1) # the stuck call was ended
def test_no_duplicate_end_on_clean_call(self):
"""A cleanly terminated call must not also get a timeout/supersession END."""
w, rpt = _world()
sid = b'\x00\x00\x00\x06'
w.feed_group_header('SERVER-1', stream_id=sid, **_KW)
for burst in range(6):
w.feed_group_burst('SERVER-1', burst, stream_id=sid, **_KW)
w.feed_group_terminator('SERVER-1', stream_id=sid, **_KW)
w.clock.tick(bridge.STREAM_TIMEOUT + 1)
bridge.stream_trimmer_loop()
self.assertEqual(len(_ends(rpt)), 1) # exactly one, not two
class TestCollisionLogging(unittest.TestCase):
def test_collision_logged_once_per_stream(self):
"""A contending stream is reported once, not once per colliding frame."""
w, _ = _world()
owner = b'\x00\x00\x00\x10'
w.feed_group_header('SERVER-1', stream_id=owner, **_KW)
w.feed_group_burst('SERVER-1', 1, stream_id=owner, **_KW)
collider = dict(_KW, rf_src=bytes_3(312999)) # different subscriber
cid = b'\x00\x00\x00\x11'
with self.assertLogs('bridge', level='WARNING') as cm:
w.feed_group_header('SERVER-1', stream_id=cid, **collider)
w.feed_group_burst('SERVER-1', 1, stream_id=cid, **collider)
w.feed_group_burst('SERVER-1', 2, stream_id=cid, **collider)
hits = [m for m in cm.output if 'collided with existing call' in m]
self.assertEqual(len(hits), 1)
if __name__ == '__main__':
unittest.main()
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python
#
# Smoke / characterization tests proving the frame-replay harness actually
# drives the real routing core and captures sensible emissions. These also
# document current behavior that the planned unification must preserve.
#
# Run from the repo root: venv/bin/python -m unittest discover -s tests
import os
import sys
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE)) # repo root
sys.path.insert(0, _HERE) # tests/ dir, for 'import harness'
from dmr_utils3.utils import bytes_3, bytes_4
import harness
# The sample config defines SERVER-1 (HBP master), REPEATER-1 (HBP peer) and
# OBP-1 (OpenBridge). Build bridges over them on TS1 / TGID 3100.
def _bridge_spec(systems):
return {
'TEST': [
{'SYSTEM': s, 'TS': 1, 'TGID': 3100, 'ACTIVE': True,
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []}
for s in systems
]
}
def _tgid_of(pkt):
return pkt[8:11]
def _stream_of(pkt):
return pkt[16:20]
class TestHarnessDrivesRoutingCore(unittest.TestCase):
def test_group_call_hbp_to_hbp_is_forwarded(self):
w = harness.World(_bridge_spec(['SERVER-1', 'REPEATER-1']))
sid = b'\x00\x00\x00\x01'
kw = dict(rf_src=bytes_3(312000), dst=bytes_3(3100),
peer=bytes_4(312000), slot=1, stream_id=sid)
w.feed_group_header('SERVER-1', **kw)
w.feed_group_terminator('SERVER-1', **kw)
# The call should have been bridged to REPEATER-1 (and never echoed back
# to the source system).
to_repeater = w.emitted_to('REPEATER-1')
self.assertTrue(to_repeater, 'call was not bridged to the peer system')
self.assertEqual(w.emitted_to('SERVER-1'), [])
# Destination TGID preserved on the bridged copy.
self.assertEqual(_tgid_of(to_repeater[0]), bytes_3(3100))
self.assertEqual(_stream_of(to_repeater[0]), sid)
def test_group_call_hbp_to_obp_is_forwarded(self):
w = harness.World(_bridge_spec(['SERVER-1', 'OBP-1']))
sid = b'\x00\x00\x00\x02'
kw = dict(rf_src=bytes_3(312000), dst=bytes_3(3100),
peer=bytes_4(312000), slot=1, stream_id=sid)
w.feed_group_header('SERVER-1', **kw)
w.feed_group_terminator('SERVER-1', **kw)
self.assertTrue(w.emitted_to('OBP-1'), 'call was not bridged to OBP')
def test_two_concurrent_obp_source_streams_both_forward(self):
# THE case the naive "OBP-as-TS1" unification would break: two different
# talkgroups arriving concurrently from an OBP trunk must BOTH bridge out.
# TGID 3100 routes to SERVER-1, TGID 3200 routes to REPEATER-1, so the two
# interleaved OBP streams have independent destinations and both forward.
spec = {
'B3100': [
{'SYSTEM': 'OBP-1', 'TS': 1, 'TGID': 3100, 'ACTIVE': True,
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []},
{'SYSTEM': 'SERVER-1', 'TS': 1, 'TGID': 3100, 'ACTIVE': True,
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []},
],
'B3200': [
{'SYSTEM': 'OBP-1', 'TS': 1, 'TGID': 3200, 'ACTIVE': True,
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []},
{'SYSTEM': 'REPEATER-1', 'TS': 1, 'TGID': 3200, 'ACTIVE': True,
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []},
],
}
w = harness.World(spec)
a = b'\x00\x00\x0A\x0A'
b = b'\x00\x00\x0B\x0B'
# Interleave the two streams' headers -- the OBP STATUS dict must hold both.
w.feed_group_header('OBP-1', rf_src=bytes_3(1111), dst=bytes_3(3100),
peer=bytes_4(3129100), slot=1, stream_id=a)
w.feed_group_header('OBP-1', rf_src=bytes_3(2222), dst=bytes_3(3200),
peer=bytes_4(3129100), slot=1, stream_id=b)
src = bridge_status('OBP-1')
self.assertIn(a, src, 'first concurrent OBP stream not tracked')
self.assertIn(b, src, 'second concurrent OBP stream not tracked')
# Each stream reached its own destination, concurrently.
self.assertEqual([_stream_of(p) for p in w.emitted_to('SERVER-1')], [a])
self.assertEqual([_stream_of(p) for p in w.emitted_to('REPEATER-1')], [b])
def test_clock_is_controllable(self):
w = harness.World(_bridge_spec(['SERVER-1', 'REPEATER-1']))
t0 = w.clock()
w.clock.tick(5.0)
self.assertEqual(w.clock() - t0, 5.0)
def bridge_status(system):
import bridge
return bridge.systems[system].STATUS
if __name__ == '__main__':
unittest.main()
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python
#
# Regression tests for the Link Control helpers extracted from bridge.py during
# the 2026 cleanup: gen_lcs() and embed_lc(). These pin the behavior that all
# four group-routing paths depend on, since live OBP/HBP audio cannot be
# reproduced in a test environment.
#
# Run from the repo root: venv/bin/python -m unittest discover -s tests
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from bitarray import bitarray
from dmr_utils3 import bptc
import bridge
from const import (
HBPF_VOICE, HBPF_DATA_SYNC, HBPF_SLT_VHEAD, HBPF_SLT_VTERM, LC_OPT,
)
def _bits(_b):
ba = bitarray(endian='big')
ba.frombytes(_b)
return ba
# A representative destination LC: 3 option bytes + 3 TGID bytes + 3 source bytes
DST_LC = LC_OPT + b'\x00\x00\x09' + b'\x30\x40\x50'
# A fixed, non-trivial 33-byte DMR payload (264 bits)
DMRPKT = bytes((i * 7 + 3) & 0xFF for i in range(33))
class TestGenLcs(unittest.TestCase):
def test_returns_header_terminator_embedded_in_order(self):
h, t, e = bridge.gen_lcs(DST_LC)
self.assertEqual(h, bptc.encode_header_lc(DST_LC))
self.assertEqual(t, bptc.encode_terminator_lc(DST_LC))
self.assertEqual(e, bptc.encode_emblc(DST_LC))
def test_embedded_lc_has_bursts_1_through_4(self):
_, _, e = bridge.gen_lcs(DST_LC)
for burst in (1, 2, 3, 4):
self.assertEqual(len(e[burst]), 32,
'embedded LC burst {} must be 32 bits'.format(burst))
class TestEmbedLc(unittest.TestCase):
def setUp(self):
self.h, self.t, self.e = bridge.gen_lcs(DST_LC)
def test_voice_header_injects_full_lc_and_preserves_voice(self):
out = _bits(bridge.embed_lc(DMRPKT, HBPF_DATA_SYNC, HBPF_SLT_VHEAD,
self.h, self.t, self.e))
# Full LC replaces the outer bits; the middle voice/sync bits survive
self.assertEqual(out[0:98], self.h[0:98])
self.assertEqual(out[98:166], _bits(DMRPKT)[98:166])
self.assertEqual(out[166:], self.h[98:197])
def test_voice_terminator_injects_full_lc_and_preserves_voice(self):
out = _bits(bridge.embed_lc(DMRPKT, HBPF_DATA_SYNC, HBPF_SLT_VTERM,
self.h, self.t, self.e))
self.assertEqual(out[0:98], self.t[0:98])
self.assertEqual(out[98:166], _bits(DMRPKT)[98:166])
self.assertEqual(out[166:], self.t[98:197])
def test_burst_injects_embedded_lc_and_preserves_voice(self):
# dtype 1-4 with a non-DATA_SYNC frame type takes the embedded-LC path
for burst in (1, 2, 3, 4):
out = _bits(bridge.embed_lc(DMRPKT, HBPF_VOICE, burst,
self.h, self.t, self.e))
self.assertEqual(out[0:116], _bits(DMRPKT)[0:116],
'burst {}: leading voice bits changed'.format(burst))
self.assertEqual(out[116:148], self.e[burst],
'burst {}: embedded LC not injected'.format(burst))
self.assertEqual(out[148:264], _bits(DMRPKT)[148:264],
'burst {}: trailing voice bits changed'.format(burst))
def test_other_frames_pass_through_unchanged(self):
# dtype 0 (and 5) are neither full-LC nor embedded-LC frames
for dtype in (0, 5):
self.assertEqual(
bridge.embed_lc(DMRPKT, HBPF_VOICE, dtype, self.h, self.t, self.e),
DMRPKT,
'dtype {} should pass through unchanged'.format(dtype))
def test_is_pure_does_not_mutate_input(self):
src = bytearray(DMRPKT)
bridge.embed_lc(bytes(src), HBPF_DATA_SYNC, HBPF_SLT_VHEAD,
self.h, self.t, self.e)
self.assertEqual(bytes(src), DMRPKT)
if __name__ == '__main__':
unittest.main()
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python
#
# Tests for expand_obp_bridges(): the per-OBP TGID<->bridge table (rules.OBP_BRIDGES)
# that replaces inline OpenBridge bridge membership. Covers expansion into synthetic
# members, the TS default + override, and the load-time validation (ingress-fork
# ERROR, inter-OBP renumber WARNING, inline-OBP-member ERROR, non-OBP system ERROR).
#
# Run from the repo root: venv/bin/python -m unittest discover -s tests
import copy
import os
import sys
import unittest
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _ROOT)
sys.path.insert(0, os.path.join(_ROOT, 'tools'))
import bridge
import migrate_obp_rules
def _member(system, ts, tgid):
# A plain inline (non-OBP) bridge member, pre-make_bridges (raw int TGID).
return {'SYSTEM': system, 'TS': ts, 'TGID': tgid, 'ACTIVE': True,
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []}
class TestExpandOBPBridges(unittest.TestCase):
def setUp(self):
bridge.CONFIG = {'SYSTEMS': {
'VESTA_OBP': {'MODE': 'OPENBRIDGE'},
'CC_OBP': {'MODE': 'OPENBRIDGE'},
'NEIGHBOR': {'MODE': 'OPENBRIDGE'},
'REPEATERS': {'MODE': 'SERVER'},
}}
def test_noop_without_obp_table(self):
bridges = {'B1': [_member('REPEATERS', 1, 2)]}
out = bridge.expand_obp_bridges(bridges, {})
self.assertEqual(len(out['B1']), 1)
self.assertEqual(out['B1'][0]['SYSTEM'], 'REPEATERS')
def test_expands_and_appends_synthetic_member(self):
bridges = {'B1': [_member('REPEATERS', 1, 2)]}
bridge.expand_obp_bridges(bridges, {'VESTA_OBP': {'B1': 2}})
self.assertEqual(len(bridges['B1']), 2) # appended alongside REPEATERS
obp = bridges['B1'][1]
self.assertEqual(obp['SYSTEM'], 'VESTA_OBP')
self.assertEqual(obp['TGID'], 2)
self.assertEqual(obp['TS'], 1) # default
self.assertTrue(obp['ACTIVE'])
self.assertEqual(obp['TO_TYPE'], 'NONE') # triggers hard-wired inert
self.assertEqual((obp['ON'], obp['OFF'], obp['RESET']), ([], [], []))
def test_ts_override_tuple(self):
bridges = {}
bridge.expand_obp_bridges(bridges, {'VESTA_OBP': {'KS-STATEWIDE': (3120, 2)}})
m = bridges['KS-STATEWIDE'][0]
self.assertEqual((m['TGID'], m['TS']), (3120, 2))
def test_creates_obp_only_bridge(self):
bridges = {}
bridge.expand_obp_bridges(bridges, {'VESTA_OBP': {'TRANSIT': 31}})
self.assertIn('TRANSIT', bridges)
self.assertEqual(bridges['TRANSIT'][0]['SYSTEM'], 'VESTA_OBP')
def test_ingress_fork_is_error(self):
# One TGID mapped to two bridges on the SAME OBP -> stream duplication.
with self.assertRaises(SystemExit):
bridge.expand_obp_bridges({}, {'VESTA_OBP': {'B1': 2, 'B2': 2}})
def test_unknown_obp_system_is_error(self):
with self.assertRaises(SystemExit):
bridge.expand_obp_bridges({}, {'NOPE': {'B1': 2}})
def test_non_openbridge_system_is_error(self):
# REPEATERS exists but is a SERVER, not an OPENBRIDGE.
with self.assertRaises(SystemExit):
bridge.expand_obp_bridges({}, {'REPEATERS': {'B1': 2}})
def test_inline_obp_member_is_error(self):
# Old-style: an OBP system left as an inline BRIDGES member.
bridges = {'B1': [_member('VESTA_OBP', 1, 2)]}
with self.assertRaises(SystemExit):
bridge.expand_obp_bridges(bridges, {})
def test_same_tgid_across_obps_no_warning(self):
# Two OBPs agree on the number -> no renumber, no warning.
bridges = {}
with self.assertNoLogs(bridge.logger, level='WARNING'):
bridge.expand_obp_bridges(bridges, {
'VESTA_OBP': {'B1': 2},
'CC_OBP': {'B1': 2},
})
self.assertEqual(len(bridges['B1']), 2)
def test_renumber_across_obps_warns_but_starts(self):
# A bridge carrying different TGIDs on two OBPs: WARNING, not ERROR.
bridges = {}
with self.assertLogs(bridge.logger, level='WARNING') as cm:
bridge.expand_obp_bridges(bridges, {
'VESTA_OBP': {'KS-STATEWIDE': 3120},
'NEIGHBOR': {'KS-STATEWIDE': 8},
})
self.assertTrue(any('renumbers TGID' in line for line in cm.output))
self.assertEqual(len(bridges['KS-STATEWIDE']), 2) # both still added
class TestMigrateOBPRules(unittest.TestCase):
"""The migration tool (tools/migrate_obp_rules.py) must move inline OBP members
into OBP_BRIDGES while preserving the exact routing."""
OBP_SYSTEMS = {'VESTA_OBP', 'CC_OBP'}
def setUp(self):
bridge.CONFIG = {'SYSTEMS': {
'VESTA_OBP': {'MODE': 'OPENBRIDGE'},
'CC_OBP': {'MODE': 'OPENBRIDGE'},
'REPEATERS': {'MODE': 'SERVER'},
'IPSC': {'MODE': 'SERVER'},
}}
def _old_bridges(self):
# Old inline form: OBP systems mixed in as ordinary members.
return {
'B2': [_member('REPEATERS', 1, 2), _member('IPSC', 1, 2), _member('VESTA_OBP', 1, 2)],
'B9': [_member('REPEATERS', 1, 9), _member('VESTA_OBP', 1, 9), _member('CC_OBP', 1, 9)],
'B8': [_member('CC_OBP', 1, 8)], # OBP-only -> should drop from BRIDGES
'BSW': [_member('REPEATERS', 2, 3120), _member('VESTA_OBP', 1, 3120)],
}
def _routing_projection(self, bridges, obp_bridges=None):
# {(system, ts, tgid_bytes): sorted[bridge names]} -- identity-independent.
# obp_bridges=None => old inline path (no expand); a dict => new expanded path.
b = copy.deepcopy(bridges)
if obp_bridges is not None:
b = bridge.expand_obp_bridges(b, obp_bridges)
src, _ = bridge.index_bridges(bridge.make_bridges(b))
return {k: sorted(hit[0] for hit in v) for k, v in src.items()}
def test_roundtrip_preserves_routing(self):
old = self._old_bridges()
# OLD routing: inline OBP members, no OBP_BRIDGES table.
want = self._routing_projection(old)
new_bridges, obp_bridges, moved, errors, warnings = migrate_obp_rules.migrate(
copy.deepcopy(old), self.OBP_SYSTEMS)
self.assertFalse(errors)
# NEW routing: trimmed BRIDGES + expanded OBP_BRIDGES.
got = self._routing_projection(new_bridges, obp_bridges)
self.assertEqual(got, want)
def test_moves_and_drops_obp_only_bridge(self):
new_bridges, obp_bridges, moved, errors, warnings = migrate_obp_rules.migrate(
copy.deepcopy(self._old_bridges()), self.OBP_SYSTEMS)
self.assertEqual(len(moved), 5) # VESTA_OBP x3 + CC_OBP x2
self.assertNotIn('B8', new_bridges) # OBP-only bridge dropped from BRIDGES
self.assertEqual(obp_bridges['CC_OBP']['B8'], 8) # but present in the table
self.assertEqual(obp_bridges['VESTA_OBP']['B2'], 2)
def test_ts_override_becomes_tuple(self):
old = {'BSW': [_member('VESTA_OBP', 2, 3120)]} # TS 2 (non-default)
_, obp_bridges, _, _, _ = migrate_obp_rules.migrate(old, {'VESTA_OBP'})
self.assertEqual(obp_bridges['VESTA_OBP']['BSW'], (3120, 2))
def test_detects_ingress_fork(self):
old = {'B1': [_member('VESTA_OBP', 1, 2)], 'B2': [_member('VESTA_OBP', 1, 2)]}
_, _, _, errors, _ = migrate_obp_rules.migrate(old, {'VESTA_OBP'})
self.assertTrue(errors)
def test_detects_renumber(self):
old = {'BSW': [_member('VESTA_OBP', 1, 3120), _member('CC_OBP', 1, 8)]}
_, _, _, _, warnings = migrate_obp_rules.migrate(old, {'VESTA_OBP', 'CC_OBP'})
self.assertTrue(warnings)
if __name__ == '__main__':
unittest.main()
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python
#
# Regression test: the router must not forward bridged call traffic to a PEER
# system that is not currently logged into its upstream master. Doing so would
# fire DMRD at a master that drops it (we're not registered) and falsely show as
# an active forward on the dashboard.
import os
import sys
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE))
sys.path.insert(0, _HERE)
from dmr_utils3.utils import bytes_3, bytes_4
import bridge
import harness
def _member(system, ts, tgid):
return {'SYSTEM': system, 'TS': ts, 'TGID': tgid, 'ACTIVE': True,
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []}
_KW = dict(rf_src=bytes_3(312000), dst=bytes_3(3100), peer=bytes_4(312000), slot=1)
class TestPeerEgress(unittest.TestCase):
def _world(self):
# SERVER-1 (source) bridged to REPEATER-1, which is MODE PEER in harness.cfg.
return harness.World({'B': [_member('SERVER-1', 1, 3100),
_member('REPEATER-1', 1, 3100)]})
def test_connected_peer_receives_forward(self):
w = self._world() # harness marks the peer connected
self.assertEqual(bridge.systems['REPEATER-1']._stats['CONNECTION'], 'YES')
w.feed_group_header('SERVER-1', stream_id=b'\x00\x00\x00\x40', **_KW)
w.feed_group_burst('SERVER-1', 0, stream_id=b'\x00\x00\x00\x40', **_KW)
self.assertTrue(w.emitted_to('REPEATER-1'))
def test_disconnected_peer_target_skipped(self):
w = self._world()
bridge.systems['REPEATER-1']._stats['CONNECTION'] = 'NO' # not logged in
w.feed_group_header('SERVER-1', stream_id=b'\x00\x00\x00\x41', **_KW)
for burst in (0, 1, 2):
w.feed_group_burst('SERVER-1', burst, stream_id=b'\x00\x00\x00\x41', **_KW)
self.assertEqual(w.emitted_to('REPEATER-1'), []) # nothing forwarded
if __name__ == '__main__':
unittest.main()
+426
View File
@@ -0,0 +1,426 @@
#!/usr/bin/env python
#
# Regression tests for the asyncio migration's protocol mechanics -- the parts
# the routing harness does not exercise: the server login handshake state
# machine, the reporting server's netstring framing, and that the UDP send paths
# use transport.sendto(). Driven against mock transports (no real sockets).
#
# Run from the repo root: venv/bin/python -m unittest discover -s tests
import asyncio
import json
import logging
import os
import sys
import unittest
from hashlib import sha256
from binascii import a2b_hex as bhex
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE))
sys.path.insert(0, _HERE)
import config
import hblink
# Quiet the protocol's INFO chatter (assertLogs elsewhere still works, since it
# temporarily lowers the level within its own context).
logging.getLogger('hblink').setLevel(logging.CRITICAL)
from dmr_utils3.utils import bytes_3, bytes_4
from const import RPTL, RPTK, RPTC, RPTACK, MSTNAK, DMRD, HBPF_SLT_VHEAD, HBPF_SLT_VTERM
CFG = config.build_config(os.path.join(_HERE, 'harness.cfg'))
class MockDatagramTransport:
"""Captures sendto(data, addr) like an asyncio DatagramTransport."""
def __init__(self):
self.sent = []
def sendto(self, data, addr=None):
self.sent.append((bytes(data), addr))
class MockStreamTransport:
"""Captures write(data) like an asyncio (TCP) Transport."""
def __init__(self, peername=('127.0.0.1', 50001)):
self.written = bytearray()
self._peername = peername
self.closed = False
def write(self, data):
self.written += data
def get_extra_info(self, key):
return self._peername if key == 'peername' else None
def close(self):
self.closed = True
class TestServerHandshake(unittest.TestCase):
"""Drive a SERVER through RPTL -> RPTK -> RPTC over datagram_received and
assert the state machine and responses, exercising the asyncio rename
(datagram_received) and transport.sendto path."""
def setUp(self):
self.server = hblink.HBSYSTEM('SERVER-1', CFG, None)
self.server.transport = MockDatagramTransport() # bypass connection_made's task
self.peer_id = bytes_4(312000)
self.addr = ('127.0.0.1', 50000)
self.passphrase = CFG['SYSTEMS']['SERVER-1']['PASSPHRASE']
def test_full_login_exchange(self):
m = self.server
# 1) Login request -> challenge
m.datagram_received(RPTL + self.peer_id, self.addr)
self.assertIn(self.peer_id, m._repeaters)
self.assertEqual(m._repeaters[self.peer_id]['CONNECTION'], 'CHALLENGE_SENT')
self.assertEqual(m.transport.sent[-1][0][:len(RPTACK)], RPTACK)
# 2) Answer the challenge with the correct hash -> WAITING_CONFIG
salt = bytes_4(m._repeaters[self.peer_id]['SALT'])
calc = bhex(sha256(salt + self.passphrase).hexdigest())
m.datagram_received(RPTK + self.peer_id + calc, self.addr)
self.assertEqual(m._repeaters[self.peer_id]['CONNECTION'], 'WAITING_CONFIG')
self.assertEqual(m.transport.sent[-1][0], RPTACK + self.peer_id)
# 3) Send configuration -> connected
rptc = RPTC + self.peer_id + b'TEST ' + b'\x00' * 300
m.datagram_received(rptc, self.addr)
self.assertEqual(m._repeaters[self.peer_id]['CONNECTION'], 'YES')
self.assertEqual(m.transport.sent[-1][0], RPTACK + self.peer_id)
def test_wrong_passphrase_is_rejected(self):
m = self.server
m.datagram_received(RPTL + self.peer_id, self.addr)
bad = bhex(sha256(b'\x00\x00\x00\x00' + b'wrong').hexdigest())
m.datagram_received(RPTK + self.peer_id + bad, self.addr)
# Peer removed and a NAK sent
self.assertNotIn(self.peer_id, m._repeaters)
self.assertEqual(m.transport.sent[-1][0][:6], MSTNAK)
class TestPeerEvents(unittest.TestCase):
"""The reconciliation sweep emits granular 'peer' connected/disconnected
events so the dashboard reflects repeater changes without the full push."""
def _server(self):
captured = []
class Rep:
def send_peer(self, system, radio_id, action, info=None):
captured.append((system, radio_id, action, info))
srv = hblink.HBSYSTEM('SERVER-1', CFG, Rep())
srv.transport = MockDatagramTransport()
return srv, captured
def _login(self, srv, radio_id=312000):
pid = bytes_4(radio_id)
addr = ('127.0.0.1', 50000)
passphrase = CFG['SYSTEMS']['SERVER-1']['PASSPHRASE']
srv.datagram_received(RPTL + pid, addr)
salt = bytes_4(srv._repeaters[pid]['SALT'])
calc = bhex(sha256(salt + passphrase).hexdigest())
srv.datagram_received(RPTK + pid + calc, addr)
srv.datagram_received(RPTC + pid + b'TEST ' + b'\x00' * 300, addr)
return pid
def test_connected_event_on_login(self):
srv, cap = self._server()
self._login(srv)
conn = [c for c in cap if c[2] == 'connected']
self.assertEqual(len(conn), 1)
self.assertEqual(conn[0][0], 'SERVER-1') # system
self.assertEqual(conn[0][1], 312000) # radio_id
self.assertIsNotNone(conn[0][3]) # info payload present
self.assertEqual(conn[0][3]['RADIO_ID'], 312000)
def test_no_duplicate_connected_event(self):
srv, cap = self._server()
self._login(srv)
srv.report_peer_deltas() # a later sweep, nothing changed
self.assertEqual(len([c for c in cap if c[2] == 'connected']), 1)
def test_disconnected_event_on_timeout(self):
srv, cap = self._server()
pid = self._login(srv)
srv._repeaters[pid]['LAST_PING'] = 0 # force the ping timeout
srv.server_maintenance_loop()
disc = [c for c in cap if c[2] == 'disconnected']
self.assertEqual(len(disc), 1)
self.assertEqual(disc[0][1], 312000)
class TestPingQuality(unittest.TestCase):
"""Per-repeater ping-loss %: self-calibrated cadence, decaying, cadence-agnostic."""
def _server(self):
srv = hblink.HBSYSTEM('SERVER-1', CFG, None)
srv.transport = MockDatagramTransport()
return srv
def _feed_pings(self, srv, pid, times):
# Drive _note_ping with an explicit sequence of arrival timestamps.
prev = times[0]
srv._note_ping(pid, times[0], 0) # first ping, seeds
for t in times[1:]:
srv._note_ping(pid, t, prev)
prev = t
def test_clean_pings_zero_loss(self):
srv = self._server()
pid = bytes_4(312000)
self._feed_pings(srv, pid, [i * 10.0 for i in range(30)]) # every 10s, no gaps
self.assertEqual(srv._ping_loss_pct(pid, 300.0), 0)
def test_slower_cadence_is_not_loss(self):
srv = self._server()
pid = bytes_4(312000)
# Pings every 30s (much slower than GLOBAL PING_TIME) must NOT read as loss.
self._feed_pings(srv, pid, [i * 30.0 for i in range(20)])
self.assertEqual(srv._ping_loss_pct(pid, 600.0), 0)
def test_dropped_pings_register_loss(self):
srv = self._server()
pid = bytes_4(312000)
# Establish a 10s cadence, then drop roughly 1 of every 3 pings.
times = [i * 10.0 for i in range(10)] # clean baseline
t = times[-1]
for k in range(20):
t += 30.0 if k % 3 == 0 else 10.0 # every 3rd interval misses 2 pings
times.append(t)
self._feed_pings(srv, pid, times)
loss = srv._ping_loss_pct(pid, times[-1])
self.assertGreater(loss, 10) # clearly lossy
self.assertLess(loss, 60)
def test_loss_registers_then_ages_out(self):
win = CFG['GLOBAL']['PING_LOSS_WINDOW'] * 60 # seconds
pid = bytes_4(312000)
# Clean warmup (calibrates base=10s), then a burst of dropped pings.
times = [i * 10.0 for i in range(6)]
t = times[-1]
for _ in range(5): # 5 drops: 20s gaps = lost pings
t += 20.0
times.append(t)
srv = self._server()
self._feed_pings(srv, pid, times)
self.assertGreater(srv._ping_loss_pct(pid, times[-1]), 0) # loss registered
# Same start, then clean pings filling more than the whole window: the old
# loss must fall out of the window and the figure return to 0.
loss_end = times[-1] # fix the bound BEFORE extending `times`
while t < loss_end + win + 30: # (appending inside made times[-1] track t -> infinite loop)
t += 10.0
times.append(t)
srv2 = self._server()
self._feed_pings(srv2, pid, times)
self.assertEqual(srv2._ping_loss_pct(pid, times[-1]), 0)
class MockWriteTransport:
"""Mimics enough of an asyncio Transport for ReportServer._send_json."""
def __init__(self, buffer_size=0):
self._buffer_size = buffer_size
def get_write_buffer_size(self):
return self._buffer_size
class MockAsyncWriter:
"""Mimics enough of an asyncio StreamWriter for ReportServer."""
def __init__(self, peername=('127.0.0.1', 51000), closing=False, buffer_size=0):
self.written = bytearray()
self._peername = peername
self._closing = closing
self.closed = False
self.transport = MockWriteTransport(buffer_size)
def write(self, data):
self.written += data
def is_closing(self):
return self._closing
def close(self):
self.closed = True
self._closing = True
def get_extra_info(self, key):
# 'socket' returns None so keepalive tuning is skipped in tests.
return {'peername': self._peername, 'socket': None}.get(key)
class MockAsyncReader:
"""Async reader that yields the given chunks then EOF (b'')."""
def __init__(self, chunks=()):
self._chunks = list(chunks)
async def read(self, _n):
return self._chunks.pop(0) if self._chunks else b''
class TestReportingNDJSON(unittest.TestCase):
# Exercises the asyncio ReportServer: the client ACL, and the write path's
# shedding of dead/unresponsive clients (is_closing() or an over-limit write
# buffer). Uses mock reader/writer objects -- no real sockets or config.
def _server(self):
return hblink.ReportServer({})
def test_send_json_emits_one_terminated_line(self):
srv = self._server()
w = MockAsyncWriter()
srv.clients.append(w)
srv._send_json({'type': 'ping', 'n': 1})
data = bytes(w.written)
self.assertTrue(data.endswith(b'\n'))
self.assertEqual(json.loads(data), {'type': 'ping', 'n': 1})
self.assertEqual(data.count(b'\n'), 1)
def test_send_json_sheds_closing_writer(self):
srv = self._server()
alive, dead = MockAsyncWriter(), MockAsyncWriter(closing=True)
srv.clients.extend([alive, dead])
srv._send_json({'type': 'ping'})
self.assertIn(alive, srv.clients)
self.assertNotIn(dead, srv.clients) # is_closing() -> shed
self.assertEqual(bytes(dead.written), b'')
def test_send_json_sheds_unresponsive_writer(self):
srv = self._server()
stuck = MockAsyncWriter(buffer_size=(2 << 20)) # over the 1 MiB limit
srv.clients.append(stuck)
srv._send_json({'type': 'ping'})
self.assertNotIn(stuck, srv.clients) # not reading -> shed and closed
self.assertTrue(stuck.closed)
def test_disallowed_client_is_closed(self):
srv = self._server()
w = MockAsyncWriter(peername=('10.9.9.9', 1234)) # not in allowed list
asyncio.run(srv._client_connected(MockAsyncReader(), w, allowed=['127.0.0.1']))
self.assertTrue(w.closed)
self.assertNotIn(w, srv.clients)
def test_json_systems_omits_secrets(self):
view = hblink.json_systems(CFG['SYSTEMS'])
for sysview in view.values():
self.assertNotIn('PASSPHRASE', sysview)
self.assertNotIn('SUB_ACL', sysview)
class TestBridgeStreamEvents(unittest.TestCase):
# BridgeReportServer.send_bridge_event() turns the CSV strings the routing
# code emits into JSON stream events. Capture _send_json to inspect them.
def _server(self):
import bridge
srv = bridge.BridgeReportServer({})
captured = []
srv._send_json = captured.append
return srv, captured
def test_start_csv_becomes_json(self):
srv, cap = self._server()
srv.send_bridge_event(b'GROUP VOICE,START,RX,SERVER-1,123,312000,3120001,1,3100')
self.assertEqual(cap[0], {
'type': 'stream_start', 'call_type': 'GROUP VOICE', 'action': 'START',
'trx': 'RX', 'system': 'SERVER-1', 'stream_id': 123, 'peer': 312000,
'src': 3120001, 'slot': 1, 'dst': 3100})
def test_end_csv_includes_duration(self):
srv, cap = self._server()
srv.send_bridge_event('GROUP VOICE,END,TX,SERVER-1,123,312000,3120001,2,3100,4.20')
self.assertEqual(cap[0]['type'], 'stream_end')
self.assertEqual(cap[0]['action'], 'END')
self.assertEqual(cap[0]['slot'], 2)
self.assertEqual(cap[0]['duration'], 4.20)
class TestTransportSend(unittest.TestCase):
def test_openbridge_send_uses_sendto_to_target(self):
obp = hblink.OPENBRIDGE('OBP-1', CFG, None)
obp.transport = MockDatagramTransport()
# 20-byte DMRD header + payload; send_system rewrites peer id and appends HMAC
pkt = DMRD + b'\x00' + b'\x11\x22\x33' + b'\x00\x00\x09' + b'\xaa\xbb\xcc\xdd' + b'\x00' + b'\x00\x00\x00\x01' + b'\x00' * 33
obp.send_system(pkt)
self.assertEqual(len(obp.transport.sent), 1)
data, addr = obp.transport.sent[0]
self.assertEqual(addr, (CFG['SYSTEMS']['OBP-1']['TARGET_IP'], CFG['SYSTEMS']['OBP-1']['TARGET_PORT']))
# NETWORK_ID was written into the peer-id slot [11:15]
self.assertEqual(data[11:15], CFG['SYSTEMS']['OBP-1']['NETWORK_ID'])
def test_outbound_send_server_uses_sendto(self):
peer = hblink.HBSYSTEM('REPEATER-1', CFG, None)
peer.transport = MockDatagramTransport()
pkt = DMRD + b'\x00' + b'\x11\x22\x33' + b'\x00\x00\x09' + b'\xaa\xbb\xcc\xdd' + b'\x00' + b'\x00\x00\x00\x01' + b'\x00' * 33
# A logged-in outbound system sends call traffic to its server via sendto.
peer._stats['CONNECTION'] = 'YES'
peer.send_server(pkt)
self.assertEqual(len(peer.transport.sent), 1)
_, addr = peer.transport.sent[0]
self.assertEqual(addr, CFG['SYSTEMS']['REPEATER-1']['SERVER_SOCKADDR'])
def test_outbound_send_server_drops_dmrd_when_not_connected(self):
peer = hblink.HBSYSTEM('REPEATER-1', CFG, None)
peer.transport = MockDatagramTransport()
peer._stats['CONNECTION'] = 'NO'
pkt = DMRD + b'\x00' + b'\x11\x22\x33' + b'\x00\x00\x09' + b'\xaa\xbb\xcc\xdd' + b'\x00' + b'\x00\x00\x00\x01' + b'\x00' * 33
peer.send_server(pkt)
self.assertEqual(peer.transport.sent, []) # call traffic dropped
# but login/keepalive packets still go out
peer.send_server(RPTL + b'\x00\x00\x00\x01')
self.assertEqual(len(peer.transport.sent), 1)
class TestSupersessionEnd(unittest.TestCase):
"""routerHBP._end_slot_stream emits a matching END for a slot's un-terminated
stream (superseded by a new stream, or timed out) so consumers don't leak it."""
def _router(self):
import bridge
cfg = {'REPORTS': {'REPORT': True}, 'SYSTEMS': CFG['SYSTEMS']}
bridge.CONFIG = cfg
captured = []
class Rep:
def send_bridge_event(self, data):
captured.append(data.decode() if isinstance(data, (bytes, bytearray)) else data)
return bridge.routerHBP('SERVER-1', cfg, Rep()), captured
def _activate(self, st, ct='GROUP VOICE'):
st['RX_TYPE'] = HBPF_SLT_VHEAD # an active (non-terminated) stream
st['RX_TERMINATED'] = False
st['RX_CT'] = ct
st['RX_STREAM_ID'] = bytes_4(12345)
st['RX_PEER'] = bytes_4(312100)
st['RX_RFS'] = bytes_3(3120001)
st['RX_TGID'] = bytes_3(3100)
st['RX_START'] = 100.0
st['RX_TIME'] = 104.2
def test_emits_end_and_marks_terminated(self):
r, cap = self._router()
self._activate(r.STATUS[1])
r._end_slot_stream(1)
self.assertEqual(cap, ['GROUP VOICE,END,RX,SERVER-1,12345,312100,3120001,1,3100,4.20'])
self.assertTrue(r.STATUS[1]['RX_TERMINATED'])
# idempotent: a second call (already terminated) emits nothing more
r._end_slot_stream(1)
self.assertEqual(len(cap), 1)
def test_unit_call_type_preserved(self):
r, cap = self._router()
self._activate(r.STATUS[2], ct='UNIT VOICE')
r._end_slot_stream(2)
self.assertTrue(cap[0].startswith('UNIT VOICE,END,RX,SERVER-1,12345,'))
def test_no_event_for_idle_slot(self):
r, cap = self._router()
r._end_slot_stream(1) # slot starts terminated (RX_TERMINATED == True)
self.assertEqual(cap, [])
if __name__ == '__main__':
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python
#
# Regression tests for the bridge routing indexes (index_bridges) added in the
# 2026 cleanup. They prove the O(1) indexed lookup selects exactly the same
# bridges/sources/targets as the original O(bridges x members) nested scan, for
# randomized rule sets -- the transformation that replaced the per-frame scan in
# routerOBP/routerHBP.group_received and the in-band signalling pass.
#
# Run from the repo root: venv/bin/python -m unittest discover -s tests
import os
import random
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import bridge
from dmr_utils3.utils import bytes_3
SYSTEMS = ['ALPHA', 'BRAVO', 'CHARLIE', 'DELTA']
TGIDS = [bytes_3(t) for t in (1, 9, 3100, 3129, 31665)]
SLOTS = [1, 2]
def _random_bridges(rng):
bridges = {}
for b in range(rng.randint(1, 6)):
members = []
for _ in range(rng.randint(0, 5)):
members.append({
'SYSTEM': rng.choice(SYSTEMS),
'TS': rng.choice(SLOTS),
'TGID': rng.choice(TGIDS),
'ACTIVE': rng.random() < 0.7,
})
bridges['BRIDGE{}'.format(b)] = members
return bridges
# Reference: the ORIGINAL nested-scan selection logic from group_received.
def _ref_targets(bridges, system, slot, dst):
out = []
for _bridge in bridges:
for _src in bridges[_bridge]:
if (_src['SYSTEM'] == system and _src['TGID'] == dst
and _src['TS'] == slot and _src['ACTIVE'] == True):
for _tgt in bridges[_bridge]:
if _tgt['SYSTEM'] != system and _tgt['ACTIVE']:
out.append((_bridge, id(_src), id(_tgt)))
return out
def _idx_targets(src_index, system, slot, dst):
out = []
for _bridge, _src, _members in src_index.get((system, slot, dst), ()):
if _src['ACTIVE'] == True:
for _tgt in _members:
if _tgt['SYSTEM'] != system and _tgt['ACTIVE']:
out.append((_bridge, id(_src), id(_tgt)))
return out
# Reference: the ORIGINAL nested-scan from the in-band signalling pass.
def _ref_by_system(bridges, system):
out = []
for _bridge in bridges:
for _sys in bridges[_bridge]:
if _sys['SYSTEM'] == system:
out.append((_bridge, id(_sys)))
return out
def _idx_by_system(by_system, system):
return [(_bridge, id(_sys)) for _bridge, _sys in by_system.get(system, ())]
class TestRoutingIndexEquivalence(unittest.TestCase):
def test_source_selection_matches_bruteforce(self):
rng = random.Random(2026)
for _ in range(300):
bridges = _random_bridges(rng)
src_index, _ = bridge.index_bridges(bridges)
for system in SYSTEMS:
for slot in SLOTS:
for dst in TGIDS:
self.assertEqual(
_idx_targets(src_index, system, slot, dst),
_ref_targets(bridges, system, slot, dst),
'mismatch for {} ts{} {!r}'.format(system, slot, dst))
def test_by_system_selection_matches_bruteforce(self):
rng = random.Random(7)
for _ in range(300):
bridges = _random_bridges(rng)
_, by_system = bridge.index_bridges(bridges)
for system in SYSTEMS:
self.assertEqual(_idx_by_system(by_system, system),
_ref_by_system(bridges, system))
def test_missing_key_yields_no_targets(self):
bridges = {'B': [{'SYSTEM': 'ALPHA', 'TS': 1, 'TGID': bytes_3(9), 'ACTIVE': True}]}
src_index, by_system = bridge.index_bridges(bridges)
self.assertEqual(src_index.get(('NOPE', 1, bytes_3(9)), []), [])
self.assertEqual(by_system.get('NOPE', []), [])
def test_index_uses_live_active_flag(self):
# The index references the live member dicts, so flipping ACTIVE after
# building must take effect without rebuilding.
member = {'SYSTEM': 'ALPHA', 'TS': 1, 'TGID': bytes_3(9), 'ACTIVE': True}
target = {'SYSTEM': 'BRAVO', 'TS': 1, 'TGID': bytes_3(9), 'ACTIVE': True}
bridges = {'B': [member, target]}
src_index, _ = bridge.index_bridges(bridges)
self.assertEqual(len(_idx_targets(src_index, 'ALPHA', 1, bytes_3(9))), 1)
target['ACTIVE'] = False
self.assertEqual(len(_idx_targets(src_index, 'ALPHA', 1, bytes_3(9))), 0)
if __name__ == '__main__':
unittest.main()
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python
#
# migrate_obp_rules.py -- one-shot migration for the OpenBridge config-model change.
#
# HBlink3 no longer configures OpenBridge (MODE: OPENBRIDGE) systems as inline
# BRIDGES members. They now live in a per-OBP table:
#
# OBP_BRIDGES = { <obp system> : { <bridge name> : <TGID> } } # TS 1, or (TGID, TS)
#
# This tool reads your existing hblink.cfg (to learn which systems are OPENBRIDGE)
# and your existing rules.py (old inline form), then:
# * lifts every inline OBP member out of BRIDGES into OBP_BRIDGES,
# * drops any bridge left with no members (OBP_BRIDGES recreates it at load),
# * flags the two problems the new loader would reject/warn on
# - a TGID mapped to >1 bridge on one OBP (ingress fork) -> ERROR
# - a bridge with different TGIDs across OBPs (renumber) -> WARNING
# * prints a summary of what moved, and writes a new rules file.
#
# It never overwrites your rules.py: output goes to a separate file you review
# and rename. Comments and commented-out members in the old file are NOT carried
# over (the tool works on the active, executed config), so re-add any notes by hand.
#
# venv/bin/python tools/migrate_obp_rules.py [--config hblink.cfg] [--rules rules.py] [--out rules.py.migrated]
#
# Exit status is non-zero if an ingress-fork ERROR was found (the output still
# gets written so you can see and fix it).
import argparse
import configparser
import importlib.util
import os
import sys
def _openbridge_systems(cfg_path):
"""Section names in hblink.cfg whose MODE is OPENBRIDGE."""
cp = configparser.ConfigParser()
if not cp.read(cfg_path):
sys.exit('ERROR: could not read config file: {}'.format(cfg_path))
obp = set()
for section in cp.sections():
if cp.has_option(section, 'MODE') and cp.get(section, 'MODE').strip().upper() == 'OPENBRIDGE':
obp.add(section)
return obp
def _load_rules(rules_path):
spec = importlib.util.spec_from_file_location('rules_old', rules_path)
mod = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(mod)
except Exception as e:
sys.exit('ERROR: could not import rules file {}: {}'.format(rules_path, e))
return mod
def _fmt_member(m):
return ("{{'SYSTEM': {!r}, 'TS': {}, 'TGID': {}, 'ACTIVE': {}, "
"'TIMEOUT': {}, 'TO_TYPE': {!r}, 'ON': {}, 'OFF': {}, 'RESET': {}}},").format(
m['SYSTEM'], m['TS'], m['TGID'], m['ACTIVE'],
m.get('TIMEOUT', 2), m.get('TO_TYPE', 'NONE'),
m.get('ON', []), m.get('OFF', []), m.get('RESET', []))
def migrate(bridges, obp_systems):
"""Return (new_bridges, obp_bridges, moved, errors, warnings)."""
new_bridges = {}
obp_bridges = {} # obp -> {bridge: tgid_or_(tgid, ts)}
moved = [] # (obp, bridge, tgid, ts)
errors = []
warnings = []
tgid_to_bridge = {} # (obp, tgid) -> bridge, ingress-fork check
bridge_obp_tgid = {} # bridge -> {obp: tgid}, renumber check
for bridge_name, members in bridges.items():
kept = []
for m in members:
if m['SYSTEM'] in obp_systems:
tgid, ts = m['TGID'], m.get('TS', 1)
key = (m['SYSTEM'], tgid)
if key in tgid_to_bridge and tgid_to_bridge[key] != bridge_name:
errors.append('OBP "{}" TGID {} maps to more than one bridge ("{}" and "{}") -- '
'ingress fork; keep only one before starting HBlink3'
.format(m['SYSTEM'], tgid, tgid_to_bridge[key], bridge_name))
tgid_to_bridge[key] = bridge_name
obp_bridges.setdefault(m['SYSTEM'], {})[bridge_name] = tgid if ts == 1 else (tgid, ts)
bridge_obp_tgid.setdefault(bridge_name, {})[m['SYSTEM']] = tgid
moved.append((m['SYSTEM'], bridge_name, tgid, ts))
else:
kept.append(m)
if kept:
new_bridges[bridge_name] = kept # drop bridges left empty (OBP_BRIDGES recreates them)
for bridge_name, obp_tgids in bridge_obp_tgid.items():
if len(set(obp_tgids.values())) > 1:
detail = ', '.join('{}={}'.format(o, t) for o, t in sorted(obp_tgids.items()))
warnings.append('bridge "{}" renumbers TGID across OpenBridges ({}) -- legal but unusual'
.format(bridge_name, detail))
return new_bridges, obp_bridges, moved, errors, warnings
def render(new_bridges, obp_bridges, unit):
L = []
L.append('# Generated by tools/migrate_obp_rules.py -- REVIEW before use.')
L.append('# OpenBridge members were moved from BRIDGES into OBP_BRIDGES; comments from')
L.append('# the original rules.py were not preserved.')
L.append('')
L.append('BRIDGES = {')
for bridge_name, members in new_bridges.items():
L.append(' {!r}: ['.format(bridge_name))
for m in members:
L.append(' ' + _fmt_member(m))
L.append(' ],')
L.append('}')
L.append('')
L.append('OBP_BRIDGES = {')
for obp, table in obp_bridges.items():
L.append(' {!r}: {{'.format(obp))
for bridge_name, val in table.items():
L.append(' {!r}: {},'.format(bridge_name, val))
L.append(' },')
L.append('}')
L.append('')
L.append('UNIT = {!r}'.format(list(unit)))
L.append('')
return '\n'.join(L)
def main():
ap = argparse.ArgumentParser(description='Migrate rules.py OpenBridge members to the OBP_BRIDGES table')
ap.add_argument('--config', default='hblink.cfg', help='hblink.cfg to read system MODEs from')
ap.add_argument('--rules', default='rules.py', help='existing (old-form) rules.py')
ap.add_argument('--out', default=None, help='output file (default: <rules>.migrated)')
args = ap.parse_args()
out_path = args.out or (args.rules + '.migrated')
obp_systems = _openbridge_systems(args.config)
rules = _load_rules(args.rules)
bridges = getattr(rules, 'BRIDGES', {})
unit = getattr(rules, 'UNIT', [])
if getattr(rules, 'OBP_BRIDGES', None):
print('NOTE: {} already defines OBP_BRIDGES; this looks migrated already.'.format(args.rules))
new_bridges, obp_bridges, moved, errors, warnings = migrate(bridges, obp_systems)
print('OpenBridge systems found in {}: {}'.format(args.config, ', '.join(sorted(obp_systems)) or '(none)'))
print('\n{} inline OBP member(s) moved into OBP_BRIDGES:'.format(len(moved)))
for obp, bridge_name, tgid, ts in moved:
print(' {:<12} {:<14} TGID {}{}'.format(obp, bridge_name, tgid, '' if ts == 1 else ' (TS {})'.format(ts)))
dropped = [b for b in bridges if b not in new_bridges]
if dropped:
print('\nBridges now defined only via OBP_BRIDGES (dropped from BRIDGES): {}'.format(', '.join(dropped)))
for w in warnings:
print('\nWARNING: ' + w)
for e in errors:
print('\nERROR: ' + e)
with open(out_path, 'w', encoding='utf-8') as f:
f.write(render(new_bridges, obp_bridges, unit))
print('\nWrote {}. Review it, then replace your rules.py with it.'.format(out_path))
return 1 if errors else 0
if __name__ == '__main__':
sys.exit(main())