The dashboard has built its callsign table from radioid.net since v4.7.0,
but the old manual process was still documented as usable. Nothing reads
user.csv, so an operator who followed it got a correct-looking file and a
dash in Last Heard. Remove the file, scripts/filter_user_csv.py and its
instructions.
min_rows_required rejected any filter result under 1000 rows, which ruled
out 153 of the 190 countries in the current data. Refuse only an empty
result, which means a misspelled country. Truncated or malformed downloads
still fail earlier. A valid but unexpectedly short file is now accepted
until the next good download. That leaves nothing in the fallback section
that is read, as keep_stale_on_failure never was, so remove it; existing
configs that carry it are unaffected.
The filter still defaults to the United States and Canada. Document how to
change it and when a change takes effect: at the next daily download, or
immediately by deleting the snapshot and meta files and restarting. Both
files have to go, because the meta file's timestamp suppresses the startup
refresh.
Reported in #7.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sanity limit matching HBlink3; the OpenBridge spec itself sets none.
Longer is a config error at startup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dashboard's OBP support was complete except for the browser's event
switch, which had no case for openbridge_connected -- so state.openbridges
was only ever populated by initial_state. The core emits the event in
response to sync_request, after broadcast_hblink_status has already pushed
initial_state on socket connect, so a tab open across an hblink restart saw
an empty list, updateOpenbridges hid the section, and only a page reload
brought it back.
Handle openbridge_connected and openbridge_disconnected alongside the
outbound cases. Two details differ from a repeater or outbound connection,
because OBP has no handshake to observe: the event is re-sent on every
sync_request, so this upserts and plays no connect tone, preserving the
original connected_at rather than restarting the uptime counter; and the
core's payload carries no connected_at at all -- the server stamps its own
copy from the event envelope, so the browser does the same or the card
renders no uptime.
openbridge_disconnected is wired but currently dead. A bound socket has
nothing to drop, so nothing emits it, but the server already handles it and
the card should follow if that changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
utils.py: pad_text/pad_num/pad_decimal, one per convention in DMRGateway's
config-blob sprintf -- text left/space, numerics right/zero, lat-long
right/space. _send_outbound_config uses them; _send_outbound_options sends
the string unpadded.
safe_decode_bytes strips NUL as well as whitespace, so a client that
NUL-pads its fields matches a callsign ACL and reports cleanly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The connection flow described a handshake the code never performed: MSTCL
as the login challenge (it is a shutdown notice), RPTA as the accept (the
constant is never sent), no RPTK acknowledgment, and state names that did
not exist. The keepalive section contradicted itself, describing an "MSTP"
command answered with RPTACK.
Rewrite it from the code. The challenge is RPTACK + salt, the accept is
RPTACK + repeater_id, and every handshake step is shown with its reply.
Fix the ID width claim: repeater IDs are 4 bytes, but the radio and
talkgroup IDs inside DMRD are 3. Document DMRD as a 53-byte structure with
a 55-byte wire length, which is what the parser actually requires.
Add what was missing: MSTCL, MSTNAK, and DMRA as commands, the RPTK hash
construction, the ID-takeover challenge, RPTO's trust gating and UNIT=,
voice-vs-data stream classification, and the outbound client role.
Soften two claims the code does not enforce. RPTO does not require the
connected state, and any packet from the registered address refreshes
liveness, so disconnect lands at max_missed times the sweep interval
rather than timeout_duration.
The outbound path logged "waiting for MSTCL..." while correctly waiting
for RPTACK + salt — the same confusion the document had. Group the
four-byte command prefixes in constants.py and record what they are for,
since HBlink4 only uses RPTP and the rest read as dead code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Points at the separate C companion repos: dmr-talkback (voice test
endpoint), ipsc2hbpc (IPSC<->HBP translator), and cc2obp (c-Bridge
CC-CC<->OpenBridge translator).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trailing "; the incumbent is kept until this address authenticates"
was superfluous — "challenging" already implies it — and pushed the line
to 221 characters with the log prefix, wrapping in a terminal and making
it hard to read. Now 165, which matters here because dual-stack binds
report IPv4-mapped IPv6, spending an extra 7 characters per address.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A repeater's registration was displaced by an unauthenticated RPTL: any
packet carrying a known radio ID, from any source address, tore down the
live registration before anything was proven. Radio IDs are public, so
this was enough to knock any repeater off the network with an 8-byte
packet, repeatable at will.
A login claiming an ID that is already registered from a different
sockaddr is now held in _pending_logins and challenged. The incumbent is
removed only after the claimant proves the passphrase; on any failure the
claimant is discarded and the registration is untouched. Recovery for a
genuine NAT remap is unchanged in speed — login, auth, config, one round
trip later.
Liveness is now gated on the address matching too. _check_repeater_timeouts
decides purely on last_ping, which was refreshed by any packet bearing a
known ID before validation, so a stale or spoofed source could keep a dead
entry from ever aging out.
Report reconnects instead of diagnosing them. When a radio ID logs in
again, log how long the previous session lasted as D:HH:MM:SS. Hours means
a NAT lease expired or the device was power cycled; seconds, repeating,
means it cannot stay online — and two addresses alternating is a duplicate
radio ID. The operator reads that in a glance; no thresholds are guessed
at in code. reconnect_report_threshold (default 3600, 0 disables) filters
out routine long sessions. Only reported once a session actually existed,
since connect_time is meaningless before the connected state.
Fix dashboard uptime resetting for repeaters that never dropped. The
dashboard stamped its own event-arrival time as connected_at, but
repeater_connected is also emitted on missed pings, on recovery, and on
state replay when the dashboard reconnects. The server's connect_time now
rides in the payload, with a fallback for older servers.
Logging: ping and pong lines now both carry radio ID and sockaddr, as do
all inbound dispatch lines, so either field can be grepped for a whole
exchange. Fixes an f-string whose conditional sat inside the string
literal, raising AttributeError and surfacing as a generic datagram error
for exactly the wrong-address pings this change is about.
Adds tests/test_id_takeover.py: 31 tests over the auth gate, the login
races, threshold filtering and connect_time. Verified by mutation — each
reverted behavior fails a specific test.
Also documents three unreachable/misleading branches in the dispatch chain
found along the way, in code and in docs/TODO.md. Behavior unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HBlink4 draws operators who have never run a network server before, and
several things in the install path assumed otherwise.
Sample configuration split in two. config_sample.json was a feature
showcase, and copying it as documented produced a broken server: its
example repeater pattern claimed IDs 310000-312999 with a passphrase of
its own, so an operator whose repeater fell in that range was rejected
with no way to diagnose it (patterns match first-wins and never fall
through to "default"). It also shipped an outbound link and an OpenBridge
trunk enabled against hosts that do not exist, and bound both address
families to one port, which cannot work. It is now a minimal working
config needing one edit -- the passphrase. The full reference moves to
config_advanced_sample.json with its link examples disabled and the ID
overlap called out.
Ship IPv4-only. Dual-stack is fully supported and now documented as four
concrete layouts, each verified to bind; it is simply not the default,
because most repeaters are IPv4 and the same-port bind conflict is not
something a new operator should have to learn first. The built-in default
when the key is absent stays false, so existing installs are unaffected.
"IPv6 is globally disabled" drops from WARNING to INFO -- it is now the
recommended setting and should not read as a fault.
install_services.sh: refuse paths containing a space, $ or % (systemd
mis-parses them in ExecStart=, and a space previously produced a unit
that passed verification and then failed at start); escape & in the path
substitution; render and verify units in a temp dir so a failed
substitution cannot leave a broken unit in /etc/systemd/system. Check the
things that otherwise surface only in the journal: complete checkout,
Python 3.9+, server and dashboard dependencies, both config files, and
agreement between the two halves of the dashboard event link.
readme: seven numbered steps with no diversions, adding the prerequisites
(python3-venv, which Debian and Ubuntu users hit constantly) and the
firewall ports, neither of which were mentioned.
Also: the dashboard logged a hardcoded localhost:8080 regardless of the
configured port; run_all.sh now fails clearly on missing config instead of
a traceback; pyproject's comment claimed systemd should invoke the console
scripts, contradicting how the units actually work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Leaving <unit>.bak-<stamp> next to the live units puts clutter in a
system directory that systemd itself scans, and it accumulates where
nobody looks. Back up into the project root instead, chowned back to the
invoking user rather than left owned by root.
Renamed to <unit>.<timestamp>.bak so the existing *.bak ignore rule
actually matches -- the previous .bak-<timestamp> form does not end in
.bak and would have shown up as untracked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The installer script, the user/group/path warning and the service
management steps were described in readme.md, SYSTEMD.md and
scripts/README.md, which is three places to keep in sync.
SYSTEMD.md is now authoritative. readme.md's Running section points at it
instead of repeating the install command and its caveats, and its
Installation warning drops the systemd-specific detail it duplicated,
keeping only the write-access requirement that belongs there.
scripts/README.md keeps a two-line index entry that links onward.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shipped unit files carry the author's user and paths (User=cort,
/home/cort/hblink4), so copying them verbatim produces two services that
fail to start. The docs said so only in a Customization section below the
copy instructions, and told people to edit the repo copies -- which then
conflict on the next git pull.
The installer prompts for the service user and group (defaulting to the
invoking sudo user and their primary group), substitutes them and the
detected installation path into both units, installs to
/etc/systemd/system/, reloads systemd, and optionally enables and starts
the services. It backs up existing units, refuses unknown users/groups,
checks the venv exists, warns when the service user cannot write the
installation directory, and verifies the substitution landed before
leaving anything in place. DESTDIR previews the output without touching
the live system.
The manual instructions now say to copy first and edit the installed
copy, and note that daemon-reload is required after any unit change. Both
unit files carry a header saying the same.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/integration.md still documented a Twisted application: reactor,
listenUDP, callLater, reactor.run, and "HBlink4 uses Twisted's event
loop". HBlink4 moved to asyncio in 4.7.0. Beyond the framework, the
examples referenced things that do not exist -- connection_state == 'yes'
(the real values are login/config/connected), and handle_repeater_connection
/ handle_dmr_data / datagramReceived, none of which are methods on
HBProtocol. Rewritten against the actual API: create_datagram_endpoint,
datagram_received, the _handle_* override points, and the real
RepeaterState fields, with asyncio-appropriate notes on threading and
not blocking the loop.
SYSTEMD.md's upgrade section narrated the recent changes; reduced to the
operational fact that there is no install step and old units pointing at
run.py need re-copying.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit required "pip install -e ." before anything would
run, and made "python hblink4/hblink.py" fail outright. That trades a
real support burden -- an install step people forget after git pull, and
an error message that looks like a broken checkout -- for an internal
correctness property. Not a good trade for this project's users, who have
run hblink by interpreter-plus-path since HBlink3 and DMRlink.
Establish the package context explicitly instead. When hblink4/hblink.py
or dashboard/server.py is run by path, __package__ is empty, so each now
puts the project root on sys.path and sets __package__ before its
relative imports. That is enough for "from .constants import ..." to
resolve to hblink4.constants -- one copy, not the two the old
except-ImportError fallback produced. Importing the parent package is
cheap because __init__.py is metadata only.
Every invocation style now works and none of them double-load:
python hblink4/hblink.py config/config.json <- systemd uses this
python dashboard/server.py <- systemd uses this
python run_hblink.py config/config.json <- convenience
python run_dashboard.py <- convenience
python -m hblink4.hblink
hblink4 <- if pip installed
The systemd units invoke the module files directly rather than going
through a launcher, so there is one less moving part between systemd and
the program. run_hblink.py and run_dashboard.py stay in the project root
as launchers for interactive use, but they are now genuinely logic-free:
the uvicorn call that used to live in run_dashboard.py is dashboard.server.run().
requirements.txt and requirements-dashboard.txt come back as the
documented install path. pyproject.toml stays for those who want
"pip install -e .", but is now optional and reads its dependencies from
those files so the two cannot drift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running a module inside a package by file path puts the package's own
directory on sys.path while the package is also importable, which is the
condition for the dual import trap. hblink.py papered over it with a
"try: from .x import ... except ImportError: from x import ..." block --
an idiom that cannot fix the problem, it only picks which of the two
copies you get.
Add pyproject.toml and install the package. The systemd units now invoke
the generated console scripts:
venv/bin/hblink4 /path/to/config.json
venv/bin/hblink4-dashboard
Those are still explicit absolute paths, but they enter through the
package rather than around it, so each module is imported exactly once.
The 30-line except-ImportError branch in hblink.py is deleted and the
relative imports stand on their own; dashboard/server.py grows a run()
function for its entry point, with __main__ delegating to it.
tests/test_openbridge.py was the last place doing the same dance --
sys.path.insert into hblink4/ then bare "from hblink import HBProtocol",
loading a second copy of the package alongside the hblink4.* modules the
other eight test modules import. Converted to package imports; the suite
now loads no duplicates.
requirements.txt and requirements-dashboard.txt are removed in favour of
pyproject's dependencies and the [dashboard] / [dev] extras, so there is
one place to declare a dependency.
Verified: 94 tests pass, both entry points resolve to the right
callables, no bare-name duplicates under either, and running hblink.py by
path now fails loudly with ImportError instead of silently double-loading.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Importing the hblink4 package ran "from .hblink import main, HBProtocol,
RepeaterState", which pulled hblink.py in as hblink4.hblink. When
hblink.py is executed directly it is already loaded as __main__, so this
produced a second, independent copy of it and of every module it imports
-- constants, models, lc and the rest existed twice, under bare and
hblink4-prefixed names, with distinct class objects that fail isinstance()
against each other. The classic dual import trap.
The whole cascade was triggered by one line in main(), "from hblink4
import __version__", and nothing anywhere used the re-exports. Reducing
__init__.py to metadata removes the duplication entirely and also clears
the RuntimeWarning emitted under "python -m hblink4.hblink".
Verified: with the production sys.path, no hblink4.* modules are loaded
at all where six were before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.claude/ is editor/agent tooling state, and obp_config.json is a live
per-install config alongside the already-ignored config.json. Add a
*.bak catch-all so config backups stop showing up as untracked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dashboard event link is documented as if both sides "listen on" an
address, which is misleading: hblink4 is the client. It dials the
dashboard and pushes events; the dashboard only sends sync_request back
over the connection it accepted.
Establish one naming convention across both configs -- bind_* is an
address we listen on, host_* is an address we connect out to -- and
state who initiates the connection in the docs, config samples, and
module docstrings. docs/configuration.md previously documented hblink4's
connect target with listener semantics ("Empty = disable IPv4 listener",
"0.0.0.0", "may need different ports if bind error"); those values are
unusable in a connect() and are replaced with real dashboard addresses.
The dashboard's event_receiver keys are renamed host_ipv4/host_ipv6 ->
bind_ipv4/bind_ipv6, which collided with the identically-named
connect-target keys in hblink4's config. Old names still work and log a
deprecation warning.
Drop run.py and run_dashboard.py in favour of direct execution:
python hblink4/hblink.py [config/config.json]
python dashboard/server.py [bind] [port]
run.py's only real behaviour (defaulting the config path) moves into
main(). The dashboard was not runnable on its own: server.py had a
__main__ block, but it passed "dashboard.server:app" as an import string,
which cannot resolve in script mode, and it omitted the loop="asyncio"
uvloop workaround that lived only in the shim. It now passes the app
object directly -- the import string would have executed the module a
second time under another name, yielding two DashboardState objects and
two atexit saves, the empty one clobbering real data.
Signal handling: the SIGTERM/SIGINT handlers were guarded by
__name__ == "__main__", never true while uvicorn imported the module.
Making server.py the entry point would have activated them for the first
time, and they sys.exit(0) from inside the handler, pre-empting uvicorn's
graceful shutdown. shutdown_event() already saves, so leave signals to
uvicorn and keep atexit as the fallback.
Add a 'web' section to the dashboard config so its bind address and port
come from disk rather than the command line; CLI arguments remain as an
override. Drop event_receiver.buffer_size and .ipv6 from the defaults --
neither is read, and the defaults block was writing them into every
freshly created config.json.
Remove PrivateTmp=true from both units. It gives each service a private
/tmp, so with the 'unix' transport hblink4 and the dashboard each create
a separate /tmp/hblink4.sock and never see each other -- the dashboard
just reports hblink4 as disconnected, with no error on either side.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The OBP ingress handler extracted the origin peer (DMRD bytes 11:15) but
never forwarded it to the dashboard, and the dashboard's openbridge
last-heard branch was hardcoded to display only the trunk name. Result:
calls arriving over an OpenBridge trunk showed the connection as the
source even when both ends preserve the source peer.
- hblink.py: pass peer_id as remote_repeater_id in the OBP ingress
stream_start emit, mirroring the outbound ingress path.
- dashboard/server.py: render "trunk (peer)" for openbridge like the
outbound branch does, falling back to the bare trunk name when the far
end stamps its own network_id (preserve_source_peer off upstream).
This is the closest-hop source peer; over multi-hop OBP it reflects the
immediate upstream, not the full path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
hblink4's event link is more resilient than hblink3/dmrlink3 (the emitter is a
client that pushes with raw sendall(), which raises on a dead peer and triggers
reconnect), but two gaps let a silently-severed TCP flow (NIC flap, DHCP renew,
firewall/conntrack eviction) go undetected far longer than necessary:
- The emitter set SO_KEEPALIVE but left the default ~2h timers, so an *idle*
emitter (no calls to push) could sit on a dead socket for hours. Now tuned to
idle 60s / intvl 15s / cnt 4 at all three TCP socket-creation points.
- The dashboard's accepted server sockets had no keepalive at all, so the
dashboard could hold a dead hblink4 connection (hblink_connected stuck True)
until hblink4 reconnected. connection_made now tunes keepalive on the accepted
socket so a dead peer fires connection_lost within ~2 min.
Unix-socket transport is unaffected (local, not exposed to this). 94 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Drop stat value (data) font 2px: 22->20 base, 18->16 mobile
- Fixed-width statistics pills (170px) so they're all equal and only
as wide as the longest label, instead of stretching to fill the row
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Shrink fonts across the board (-2px non-pill, -1px slot/OBP pills)
- Fix missing brace on body rule so .container width/centering applies
- Fix DMR-disconnect cleanup (wrong element IDs left stale data on screen)
- Move OpenBridge section above Repeaters
- Add click-to-collapse carets to all sections below Statistics
(state persists in localStorage; Recent Events collapsed by default)
- Minimized Last Heard becomes "Active Streams" (active calls only)
- Keep Active Streams height stable between 0 and 1 active call
- Move Recent Events header outside its card to match other sections
- Enlarge collapse carets for visibility
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The browser stream_start/stream_end handlers only branched on 'outbound'
vs a repeater else (matched by repeater_id), so OpenBridge streams were
driven only by the periodic full-state snapshot, not live events — and
worse, an OBP stream_start could match/overwrite an outbound (or other
OBP) stream on the same slot, since both have repeater_id === undefined.
Add explicit 'openbridge' branches:
- stream_start: match by connection_name + stream_id (OBP is stream-
multiplexed; slot is not a unique key), and call updateOpenbridges.
- stream_end: OBP has no hang time, so drop the stream immediately
(matches server.py) rather than transitioning to hang_time; match by
stream_id; leave data pills to the persistence timer.
- data-persist: scope the persist timer/key and drop predicate by
stream_id for OBP, and re-render via updateOpenbridges.
OBP pills now update instantly on events instead of waiting for the next
snapshot. hang_time_expired needs no OBP branch (never emitted for OBP).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OBP is stream-multiplexed with no slot to protect and no hang time, and
the stream_end client handler only matches repeater/outbound streams
(by repeater_id) — never openbridge — so an OBP stream never acquires
hang_time status. The HANG branch was dead. Reduce the OBP pill to
TX/RX with the DATA override.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OpenBridge trunk pill hardcoded the receiving/RX badge, so
outbound (assumed) streams showed a green RX pill. Mirror renderSlot():
pick transmitting/⬆️ TX vs receiving/⬇️ RX from is_assumed, with the
existing HANG and DATA overrides.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OBP egress branch in _forward_stream sent the frame but never
tracked the stream, so no stream_start/stream_end was emitted and the
dashboard drew no outbound pill for OpenBridge TX (the ingress/RX path
already emitted these).
Add _update_assumed_stream_obp, mirroring the OBP ingress emit: the
assumed TX stream is keyed by stream_id in the target trunk's own
stream dict (OBP is stream-multiplexed, no TDMA slot), marked
is_assumed=True, and emits stream_start on first frame and stream_end
on terminator. Deliberately does not touch _active_calls, matching OBP
ingress for internal consistency. Called from the OBP egress branch.
Also harden the stale-stream reaper to emit stream_end ('timeout') for
any OBP stream (RX or TX) it drops that had not already ended, so a
lost terminator no longer leaves a hung pill on the dashboard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HBP repeater sources deliver 55-byte DMRD frames (trailing BER/RSSI
bytes). _obp_build_egress copied dmrd53[16:] to end, producing a
55-byte "DMRD" + 20-byte HMAC = 75-byte OBP packet. The peer splits
at byte 53, sees a 22-byte HMAC, computes its own 20-byte SHA1 over
the first 53 bytes, and fails compare_digest on length alone
("OpenBridge HMAC failed ... HMAC LENGTH: 22").
Slice dmrd53[16:53] so the wire frame is always 53 DMRD + 20 HMAC = 73,
regardless of whether the source frame was 53 (OBP ingress) or 55 (HBP
repeater ingress) bytes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the ?demo feature from the HBlink3 / DMRlink3 dashboards. When
?demo is in the URL, populate a coherent static scenario and render it
without opening a WebSocket; the 1s duration ticker keeps active calls
counting up.
Scenario exercises HBlink4 as an OBP-fed edge: a KS-CORE OpenBridge
trunk (US-Nationwide 31/TS1, KS-Statewide 3120/TS2, MO-Local 3100/TS2),
an inbound trunk call delivered to subscribed devices, a local call
egressing out the trunk, a TX-hang, an RPTO translation shown in gold,
plus an outbound server link and a Last Heard mix (group/private,
voice/data). No re-fetch path runs in demo mode (handleEvent is
WebSocket-only), so the scenario stays put.
Note: shape-verified against the render functions; not render-tested
(no browser/JS engine in the build env).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-trunk TG list rendered TGID white and the /TSn suffix in a dimmer
blue. Unify to a single inherited color and reverse to TS/TGID (standard
DMR nomenclature). Config still keys by TGID — the table assigns each
TGID a TS — so only the display order changed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Active OBP transmissions were each a full-width slot row, stacking
vertically as the trunk got busy. Render them as inline pills in a
flex-wrap container (.obp-streams / .obp-stream-pill) so they flow
left→right and wrap only when out of room, matching the other panels.
Hang-time streams dim; idle shows a single muted pill.
Swap the section/trunk-card icon from 🌉 (too dark/detailed at small
size) to ↔️, which reads clearly as a bidirectional trunk.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Integrate the unit (private) call routing feature and DMR data-call
classification (v4.8.0) into the OpenBridge branch, which had diverged
from main before that work landed. This resolves a production startup
crash: configs written for v4.8.0 carry `default_unit_calls` in pattern
configs, which the pre-v4.8.0 openbridge RepeaterConfig rejected.
Conflicts (hblink4/hblink.py, in _forward_stream target loop):
- Kept the OpenBridge egress `elif target[0] == 'openbridge'` branch.
- Threaded unit-call metadata through the assumed-stream trackers:
pass the generalized `source_disp_id` (identical to main's
int.from_bytes(source_repeater_id) for repeater sources, and correct
for OBP sources) plus `is_unit_call=source_stream.is_unit_call`.
StreamState.is_unit_call defaults False, so OBP-sourced streams read
as group calls, matching the TGID-trunk model.
readme.md: keep both the OpenBridge-trunks and unit-call feature bullets
and take main's v4.8.0 release-notes list.
Tests: 94 passed (main's unit-call suites + openbridge's 10 OBP tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New docs/openbridge.md: comprehensive guide — design principles (canonical
TGID / no OBP-edge translation, stream-multiplexed, one-OBP-per-TGID,
fail-closed, reflection guard), routing integration, config reference,
dashboard, and the HBlink3-core/HBlink4-edge topology.
- readme.md: OpenBridge added to Features and Documentation; the analysis doc
reframed as background (now implemented).
- docs/configuration.md: new 'OpenBridge Trunks' section (openbridge_connections
+ talkgroup_slots reference, validation, failover).
- docs/OPENBRIDGE_ANALYSIS.md: status banner pointing to the implemented feature.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Server (dashboard/server.py):
- New state.openbridges; openbridge_connected/disconnected handlers.
- stream_start/update/end handle connection_type='openbridge' (keyed by
connection_name.stream_id, since OBP is stream-multiplexed). OBP calls flow
into stats + Last Heard like any RX; ended OBP streams are dropped (no hang).
- 'openbridges' included in both initial_state payloads; cleared on HBlink
disconnect.
Front-end (dashboard.html):
- New OpenBridge section + updateOpenbridges(): per-trunk card (name, NetID,
peer, uptime, preserve_source_peer, TGID->TS map) with a live multi-stream
list. Wired into initial_state and all connection re-render points.
Verified: server OBP event flow (connected -> stream tracked + last_heard ->
dropped on end) via smoke test; dashboard.html JS parses clean; core suite 94/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- _start_openbridge / _send_initial_state emit 'openbridge_connected' with the
trunk's name, peer, preserve_source_peer, and TGID->TS map (via new
_openbridge_event_data helper).
- _handle_openbridge_packet emits stream_start/stream_end tagged
connection_type='openbridge' (the emitters' generic else-branch handles it).
- Test updated: bare instance gets a mock _events; asserts the stream_start
event is emitted with connection_type='openbridge'.
Dashboard server + rendering consume these next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
These 5 tests had been failing since commit 7da5c4b, which restructured
config_sample.json's repeater patterns (renamed 'KS-DMR Network' -> 'Core
Network Repeaters (Trusted)', etc.) without updating the tests. setUp() loaded
the real sample config, so the tests' next(... name == 'KS-DMR Network') lookups
raised StopIteration. The matcher itself was never broken.
Root fix (not a re-sync): the tests now build their matcher from an inline
fixture config carrying the vocabulary they assert, instead of loading the
user-editable sample. They're hermetic and can't drift when the sample changes.
Full suite now 94 passed / 0 failed. (Unrelated to OpenBridge; rides along on
this branch as a self-contained, cherry-pickable commit.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wires OBP into the existing calculate-once/cache-per-stream routing core as
just another source and sink — one routing path for repeaters, outbounds, and
OBPs (no parallel/duplicated path).
- _calculate_stream_targets: generalized 'source' (repeater_id bytes OR
('openbridge', name)); the existing self-skip doubles as the reflection guard.
Added an OBP target loop — ownership by canonical TGID only (no slot/contention
gating, no remap; Position B). One-OBP-per-TGID means OBP->OBP transit can't occur.
- _forward_stream: source resolution now handles a repeater OR an OBP source
(OBP = identity translation, stream from OpenBridgeState.streams). Added the
('openbridge', name) target branch: net-side DMRD -> _obp_build_egress -> sendto.
- _handle_openbridge_packet: full ingress lifecycle — authenticate, fail-closed
filter, normalize the wire slot to the assigned local TS (core is (TS,TGID)-keyed),
create/track the stream by stream_id, forward through the shared path, end on
terminator.
- _check_stream_timeouts: reap stale/ended OBP streams (stream-multiplexed, no
slot/hang-time), preventing unbounded growth.
Tests: 10 OBP tests (config/state/egress-codec + target selection & reflection +
ingress lifecycle with slot normalization and unmapped-drop). Full suite 89 pass /
5 pre-existing access_control failures (identical on main).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8 tests covering parse/validation (quoted-both map, dup-TGID fatal, disabled
standby may mirror, bad TS fatal, missing field fatal), OpenBridgeState
ownership/stream tracking, and the real HBProtocol._obp_build_egress framing
(HMAC valid, wire slot forced to 1, preserve_source_peer true/false).
Regression: existing suite is 79 pass / 5 pre-existing failures in
test_access_control.py (identical on main; unrelated to OBP).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the config and state layer for OpenBridge trunk connections (design:
development/openbridge-hblink3-hblink4-design.md). Socket/protocol, ingress,
and egress follow in subsequent commits.
- models: OpenBridgeConnectionConfig (lean transport + talkgroup_slots map +
preserve_source_peer, default True) and OpenBridgeState with dict-keyed
streams (stream_id -> StreamState); OBP concurrency is not slot-gated.
- config: parse_openbridge_connections() parses the quoted-both
{"tgid":"ts"} map to {3-byte TGID -> int TS} and enforces, fatally:
TS in {1,2}, one-TS-per-TGID (structural), and one-OBP-per-TGID across
enabled OBPs (disabled OBPs skipped, so a standby may mirror TGIDs).
- config_sample.json: an enabled trunk plus a disabled standby example.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Headline changes since v4.7.0: unit (private) call routing with user
cache + broadcast fallback, subscriber-pair hang time, cross-slot
forwarding, and optional propagation across HBlink4 outbound links;
DMR data-call classification so APRS/SMS streams are logged once
instead of fanning out as voice; RPTO empty-timeslot fix so
"Options=TS1=;TS2=..." correctly disables TS1 instead of silently
falling back to configured defaults.
See docs/RELEASE_NOTES_v4.8.0.md for the full change list and
migration notes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Folds in unit (private) call routing along with the data-call
classification fix and the RPTO empty-TS-slot deny-all fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>