Compare commits

..
4 Commits
Author SHA1 Message Date
Cort BuffingtonandClaude Opus 5 3fa1b32f16 Add XLX reflector support
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>
2026-08-16 10:14:30 -05:00
Cort BuffingtonandClaude Opus 5 60d67405a2 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:14 -05:00
Cort BuffingtonandClaude Opus 4.8 f81872b60c Stop tracking site-specific dashboard logo images
KS-DMR*.png are the operator's own branding (referenced by the gitignored
dashboard/config.py LOGO_FILE), not project assets -- they were swept in by a
`git add -A` in the previous commit. Untrack them and gitignore dashboard/*.png
so they stay local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 14:17:50 -05:00
Cort BuffingtonandClaude Opus 4.8 aae53890f7 archive: move legacy/unported modules out of the live tree
Nothing in the asyncio core imports any of these; they are moved to archive/
(history preserved) and dropped from pyproject.toml py-modules:

  * bridge_all.py -- forward-everything proxy, never ported to the asyncio core
    (imports the Twisted-era reportFactory -> ImportError today).
  * playback.py -- talkback/parrot app (connects like a repeater, plays a stream
    back). Twisted-based; earmarked to return as an OmniLink adapter.
  * play_ambe.py / mk_voice.py / voice_lib.py -- Twisted-era voice test utilities.
  * reporting_const.py -- REPORT_OPCODES for the old pickle/opcode reporting
    protocol, superseded by the NDJSON feed; it was imported but never used.

Also removes the now-dead `from reporting_const import *` (hblink.py) and the
unused REPORT_OPCODES name from bridge.py's hblink import. Docs/service updated
(README app table, INSTALL run + legacy note, hblink3.service comment,
dashboard/FIELDS.md); archive/README.md explains what's there and why. Suite
still 74/74.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 14:17:02 -05:00
21 changed files with 633 additions and 14 deletions

No files matched your search

+4
View File
@@ -102,3 +102,7 @@ talkgroup_ids.*
# Dashboard local config (copy from dashboard/config_sample.py)
dashboard/config.py
dashboard/__pycache__/
# Site-specific dashboard branding (operator's own logo; not shipped)
dashboard/*.png
dashboard/*.jpg
+1 -3
View File
@@ -64,8 +64,6 @@ Every system named in `rules.py` must exist and be enabled in `hblink.cfg`. **Up
source venv/bin/activate
python bridge.py -c hblink.cfg -r rules.py # conference-bridge router (main app)
# or
python bridge_all.py -c hblink.cfg # forward-everything proxy
# or
python hblink.py -c hblink.cfg # protocol core, standalone server/outbound
```
@@ -96,4 +94,4 @@ sudo systemctl restart hblink3
## Notes
- The legacy voice utilities (`playback.py`, `play_ambe.py`) are **not** migrated to asyncio and will not run against the current core.
- Legacy, unmaintained code lives in [`archive/`](archive/) and is excluded from packaging. It includes the Twisted-era voice utilities (`playback.py`, `play_ambe.py`, `mk_voice.py`, `voice_lib.py`), the old opcode reporting constants (`reporting_const.py`), and `bridge_all.py` (a forward-everything proxy that was never ported to the asyncio core). None of it runs against the current core.
+11 -1
View File
@@ -15,7 +15,6 @@ HBlink3 is an open-source implementation of the DMR **HomeBrew Repeater Protocol
| Program | Purpose |
|---|---|
| `bridge.py` | The main application: a configurable conference-bridge call router with dynamic on/off triggering, timeouts, and private (unit) call routing. |
| `bridge_all.py` | A simple proxy that forwards all traffic between every configured system — makes several repeaters appear as one. |
| `hblink.py` | The protocol core. Runs standalone as a server/outbound system for testing, and is the module the applications are built on. |
## Features
@@ -109,6 +108,17 @@ information -- preferencing voice quality and integrity over all else.
database for ID to callsign mapping. Inclusion of Talker Alias may be
considered in the future.
## Companion programs
Separate repositories by the same author. All are small, dependency-free C
programs that run alongside HBlink3 and connect to it over the network.
| Program | Purpose |
|---|---|
| [dmr-talkback](https://github.com/n0mjs710/dmr-talkback) | Voice test ("echo") endpoint. Connects as an ordinary HBP repeater, records a call and plays it back so the caller hears their own audio. Answers group calls, private (unit) calls, or both. Give it a dedicated bridge for its talkgroup in `rules.py`. |
| [ipsc2hbpc](https://github.com/n0mjs710/ipsc2hbpc) | IPSC ⇄ HBP translator. Connects Motorola IPSC systems (and c-Bridge IPSC peers) to an HBlink3 system. |
| [cc2obp](https://github.com/n0mjs710/cc2obp) | c-Bridge CC-CC ⇄ OpenBridge translator. Peers an HBlink3 OpenBridge system with a c-Bridge over its native CC-CC link. |
## Requirements
- Python, at least, **3.8+** (Linux recommended)
+17
View File
@@ -0,0 +1,17 @@
# archive/
Legacy, unmaintained code kept for reference. **None of it runs against the
current asyncio core**, and it is excluded from packaging (`pyproject.toml`).
Nothing in the live code path imports anything here.
| File | What it was | Why it's here |
|---|---|---|
| `bridge_all.py` | A "forward-everything" proxy (every system bridged to every other). | Never ported to the asyncio core — still imports the Twisted-era `reportFactory`, so it fails at import. |
| `playback.py` | A talkback/parrot app: connected like a repeater and played a call stream back so users could hear themselves. | Twisted-based. **Earmarked to return as an OmniLink adapter** — running it as a separate process that connects like a repeater (tied to a system or bridge) keeps the repeat-back off the routing hot path, which is why it was standalone here too. |
| `play_ambe.py` | Played canned AMBE/voice sequences into a system for testing. | Twisted-based; depends on the voice utilities below. |
| `mk_voice.py` | Built synthetic voice/AMBE packet sequences. | Only used by `play_ambe.py`. |
| `voice_lib.py` | Word/AMBE sample library for the voice utilities. | Only used by `mk_voice.py` / `play_ambe.py`. |
| `reporting_const.py` | `REPORT_OPCODES` for the old pickle/opcode reporting protocol. | Superseded by the newline-delimited-JSON reporting feed; no longer referenced. |
If any of these is revived, port it to the asyncio core and the NDJSON reporting
feed first.
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
+64 -2
View File
@@ -38,7 +38,7 @@ import importlib.util
import asyncio
# Things we import from the main hblink module
from hblink import HBSYSTEM, OPENBRIDGE, systems, hblink_handler, ReportServer, config_reports, REPORT_OPCODES, mk_aliases, run_periodic
from hblink import HBSYSTEM, OPENBRIDGE, systems, hblink_handler, ReportServer, config_reports, mk_aliases, run_periodic
from dmr_utils3.utils import bytes_3, int_id, get_alias
from dmr_utils3 import decode, bptc, const
import config
@@ -188,6 +188,65 @@ def expand_obp_bridges(_bridges, _obp_bridges):
return _bridges
# XLX reflector connections are configured in their own table (XLX_BRIDGES in
# rules.py), mapping each XLX system to exactly one conference bridge:
#
# XLX_BRIDGES = { 'XLX950-D': 'WORLDWIDE' }
#
# There is deliberately no TS or TGID column. Every XLX module presents as TS2/TG9
# on the wire, so those are protocol constants injected here, never read from the
# rules file. This is not merely a default: a wrong value would produce traffic
# that leaves HBlink looking healthy and is silently discarded by xlxd, which never
# acknowledges anything and puts no module identity on the wire -- there would be no
# symptom to debug. The flat {system: bridge} shape makes a wrong value inexpressible.
#
# No timers, triggers, or ON/OFF/RESET lists: there is no end user on an XLX
# connection to send them.
def expand_xlx_bridges(_bridges, _xlx_bridges):
def _is_xlx(_sys):
return _sys in CONFIG['SYSTEMS'] and CONFIG['SYSTEMS'][_sys].get('XLX_MODULE')
# Reject inline XLX membership -- keeping them in one table is the whole point.
for _bridge, _members in _bridges.items():
for _m in _members:
if _is_xlx(_m['SYSTEM']):
sys.exit('ERROR: XLX system "{}" is an inline member of bridge "{}". '
'XLX systems belong in the XLX_BRIDGES table in rules.py, not in BRIDGES.'
.format(_m['SYSTEM'], _bridge))
for _xlx, _bridge in _xlx_bridges.items():
if not _is_xlx(_xlx):
sys.exit('ERROR: XLX_BRIDGES entry "{}" is not an enabled system with XLX_MODULE set '
'in the main configuration'.format(_xlx))
if not isinstance(_bridge, str):
sys.exit('ERROR: XLX_BRIDGES entry "{}" must map to a single bridge name. XLX systems '
'carry one module on one bridge; TS and TGID are not configurable.'.format(_xlx))
_bridges.setdefault(_bridge, []).append({
'SYSTEM': _xlx, 'TS': XLX_TS, 'TGID': XLX_TGID, 'ACTIVE': True,
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': [],
})
logger.info('(ROUTER) XLX system %s (module %s) bridged to "%s" on TS%s/TG%s',
_xlx, CONFIG['SYSTEMS'][_xlx]['XLX_MODULE'], _bridge, XLX_TS, XLX_TGID)
return _bridges
# An XLX connection must never receive a private call. The module binding is
# changed by a private call to 4001-4026, so a unit call forwarded to an XLX system
# could silently move the reflector into a different room for every user on it.
#
# Scrubbing UNIT is sufficient on its own: UNIT is the only source of unit-call
# targets (the unknown-destination flood path uses list(UNIT)), and reflector IDs
# are never in UNIT_MAP because that is populated only when a subscriber transmits.
def validate_xlx_unit(_unit):
for _sys in _unit:
if _sys in CONFIG['SYSTEMS'] and CONFIG['SYSTEMS'][_sys].get('XLX_MODULE'):
sys.exit('ERROR: XLX system "{}" cannot be a member of UNIT. A private call forwarded '
'to an XLX reflector would change its module for every connected user.'
.format(_sys))
# Build the routing indexes from the processed BRIDGES structure. Call once after
# make_bridges() (and again only if bridge membership is ever rebuilt at runtime).
# src_index: (system, ts, tgid) -> [(bridge_name, source_member, sibling_members), ...]
@@ -1091,13 +1150,16 @@ if __name__ == '__main__':
# Expand the per-OBP TGID<->bridge table (rules.OBP_BRIDGES, optional) into
# synthetic bridge members, then build the routing rules file.
_obp_bridges = getattr(rules_module, 'OBP_BRIDGES', {})
BRIDGES = make_bridges(expand_obp_bridges(rules_module.BRIDGES, _obp_bridges))
_xlx_bridges = getattr(rules_module, 'XLX_BRIDGES', {})
_expanded = expand_obp_bridges(rules_module.BRIDGES, _obp_bridges)
BRIDGES = make_bridges(expand_xlx_bridges(_expanded, _xlx_bridges))
# Build the per-frame routing lookup indexes from the rules
BRIDGE_SRC_INDEX, BRIDGE_BY_SYSTEM = index_bridges(BRIDGES)
# Get rule parameter for private calls
UNIT = rules_module.UNIT
validate_xlx_unit(UNIT)
# The asyncio entry point: signal handling, reporting, a UDP endpoint for each
# enabled system, and the rule-timer / stream-trimmer periodic tasks.
+32 -2
View File
@@ -113,6 +113,27 @@ def acl_build(_acl, _max):
ends = tuple(r[1] for r in merged)
return (action, frozenset(singles), starts, ends)
# Read the optional XLX_MODULE field for an OUTBOUND system. Presence of this
# field is what marks a system as an XLX reflector connection -- there is no
# separate mode or boolean, because XLX's DMR interface *is* the homebrew protocol
# and needs no special handling beyond one link packet at connect.
#
# Returns the module letter as an uppercase str, or '' if the system is not XLX.
# Note that a stock xlxd is built with NB_OF_MODULES = 10 (modules A-J); asking for
# a module the reflector does not have produces no link and no error message of any
# kind, so the value is validated here for form only.
def _parse_xlx_module(_config, _section):
if not _config.has_option(_section, 'XLX_MODULE'):
return ''
_module = _config.get(_section, 'XLX_MODULE').strip().upper()
if not _module:
return ''
if len(_module) != 1 or not ('A' <= _module <= 'Z'):
sys.exit('XLX CONFIGURATION ERROR: XLX_MODULE for system "{}" must be a single '
'letter A-Z, got "{}". Module numbers (4001-4026) are not accepted -- '
'the talkgroup is derived from the letter.'.format(_section, _module))
return _module
def build_config(_config_file):
config = configparser.ConfigParser()
@@ -210,6 +231,7 @@ def build_config(_config_file):
'PACKAGE_ID': bytes(config.get(section, 'PACKAGE_ID').ljust(40)[:40], 'utf-8'),
'GROUP_HANGTIME': config.getint(section, 'GROUP_HANGTIME'),
'OPTIONS': bytes(config.get(section, 'OPTIONS'), 'utf-8'),
'XLX_MODULE': _parse_xlx_module(config, section),
'USE_ACL': config.getboolean(section, 'USE_ACL'),
'SUB_ACL': config.get(section, 'SUB_ACL'),
'TG1_ACL': config.get(section, 'TGID_TS1_ACL'),
@@ -266,9 +288,17 @@ def build_config(_config_file):
except configparser.Error as err:
sys.exit('Error processing configuration file -- {}'.format(err))
# XLX_MODULE is only meaningful on an OUTBOUND system: it is a client-side link
# sent to an upstream reflector. Catch it on any other mode rather than silently
# ignoring it, since a misplaced field would otherwise look like it took effect.
for _system, _sysconfig in CONFIG['SYSTEMS'].items():
if _sysconfig.get('XLX_MODULE') and _sysconfig['MODE'] != 'OUTBOUND':
sys.exit('XLX CONFIGURATION ERROR: system "{}" is MODE {} but sets XLX_MODULE. '
'XLX connections must be MODE: OUTBOUND.'.format(_system, _sysconfig['MODE']))
process_acls(CONFIG)
return CONFIG
# Used to run this file direclty and print the config,
+17
View File
@@ -76,3 +76,20 @@ RPTO = b'RPTO'
# Higheset peer ID permitted by HBP
PEER_MAX = 4294967295
# XLX reflector constants. These are protocol values, NOT configuration -- every
# XLX module presents as TS2/TG9 on the wire and there is no module identifier in
# any frame. xlxd hardcodes the same pair (DMRMMDVM_REFLECTOR_SLOT = DMR_SLOT2,
# and dstId 9 in cdmrmmdvmprotocol.cpp), as does DMRGateway (XLX_SLOT/XLX_TG at
# DMRGateway.cpp:57-58). The "TG 6" seen in hotspot documentation is DMRGateway's
# configurable RF-side talkgroup, which it rewrites to TG9 before transmitting --
# HBlink peers directly with xlxd, so TG9 is what we use. Never make these
# settable: a wrong value produces traffic xlxd silently discards, with no ACK,
# no module identity on the wire, and therefore no observable symptom.
XLX_TS = 2
XLX_TGID = 9
# Module letter <-> reflector talkgroup. A-Z maps to 4001-4026; 4000 is UNLINK and
# is deliberately not a module.
XLX_UNLINK = 4000
XLX_TG_BASE = 4000
+2 -2
View File
@@ -114,8 +114,8 @@ for a non-local dashboard or a feed interruption).
## Conference Bridges
One table per conference bridge defined in `rules.py`, with a row per member
system. (Empty if you're running `bridge_all` or standalone `hblink.py`, which
have no conference-bridge rules.)
system. (Empty if you're running standalone `hblink.py`, which has no
conference-bridge rules.)
| Column | Meaning |
|---|---|
+51
View File
@@ -139,3 +139,54 @@ USE_ACL: True
SUB_ACL: DENY:1
TGID_TS1_ACL: PERMIT:ALL
TGID_TS2_ACL: PERMIT:ALL
# [OUTBOUND to an XLX reflector] — identical to the Client stanza above plus one
# field. XLX's DMR interface *is* the homebrew protocol, so no special mode exists:
# adding XLX_MODULE is what marks the system as a reflector connection.
#
# XLX_MODULE is the module (room) letter A-Z. HBlink sends the in-band private call
# that selects it immediately after login, and again after every reconnect, because
# the binding lives on the reflector for the life of the session.
#
# * The module is fixed. There is no runtime or end-user module selection -- that
# would break the nailed-up systems/bridges model. Use DMRGateway at a repeater
# or hotspot if users need to pick modules.
# * One module per connection. To bridge two modules, define two systems.
# * SERVER_PORT is 62030 and PASSPHRASE is usually passw0rd. LOOSE: True, since
# xlxd is not strict about the login handshake.
# * The reflector never acknowledges the link, and no frame on the wire says which
# module you are in. If traffic does not flow, check the reflector's dashboard --
# HBlink cannot tell you. Note a stock xlxd is built with 10 modules (A-J).
# * Bridge membership goes in XLX_BRIDGES in rules.py, not in BRIDGES.
#
# [XLX-950-D]
# MODE: OUTBOUND
# ENABLED: True
# LOOSE: True
# IP: 0.0.0.0
# PORT: 54002
# SERVER_IP: 172.16.1.1
# SERVER_PORT: 62030
# PASSPHRASE: passw0rd
# CALLSIGN: W1ABC
# RADIO_ID: 312000
# RX_FREQ: 449000000
# TX_FREQ: 444000000
# TX_POWER: 25
# COLORCODE: 1
# SLOTS: 1
# LATITUDE: 38.0000
# LONGITUDE: -095.0000
# HEIGHT: 75
# LOCATION: Anywhere, USA
# DESCRIPTION: XLX950 module D
# URL: www.w1abc.org
# SOFTWARE_ID: 20170620
# PACKAGE_ID: MMDVM_HBlink
# GROUP_HANGTIME: 5
# OPTIONS:
# XLX_MODULE: D
# USE_ACL: True
# SUB_ACL: DENY:1
# TGID_TS1_ACL: PERMIT:ALL
# TGID_TS2_ACL: PERMIT:ALL
+79 -1
View File
@@ -45,11 +45,13 @@ from bisect import bisect_right
import log
import config
from const import *
from bitarray import bitarray
from dmr_utils3 import bptc, golay
from dmr_utils3.const import BS_DATA_SYNC, LC_OPT_U
from dmr_utils3.utils import int_id, bytes_4, try_download, mk_id_dict
# Imports for the reporting server
import json
from reporting_const import *
# The module needs logging logging, but handlers, etc. are controlled by the parent
import logging
@@ -79,6 +81,15 @@ REPORT_RESYNC_SECONDS = 60
# repeater's ping cadence (median) before any loss is counted.
PING_WARMUP = 4
# Golay(20,8) slot type for a colour code and data type, as the 20 bits that sit
# either side of the sync in a DMR burst. dmr_utils3.const.SLOT_TYPE has these
# precomputed but only for colour code 1, and an XLX system uses whatever colour
# code its config declares.
def xlx_slot_type(_cc, _dtype):
_st = bitarray(endian='big')
_st.frombytes(golay.encode_2087(bytes([(_cc << 4) | _dtype])).to_bytes(3, 'big'))
return _st[4:24]
# Generic periodic-task runner replacing twisted's task.LoopingCall. Runs _func
# every _interval seconds. Unlike a bare LoopingCall, a raised exception is logged
# and the loop continues, so a transient error can't silently kill the timer.
@@ -398,6 +409,68 @@ class HBSYSTEM(asyncio.DatagramProtocol):
_packet = b''.join([_packet[:11], self._config['RADIO_ID'], _packet[15:]])
self.transport.sendto(_packet, self._config['SERVER_SOCKADDR'])
# --- XLX reflector module linking ---------------------------------------
#
# An XLX reflector selects its module (room) with an in-band private call to
# 4000+module on TS2 -- there is no field for it in the login handshake and no
# acknowledgement of any kind. The call is 5 DMRD frames sharing one stream ID:
# 3x VOICE_LC_HEADER then 2x TERMINATOR_WITH_LC.
#
# xlxd validates each frame at cdmrmmdvmprotocol.cpp:636-655 and silently drops
# anything failing: the packet must be exactly 55 bytes, DATA_SYNC frame type,
# TS2, slot type VOICE_LC_HEADER, and carry a real DMR data sync pattern. Only
# the header frames are parsed for the link command (the terminators have slot
# type 2 and are ignored); they are sent to frame the call correctly.
#
# xlxd reads the destination from the DMRD header and never decodes the LC (its
# BPTC decode is commented out), but we build the LC honestly from the same
# src/dst so the packet is not self-contradictory.
def send_xlx_link(self, _dst_id):
_src_id = int_id(self._config['RADIO_ID'])
_cc = int(self._config['COLORCODE'])
_stream_id = bytes([randint(0, 255) for _ in range(4)])
_lc = b''.join([LC_OPT_U, _dst_id.to_bytes(3, 'big'), _src_id.to_bytes(3, 'big')])
for _seq in range(5):
_term = _seq >= 3
_payload = bptc.encode_terminator_lc(_lc) if _term else bptc.encode_header_lc(_lc)
_st = xlx_slot_type(_cc, 2 if _term else 1)
# 33-byte burst: info | slot type | sync | slot type | info
_burst = _payload[0:98] + _st[0:10] + BS_DATA_SYNC + _st[10:20] + _payload[98:196]
# TS2 | private call | DATA_SYNC | slot type
_bits = 0x80 | 0x40 | 0x20 | (0x02 if _term else 0x01)
self.send_server(b''.join([
DMRD,
_seq.to_bytes(1, 'big'),
_src_id.to_bytes(3, 'big'),
_dst_id.to_bytes(3, 'big'),
self._config['RADIO_ID'],
_bits.to_bytes(1, 'big'),
_stream_id,
_burst.tobytes(),
b'\x00\x00', # xlxd requires exactly 55 bytes
]))
# Bind this connection to its configured XLX module. Sent on every transition
# into CONNECTION == 'YES', because the binding is per-session state on xlxd and
# is lost on any re-login.
#
# The unlink is not optional. While a client already holds a module, xlxd
# discards a link naming a different one and rewrites the header to the module
# already held (cdmrmmdvmprotocol.cpp:297-325) -- so on any reconnect where the
# old binding survives, link-only would silently leave us in the previous room.
def xlx_link_module(self):
if not self._config.get('XLX_MODULE'):
return
_module = self._config['XLX_MODULE']
_dst_id = XLX_TG_BASE + (ord(_module) - ord('A')) + 1
self.send_xlx_link(XLX_UNLINK)
self.send_xlx_link(_dst_id)
# This log line is the only evidence the link was attempted: xlxd never
# acknowledges it and no frame on the wire identifies the module.
logger.info('(%s) XLX module link sent: unlink then module %s (TG %s) -- no acknowledgement '
'exists, confirm on the reflector dashboard', self._system, _module, _dst_id)
def dmrd_received(self, _peer_id, _rf_src, _dst_id, _seq, _slot, _call_type, _frame_type, _dtype_vseq, _stream_id, _data):
"""Override in a subclass to handle inbound DMRD frames."""
@@ -756,6 +829,8 @@ class HBSYSTEM(asyncio.DatagramProtocol):
self._stats['CONNECTION'] = 'YES'
self._stats['CONNECTED'] = time()
logger.info('(%s) Connection to Server Completed', self._system)
# Must follow the flag: send_server drops DMRD unless 'YES'
self.xlx_link_module()
else:
self._stats['CONNECTION'] = 'NO'
@@ -768,6 +843,9 @@ class HBSYSTEM(asyncio.DatagramProtocol):
self._stats['CONNECTION'] = 'YES'
self._stats['CONNECTED'] = time()
logger.info('(%s) Connection to Server Completed with options', self._system)
# Second link site: an XLX system may also carry OPTIONS, and
# this path bypasses the one above entirely.
self.xlx_link_module()
else:
self._stats['CONNECTION'] = 'NO'
logger.error('(%s) Server ACK Contained wrong ID - Connection Reset', self._system)
+2 -2
View File
@@ -7,8 +7,8 @@
#
# Adjust User/Group and the paths below to your install. bridge.py defaults to
# hblink.cfg and rules.py in its own directory; pass -c/-r in ExecStart to use
# other files. To run a different app (e.g. the plain proxy), point ExecStart at
# bridge_all.py or hblink.py instead.
# other files. To run the protocol core standalone, point ExecStart at hblink.py
# instead.
[Unit]
Description=HBlink3 DMR Conference Bridge Router
+1 -1
View File
@@ -44,4 +44,4 @@ Homepage = "https://github.com/n0mjs710/hblink3"
# The core routing modules. Listed explicitly because they are top-level modules
# rather than a package, so a `pip install .` resolves them correctly.
[tool.setuptools]
py-modules = ["hblink", "bridge", "bridge_all", "config", "const", "log", "reporting_const"]
py-modules = ["hblink", "bridge", "config", "const", "log"]
+26
View File
@@ -60,6 +60,31 @@ OBP_BRIDGES = {
# },
}
'''
XLX reflector connections also get their own table. An XLX system is a MODE:
OUTBOUND system in hblink.cfg with an XLX_MODULE letter set; it is nailed up to
exactly one conference bridge:
XLX_BRIDGES = { <XLX system from hblink.cfg> : <bridge name> }
There is no TS or TGID column, and this is deliberate. Every XLX module presents
as TS2/TG9 on the wire -- there is no module identifier in any frame -- so those
values are protocol constants that bridge.py injects for you. They are not made
configurable because a wrong value produces no visible symptom: the reflector
never acknowledges anything, so the traffic would simply vanish. Notes:
* The module (A-Z) is set in hblink.cfg as XLX_MODULE, not here. One module
per connection; to bridge two modules, configure two systems.
* Leaving an XLX system as an inline BRIDGES member above is a startup ERROR.
* An XLX system in UNIT (below) is a startup ERROR -- a private call to the
reflector would change its module for everyone connected to it.
* No timers or ON/OFF/RESET triggers: there is no end user on the connection.
* XLX_BRIDGES is optional -- omit it or leave it {} if you run no reflectors.
'''
XLX_BRIDGES = {
# 'XLX950-D': 'WORLDWIDE',
}
'''
UNIT: system names that should bridge unit-to-unit (individual/private) calls to each other.
'''
@@ -75,4 +100,5 @@ if __name__ == '__main__':
from pprint import pprint
pprint(BRIDGES)
pprint(OBP_BRIDGES)
pprint(XLX_BRIDGES)
print(UNIT)
+7
View File
@@ -9,6 +9,13 @@ isolation instead.
PERMIT/DENY matching, per-timeslot scoping, and the once-per-stream drop logging.
- `test_lc.py` — `gen_lcs()` and `embed_lc()` from `bridge.py`: Link Control
generation and the DMR payload LC rewrite used by all four group-routing paths.
- `test_xlx_link.py` — the XLX reflector module link: packet construction
(`send_xlx_link`/`xlx_link_module`), `expand_xlx_bridges()` and its guards, and
`XLX_MODULE` config validation. The acceptance gates asserted here are
transcribed from xlxd's own parser, because a reflector never acknowledges a
link and puts no module identity on the wire — a malformed packet is silently
discarded, so there is no runtime signal to fall back on. One test reproduces
the field-proven 2019 reference bursts byte for byte.
## Running
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python
#
# Verify the XLX link packet HBlink builds is accepted by xlxd.
#
# Every gate below is transcribed from xlxd's own parser
# (src/cdmrmmdvmprotocol.cpp:636-655, CDmrmmdvmProtocol::IsValidDvHeaderPacket).
# A packet failing any of them is dropped with no response whatsoever -- xlxd never
# acknowledges a link and no frame on the wire carries a module identity -- so these
# checks are the only pre-flight signal available.
#
# The strongest check here is test_matches_2019_reference: the burst builder is run
# against the source and destination IDs decoded out of the hardcoded payload that
# Andy Taylor's 2019 implementation has been sending in the field since then, and
# must reproduce it byte for byte.
# Run from the repo root: venv/bin/python -m unittest discover -s tests
import os
import sys
import unittest
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _ROOT)
import hblink
from const import XLX_UNLINK, XLX_TG_BASE
# xlxd src/cdmrmmdvmprotocol.cpp:51-53
G_DMR_SYNC_BS_DATA = [0x0D, 0xFF, 0x57, 0xD7, 0x5D, 0xF5, 0xD0]
G_DMR_SYNC_MS_DATA = [0x0D, 0x5D, 0x7F, 0x77, 0xFD, 0x75, 0x70]
# The canned bursts in hblink-org/hblink.py:301,305, still deployed everywhere the
# 2019 code runs. Their embedded LC decodes to a private call, 2353150 -> 4005, at
# colour code 3.
REF_HEADER = bytes.fromhex('4f2e00b501ae3a001c40a0c1cc7dff57d75df5d5065026f82880bd616f13f185890000')
REF_TERM = bytes.fromhex('4f410061011e3a781c30a061ccbdff57d75df5d2534425c02fe0b1216713e885ba0000')
REF_SRC, REF_DST, REF_CC = 2353150, 4005, 3
def make_system(_radio_id=3120101, _colorcode=1, _module='D'):
"""An HBSYSTEM with just enough config to build link packets, capturing sends."""
_sys = hblink.HBSYSTEM.__new__(hblink.HBSYSTEM)
_sys._system = 'XLX-TEST'
_sys._config = {
'RADIO_ID': _radio_id.to_bytes(4, 'big'),
'COLORCODE': str(_colorcode),
'XLX_MODULE': _module,
'MODE': 'OUTBOUND',
}
_sys.sent = []
_sys.send_server = _sys.sent.append
return _sys
class TestXLXLinkPacket(unittest.TestCase):
# --- xlxd acceptance gates ---------------------------------------------
def test_packet_is_exactly_55_bytes(self):
# Buffer.size() == 55. A bare 53-byte DMRD (20 header + 33 burst) is
# rejected outright; the two trailing pad bytes are load-bearing.
_sys = make_system()
_sys.send_xlx_link(4001)
self.assertEqual(len(_sys.sent), 5)
for _pkt in _sys.sent:
self.assertEqual(len(_pkt), 55)
def test_frame_type_is_data_sync(self):
# (data[15] & 0x30) >> 4 == DMRMMDVM_FRAMETYPE_DATASYNC (2)
_sys = make_system()
_sys.send_xlx_link(4001)
for _pkt in _sys.sent:
self.assertEqual((_pkt[15] & 0x30) >> 4, 2)
def test_slot_is_ts2(self):
# uiSlot must equal DMRMMDVM_REFLECTOR_SLOT (DMR_SLOT2)
_sys = make_system()
_sys.send_xlx_link(4001)
for _pkt in _sys.sent:
self.assertTrue(_pkt[15] & 0x80)
def test_call_type_is_private(self):
# Module selection is a private call; group traffic is TS2/TG9 instead.
_sys = make_system()
_sys.send_xlx_link(4001)
for _pkt in _sys.sent:
self.assertTrue(_pkt[15] & 0x40)
def test_only_header_frames_carry_the_command(self):
# data[15] & 0x0F == MMDVM_SLOTTYPE_HEADER (1) is required for xlxd to read
# a link/unlink command. The two terminators are slot type 2 and are ignored
# as commands -- they exist to frame the call.
_sys = make_system()
_sys.send_xlx_link(4001)
self.assertEqual([_p[15] & 0x0F for _p in _sys.sent], [1, 1, 1, 2, 2])
def test_sync_pattern_is_valid_dmr_data_sync(self):
# dmrsync[0] = data[33] & 0x0F, [1:6] = data[34:39], [6] = data[39] & 0xF0
_sys = make_system()
_sys.send_xlx_link(4001)
for _pkt in _sys.sent:
_sync = [_pkt[33] & 0x0F] + list(_pkt[34:39]) + [_pkt[39] & 0xF0]
self.assertIn(_sync, (G_DMR_SYNC_BS_DATA, G_DMR_SYNC_MS_DATA))
def test_destination_is_readable_from_the_header(self):
# xlxd takes uiDstId from bytes 8-10 and never decodes the LC.
_sys = make_system()
_sys.send_xlx_link(4017)
for _pkt in _sys.sent:
self.assertEqual(int.from_bytes(_pkt[8:11], 'big'), 4017)
# --- packet structure ---------------------------------------------------
def test_all_five_frames_share_one_stream_id(self):
_sys = make_system()
_sys.send_xlx_link(4001)
self.assertEqual(len({_p[16:20] for _p in _sys.sent}), 1)
def test_separate_calls_use_different_stream_ids(self):
_sys = make_system()
_sys.send_xlx_link(XLX_UNLINK)
_sys.send_xlx_link(4004)
self.assertNotEqual(_sys.sent[0][16:20], _sys.sent[5][16:20])
def test_sequence_numbers_run_0_to_4(self):
_sys = make_system()
_sys.send_xlx_link(4001)
self.assertEqual([_p[4] for _p in _sys.sent], [0, 1, 2, 3, 4])
def test_source_and_peer_id_are_our_radio_id(self):
_sys = make_system(_radio_id=3120101)
_sys.send_xlx_link(4001)
for _pkt in _sys.sent:
self.assertEqual(int.from_bytes(_pkt[5:8], 'big'), 3120101)
self.assertEqual(int.from_bytes(_pkt[11:15], 'big'), 3120101)
def test_embedded_lc_agrees_with_the_header(self):
# xlxd ignores the LC, but a packet whose payload contradicts its header is
# a debugging trap: anything that does decode it reports the wrong call.
from bitarray import bitarray
from dmr_utils3 import bptc
_sys = make_system(_radio_id=3120101)
_sys.send_xlx_link(4009)
for _pkt in _sys.sent:
_ba = bitarray(endian='big')
_ba.frombytes(_pkt[20:53])
_lc = bptc.decode_full_lc(_ba[0:98] + _ba[166:264]).tobytes()
self.assertEqual(_lc[0] & 0x3f, 0x03) # FLCO USER_USER
self.assertEqual(int.from_bytes(_lc[3:6], 'big'), 4009) # dst == header dst
self.assertEqual(int.from_bytes(_lc[6:9], 'big'), 3120101) # src == header src
# --- the reference check ------------------------------------------------
def test_matches_2019_reference(self):
# Reproduce the field-proven canned bursts from their own decoded values.
_sys = make_system(_radio_id=REF_SRC, _colorcode=REF_CC)
_sys.send_xlx_link(REF_DST)
self.assertEqual(_sys.sent[0][20:53], REF_HEADER[:33])
self.assertEqual(_sys.sent[3][20:53], REF_TERM[:33])
# --- link sequencing ----------------------------------------------------
def test_link_sends_unlink_then_module(self):
# Not defensive: while a client already holds a module, xlxd discards a link
# naming a different one (cdmrmmdvmprotocol.cpp:297-325), so a reconnect that
# skipped the unlink would silently stay in the previous room.
_sys = make_system(_module='D')
_sys.xlx_link_module()
self.assertEqual(len(_sys.sent), 10)
self.assertEqual(int.from_bytes(_sys.sent[0][8:11], 'big'), XLX_UNLINK)
self.assertEqual(int.from_bytes(_sys.sent[5][8:11], 'big'), XLX_TG_BASE + 4)
def test_module_letters_map_to_4001_through_4026(self):
for _letter, _expected in (('A', 4001), ('D', 4004), ('J', 4010), ('Z', 4026)):
_sys = make_system(_module=_letter)
_sys.xlx_link_module()
self.assertEqual(int.from_bytes(_sys.sent[5][8:11], 'big'), _expected)
def test_non_xlx_system_sends_nothing(self):
_sys = make_system(_module='')
_sys.xlx_link_module()
self.assertEqual(_sys.sent, [])
def test_colorcode_is_taken_from_config(self):
# The slot type carries the colour code; xlxd does not check it, but the
# packet should still describe the system it claims to come from.
_a = make_system(_colorcode=1)
_b = make_system(_colorcode=7)
_a.send_xlx_link(4001)
_b.send_xlx_link(4001)
self.assertNotEqual(_a.sent[0][20:53], _b.sent[0][20:53])
class TestExpandXLXBridges(unittest.TestCase):
"""XLX_BRIDGES expansion and the load-time guards."""
def setUp(self):
import bridge
self.bridge = bridge
bridge.CONFIG = {'SYSTEMS': {
'XLX950-D': {'MODE': 'OUTBOUND', 'XLX_MODULE': 'D'},
'XLX068-A': {'MODE': 'OUTBOUND', 'XLX_MODULE': 'A'},
'HOTSPOT': {'MODE': 'OUTBOUND', 'XLX_MODULE': ''},
'REPEATERS': {'MODE': 'SERVER'},
'VESTA_OBP': {'MODE': 'OPENBRIDGE'},
}}
@staticmethod
def _member(system, ts, tgid):
return {'SYSTEM': system, 'TS': ts, 'TGID': tgid, 'ACTIVE': True,
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []}
def test_noop_without_xlx_table(self):
bridges = {'B1': [self._member('REPEATERS', 1, 2)]}
out = self.bridge.expand_xlx_bridges(bridges, {})
self.assertEqual(len(out['B1']), 1)
def test_ts_and_tgid_are_forced_to_2_and_9(self):
# The whole point: these are injected, never read from the rules file.
bridges = {}
self.bridge.expand_xlx_bridges(bridges, {'XLX950-D': 'WORLDWIDE'})
_m = bridges['WORLDWIDE'][0]
self.assertEqual((_m['TS'], _m['TGID']), (2, 9))
def test_appends_alongside_existing_members(self):
bridges = {'WORLDWIDE': [self._member('REPEATERS', 1, 3100)]}
self.bridge.expand_xlx_bridges(bridges, {'XLX950-D': 'WORLDWIDE'})
self.assertEqual(len(bridges['WORLDWIDE']), 2)
self.assertEqual(bridges['WORLDWIDE'][1]['SYSTEM'], 'XLX950-D')
def test_triggers_and_timers_are_inert(self):
# No end user on an XLX connection to send ON/OFF/RESET TGIDs.
bridges = {}
self.bridge.expand_xlx_bridges(bridges, {'XLX950-D': 'WORLDWIDE'})
_m = bridges['WORLDWIDE'][0]
self.assertEqual(_m['TO_TYPE'], 'NONE')
self.assertEqual((_m['ON'], _m['OFF'], _m['RESET']), ([], [], []))
self.assertTrue(_m['ACTIVE'])
def test_two_modules_are_two_systems_on_one_bridge(self):
bridges = {}
self.bridge.expand_xlx_bridges(bridges, {'XLX950-D': 'WW', 'XLX068-A': 'WW'})
self.assertEqual({_m['SYSTEM'] for _m in bridges['WW']}, {'XLX950-D', 'XLX068-A'})
def test_inline_xlx_member_is_fatal(self):
bridges = {'WORLDWIDE': [self._member('XLX950-D', 2, 9)]}
with self.assertRaises(SystemExit):
self.bridge.expand_xlx_bridges(bridges, {})
def test_non_xlx_system_in_table_is_fatal(self):
with self.assertRaises(SystemExit):
self.bridge.expand_xlx_bridges({}, {'REPEATERS': 'WORLDWIDE'})
def test_outbound_without_module_is_not_xlx(self):
with self.assertRaises(SystemExit):
self.bridge.expand_xlx_bridges({}, {'HOTSPOT': 'WORLDWIDE'})
def test_unknown_system_is_fatal(self):
with self.assertRaises(SystemExit):
self.bridge.expand_xlx_bridges({}, {'NOT-A-SYSTEM': 'WORLDWIDE'})
def test_tgid_override_attempt_is_rejected(self):
# An operator reaching for the OBP {bridge: tgid} shape must not silently
# get a working config with an unusable TGID.
with self.assertRaises(SystemExit):
self.bridge.expand_xlx_bridges({}, {'XLX950-D': {'WORLDWIDE': 3100}})
def test_xlx_in_unit_is_fatal(self):
# A private call forwarded to the reflector would change its module for
# every connected user.
with self.assertRaises(SystemExit):
self.bridge.validate_xlx_unit(['REPEATERS', 'XLX950-D'])
def test_unit_without_xlx_is_accepted(self):
self.bridge.validate_xlx_unit(['REPEATERS', 'HOTSPOT'])
class TestXLXConfigValidation(unittest.TestCase):
"""XLX_MODULE parsing in config.py."""
def setUp(self):
import configparser
import config
self.config = config
self.parser = configparser.ConfigParser()
self.parser.add_section('XLX-1')
def _parse(self, _value):
self.parser.set('XLX-1', 'XLX_MODULE', _value)
return self.config._parse_xlx_module(self.parser, 'XLX-1')
def test_absent_field_means_not_xlx(self):
self.assertEqual(self.config._parse_xlx_module(self.parser, 'XLX-1'), '')
def test_empty_field_means_not_xlx(self):
self.assertEqual(self._parse(''), '')
def test_letter_is_accepted_and_uppercased(self):
self.assertEqual(self._parse('d'), 'D')
self.assertEqual(self._parse(' A '), 'A')
def test_module_number_is_rejected(self):
# Rejecting 4004 outright is deliberate: it is what the 2019 implementation
# took, so an operator migrating a config would otherwise get a silent
# mis-parse rather than an error.
with self.assertRaises(SystemExit):
self._parse('4004')
def test_multi_character_is_rejected(self):
with self.assertRaises(SystemExit):
self._parse('AB')
def test_non_letter_is_rejected(self):
with self.assertRaises(SystemExit):
self._parse('1')
if __name__ == '__main__':
unittest.main(verbosity=2)