An XLX system is a MODE: OUTBOUND system with an XLX_MODULE letter set. XLX's
DMR interface is the homebrew protocol unmodified -- DMRGateway uses the same
CDMRNetwork class for XLX and BrandMeister, and there are no XLX conditionals in
its network layer -- so this adds no new mode, subclass, or data path. The only
protocol difference is one in-band private call at connect that selects the
module, plus pinning the connection to TS2/TG9.
Module binding is static by design. Runtime or end-user module selection would
break the nailed-up systems/bridges model; DMRGateway is the right tool for
user-selectable modules at a repeater or hotspot.
TS2/TG9 is injected by expand_xlx_bridges rather than read from rules.py, so
XLX_BRIDGES is a flat {system: bridge} map with no TS or TGID column. This is
not a default -- it makes a wrong value inexpressible. That matters more here
than elsewhere because every XLX failure is silent: the reflector never
acknowledges a link, and no frame on the wire carries a module identity, so a
mistyped talkgroup would leave HBlink looking healthy while traffic vanished.
The existing conference-bridge retarget path already rewrites the header dst,
flips the slot bit, and regenerates the full and embedded LC, so no XLX-specific
rewrite code is needed -- an XLX system is just a bridge member with a fixed
TS/TGID.
An XLX system in UNIT is a startup error. Reflector module selection *is* a
private call to 4001-4026, so forwarding a unit call to one could silently move
the reflector into a different room for every user connected to it.
Behaviour verified against xlxd source (src/cdmrmmdvmprotocol.cpp):
* Five acceptance gates at :636-655, each of which silently drops the packet:
exactly 55 bytes, DATA_SYNC frame type, TS2, slot type VOICE_LC_HEADER, and
a valid BS/MS data sync. The 55-byte requirement is why the packet carries
two trailing pad bytes; a bare 53-byte DMRD is rejected.
* Only the three header frames are parsed for the link command; the two
terminators have slot type 2 and are ignored, but are sent to frame the call.
* Unlink-then-link is required, not defensive. At :297-325 a link naming a
different module while already linked is discarded and rewritten to the
module already held, so a reconnect that skipped the unlink would silently
stay in the previous room.
* xlxd reads the destination from DMRD header bytes 8-10 and never decodes the
LC (its BPTC decode is commented out at :657-660). The LC is still built
honestly from the same src/dst: a packet whose payload contradicts its header
is a debugging trap for anything that does decode it.
Note that a stock xlxd is built with NB_OF_MODULES = 10, so only modules A-J
exist unless the operator rebuilt it. A-Z is accepted here since the protocol
allows it, but an absent module produces no link and no error.
tests/test_xlx_link.py adds 35 tests: the acceptance gates transcribed from
xlxd's parser, packet structure, XLX_BRIDGES expansion and its guards, and
XLX_MODULE validation. One test reproduces the canned bursts from the 2019
reference implementation byte for byte, from their own decoded src/dst and
colour code, which pins the burst builder against six years of field-proven
behaviour.
No new dependencies: bitarray and dmr_utils3 were already required.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OpenBridge systems are point-to-point trunks that pass talkgroups by TGID: no RF
user to key ON/OFF/TIMEOUT triggers, no meaningful timeslot. Configuring them as
inline BRIDGES members was verbose and made every trunk TGID carry dead trigger
machinery. They now live in a compact per-OBP table in rules.py:
OBP_BRIDGES = { <OPENBRIDGE system> : { <bridge name> : <TGID> } } # TS 1, or (TGID, TS)
expand_obp_bridges() (bridge.py) expands each row into a synthetic bridge member
with triggers hard-wired inert (TO_TYPE='NONE') and appends it to the named
bridge, so the forwarding path and indexes run UNCHANGED. Because an unmapped
TGID is never a routing key, the fail-closed ingress/egress filter falls out for
free -- no separate ACL. A TGID-heavy trunk now costs zero rule-timer work.
Load-time validation (design spec §3.3/§3.5):
* OBP system left as an inline BRIDGES member -> hard ERROR
* OBP_BRIDGES entry that isn't an enabled OPENBRIDGE system -> hard ERROR
* one TGID -> two bridges on a single OBP (ingress fork) -> hard ERROR
(the mirror, a bridge with two TGIDs on one OBP, is unrepresentable in the
{bridge: tgid} dict, so it needs no check)
* a bridge carrying different TGIDs across two OBPs (renumber-in-transit)
-> non-blocking WARNING
OBP_BRIDGES is optional (getattr fallback), so non-OBP configs are unaffected.
tests/test_obp_bridges.py covers expansion, TS default/override, and every
validation branch (10 tests). rules_SAMPLE.py + CONFIGURING.md document the model.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New CONFIGURING.md is the full field reference for hblink.cfg (GLOBAL,
REPORTS, LOGGER, ALIASES, and the SERVER/OUTBOUND/OPENBRIDGE system
modes) and rules.py (BRIDGES/UNIT), matching the cc2obp CONFIGURING.md
house style. hblink-SAMPLE.cfg and rules_SAMPLE.py comments are trimmed
to terse one-liners that point here.
Notable content:
- Server/Client terminology throughout (a "master" is a Server; Clients
are repeaters/hotspots, or an OUTBOUND HBlink masquerading as one).
- Hard warning that a rules.py SYSTEM with no matching hblink.cfg system
stops bridge.py from starting.
- ALIASES memory warning: full subscriber DB ~500 MB per process, >1 GB
if the dashboard loads it too; use dashboard-only or filter via
FILTER_COUNTRIES.
- BrandMeister prohibits OUTBOUND/Client links and requires OpenBridge.
- BOTH_SLOTS is HBlink-to-HBlink only; no other server accepts it.
- systemd/journal logging note; pointer to ACLS.md for the ACL model.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
unit calls should be working on HBP and OBP systems. Also adds the ability to send TS data in OBP for unit calls to preserve timeslot routing for unit calls.