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>
This commit is contained in:
Cort BuffingtonandClaude Opus 5 committed 2026-08-16 10:14:30 -05:00
1 parent 60d67405a2
commit 3fa1b32f16
8 files changed
+594 -3

No files matched your search

+63 -1
View File
@@ -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
+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
View File
@@ -45,6 +45,9 @@ 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
@@ -78,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.
@@ -397,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."""
@@ -755,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'
@@ -767,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)
+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)