Without LOG_HANDLERS the logger defaults to console-timed, so the
example's LOG_FILE was never used and the gateway logged to the journal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
- ENDPOINT must be https:// (http only for localhost); a 3xx is a
failure and is never followed, so the bearer token cannot travel in
cleartext or reach another host.
- The loop follows REPORT_INTERVAL after a config reload (the
LoopingCall's interval is updated on each tick).
- adn-server.example.yaml: the section, commented out, with
placeholders.
- Without the integrated proxy, hbp_ports skips MASTERs bound to
loopback (internal, e.g. a local gateway's master).
- Tests: reload (new token and interval, removing the section stops the
reports), https rule, redirect not followed, loopback masters, peers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
Optional section ADN_PANEL in adn-server.yaml, added by hand by the
sysop with the token generated for the server in the ADN panel; it is
not part of the config template. Without ENDPOINT and TOKEN nothing is
sent.
Every REPORT_INTERVAL (default 300 s, minimum 60; first one a minute
after start) the server POSTs to <ENDPOINT>/ingest/server-report:
version, server ID, uptime, the HBP port hotspots connect to, the ODTP
port (ODTP_PORT, since the gateway is its own service), the OBP proxy
port, connected peers, OpenBridge links with their keepalive state and
the plugins loaded. No IP addresses, passphrases or user IDs.
- The payload is built on the reactor from the dashboard state; the
POST runs in a thread with a 10 s timeout, so a slow or unreachable
panel never delays traffic.
- A failure is logged once (401/403 hint at the token) and again when
it recovers; the token is never logged.
- Settings are read on every tick and ADN_PANEL follows config reloads,
so a new token or removing the section applies without a restart.
- Config validation for the section; docs in en/es (monitoring guide).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
glibc gives every thread its own malloc arena; reloads spread over the
reactor pool left each arena holding the fragmented peak of one reload,
so the RSS climbed with every list change. A tick that finds a reload
still running is skipped (the superseded-generation logic is gone). The
thread starts with the reactor and stops before shutdown.
Closes#167
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
Each terminal logs in to the MASTER as one HBP peer with OPTIONS TERMINAL=1,
relaying the MASTER's salt as its challenge. Voice, embedded data, text
messages and locations go both ways; a call without a terminator ends 1 s
after its last frame, on its own timer. A terminal always talks as its
session's DMR ID (a 9-digit login as login // 100). Sessions are capped in
total, per IP and per minute; only a login that opens a MASTER link counts.
Tests run against a real HBPProtocol MASTER.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
A peer whose OPTIONS carry TERMINAL=1 (an app behind the ODTP gateway) gets
only the statics in its own OPTIONS, never the MASTER's TS1/TS2_STATIC. A
radio behind a hotspot mutes the MASTER's statics with its RX group list; an
app has none and plays everything it receives, so a user on TG 666 heard the
TG 214 beacon of a MASTER with TS2_STATIC 666,214.
cached_peer_static_tgs is the one place the downlink gate, the downlink index
and the reports read a peer's statics from, so the flag is checked there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
Data header, rate 1/2 and rate 3/4 blocks (trellis with hard-decision
Viterbi), CSBK preambles, the packet CRCs (CRC-16 header/CSBK, CRC-32,
CRC-9), IPv4/UDP with MOTOTRBO TMS and "DMR standard" SMS, and Hytera
HDAP/TMP, next to bptc, crc and rs129. bptc gains decode_19696 (the 96
data bits, beside encode_19696); the slot types come from const. DMRD
framing is one dmrd_frame() in frames.py, shared with DmrdStream.
Tested against on-air Hytera captures (location report, registration,
SACK) as well as round trips.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
parse_dmrd_header and DmrdHeader move from the plugin package to
domain/dmrd.py so other layers can use them; plugins/domain/send.py
re-exports them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
The Talker Alias path reassembled the embedded LC of bursts B-E inline.
collect_embedded_lc() (domain/dmr/embedded_lc.py) does it for any LC,
with the same fragments, decode and errors, and the Talker Alias uses it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
pkt_gen built the header, the A-F bursts and the terminator in one
generator. DmrdStream (domain/dmr/frames.py) builds them one at a time
and pkt_gen now uses it; its output is byte for byte the same
(test_pkt_gen_golden pins the old digest). voice_bits() takes the 216
voice bits of a burst, as the recorder did inline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
Moves the RPTL/RPTK/RPTC/RPTO exchange and the RPTPING keepalive out of
the PEER mode of HBPProtocol into application/hbp_peer_login.py, with
the same states, stats and log lines. The MASTER's salt goes to an
on_salt hook, so a caller can relay it and send_key() later. The PEER
mode builds its PeerLogin with the system, and again on a hot reload.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
The application layer needs them for the PEER login; hbp_constants
re-exports them, as it already does with the frame types.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
On the inject-only proxy MASTER (or any MASTER with MAX_PEERS > 1) the hourly
voice ident never played. Now VOICE_IDENT in the YAML allows it and each
hotspot that asks with VOICE=1 (or IDENT=1) in its OPTIONS gets its own ident
on TS2, sent to that hotspot only, with its callsign, LANG and VOICETG/IDENTTG,
once it has been quiet for 30 s. On a shared MASTER a hotspot's OPTIONS no
longer change VOICE_IDENT or the ident TG for the others. An empty
OVERRIDE_IDENT_TG now means all-call instead of failing the ident.
The bootstrap's LoopingCall errback got its arguments reversed, so it raised
itself and the loop died without a proper log; the HBP maintenance, OBP
keepalive/DNS and self-service loops had no restart at all. A dead
maintenance or stream trimmer loop leaves peers and streams that never time
out. Every periodic task now goes through start_loop: the error is logged
with its traceback and the loop is scheduled again at its interval.
peer_matches_rf_source() runs for every hotspot on every group voice frame
(_peer_should_receive_dmrd). It matched every peer of the MASTER against the
source with _fuzzy_peer_matches() and then looked for this peer in the list:
O(peers) per hotspot, O(peers^2) per frame, each step converting a key to int
and str. The answer only depends on this peer's own ID, so it is now checked
directly with the same rule (_peer_id_matches, shared with
_fuzzy_peer_matches). Peers stored with int/str keys keep the scan, which
normalises them.
Measured with tests/support/hbp_repeat_stack (REPEAT MASTER, group voice on a
static TG, 2,880 frames x 3 runs, Raspberry Pi 5), µs per frame:
peers 1: 143 -> 146
peers 5: 600 -> 572
peers 20: 2711 -> 2079 (-23 %)
peers 50: 8792 -> 5126 (-42 %)
Tests: direct cases (suffix, own ID, 5-digit prefix rule) and 300 randomised
peer tables (bytes, int and str keys) checked against the previous
implementation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
Slot RX_TYPE/TX_TYPE stored the frame's raw dtype/vseq, so voice burst C
(vseq 2) looked like HBPF_SLT_VTERM. Record voice bursts as
SLOT_TYPE_VOICE | vseq via domain slot_frame_type at every write site.
_pvt_call_received kept a private call's targets (_pvt_targets,
_pvt_target_peer_ids, _pvt_same_system_dst_peer_id) on the
RoutingUseCases instance, so every private call on the server shared
them. When two private calls overlapped on different systems, the
second call's header re-targeted the first call's frames to the second
call's destination, and the second call's terminator emptied the
targets, so the rest of the first call reached nobody.
Keep them in the source slot's state, which already holds the rest of
the call's RX state. They are written and read at the same points as
before, including the clear on the terminator.
A per-stream bridge TX leg on a MASTER slot is popped by its terminator, so a leg still there after 5 s lost it. The trimmer also required TX_TYPE != VTERM, but TX_TYPE holds the frame's dtype_vseq and voice burst C carries 2, the terminator's value: a call that lost its terminator right after burst C kept its leg forever and never got its END TX.
Loss divided the skipped frames by the frames received, so a stream resuming after a
pause (a jump of 150 with 51 frames received) logged 292 %. It also counted repeated,
late and out-of-order frames as lost, and a late frame twice: once in the jump it fell
into and once when dropped. Those now go to their own "dropped" counter, and Loss is
skipped / (received - dropped + skipped).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LweTug2oJgREHKxEUuE38Q
_send_data_to_hbp rebuilt each frame as header + burst and dropped the two
BER/RSSI octets, so every unit-data frame went out 53 bytes long. DMRGateway
only accepts DMRD frames of exactly 55 bytes (g4klx/DMRGateway DMRNetwork.cpp,
HOMEBREW_DATA_PACKET_LENGTH), so these frames never reached the radio.
It went unnoticed because a peer-originated data stream also reached the
hotspot through the private-call path in full. Frames sent by a plugin
(ServerContext.send_dmrd) only take the unit-data path: a D-APRS plugin's
answers reached the hotspot but the Hytera/MOTOTRBO radio never heard them
(seen on air 24-oct-2026: same bytes accepted at 55, ignored at 53).
Keep the received BER/RSSI octets (zero when absent).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LweTug2oJgREHKxEUuE38Q
The MASTER local REPEAT (_pvt_repeat_targets) already delivers a private
unit-data frame to the destination hotspot when both are on the same MASTER;
the SUB_MAP lookup in RoutingUseCases sent it a second time through
_send_data_to_hbp. The hotspot got every CSBK/header/block twice, which
breaks unconfirmed (rate 1/2) SMS reassembly on the radio. Same rule as
private voice. Plugin frames still go out through routing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LweTug2oJgREHKxEUuE38Q
The config normalizer, the settings lookup and the HBP embedded-LC buffer
all defaulted a missing key to false. Default it to true, matching the
example config; an explicit false still disables it.
With ALLOW_UNREG_ID off, the master compared the RPTC callsign with the
subscriber DB byte for byte. Older IPSC2HBP builds send it lower case
("hp3icc"), so after a good RPTL/RPTK the master answered MSTNAK
("Callsign does not match subscriber database: Sent Call: hp3icc, DB
call HP3ICC") on every attempt, while BM, XLX and other masters accepted
the same repeater.
Callsigns are case-insensitive: compare both sides upper-cased. An
unknown ID (validate_id() returns False) is still rejected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ma9qC57Hrw7b4dZWoErMp4