Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fa1b32f16 | ||
|
|
60d67405a2 | ||
|
|
f81872b60c | ||
|
|
aae53890f7 |
No files matched your search
@@ -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
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user