369 Commits
Author SHA1 Message Date
Cort BuffingtonandClaude Opus 5.5 72cf6423f5 Remove the manual user CSV pipeline; load a filter of any size
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>
2026-11-03 15:45:55 -06:00
Cort BuffingtonandClaude Opus 5.5 2348c3bcc5 readme: describe HBlink4 as a network's own server, not a successor to HBlink3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:20:01 -05:00
Cort BuffingtonandClaude Opus 5.5 5e472c18d7 OpenBridge: cap the passphrase at 80 characters
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>
2026-10-21 12:48:50 -05:00
Cort BuffingtonandClaude Opus 5 0b591da73f Show OpenBridge trunks without a browser refresh
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>
2026-10-12 17:23:29 -05:00
Cort BuffingtonandClaude Opus 5 4024413c4c Pad the outbound RPTC blob like DMRGateway; tolerate NUL pads on ingest
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>
2026-10-08 09:00:25 -05:00
cortandClaude Opus 5 395378a87a Correct protocol.md against the implementation
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>
2026-09-03 06:56:41 -05:00
Cort BuffingtonandClaude Opus 5 2212051a77 docs: add Companion programs section to readme
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>
2026-08-15 14:45:36 -05:00
cortandClaude Opus 5 adf013c458 Shorten the ID-challenge log line
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>
2026-08-14 10:11:26 -05:00
cortandClaude Opus 5 35fd2c2492 Require authentication before a radio ID changes address
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>
2026-08-14 09:32:49 -05:00
cortandClaude Opus 5 6f6dadd6ac Make a first-time install work without prior DMR or Linux experience
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>
2026-08-07 08:37:11 -05:00
cortandClaude Opus 5 142ea512ec Write unit backups to the project root, not /etc/systemd/system
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>
2026-08-06 21:12:08 -05:00
cortandClaude Opus 5 26255c47da Make SYSTEMD.md the single reference for systemd deployment
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>
2026-08-06 21:10:24 -05:00
cortandClaude Opus 5 331fe8814c Add scripts/install_services.sh to install the systemd units
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>
2026-08-06 21:08:47 -05:00
cortandClaude Opus 5 512249c3ed Correct the integration guide and trim systemd doc
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>
2026-08-06 21:03:57 -05:00
cortandClaude Opus 5 f1a2e4133c Make the module files directly runnable again; no install step
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>
2026-08-06 20:54:37 -05:00
cortandClaude Opus 5 6a644a818c Package hblink4 properly and run it via console-script entry points
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>
2026-08-06 20:37:44 -05:00
cortandClaude Opus 5 d03ad4be5b Stop re-exporting hblink from the package __init__
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>
2026-08-06 20:31:12 -05:00
cortandClaude Opus 5 1c8ac11123 Ignore local tooling and per-install config files
.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>
2026-08-06 18:25:20 -05:00
cortandClaude Opus 5 18c90f4631 Remove launcher shims; clarify connect/listen roles in dashboard link
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>
2026-08-06 18:23:18 -05:00
Cort Buffington 5c8cf7f8b4 Update repository URL in README 2026-08-06 10:01:35 -05:00
cort 8bb8c7e41c Clarify hang-time handoff log for assumed relay streams 2026-07-28 17:21:18 -05:00
Cort BuffingtonandClaude Opus 4.8 3fea8c6265 Dashboard: show source peer for OBP ingress in last-heard
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>
2026-07-14 12:46:29 -05:00
Cort Buffington eddd0e562b Merge fix/reporting-link-keepalive: tune TCP keepalive on the event link 2026-07-11 17:53:46 -05:00
Cort BuffingtonandClaude Opus 4.8 599e29da9d events/dashboard: tune TCP keepalive so a silently-severed link is detected
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>
2026-07-11 17:47:42 -05:00
cortandClaude Opus 4.8 5eae7fa214 Dashboard: shrink stat values, narrow statistics pills
- 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>
2026-07-08 07:47:58 -05:00
cortandClaude Opus 4.8 4cd4d72ea8 Dashboard: collapsible sections, reorder OpenBridge, font/UX tweaks
- 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>
2026-07-08 07:32:00 -05:00
Cort BuffingtonandClaude Opus 4.8 6354676e0f Dashboard: handle OpenBridge streams in live event handlers
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>
2026-07-06 16:44:17 -05:00
Cort BuffingtonandClaude Opus 4.8 3a269e137f Dashboard: drop dead HANG handling from OBP pill
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>
2026-07-06 16:08:48 -05:00
Cort BuffingtonandClaude Opus 4.8 41fd4cc8d5 Dashboard: render OBP TX streams as TX, not hardcoded RX
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>
2026-07-06 16:05:34 -05:00
Cort BuffingtonandClaude Opus 4.8 ffd4b7b1a2 Emit dashboard stream events for OBP outbound (TX) traffic
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>
2026-07-06 16:00:08 -05:00
Cort BuffingtonandClaude Opus 4.8 cc810fa1a8 Fix OBP egress: truncate DMRD to canonical 53 bytes
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>
2026-07-06 15:52:32 -05:00
Cort BuffingtonandClaude Opus 4.8 8b1652087e dashboard: add ?demo mode (static scenario, no server)
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>
2026-07-06 15:33:00 -05:00
Cort BuffingtonandClaude Opus 4.8 037a55cc2d dashboard: show OBP talkgroups as TS/TGID in one color
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>
2026-07-06 15:26:05 -05:00
Cort BuffingtonandClaude Opus 4.8 7110966e34 dashboard: OpenBridge streams as flowing pills; ↔️ header icon
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>
2026-07-06 15:20:15 -05:00
Cort BuffingtonandClaude Opus 4.8 97818227d0 Merge branch 'main' (v4.8.0) into openbridge
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>
2026-07-06 14:49:40 -05:00
Cort BuffingtonandClaude Opus 4.8 46a5451bc4 docs: document OpenBridge (OBP) trunks
- 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>
2026-07-06 12:48:28 -05:00
Cort BuffingtonandClaude Opus 4.8 e251afd252 OpenBridge (OBP): dashboard visibility (server state + rendering)
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>
2026-07-06 12:44:37 -05:00
Cort BuffingtonandClaude Opus 4.8 0b7b2b8125 OpenBridge (OBP): emit dashboard events (connection + streams)
- _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>
2026-07-06 12:34:59 -05:00
Cort BuffingtonandClaude Opus 4.8 2e8ef43fc6 fix: make access_control tests hermetic (inline fixture, not config_sample.json)
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>
2026-07-06 12:27:31 -05:00
Cort BuffingtonandClaude Opus 4.8 6196cface3 OpenBridge (OBP): ingress routing + egress (approach B), reflection guard, reaping
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>
2026-07-06 12:16:59 -05:00
Cort BuffingtonandClaude Opus 4.8 b5909b359d OpenBridge (OBP): tests for config, state, and egress codec
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>
2026-07-06 11:49:08 -05:00
Cort BuffingtonandClaude Opus 4.8 1ed45faf20 OpenBridge (OBP): per-OBP socket + wire codec
- OpenBridgeProtocol: one bound UDP socket per OBP (mirrors OutboundProtocol);
  no login/keepalive.
- _start_openbridge(): resolves target, binds local_addr, builds OpenBridgeState,
  indexes _obp_by_tgid (canonical TGID -> owning OBP, enabled only). Wired into
  async_main() alongside outbound startup.
- Wire codec (ported from HBlink3 OPENBRIDGE): 53-byte DMRD + 20-byte
  HMAC-SHA1(passphrase, DMRD).
  - _handle_openbridge_packet(): authenticates (HMAC + source socket), applies
    the fail-closed talkgroup_slots filter, and assigns the local TS. Routing
    into the core is the marked TODO (item 4).
  - _obp_build_egress(): frames a DMRD for egress — RptrId = source peer when
    preserve_source_peer else network_id; wire slot forced to 1; HMAC appended.

Verified: py_compile clean; egress/ingress codec round-trip (HMAC valid, slot->1,
preserve_source_peer both ways, TS assignment, and drops for bad HMAC / wrong
source / unmapped TGID). Full-boot import needs deps (bitarray) not present in
this sandbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:41:07 -05:00
Cort BuffingtonandClaude Opus 4.8 856b639a5d OpenBridge (OBP) support: config + state foundation
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>
2026-07-06 11:34:31 -05:00
Cort Buffington f5b840ff1f Fix header level for GitHub 'Issues' section
Correct the header level for 'GitHub Issues' section.
2026-07-03 08:25:53 -05:00
Cort Buffington 6b3d1bfb75 Revise support and contributing guidelines in README
Updated support and contributing sections to clarify lack of support and guidelines for GitHub issues.
2026-07-03 08:22:23 -05:00
Cort Buffington e6cace30a0 Change HBlink3 status to active development
Updated the status of HBlink3 to indicate active development.
2026-06-25 20:51:24 -05:00
Cort Buffington 58bf556b2f Fix typo in readme.md for 'companion' spelling 2026-05-24 19:58:50 -05:00
Cort Buffington fd597ced7c Fix typos and formatting in readme.md
Corrected typos and formatting in the readme.
2026-05-24 17:54:01 -05:00
cortandClaude Opus 4.7 ddb0dff3f1 release: bump to v4.8.0
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>
v4.8.0
2026-05-06 07:35:59 -05:00
cortandClaude Opus 4.7 1e5db7fda5 Merge feature/unit-call into main
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>
2026-05-06 07:23:09 -05:00