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 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>
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>
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>
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>
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>
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>
Subscriber-to-subscriber call routing with user cache + broadcast
fallback, subscriber-pair hang time, cross-slot forwarding, and
optional propagation over outbound server links that forms an
implicit reverse-path tree between HBlink4 peers.
Source-side gating:
- Per-repeater unit_calls_enabled seeded from pattern default_unit_calls
- Trusted repeaters can override via UNIT=true|false in RPTO
Target calculation (_calculate_unit_call_targets):
- Cache hit -> one-to-one to local repeater or outbound link
- Cache miss -> broadcast to all unit-enabled peers on source's slot
- Anti-loop: outbound-sourced calls never forward to any outbound
Slot semantics are ships-in-the-night. The originating slot is always
preserved on forward; the target's cached slot is informational only
because DMR radios can monitor both slots simultaneously.
Hang time protects the (rf_src, dst_id) subscriber pair. Either
direction of the same pair passes; same source to a new target
passes; anything else is a hijack.
Log lines are normalized so group and unit share identical shape,
differing only in TS/TGID vs TS/RID and optional annotations
([broadcast], [one-to-one via N], etc).
Dashboard Last Heard "Talkgroup" column renamed to "Destination"
with group/unit emoji indicators.
Known limitation: no cross-outbound forwarding for either call type;
TODO includes a stream-id-based loop-detection design sketch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First production release after the Twisted → asyncio migration.
Covers everything since v4.6.1 (2025-11-01): asyncio stack, DMRA
(Talker Alias) support, outbound network connections, per-repeater
DMRD translation with on-the-wire Link Control rewriting, automated
radioid.net user DB refresh in the dashboard, and the single-module
→ package refactor (config / protocol / utils / models / lc).
See docs/RELEASE_NOTES_v4.7.0.md for the full change list and
migration notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documentation cleanup pass across the tree, driven by the upcoming merge
of the dmrd-translation branch to main.
Deletions (obsolete / superseded):
- docs/user_csv_automation_proposal.md — implemented as dashboard/user_db.py
- docs/QUICK_TEST_REFERENCE.md — stale test counts, referenced a
TEST_COVERAGE.md that no longer exists
- user.old.csv — development backup artifact (also added user.old* and
user.bak* to .gitignore)
Release notes renumbered to the 4.x series (file kept 1.x-labelled from
a prior numbering scheme that predated the 4.1.0 release):
- docs/RELEASE_NOTES_v1.5.md -> docs/RELEASE_NOTES_v4.5.md
- docs/RELEASE_NOTES_v1.6.1.md -> docs/RELEASE_NOTES_v4.6.1.md
- Also fixed a stray "v1.5+" -> "v4.5+" in stream_tracking_diagrams.md
Content fixes where docs contradicted code or were stale:
- docs/dmrd_translation.md: replaced the "Payload blanking" section with
"Link Control rewriting" describing VHEAD/VTERM full-LC and voice-burst
EMB_LC re-encoding via dmr_utils3.bptc
- docs/connecting_to_hblink4.md: cross-reference updated to match
- docs/protocol.md: corrected call-type bit direction (0=Group,
1=Private/Unit — previously reversed), replaced "Bits 0-3: Reserved"
with actual dtype_vseq semantics, updated translation-doc link
- readme.md: "Built on Twisted" -> asyncio (Twisted was removed in the
outbound-connection release); expanded docs index to include routing,
DMRD translation, diagrams, TODO, and OpenBridge analysis; added DMRD
translation to the features list
- docs/stream_tracking.md: StreamState snippet now shows lc_base and
lc_cache fields added for LC rewriting
- scripts/README.md: noted that dashboard/user_db.py is the primary
refresh path; script remains as manual fallback
- dashboard/user_db.py: dropped the doctring reference to the deleted
proposal file
Note: dmrd-translation branch remains in active development but is
fully functional as of this commit. Tests: 75/75 pass (pre-existing
test_access_control.py failures are unrelated and predate this branch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If a repeater declared a translation for (net_slot, net_tgid), that pair
is now a network-only key — the repeater must key its declared local
side. Previously a packet keyed on the net-side address would miss
inbound_map, fall through untranslated, and still pass ACL because the
net-side TGID lives in the subscription set (it has to, for outbound
routing TO the repeater). Reject at ingress with a distinct log line
identifying the mis-keyed address and the correct local alias.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard now pulls user.csv from radioid.net on a daily schedule,
filters it to the operator's configured country/callsign/ID scope, and
hot-swaps the in-memory radio_id -> callsign map without a restart. No more
stale callsigns when someone new gets their DMR ID.
Pipeline (dashboard/user_db.py):
- Stage 1: conditional HTTPS GET (If-Modified-Since) against
database.radioid.net/static/user.csv with identifying User-Agent. Uses
stdlib urllib.request, gzip-aware, 100 MB safety cap, 120s timeout. No
new dependencies.
- Stage 2: stream through csv.DictReader applying country / callsign_regex
/ radio_id_range filters. Default countries are ["United States",
"Canada"], reproducing the current scripts/filter_user_csv.py behavior
byte-for-byte.
- Stage 3: atomic write to data/user_db.json (stringified dict, ~2.4 MB vs
the current 7.7 MB CSV), sidecar metadata, then wholesale replacement of
the in-memory dict reference — single bytecode op under the GIL, no lock
needed. Readers see either the old or new dict, never a partial state.
Failure handling: any stage fault leaves the previous snapshot in place
and logs a warning. A filter that produces fewer than
fallback.min_rows_required rows is treated as a malformed download and
rejected. 304 Not Modified touches the timestamp so the "too stale" check
doesn't re-fire on every restart. The guiding rule: callsign lookups must
never get worse than they were before.
Scheduling (user_db_refresh_task coroutine):
- Catch-up refresh at startup if the snapshot is missing or older than
on_startup_if_older_than_hours (default 36).
- Then schedules the next run at time_of_day + [0, jitter_minutes)
random offset. Defaults: 03:17 local, 15 min jitter — deliberately
off-hour and off-minute to avoid the top-of-hour stampede hitting
radioid.net from Pi-Star fleets every night.
- "hourly" in config is clamped to "daily" with a warning. Upstream only
regenerates daily; sub-daily polling is pure waste.
Config: new user_database block in dashboard/config.json with source URL,
User-Agent (with explicit contact placeholder), schedule, filter rules, and
fallback thresholds. Sample and defaults in load_config() match. Startup
warns if the placeholder contact 'operator@example.org' wasn't changed so
radioid.net can reach the operator if there's ever an issue.
Dashboard UI: small "Callsign DB: N entries, updated Xh ago" indicator to
the right of the "Last Heard" section header, with a verbose hover tooltip
showing source URL, last refresh status, upstream Last-Modified, and last
error if any. Gold when the last refresh failed; re-ticks every 60s
client-side. The single call site that consumes user_database (stream_start
handler) was updated from module global to state.user_db.
scripts/filter_user_csv.py gets a deprecation notice but stays functional
for air-gapped workflows. Its filter logic now lives in
dashboard.user_db.filter_rows_from_csv_bytes() as a pure function so the
legacy script could be rewritten to import it without duplicating rules.
Design rationale and sources documented in
docs/user_csv_automation_proposal.md — notably, radioid.net's terms
explicitly flag "bulk mirroring" of the JSON API as blockable behavior, so
the daily static-CSV pull is the intended channel (Pi-Star, HBMonv2, and
others all use this same URL). The dashboard now follows the same
convention.
Locally verified: filter round-trips the existing user.csv to 136,613
rows in ~750ms, snapshot is 2.41 MB on disk, and the import path cleanly
wires into DashboardState without regressing existing stats/last-heard
persistence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surface DMRD translation state to sysops in two places where it was
previously invisible: log lines and the dashboard.
Logging:
- Add fmt_ts_tg() helper in utils that renders "TS/TGID: 2/9" for the
untranslated case and "TS/TGID: 2/9 (rf: 1/3172)" when rf-side differs
from net-side. Network side is always primary since ACLs, subscriptions,
and routing all reason in that vocabulary.
- Update every stream-lifecycle log (RX started, TX started, contention,
hang-time same-user / hijack, inbound routing denied, outbound RX,
outbound forwarding). Source-RX and TX-assumed sites carry both sides
when translation is in play; outbound and unit-call sites carry net-only.
- UNIT CALL log now uses TS/RID format since unit calls target a radio
ID, not a talkgroup, and are never translated.
- _update_assumed_stream() gained optional net_slot/net_dst_id so target-
side logs can annotate rf-local values with their net-side origin.
Dashboard:
- Emit translations as [rf_slot, rf_tgid, net_slot, net_tgid] tuples on
repeater_connected / repeater_options_updated / repeater_details so the
frontend can render an RF-side view without server-side rewrites.
- Repeater cards now display TGIDs in RF vocabulary (rebucketed by rf_slot).
Translated TGIDs are gold and carry a tooltip back to the net-side value.
A 🔀 badge sits between the connection-type icon and the callsign when a
repeater has any translation rules active.
- Last Heard gains a per-transmission 🔀 emoji — only when THIS specific
(slot, talkgroup) matched a translation rule on the source repeater.
Tooltip shows the exact mapping. Outbound-connection ingresses skip the
lookup (no local translation state to reference).
- source_repeater_id added to Last Heard entries for local repeater
ingresses so the client can cross-reference translations.
Docs:
- dmrd_translation.md: new "Processing order" section spelling out that
translation is the first thing on ingress and last on egress, so ACL /
contention / hang-time / routing all operate in network vocabulary.
- configuration.md and routing.md: cross-reference the processing order
in the ACL and contention sections so the network-vocabulary rule isn't
easy to miss.
Zero overhead for untranslated repeaters: inbound_map / outbound_map are
empty-dict short-circuits, rfTgLists() passes through, and fmt_ts_tg()
collapses to a single-value render when rf == net.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trusted repeaters can now declare per-entry slot/TGID translation rules in
their RPTO (OPTIONS) string, plus an optional single-ID rf_src override for
outbound group voice.
Grammar (operator-facing, goes into MMDVM.ini / DMRGateway.ini):
TS1 = entry[,entry...]
TS2 = entry[,entry...]
SRC = radio_id
entry = net_tgid_spec[:local_slot[:local_tgid]]
net_tgid_spec: N | N-M (range, specificity 2) | N (exact, specificity 3)
local_slot: 1 | 2 | * (preserve net slot)
local_tgid: N | * (preserve matched net tgid)
Wildcards on the network side (*, N*) are rejected at parse time to keep
the hot path O(1). Ranges expand to concrete map entries at RPTO parse
time (capped at 10,000 tgids). Most-specific rule wins on collision;
losers are dropped with a warning.
Runtime:
- RepeaterState grows inbound_map / outbound_map (inverses) and a single
tx_src_override.
- _check_inbound_routing translates local -> network before the ACL check
so subscriptions stay in one vocabulary.
- _calculate_stream_targets uses each target's outbound_map to run the
slot-busy / hang-time check against the slot the packet will actually
occupy on air.
- _forward_stream (and the outbound->local forwarding path) rewrite
bytes 5-7 (rf_src via override), 8-10 (tgid), 11-14 (repeater id for
outbound targets), and the slot bit in byte 15. Data-sync frames
(frame_type==2) get bytes 20-52 zeroed so MMDVMHost's BPTC decode
fails and it falls back to the rewritten DMRD header; voice frames
keep AMBE vocoder bits intact.
- Untranslated repeaters pay zero overhead (empty-dict short-circuits).
- Unit/private calls are still rejected upstream; SRC= has a defensive
group-only gate regardless.
- Mid-stream RPTO logs a warning and applies new rules immediately;
in-flight streams finish on their cached routing.
Validated via parser unit tests (exact/range/collision/error cases, NAT
grammar, specificity precedence) and module imports. NOT yet validated
on live DMR traffic — that's what this branch is for.
Docs:
- docs/dmrd_translation.md: full feature reference (new)
- docs/configuration.md: trust + RPTO extensions section
- docs/connecting_to_hblink4.md: operator Options= examples for
MMDVMHost / DMRGateway (Pi-Star/WPSD references stripped — out of scope)
- docs/protocol.md: extended RPTO grammar note
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Changes to hblink4/hblink.py:
- Renamed our_id → radio_id (more consistent with DMR terminology)
- Added colorcode, software_id, package_id fields to OutboundConnectionConfig
- Updated _send_outbound_config() to use configurable values instead of hardcoded
- All event emissions updated to use 'radio_id' field name
Changes to config/config_sample.json:
- Fixed field names: host→address, passphrase→password, our_id→radio_id
- Added all optional HBP metadata fields with proper defaults
- Shows comprehensive example with full metadata and minimal example
Changes to docs/configuration.md:
- Added complete Outbound Connections section
- Documents all required and optional fields
- Explains options format (TS1=x,y;TS2=a,b)
- Covers bidirectional traffic, multiple connections, DNS resolution
- Examples showing different configuration scenarios
CRITICAL documentation fixes:
- Hang time reserves slot for same TALKGROUP (or original user)
- Fixed typical transmission duration: 30-90 seconds (not 0.6-3.6s)
- Removed confusing echo/loop prevention discussion (half-duplex is obvious)
- Updated all examples to reflect actual implementation
Implementation reference: hblink.py lines 2173-2183 (same_tg or same_user)
- Complete design specification for server-to-server linking
- Config schema based on MMDVM.ini format
- State management with separate dictionaries for O(1) routing
- ID conflict protection to prevent DoS
- Connection lifecycle and error handling
- Dashboard integration requirements
- Implementation checklist with 11 phases
The goal was always to move to asyncio from twisted, however, it was necessary
to develop the asyncio solution over time. Now that it is stable, the
twisted version is being moved to a legacy branch, and the asyncio will
become the main branch for continued development
- Add early detection of unit/private calls (call_type_bit == 1)
- Log unit calls at INFO level with clear identification
- Reject unit calls until full implementation ready (return False)
- Update TODO with comprehensive 8-phase unit call implementation plan
- Move code refactoring to 'Ongoing Priority' section
- Document user cache enhancement needed (slot tracking)
- Document routing strategies: one-to-one and broadcast with pruning
- Document slot compatibility requirements (no slot translation)
- Document hang time and contention handling for unit calls
- Include performance monitoring and testing strategies
- Fix whitespace alignment in stream_tracking_diagrams.md
Features:
- Add trust flag to RepeaterConfig (default: false)
- Trusted repeaters can use any TG dynamically via OPTIONS
- Config TGs become defaults instead of restrictions
- Use for core network, testing, or administrative repeaters
- Make default repeater config optional
- Omitting default rejects non-matching repeaters
- Provides better security by requiring explicit config
Code improvements:
- Remove deprecated _is_talkgroup_allowed() method (never called)
- Remove defensive exception masking from _load_repeater_tg_config()
- Implement fail-fast design - bugs crash immediately with clear traceback
- Authentication at line 1120 is single point of truth for config validation
Documentation:
- Update configuration.md with trust flag examples and security notes
- Explain optional default behavior
- Update config_sample.json with trusted repeater example
- Allow patterns to specify multiple match types (ids, id_ranges, callsigns)
- Patterns evaluated in config order (first match wins) with OR logic
- Callsign '*' matches all repeaters (useful for catch-all patterns)
- Emit repeater_connected event when any packet clears missed_pings
- Update documentation to reflect pattern matching behavior
- Remove outdated config fields from docs (enabled, timeout, talkgroups)
- Update tests to match new pattern evaluation order
Added configuration examples and guidance for using TCP transport with
dashboard on a remote machine. Same dual-stack considerations apply as
the UDP server when using TCP for remote dashboard connections.
Options documented:
- Localhost: No dual-stack issues (127.0.0.1 / ::1)
- Remote dual-stack: host_ipv4="", host_ipv6="::", single port
- Remote separate ports: If dual-stack bind conflicts
- Remote IPv4-only: Simplest for cross-machine deployments
HBlink4's event emitter already tries IPv6 first with automatic IPv4
fallback, so dual-stack works seamlessly.
When IPv6 bind fails with 'address already in use' on the same port as IPv4,
provide helpful warnings explaining this is normal dual-stack behavior and
offer three clear solutions.
Code changes:
- Detect 'address already in use' errors specifically
- Provide context-aware warnings with actionable solutions
- Changed log from 'IPv6/dual-stack' to just 'IPv6' (more accurate)
Documentation changes:
- Added 'Common Issue' section explaining dual-stack port conflicts
- Recommend using separate ports as primary solution
- Document IPv4-only and IPv6-only alternatives
- Updated examples to show port_ipv4: 62031, port_ipv6: 62032
Production-tested solution for the common dual-stack bind error.
Added examples showing multiple ID ranges in a single pattern for both
blacklist and repeater configuration sections. This feature already works
in the code but wasn't clearly documented.
Examples added:
- Blacklist: Multiple blocked ranges in one pattern
- Repeater configs: Regional network covering multiple DMR-MARC regions
- Both config_sample.json and configuration.md updated
Example usage:
"id_ranges": [[310000, 310999], [311000, 311999], [312000, 312999]]
This is much cleaner than creating separate patterns for each range!
- Note that most firewalls handle both protocols with single rule
- Explain when separate IPv4/IPv6 rules might be needed
- Make port purposes clearer in comments