From 562d86f9497cf5c64c4f996bc6a561a095c86057 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 23 May 2026 02:37:20 +0100 Subject: [PATCH] bugfix pass --- .gitignore | 6 + AMI.py | 62 +- API.py | 276 +- README.md | 2 + api_client.py | 49 +- bridge.py | 51 +- bridge_master.py | 732 +++-- config.py | 5 +- docs/api.md | 113 + docs/codex-notes.md | 4280 +++++++++++++++++++++++++++ docs/test-harness-design.md | 621 ++++ docs/testing.md | 258 ++ hblink.py | 26 +- hotspot_proxy_v2.py | 16 +- pyvenv.cfg | 3 - report_receiver.py | 11 +- report_sql.py | 46 +- requirements.txt | 1 - tests/__init__.py | 1 + tests/harness/__init__.py | 1 + tests/harness/deterministic.py | 491 +++ tests/harness/udp_blackbox.py | 1014 +++++++ tests/test_api.py | 162 + tests/test_auxiliary_tools.py | 165 ++ tests/test_bridge_backports.py | 32 + tests/test_deterministic_harness.py | 2862 ++++++++++++++++++ tests/test_udp_blackbox_harness.py | 1223 ++++++++ 27 files changed, 12044 insertions(+), 465 deletions(-) create mode 100644 docs/api.md create mode 100644 docs/codex-notes.md create mode 100644 docs/test-harness-design.md create mode 100644 docs/testing.md delete mode 100644 pyvenv.cfg create mode 100644 tests/__init__.py create mode 100644 tests/harness/__init__.py create mode 100644 tests/harness/deterministic.py create mode 100644 tests/harness/udp_blackbox.py create mode 100644 tests/test_api.py create mode 100644 tests/test_auxiliary_tools.py create mode 100644 tests/test_bridge_backports.py create mode 100644 tests/test_deterministic_harness.py create mode 100644 tests/test_udp_blackbox_harness.py diff --git a/.gitignore b/.gitignore index 192fd9b..f1e5481 100755 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,8 @@ celerybeat-schedule # virtualenv venv/ ENV/ +.venv/ +pyvenv.cfg # Spyder project settings .spyderproject @@ -93,6 +95,10 @@ hblink.cfg *.config *.bak rules.py +bridge.pkl +config.pkl +sub_map.pkl +keys.json subscriber_ids.* local_subscriber_ids.* peer_ids.* diff --git a/AMI.py b/AMI.py index d6d4086..cf47846 100644 --- a/AMI.py +++ b/AMI.py @@ -1,37 +1,46 @@ import sys -from time import time +import logging -from twisted.internet import reactor,task -from twisted.internet.defer import Deferred -from twisted.internet.protocol import ClientFactory,ClientFactory,Protocol +from twisted.internet import reactor +from twisted.internet.protocol import ClientFactory from twisted.protocols.basic import LineReceiver +logger = logging.getLogger(__name__) + class AMI(): def __init__(self,host,port,username,secret,nodenum): - self._AMIClient = self.AMIClient self.host = host self.port = port self.username = username.encode('utf-8') self.secret = secret.encode('utf-8') - self.nodenum = str(nodenum) + self.nodenum = str(nodenum).encode('utf-8') + self.CF = None def send_command(self,command): - self._AMIClient.command = command.encode('utf-8') - self._AMIClient.username = self.username - self._AMIClient.secret = self.secret - self._AMIClient.nodenum = self.nodenum.encode('utf-8') - self.command = command - - self.CF = reactor.connectTCP(self.host, self.port, self.AMIClientFactory(self._AMIClient)) + factory = self.AMIClientFactory( + self.AMIClient, + self.username, + self.secret, + self.nodenum, + command.encode('utf-8') + ) + self.CF = reactor.connectTCP(self.host, self.port, factory) def closeConnection(self): - self.transport.loseConnection() + if self.CF is not None: + self.CF.disconnect() class AMIClient(LineReceiver): delimiter = b'\r\n' + + def __init__(self, username, secret, nodenum, command): + self.username = username + self.secret = secret + self.nodenum = nodenum + self.command = command def connectionMade(self): self.sendLine(b'Action: login') @@ -40,31 +49,40 @@ class AMI(): self.sendLine(self.delimiter) def lineReceived(self,line): - print(line) + logger.debug('(AMI) RX: %r', line) if line == b'Asterisk Call Manager/1.0': return if line == b'Response: Success': self.sendLine(b'Action: command') - #print(b''.join([b'Command: ',b'rpt cmd ',self.nodenum,b' ',self.command])) self.sendLine(b''.join([b'Command: ',b'rpt cmd ',self.nodenum,b' ',self.command])) - #self.sendLine(b'Command: ' + b'rpt cmd 29177 ilink 3 2001') self.sendLine(self.delimiter) self.transport.loseConnection() class AMIClientFactory(ClientFactory): - def __init__(self,AMIClient): - #self.command = command - self.done = Deferred() + def __init__(self,AMIClient,username,secret,nodenum,command): self.protocol = AMIClient - #self.protocol.command = command + self.username = username + self.secret = secret + self.nodenum = nodenum + self.command = command + + def buildProtocol(self, addr): + return self.protocol( + self.username, + self.secret, + self.nodenum, + self.command + ) def clientConnectionFailed(self, connector, reason): - ClientFactory.clientConnectionLost(self, connector, reason) + logger.warning('(AMI) Connection failed: %s', reason) + ClientFactory.clientConnectionFailed(self, connector, reason) def clientConnectionLost(self, connector, reason): + logger.debug('(AMI) Connection lost: %s', reason) ClientFactory.clientConnectionLost(self, connector, reason) diff --git a/API.py b/API.py index 416fb93..45a5d7b 100644 --- a/API.py +++ b/API.py @@ -17,132 +17,194 @@ # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA ############################################################################### -from spyne import ServiceBase, rpc, Integer, Decimal, UnsignedInteger32, Unicode, Iterable, error -from dmr_utils3.utils import bytes_3, bytes_4 +import json +import logging +from twisted.web.resource import Resource -class FD_APIUserDefinedContext(object): - def __init__(self,CONFIG,BRIDGES): - self.CONFIG = CONFIG - self.BRIDGES = BRIDGES +from dmr_utils3.utils import bytes_4 - def getconfig(self): - return self.CONFIG - - def getbridges(self): - return self.BRIDGES - - def validateKey(self,dmrid,key): - systems = self.CONFIG['SYSTEMS'] - dmrid = bytes_4(dmrid) - print(dmrid) - for system in systems: - if systems[system]['MODE'] == 'MASTER': - for peerid in systems[system]['PEERS']: - print(peerid) - if peerid == dmrid: - try: - if key == systems[system]['_opt_key']: - return(system) - else: - return(False) - except KeyError: - return(False) - return(False) - - def validateSystemKey(self,systemkey): - if systemkey == self.CONFIG['GLOBAL']['SYSTEM_API_KEY']: - return True - else: - return False - def reset(self,system): - self.CONFIG['SYSTEMS'][system]['_reset'] = True +logger = logging.getLogger(__name__) +MAX_API_BODY = 8192 - def options(self,system,options): - self.CONFIG['SYSTEMS'][system]['OPTIONS'] = options - def getoptions(self,system): - return self.CONFIG['SYSTEMS'][system]['OPTIONS'] +class APIError(Exception): + def __init__(self, status, message): + self.status = status + self.message = message + super().__init__(message) - def killserver(self): - self.CONFIG['GLOBAL']['_KILL_SERVER'] = True - def resetAllConnections(self): - systems = self.CONFIG['SYSTEMS'] - for system in systems: - self.CONFIG['SYSTEMS'][system]['_reset'] = True +class FD_APIController(object): + """Small, non-blocking control-plane API for live FreeDMR state. + The methods intentionally perform only in-memory dictionary reads/writes. + Expensive snapshots of CONFIG/BRIDGES are not exposed here because this API + shares the Twisted reactor with packet handling. + """ + version = 1 -class FD_API(ServiceBase): - _version = 0.1 + def __init__(self, CONFIG, BRIDGES): + self.CONFIG = CONFIG + self.BRIDGES = BRIDGES - #return API version - @rpc(Unicode, _returns=Decimal()) - def version(ctx, sessionid): - return(FD_API._version) + def validateKey(self, dmrid, key): + try: + peer_id = bytes_4(int(dmrid)) + except (TypeError, ValueError, OverflowError): + return False - @rpc() - def dummy(ctx): - pass + for system, system_config in self.CONFIG['SYSTEMS'].items(): + if system_config['MODE'] != 'MASTER': + continue + if peer_id not in system_config['PEERS']: + continue + if key == system_config.get('_opt_key'): + return system + return False + return False - ###################### - #User level API calls# - ###################### - @rpc(UnsignedInteger32,Unicode) - def reset(ctx,dmrid,key): - system = ctx.udc.validateKey(int(dmrid),key) - if system: - ctx.udc.reset(system) - else: - raise error.InvalidCredentialsError() + def validateSystemKey(self, systemkey): + return systemkey == self.CONFIG['GLOBAL'].get('SYSTEM_API_KEY') - @rpc(UnsignedInteger32,Unicode,Unicode) - def setoptions(ctx,dmrid,key,options): - system = ctx.udc.validateKey(int(dmrid),key) - if system: - ctx.udc.options(system,options) - else: - raise error.InvalidCredentialsError() + def reset(self, system): + self.CONFIG['SYSTEMS'][system]['_reset'] = True - @rpc(UnsignedInteger32,Unicode,_returns=Unicode()) - def getoptions(ctx,dmrid,key): - system = ctx.udc.validateKey(int(dmrid),key) - if system: - return ctx.udc.getoptions(system) - else: - raise error.InvalidCredentialsError() - - ######################## - #System level API calls# - ######################## - @rpc(Unicode) - def killserver(ctx,systemkey): - if ctx.udc.validateSystemKey(systemkey): - return ctx.udc.killserver() - else: - raise error.InvalidCredentialsError() + def options(self, system, options): + self.CONFIG['SYSTEMS'][system]['OPTIONS'] = options - @rpc(Unicode) - def resetall(ctx,systemkey): - if ctx.udc.validateSystemKey(systemkey): - return ctx.udc.resetAllConnections() + def getoptions(self, system): + options = self.CONFIG['SYSTEMS'][system].get('OPTIONS') + has_options = options is not None + if isinstance(options, bytes): + options = options.decode('utf-8', 'ignore') + elif options is None: + options = '' else: - raise error.InvalidCredentialsError() + options = str(options) + return { + 'connected': bool(self.CONFIG['SYSTEMS'][system]['PEERS']), + 'has_options': has_options, + 'options': options, + } - @rpc(Unicode,_returns=Unicode()) - def getconfig(ctx,systemkey): - if ctx.udc.validateSystemKey(systemkey): - return ctx.udc.getconfig() - else: - raise error.InvalidCredentialsError() + def killserver(self): + self.CONFIG['GLOBAL']['_KILL_SERVER'] = True - @rpc(Unicode,_returns=Unicode()) - def getbridges(ctx,systemkey): - if ctx.udc.validateSystemKey(systemkey): - return ctx.udc.getbridges() - else: - raise error.InvalidCredentialsError() + def resetAllConnections(self): + for system in self.CONFIG['SYSTEMS']: + self.CONFIG['SYSTEMS'][system]['_reset'] = True +class FD_APIResource(Resource): + isLeaf = True + + def __init__(self, controller): + Resource.__init__(self) + self.controller = controller + + def render_GET(self, request): + path = self._path(request) + if path == '/api/v1/version': + return self._json(request, 200, {'ok': True, 'version': self.controller.version}) + if path == '/api/v1/health': + return self._json(request, 200, {'ok': True}) + return self._json(request, 404, {'ok': False, 'error': 'not_found'}) + + def render_POST(self, request): + try: + payload = self._payload(request) + path = self._path(request) + + if path == '/api/v1/reset': + return self._user_call(request, payload, self._reset) + if path == '/api/v1/options/get': + return self._user_call(request, payload, self._get_options) + if path == '/api/v1/options/set': + return self._user_call(request, payload, self._set_options) + if path == '/api/v1/system/kill': + return self._system_call(request, payload, self._kill_server) + if path == '/api/v1/system/resetall': + return self._system_call(request, payload, self._reset_all) + + return self._json(request, 404, {'ok': False, 'error': 'not_found'}) + except APIError as exc: + return self._json(request, exc.status, {'ok': False, 'error': exc.message}) + except Exception: + logger.exception('(API) Unhandled API error') + return self._json(request, 500, {'ok': False, 'error': 'internal_error'}) + + def _path(self, request): + return (b'/' + b'/'.join(request.postpath)).decode('utf-8', 'ignore') + + def _payload(self, request): + content_length = None + if hasattr(request, 'getHeader'): + content_length = request.getHeader('content-length') + if content_length is not None: + try: + if int(content_length) > MAX_API_BODY: + raise APIError(413, 'request_too_large') + except ValueError: + raise APIError(400, 'invalid_content_length') + + body = request.content.read() + if len(body) > MAX_API_BODY: + raise APIError(413, 'request_too_large') + if not body: + return {} + try: + payload = json.loads(body.decode('utf-8')) + except (TypeError, ValueError): + raise APIError(400, 'invalid_json') + if not isinstance(payload, dict): + raise APIError(400, 'invalid_json') + return payload + + def _user_call(self, request, payload, handler): + dmrid = payload.get('dmrid') + key = payload.get('key') + system = self.controller.validateKey(dmrid, key) + if not system: + raise APIError(401, 'invalid_credentials') + result = handler(system, payload) + return self._json(request, 200, {'ok': True, **result}) + + def _system_call(self, request, payload, handler): + if not self.controller.validateSystemKey(payload.get('systemkey')): + raise APIError(401, 'invalid_credentials') + result = handler(payload) + return self._json(request, 200, {'ok': True, **result}) + + def _reset(self, system, payload): + self.controller.reset(system) + return {'reset': True, 'system': system} + + def _get_options(self, system, payload): + return self.controller.getoptions(system) + + def _set_options(self, system, payload): + options = payload.get('options') + if not isinstance(options, str): + raise APIError(400, 'missing_options') + self.controller.options(system, options) + return {'updated': True, 'system': system} + + def _kill_server(self, payload): + self.controller.killserver() + return {'killserver': True} + + def _reset_all(self, payload): + self.controller.resetAllConnections() + return {'resetall': True} + + def _json(self, request, status, payload): + request.setResponseCode(status) + request.setHeader(b'content-type', b'application/json') + return json.dumps(payload, separators=(',', ':')).encode('utf-8') + + +def make_api_resource(CONFIG, BRIDGES): + return FD_APIResource(FD_APIController(CONFIG, BRIDGES)) diff --git a/README.md b/README.md index c2d88e7..83de5e4 100755 --- a/README.md +++ b/README.md @@ -3,3 +3,5 @@ FreeDMR Peer Server - software to assist in building a peer mesh network Please see the wiki for documentation. + +For packet harness tests and run commands, see [docs/testing.md](docs/testing.md). diff --git a/api_client.py b/api_client.py index 19dbc25..e6f0530 100644 --- a/api_client.py +++ b/api_client.py @@ -1,23 +1,26 @@ - -from twisted.internet import reactor -from twisted.web.xmlrpc import Proxy - - -def printValue(value): - print(repr(value)) - reactor.stop() - - -def printError(error): - print("error", error) - reactor.stop() - - -def capitalize(value): - print(value) - - -proxy = Proxy(b"http://localhost:7080/xmlrpc") -# The callRemote method accepts a method name and an argument list. -proxy.callRemote("FD_API.reset", '2', '55555').addCallbacks(capitalize, printError) -reactor.run() +import json +import sys +from urllib import request + + +def post(path, payload): + body = json.dumps(payload).encode("utf-8") + req = request.Request( + "http://127.0.0.1:8000" + path, + data=body, + headers={"content-type": "application/json"}, + method="POST", + ) + with request.urlopen(req, timeout=3) as response: + return json.loads(response.read().decode("utf-8")) + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print("usage: api_client.py ") + raise SystemExit(2) + print(post("/api/v1/options/set", { + "dmrid": int(sys.argv[1]), + "key": sys.argv[2], + "options": sys.argv[3], + })) diff --git a/bridge.py b/bridge.py index 86d6dc3..4ffab35 100755 --- a/bridge.py +++ b/bridge.py @@ -67,6 +67,12 @@ __email__ = 'n0mjs@me.com' # Module gobal varaibles +def dmrd_seq_delta(seq, last_seq): + if last_seq is False or last_seq is None: + return None + return (seq - last_seq) % 256 + + # Timed loop used for reporting HBP status # # REPORT BASED ON THE TYPE SELECTED IN THE MAIN CONFIG FILE @@ -323,16 +329,16 @@ class routerOBP(OPENBRIDGE): if self.STATUS[_stream_id]['lastData'] and self.STATUS[_stream_id]['lastData'] == _data and _seq > 1: logger.warning("(%s) *PacketControl* last packet is a complete duplicate of the previous one, disgarding. Stream ID:, %s TGID: %s",self._system,int_id(_stream_id),int_id(_dst_id)) return - #Handle inbound duplicates - if _seq and _seq == self.STATUS[_stream_id]['lastSeq']: + _seq_delta = dmrd_seq_delta(_seq, self.STATUS[_stream_id]['lastSeq']) + if _seq_delta == 0: logger.warning("(%s) *PacketControl* Duplicate sequence number %s, disgarding. Stream ID:, %s TGID: %s",self._system,_seq,int_id(_stream_id),int_id(_dst_id)) return #Inbound out-of-order packets - if _seq and self.STATUS[_stream_id]['lastSeq'] and (_seq != 1) and (_seq < self.STATUS[_stream_id]['lastSeq']): + if _seq_delta is not None and _seq_delta > 127: logger.warning("%s) *PacketControl* Out of order packet - last SEQ: %s, this SEQ: %s, disgarding. Stream ID:, %s TGID: %s ",self._system,self.STATUS[_stream_id]['lastSeq'],_seq,int_id(_stream_id),int_id(_dst_id)) return #Inbound missed packets - if _seq and self.STATUS[_stream_id]['lastSeq'] and _seq > (self.STATUS[_stream_id]['lastSeq']+1): + if _seq_delta is not None and _seq_delta > 1: logger.warning("(%s) *PacketControl* Missed packet(s) - last SEQ: %s, this SEQ: %s. Stream ID:, %s TGID: %s ",self._system,self.STATUS[_stream_id]['lastSeq'],_seq,int_id(_stream_id),int_id(_dst_id)) #Save this sequence number @@ -495,14 +501,15 @@ class routerOBP(OPENBRIDGE): self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot, call_duration) if CONFIG['REPORTS']['REPORT']: self._report.send_bridgeEvent('GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id), call_duration).encode(encoding='utf-8', errors='ignore')) - self.STATUS[_stream_id]['_fin'] = True + self.STATUS[_stream_id]['_fin'] = True #removed = self.STATUS.pop(_stream_id) #logger.debug('(%s) OpenBridge sourced call stream end, remove terminated Stream ID: %s', self._system, int_id(_stream_id)) #if not removed: #selflogger.error('(%s) *CALL END* STREAM ID: %s NOT IN LIST -- THIS IS A REAL PROBLEM', self._system, int_id(_stream_id)) - #Reset sequence number - self._lastSeq = False + #Reset sequence tracking + self.STATUS[_stream_id]['lastSeq'] = False + self.STATUS[_stream_id]['lastData'] = False class routerHBP(HBSYSTEM): @@ -539,7 +546,9 @@ class routerHBP(HBSYSTEM): 4: b'\x00', }, 'lastSeq': False, - 'lastData': False + 'lastData': False, + 'RX_FINISHED_STREAM_ID': b'\x00', + 'RX_FINISHED_STREAM_LOG': False }, 2: { @@ -568,7 +577,9 @@ class routerHBP(HBSYSTEM): 4: b'\x00', }, 'lastSeq': False, - 'lastData': False + 'lastData': False, + 'RX_FINISHED_STREAM_ID': b'\x00', + 'RX_FINISHED_STREAM_LOG': False } } @@ -584,9 +595,19 @@ class routerHBP(HBSYSTEM): _source_rptr = _peer_id if _call_type == 'group': + if self.STATUS[_slot].get('RX_FINISHED_STREAM_ID') == _stream_id: + if not self.STATUS[_slot].get('RX_FINISHED_STREAM_LOG'): + logger.warning("(%s) HBP *LoopControl* STREAM ID: %s ALREADY FINISHED FROM THIS SOURCE, IGNORING",self._system, int_id(_stream_id)) + self.STATUS[_slot]['RX_FINISHED_STREAM_LOG'] = True + return # Is this a new call stream? if (_stream_id != self.STATUS[_slot]['RX_STREAM_ID']): + self.STATUS[_slot]['RX_FINISHED_STREAM_ID'] = b'\x00' + self.STATUS[_slot]['RX_FINISHED_STREAM_LOG'] = False + self.STATUS[_slot]['lastSeq'] = False + self.STATUS[_slot]['lastData'] = False + if (self.STATUS[_slot]['RX_TYPE'] != HBPF_SLT_VTERM) and (pkt_time < (self.STATUS[_slot]['RX_TIME'] + STREAM_TO)) and (_rf_src != self.STATUS[_slot]['RX_RFS']): logger.warning('(%s) Packet received with STREAM ID: %s SUB: %s PEER: %s TGID %s, SLOT %s collided with existing call', self._system, int_id(_stream_id), int_id(_rf_src), int_id(_peer_id), int_id(_dst_id), _slot) return @@ -639,16 +660,16 @@ class routerHBP(HBSYSTEM): if self.STATUS[_slot]['lastData'] and self.STATUS[_slot]['lastData'] == _data and _seq > 1: logger.warning("(%s) *PacketControl* last packet is a complete duplicate of the previous one, disgarding. Stream ID:, %s TGID: %s",self._system,int_id(_stream_id),int_id(_dst_id)) return - #Handle inbound duplicates - if _seq and _seq == self.STATUS[_slot]['lastSeq']: + _seq_delta = dmrd_seq_delta(_seq, self.STATUS[_slot]['lastSeq']) + if _seq_delta == 0: logger.warning("(%s) *PacketControl* Duplicate sequence number %s, disgarding. Stream ID:, %s TGID: %s",self._system,_seq,int_id(_stream_id),int_id(_dst_id)) return #Inbound out-of-order packets - if _seq and self.STATUS[_slot]['lastSeq'] and (_seq != 1) and (_seq < self.STATUS[_slot]['lastSeq']): + if _seq_delta is not None and _seq_delta > 127: logger.warning("%s) *PacketControl* Out of order packet - last SEQ: %s, this SEQ: %s, disgarding. Stream ID:, %s TGID: %s ",self._system,self.STATUS[_slot]['lastSeq'],_seq,int_id(_stream_id),int_id(_dst_id)) return #Inbound missed packets - if _seq and self.STATUS[_slot]['lastSeq'] and _seq > (self.STATUS[_slot]['lastSeq']+1): + if _seq_delta is not None and _seq_delta > 1: logger.warning("(%s) *PacketControl* Missed packet(s) - last SEQ: %s, this SEQ: %s. Stream ID:, %s TGID: %s ",self._system,self.STATUS[_slot]['lastSeq'],_seq,int_id(_stream_id),int_id(_dst_id)) #Save this sequence number @@ -812,6 +833,10 @@ class routerHBP(HBSYSTEM): self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot, call_duration) if CONFIG['REPORTS']['REPORT']: self._report.send_bridgeEvent('GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id), call_duration).encode(encoding='utf-8', errors='ignore')) + self.STATUS[_slot]['RX_FINISHED_STREAM_ID'] = _stream_id + self.STATUS[_slot]['RX_FINISHED_STREAM_LOG'] = False + self.STATUS[_slot]['lastSeq'] = False + self.STATUS[_slot]['lastData'] = False # # Begin in-band signalling for call end. This has nothign to do with routing traffic directly. diff --git a/bridge_master.py b/bridge_master.py index d2e5834..2cd0487 100644 --- a/bridge_master.py +++ b/bridge_master.py @@ -52,11 +52,6 @@ from twisted.protocols.basic import NetstringReceiver from twisted.internet import reactor, task from twisted.web.server import Site -from spyne import Application -from spyne.server.twisted import TwistedWebResource -from spyne.protocol.http import HttpRpc -from spyne.protocol.json import JsonDocument - @@ -93,7 +88,7 @@ import pprint from binascii import b2a_hex as ahex from AMI import AMI -from API import FD_API, FD_APIUserDefinedContext +from API import make_api_resource # Does anybody read this stuff? There's a PEP somewhere that says I should do this. __author__ = 'Cortney T. Buffington, N0MJS, Forked by Simon Adlem - G7RZU' @@ -103,6 +98,89 @@ __license__ = 'GNU GPLv3' __maintainer__ = 'Simon Adlem G7RZU' __email__ = 'simon@gb7fr.org.uk' +DIAL_A_TG_PROHIBITED_DEFAULTS = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999, +] +DIAL_A_TG_MAX = 999999 +DMR_ID_MAX = 16777215 + +def group_data_event_name(_call_type, _dtype_vseq): + if _call_type == 'group' and _dtype_vseq == 6: + return 'DATA HEADER' + if _call_type in ('group', 'vcsbk') and _dtype_vseq == 7: + return 'VCSBK 1/2 DATA BLOCK' + if _call_type in ('group', 'vcsbk') and _dtype_vseq == 8: + return 'VCSBK 3/4 DATA BLOCK' + return None + +def is_group_data_control(_call_type, _dtype_vseq): + return group_data_event_name(_call_type, _dtype_vseq) is not None or _call_type == 'vcsbk' + +def group_data_report_name(_call_type, _dtype_vseq): + return group_data_event_name(_call_type, _dtype_vseq) or 'OTHER DATA' + +def dmrd_seq_delta(seq, last_seq): + if last_seq is False or last_seq is None: + return None + return (seq - last_seq) % 256 + +def valid_dial_a_tg_reflector(tg): + if not tg: + return False + tg = int(tg) + return tg > 0 and tg <= DIAL_A_TG_MAX and tg not in DIAL_A_TG_PROHIBITED_DEFAULTS + +def valid_static_tg(tg): + if not tg: + return False + tg = int(tg) + return tg > 0 and tg < DMR_ID_MAX and tg not in DIAL_A_TG_PROHIBITED_DEFAULTS + +def parse_static_tgs(value): + if not value: + return [] + static_tgs = [] + for tg in re.sub(r"\s", "", str(value)).split(','): + if not tg or not tg.isdigit(): + continue + if not valid_static_tg(tg): + continue + static_tgs.append(int(tg)) + return static_tgs + +def parse_int_option(value, default=None): + if value is False or value is None: + return default + value = str(value).strip() + if not value.isdigit(): + return default + return int(value) + +def parse_bool_option(value, default=None): + parsed = parse_int_option(value, default=None) + if parsed in (0, 1): + return bool(parsed) + return default + +def parse_default_reflector_option(value, default=None): + if value is False or value is None: + return 0 + value = str(value).strip() + if value == '': + return 0 + if not value.isdigit(): + return default + return int(value) + +def valid_ident_override_tg(tg): + parsed = parse_int_option(tg, default=None) + if parsed is None or parsed <= 0 or parsed >= DMR_ID_MAX: + return None + if parsed in DIAL_A_TG_PROHIBITED_DEFAULTS: + return None + return parsed + #Set header bits #used for slot rewrite and type rewrite def header(slot,call_type,bits): @@ -149,20 +227,7 @@ def config_reports(_config, _factory): # Start API server def config_API(_config, _bridges): - - - application = Application([FD_API], - tns='freedmr.api', - in_protocol=HttpRpc(validator='soft'), - out_protocol=JsonDocument() - ) - - def _on_method_call(ctx): - ctx.udc = FD_APIUserDefinedContext(CONFIG,_bridges) - - application.event_manager.add_listener('method_call', _on_method_call) - - resource = TwistedWebResource(application) + resource = make_api_resource(_config, _bridges) site = Site(resource) r = reactor.listenTCP(8000, site, interface='0.0.0.0') @@ -273,6 +338,29 @@ def make_default_reflector(reflector,_tmout,system): bridgetemp.append(bridgesystem) BRIDGES[bridge] = bridgetemp + +def make_default_reflectors(): + logger.debug('(ROUTER) Setting default dial-a-tgs') + for system in CONFIG['SYSTEMS']: + if CONFIG['SYSTEMS'][system]['MODE'] != 'MASTER': + continue + _default_reflector = CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR'] + if valid_dial_a_tg_reflector(_default_reflector): + make_default_reflector(_default_reflector,CONFIG['SYSTEMS'][system]['DEFAULT_UA_TIMER'],system) + elif int(_default_reflector) > 0: + logger.warning('(ROUTER) %s default dial-a-tg %s is invalid or prohibited, ignoring', system, _default_reflector) + CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR'] = 0 + +def make_static_tgs(): + logger.debug('(ROUTER) setting static TGs') + for system in CONFIG['SYSTEMS']: + if CONFIG['SYSTEMS'][system]['MODE'] != 'MASTER': + continue + _tmout = CONFIG['SYSTEMS'][system]['DEFAULT_UA_TIMER'] + for tg in parse_static_tgs(CONFIG['SYSTEMS'][system]['TS1_STATIC']): + make_static_tg(tg,1,_tmout,system) + for tg in parse_static_tgs(CONFIG['SYSTEMS'][system]['TS2_STATIC']): + make_static_tg(tg,2,_tmout,system) def make_static_tg(tg,ts,_tmout,system): #_tmout = CONFIG['SYSTEMS'][system]['DEFAULT_UA_TIMER'] @@ -381,17 +469,28 @@ def make_single_reflector(_tgid,_tmout,_sourcesystem): def remove_bridge_system(remsystem): bt = {} - for system in CONFIG['SYSTEMS']: - for bridge in BRIDGES: - bridgetemp = [] - for bridgesystem in BRIDGES[bridge]: - if bridgesystem['SYSTEM'] == remsystem: - bridgetemp.append({'SYSTEM': system, 'TS': bridgesystem['TS'], 'TGID': bridgesystem['TGID'],'ACTIVE': False,'TIMEOUT': bridgesystem['TIMEOUT'],'TO_TYPE': 'ON','OFF': [],'ON': [bridgesystem['TGID'],],'RESET': [], 'TIMER': time() + bridgesystem['TIMEOUT']}) - logger.debug('RBS False: %s: %s',system, {'SYSTEM': system, 'TS': bridgesystem['TS'], 'TGID': bridgesystem['TGID'],'ACTIVE': False,'TIMEOUT': bridgesystem['TIMEOUT'],'TO_TYPE': 'ON','OFF': [],'ON': [bridgesystem['TGID'],],'RESET': [], 'TIMER': time() + bridgesystem['TIMEOUT']} ) - else: - bridgetemp.append(bridgesystem) - logger.debug('RBS: existing %s',bridgesystem) - bt[bridge] = bridgetemp + for bridge in BRIDGES: + bridgetemp = [] + for bridgesystem in BRIDGES[bridge]: + if bridgesystem['SYSTEM'] == remsystem: + reset_bridgesystem = { + 'SYSTEM': remsystem, + 'TS': bridgesystem['TS'], + 'TGID': bridgesystem['TGID'], + 'ACTIVE': False, + 'TIMEOUT': bridgesystem['TIMEOUT'], + 'TO_TYPE': 'ON', + 'OFF': bridgesystem['OFF'], + 'ON': bridgesystem['ON'], + 'RESET': bridgesystem['RESET'], + 'TIMER': time() + bridgesystem['TIMEOUT'], + } + bridgetemp.append(reset_bridgesystem) + logger.debug('RBS False: %s: %s',remsystem, reset_bridgesystem) + else: + bridgetemp.append(bridgesystem) + logger.debug('RBS: existing %s',bridgesystem) + bt[bridge] = bridgetemp for bridge in bt: BRIDGES[bridge] = bt[bridge] @@ -605,19 +704,24 @@ def stream_trimmer_loop(): else: logger.info('(%s) *TIME OUT* RX STREAM ID: %s SUB: %s TGID %s, TS %s, Duration: %.2f', \ system, int_id(_slot['RX_STREAM_ID']), int_id(_slot['RX_RFS']), int_id(_slot['RX_TGID']), slot, _slot['RX_TIME'] - _slot['RX_START']) - if CONFIG['REPORTS']['REPORT']: + if CONFIG['REPORTS']['REPORT'] and _slot.get('RX_GROUP_VOICE_STREAM') and not _slot.get('RX_DATA_STREAM'): systems[system]._report.send_bridgeEvent('GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}'.format(system, int_id(_slot['RX_STREAM_ID']), int_id(_slot['RX_PEER']), int_id(_slot['RX_RFS']), slot, int_id(_slot['RX_TGID']), _slot['RX_TIME'] - _slot['RX_START']).encode(encoding='utf-8', errors='ignore')) + _slot['RX_DATA_STREAM'] = False + _slot['RX_GROUP_VOICE_STREAM'] = False #Null stream_id - for loop control if _slot['RX_TIME'] < _now - 60: _slot['RX_STREAM_ID'] = b'\x00' + _slot['RX_FINISHED_STREAM_ID'] = b'\x00' + _slot['RX_FINISHED_STREAM_LOG'] = False # TX slot check if _slot['TX_TYPE'] != HBPF_SLT_VTERM and _slot['TX_TIME'] < _now - 5: _slot['TX_TYPE'] = HBPF_SLT_VTERM logger.debug('(%s) *TIME OUT* TX STREAM ID: %s SUB: %s TGID %s, TS %s, Duration: %.2f', \ system, int_id(_slot['TX_STREAM_ID']), int_id(_slot['TX_RFS']), int_id(_slot['TX_TGID']), slot, _slot['TX_TIME'] - _slot['TX_START']) - if CONFIG['REPORTS']['REPORT']: + if CONFIG['REPORTS']['REPORT'] and not _slot.get('TX_DATA_STREAM'): systems[system]._report.send_bridgeEvent('GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}'.format(system, int_id(_slot['TX_STREAM_ID']), int_id(_slot['TX_PEER']), int_id(_slot['TX_RFS']), slot, int_id(_slot['TX_TGID']), _slot['TX_TIME'] - _slot['TX_START']).encode(encoding='utf-8', errors='ignore')) + _slot['TX_DATA_STREAM'] = False # OBP systems # We can't delete items from a dicationry that's being iterated, so we have to make a temporarly list of entrys to remove later @@ -655,7 +759,7 @@ def stream_trimmer_loop(): logger.debug('(%s) *TIME OUT* STREAM ID: %s SUB: %s PEER: %s TGID: %s TS 1 Duration: %.2f', \ system, int_id(stream_id), get_alias(int_id(_stream['RFS']), subscriber_ids), get_alias(int_id(_stream['RX_PEER']), peer_ids), get_alias(int_id(_stream['TGID']), talkgroup_ids), _stream['LAST'] - _stream['START']) - if CONFIG['REPORTS']['REPORT']: + if CONFIG['REPORTS']['REPORT'] and not _stream.get('DATA_STREAM'): systems[system]._report.send_bridgeEvent('GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}'.format(system, int_id(stream_id), int_id(_stream['RX_PEER']), int_id(_stream['RFS']), 1, int_id(_stream['TGID']), _stream['LAST'] - _stream['START']).encode(encoding='utf-8', errors='ignore')) systems[system].STATUS[stream_id]['_to'] = True continue @@ -696,9 +800,31 @@ def stream_trimmer_loop(): else: logger.debug('(%s) Attemped to remove OpenBridge Stream ID %s not in the Stream ID list: %s', system, int_id(stream_id), [id for id in systems[system].STATUS]) +def _beginGeneratedVoice(_slot): + _token = object() + _slot['TX_PROMPT_ACTIVE'] = True + _slot['TX_PROMPT_CANCEL'] = False + _slot['TX_PROMPT_TOKEN'] = _token + return _token + +def _generatedVoiceCancelled(_slot, _token): + return _slot.get('TX_PROMPT_CANCEL', False) or _slot.get('TX_PROMPT_TOKEN') is not _token + +def _endGeneratedVoice(_slot, _token): + if _slot.get('TX_PROMPT_TOKEN') is _token: + _slot['TX_PROMPT_ACTIVE'] = False + +def _cancelGeneratedVoice(_slot): + if _slot.get('TX_PROMPT_ACTIVE', False): + _slot['TX_PROMPT_CANCEL'] = True + _slot['TX_PROMPT_ACTIVE'] = False + def sendVoicePacket(self,pkt,_source_id,_dest_id,_slot): + system = self._system _stream_id = pkt[16:20] _pkt_time = time() + if _slot.get('TX_PROMPT_CANCEL', False): + return if _stream_id not in systems[system].STATUS: systems[system].STATUS[_stream_id] = { 'START': _pkt_time, @@ -707,28 +833,38 @@ def sendVoicePacket(self,pkt,_source_id,_dest_id,_slot): 'TGID': _dest_id, 'LAST': _pkt_time } - _slot['TX_TGID'] = _dest_id else: systems[system].STATUS[_stream_id]['LAST'] = _pkt_time - _slot['TX_TIME'] = _pkt_time + _slot['TX_PROMPT_ACTIVE'] = True + _slot['TX_PROMPT_TIME'] = _pkt_time + _slot['TX_PROMPT_STREAM_ID'] = _stream_id + _slot['TX_PROMPT_TGID'] = _dest_id + _slot['TX_PROMPT_RFS'] = _source_id self.send_system(pkt) def sendSpeech(self,speech): + system = self._system logger.debug('(%s) Inside sendspeech thread',self._system) sleep(1) _nine = bytes_3(9) _source_id = bytes_3(5000) _slot = systems[system].STATUS[2] + _prompt_token = _beginGeneratedVoice(_slot) while True: + if _generatedVoiceCancelled(_slot, _prompt_token): + break try: pkt = next(speech) except StopIteration: break #Packet every 60ms sleep(0.058) + if _generatedVoiceCancelled(_slot, _prompt_token): + break reactor.callFromThread(sendVoicePacket,self,pkt,_source_id,_nine,_slot) + _endGeneratedVoice(_slot, _prompt_token) logger.debug('(%s) Sendspeech thread ended',self._system) def disconnectedVoice(system): @@ -758,17 +894,21 @@ def disconnectedVoice(system): sleep(1) _slot = systems[system].STATUS[2] + _prompt_token = _beginGeneratedVoice(_slot) while True: + if _generatedVoiceCancelled(_slot, _prompt_token): + break try: pkt = next(speech) except StopIteration: break #Packet every 60ms sleep(0.058) - _stream_id = pkt[16:20] - _pkt_time = time() + if _generatedVoiceCancelled(_slot, _prompt_token): + break reactor.callFromThread(sendVoicePacket,systems[system],pkt,_source_id,_nine,_slot) logger.debug('(%s) disconnected voice thread end',system) + _endGeneratedVoice(_slot, _prompt_token) def playFileOnRequest(self,fileNumber): system = self._system @@ -786,16 +926,20 @@ def playFileOnRequest(self,fileNumber): speech = pkt_gen(_source_id, _nine, bytes_4(9), 1, _say) sleep(1) _slot = systems[system].STATUS[2] + _prompt_token = _beginGeneratedVoice(_slot) while True: + if _generatedVoiceCancelled(_slot, _prompt_token): + break try: pkt = next(speech) except StopIteration: break #Packet every 60ms sleep(0.058) - _stream_id = pkt[16:20] - _pkt_time = time() + if _generatedVoiceCancelled(_slot, _prompt_token): + break reactor.callFromThread(sendVoicePacket,self,pkt,_source_id,_nine,_slot) + _endGeneratedVoice(_slot, _prompt_token) logger.debug('(%s) Sending AMBE file %s end',system,fileNumber) @@ -809,7 +953,13 @@ def threadAlias(): reactor.callInThread(aliasb) def setAlias(_peer_ids,_subscriber_ids, _talkgroup_ids, _local_subscriber_ids, _server_ids, _checksums): + global peer_ids, subscriber_ids, talkgroup_ids, local_subscriber_ids, server_ids, checksums peer_ids, subscriber_ids, talkgroup_ids,local_subscriber_ids,server_ids,checksums = _peer_ids, _subscriber_ids, _talkgroup_ids, _local_subscriber_ids,_server_ids,_checksums + CONFIG['_SUB_IDS'] = subscriber_ids + CONFIG['_PEER_IDS'] = peer_ids + CONFIG['_LOCAL_SUBSCRIBER_IDS'] = local_subscriber_ids + CONFIG['_SERVER_IDS'] = server_ids + CONFIG['CHECKSUMS'] = checksums def aliasb(): _peer_ids, _subscriber_ids, _talkgroup_ids, _local_subscriber_ids, _server_ids, _checksums = mk_aliases(CONFIG) @@ -838,10 +988,14 @@ def ident(): _source_id= bytes_3(5000) _dst_id = b'' - - if 'OVERRIDE_IDENT_TG' in CONFIG['SYSTEMS'][system] and CONFIG['SYSTEMS'][system]['OVERRIDE_IDENT_TG'] and int(CONFIG['SYSTEMS'][system]['OVERRIDE_IDENT_TG']) > 0 and int(CONFIG['SYSTEMS'][system]['OVERRIDE_IDENT_TG'] < 16777215): - _dst_id = bytes_3(CONFIG['SYSTEMS'][system]['OVERRIDE_IDENT_TG']) + _override_ident_tg = CONFIG['SYSTEMS'][system].get('OVERRIDE_IDENT_TG') + _override_ident_tg_valid = valid_ident_override_tg(_override_ident_tg) + + if _override_ident_tg_valid is not None: + _dst_id = bytes_3(_override_ident_tg_valid) else: + if _override_ident_tg: + logger.warning('(IDENT) %s invalid OVERRIDE_IDENT_TG %s, using all-call', system, _override_ident_tg) _dst_id = _all_call logger.info('(%s) %s System idle. Sending voice ident to TG %s',system,_callsign,get_alias(_dst_id,talkgroup_ids)) _say = [words[_lang]['silence']] @@ -878,17 +1032,20 @@ def ident(): sleep(1) _slot = systems[system].STATUS[2] + _prompt_token = _beginGeneratedVoice(_slot) while True: + if _generatedVoiceCancelled(_slot, _prompt_token): + break try: pkt = next(speech) except StopIteration: break #Packet every 60ms sleep(0.058) - - _stream_id = pkt[16:20] - _pkt_time = time() + if _generatedVoiceCancelled(_slot, _prompt_token): + break reactor.callFromThread(sendVoicePacket,systems[system],pkt,_source_id,_dst_id,_slot) + _endGeneratedVoice(_slot, _prompt_token) def bridge_reset(): logger.debug('(BRIDGERESET) Running bridge resetter') @@ -902,13 +1059,13 @@ def bridge_reset(): pass CONFIG['SYSTEMS'][_system]['_reset'] = False CONFIG['SYSTEMS'][_system]['_resetlog'] = False - if 'OPTIONS' in CONFIG['SYSTEMS'][_system]['OPTIONS']: + if 'OPTIONS' in CONFIG['SYSTEMS'][_system]: CONFIG['SYSTEMS'][_system]['_reloadoptions'] = True def options_config(): logger.debug('(OPTIONS) Running options parser') - prohibitedTGs = [0,1,2,3,4,5,6,7,8,9,9990,9991,9992,9993,9994,9995,9996,9997,9998,9999] + prohibitedTGs = DIAL_A_TG_PROHIBITED_DEFAULTS systemList = CONFIG['SYSTEMS'].keys() for _system in systemList: @@ -1022,23 +1179,45 @@ def options_config(): if 'DEFAULT_UA_TIMER' not in _options: _options['DEFAULT_UA_TIMER'] = CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER'] - - if 'VOICE' in _options and bool(_options['VOICE']) and (CONFIG['SYSTEMS'][_system]['VOICE_IDENT'] != bool(int(_options['VOICE']))): - CONFIG['SYSTEMS'][_system]['VOICE_IDENT'] = bool(int(_options['VOICE'])) - logger.debug("(OPTIONS) %s - Setting voice ident to %s",_system,CONFIG['SYSTEMS'][_system]['VOICE_IDENT']) - - if 'OVERRIDE_IDENT_TG' in _options and _options['OVERRIDE_IDENT_TG'] and (CONFIG['SYSTEMS'][_system]['OVERRIDE_IDENT_TG'] != int(_options['OVERRIDE_IDENT_TG'])): - CONFIG['SYSTEMS'][_system]['OVERRIDE_IDENT_TG'] = int(_options['OVERRIDE_IDENT_TG']) - logger.debug("(OPTIONS) %s - Setting OVERRIDE_IDENT_TG to %s",_system,CONFIG['SYSTEMS'][_system]['OVERRIDE_IDENT_TG']) + + _default_reflector = parse_default_reflector_option(_options['DEFAULT_REFLECTOR'], default=None) + if _default_reflector is None: + logger.debug('(OPTIONS) %s - DEFAULT_REFLECTOR is not an integer, ignoring',_system) + _default_reflector = CONFIG['SYSTEMS'][_system]['DEFAULT_REFLECTOR'] + + _default_ua_timer = parse_int_option(_options['DEFAULT_UA_TIMER'], default=None) + if _default_ua_timer is None: + logger.debug('(OPTIONS) %s - DEFAULT_UA_TIMER is not an integer, ignoring',_system) + _default_ua_timer = CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER'] + + if 'VOICE' in _options: + _voice_ident = parse_bool_option(_options['VOICE'], default=None) + if _voice_ident is None: + logger.debug('(OPTIONS) %s - VOICE is not 0 or 1, ignoring',_system) + elif CONFIG['SYSTEMS'][_system]['VOICE_IDENT'] != _voice_ident: + CONFIG['SYSTEMS'][_system]['VOICE_IDENT'] = _voice_ident + logger.debug("(OPTIONS) %s - Setting voice ident to %s",_system,CONFIG['SYSTEMS'][_system]['VOICE_IDENT']) + + if 'OVERRIDE_IDENT_TG' in _options and _options['OVERRIDE_IDENT_TG']: + _override_ident_tg = parse_int_option(_options['OVERRIDE_IDENT_TG'], default=None) + if _override_ident_tg is None: + logger.debug('(OPTIONS) %s - OVERRIDE_IDENT_TG is not an integer, ignoring',_system) + elif CONFIG['SYSTEMS'][_system]['OVERRIDE_IDENT_TG'] != _override_ident_tg: + CONFIG['SYSTEMS'][_system]['OVERRIDE_IDENT_TG'] = _override_ident_tg + logger.debug("(OPTIONS) %s - Setting OVERRIDE_IDENT_TG to %s",_system,CONFIG['SYSTEMS'][_system]['OVERRIDE_IDENT_TG']) if 'LANG' in _options and _options['LANG'] in words and _options['LANG'] != CONFIG['SYSTEMS'][_system]['ANNOUNCEMENT_LANGUAGE'] : CONFIG['SYSTEMS'][_system]['ANNOUNCEMENT_LANGUAGE'] = _options['LANG'] logger.debug("(OPTIONS) %s - Setting voice language to %s",_system,CONFIG['SYSTEMS'][_system]['ANNOUNCEMENT_LANGUAGE']) - if 'SINGLE' in _options and (CONFIG['SYSTEMS'][_system]['SINGLE_MODE'] != bool(int(_options['SINGLE']))): - CONFIG['SYSTEMS'][_system]['SINGLE_MODE'] = bool(int(_options['SINGLE'])) - logger.debug("(OPTIONS) %s - Setting SINGLE_MODE to %s",_system,CONFIG['SYSTEMS'][_system]['SINGLE_MODE']) + if 'SINGLE' in _options: + _single_mode = parse_bool_option(_options['SINGLE'], default=None) + if _single_mode is None: + logger.debug('(OPTIONS) %s - SINGLE is not 0 or 1, ignoring',_system) + elif CONFIG['SYSTEMS'][_system]['SINGLE_MODE'] != _single_mode: + CONFIG['SYSTEMS'][_system]['SINGLE_MODE'] = _single_mode + logger.debug("(OPTIONS) %s - Setting SINGLE_MODE to %s",_system,CONFIG['SYSTEMS'][_system]['SINGLE_MODE']) if 'TS1_STATIC' not in _options or 'TS2_STATIC' not in _options or 'DEFAULT_REFLECTOR' not in _options or 'DEFAULT_UA_TIMER' not in _options: logger.debug('(OPTIONS) %s - Required field missing, ignoring',_system) @@ -1048,105 +1227,54 @@ def options_config(): _options['TS1_STATIC'] = False if _options['TS2_STATIC'] == '': _options['TS2_STATIC'] = False - - if _options['TS1_STATIC']: - re.sub("\s","",_options['TS1_STATIC']) - if re.search("![\d\,]",_options['TS1_STATIC']): - logger.debug('(OPTIONS) %s - TS1_STATIC contains characters other than numbers and comma, ignoring',_system) - continue - if _options['TS2_STATIC']: - re.sub("\s","",_options['TS2_STATIC']) - if re.search("![\d\,]",_options['TS2_STATIC']): - logger.debug('(OPTIONS) %s - TS2_STATIC contains characters other than numbers and comma, ignoring',_system) - continue - - if isinstance(_options['DEFAULT_REFLECTOR'], str) and not _options['DEFAULT_REFLECTOR'].isdigit(): - logger.debug('(OPTIONS) %s - DEFAULT_REFLECTOR is not an integer, ignoring',_system) - continue - - if isinstance(_options['OVERRIDE_IDENT_TG'], str) and not _options['OVERRIDE_IDENT_TG'].isdigit(): - logger.debug('(OPTIONS) %s - OVERRIDE_IDENT_TG is not an integer, ignoring',_system) - continue - - - if isinstance(_options['DEFAULT_UA_TIMER'], str) and not _options['DEFAULT_UA_TIMER'].isdigit(): - logger.debug('(OPTIONS) %s - DEFAULT_UA_TIMER is not an integer, ignoring',_system) - continue - #if the UA timer is set to 0 - actually set it to (close to) maximum size of a 32 #bit signed int - which works out at around 68 years! #For all practical purposes, this implements an unlimited timer - aka sticky static. - if int(_options['DEFAULT_UA_TIMER']) == 0: - _options['DEFAULT_UA_TIMER'] = 35791394 + if _default_ua_timer == 0: + _default_ua_timer = 35791394 - _tmout = int(_options['DEFAULT_UA_TIMER']) + _tmout = _default_ua_timer - if ('_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']) or (int(_options['DEFAULT_UA_TIMER']) != CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER']): + if ('_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']) or (_default_ua_timer != CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER']): logger.debug('(OPTIONS) %s Updating DEFAULT_UA_TIMER for existing bridges.',_system) update_timeout(_system,_tmout) - if int(_options['DEFAULT_REFLECTOR']) != CONFIG['SYSTEMS'][_system]['DEFAULT_REFLECTOR']: + if _default_reflector != CONFIG['SYSTEMS'][_system]['DEFAULT_REFLECTOR']: - if int(_options['DEFAULT_REFLECTOR']) in prohibitedTGs and int(_options['DEFAULT_REFLECTOR']) > 0: + if _default_reflector > 0 and not valid_dial_a_tg_reflector(_default_reflector): logger.debug('(OPTIONS) %s default dial-a-tg is in prohibited list, ignoring change',_system) - elif int(_options['DEFAULT_REFLECTOR']) > 0: + reset_all_reflector_system(_tmout,_system) + _default_reflector = 0 + elif _default_reflector > 0: logger.debug('(OPTIONS) %s default dial-a-tg changed, updating',_system) reset_all_reflector_system(_tmout,_system) - make_default_reflector(int(_options['DEFAULT_REFLECTOR']),_tmout,_system) + make_default_reflector(_default_reflector,_tmout,_system) else: logger.debug('(OPTIONS) %s default dial-a-tg disabled, updating',_system) reset_all_reflector_system(_tmout,_system) ts1 = [] if ('_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']) or (_options['TS1_STATIC'] != CONFIG['SYSTEMS'][_system]['TS1_STATIC']): - _tmout = int(_options['DEFAULT_UA_TIMER']) + _tmout = _default_ua_timer logger.debug('(OPTIONS) %s TS1 static TGs changed, updating',_system) - ts1 = [] - if CONFIG['SYSTEMS'][_system]['TS1_STATIC']: - ts1 = CONFIG['SYSTEMS'][_system]['TS1_STATIC'].split(',') - for tg in ts1: - if not tg: - continue - tg = int(tg) - reset_static_tg(tg,1,_tmout,_system) - if _options['TS1_STATIC']: - ts1 = _options['TS1_STATIC'].split(',') - for tg in ts1: - if not tg: - continue - elif int(tg) in prohibitedTGs: - logger.debug('(OPTIONS) %s TS1 TG %s is prohibited, ignoring change',_system,tg) - tg = int(tg) - make_static_tg(tg,1,_tmout,_system) + for tg in parse_static_tgs(CONFIG['SYSTEMS'][_system]['TS1_STATIC']): + reset_static_tg(tg,1,_tmout,_system) + for tg in parse_static_tgs(_options['TS1_STATIC']): + make_static_tg(tg,1,_tmout,_system) ts2 = [] if ('_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']) or (_options['TS2_STATIC'] != CONFIG['SYSTEMS'][_system]['TS2_STATIC']): - _tmout = int(_options['DEFAULT_UA_TIMER']) + _tmout = _default_ua_timer logger.debug('(OPTIONS) %s TS2 static TGs changed, updating',_system) - if CONFIG['SYSTEMS'][_system]['TS2_STATIC']: - ts2 = CONFIG['SYSTEMS'][_system]['TS2_STATIC'].split(',') - for tg in ts2: - if not tg or int(tg) == 0 or int(tg) >= 16777215: - continue - tg = int(tg) - reset_static_tg(tg,2,_tmout,_system) - ts2 = [] - if _options['TS2_STATIC']: - ts2 = _options['TS2_STATIC'].split(',') - for tg in ts2: - if not tg or int(tg) == 0 or int(tg) >= 16777215: - continue - elif int(tg) in prohibitedTGs: - logger.debug('(OPTIONS) %s TS2 TG %s is prohibited, ignoring change',_system,tg) - continue - - tg = int(tg) - make_static_tg(tg,2,_tmout,_system) + for tg in parse_static_tgs(CONFIG['SYSTEMS'][_system]['TS2_STATIC']): + reset_static_tg(tg,2,_tmout,_system) + for tg in parse_static_tgs(_options['TS2_STATIC']): + make_static_tg(tg,2,_tmout,_system) CONFIG['SYSTEMS'][_system]['TS1_STATIC'] = _options['TS1_STATIC'] CONFIG['SYSTEMS'][_system]['TS2_STATIC'] = _options['TS2_STATIC'] - CONFIG['SYSTEMS'][_system]['DEFAULT_REFLECTOR'] = int(_options['DEFAULT_REFLECTOR']) - CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER'] = int(_options['DEFAULT_UA_TIMER']) + CONFIG['SYSTEMS'][_system]['DEFAULT_REFLECTOR'] = _default_reflector + CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER'] = _default_ua_timer if '_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']: CONFIG['SYSTEMS'][_system]['_reloadoptions'] = False @@ -1175,6 +1303,8 @@ class routerOBP(OPENBRIDGE): def to_target(self, _peer_id, _rf_src, _dst_id, _seq, _slot, _call_type, _frame_type, _dtype_vseq, _stream_id, _data, pkt_time, dmrpkt, _bits,_bridge,_system,_noOBP,sysIgnore, _hops = b'', _source_server = b'\x00\x00\x00\x00', _ber = b'\x00', _rssi = b'\x00', _source_rptr = b'\x00\x00\x00\x00'): _sysIgnore = sysIgnore + _data_control = is_group_data_control(_call_type, _dtype_vseq) + _data_event = group_data_report_name(_call_type, _dtype_vseq) for _target in BRIDGES[_bridge]: if (_target['SYSTEM'] != self._system) and (_target['ACTIVE']): _target_status = systems[_target['SYSTEM']].STATUS @@ -1217,6 +1347,7 @@ class routerOBP(OPENBRIDGE): 'RFS': _rf_src, 'TGID': _dst_id, 'RX_PEER': _peer_id, + 'DATA_STREAM': _data_control, } # Generate LCs (full and EMB) for the TX stream @@ -1232,7 +1363,10 @@ class routerOBP(OPENBRIDGE): logger.debug('(%s) Conference Bridge: %s, Call Bridged to OBP System: %s TS: %s, TGID: %s', self._system, _bridge, _target['SYSTEM'], _target['TS'], int_id(_target['TGID'])) if CONFIG['REPORTS']['REPORT']: - systems[_target['SYSTEM']]._report.send_bridgeEvent('GROUP VOICE,START,TX,{},{},{},{},{},{}'.format(_target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID'])).encode(encoding='utf-8', errors='ignore')) + if not _data_control: + systems[_target['SYSTEM']]._report.send_bridgeEvent('GROUP VOICE,START,TX,{},{},{},{},{},{}'.format(_target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID'])).encode(encoding='utf-8', errors='ignore')) + else: + systems[_target['SYSTEM']]._report.send_bridgeEvent('{},DATA,TX,{},{},{},{},{},{}'.format(_data_event, _target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID'])).encode(encoding='utf-8', errors='ignore')) # Record the time of this packet so we can later identify a stale stream _target_status[_stream_id]['LAST'] = pkt_time @@ -1255,16 +1389,18 @@ class routerOBP(OPENBRIDGE): try: dmrbits = _target_status[_stream_id]['T_LC'][0:98] + dmrbits[98:166] + _target_status[_stream_id]['T_LC'][98:197] except KeyError: - logger.warning('(%s) KeyError - T_LC, Skipping',system) - if CONFIG['REPORTS']['REPORT']: + logger.warning('(%s) KeyError - T_LC, Skipping', self._system) + if CONFIG['REPORTS']['REPORT'] and not _target_status[_stream_id].get('DATA_STREAM'): call_duration = pkt_time - _target_status[_stream_id]['START'] systems[_target['SYSTEM']]._report.send_bridgeEvent('GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}'.format(_target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID']), call_duration).encode(encoding='utf-8', errors='ignore')) + if not _target_status[_stream_id].get('DATA_STREAM'): + _target_status[_stream_id]['_fin'] = True # Create a Burst B-E packet (Embedded LC) - elif _dtype_vseq in [1,2,3,4]: + elif _frame_type == HBPF_VOICE and _dtype_vseq in [1,2,3,4]: try: dmrbits = dmrbits[0:116] + _target_status[_stream_id]['EMB_LC'][_dtype_vseq] + dmrbits[148:264] except KeyError: - logger.warning('(%s) KeyError - EMB_LC, skipping',system) + logger.warning('(%s) KeyError - EMB_LC, skipping', self._system) continue dmrpkt = dmrbits.tobytes() _tmp_data = b''.join([_tmp_data, dmrpkt]) @@ -1302,12 +1438,15 @@ class routerOBP(OPENBRIDGE): # Is this a new call stream? if (_target_status[_target['TS']]['TX_STREAM_ID'] != _stream_id): + if not _data_control: + _cancelGeneratedVoice(_target_status[_target['TS']]) # Record the DST TGID and Stream ID _target_status[_target['TS']]['TX_START'] = pkt_time _target_status[_target['TS']]['TX_TGID'] = _target['TGID'] _target_status[_target['TS']]['TX_STREAM_ID'] = _stream_id _target_status[_target['TS']]['TX_RFS'] = _rf_src _target_status[_target['TS']]['TX_PEER'] = _peer_id + _target_status[_target['TS']]['TX_DATA_STREAM'] = _data_control # Generate LCs (full and EMB) for the TX stream dst_lc = b''.join([self.STATUS[_stream_id]['LC'][0:3], _target['TGID'], _rf_src]) _target_status[_target['TS']]['TX_H_LC'] = bptc.encode_header_lc(dst_lc) @@ -1316,7 +1455,10 @@ class routerOBP(OPENBRIDGE): logger.debug('(%s) Generating TX FULL and EMB LCs for HomeBrew destination: System: %s, TS: %s, TGID: %s', self._system, _target['SYSTEM'], _target['TS'], int_id(_target['TGID'])) logger.debug('(%s) Conference Bridge: %s, Call Bridged to HBP System: %s TS: %s, TGID: %s', self._system, _bridge, _target['SYSTEM'], _target['TS'], int_id(_target['TGID'])) if CONFIG['REPORTS']['REPORT']: - systems[_target['SYSTEM']]._report.send_bridgeEvent('GROUP VOICE,START,TX,{},{},{},{},{},{}'.format(_target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID'])).encode(encoding='utf-8', errors='ignore')) + if not _data_control: + systems[_target['SYSTEM']]._report.send_bridgeEvent('GROUP VOICE,START,TX,{},{},{},{},{},{}'.format(_target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID'])).encode(encoding='utf-8', errors='ignore')) + else: + systems[_target['SYSTEM']]._report.send_bridgeEvent('{},DATA,TX,{},{},{},{},{},{}'.format(_data_event, _target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID'])).encode(encoding='utf-8', errors='ignore')) # Set other values for the contention handler to test next time there is a frame to forward _target_status[_target['TS']]['TX_TIME'] = pkt_time @@ -1342,11 +1484,11 @@ class routerOBP(OPENBRIDGE): # Create a voice terminator packet (FULL LC) elif _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VTERM: dmrbits = _target_status[_target['TS']]['TX_T_LC'][0:98] + dmrbits[98:166] + _target_status[_target['TS']]['TX_T_LC'][98:197] - if CONFIG['REPORTS']['REPORT']: + if CONFIG['REPORTS']['REPORT'] and not _target_status[_target['TS']].get('TX_DATA_STREAM'): call_duration = pkt_time - _target_status[_target['TS']]['TX_START'] systems[_target['SYSTEM']]._report.send_bridgeEvent('GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}'.format(_target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID']), call_duration).encode(encoding='utf-8', errors='ignore')) # Create a Burst B-E packet (Embedded LC) - elif _dtype_vseq in [1,2,3,4]: + elif _frame_type == HBPF_VOICE and _dtype_vseq in [1,2,3,4]: dmrbits = dmrbits[0:116] + _target_status[_target['TS']]['TX_EMB_LC'][_dtype_vseq] + dmrbits[148:264] dmrpkt = dmrbits.tobytes() #_tmp_data = b''.join([_tmp_data, dmrpkt, b'\x00\x00']) # Add two bytes of nothing since OBP doesn't include BER & RSSI bytes #_data[53:55] @@ -1364,9 +1506,9 @@ class routerOBP(OPENBRIDGE): _tmp_data = b''.join([_data[:15], _tmp_bits.to_bytes(1, 'big'), _data[16:20]]) _tmp_data = b''.join([_tmp_data, dmrpkt]) systems[_d_system].send_system(_tmp_data) - logger.debug('(%s) UNIT Data Bridged to HBP on slot 1: %s DST_ID: %s',self._system,_d_system,_int_dst_id) + logger.debug('(%s) UNIT Data Bridged to HBP on slot %s: %s DST_ID: %s',self._system,_d_slot,_d_system,_int_dst_id) if CONFIG['REPORTS']['REPORT']: - systems[_d_system]._report.send_bridgeEvent('UNIT DATA,DATA,TX,{},{},{},{},{},{}'.format(_d_system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), 1, _int_dst_id).encode(encoding='utf-8', errors='ignore')) + systems[_d_system]._report.send_bridgeEvent('UNIT DATA,DATA,TX,{},{},{},{},{},{}'.format(_d_system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _d_slot, _int_dst_id).encode(encoding='utf-8', errors='ignore')) def sendDataToOBP(self,_target,_data,dmrpkt,pkt_time,_stream_id,_dst_id,_peer_id,_rf_src,_bits,_slot,_hops = b'',_source_server = b'\x00\x00\x00\x00', _ber = b'\x00', _rssi = b'\x00', _source_rptr = b'\x00\x00\x00\x00'): @@ -1376,7 +1518,7 @@ class routerOBP(OPENBRIDGE): #If target has missed 6 (on 1 min) of keepalives, don't send - if _target_system['ENHANCED_OBP'] and '_bcka' in _target_system and _target_system['_bcka'] < pkt_time - 60: + if _target_system['ENHANCED_OBP'] and ('_bcka' not in _target_system or _target_system['_bcka'] < pkt_time - 60): return if (_stream_id not in _target_status): @@ -1482,7 +1624,7 @@ class routerOBP(OPENBRIDGE): if 'LOOPLOG' not in self.STATUS[_stream_id] or not self.STATUS[_stream_id]['LOOPLOG']: call_duration = pkt_time - self.STATUS[_stream_id]['START'] packet_rate = 0 - if 'packets' in self.STATUS[_stream_id]: + if call_duration and 'packets' in self.STATUS[_stream_id]: packet_rate = self.STATUS[_stream_id]['packets'] / call_duration logger.debug("(%s) OBP UNIT *LoopControl* FIRST OBP %s, STREAM ID: %s, TG %s, IGNORE THIS SOURCE. PACKET RATE %0.2f/s",self._system, fi, int_id(_stream_id), int_id(_dst_id),packet_rate) self.STATUS[_stream_id]['LOOPLOG'] = True @@ -1530,7 +1672,7 @@ class routerOBP(OPENBRIDGE): #We only want to send data calls to individual IDs via OpenBridge #Only send if proto ver for bridge is > 1 if CONFIG['SYSTEMS'][system]['MODE'] == 'OPENBRIDGE' and CONFIG['SYSTEMS'][system]['VER'] > 1 and (_int_dst_id >= 1000000): - self.sendDataToOBP(system,_data,dmrpkt,pkt_time,_stream_id,_dst_id,_peer_id,_rf_src,_bits,_slot,_hops,_source_server,_ber,_rssi) + self.sendDataToOBP(system,_data,dmrpkt,pkt_time,_stream_id,_dst_id,_peer_id,_rf_src,_bits,_slot,_hops,_source_server,_ber,_rssi,_source_rptr) #If destination ID is in the Subscriber Map if _dst_id in SUB_MAP: @@ -1578,6 +1720,8 @@ class routerOBP(OPENBRIDGE): if _call_type == 'group' or _call_type == 'vcsbk': + _data_control = is_group_data_control(_call_type, _dtype_vseq) + _data_event = group_data_report_name(_call_type, _dtype_vseq) # Is this a new call stream? if (_stream_id not in self.STATUS): @@ -1593,7 +1737,8 @@ class routerOBP(OPENBRIDGE): 'RX_PEER': _peer_id, 'packets': 0, 'loss': 0, - 'crcs': set() + 'crcs': set(), + 'DATA_STREAM': _data_control } @@ -1610,10 +1755,14 @@ class routerOBP(OPENBRIDGE): _inthops = 0 if _hops: _inthops = int.from_bytes(_hops,'big') - logger.info('(%s) *CALL START* STREAM ID: %s, SUB: %s (%s), RPTR: %s (%s), PEER: %s (%s) TGID %s (%s), TS %s, SRC: %s, HOPS %s', - self._system, int_id(_stream_id),get_alias(_rf_src, subscriber_ids),int_id(_rf_src),self.get_rptr(_source_rptr), int_id(_source_rptr), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot,int_id(_source_server),_inthops) + _rx_event = _data_event if _data_control else 'CALL START' + logger.info('(%s) *%s* STREAM ID: %s, SUB: %s (%s), RPTR: %s (%s), PEER: %s (%s) TGID %s (%s), TS %s, SRC: %s, HOPS %s', + self._system, _rx_event, int_id(_stream_id),get_alias(_rf_src, subscriber_ids),int_id(_rf_src),self.get_rptr(_source_rptr), int_id(_source_rptr), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot,int_id(_source_server),_inthops) if CONFIG['REPORTS']['REPORT']: - self._report.send_bridgeEvent('GROUP VOICE,START,RX,{},{},{},{},{},{}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id)).encode(encoding='utf-8', errors='ignore')) + if not _data_control: + self._report.send_bridgeEvent('GROUP VOICE,START,RX,{},{},{},{},{},{}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id)).encode(encoding='utf-8', errors='ignore')) + else: + self._report.send_bridgeEvent('{},DATA,RX,{},{},{},{},{},{}'.format(_data_event, self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id)).encode(encoding='utf-8', errors='ignore')) else: @@ -1667,9 +1816,9 @@ class routerOBP(OPENBRIDGE): if 'LOOPLOG' not in self.STATUS[_stream_id] or not self.STATUS[_stream_id]['LOOPLOG']: call_duration = pkt_time - self.STATUS[_stream_id]['START'] packet_rate = 0 - if 'packets' in self.STATUS[_stream_id]: + if call_duration and 'packets' in self.STATUS[_stream_id]: packet_rate = self.STATUS[_stream_id]['packets'] / call_duration - logger.debug("(%s) OBP *LoopControl* FIRST OBP %s, STREAM ID: %s, TG %s, IGNORE THIS SOURCE. PACKET RATE %0.2f/s",self._system, fi, int_id(_stream_id), int_id(_dst_id),call_duration) + logger.debug("(%s) OBP *LoopControl* FIRST OBP %s, STREAM ID: %s, TG %s, IGNORE THIS SOURCE. PACKET RATE %0.2f/s",self._system, fi, int_id(_stream_id), int_id(_dst_id),packet_rate) self.STATUS[_stream_id]['LOOPLOG'] = True self.STATUS[_stream_id]['LAST'] = pkt_time @@ -1679,7 +1828,8 @@ class routerOBP(OPENBRIDGE): return #Rate drop - if self.STATUS[_stream_id]['packets'] > 18 and (self.STATUS[_stream_id]['packets'] / self.STATUS[_stream_id]['START'] > 25): + call_duration = pkt_time - self.STATUS[_stream_id]['START'] + if call_duration and self.STATUS[_stream_id]['packets'] > 18 and (self.STATUS[_stream_id]['packets'] / call_duration > 25): logger.warning("(%s) *PacketControl* RATE DROP! Stream ID:, %s TGID: %s",self._system,int_id(_stream_id),int_id(_dst_id)) self.proxy_BadPeer() return @@ -1691,23 +1841,23 @@ class routerOBP(OPENBRIDGE): self.STATUS[_stream_id]['loss'] += 1 logger.debug("(%s) *PacketControl* last packet is a complete duplicate of the previous one, disgarding. Stream ID:, %s TGID: %s, LOSS: %.2f%%",self._system,int_id(_stream_id),int_id(_dst_id),((self.STATUS[_stream_id]['loss'] / self.STATUS[_stream_id]['packets']) * 100)) return - #Duplicate SEQ number - if _seq and _seq == self.STATUS[_stream_id]['lastSeq']: + _seq_delta = dmrd_seq_delta(_seq, self.STATUS[_stream_id]['lastSeq']) + if _seq_delta == 0: self.STATUS[_stream_id]['loss'] += 1 logger.debug("(%s) *PacketControl* Duplicate sequence number %s, disgarding. Stream ID:, %s TGID: %s, LOSS: %.2f%%",self._system,_seq,int_id(_stream_id),int_id(_dst_id),((self.STATUS[_stream_id]['loss'] / self.STATUS[_stream_id]['packets']) * 100)) return #Inbound out-of-order packets - if _seq and self.STATUS[_stream_id]['lastSeq'] and (_seq != 1) and (_seq < self.STATUS[_stream_id]['lastSeq']): + if _seq_delta is not None and _seq_delta > 127: self.STATUS[_stream_id]['loss'] += 1 logger.debug("%s) *PacketControl* Out of order packet - last SEQ: %s, this SEQ: %s, disgarding. Stream ID:, %s TGID: %s, LOSS: %.2f%%",self._system,self.STATUS[_stream_id]['lastSeq'],_seq,int_id(_stream_id),int_id(_dst_id),((self.STATUS[_stream_id]['loss'] / self.STATUS[_stream_id]['packets']) * 100)) return #Duplicate DMR payload to previuos packet (by hash - if _seq > 0 and _pkt_crc in self.STATUS[_stream_id]['crcs']: + if _pkt_crc in self.STATUS[_stream_id]['crcs']: self.STATUS[_stream_id]['loss'] += 1 logger.debug("(%s) *PacketControl* DMR packet payload with hash: %s seen before in this stream, disgarding. Stream ID:, %s TGID: %s: SEQ:%s PACKETS: %s, LOSS: %.2f%% ",self._system,_pkt_crc,int_id(_stream_id),int_id(_dst_id),_seq, self.STATUS[_stream_id]['packets'],((self.STATUS[_stream_id]['loss'] / self.STATUS[_stream_id]['packets']) * 100)) return #Inbound missed packets - if _seq and self.STATUS[_stream_id]['lastSeq'] and _seq > (self.STATUS[_stream_id]['lastSeq']+1): + if _seq_delta is not None and _seq_delta > 1: self.STATUS[_stream_id]['loss'] += 1 logger.debug("(%s) *PacketControl* Missed packet(s) - last SEQ: %s, this SEQ: %s. Stream ID:, %s TGID: %s , LOSS: %.2f%%",self._system,self.STATUS[_stream_id]['lastSeq'],_seq,int_id(_stream_id),int_id(_dst_id),((self.STATUS[_stream_id]['loss'] / self.STATUS[_stream_id]['packets']) * 100)) @@ -1748,8 +1898,9 @@ class routerOBP(OPENBRIDGE): loss = (self.STATUS[_stream_id]['loss'] / self.STATUS[_stream_id]['packets']) * 100 logger.info('(%s) *CALL END* STREAM ID: %s SUB: %s (%s) PEER: %s (%s) TGID %s (%s), TS %s, Duration: %.2f, Packet rate: %.2f/s, Loss: %.2f%%', \ self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot, call_duration, packet_rate,loss) - if CONFIG['REPORTS']['REPORT']: - self._report.send_bridgeEvent('GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id), call_duration).encode(encoding='utf-8', errors='ignore')) + if not self.STATUS[_stream_id].get('DATA_STREAM'): + if CONFIG['REPORTS']['REPORT']: + self._report.send_bridgeEvent('GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id), call_duration).encode(encoding='utf-8', errors='ignore')) self.STATUS[_stream_id]['_fin'] = True self.STATUS[_stream_id]['lastSeq'] = False @@ -1778,6 +1929,18 @@ class routerHBP(HBSYSTEM): 'TX_TIME': time(), 'RX_TYPE': HBPF_SLT_VTERM, 'TX_TYPE': HBPF_SLT_VTERM, + 'RX_DATA_STREAM': False, + 'TX_DATA_STREAM': False, + 'RX_GROUP_VOICE_STREAM': False, + 'RX_FINISHED_STREAM_ID': b'\x00', + 'RX_FINISHED_STREAM_LOG': False, + 'TX_PROMPT_ACTIVE': False, + 'TX_PROMPT_CANCEL': False, + 'TX_PROMPT_TOKEN': None, + 'TX_PROMPT_TIME': 0, + 'TX_PROMPT_STREAM_ID': b'\x00', + 'TX_PROMPT_TGID': b'\x00\x00\x00', + 'TX_PROMPT_RFS': b'\x00', 'RX_LC': b'\x00', 'TX_H_LC': b'\x00', 'TX_T_LC': b'\x00', @@ -1809,6 +1972,18 @@ class routerHBP(HBSYSTEM): 'TX_TIME': time(), 'RX_TYPE': HBPF_SLT_VTERM, 'TX_TYPE': HBPF_SLT_VTERM, + 'RX_DATA_STREAM': False, + 'TX_DATA_STREAM': False, + 'RX_GROUP_VOICE_STREAM': False, + 'RX_FINISHED_STREAM_ID': b'\x00', + 'RX_FINISHED_STREAM_LOG': False, + 'TX_PROMPT_ACTIVE': False, + 'TX_PROMPT_CANCEL': False, + 'TX_PROMPT_TOKEN': None, + 'TX_PROMPT_TIME': 0, + 'TX_PROMPT_STREAM_ID': b'\x00', + 'TX_PROMPT_TGID': b'\x00\x00\x00', + 'TX_PROMPT_RFS': b'\x00', 'RX_LC': b'\x00', 'TX_H_LC': b'\x00', 'TX_T_LC': b'\x00', @@ -1828,6 +2003,8 @@ class routerHBP(HBSYSTEM): def to_target(self, _peer_id, _rf_src, _dst_id, _seq, _slot, _call_type, _frame_type, _dtype_vseq, _stream_id, _data, pkt_time, dmrpkt, _bits,_bridge,_system,_noOBP,sysIgnore,_source_server, _ber, _rssi, _source_rptr): _sysIgnore = sysIgnore + _data_control = is_group_data_control(_call_type, _dtype_vseq) + _data_event = group_data_report_name(_call_type, _dtype_vseq) for _target in BRIDGES[_bridge]: #if _target['SYSTEM'] != self._system or (_target['SYSTEM'] == self._system and _target['TS'] != _slot): if _target['SYSTEM'] != self._system and _target['ACTIVE']: @@ -1845,11 +2022,11 @@ class routerHBP(HBSYSTEM): _sysIgnore.append((_target['SYSTEM'],_target['TS'])) #If target has quenched us, don't send - if ('_bcsq' in _target_system) and (_dst_id in _target_system['_bcsq']) and (_target_system['_bcsq'][_target['TGID']] == _stream_id): + if ('_bcsq' in _target_system) and (_target['TGID'] in _target_system['_bcsq']) and (_target_system['_bcsq'][_target['TGID']] == _stream_id): continue #If target has missed 6 (on 1 min) of keepalives, don't send - if _target_system['ENHANCED_OBP'] and '_bcka' in _target_system and _target_system['_bcka'] < pkt_time - 60: + if _target_system['ENHANCED_OBP'] and ('_bcka' not in _target_system or _target_system['_bcka'] < pkt_time - 60): continue #If talkgroup is prohibited by ACL @@ -1870,7 +2047,8 @@ class routerHBP(HBSYSTEM): 'CONTENTION':False, 'RFS': _rf_src, 'TGID': _dst_id, - 'RX_PEER': _peer_id + 'RX_PEER': _peer_id, + 'DATA_STREAM': _data_control } # Generate LCs (full and EMB) for the TX stream dst_lc = b''.join([self.STATUS[_slot]['RX_LC'][0:3], _target['TGID'], _rf_src]) @@ -1880,7 +2058,10 @@ class routerHBP(HBSYSTEM): logger.debug('(%s) Conference Bridge: %s, Call Bridged to OBP System: %s TS: %s, TGID: %s', self._system, _bridge, _target['SYSTEM'], _target['TS'], int_id(_target['TGID'])) if CONFIG['REPORTS']['REPORT']: - systems[_target['SYSTEM']]._report.send_bridgeEvent('GROUP VOICE,START,TX,{},{},{},{},{},{}'.format(_target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID'])).encode(encoding='utf-8', errors='ignore')) + if not _data_control: + systems[_target['SYSTEM']]._report.send_bridgeEvent('GROUP VOICE,START,TX,{},{},{},{},{},{}'.format(_target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID'])).encode(encoding='utf-8', errors='ignore')) + else: + systems[_target['SYSTEM']]._report.send_bridgeEvent('{},DATA,TX,{},{},{},{},{},{}'.format(_data_event, _target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID'])).encode(encoding='utf-8', errors='ignore')) # Record the time of this packet so we can later identify a stale stream _target_status[_stream_id]['LAST'] = pkt_time @@ -1901,11 +2082,13 @@ class routerHBP(HBSYSTEM): # Create a voice terminator packet (FULL LC) elif _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VTERM: dmrbits = _target_status[_stream_id]['T_LC'][0:98] + dmrbits[98:166] + _target_status[_stream_id]['T_LC'][98:197] - if CONFIG['REPORTS']['REPORT']: + if CONFIG['REPORTS']['REPORT'] and not _target_status[_stream_id].get('DATA_STREAM'): call_duration = pkt_time - _target_status[_stream_id]['START'] systems[_target['SYSTEM']]._report.send_bridgeEvent('GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}'.format(_target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID']), call_duration).encode(encoding='utf-8', errors='ignore')) + if not _target_status[_stream_id].get('DATA_STREAM'): + _target_status[_stream_id]['_fin'] = True # Create a Burst B-E packet (Embedded LC) - elif _dtype_vseq in [1,2,3,4]: + elif _frame_type == HBPF_VOICE and _dtype_vseq in [1,2,3,4]: dmrbits = dmrbits[0:116] + _target_status[_stream_id]['EMB_LC'][_dtype_vseq] + dmrbits[148:264] dmrpkt = dmrbits.tobytes() _tmp_data = b''.join([_tmp_data, dmrpkt]) @@ -1939,12 +2122,15 @@ class routerHBP(HBSYSTEM): # Is this a new call stream? if (_stream_id != self.STATUS[_slot]['RX_STREAM_ID']): + if not _data_control: + _cancelGeneratedVoice(_target_status[_target['TS']]) # Record the DST TGID and Stream ID _target_status[_target['TS']]['TX_START'] = pkt_time _target_status[_target['TS']]['TX_TGID'] = _target['TGID'] _target_status[_target['TS']]['TX_STREAM_ID'] = _stream_id _target_status[_target['TS']]['TX_RFS'] = _rf_src _target_status[_target['TS']]['TX_PEER'] = _peer_id + _target_status[_target['TS']]['TX_DATA_STREAM'] = _data_control # Generate LCs (full and EMB) for the TX stream dst_lc = b''.join([self.STATUS[_slot]['RX_LC'][0:3],_target['TGID'],_rf_src]) _target_status[_target['TS']]['TX_H_LC'] = bptc.encode_header_lc(dst_lc) @@ -1953,7 +2139,10 @@ class routerHBP(HBSYSTEM): logger.debug('(%s) Generating TX FULL and EMB LCs for HomeBrew destination: System: %s, TS: %s, TGID: %s', self._system, _target['SYSTEM'], _target['TS'], int_id(_target['TGID'])) logger.debug('(%s) Conference Bridge: %s, Call Bridged to HBP System: %s TS: %s, TGID: %s', self._system, _bridge, _target['SYSTEM'], _target['TS'], int_id(_target['TGID'])) if CONFIG['REPORTS']['REPORT']: - systems[_target['SYSTEM']]._report.send_bridgeEvent('GROUP VOICE,START,TX,{},{},{},{},{},{}'.format(_target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID'])).encode(encoding='utf-8', errors='ignore')) + if not _data_control: + systems[_target['SYSTEM']]._report.send_bridgeEvent('GROUP VOICE,START,TX,{},{},{},{},{},{}'.format(_target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID'])).encode(encoding='utf-8', errors='ignore')) + else: + systems[_target['SYSTEM']]._report.send_bridgeEvent('{},DATA,TX,{},{},{},{},{},{}'.format(_data_event, _target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID'])).encode(encoding='utf-8', errors='ignore')) # Set other values for the contention handler to test next time there is a frame to forward _target_status[_target['TS']]['TX_TIME'] = pkt_time @@ -1979,11 +2168,11 @@ class routerHBP(HBSYSTEM): # Create a voice terminator packet (FULL LC) elif _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VTERM: dmrbits = _target_status[_target['TS']]['TX_T_LC'][0:98] + dmrbits[98:166] + _target_status[_target['TS']]['TX_T_LC'][98:197] - if CONFIG['REPORTS']['REPORT']: + if CONFIG['REPORTS']['REPORT'] and not _target_status[_target['TS']].get('TX_DATA_STREAM'): call_duration = pkt_time - _target_status[_target['TS']]['TX_START'] systems[_target['SYSTEM']]._report.send_bridgeEvent('GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}'.format(_target['SYSTEM'], int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _target['TS'], int_id(_target['TGID']), call_duration).encode(encoding='utf-8', errors='ignore')) # Create a Burst B-E packet (Embedded LC) - elif _dtype_vseq in [1,2,3,4]: + elif _frame_type == HBPF_VOICE and _dtype_vseq in [1,2,3,4]: dmrbits = dmrbits[0:116] + _target_status[_target['TS']]['TX_EMB_LC'][_dtype_vseq] + dmrbits[148:264] try: dmrpkt = dmrbits.tobytes() @@ -2003,9 +2192,9 @@ class routerHBP(HBSYSTEM): _tmp_data = b''.join([_data[:15], _tmp_bits.to_bytes(1, 'big'), _data[16:20]]) _tmp_data = b''.join([_tmp_data, dmrpkt]) systems[_d_system].send_system(_tmp_data,None) - logger.debug('(%s) UNIT Data Bridged to HBP on slot 1: %s DST_ID: %s',self._system,_d_system,_int_dst_id) + logger.debug('(%s) UNIT Data Bridged to HBP on slot %s: %s DST_ID: %s',self._system,_d_slot,_d_system,_int_dst_id) if CONFIG['REPORTS']['REPORT']: - systems[_d_system]._report.send_bridgeEvent('UNIT DATA,DATA,TX,{},{},{},{},{},{}'.format(_d_system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), 1, _int_dst_id).encode(encoding='utf-8', errors='ignore')) + systems[_d_system]._report.send_bridgeEvent('UNIT DATA,DATA,TX,{},{},{},{},{},{}'.format(_d_system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _d_slot, _int_dst_id).encode(encoding='utf-8', errors='ignore')) def sendDataToOBP(self,_target,_data,dmrpkt,pkt_time,_stream_id,_dst_id,_peer_id,_rf_src,_bits,_slot,_hops = b'',_ber = b'\x00', _rssi = b'\x00',_source_server = b'\x00\x00\x00\x00', _source_rptr = b'\x00\x00\x00\x00'): # _sysIgnore = sysIgnore @@ -2019,7 +2208,7 @@ class routerHBP(HBSYSTEM): # _sysIgnore.append((_target,_target['TS'])) #If target has missed 6 (in 1 min) of keepalives, don't send - if _target_system['ENHANCED_OBP'] and '_bcka' in _target_system and _target_system['_bcka'] < pkt_time - 60: + if _target_system['ENHANCED_OBP'] and ('_bcka' not in _target_system or _target_system['_bcka'] < pkt_time - 60): return if (_stream_id not in _target_status): @@ -2047,19 +2236,17 @@ class routerHBP(HBSYSTEM): systems[_target].send_system(_tmp_data,b'',_ber,_rssi,_source_server,_source_rptr) logger.debug('(%s) UNIT Data Bridged to OBP System: %s DST_ID: %s', self._system, _target,_int_dst_id) if CONFIG['REPORTS']['REPORT']: - systems[system]._report.send_bridgeEvent('UNIT DATA,DATA,TX,{},{},{},{},{},{}'.format(_target, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), 1, _int_dst_id).encode(encoding='utf-8', errors='ignore')) + systems[_target]._report.send_bridgeEvent('UNIT DATA,DATA,TX,{},{},{},{},{},{}'.format(_target, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), 1, _int_dst_id).encode(encoding='utf-8', errors='ignore')) def dmrd_received(self, _peer_id, _rf_src, _dst_id, _seq, _slot, _call_type, _frame_type, _dtype_vseq, _stream_id, _data): - try: - if CONFIG['SYSTEMS'][self._system]['_reset'] or CONFIG['SYSTEMS'][_system]['_reloadoptions']: - if not CONFIG['SYSTEMS'][self._system]['_resetlog']: - logger.info('(%s) disallow transmission until reset cycle is complete',_system) - CONFIG['SYSTEMS'][self._system]['_resetlog'] = True + _system_config = CONFIG['SYSTEMS'][self._system] + if _system_config.get('_reset') or _system_config.get('_reloadoptions'): + if not _system_config.get('_resetlog'): + logger.info('(%s) disallow transmission until reset cycle is complete',self._system) + _system_config['_resetlog'] = True return - except KeyError: - pass pkt_time = time() dmrpkt = _data[20:53] @@ -2145,7 +2332,7 @@ class routerHBP(HBSYSTEM): #Send all data to DATA-GATEWAY if enabled and valid if CONFIG['GLOBAL']['DATA_GATEWAY'] and 'DATA-GATEWAY' in CONFIG['SYSTEMS'] and CONFIG['SYSTEMS']['DATA-GATEWAY']['MODE'] == 'OPENBRIDGE' and CONFIG['SYSTEMS']['DATA-GATEWAY']['ENABLED']: logger.debug('(%s) DATA packet sent to DATA-GATEWAY',self._system) - self.sendDataToOBP('DATA-GATEWAY',_data,dmrpkt,pkt_time,_stream_id,_dst_id,_peer_id,_rf_src,_bits,_slot,_source_rptr) + self.sendDataToOBP('DATA-GATEWAY',_data,dmrpkt,pkt_time,_stream_id,_dst_id,_peer_id,_rf_src,_bits,_slot,_ber=_ber,_rssi=_rssi) #Send to all openbridges # sysIgnore = [] @@ -2156,7 +2343,7 @@ class routerHBP(HBSYSTEM): continue #We only want to send data calls to individual IDs via FreeBridge (not OpenBridge) if CONFIG['SYSTEMS'][system]['MODE'] == 'OPENBRIDGE' and CONFIG['SYSTEMS'][system]['VER'] > 1 and (_int_dst_id >= 1000000): - self.sendDataToOBP(system,_data,dmrpkt,pkt_time,_stream_id,_dst_id,_peer_id,_rf_src,_bits,_slot,_source_rptr) + self.sendDataToOBP(system,_data,dmrpkt,pkt_time,_stream_id,_dst_id,_peer_id,_rf_src,_bits,_slot,_ber=_ber,_rssi=_rssi) #If destination ID is in the Subscriber Map if _dst_id in SUB_MAP: @@ -2242,6 +2429,7 @@ class routerHBP(HBSYSTEM): self.STATUS[_slot]['RX_TGID'] = _dst_id self.STATUS[_slot]['RX_TIME'] = pkt_time self.STATUS[_slot]['RX_STREAM_ID'] = _stream_id + self.STATUS[_slot]['RX_GROUP_VOICE_STREAM'] = False self.STATUS[_slot]['VOICE_STREAM'] = _voice_call self.STATUS[_slot]['packets'] = self.STATUS[_slot]['packets'] +1 @@ -2250,6 +2438,19 @@ class routerHBP(HBSYSTEM): #Handle private voice calls (for dial-a-tg) elif _call_type == 'unit' and not _data_call and not self.STATUS[_slot]['_allStarMode']: + # Dial-A-TG private calls on either RF slot control the TS2 reflector state. + _dial_tg_slot = 2 + _dial_tg_max = DIAL_A_TG_MAX + _dial_tg_in_range = _int_dst_id >= 5 and _int_dst_id <= _dial_tg_max + _dial_tg_out_of_range = _int_dst_id > _dial_tg_max + _dial_tg_can_link = ( + _int_dst_id > 7 + and _int_dst_id <= _dial_tg_max + and not (_int_dst_id >= 4000 and _int_dst_id <= 5000) + and not (_int_dst_id >=9991 and _int_dst_id <= 9999) + ) + _dial_tg_disconnect = _int_dst_id == 4000 + _dial_tg_reserved_control = _int_dst_id > 4000 and _int_dst_id < 5000 if (_stream_id != self.STATUS[_slot]['RX_STREAM_ID']): self.STATUS[_slot]['packets'] = 0 @@ -2258,26 +2459,29 @@ class routerHBP(HBSYSTEM): self.STATUS[_slot]['_stopTgAnnounce'] = False logger.info('(%s) Dial-A-TG: Private call from %s to %s',self._system, int_id(_rf_src), _int_dst_id) - if _int_dst_id >= 5 and _int_dst_id != 8 and _int_dst_id != 9 and _int_dst_id <= 999999: + if _dial_tg_in_range and _int_dst_id != 8 and _int_dst_id != 9: _bridgename = ''.join(['#',str(_int_dst_id)]) - if _bridgename not in BRIDGES and not (_int_dst_id >= 4000 and _int_dst_id <= 5000) and not (_int_dst_id >=9991 and _int_dst_id <= 9999): + if _dial_tg_can_link and _bridgename not in BRIDGES: logger.info('(%s) [A] Dial-A-TG for TG %s does not exist. Creating as User Activated. Timeout: %s',self._system, _int_dst_id,CONFIG['SYSTEMS'][self._system]['DEFAULT_UA_TIMER']) make_single_reflector(_dst_id,CONFIG['SYSTEMS'][self._system]['DEFAULT_UA_TIMER'],self._system) - if _int_dst_id > 5 and _int_dst_id != 9 and _int_dst_id != 5000 and not (_int_dst_id >=9991 and _int_dst_id <= 9999): + if _dial_tg_can_link or _dial_tg_disconnect: for _bridge in BRIDGES: if _bridge[0:1] != '#': continue for _system in BRIDGES[_bridge]: _dehash_bridge = _bridge[1:] - if _system['SYSTEM'] == self._system: - # TGID matches a rule source, reset its timer - if _slot == _system['TS'] and _dst_id == _system['TGID'] and ((_system['TO_TYPE'] == 'ON' and (_system['ACTIVE'] == True)) or (_system['TO_TYPE'] == 'OFF' and _system['ACTIVE'] == False)): - _system['TIMER'] = pkt_time + _system['TIMEOUT'] - logger.info('(%s) [B] Transmission match for Dial-A-TG: %s. Reset timeout to %s', self._system, _bridge, _system['TIMER']) + _dial_tg_entry_for_this_system = ( + _system['SYSTEM'] == self._system + and _dial_tg_slot == _system['TS'] + ) + # TGID matches a rule source, reset its timer + if _dial_tg_entry_for_this_system and _dst_id == _system['TGID'] and ((_system['TO_TYPE'] == 'ON' and (_system['ACTIVE'] == True)) or (_system['TO_TYPE'] == 'OFF' and _system['ACTIVE'] == False)): + _system['TIMER'] = pkt_time + _system['TIMEOUT'] + logger.info('(%s) [B] Transmission match for Dial-A-TG: %s. Reset timeout to %s', self._system, _bridge, _system['TIMER']) # TGID matches an ACTIVATION trigger - if _int_dst_id == int(_dehash_bridge) and _system['SYSTEM'] == self._system and _slot == _system['TS']: + if _dial_tg_entry_for_this_system and _int_dst_id == int(_dehash_bridge): # Set the matching rule as ACTIVE if _system['ACTIVE'] == False: _system['ACTIVE'] = True @@ -2288,13 +2492,13 @@ class routerHBP(HBSYSTEM): _system['TIMER'] = pkt_time logger.info('(%s) [D] Dial-A-TG: %s has an "OFF" timer and set to "ON": timeout timer cancelled', self._system, _bridge) # Reset the timer for the rule - if _system['ACTIVE'] == True and _system['TO_TYPE'] == 'ON': + if _dial_tg_entry_for_this_system and _system['ACTIVE'] == True and _system['TO_TYPE'] == 'ON': _system['TIMER'] = pkt_time + _system['TIMEOUT'] logger.info('(%s) [E] Dial-A-TG: %s, timeout timer reset to: %s', self._system, _bridge, _system['TIMER'] - pkt_time) # TGID matches an DE-ACTIVATION trigger #Single TG mode - if (_dst_id in _system['OFF'] or _dst_id in _system['RESET'] or (_int_dst_id != int(_dehash_bridge)) and _system['SYSTEM'] == self._system and _slot == _system['TS']): + if _dial_tg_entry_for_this_system and (_dst_id in _system['OFF'] or _dst_id in _system['RESET'] or _int_dst_id != int(_dehash_bridge)): # Set the matching rule as ACTIVE #Single TG mode if _dst_id in _system['OFF'] or _int_dst_id != int(_dehash_bridge) : @@ -2351,11 +2555,13 @@ class routerHBP(HBSYSTEM): elif _int_dst_id == 5000: _active = False for _bridge in BRIDGES: + if _active: + break if _bridge[0:1] != '#': continue for _system in BRIDGES[_bridge]: _dehash_bridge = _bridge[1:] - if _system['SYSTEM'] == self._system and _slot == _system['TS']: + if _system['SYSTEM'] == self._system and _dial_tg_slot == _system['TS']: if _system['ACTIVE'] == True: logger.info('(%s) Dial-A-TG: voice called - 5000 status - "linked to %s"', self._system,_dehash_bridge) _say.append(words[_lang]['silence']) @@ -2375,6 +2581,18 @@ class routerHBP(HBSYSTEM): logger.info('(%s) Dial-A-TG: voice called - 5000 status - "not linked"', self._system) _say.append(words[_lang]['notlinked']) + elif _dial_tg_reserved_control: + logger.info('(%s) Dial-A-TG: voice called - reserved control TG %s - "busy"', self._system, _int_dst_id) + _say.append(words[_lang]['busy']) + _say.append(words[_lang]['silence']) + self.STATUS[_slot]['_stopTgAnnounce'] = True + + elif _dial_tg_out_of_range: + logger.info('(%s) Dial-A-TG: voice called - TG %s out of range - "busy"', self._system, _int_dst_id) + _say.append(words[_lang]['busy']) + _say.append(words[_lang]['silence']) + self.STATUS[_slot]['_stopTgAnnounce'] = True + #Information services elif _int_dst_id >= 9991 and _int_dst_id <= 9999: self.STATUS[_slot]['_stopTgAnnounce'] = True @@ -2408,19 +2626,40 @@ class routerHBP(HBSYSTEM): self.STATUS[_slot]['RX_TGID'] = _dst_id self.STATUS[_slot]['RX_TIME'] = pkt_time self.STATUS[_slot]['RX_STREAM_ID'] = _stream_id + self.STATUS[_slot]['RX_GROUP_VOICE_STREAM'] = False self.STATUS[_slot]['VOICE_STREAM'] = _voice_call self.STATUS[_slot]['packets'] = self.STATUS[_slot]['packets'] +1 #Handle group calls if _call_type == 'group' or _call_type == 'vcsbk': + _data_control = is_group_data_control(_call_type, _dtype_vseq) + _data_event = group_data_report_name(_call_type, _dtype_vseq) + if ( + not _data_control + and self.STATUS[_slot].get('RX_FINISHED_STREAM_ID') == _stream_id + ): + if not self.STATUS[_slot].get('RX_FINISHED_STREAM_LOG'): + logger.debug( + "(%s) HBP *LoopControl* STREAM ID: %s ALREADY FINISHED FROM THIS SOURCE, IGNORING", + self._system, + int_id(_stream_id), + ) + self.STATUS[_slot]['RX_FINISHED_STREAM_LOG'] = True + return # Is this a new call stream? if (_stream_id != self.STATUS[_slot]['RX_STREAM_ID']): + if not _data_control: + _cancelGeneratedVoice(self.STATUS[_slot]) + self.STATUS[_slot]['RX_FINISHED_STREAM_ID'] = b'\x00' + self.STATUS[_slot]['RX_FINISHED_STREAM_LOG'] = False self.STATUS[_slot]['packets'] = 0 self.STATUS[_slot]['loss'] = 0 self.STATUS[_slot]['crcs'] = set() + self.STATUS[_slot]['lastSeq'] = False + self.STATUS[_slot]['lastData'] = False if (self.STATUS[_slot]['RX_TYPE'] != HBPF_SLT_VTERM) and (pkt_time < (self.STATUS[_slot]['RX_TIME'] + STREAM_TO)) and (_rf_src != self.STATUS[_slot]['RX_RFS']): logger.warning('(%s) Packet received with STREAM ID: %s SUB: %s PEER: %s TGID %s, SLOT %s collided with existing call', self._system, int_id(_stream_id), int_id(_rf_src), int_id(_peer_id), int_id(_dst_id), _slot) @@ -2428,24 +2667,24 @@ class routerHBP(HBSYSTEM): # This is a new call stream self.STATUS[_slot]['RX_START'] = pkt_time + self.STATUS[_slot]['RX_DATA_STREAM'] = _data_control + self.STATUS[_slot]['RX_GROUP_VOICE_STREAM'] = not _data_control - if _call_type == 'group' : - if _dtype_vseq == 6: - logger.info('(%s) *DATA HEADER* STREAM ID: %s SUB: %s (%s) PEER: %s (%s) TGID %s (%s), TS %s', \ - self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot) - if CONFIG['REPORTS']['REPORT']: - self._report.send_bridgeEvent('DATA HEADER,DATA,RX,{},{},{},{},{},{}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id)).encode(encoding='utf-8', errors='ignore')) - - else: - logger.info('(%s) *CALL START* STREAM ID: %s SUB: %s (%s) PEER: %s (%s) TGID %s (%s), TS %s', \ - self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot) - if CONFIG['REPORTS']['REPORT']: - self._report.send_bridgeEvent('GROUP VOICE,START,RX,{},{},{},{},{},{}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id)).encode(encoding='utf-8', errors='ignore')) - else: + if _call_type == 'vcsbk' and _data_event == 'OTHER DATA': logger.info('(%s) *VCSBK* STREAM ID: %s SUB: %s (%s) PEER: %s (%s) TGID %s (%s), TS %s _dtype_vseq: %s', self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot, _dtype_vseq) if CONFIG['REPORTS']['REPORT']: self._report.send_bridgeEvent('OTHER DATA,DATA,RX,{},{},{},{},{},{}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id)).encode(encoding='utf-8', errors='ignore')) + elif _data_control: + logger.info('(%s) *%s* STREAM ID: %s SUB: %s (%s) PEER: %s (%s) TGID %s (%s), TS %s', \ + self._system, _data_event, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot) + if CONFIG['REPORTS']['REPORT']: + self._report.send_bridgeEvent('{},DATA,RX,{},{},{},{},{},{}'.format(_data_event, self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id)).encode(encoding='utf-8', errors='ignore')) + else: + logger.info('(%s) *CALL START* STREAM ID: %s SUB: %s (%s) PEER: %s (%s) TGID %s (%s), TS %s', \ + self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot) + if CONFIG['REPORTS']['REPORT']: + self._report.send_bridgeEvent('GROUP VOICE,START,RX,{},{},{},{},{},{}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id)).encode(encoding='utf-8', errors='ignore')) # If we can, use the LC from the voice header as to keep all options intact if _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VHEAD: @@ -2464,21 +2703,10 @@ class routerHBP(HBSYSTEM): self.STATUS[_slot]['packets'] = self.STATUS[_slot]['packets'] +1 - if _call_type == 'vcsbk': - if _dtype_vseq == 7: - logger.info('(%s) *VCSBK 1/2 DATA BLOCK * STREAM ID: %s SUB: %s (%s) PEER: %s (%s) TGID %s (%s), TS %s', \ - self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot) - if CONFIG['REPORTS']['REPORT']: - self._report.send_bridgeEvent('VCSBK 1/2 DATA BLOCK,DATA,RX,{},{},{},{},{},{}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id)).encode(encoding='utf-8', errors='ignore')) - elif _dtype_vseq == 8: - logger.info('(%s) *VCSBK 3/4 DATA BLOCK * STREAM ID: %s SUB: %s (%s) PEER: %s (%s) TGID %s (%s), TS %s', \ - self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot) - if CONFIG['REPORTS']['REPORT']: - self._report.send_bridgeEvent('VCSBK 3/4 DATA BLOCK,DATA,RX,{},{},{},{},{},{}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id)).encode(encoding='utf-8', errors='ignore')) - #Packet rate limit #Rate drop - if self.STATUS[_slot]['packets'] > 18 and (self.STATUS[_slot]['packets'] / (pkt_time - self.STATUS[_slot]['RX_START']) > 25): + call_duration = pkt_time - self.STATUS[_slot]['RX_START'] + if call_duration and self.STATUS[_slot]['packets'] > 18 and (self.STATUS[_slot]['packets'] / call_duration > 25): logger.warning("(%s) *PacketControl* RATE DROP! Stream ID:, %s TGID: %s",self._system,int_id(_stream_id),int_id(_dst_id)) self.STATUS[_slot]['LAST'] = pkt_time return @@ -2521,23 +2749,23 @@ class routerHBP(HBSYSTEM): self.STATUS[_slot]['loss'] += 1 logger.info("(%s) *PacketControl* last packet is a complete duplicate of the previous one, disgarding. Stream ID:, %s TGID: %s",self._system,int_id(_stream_id),int_id(_dst_id)) return - #Handle inbound duplicates - if _seq and _seq == self.STATUS[_slot]['lastSeq']: + _seq_delta = dmrd_seq_delta(_seq, self.STATUS[_slot]['lastSeq']) + if _seq_delta == 0: self.STATUS[_slot]['loss'] += 1 logger.debug("(%s) *PacketControl* Duplicate sequence number %s, disgarding. Stream ID:, %s TGID: %s",self._system,_seq,int_id(_stream_id),int_id(_dst_id)) return #Inbound out-of-order packets - if _seq and self.STATUS[_slot]['lastSeq'] and (_seq != 1) and (_seq < self.STATUS[_slot]['lastSeq']): + if _seq_delta is not None and _seq_delta > 127: self.STATUS[_slot]['loss'] += 1 logger.debug("%s) *PacketControl* Out of order packet - last SEQ: %s, this SEQ: %s, disgarding. Stream ID:, %s TGID: %s ",self._system,self.STATUS[_slot]['lastSeq'],_seq,int_id(_stream_id),int_id(_dst_id)) return #Duplicate DMR payload to previuos packet (by hash) - if _seq > 0 and _pkt_crc in self.STATUS[_slot]['crcs']: + if _pkt_crc in self.STATUS[_slot]['crcs']: self.STATUS[_slot]['loss'] += 1 logger.debug("(%s) *PacketControl* DMR packet payload with hash: %s seen before in this stream, disgarding. Stream ID:, %s TGID: %s, SEQ: %s, packets %s: ",self._system,_pkt_crc,int_id(_stream_id),int_id(_dst_id),_seq,self.STATUS[_slot]['packets']) return #Inbound missed packets - if _seq and self.STATUS[_slot]['lastSeq'] and _seq > (self.STATUS[_slot]['lastSeq']+1): + if _seq_delta is not None and _seq_delta > 1: self.STATUS[_slot]['loss'] += 1 logger.debug("(%s) *PacketControl* Missed packet(s) - last SEQ: %s, this SEQ: %s. Stream ID:, %s TGID: %s ",self._system,self.STATUS[_slot]['lastSeq'],_seq,int_id(_stream_id),int_id(_dst_id)) @@ -2563,7 +2791,13 @@ class routerHBP(HBSYSTEM): _sysIgnore = self.to_target(_peer_id, _rf_src, _dst_id, _seq, _slot, _call_type, _frame_type, _dtype_vseq, _stream_id, _data, pkt_time, dmrpkt, _bits,_bridge,_system,False,_sysIgnore,_source_server,_ber,_rssi,_source_rptr) # Final actions - Is this a voice terminator? - if (_frame_type == HBPF_DATA_SYNC) and (_dtype_vseq == HBPF_SLT_VTERM) and (self.STATUS[_slot]['RX_TYPE'] != HBPF_SLT_VTERM): + _current_voice_terminator = ( + _frame_type == HBPF_DATA_SYNC + and _dtype_vseq == HBPF_SLT_VTERM + and not self.STATUS[_slot].get('RX_DATA_STREAM') + and self.STATUS[_slot].get('RX_FINISHED_STREAM_ID') != _stream_id + ) + if _current_voice_terminator: packet_rate = 0 loss = 0.00 call_duration = pkt_time - self.STATUS[_slot]['RX_START'] @@ -2574,6 +2808,8 @@ class routerHBP(HBSYSTEM): self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot, call_duration, packet_rate, loss) if CONFIG['REPORTS']['REPORT']: self._report.send_bridgeEvent('GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id), call_duration).encode(encoding='utf-8', errors='ignore')) + self.STATUS[_slot]['RX_FINISHED_STREAM_ID'] = _stream_id + self.STATUS[_slot]['RX_FINISHED_STREAM_LOG'] = False #Reset back to False self.STATUS[_slot]['lastSeq'] = False @@ -2670,6 +2906,8 @@ class routerHBP(HBSYSTEM): self.STATUS[_slot]['RX_TGID'] = _dst_id self.STATUS[_slot]['RX_TIME'] = pkt_time self.STATUS[_slot]['RX_STREAM_ID'] = _stream_id + self.STATUS[_slot]['RX_DATA_STREAM'] = _data_control + self.STATUS[_slot]['RX_GROUP_VOICE_STREAM'] = not _data_control self.STATUS[_slot]['crcs'].add(_pkt_crc) @@ -2758,7 +2996,7 @@ if __name__ == '__main__': if CONFIG['ALLSTAR']['ENABLED']: - logger.info('(AMI) Setting up AMI: Server: %s, Port: %s, User: %s, Pass: %s, Node: %s',CONFIG['ALLSTAR']['SERVER'],CONFIG['ALLSTAR']['PORT'],CONFIG['ALLSTAR']['USER'],CONFIG['ALLSTAR']['PASS'],CONFIG['ALLSTAR']['NODE']) + logger.info('(AMI) Setting up AMI: Server: %s, Port: %s, User: %s, Pass: , Node: %s',CONFIG['ALLSTAR']['SERVER'],CONFIG['ALLSTAR']['PORT'],CONFIG['ALLSTAR']['USER'],CONFIG['ALLSTAR']['NODE']) AMIOBJ = AMI(CONFIG['ALLSTAR']['SERVER'],CONFIG['ALLSTAR']['PORT'],CONFIG['ALLSTAR']['USER'],CONFIG['ALLSTAR']['PASS'],CONFIG['ALLSTAR']['NODE']) @@ -2777,7 +3015,7 @@ if __name__ == '__main__': #Server kill routine def kill_server(): try: - if CONFIG['GLOBAL']['_KILL_SERVER']: + if CONFIG['GLOBAL'].get('_KILL_SERVER'): logger.info('(GLOBAL) SHUTDOWN: CONFBRIDGE IS TERMINATING - killserver called') if reactor.running: reactor.stop() @@ -2885,43 +3123,11 @@ if __name__ == '__main__': del generator del systemdelete - prohibitedTGs = [0,1,2,3,4,5,9,9990,9991,9992,9993,9994,9995,9996,9997,9998,9999] - # Default reflector - logger.debug('(ROUTER) Setting default dial-a-tgs') - for system in CONFIG['SYSTEMS']: - if CONFIG['SYSTEMS'][system]['MODE'] != 'MASTER': - continue - if CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR'] not in prohibitedTGs: - make_default_reflector(CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR'],CONFIG['SYSTEMS'][system]['DEFAULT_UA_TIMER'],system) - + make_default_reflectors() + #static TGs - logger.debug('(ROUTER) setting static TGs') - for system in CONFIG['SYSTEMS']: - if CONFIG['SYSTEMS'][system]['MODE'] != 'MASTER': - continue - _tmout = CONFIG['SYSTEMS'][system]['DEFAULT_UA_TIMER'] - ts1 = [] - ts2 = [] - if CONFIG['SYSTEMS'][system]['TS1_STATIC']: - ts1 = CONFIG['SYSTEMS'][system]['TS1_STATIC'].split(',') - if CONFIG['SYSTEMS'][system]['TS2_STATIC']: - ts2 = CONFIG['SYSTEMS'][system]['TS2_STATIC'].split(',') - - for tg in ts1: - if not tg: - continue - if tg in prohibitedTGs: - continue - tg = int(tg) - make_static_tg(tg,1,_tmout,system) - for tg in ts2: - if not tg: - continue - if tg in prohibitedTGs: - continue - tg = int(tg) - make_static_tg(tg,2,_tmout,system) + make_static_tgs() # INITIALIZE THE REPORTING LOOP if CONFIG['REPORTS']['REPORT']: @@ -3013,7 +3219,7 @@ if __name__ == '__main__': identa.addErrback(loopingErrHandle) #Alias reloader - alias_time = CONFIG['ALIASES']['STALE_TIME'] * 86400 + alias_time = CONFIG['ALIASES']['STALE_TIME'] aliasa_task = task.LoopingCall(threadAlias) aliasa = aliasa_task.start(alias_time) aliasa.addErrback(loopingErrHandle) diff --git a/config.py b/config.py index b90081f..892b277 100755 --- a/config.py +++ b/config.py @@ -137,7 +137,7 @@ def build_config(_config_file): 'PATH': config.get(section, 'PATH',fallback='./'), 'PING_TIME': config.getint(section, 'PING_TIME', fallback=10), 'MAX_MISSED': config.getint(section, 'MAX_MISSED', fallback=3), - 'USE_ACL': config.get(section, 'USE_ACL', fallback=True), + 'USE_ACL': config.getboolean(section, 'USE_ACL', fallback=True), 'REG_ACL': config.get(section, 'REG_ACL', fallback='PERMIT:ALL'), 'SUB_ACL': config.get(section, 'SUB_ACL', fallback='DENY:1'), 'TG1_ACL': config.get(section, 'TGID_TS1_ACL', fallback='PERMIT:ALL'), @@ -149,7 +149,8 @@ def build_config(_config_file): 'DATA_GATEWAY': config.getboolean(section, 'DATA_GATEWAY', fallback=False), 'VALIDATE_SERVER_IDS': config.getboolean(section, 'VALIDATE_SERVER_IDS', fallback=True), 'DEBUG_BRIDGES' : config.getboolean(section, 'DEBUG_BRIDGES', fallback=False), - 'ENABLE_API' : config.getboolean(section, 'ENABLE_API', fallback=False) + 'ENABLE_API' : config.getboolean(section, 'ENABLE_API', fallback=False), + '_KILL_SERVER': False }) diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..0bfeedd --- /dev/null +++ b/docs/api.md @@ -0,0 +1,113 @@ +# FreeDMR API + +FreeDMR includes an experimental HTTP/JSON API for small live control-plane +actions. It is intended for local administration and automation, not for public +internet exposure. + +Enable it with: + +```ini +[GLOBAL] +ENABLE_API: True +``` + +When enabled, the API listens on TCP port `8000`. + +## Safety Notes + +FreeDMR is a live voice routing process. API requests are deliberately limited +to small in-memory operations so they do not delay DMR voice packet handling. +Request bodies larger than 8192 bytes are rejected. + +Bind or firewall port `8000` appropriately. Do not expose it publicly without a +trusted reverse proxy and access controls. + +## Authentication + +User-level endpoints require: + +- `dmrid`: the connected HBP peer/repeater DMR ID +- `key`: the session options key for that peer + +System-level endpoints require: + +- `systemkey`: the FreeDMR system API key + +## Endpoints + +### Health + +```bash +curl http://127.0.0.1:8000/api/v1/health +``` + +### Version + +```bash +curl http://127.0.0.1:8000/api/v1/version +``` + +### Get Options + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/options/get \ + -H 'content-type: application/json' \ + -d '{"dmrid":1234567,"key":"secret"}' +``` + +If no live options are present, the response is: + +```json +{"ok":true,"connected":true,"has_options":false,"options":""} +``` + +### Set Options + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/options/set \ + -H 'content-type: application/json' \ + -d '{"dmrid":1234567,"key":"secret","options":"KEY=secret;TS1=91;DIAL=2350"}' +``` + +The `options` value must be the complete FreeDMR `OPTIONS` string. The API does +not add or preserve `KEY=...` automatically. + +### Reset Peer Session + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/reset \ + -H 'content-type: application/json' \ + -d '{"dmrid":1234567,"key":"secret"}' +``` + +FreeDMR expects one HBP peer per master instance, so this resets the master +instance that owns the authenticated peer session. + +### Reset All Connections + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/system/resetall \ + -H 'content-type: application/json' \ + -d '{"systemkey":"system-secret"}' +``` + +### Stop FreeDMR + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/system/kill \ + -H 'content-type: application/json' \ + -d '{"systemkey":"system-secret"}' +``` + +## Responses + +Successful responses include `"ok": true`. Failed responses include +`"ok": false` and an `error` string. + +Common errors: + +- `invalid_credentials` +- `invalid_json` +- `missing_options` +- `request_too_large` +- `not_found` diff --git a/docs/codex-notes.md b/docs/codex-notes.md new file mode 100644 index 0000000..8310503 --- /dev/null +++ b/docs/codex-notes.md @@ -0,0 +1,4280 @@ +# Codex Notes + +## Current Analysis: Options Static TG Validation + +### Findings + +- `bridge_master.py` attempts to validate `TS1_STATIC` and `TS2_STATIC` + options before parsing them, but the current validation is ineffective. +- The whitespace removal calls use `re.sub(...)` without assigning the result, + so whitespace remains in the option value. +- The pattern `"![\d\,]"` matches a literal exclamation mark followed by a + digit or comma. It does not mean "any character except digit or comma". +- Invalid values such as `TS1=91,A92` can reach `int(tg)` and raise + `ValueError`. +- That exception is caught by the broad `except Exception` around the whole + options block, so the options update can abort partway through. + +### Assumptions + +- Static TG option strings are intended to contain only decimal TG numbers + separated by commas. +- Simple whitespace in static TG option strings is acceptable and should be + normalized away, even though it is not strictly correct option syntax. +- Malformed static TG tokens should be rejected cleanly without creating new + bridge state for that token. +- Valid fields in the same options string should still be applied when another + field is invalid, because there is no good feedback mechanism for rejected + options. +- TS1 and TS2 static TG options should be treated independently. +- The existing broad exception handler is not intended as the normal validation + path for malformed user options. + +### Unresolved Questions + +- Should an invalid new options value leave the previous `CONFIG['SYSTEMS'][...] + ['TS1_STATIC']` / `['TS2_STATIC']` value unchanged? +- Resolved: in a mixed list like `91,A92`, valid token `91` should still be + applied while invalid token `A92` is skipped. + +### Protocol-Sensitive Areas + +- Static TG configuration affects bridge membership and therefore routing + decisions for HBP packets. +- TS1 and TS2 static TG handling must remain symmetrical unless there is an + intentional FreeDMR-specific reason for divergence. +- Static TG validation must not change packet bytes. It only controls whether + bridge state is created or reset. +- Control/local targets such as `6`, `7`, `8`, `9`, `9990`, and `9991..9999` + should not accidentally become static routes. + +### Inferred Invariants + +- Static TG option parsing should normalize transport/config text before + mutating bridge state. +- A malformed static TG option should not prevent other valid option fields from + applying. +- Static TGs must pass the shared static TG validator before either + `make_static_tg()` or `reset_static_tg()` is called. +- Config and runtime option paths should use the same validation rules for TS1 + and TS2. +- TS1 and TS2 static option lists should be parsed independently so a malformed + TS1 token does not block valid TS2 changes, and vice versa. + +### Resolution + +- Confirmed intended behavior: static TG options are parsed at token level. +- Simple whitespace is normalized away. +- Invalid, prohibited or out-of-range tokens are skipped. +- Valid tokens in the same TS1 or TS2 list still apply. +- TS1 and TS2 are independent; invalid tokens on one slot do not block valid + tokens on the other slot. + +## Current Analysis: Invalid Default Reflector Persisted After Rejection + +### Findings + +- `options_config()` rejects invalid non-zero `DEFAULT_REFLECTOR` / `DIAL` + values for bridge creation when `valid_dial_a_tg_reflector()` returns false. +- After logging that the default dial-a-TG is prohibited, the function still + writes the raw option value into + `CONFIG['SYSTEMS'][_system]['DEFAULT_REFLECTOR']`. +- This means an invalid value such as `DIAL=1000000` can be "ignored" for bridge + state but retained in system configuration. +- Other code, such as disconnected voice announcement logic, reads + `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']` directly and may treat any + positive value as linked/default reflector state. + +### Assumptions + +- If a default reflector option is rejected, no default reflector should remain + set for that master during the current session. +- Rejected options should not leave `CONFIG` saying a reflector is configured + when no corresponding reflector bridge was created. +- If an invalid default reflector is supplied while an old valid default + reflector is active, the old default should be disabled rather than preserved. +- The runtime config should reflect the effective routing state, not merely the + last raw option value seen. + +### Unresolved Questions + +- Should this behavior be visible to clients via an error/status path, or is the + existing log message sufficient? +- Should rejected `DEFAULT_REFLECTOR` values preserve the old timer/static TG + changes from the same options string, or should the entire options update be + rejected atomically? + +### Protocol-Sensitive Areas + +- Default reflector state is dial-a-TG reflector state on TS2 and affects where + a repeater appears linked after startup or options reload. +- Voice announcements use TG9 TS2 prompts and may report misleading reflector + state if configuration and bridge state diverge. +- A rejected default reflector must not create or retune reflector bridge state. +- The FreeDMR dial-a-TG policy cap remains `999999`; higher values are not valid + link targets. + +### Inferred Invariants + +- A rejected default reflector must be stored as effective + `DEFAULT_REFLECTOR = 0`. +- `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']` should match the active or + intended default reflector policy state used by bridge creation. +- Default reflector validation should be applied before both bridge mutation and + config mutation. +- RF dial-a-TG and configured default reflector paths should use the same + policy bounds. + +### Resolution + +- Confirmed intended behavior: an invalid default reflector option means no + default reflector should be set for the current session. +- Production handling should reset TS2 TG9 reflector state for the affected + master and persist effective `DEFAULT_REFLECTOR = 0`. +- Tests should assert both absence of the invalid reflector bridge and + deactivation of any previously active default reflector. + +## Current Analysis: Invalid Startup Default Reflector Remains In Config + +### Findings + +- `make_default_reflectors()` uses `valid_dial_a_tg_reflector()` before creating + a startup default reflector bridge. +- When `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']` is invalid, prohibited, + or above the FreeDMR dial-a-TG policy cap, no bridge is created. +- Invalid startup default reflectors should be logged because otherwise the + config-file value is silently ignored. +- The invalid configured value remains in + `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']`. +- Other code paths, including disconnected voice announcement logic, read + `DEFAULT_REFLECTOR` directly and treat positive values as configured/default + reflector state. + +### Assumptions + +- Startup configuration should follow the same effective-state rule as options + reload: invalid default reflector means no default reflector for the current + session. +- If startup rejects a default reflector, the effective runtime + `DEFAULT_REFLECTOR` should become `0`. +- The server should not announce or expose an invalid positive default reflector + value after rejecting it for bridge creation. + +### Unresolved Questions + +- Should invalid startup `DEFAULT_REFLECTOR` values be rewritten only in runtime + `CONFIG`, or should any persisted/generated config output also normalize them? +- Should startup log invalid default reflectors at debug level, warning level, or + leave existing logging style unchanged? + +### Protocol-Sensitive Areas + +- Default reflectors connect TS2 TG9 reflector state at startup. +- System-wide defaults are intended for sparing use and ideally should not be + used; they remain available for scenarios that require them. +- Client-requested settings should be treated as preferential over system-wide + defaults. +- A positive `DEFAULT_REFLECTOR` value can affect voice announcements and API or + reporting output even when bridge state was not created. +- Invalid startup defaults must not create, activate, or retune reflector bridge + state. + +### Resolution + +- Invalid positive startup default reflectors are logged at warning level and + still do not create reflector bridge state. +- Confirmed intended behavior: invalid startup default reflectors normalize the + in-memory effective `DEFAULT_REFLECTOR` to `0` for the current session. This + does not write back to the config file. + +### Inferred Invariants + +- Runtime `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']` should describe the + effective default reflector state for the session. +- Startup and live options should apply the same dial-a-TG default reflector + policy. +- Rejecting an invalid startup default reflector should leave no TS2 TG9 default + reflector active for that system. + +## Current Analysis: Options Numeric Fields Converted Before Validation + +### Findings + +- In `options_config()`, some numeric option fields are converted with `int()` + before the later validation block that checks `.isdigit()`. +- `OVERRIDE_IDENT_TG` is converted and assigned before the code reaches the + validation that logs "`OVERRIDE_IDENT_TG is not an integer`". +- `VOICE` and `SINGLE` are also converted before any field-specific numeric + validation. +- A malformed option such as `IDENTTG=A` or `VOICE=A` can raise `ValueError` + inside the broad per-system `except Exception` block. +- That broad exception can abort processing of otherwise valid fields in the + same options string, which conflicts with the confirmed tolerant-options + approach used for static TG tokens. + +### Assumptions + +- Malformed optional numeric fields should not abort processing of otherwise + valid fields in the same options string. +- Invalid numeric option fields should be ignored individually and logged. +- Valid fields such as `DIAL`, `TIMER`, `TS1`, or `TS2` should still apply when + an unrelated optional numeric field is malformed. +- The broad exception handler should remain a last-resort guard, not the normal + validation path. + +### Unresolved Questions + +- Should invalid `VOICE`, `SINGLE`, and `OVERRIDE_IDENT_TG` each preserve their + previous effective value? +- Should invalid values for these fields be logged at debug level, matching + existing option validation, or warning level? +- Should boolean-like fields accept only `0` and `1`, or continue accepting any + integer where Python truthiness determines the effective boolean? + +### Protocol-Sensitive Areas + +- `OVERRIDE_IDENT_TG` controls where voice ident packets are sent. +- Voice ident traffic uses generated DMR packets and should not be redirected to + an invalid TG because of malformed options. +- Options parsing should not partially abort before dial-a-TG/default/static + routing changes are considered. + +### Inferred Invariants + +- Options fields should be normalized and validated before mutating runtime + config. +- Invalid independent option fields should not block valid independent option + fields. +- Numeric option parsing should avoid `ValueError` as normal control flow. + +### Resolution + +- Client session options now validate numeric fields before mutating runtime + config. +- Invalid `VOICE`, `SINGLE`, and `OVERRIDE_IDENT_TG` values are ignored + independently and do not block valid fields in the same options string. +- `VOICE` and `SINGLE` accept only `0` and `1`. +- Empty `DIAL` / `DEFAULT_REFLECTOR` is parsed as `0`, meaning no default + reflector. + +## Current Analysis: Voice Ident Override TG Range Check + +### Findings + +- `ident()` checks `OVERRIDE_IDENT_TG` before sending generated voice ident + packets. +- The upper-bound expression is malformed: + `int(CONFIG['SYSTEMS'][system]['OVERRIDE_IDENT_TG'] < 16777215)`. +- The comparison happens before `int(...)`, so string config values such as + `"9"` can be compared directly to integer `16777215`, raising `TypeError`. +- Numeric config values avoid the type error, but the expression becomes + `int(True)` or `int(False)`, so the second half of the range check is only + `1` or `0`. +- `bytes_3(CONFIG['SYSTEMS'][system]['OVERRIDE_IDENT_TG'])` is then called with + the raw config value rather than the parsed integer. + +### Assumptions + +- `OVERRIDE_IDENT_TG` is intended to direct voice ident packets to a configured + TG instead of all-call. +- Valid override TGs should be positive and less than the DMR all-call value + `16777215`. +- Empty string, `False`, or `0` should mean no override and should fall back to + all-call. +- Invalid override values should not crash `ident()`; they should fall back to + all-call and ideally be logged. +- Local/control TGs should be rejected for ident override. + +### Unresolved Questions + +- Should invalid startup `OVERRIDE_IDENT_TG` be normalized in runtime config, or + only ignored at ident send time? +- Resolved: invalid ident override values should be logged. + +### Protocol-Sensitive Areas + +- Voice ident sends generated DMR packets and chooses the destination TG for + those packets. +- All-call `16777215` is the fallback destination when no override is active. +- This path must not mutate packet bytes outside the intended generated voice + packet destination. + +### Inferred Invariants + +- Voice ident destination selection should parse and validate the override TG + before packet generation. +- Invalid override TG values should not prevent voice ident from running. +- Empty or disabled override values should use all-call. + +### Resolution + +- `OVERRIDE_IDENT_TG` is parsed before use. +- Valid positive TGs below all-call are used as the generated voice ident + destination. +- Empty string, `False`, and `0` use all-call. +- Malformed, out-of-range, or local/control TG values are logged and use + all-call. + +## Current Analysis: Invalid TIMER Still Used Raw In Static TG Branches + +### Findings + +- `options_config()` now parses `DEFAULT_UA_TIMER` / `TIMER` into + `_default_ua_timer` and falls back to the current effective timer when the + option is malformed. +- Later in the same function, the TS1 and TS2 static TG update branches still + use `int(_options['DEFAULT_UA_TIMER'])` instead of `_default_ua_timer`. +- A client option string such as `TIMER=A;TS1=91` logs that the timer is invalid, + but then can still raise `ValueError` when the static TG branch runs. +- That aborts otherwise valid static TG changes in the same options string. + +### Assumptions + +- Invalid `TIMER` should be ignored independently for the session and should use + the current effective `DEFAULT_UA_TIMER`. +- Valid static TG changes in the same options string should still apply. +- The raw `DEFAULT_UA_TIMER` option value should not be converted again after + `_default_ua_timer` has been parsed and normalized. + +### Unresolved Questions + +- None for the immediate fix if the previously confirmed tolerant options model + applies. + +### Protocol-Sensitive Areas + +- The timer value controls bridge timeout state but should not block static TG + routing updates when malformed. +- Static TG updates must use the effective parsed timer, not raw client text. + +### Inferred Invariants + +- Once an option field is parsed into an effective value, subsequent bridge + mutations should use that effective value. +- Invalid independent option fields should not trigger `ValueError` as normal + control flow. +- Invalid option fields should be logged. + +### Resolution + +- TS1 and TS2 static TG updates now use the parsed effective `_default_ua_timer` + rather than the raw option string. +- Invalid `TIMER` values are logged and valid static TG updates in the same + options string still apply with the current effective timer. + +### Clarified Configuration Model + +- Client-supplied options are session-scoped. They should persist only for the + duration of that client session. +- FreeDMR has a disconnect/timeout routine that resets session-scoped options to + system configured defaults. +- Startup config-file defaults and client options are different layers and + should not be treated identically. +- If a startup config directive has an invalid field and that directive has a + defined system fallback default, FreeDMR should use the fallback as if the + directive had not been present and log that the startup default setting was + invalid. +- If a startup config directive is invalid and has no system default fallback, + FreeDMR should exit with an error. +- For client options, invalid fields should not become persisted configuration; + they should apply only if valid for the current session. +- Rejected configuration or client-option errors should be logged. Normalizing, + ignoring, or falling back silently is not acceptable because many rejected + options have no direct user feedback path. + +### Startup Fallback Mapping Observed In `config.py` + +- `DEFAULT_UA_TIMER` has a startup fallback of `10`. +- `SINGLE_MODE` has a startup fallback of `True`. +- `VOICE_IDENT` has a startup fallback of `True`. +- `TS1_STATIC` has a startup fallback of empty string. +- `TS2_STATIC` has a startup fallback of empty string. +- Empty `TS1_STATIC` and `TS2_STATIC` values are valid and mean no static TGs + for that slot. +- `OVERRIDE_IDENT_TG` has a startup fallback of `False`. +- `DEFAULT_REFLECTOR` is read with `getint()` and no fallback in `config.py`; + however the runtime policy now treats invalid positive values as disabled + default reflector state for the current session. +- Empty string, integer `0`, and boolean false for `DEFAULT_REFLECTOR` are + equivalent and mean no default reflector is set. +- `OPTIONS` is session-scoped and is reset by the HBP connection lifecycle to + `_default_options` when present, or removed otherwise. + +## Voice Prompt Packet System Scoping + +### Findings + +- `sendVoicePacket(self, pkt, _source_id, _dest_id, _slot)` indexes + `systems[system].STATUS`, but `system` is not a function argument or local + variable. +- `sendSpeech(self, speech)` also indexes `systems[system].STATUS[2]` without a + local `system`. +- The call sites pass a router instance as `self`, for example + `reactor.callInThread(sendSpeech, self, speech)` and + `reactor.callFromThread(sendVoicePacket, systems[system], ...)`. +- In imported deterministic tests this can raise `NameError` if those functions + execute directly. In the full process it can be worse: module-level `for + system in ...` loops may leave a stale global `system`, causing prompt stream + state to be written to the wrong router. + +### Assumptions To Validate + +- Generated voice packets should update the status for the router instance being + sent on, i.e. `self._system`. +- Dial-a-TG voice prompts, disconnected announcements, on-demand files, and + idents should all be emitted on TG9 slot 2 unless their existing call path + intentionally selects a different destination. +- A voice prompt should not mutate stream status for another master just because + a global loop variable currently names that system. + +### Unresolved Questions + +- Should invalid prompt packet/state errors be caught and logged inside the + thread helpers, or should existing Twisted thread exception logging remain the + only failure report? + +### Protocol-Sensitive Areas + +- Voice prompt packet bytes and destination TG must remain unchanged unless the + production code intentionally generates them that way. +- The fix should be transport/state scoping only; it should not alter dial-a-TG + routing decisions or prompt content. + +### Inferred Invariants + +- Helper functions that receive a router instance should derive the system name + from `self._system`, not from a module-global loop variable. +- Prompt stream lifecycle bookkeeping must be attached to the same system that + sends the prompt packet. + +### Resolution + +- `sendVoicePacket()` and `sendSpeech()` now bind `system = self._system` before + accessing `systems[system].STATUS`. +- Deterministic coverage exercises `sendSpeech()` with a deliberately stale + module-level `system` value and verifies stream state and captured prompt + packets stay on the router instance's own system. + +## Bridge Reset System Replacement + +### Findings + +- `remove_bridge_system(remsystem)` loops `for system in CONFIG['SYSTEMS']` and + rebuilds every bridge once per configured system. +- When it finds an entry where `bridgesystem['SYSTEM'] == remsystem`, it appends + a replacement entry with `'SYSTEM': system`, using the outer loop variable + rather than `remsystem`. +- Because `bt[bridge]` is overwritten on every outer-loop pass, the final bridge + state depends on the last configured system. In a two-master topology, + removing `MASTER-A` can leave two `MASTER-B` entries and no `MASTER-A` entry. +- Additional deterministic probes show the same identity corruption with three + masters and with OpenBridge present: the replacement entry can become + `MASTER-C` or even an `OBP-*` system depending on config iteration order. +- Neighboring reset helpers do not support this as intentional behavior: + `reset_static_tg()` and `reset_all_reflector_system()` preserve the target + system identity in replacement entries. + +### Assumptions To Validate + +- Bridge reset for a disconnected or timed-out master should keep that master's + bridge entry present but inactive, preserving its slot, TGID, timeout and + normal dial-a-TG activation trigger. +- Bridge reset should not create duplicate entries for another master. +- Bridge reset should not remove or deactivate unrelated systems' bridge entries. + +### Unresolved Questions + +- Should reset entries keep the previous `ON` list exactly as-is, or should they + keep the current behavior of setting `ON` to the entry's `TGID`? + +### Protocol-Sensitive Areas + +- This affects bridge state only; packet bytes should not be changed. +- Incorrect reset state can change which systems receive later TG traffic and + can prevent the reset master from reactivating the intended TG. + +### Inferred Invariants + +- A reset of system `X` must leave bridge entries for system `X` named `X`. +- The number of entries for unrelated systems should not change during reset. + +### Resolution + +- `remove_bridge_system()` now rebuilds each bridge once instead of once per + configured system. +- Replacement entries preserve `SYSTEM` as `remsystem`, set `ACTIVE` false, set + `TO_TYPE` to `ON`, reset the timer, and preserve existing `ON`, `OFF`, and + `RESET` trigger lists. +- Deterministic coverage verifies reset identity preservation for normal bridges + and trigger preservation for `#` reflector bridges with an FBP peer present. + +## HBP Packet Reset/Reload Guard + +### Findings + +- `routerHBP.dmrd_received()` checks reset/reload state before parsing a packet: + `CONFIG['SYSTEMS'][self._system]['_reset'] or + CONFIG['SYSTEMS'][_system]['_reloadoptions']`. +- `_system` is assigned later in the same function by bridge iteration loops, + so Python treats it as a local variable. Referencing it in the guard before + assignment raises `UnboundLocalError` when the `_reset` key exists and does + not short-circuit safely. +- The `return` is outside the `if` block but inside the `try`. If both + `_reset` and `_reloadoptions` existed and were false, the function would still + return and drop the packet. +- The current code only reaches normal packet parsing when a `KeyError` is + raised by missing state keys. That makes normal operation depend on absent + lifecycle keys rather than their boolean values. + +### Assumptions To Validate + +- HBP packets should be dropped only while the receiving system's `_reset` or + `_reloadoptions` flag is true. +- The guard should inspect `CONFIG['SYSTEMS'][self._system]` for both flags. +- When a packet is dropped due reset/reload, it should be logged once using + `_resetlog`, preserving the current intent. +- When both flags are false or absent, packet handling should continue normally. + +### Unresolved Questions + +- Should reset and reload use separate one-shot log flags, or is the existing + shared `_resetlog` flag sufficient? + +### Protocol-Sensitive Areas + +- This is a packet admission guard. The fix should not mutate packet bytes or + alter routing after a packet is admitted. +- Incorrectly dropping during false reset/reload state can look like a silent + repeater or TG routing failure. + +### Inferred Invariants + +- Lifecycle flags should be treated as optional booleans with false defaults. +- A packet should be rejected during reset/reload only when the relevant flag is + explicitly true. +- Rejected packet admission during reset/reload should remain logged. + +### Resolution + +- The HBP packet admission guard now reads `_reset` and `_reloadoptions` from + `CONFIG['SYSTEMS'][self._system]` with false-default `dict.get()`. +- The guard returns only when reset or reload is active, and logs the rejection + once through `_resetlog`. +- Deterministic coverage verifies false lifecycle flags admit packets, while + active reset or reload drops packets and logs exactly once. + +## HBP Unit Data To OBP Reporting Target + +### Findings + +- `routerHBP.sendDataToOBP()` sends the packet to `systems[_target]` correctly. +- When reporting is enabled, it then calls + `systems[system]._report.send_bridgeEvent(...)`, but `system` is not a + function argument or local variable in `sendDataToOBP()`. +- In a deterministic HBP unit-data-to-OBP path with reporting enabled, the + packet is captured for the OBP target and then the function raises + `NameError: name 'system' is not defined`. +- If a module-level `system` happens to exist in the full process, the report + event could be attached to the wrong system's report object instead of raising. + +### Assumptions To Validate + +- The TX report for `sendDataToOBP()` should be emitted on the target OBP + system's report object, matching the packet destination and the event payload. +- Reporting should not be able to raise after a packet send and interrupt the + rest of packet handling. +- This fix is reporting-only and should not change packet bytes, routing, stream + state, or source-quench behavior. + +### Unresolved Questions + +- None for the minimal fix; this appears to be a direct variable-name error. + +### Protocol-Sensitive Areas + +- The packet send path already uses `_target`; only the reporting object should + change. +- The event payload already names `_target`, so preserving it avoids report + semantic changes. + +### Inferred Invariants + +- Reporting side effects should use the same target system as the packet send + unless a caller explicitly reports on the source router. +- Enabling `CONFIG['REPORTS']['REPORT']` must not change packet routing + behavior or raise after a successful send. + +### Resolution + +- `routerHBP.sendDataToOBP()` now reports through `systems[_target]._report`, + matching the packet send target and existing event payload. +- Deterministic coverage verifies HBP unit data forwarded to OBP captures the + packet on the OBP target and records the TX report on that target report + object without raising. + +### Data Packet Review Scope + +- Per user direction, continue evaluating data packet processing before moving + to voice packet path debugging. + +## OBP Data Gateway Metadata Argument Order + +### Findings + +- In `routerOBP.dmrd_received()`, the DATA-GATEWAY forwarding path calls + `sendDataToOBP('DATA-GATEWAY', ..., _source_rptr, _ber, _rssi)`. +- `routerOBP.sendDataToOBP()` expects positional arguments after `_slot` as + `_hops, _source_server, _ber, _rssi, _source_rptr`. +- This shifts metadata fields: + `_source_rptr` is sent as hops, `_ber` is sent as source server, `_rssi` is + sent as BER, RSSI falls back to zero, and source repeater falls back to zero. +- A deterministic probe with distinctive metadata captured: + `hops=b'\x01\x02\x03\x05'`, `source_server=b'\x12'`, + `ber=b'4'`, `source_rptr=b'\x00\x00\x00\x00'` for the DATA-GATEWAY target. + +### Assumptions To Validate + +- DATA-GATEWAY forwarding from OBP should preserve incoming `_hops`, + `_source_server`, `_ber`, `_rssi`, and `_source_rptr` exactly as received + unless production code intentionally rewrites them. +- The DATA-GATEWAY path should match the later OBP-to-OBP forwarding call that + already passes `_hops, _source_server, _ber, _rssi`. +- Because this is DATA-GATEWAY forwarding, preserving source repeater metadata is + preferable to falling back to zeros. + +### Unresolved Questions + +- Should `routerOBP.sendDataToOBP()` calls always use keyword arguments for + protocol metadata to prevent this class of positional-order bug? + +### Protocol-Sensitive Areas + +- This affects OBP metadata, not DMR payload bytes. The packet bytes should + remain unchanged except for existing slot-bit handling. +- Hops, source server, source repeater, BER, and RSSI can affect loop control, + observability and downstream data handling. + +### Inferred Invariants + +- Transport metadata forwarding must not be confused with protocol mutation. +- DATA-GATEWAY forwarding should not corrupt metadata by positional argument + shifts. + +### Validation Result + +- Rejected as a bug. User confirmed DATA-GATEWAY forwarding targets SMS/GPS + packet processing by KF1EEL and is protocol v1, not FBP. +- Protocol metadata expectations depend on protocol version; DATA-GATEWAY should + not be evaluated as though it were an FBP peer. +- Do not change this DATA-GATEWAY call based on FBP metadata preservation + assumptions. +- General protocol invariant confirmed by user: protocol options and argument + order must match the protocol version actually in use for that session. + +## Unit Data To HBP Reporting Slot + +### Findings + +- Both `routerOBP.sendDataToHBP()` and `routerHBP.sendDataToHBP()` accept a + target slot argument named `_d_slot`. +- Callers calculate `_tmp_bits` before calling the helper, so the packet bytes + are rewritten to the target slot when needed. +- The helpers log and report the TX event with a hardcoded slot value of `1`. +- Deterministic probes show the packet is captured on slot 2 when SUB_MAP points + to slot 2, but the report event says slot 1: + `UNIT DATA,DATA,TX,MASTER-B,...,1,1234567`. +- This reproduces for both HBP-originated unit data and OBP-originated unit data + forwarded to an HBP master via `SUB_MAP`. + +### Assumptions To Validate + +- Unit data TX reports should identify the actual target HBP slot `_d_slot`. +- The packet bytes should remain unchanged; the existing caller-side slot bit + rewrite is already producing the expected target slot. +- This is reporting/observability only and should not alter data routing, + DATA-GATEWAY behavior, or protocol-version-specific metadata. + +### Unresolved Questions + +- Should the debug log wording also be changed from "on slot 1" to include + `_d_slot`, or should only the reporting payload change? + +### Protocol-Sensitive Areas + +- This is independent of OBP protocol version because the affected target is + HBP and `_d_slot` is already an explicit HBP target slot. +- Reporting should reflect the actual transport target without mutating packet + payload bytes. + +### Inferred Invariants + +- If a helper receives `_d_slot`, reporting and logs should not hardcode slot 1. +- Data packet tests should distinguish correct packet slot bits from incorrect + report metadata. + +### Resolution + +- `routerOBP.sendDataToHBP()` and `routerHBP.sendDataToHBP()` now use `_d_slot` + in both debug logging and `UNIT DATA,DATA,TX` report payloads. +- Deterministic coverage verifies HBP-originated and OBP-originated unit data + forwarded to HBP slot 2 via `SUB_MAP` captures slot 2 packet bits and reports + slot 2. + +## OBP Unit CSBK Data Stream Classification + +### Findings + +- HBP unit data classification treats `_dtype_vseq == 3` as data when the packet + stream differs from the slot's current `RX_STREAM_ID`. +- HBP unit data handling does not update the slot `RX_STREAM_ID` in the data + branch, so repeated unit CSBK packets with the same stream continue through + the data forwarding path. +- OBP unit data classification treats `_dtype_vseq == 3` as data only when + `_stream_id not in self.STATUS`. +- Once the first OBP unit CSBK packet creates `self.STATUS[_stream_id]`, + subsequent unit CSBK packets with the same stream do not enter the data path + and are not forwarded via `SUB_MAP`. +- Deterministic probe: two HBP unit CSBK packets with the same stream both + forward to the HBP `SUB_MAP` target; two OBP unit CSBK packets with the same + stream forward only the first packet. + +### Assumptions To Validate + +- Repeated unit CSBK packets with the same stream ID should still be treated as + data packets and forwarded, unless duplicate control rejects the exact packet. +- OBP and HBP should not diverge on this classification solely because OBP uses + per-stream `STATUS` and HBP uses per-slot `STATUS`. +- If this is intentional first-packet-only OBP behavior for a protocol version, + the deterministic harness should document it rather than change it. + +### Unresolved Questions + +- Is `_dtype_vseq == 3` intended to represent only an initial unit CSBK in OBP, + or can a valid data transaction contain multiple non-identical CSBK packets + with the same stream ID? + +### Protocol-Sensitive Areas + +- This affects data packet admission to the forwarding path, not packet byte + mutation. +- Duplicate suppression should remain separate from protocol classification: + exact duplicate packets may be dropped, but distinct CSBK data packets should + not be lost just because their stream already exists if the protocol allows + them. + +### Inferred Invariants + +- Data packet classification should be explicit about whether stream state is a + first-packet guard or only duplicate/loop-control state. +- OBP/HBP behavioral differences need protocol-version justification. + +### Deeper Analysis + +- ETSI TS 102 361-2 describes CSBK preamble use as a way to improve successful + delivery to mobile stations that are scanning or using sleep mode for battery + saving. +- User confirmed a DMR terminal can send more than one CSBK, usually as a + preamble to wake battery-operated terminals, but only one is required for the + route. +- In FreeDMR's OBP path, the first unit CSBK creates per-stream `STATUS` and + can be forwarded through DATA-GATEWAY, other OpenBridge/FBP peers, `SUB_MAP`, + or hotspot matching. +- Subsequent data header/block packets (`_dtype_vseq` 6, 7, 8) are still always + classified as unit data for the same stream. Therefore, the OBP first-CSBK + gate does not block the actual data payload path. +- Forwarding only the first CSBK may be intentional: it is enough to establish + routing/wake-up behavior and avoids multiplying preamble bursts across the + network. +- HBP's different behavior appears partly caused by its per-slot data state: the + unit-data branch does not set `RX_STREAM_ID`, so repeated CSBKs continue to + satisfy `_stream_id != self.STATUS[_slot]['RX_STREAM_ID']`. + +### Validation Result + +- Do not change this yet. The observed OBP/HBP difference is real, but not yet a + confirmed bug. +- Treat OBP first-CSBK-only forwarding as likely intentional until a recorded + fixture or live test shows a valid data transaction needing multiple distinct + CSBKs forwarded over OBP for correct behavior. +- Add future fixture coverage before changing this classification logic. + +### Follow-Up Verification + +- Code parsing in `hblink.py` extracts `_stream_id = _data[16:20]` for every + DMRD packet before passing it into `dmrd_received()`. +- FreeDMR does not appear to model a single transaction-level stream ID for the + whole SMS/GPS data exchange. Each DMRD packet carries its own stream ID, and + data routing decisions are made per received packet. +- OBP unit data handling always classifies `_dtype_vseq` 6, 7 and 8 as data + regardless of whether `_stream_id` already exists, so data header/block packets + with distinct stream IDs are naturally handled independently. +- OBP unit CSBK handling classifies `_dtype_vseq == 3` as data only when that + packet's `_stream_id` is not already present in `self.STATUS`. +- Deterministic probe: two OBP unit CSBK packets with distinct stream IDs both + forward via `SUB_MAP`; two with the same stream ID only forward the first. +- This supports the interpretation that repeated CSBK preambles are expected to + be distinct packets with distinct stream IDs in this code path, and the + existing OBP first-CSBK-per-stream gate is likely a duplicate/preamble + suppression mechanism rather than a transaction-level routing bug. +- User confirmed the underlying protocol model: data packets are packet-oriented + and are not a stream in the same way AMBE2+ audio is a stream. +- User also confirmed DMR data is not always unit-to-unit; the standard permits + data packets to be sent as group calls to a talkgroup. + +## HBP Group-Addressed Data Activates TG Bridge State + +### Findings + +- HBP `routerHBP.dmrd_received()` handles `group` and `vcsbk` packets in the + group-call path. +- For a new group packet, it logs `_dtype_vseq == 6` as `DATA HEADER`, but then + runs the same unknown-TG bridge creation block used for voice-like group calls: + `make_single_bridge(_dst_id, self._system, _slot, DEFAULT_UA_TIMER)`. +- Deterministic probe: a single HBP group data header to TG123 creates bridge + `123` with `MASTER-A` slot 2 active. +- If bridge `123` already exists and is active, the same group data header is + routed to target systems, so group-addressed data routing through existing TG + bridges appears intentional or at least supported. +- OBP group data to TG123 does not create a bridge in the same probe. + +### Assumptions To Validate + +- Group-addressed data packets may legitimately route over existing active TG + bridge state. +- Unknown group-addressed data packets should not necessarily create new + user-activated voice/TG bridge state just because the TG is unknown. +- If unknown group data is intended to create a bridge, that should be treated as + an explicit design decision because it can activate TG state without voice. + +### Unresolved Questions + +- Should group-addressed data headers (`_call_type == 'group'` and + `_dtype_vseq == 6`) create user-activated bridges for unknown TGs, or should + automatic bridge creation be restricted to voice-like group calls? +- Should OBP and HBP differ here, or should both follow the same group-data + bridge-creation policy? + +### Protocol-Sensitive Areas + +- Data packets are packet-oriented, so stream-style bridge activation semantics + may not be appropriate. +- Group data can be a valid TG-addressed packet, but routing it over an existing + bridge is different from creating new active bridge state. + +### Inferred Invariants + +- Packet routing and bridge-state activation should be considered separately. +- A data packet should not create voice/TG lifecycle state unless that is an + explicit FreeDMR policy. + +### Validation Result + +- Leave unchanged for now. User believes this behavior is deliberate. +- Likely rationale: allow routing of GPS packets sent as group calls to a local + APRS bridge without exposing them to the entire network, because they are + local-to-server. +- Do not treat HBP unknown group-data bridge creation as a confirmed bug unless + a later fixture or operational test disproves this policy. + +## Enhanced OBP Missing-Keepalive Gate + +### Findings + +- `kaReporting()` logs enhanced OpenBridge targets with no `_bcka` as not + sendable: `not sending to system ... as KeepAlive never seen`. +- `routerOBP.to_target()` applies that rule for OBP-originated group/voice + traffic: if `ENHANCED_OBP` is true and `_bcka` is missing or older than 60 + seconds, the target is skipped. +- `routerHBP.to_target()` only skips enhanced OpenBridge targets when `_bcka` + exists and is stale; if `_bcka` is missing, HBP-originated group/voice traffic + is still forwarded. +- `routerOBP.sendDataToOBP()` and `routerHBP.sendDataToOBP()` have the same + lenient missing-`_bcka` check, so unit data can be forwarded to enhanced + OpenBridge targets that have never sent a keepalive. +- Deterministic probes confirmed: + HBP unit data -> enhanced OBP without `_bcka` sends one packet; HBP group + voice -> enhanced OBP without `_bcka` sends one packet; OBP unit data -> + enhanced OBP without `_bcka` sends one packet; OBP group voice -> enhanced + OBP without `_bcka` sends no packet. + +### Assumptions To Validate + +- For `ENHANCED_OBP` targets, missing `_bcka` means the peer has not completed + the enhanced keepalive requirement and should not receive traffic. +- The intended rule should be the same for HBP-originated and OBP-originated + traffic, and for voice-like group packets and unit data packets. +- Periodic `kaReporting()` warnings are sufficient operational logging for + missing/stale enhanced keepalive suppression; per-packet skip logging would be + noisy. + +### Unresolved Questions + +- Should the strict missing-or-stale keepalive gate be applied everywhere an + enhanced OpenBridge target is selected, or is there a deliberate exception for + HBP-originated traffic/data? + +### Protocol-Sensitive Areas + +- Enhanced OBP keepalive state is transport/session state, not DMR payload + mutation. +- Data packet forwarding must preserve packet bytes except intentional slot or + transport metadata rewrites. + +### Inferred Invariants + +- Enhanced OpenBridge targets should not receive traffic until the session has + valid recent keepalive state. +- The same sendability predicate should be used by voice and data paths unless + an explicit protocol-version rule says otherwise. + +### Validation Result + +- User confirmed the strict missing-or-stale `_bcka` gate is intended for + enhanced OBP targets. The inconsistency likely came from protocol development + and was missed. +- Applied the strict gate in `routerHBP.to_target()`, + `routerOBP.sendDataToOBP()`, and `routerHBP.sendDataToOBP()` so the data + helpers and HBP-originated voice path match the existing OBP-originated voice + behavior. +- Added deterministic coverage for HBP unit data with missing, stale and recent + keepalive; OBP unit data with missing keepalive; and HBP group voice with + missing keepalive. + +### Remaining Risk + +- The black-box UDP harness does not yet exercise enhanced OBP keepalive + negotiation end to end. Current coverage validates the in-process routing + predicate and packet capture behavior. + +## Group-Addressed Data Reported As Group Voice + +### Findings + +- HBP `routerHBP.dmrd_received()` recognizes a group data header when + `_call_type == 'group'` and `_dtype_vseq == 6`; it logs `*DATA HEADER*` and + emits `DATA HEADER,DATA,RX`. +- The same HBP group data header then enters the normal group routing path. The + target-side `to_target()` methods emit `GROUP VOICE,START,TX` for a new target + stream regardless of `_dtype_vseq`. +- OBP `routerOBP.dmrd_received()` does not special-case group data header + receive reporting; a group data header is reported as + `GROUP VOICE,START,RX`. +- `stream_trimmer_loop()` later emits `GROUP VOICE,END,RX` and/or + `GROUP VOICE,END,TX` for timed-out group data state, because the timeout + reporter is keyed on stream/slot state and does not distinguish data headers + from voice streams. +- Deterministic probes with reporting enabled confirmed: + HBP group data to HBP target reports `DATA HEADER,DATA,RX` on the source, but + `GROUP VOICE,START,TX` on the target; after timeout the source gets + `GROUP VOICE,END,RX` and the target gets `GROUP VOICE,END,TX`. +- OBP group data reports `GROUP VOICE,START,RX` on the OBP source and + `GROUP VOICE,START,TX` on the target. + +### Assumptions To Validate + +- Group-addressed data routing may be intentional, but report events should not + identify data packets as voice calls. +- Consumers of bridge reports expect event names and categories to describe the + packet class accurately enough to avoid showing phantom voice calls. +- If a group data header creates temporary stream/slot state, timeout reporting + should either use data-specific end events or avoid voice end events for that + data-only state. + +### Unresolved Questions + +- What report event names should be used for group-addressed data TX and OBP RX? + Possible names include `GROUP DATA HEADER,DATA,TX/RX` or reusing + `DATA HEADER,DATA,TX/RX`. +- Should timeout cleanup emit a data-specific end event for group data, or + should data headers avoid creating reportable voice lifecycle state? + +### Protocol-Sensitive Areas + +- This is reporting and stream/slot observability, not packet forwarding or + payload mutation. +- Group-addressed data is valid DMR traffic and may intentionally route over + existing TG bridges. + +### Inferred Invariants + +- Routing a packet over a TG bridge does not imply the report event should be + `GROUP VOICE`. +- Voice lifecycle reports should only describe AMBE voice-like streams, not + packet-oriented data headers. + +### Validation Result + +- User agreed this should change, with the caveat that the dashboard may have + limitations and will need later compatibility testing. +- Added `group_data_event_name()` for report classification of group/vcsbk data + events. +- HBP and OBP group data headers now report `DATA HEADER,DATA,RX/TX` instead of + `GROUP VOICE,START,RX/TX`. +- HBP slot state and OBP stream state now track data-only report state so + `stream_trimmer_loop()` suppresses `GROUP VOICE,END,RX/TX` for data-only + timeouts. +- Deterministic coverage verifies HBP and OBP group data reporting does not + emit `GROUP VOICE` events, and that normal HBP group voice still emits voice + start/end lifecycle reports. + +### Remaining Risk + +- The dashboard is the primary report consumer and may assume the older + `GROUP VOICE` event names. Dashboard compatibility remains to be tested + outside this codebase. + +## HBP VCSBK Data RX Duplicate Reports + +### Findings + +- HBP `routerHBP.dmrd_received()` handles `_call_type == 'vcsbk'` in the group + call/data path. +- On a new VCSBK stream, the first `_call_type != 'group'` branch logs `*VCSBK*` + and emits `OTHER DATA,DATA,RX`. +- Immediately afterwards, the dedicated VCSBK block handler emits a second, + more specific report for `_dtype_vseq == 7` or `_dtype_vseq == 8`: + `VCSBK 1/2 DATA BLOCK,DATA,RX` or `VCSBK 3/4 DATA BLOCK,DATA,RX`. +- Deterministic probes confirmed HBP VCSBK dtype 7 and dtype 8 each produce two + RX report events on the source system. +- The target-side report is already specific (`VCSBK 1/2 DATA BLOCK,DATA,TX` or + `VCSBK 3/4 DATA BLOCK,DATA,TX`) because `to_target()` uses + `group_data_event_name()`. +- OBP-originated VCSBK dtype 7 and dtype 8 now produce only the specific RX + event and the specific TX event. + +### Assumptions To Validate + +- `OTHER DATA,DATA,RX` is intended as a fallback for VCSBK/other data types that + do not have a more specific report name. +- HBP VCSBK dtype 7/8 should emit only the specific VCSBK block RX event, not + both a generic and specific data event. +- Removing the duplicate generic HBP source report is reporting-only and should + not alter routing, packet bytes, or bridge activation behavior. + +### Unresolved Questions + +- Should any other VCSBK dtype values continue to report as `OTHER DATA,DATA,RX` + on first receipt? + +### Protocol-Sensitive Areas + +- VCSBK data block classification is protocol/data reporting, not AMBE voice + lifecycle. +- This change would affect dashboard/report consumers but not DMR payload + forwarding. + +### Inferred Invariants + +- A single received packet should normally produce one RX report event for its + packet class. +- Specific data report names should take precedence over generic fallback names. + +### Validation Result + +- User agreed that specific VCSBK events should take precedence. +- HBP `OTHER DATA,DATA,RX` fallback now only emits when + `group_data_event_name()` has no specific event name. +- Deterministic coverage verifies HBP VCSBK dtype 7 emits the specific + `VCSBK 1/2 DATA BLOCK,DATA,RX/TX` events without a generic RX duplicate. +- Deterministic coverage also verifies an unknown VCSBK dtype still emits + `OTHER DATA,DATA,RX`. + +## Unknown VCSBK Reported As Group Voice + +### Findings + +- Unknown HBP VCSBK data types currently emit `OTHER DATA,DATA,RX` on the + source, but because `group_data_event_name()` returns `None` for unknown VCSBK + values, the same packet is treated as voice-like for target and timeout + reporting. +- Deterministic probe for HBP `_call_type == 'vcsbk'` and `_dtype_vseq == 5` + produced source reports: + `OTHER DATA,DATA,RX` followed by `GROUP VOICE,END,RX` on timeout. +- The target for the same HBP packet produced `GROUP VOICE,START,TX` followed by + `GROUP VOICE,END,TX` on timeout. +- OBP-originated unknown VCSBK currently produces only `GROUP VOICE,START,RX` on + source and `GROUP VOICE,START,TX` on target, followed by voice timeout events. +- This is parallel to the group-data reporting issue, but applies to generic + VCSBK fallback data rather than known VCSBK block types. + +### Assumptions To Validate + +- `vcsbk` packets are data/control signalling for report classification, even + when `_dtype_vseq` is not one of the known block values currently named by the + code. +- Unknown VCSBK types should use `OTHER DATA,DATA,RX/TX` instead of any + `GROUP VOICE` lifecycle event. +- Timeout cleanup should suppress `GROUP VOICE,END,RX/TX` for unknown VCSBK + state just as it does for known group/vcsbk data. + +### Unresolved Questions + +- Are there any VCSBK `_dtype_vseq` values that dashboard/report consumers + intentionally expect to appear as `GROUP VOICE`? + +### Protocol-Sensitive Areas + +- VCSBK is a signalling/data classification issue; packet bytes and routing + should remain unchanged. +- Dashboard compatibility remains a risk for report event names. + +### Inferred Invariants + +- Unknown data/control packet types should fall back to generic data reporting, + not voice lifecycle reporting. +- `group_data_event_name()` currently doubles as both the event-name selector + and the data-vs-voice lifecycle classifier; unknown VCSBK exposes that + coupling. + +### Specification Check + +- ETSI TS 102 361-1 V2.6.1 separates voice burst format from data/control burst + format. Voice bursts carry vocoder payload plus either sync or embedded + signalling in the centre field. +- The embedded signalling defined for voice bursts is LC, RC, privacy-related + signalling, or null embedded message. This is not a CSBK/data continuation + Slot Type burst. +- Data/control bursts use the general data burst format and contain a Slot Type + PDU whose Data Type defines the 196 information bits. +- The Data Type table lists CSBK, Data Header, Rate 1/2 Data Continuation, and + Rate 3/4 Data Continuation as data/control burst types. +- Therefore, the packets currently classified by the HomeBrew bit parser as + `vcsbk`/data-sync data types should not be reported as part of a voice stream. + Unknown VCSBK data types should fall back to generic data/control reporting, + not `GROUP VOICE` lifecycle reporting. + +### Validation Result + +- User approved the fix after the specification check. +- Added `is_group_data_control()` to classify group/vcsbk data-control packets + independently from their specific report event name. +- Added `group_data_report_name()` so known group/vcsbk data uses the specific + event name and unknown VCSBK falls back to `OTHER DATA`. +- HBP and OBP unknown VCSBK now emit `OTHER DATA,DATA,RX/TX` and suppress + `GROUP VOICE` timeout lifecycle events. +- Deterministic coverage verifies HBP and OBP unknown VCSBK fallback reporting + produces no `GROUP VOICE` events. + +## OBP Unit Data Loop-Control Zero-Division + +### Findings + +- `routerOBP.dmrd_received()` unit-data handling creates or updates + `self.STATUS[_stream_id]`, increments `packets`, then performs OBP + first-source loop-control selection immediately in the same receive path. +- If another OBP system has the earlier `1ST` timestamp for the same + `_stream_id` and `_dst_id`, the losing source enters the `self._system != fi` + branch. +- That branch calculates `call_duration = pkt_time - + self.STATUS[_stream_id]['START']`, initializes `packet_rate = 0`, but then + divides by `call_duration` whenever `packets` exists: + `packet_rate = self.STATUS[_stream_id]['packets'] / call_duration`. +- Deterministic probe: inject the same OBP unit data packet with the same stream + ID into `OBP-1`, then into `OBP-2` without advancing the harness clock. The + second injection raises `ZeroDivisionError` at the packet-rate calculation. +- The HBP/OBP group loop-control path has a similar unguarded packet-rate + calculation, but the immediate two-source group probe did not crash because + that path only reaches the calculation after the stream already exists on the + receiving OBP source. + +### Assumptions To Validate + +- A zero-duration packet-rate sample should be reported as `0` rather than + crashing packet processing. +- Loop-control should still ignore the later source and update `LAST` as it + does today. +- This is a logging/diagnostic calculation; guarding it should not change + routing, packet bytes, or first-source selection behavior. + +### Unresolved Questions + +- Should the same duration guard be applied to all packet-rate calculations in + loop-control/rate-drop paths, including the OBP group path and HBP group + rate-drop path, as a defensive cleanup? + +### Protocol-Sensitive Areas + +- This is loop-control diagnostics for data forwarding, not protocol mutation. +- The first-source `1ST` selection remains the source-quench/loop-control + behavior under review. + +### Inferred Invariants + +- Packet processing must not fail because multiple packets share the same + timestamp. +- Diagnostic packet-rate calculations must be optional and guarded. + +### Validation Result + +- User approved the zero-duration guard. +- Added `call_duration` truthiness checks before calculating diagnostic + `packet_rate` in OBP unit-data and OBP group loop-control ignore paths. +- Deterministic coverage reproduces the same-timestamp two-OBP unit-data case + and verifies the later source is loop-controlled without raising. + +## OBP Group Packet Rate Drop Uses Absolute Start Time + +### Findings + +- `routerOBP.dmrd_received()` group/vcsbk packet-rate protection checks + `self.STATUS[_stream_id]['packets'] > 18` and then divides packet count by + `self.STATUS[_stream_id]['START']`. +- `START` is an absolute timestamp captured from `pkt_time`, not an elapsed + duration. +- With normal epoch-like times, `packets / START` is effectively zero, so the + `> 25` packet-rate threshold will not fire. +- HBP `routerHBP.dmrd_received()` has the analogous rate-drop check, but divides + by elapsed time: `pkt_time - self.STATUS[_slot]['RX_START']`. +- Deterministic probe: 19 OBP group packets over about 0.095 seconds did not + call `proxy_BadPeer()`. The equivalent HBP probe logged `RATE DROP` and set + the slot `LAST` field. +- `hblink.HBSYSTEM.proxy_BadPeer()` iterates `_peers` and emits `PRBL` proxy + blacklist packets for connected HBP client/repeater peers. +- `hotspot_proxy_v2.py` consumes `PRBL` by looking up the peer in `peerTrack` + and blacklisting the tracked client source host. +- `routerOBP` is an OpenBridge peer system, not a hotspot proxy client session; + it does not have a valid HBP `_peers` set for this purpose and should not use + proxy client blacklisting as its OBP/FBP packet-rate response. +- If the OBP elapsed-time divisor is corrected while leaving + `self.proxy_BadPeer()` in place, the rate-drop path is likely to fail at + runtime rather than cleanly dropping/controlling the offending OBP stream. +- User clarified that using the proxy as an IP-level block point can be + deliberate: the proxy can install blacklist entries into iptables, and a + malicious flood from one IP may need to be blocked for both client and OBP + traffic. +- The remaining concern is therefore not the policy of using the proxy as a + blanket block point; it is that `routerOBP` inherits from `OPENBRIDGE`, while + `proxy_BadPeer()` is only defined on `HBSYSTEM`, and OBP + `dmrd_received()` is not currently passed the source `_sockaddr`. + +### Assumptions To Validate + +- OBP group/vcsbk rate-drop should use elapsed stream duration: + `pkt_time - self.STATUS[_stream_id]['START']`. +- A zero or near-zero elapsed duration should not crash; if the threshold cannot + be evaluated safely, the packet should not be rate-dropped solely by the + diagnostic calculation. +- The existing OBP enforcement policy of asking the proxy to blacklist a flood + source may be intentional and operationally useful. +- The current OBP call site still needs validation because it calls an HBP-only + helper and does not have the inbound OBP source socket address in + `routerOBP.dmrd_received()`. +- The intended OBP/FBP response should be either local packet/stream drop only, + local drop plus enhanced-OBP source quench, or local drop plus a correctly + targeted proxy blacklist request. + +### Unresolved Questions + +- Should the OBP rate-drop response send enhanced-OBP BCSQ source quench when + `ENHANCED_OBP` is enabled? +- If proxy blacklisting is retained for OBP floods, should the blacklist target + be the validated inbound `_sockaddr`, the configured `TARGET_SOCK`, or a + separate proxy-control address? +- Should the threshold stay at the existing 25 packets/second over more than 18 + packets as a crash-protection guard, or should it be configurable later? + +### Protocol-Sensitive Areas + +- This is packet-rate control and bad-peer handling, not packet mutation. +- Changing the divisor can make an existing protection effective, which may + alter operational behavior under bursty traffic. +- `proxy_BadPeer()` currently belongs to the HBP/hotspot proxy control plane. + Reusing the proxy as a shared IP block point may be valid, but OBP needs an + explicit, correctly targeted path rather than an HBP-only method call. +- BCSQ is an enhanced-OBP source-quench mechanism; using it here would be a + protocol-visible overload signal, not just local loop protection. + +### Inferred Invariants + +- Packet-rate thresholds must be calculated against elapsed stream duration, not + absolute timestamps. +- Rate-limit calculations must guard zero elapsed duration. +- OBP/FBP overload handling must not depend on HBP client proxy state. + +### Status + +- Deferred by user. +- Do not change this path yet. The proxy/IP-blocking intent may be deliberate + because the proxy can feed blacklisted IPs into iptables and may be a useful + shared block point for malicious client and OBP floods. +- Return later with a focused review of whether `routerOBP` can correctly reach + that proxy block path as implemented, and whether the rate calculation should + be repaired without changing the intended block policy. + +## HBP Unit Data To FBP Drops BER/RSSI Metadata + +### Findings + +- `routerHBP.dmrd_received()` extracts `_ber = _data[53:54]` and + `_rssi = _data[54:55]` for inbound HBP packets. +- HBP group/voice forwarding to OpenBridge passes `_ber` and `_rssi` through + `routerHBP.to_target()` into `systems[_target['SYSTEM']].send_system(...)`. +- HBP unit-data forwarding uses `routerHBP.sendDataToOBP()`, whose signature + accepts `_ber` and `_rssi`, and then passes them into + `systems[_target].send_system(...)`. +- The two HBP unit-data call sites pass only `_source_rptr` as the positional + argument after `_slot`: + `self.sendDataToOBP(..., _bits, _slot, _source_rptr)`. +- Because of the function signature, that value is bound to `_hops`, while + `_ber` and `_rssi` use their default zero values. +- `routerHBP.sendDataToOBP()` ignores the `_hops` argument and resets + `_source_server` and `_source_rptr` internally, so the current extra + positional argument is effectively confusing but harmless. The BER/RSSI loss + is the observable issue. +- Deterministic probe with inbound HBP unit data carrying `ber=b'B'` and + `rssi=b'R'` captured an OBP send call with `ber=b'\x00'` and + `rssi=b'\x00'`. + +### Assumptions To Validate + +- For normal FBP/OpenBridge peer forwarding, HBP-originated unit-data packets + should preserve inbound BER/RSSI metadata just like HBP-originated group/voice + packets do. +- Passing BER/RSSI into `send_system()` is protocol-version safe because + `OPENBRIDGE.send_system()` decides what fields are encoded based on the target + session protocol version. +- DATA-GATEWAY remains protocol-version-specific. Passing BER/RSSI is harmless + for a v1 DATA-GATEWAY target because v1 packet construction will not encode + the FBP metadata fields. + +### Unresolved Questions + +- Should the fix touch both HBP unit-data call sites, including DATA-GATEWAY, or + only the normal FBP peer forwarding call? +- Should `routerHBP.sendDataToOBP()` drop the unused `_hops` parameter or should + we keep the signature stable and use keyword arguments at the call sites? + +### Protocol-Sensitive Areas + +- This is transport/session metadata, not DMR payload mutation. +- Protocol option order must continue to match the protocol version actually + used by the target OpenBridge session. +- DATA-GATEWAY is not FBP by design; changes must not reinterpret it as FBP. + +### Inferred Invariants + +- DMR payload bytes must be preserved unless intentionally rewritten. +- HBP BER/RSSI metadata should not be silently zeroed when forwarding to a + protocol version that can carry it. +- Positional metadata arguments are fragile in these paths; keyword arguments + are safer for reviewable fixes. + +### Validation Result + +- User confirmed this is intentional future consistency work that was not + completed in the data path. +- Severity is low because BER/RSSI metadata is not central to data routing, but + preserving it should be done for consistency with the voice/group path. +- User confirmed DATA-GATEWAY may or may not be obsolete, but should remain in + place for now. +- Updated the HBP unit-data DATA-GATEWAY and normal OpenBridge/FBP call sites to + pass `_ber` and `_rssi` as keyword arguments, avoiding the previous positional + `_source_rptr` confusion. +- Added deterministic coverage proving HBP unit data with nonzero BER/RSSI is + forwarded to an OpenBridge target with that send metadata preserved while the + captured DMRD packet bytes remain metadata-free at the deterministic capture + point. + +## HBP Group/VCSBK Rate Drop Zero-Division + +### Findings + +- `routerHBP.dmrd_received()` handles group and VCSBK packets on a per-slot + stream state. +- On a new group/VCSBK stream it sets `self.STATUS[_slot]['RX_START'] = + pkt_time`. +- Later in the same branch, before duplicate/out-of-order filtering, the packet + rate guard checks: + `self.STATUS[_slot]['packets'] / (pkt_time - self.STATUS[_slot]['RX_START'])`. +- If many packets are processed with the same `pkt_time` as the stream start, + the elapsed duration is zero and this branch raises `ZeroDivisionError`. +- Deterministic probe: injecting 19 same-timestamp HBP group data headers on the + same stream raises `ZeroDivisionError: division by zero`. +- The final call-end packet-rate logging path already guards zero elapsed + duration before dividing, and the earlier OBP loop-control diagnostic path was + already fixed to do the same. + +### Assumptions To Validate + +- The HBP packet-rate guard is intended to protect FreeDMR from excessive packet + rate, including accidental loop/flood conditions, not to crash when packets + arrive inside one timestamp quantum. +- If elapsed duration is zero, rate cannot be evaluated safely; the packet + should continue through existing duplicate/drop logic rather than rate-drop + solely from an undefined calculation. +- The existing HBP rate-drop policy should remain unchanged when elapsed + duration is nonzero: more than 18 packets and more than 25 packets/second + logs `RATE DROP`, updates `LAST`, and returns. + +### Unresolved Questions + +- Should extremely small nonzero durations be clamped to a minimum interval, or + is a simple zero guard sufficient for now? + +### Protocol-Sensitive Areas + +- This is local overload protection, not DMR payload mutation. +- HBP traffic has a practical slot-cadence limit, but test harnesses and bursty + runtime scheduling can still produce same-timestamp packet processing. + +### Inferred Invariants + +- Packet-rate calculations must never divide by zero. +- Existing HBP rate-drop behavior should be preserved for measurable elapsed + durations. +- Duplicate/out-of-order filtering should still run when rate cannot be + computed safely. + +### Validation Result + +- User approved the zero-duration guard. +- Added a `call_duration` guard to the HBP group/VCSBK rate-drop calculation. +- Added deterministic coverage for 19 same-timestamp HBP group data headers on + one stream; the path no longer raises and stream state remains intact. + +## OBP Unit Data To FBP v5+ Drops Source Repeater Metadata + +### Findings + +- `routerOBP.sendDataToOBP()` accepts `_source_rptr` and passes it to the target + OpenBridge `send_system()` call: + `systems[_target].send_system(..., _hops, _ber, _rssi, _source_server, + _source_rptr)`. +- `routerOBP.dmrd_received()` receives `_source_rptr` as decoded metadata from + `OPENBRIDGE.datagramReceived()` only for FBP protocol versions above 4. +- `OPENBRIDGE.send_system()` serializes source server for FBP v4 and above, but + serializes source repeater only when the target OpenBridge session version is + above 4. +- The normal OBP-to-OBP unit-data forwarding call for protocol versions greater + than 1 passes `_hops`, `_source_server`, `_ber`, and `_rssi`, but omits + `_source_rptr`. +- Source server is preserved by this path. The observed loss is source repeater + metadata for target sessions whose version can carry it. +- For target sessions with version 4 or lower, passing `_source_rptr` would not + change serialized packets because `OPENBRIDGE.send_system()` does not encode + source repeater for those versions. +- Deterministic probe: OBP-1 unit data to OBP-2 with + `hops=b'\x05'`, source server `7654321`, BER `b'B'`, RSSI `b'R'`, and source + repeater `1234567` captured the OBP-2 send metadata as: + hops preserved, source server preserved, BER/RSSI preserved, but + `source_rptr=b'\x00\x00\x00\x00'`. + +### Assumptions To Validate + +- For normal FBP/OpenBridge peer forwarding, OBP-originated unit-data packets + should preserve `_source_rptr` only when the target session protocol version + supports it. Passing it into `send_system()` is expected to be safe because + `send_system()` already gates serialization by target `VER`. +- This is distinct from the DATA-GATEWAY path. DATA-GATEWAY remains a + protocol-v1 SMS/GPS path and should not be evaluated as FBP metadata + forwarding. +- Using keyword arguments for the normal OBP-to-OBP unit-data forwarding call is + the smallest safe fix and avoids repeating positional metadata mistakes. + +### Unresolved Questions + +- Should DATA-GATEWAY remain unchanged exactly as currently implemented, despite + its positional argument oddity, until a separate protocol-v1 review is done? + +### Protocol-Sensitive Areas + +- Source repeater is transport/session metadata, not DMR payload mutation. +- Protocol options and order must match the protocol version in use for the + target session. +- FBP metadata preservation should not imply DATA-GATEWAY is FBP. +- OBP v1 and lower FBP versions do not support source repeater metadata. + +### Inferred Invariants + +- Normal FBP v5+ forwarding should preserve source repeater metadata it receives + unless production code intentionally rewrites it. +- Source server preservation is already present in this unit-data path. +- DATA-GATEWAY protocol-v1 behavior should remain isolated from FBP metadata + expectations. + +### Validation Result + +- User confirmed this makes sense for unit data only. +- Updated the normal OBP-to-OBP unit-data forwarding call to pass + `_source_rptr` through to `routerOBP.sendDataToOBP()`. +- Left DATA-GATEWAY unchanged. +- Added deterministic coverage proving OBP-originated unit data forwarded to + another FBP peer preserves source server, source repeater, hops, BER and RSSI + send metadata. + +## OpenBridge DMRE Parser Accepts Truncated Packets Into Indexing + +### Findings + +- `OPENBRIDGE.datagramReceived()` handles `DMRE` packets by reading + `_packet[55]` to choose the embedded protocol layout. +- It then indexes fixed offsets for v5+ (`_packet[72]`, `_packet[73:89]`) or + v4 (`_packet[68]`, `_packet[69:85]`) without first checking that the datagram + is long enough for that layout. +- Deterministic parser-seam probe: injecting `b"DMRE"` into an OpenBridge system + raises `IndexError: index out of range` at `_packet[55]`. +- A truncated `DMRE` packet long enough to contain byte 55 but shorter than the + full v5+ or v4 layout also raises `IndexError` when later fixed offsets are + accessed. +- Truncated `DMRD` v1 packets do not raise in the same simple probe because the + code computes an HMAC over the available bytes and rejects them before + indexing decoded fields. The observed crash is specific to `DMRE` parsing. + +### Assumptions To Validate + +- Malformed/truncated OpenBridge/FBP UDP packets should be logged and discarded, + not allowed to raise out of `datagramReceived()`. +- The correct minimum lengths are the layouts already implied by + `OPENBRIDGE.send_system()`: 89 bytes for v5+ `DMRE`, 85 bytes for v4 `DMRE`. +- This should be implemented as a parser guard only; it should not change + routing, metadata order, HMAC/BLAKE2 verification, or packet mutation for + valid packets. + +### Unresolved Questions + +- Should short-packet logs be warning-level like existing HMAC failures, or + debug-level to avoid noisy logs under scanning/flood attempts? + +### Protocol-Sensitive Areas + +- This is the raw UDP/parser seam in `hblink.py`, before `bridge_master.py` + decoded packet handling. +- Length guards must respect the active embedded protocol version. OBP v1, + FBP v4, and FBP v5+ have different packet layouts. + +### Inferred Invariants + +- Parser code should validate minimum length before fixed-offset indexing. +- Invalid transport packets should be rejected before decoded packet handling. +- Valid packet bytes and metadata must remain unchanged. + +### Validation Result + +- User approved adding parser guards. +- Added `DMRE` length checks in `OPENBRIDGE.datagramReceived()` before reading + byte 55, and before reading the fixed v5+ or v4 metadata offsets. +- Short `DMRE` packets are logged and discarded without reaching decoded packet + handling. +- Added deterministic parser-seam coverage for packets shorter than the version + byte and packets truncated after the version byte. + +## HBP Master DMRD Parser Accepts Truncated Packets Into Indexing + +### Findings + +- `HBSYSTEM.master_datagramReceived()` handles `DMRD` packets by first slicing + `_peer_id = _data[11:15]` and validating that the peer is connected and the + source socket matches the tracked peer. +- After that peer/socket check passes, it indexes fixed fields: + `_seq = _data[4]`, `_bits = _data[15]`, and `_stream_id = _data[16:20]`. +- There is no minimum length check before those fixed-offset indexes. +- Deterministic parser-seam probe: register peer `1001` on `MASTER-A`, then + inject a 15-byte `DMRD` packet containing the matching peer ID at bytes + 11..14. The parser raises `IndexError: index out of range` at `_data[15]`. +- This is not an arbitrary unauthenticated packet path in the reproduced case; + the packet must pass the tracked peer/socket check. + +### Assumptions To Validate + +- Malformed/truncated HBP `DMRD` packets from a connected peer should be logged + and discarded, not allowed to raise out of `master_datagramReceived()`. +- The minimum safe length for HBP `DMRD` parser indexing is 20 bytes, because + the parser reads through `_data[16:20]` and decoded packet handling expects + the 53-byte DMRD header/payload plus optional BER/RSSI later. +- A conservative 20-byte parser guard is enough to prevent fixed-offset parser + exceptions while preserving existing behavior for packets that reach decoded + packet handling. + +### Unresolved Questions + +- Should malformed-packet logging be rate-limited later if noisy connected peers + send repeated short packets? + +### Protocol-Sensitive Areas + +- This is HBP UDP parser admission, before `bridge_master.py` decoded packet + routing. +- Rejecting malformed packets must not change valid packet bytes, peer tracking + or authentication behavior. + +### Inferred Invariants + +- Parser code should validate minimum length before fixed-offset indexing. +- Invalid transport packets from connected peers should be rejected before + decoded packet handling. + +### Validation Result + +- User approved the parser guard. +- Added a 53-byte minimum length check for HBP `DMRD` packets in both + `master_datagramReceived()` and `peer_datagramReceived()`. +- Short HBP `DMRD` packets are logged and discarded before peer validation + proceeds into fixed-offset packet parsing. +- Added deterministic parser-seam coverage for a short `DMRD` packet from a + connected/tracked master peer. + +## OpenBridge BCST STUN Receiver Hashes The Wrong Bytes + +### Findings + +- `OPENBRIDGE.send_bcst()` sends a Bridge Control STUN packet as: + `BCST + HMAC_SHA1(passphrase, BCST)`. +- `OPENBRIDGE.datagramReceived()` receives `BCST` by setting + `_hash = _packet[4:]`, then calculating + `HMAC_SHA1(passphrase, _packet[4:])`. +- That means the receiver compares `HMAC(BCST)` with `HMAC(HMAC(BCST))`, so a + valid packet generated by `send_bcst()` is rejected. +- Deterministic parser-seam probe: build `BCST + HMAC(passphrase, BCST)` and + inject it into an enhanced OpenBridge system. The packet logs + `BCST invalid STUN` and does not set `_STUN`. +- If the current receive check ever did pass, the trace log references `_tgid` + and `_stream_id`, but `BCST` does not define those variables in that branch. + The practical observed bug is the hash mismatch, which prevents the success + branch. + +### Assumptions To Validate + +- `BCST` receive validation should mirror `send_bcst()` and verify + `HMAC_SHA1(passphrase, BCST)`. +- A valid `BCST` should set the same stun state that the current success branch + intends to set. +- The success trace log should not reference TGID or stream ID because `BCST` + carries only the opcode plus HMAC. + +### Unresolved Questions + +- STUN currently has no observed timeout/clear path. It remains a conceptual + temporary quench mechanism and should be reviewed separately before relying on + it operationally. + +### Protocol-Sensitive Areas + +- This is enhanced OpenBridge bridge-control traffic, not DMR payload routing. +- The hash input must match the sender and must not accidentally change other + Bridge Control opcodes (`BCKA`, `BCSQ`, `BCVE`). + +### Inferred Invariants + +- Bridge Control receive validation should hash the same byte sequence the send + helper signs. +- STUN packets do not carry TGID or stream ID fields. +- A valid STUN request should set the `STUN` flag that existing OpenBridge + traffic gates already check. + +### Validation Result + +- User confirmed STUN was conceptual but requested making it internally + consistent. Intended concept: one server can tell another to temporarily stop + sending any FBP traffic. +- Updated `BCST` receive validation to verify `HMAC_SHA1(passphrase, BCST)`, + matching `send_bcst()`. +- Updated the success branch to log without TGID/stream ID fields. +- Updated the success branch to set `self._CONFIG['STUN'] = True`, matching the + existing send/receive stun gates that check for `STUN` in the global config. +- Added deterministic parser-seam coverage proving a valid generated `BCST` + sets the global `STUN` flag and no longer writes the unused `_STUN` key. + +## OpenBridge BCSQ Target TGID Key Mismatch + +### Findings +- `hblink.py`, `OPENBRIDGE.datagramReceived()`, `BCSQ` branch stores received + source-quench state in the OpenBridge peer system config as: + `_config['_bcsq'][_tgid] = _stream_id`. +- `bridge_master.py`, `routerOBP.to_target()`, checks that quench map with the + source packet destination `_dst_id`: + `(_dst_id in _target_system['_bcsq'])` and + `(_target_system['_bcsq'][_dst_id] == _stream_id)`. +- `bridge_master.py`, `routerHBP.to_target()`, uses inconsistent keys in the + equivalent check: + `(_dst_id in _target_system['_bcsq'])` but then indexes + `_target_system['_bcsq'][_target['TGID']]`. +- For same-TG bridge rules, `_dst_id == _target['TGID']`, so the bug is masked. + For cross-TG bridge rules, the membership test and lookup can disagree. If + `_dst_id` is present but `_target['TGID']` is not, the code can raise + `KeyError`; if only `_target['TGID']` is present, source quench is not applied. + +### Assumptions +- BCSQ is intended to suppress further forwarding of a specific stream to the + OpenBridge system that sent the quench. +- User clarified that the only FreeDMR talkgroup rewrite is for dial-a-TG. In + that case, the HBP-side source is local TG9/TS2 and the OpenBridge target sees + the selected reflector TG. +- User clarified BCSQ semantics: source quench asks a peer to stop sending any + more packets for a given stream ID on a given TG. It is optional; failure to + quench is not fatal and mainly costs a small amount of bandwidth/processing. +- Therefore, the severity is lower than a routing/drop bug. The important + correctness property is that any local BCSQ check must compare the stream ID + and TGID in the same namespace as the BCSQ sender intended. + +### Unresolved Questions +- Should the stream-trimmer cleanup remove BCSQ entries only after the matching + local stream expires, or should received BCSQ entries also have an independent + expiry? Current cleanup removes entries whose stored stream ID matches a + removed local OpenBridge stream. + +### Protocol-Sensitive Areas +- `BCSQ` packet format is `BCSQ + TGID(3) + stream_id(4) + HMAC`. +- BCSQ semantics interact with dial-a-TG TG rewrite logic. The optional quench + may be ineffective if FreeDMR stores a quench under one TG namespace and + checks it under another. + +### Inferred Invariants +- Source quench state must be checked with the same TGID namespace it is stored + under. +- Source quench is an optimization/control hint, not a required condition for + correctness of stream routing. +- A BCSQ match should drop forwarding only for the matching TGID and stream ID, + not all traffic to that OpenBridge target. + +### Resolution +- User confirmed that for dial-a-TG, the BCSQ TGID should be the reflector TG, + not local TG9. +- Updated `bridge_master.py`, `routerHBP.to_target()`, so HBP-to-OpenBridge + source-quench checks test and index `_target_system['_bcsq']` with + `_target['TGID']`. +- Added deterministic dial-a-TG coverage proving a TG9/TS2 HBP reflector stream + is not forwarded to an FBP target when that target has source-quenched the + reflector TG and stream ID. + +## OpenBridge STUN Has No Clear Or Expiry Path + +### Findings +- `hblink.py`, `OPENBRIDGE.datagramReceived()`, `BCST` receive branch sets the + global config flag `self._CONFIG['STUN'] = True`. +- `hblink.py`, `OPENBRIDGE.send_system()`, `OPENBRIDGE.datagramReceived()` v1 + `DMRD` path, and `OPENBRIDGE.datagramReceived()` `DMRE` path all gate traffic + with `if 'STUN' in self._CONFIG`. +- Repository-wide search finds no code path that removes `STUN`, changes it + back to false, or expires it by time. +- `BCST` has no duration field in the current packet format: + `BCST + HMAC_SHA1(passphrase, BCST)`. +- Current behavior after a valid `BCST` is therefore effectively permanent + until process restart or external mutation of the in-memory config. + +### Assumptions To Validate +- User previously described the intended concept as one server asking another to + temporarily stop sending any FBP traffic. +- User clarified the likely intended design: a sysop/API operation would + un-stun the link. In that design, lack of automatic expiry is not necessarily + a bug; the missing piece is the operator/API clear path. +- Because `BCST` has no duration field, automatic expiry would be a local policy + change rather than protocol behavior. +- This should remain a global FBP traffic gate, not per-TG or per-stream; BCSQ + already covers the per-TG/per-stream source-quench case. + +### Unresolved Questions +- Should the eventual sysop/API un-stun operation clear global `STUN` or a + per-link/per-peer stun state if the feature is later scoped more narrowly? +- Should STUN block only outbound FBP sends, or also inbound FBP processing? The + current gates block both outbound `send_system()` and inbound DMR packet + handling. +- Should the flag remain global across all OpenBridge systems, or should it be + scoped to the peer that sent `BCST`? The current code uses global + `self._CONFIG['STUN']`. + +### Protocol-Sensitive Areas +- `BCST` is enhanced OpenBridge bridge-control traffic and currently carries no + duration or peer identity beyond the authenticated UDP source and configured + passphrase. +- Changing the traffic gate from a boolean/existence check to a timestamp must + preserve the current "block all FBP traffic while active" behavior if that is + confirmed. + +### Inferred Invariants +- STUN is distinct from BCSQ: STUN is all FBP traffic, BCSQ is one stream on one + TG. +- Valid `BCST` should never mutate DMR payload bytes. +- If STUN is operator-cleared, the missing production behavior is an explicit + management/API clear operation, not an automatic timer. + +## OBP Group Loop-Control Logs Duration As Packet Rate + +### Findings +- `bridge_master.py`, `routerOBP.dmrd_received()`, group/vcsbk loop-control + branch calculates: + `call_duration = pkt_time - self.STATUS[_stream_id]['START']`. +- It then calculates a guarded `packet_rate`: + `packet_rate = self.STATUS[_stream_id]['packets'] / call_duration`. +- The debug log string says `PACKET RATE %0.2f/s`, but the argument passed is + `call_duration`, not `packet_rate`. +- The analogous OBP unit-data loop-control branch directly above passes + `packet_rate` correctly. + +### Assumptions To Validate +- The log is intended to report packet rate, not duration, because the message + text says `PACKET RATE` and the code already computes `packet_rate`. +- This is a diagnostics-only bug. It does not affect packet routing, mutation, + source selection, BCSQ, or rate-drop enforcement. +- User clarified that data calls do not have a meaningful packet rate in this + sense because each packet is classed as its own stream; packet-rate assertions + should use voice stream fixtures, not data packet fixtures. + +### Unresolved Questions +- No protocol question identified. This is local logging correctness. + +### Protocol-Sensitive Areas +- None beyond avoiding any change to packet handling behavior. + +### Inferred Invariants +- Diagnostic logs should report the metric named by the log message. +- Fixing the argument should not change any control-flow decisions. + +### Resolution +- Updated the OBP group/vcsbk loop-control debug log to pass `packet_rate` + instead of `call_duration`. +- Added deterministic coverage that creates a two-second loop-controlled OBP + group voice stream and verifies the log reports the calculated `0.50/s` + packet rate, not the two-second duration as `2.00/s`. + +## OBP Group Data RX Log Says Call Start + +### Findings +- `bridge_master.py`, `routerOBP.dmrd_received()`, group/vcsbk new-stream branch + computes `_data_control = is_group_data_control(_call_type, _dtype_vseq)` and + reports data packets correctly with `DATA HEADER,DATA,RX`, + `VCSBK 1/2 DATA BLOCK,DATA,RX`, `VCSBK 3/4 DATA BLOCK,DATA,RX`, or + `OTHER DATA,DATA,RX`. +- The same branch always emits an info log labelled `*CALL START*` before the + report decision, even when `_data_control` is true. +- The HBP group path distinguishes group data header logging from voice start: + `_dtype_vseq == 6` logs `*DATA HEADER*`, while voice-like group packets log + `*CALL START*`. +- User clarified that data packets are packet-oriented and should not be treated + as voice streams for rate/lifecycle semantics. + +### Assumptions To Validate +- OBP group data logs should match the already-correct report classification and + should not say `*CALL START*` for data-control packets. +- This is diagnostics/logging only; reports, routing, packet bytes, stream state + and lifecycle suppression are already handled separately. + +### Unresolved Questions +- Should OBP group data logs include source server/hops metadata exactly like the + current `CALL START` log, or should they match the simpler HBP data-header log + shape? + +### Protocol-Sensitive Areas +- This must not change `DATA_STREAM` classification, reporting events, packet + forwarding, or LC construction. + +### Inferred Invariants +- Logs should not label DMR data-control packets as voice call lifecycle events. +- OBP and HBP logging should use consistent data-vs-voice terminology where the + packet classification is already known. + +### Resolution +- User confirmed the change should be made, with the caveat that dashboard + consumers may be sensitive to live report/socket event text. +- Updated the OBP group/vcsbk RX info log to use the already-computed data event + label for data-control packets and `CALL START` only for voice-like packets. +- Added deterministic coverage proving an OBP group data header logs + `*DATA HEADER*` and no longer logs `*CALL START*`; existing report-socket + assertions still verify `DATA HEADER,DATA,RX/TX` payloads. + +## Raw Parser May Not Classify VCSBK 3/4 Data Blocks + +### Findings +- `bridge_master.py` has explicit group/vcsbk report handling for + `_call_type == 'vcsbk'` and `_dtype_vseq == 8`, labelled + `VCSBK 3/4 DATA BLOCK`. +- The raw packet parsers in `hblink.py` derive `_call_type` from the DMRD bits + with: + `elif (_bits & 0x23) == 0x23: _call_type = 'vcsbk'`. +- For a data-sync packet with `_dtype_vseq == 8`, the low nibble is `0x8`; with + `HBPF_DATA_SYNC` in bits 4..5, `_bits & 0x23` evaluates to `0x20`, not + `0x23`. That means the raw parser classifies it as `group`, not `vcsbk`. +- The deterministic in-process harness can inject `call_type='vcsbk'` directly, + so current VCSBK 3/4 report tests may cover `bridge_master.py` behavior without + proving a real UDP/HBP or UDP/OBP packet can reach that branch. +- Unit data handling is not affected by this specific observation because unit + data is classified first by the unit bit and then handles dtype 8 inside the + unit-data branch. + +### Assumptions To Validate +- If FreeDMR expects group-addressed VCSBK 3/4 data blocks to arrive from real + HBP/OBP packets, the raw parser should classify the relevant bit pattern as + `vcsbk` so the existing report/routing classification can run. +- The parser predicate may have been intentionally written for only certain CSBK + or VCSBK bit patterns; changing it without confirming HomeBrew/DMR bit + semantics could misclassify normal group traffic. +- This is parser classification, not packet mutation. +- User raised an operational risk: earlier hackish data-over-OBP/FBP changes may + depend on this classification. A naive fix could affect whether data traverses + FBP or whether unknown group-addressed data creates local bridge state. + +### Unresolved Questions +- Is `_dtype_vseq == 8` genuinely reachable for group/vcsbk packets in the + HomeBrew/OpenBridge bit layout, or is the `VCSBK 3/4 DATA BLOCK` branch legacy + or only relevant to direct/internal calls? +- Should the deterministic harness include a raw-datagram parser test for VCSBK + 3/4 once the intended bit pattern is confirmed? + +### Protocol-Sensitive Areas +- This is at the HBP/OBP raw DMRD parser seam in `hblink.py`. +- Incorrectly broadening the `vcsbk` predicate could change voice/group/data + classification before packets reach `bridge_master.py`. +- Existing active bridge forwarding likely still works for both `group` and + `vcsbk` because `bridge_master.py` routes both through the group/vcsbk path. + The more sensitive behavior is automatic unknown-TG bridge creation, which is + currently restricted to `_call_type == 'group'`. + +### Inferred Invariants +- Parser-level `_call_type` classification should make production-reachable all + packet classes that `bridge_master.py` intentionally handles. +- In-process direct injection tests should not be mistaken for raw UDP parser + coverage when the question is bit-level classification. +- Any parser classification change must prove that existing data-over-FBP + traversal still works, including the local group-addressed data behavior + previously left intentional. + +### Reclassification +- User clarified that the `unit`/`group` distinction is fundamentally the DMR + addressing mode: private/unit-unit bit set, or group/TG bit unset. Data/control + subtype is orthogonal to that addressing mode. +- Therefore, changing the raw parser to classify group-addressed data/control + packets as `vcsbk` would conflate addressing mode with data subtype and could + break intentional data-over-FBP behavior. +- Leave raw parser `_call_type` classification unchanged. Data/control handling + should be derived from `_frame_type` / `_dtype_vseq` helpers while preserving + group-vs-unit addressing. + +## Group-Addressed Data Continuation Blocks Reported As Voice + +### Findings +- `bridge_master.py`, `group_data_event_name()`, treats group-addressed + `_dtype_vseq == 6` as `DATA HEADER`. +- The same helper only treats `_dtype_vseq == 7` and `_dtype_vseq == 8` as data + when `_call_type == 'vcsbk'`. +- User clarified that `_call_type == 'group'` means group/TG addressing, not + voice. A group-addressed packet can still carry DMR data/control subtypes. +- Therefore, a group-addressed data continuation block that reaches + `bridge_master.py` as `_call_type == 'group'` and `_dtype_vseq == 7` or `8` + is currently classified as voice-like by `is_group_data_control()`. +- Consequences would match the earlier group-data report bug: RX/TX reports can + use `GROUP VOICE`, timeout cleanup can emit voice lifecycle events, and logs + can say `CALL START` for a packet-oriented data block. + +### Assumptions To Validate +- Group-addressed data continuation blocks with dtype 7 or 8 should be classified + as data/control for reporting and timeout lifecycle, while preserving + `_call_type == 'group'` for TG-addressed routing and bridge creation behavior. +- Existing event labels `VCSBK 1/2 DATA BLOCK` and `VCSBK 3/4 DATA BLOCK` may be + legacy terminology. If the dashboard expects those names, changing labels could + be more disruptive than only broadening when they apply. +- This should not change raw parser classification or packet routing. + +### Unresolved Questions +- Should dtype 7/8 group-addressed data use the existing + `VCSBK 1/2 DATA BLOCK` / `VCSBK 3/4 DATA BLOCK` report labels for dashboard + compatibility, or should labels be renamed to more generic data continuation + terminology later? + +### Protocol-Sensitive Areas +- This is data-vs-voice reporting/lifecycle classification, not DMR payload + mutation. +- It must preserve the group-addressing behavior that lets data traverse + TG/FBP bridge paths. + +### Inferred Invariants +- `_call_type == 'group'` should not imply voice. +- Data/control classification should be based on `_frame_type` and + `_dtype_vseq`, without breaking group-vs-unit addressing semantics. + +### Resolution +- User approved broadening the helper-level classification. +- Updated `group_data_event_name()` so group-addressed dtype 7 and 8 packets use + the existing `VCSBK 1/2 DATA BLOCK` and `VCSBK 3/4 DATA BLOCK` data event + names. +- Raw parser `_call_type` classification remains unchanged; group-vs-unit + addressing is preserved. +- Added deterministic HBP and OBP coverage for group-addressed dtype 7/8 packets + proving they report as data and do not emit `GROUP VOICE` lifecycle events. +- Consolidated HBP known VCSBK RX reporting into the helper-driven new-stream + branch so specific VCSBK reports are still emitted once, without reintroducing + the older generic/specific duplicate report behavior. + +## Voice Path: OBP Group Stream Rate Limit Uses Absolute Start Time + +### Findings +- `bridge_master.py`, `routerOBP.dmrd_received()`, group/vcsbk stream duplicate + and rate-control branch, calculates the rate-drop predicate as + `self.STATUS[_stream_id]['packets'] / self.STATUS[_stream_id]['START'] > 25`. +- `START` is an absolute timestamp captured from `time()`, not elapsed stream + duration. In normal operation this divides by a large epoch value, so the + calculated rate is effectively zero. +- The surrounding code and earlier user clarification say this guard is intended + to stop packet floods from overwhelming FreeDMR. As written, it cannot trigger + for realistic packet counts. +- HBP group voice uses elapsed duration (`pkt_time - RX_START`) for the + comparable packet-rate guard. + +### Assumptions To Validate +- The OBP/FBP guard should measure packets per elapsed stream duration, not + packets per absolute timestamp. +- The threshold should remain the existing `> 25` for now; changing policy is + separate from fixing the calculation. +- The existing `packets > 18` warm-up should remain, so short startup bursts are + not judged before enough packets have arrived. +- The deferred question about whether `proxy_BadPeer()` is the right response to + an OBP flood remains deferred; this analysis only covers the broken rate + calculation. + +### Unresolved Questions +- Confirm whether this OBP rate-drop action should still call `proxy_BadPeer()` + after the calculation is corrected, or whether that should be revisited later + as previously deferred. + +### Protocol-Sensitive Areas +- This is transport/rate-control behavior, not DMR payload parsing or mutation. +- OBP/FBP can carry multiple arbitrary streams, so this guard is only per stream + and does not represent an overall link-rate limit. + +### Inferred Invariants +- Packet-rate protection should be based on elapsed time for the specific stream. +- Fixing the denominator should not change TG routing, slot handling, LC rewrite, + or packet bytes. + +### Resolution +- User confirmed proceeding with the narrow denominator fix. +- Updated `routerOBP.dmrd_received()` so the group/vcsbk rate-drop check computes + `call_duration = pkt_time - self.STATUS[_stream_id]['START']` and divides + packet count by elapsed duration. +- Kept the existing `> 25` threshold, `packets > 18` warm-up, and + `proxy_BadPeer()` action unchanged. +- Added deterministic OBP group voice coverage proving the rate-drop path fires + for a high-rate stream when elapsed duration is short. + +## Voice LC Rewrite Applied To Data-Sync Control Packets + +### Findings +- `bridge_master.py`, `routerOBP.to_target()`, rewrites embedded LC for any + packet where `_dtype_vseq in [1,2,3,4]`. +- `bridge_master.py`, `routerHBP.to_target()`, has the same condition in both + the OpenBridge-target and HBP-target branches. +- These branches are intended for voice bursts B-E, but they do not check + `_frame_type`. +- A deterministic probe with an HBP-originated `vcsbk` packet using + `_frame_type == HBPF_DATA_SYNC` and `_dtype_vseq == 3` captured a forwarded + HBP packet whose DMR payload bytes were changed: + `000102030405060708090a0b0c0d00c122b4b2131415161718191a1b1c1d1e1f20` + instead of the original + `000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20`. +- That mutation is the embedded-LC rewrite being applied to a data-sync/control + packet, not a voice burst. +- ETSI TS 102 361-1 distinguishes voice bursts containing embedded signalling + from data/control bursts. Search result snippets from the ETSI TS 102 361-1 + PDF describe a voice burst containing embedded signalling and separately state + that data/control bursts use data SYNC or RC signalling rather than embedded + LC. + +### Assumptions To Validate +- Embedded LC rewrite should only apply to real voice burst frames, not + data-sync/control packets. +- Voice header and voice terminator rewrite remain governed by + `_frame_type == HBPF_DATA_SYNC` plus `_dtype_vseq == HBPF_SLT_VHEAD` or + `HBPF_SLT_VTERM`, because those are voice call signalling bursts. +- VCSBK/CSBK/data-control packets should preserve their DMR payload bytes while + still allowing transport/header fields such as target TG and slot bit to be + rewritten by bridge routing when that is already intentional. + +### Unresolved Questions +- Future work: evaluate carrying source embedded LC to the destination instead + of always regenerating it. Embedded LC may carry embedded GPS and talker alias + data, which FreeDMR does not currently relay. + +### Protocol-Sensitive Areas +- This is a packet mutation bug at the boundary between voice LC rewrite and + data/control forwarding. +- The fix must not disable intended TG/LC rewrite for voice calls that are mapped + from one bridge TG to another. +- It must preserve the previously discussed data-over-FBP behavior and group-vs- + unit addressing classification. + +### Inferred Invariants +- Data/control payload bytes are not embedded-LC containers and should not be + modified by voice LC rewrite code. +- Voice LC rewrite should be gated by both frame class and dtype/voice sequence. + +### Resolution +- User confirmed embedded-LC rewrite should not apply to data-sync/control + packets. +- `mk_voice.py` shows generated voice uses `HBPF_VOICE_SYNC` for burst A with + vseq `0`, and `HBPF_VOICE` for bursts B-F; embedded LC is inserted only for + bursts B-E, vseq `1..4`. +- Updated all four embedded-LC rewrite branches in `routerOBP.to_target()` and + `routerHBP.to_target()` to require `_frame_type == HBPF_VOICE` and + `_dtype_vseq in [1,2,3,4]`. +- Added deterministic payload-preservation coverage for data-sync/control + packets across HBP-to-HBP, HBP-to-FBP, FBP-to-HBP and FBP-to-FBP forwarding. + +## Private Unit Voice Timeout Reported As Group Voice + +### Findings +- `bridge_master.py`, `routerHBP.dmrd_received()`, private AMI voice branch and + private dial-a-TG branch update slot RX state (`RX_TYPE`, `RX_TGID`, + `RX_TIME`, `RX_STREAM_ID`) for unit/private calls. +- Those private branches set `self.STATUS[_slot]['VOICE_STREAM'] = _voice_call`, + but `_voice_call` is initialized to `False` and is never set to `True`. +- `VOICE_STREAM` is not initialized in `routerHBP.__init__()` and is not read by + `stream_trimmer_loop()`. +- `stream_trimmer_loop()` emits `GROUP VOICE,END,RX` for any HBP slot whose + `RX_TYPE` is not terminator and whose `RX_DATA_STREAM` flag is false. +- Therefore a private dial-a-TG/AMI unit voice packet that does not receive a + terminator can be timed out as `GROUP VOICE,END,RX`, even though there was no + matching `GROUP VOICE,START,RX`. +- Deterministic probe: after 100 seconds of idle time, inject a private unit + voice call to `235`, advance 6 seconds, and run `stream_trimmer_loop()`. The + report event is: + `GROUP VOICE,END,RX,MASTER-A,16909060,1001,3120001,1,235,100.00`. +- The duration is also wrong for the private call because private branches do not + set `RX_START` on new private streams; the timeout report used the slot's old + initialization/start value. + +### Assumptions To Validate +- Dial-a-TG and AMI private unit voice calls should not emit `GROUP VOICE` + lifecycle reports. +- HBP timeout reporting should emit `GROUP VOICE,END,RX` only for RX state that + originated from a group voice stream that would have emitted + `GROUP VOICE,START,RX`. +- Private unit calls may still mark the RF slot busy until timeout/terminator; + the issue is lifecycle reporting and duration classification, not slot + occupancy. + +### Unresolved Questions +- Should private unit voice timeout produce a different report event, or should + it simply suppress `GROUP VOICE` timeout reporting? +- Should the existing `VOICE_STREAM` key be completed and used as the explicit + trimmer gate, or should a clearer `RX_VOICE_STREAM` / `RX_GROUP_VOICE_STREAM` + key be introduced? + +### Protocol-Sensitive Areas +- This is report/lifecycle classification for HBP slot state, not packet + mutation. +- Dial-a-TG private control behavior and voice prompts should remain unchanged. + +### Inferred Invariants +- `GROUP VOICE,END,RX` should correspond to an actual group voice RX lifecycle. +- Timeout cleanup should not infer group voice from `RX_TYPE != VTERM` alone. + +### Resolution +- User approved correcting this even if the old behavior was a dashboard + workaround. +- Added explicit HBP slot state `RX_GROUP_VOICE_STREAM`, initialized false. +- Private AMI and dial-a-TG unit voice branches now mark + `RX_GROUP_VOICE_STREAM` false when they update slot RX state. +- The group/vcsbk branch marks `RX_GROUP_VOICE_STREAM` true only when the packet + is not data/control. +- `stream_trimmer_loop()` now emits `GROUP VOICE,END,RX` only when + `RX_GROUP_VOICE_STREAM` is true, then clears the flag with the timeout cleanup. +- Added deterministic coverage proving a private dial-a-TG unit voice timeout no + longer emits an unmatched `GROUP VOICE,END,RX` report. + +## HBP-To-HBP Target TX State Uses Source RX Stream Predicate + +### Findings +- `bridge_master.py`, `routerHBP.to_target()`, HBP-target branch, initializes + target TX state when `_stream_id != self.STATUS[_slot]['RX_STREAM_ID']`. +- That predicate describes whether the source HBP slot sees a new RX stream, not + whether the target HBP slot needs a new TX stream. +- The analogous `routerOBP.to_target()` HBP-target branch correctly checks the + target slot: `_target_status[_target['TS']]['TX_STREAM_ID'] != _stream_id`. +- Deterministic reproduction: + - MASTER-A sends stream `01020304` on TG123 to MASTER-B. + - After `STREAM_TO`, MASTER-C sends stream `01020305` on TG123 to MASTER-B. + - After another gap, MASTER-A sends another packet on stream `01020304`. + - The forwarded packet to MASTER-B carries stream `01020304`, but + MASTER-B's `TX_STREAM_ID` and `TX_RFS` remain `01020305` / MASTER-C. + - For a voice burst B-E packet, the forwarded DMR payload is rewritten using + MASTER-B's stale `TX_EMB_LC` generated for MASTER-C, not MASTER-A. +- This can create stale target lifecycle state, stale TX reports/durations, and + wrong embedded LC source information when a target slot has moved to another + stream while the original source later resumes the same stream id. + +### Validated Assumptions +- User clarified this interleaved/resumed-stream scenario should never happen in + normal DMR operation. DMR channel access and hang-time mechanisms should + prevent an older stream from being resumed after a different stream has + interspersed on the same target path. +- Stream IDs are generated locally from inbound call data and are not expected to + be reused in this interleaved way. + +### Unresolved Questions +- None for now. Leave this path unchanged unless a real fixture or live trace + proves valid stream interleaving occurs. + +### Protocol-Sensitive Areas +- This affects target-side stream state and LC rewrite metadata for HBP targets. +- It does not change routing eligibility or packet source/destination header + fields. + +### Inferred Invariants +- Target TX state should describe the packet currently being sent to that target. +- Source RX stream state and target TX stream state are related but independent. +- LC rewrite state must be regenerated when the target TX stream id changes. + +### Resolution +- No runtime change. Treat the deterministic reproduction as an invalid scenario, + not a confirmed production bug. +- Keep the note as a protocol-sensitive area to revisit only if recorded traffic + shows valid resumed/interleaved stream behavior. + +## OBP Target Timeout Reports Wrong Direction And TG After Dial-A-TG Rewrite + +### Findings +- `bridge_master.py`, `routerHBP.to_target()`, OpenBridge-target branch creates + target stream state with `TGID: _dst_id`, even though the outbound packet and + `GROUP VOICE,START,TX` report use `_target['TGID']`. +- `bridge_master.py`, `routerOBP.to_target()`, OpenBridge-target branch does the + same for OBP-to-OBP targets. +- `stream_trimmer_loop()` handles all OpenBridge stream timeouts as + `GROUP VOICE,END,RX` and reports `_stream['TGID']`. +- Deterministic reproduction with dial-a-TG: + - Private call links reflector `#235`. + - HBP source sends group voice on local TS2 TG9, which is forwarded to FBP + target TG235. + - OBP target reports start as: + `GROUP VOICE,START,TX,OBP-1,16909060,1001,3120001,1,235`. + - If no terminator arrives and the OBP target stream times out, the report is: + `GROUP VOICE,END,RX,OBP-1,16909060,1001,3120001,1,9,0.00`. +- The timeout event is wrong in two ways for the target-side stream: direction is + `RX` instead of `TX`, and TG is local TG9 instead of the reflector TG235 that + was sent to the FBP peer. +- Immediate terminator handling in `to_target()` already emits + `GROUP VOICE,END,TX` with `_target['TGID']`; the mismatch only affects timeout + cleanup when no terminator is observed. + +### Validated Assumptions +- User clarified that the report side intentionally reports HBP/RF-side TG9 for + dial-a-TG reflector traffic so the repeater dashboard shows TG9 active to RF. +- The FBP-visible reflector TG is not necessarily the desired TG for this + dashboard report path. + +### Unresolved Questions +- There remains broader inconsistency between immediate terminator TX reports and + timeout RX reports for target-side FBP streams, but this is not currently + treated as a bug because the dashboard policy is intentional. + +### Protocol-Sensitive Areas +- This is report/lifecycle state for OpenBridge/FBP target streams, not packet + routing or DMR payload mutation. +- BCSQ/loop-control logic also reads OpenBridge stream `TGID`; changing that key + directly could affect source-quench behavior. A separate report TG field may be + safer. + +### Inferred Invariants +- For dial-a-TG dashboard reporting, RF-side TG9 activity is a first-class + observability requirement. + +### Resolution +- No runtime change. Treat this as intentional reporting policy for dashboard + compatibility rather than a confirmed bug. + +## Generated Voice First Packet Does Not Mark HBP TX Activity + +### Findings +- `bridge_master.py`, `sendVoicePacket()`, handles generated voice packets for + dial-a-TG prompts, disconnected announcements, on-demand AMBE files and voice + idents. +- On the first packet of a generated stream, it creates + `systems[system].STATUS[_stream_id]` and sets `_slot['TX_TGID']`, but it does + not update `_slot['TX_TIME']`, `_slot['TX_TYPE']`, `_slot['TX_STREAM_ID']`, + `_slot['TX_START']`, or `_slot['TX_RFS']`. +- `_slot['TX_TIME']` is only updated in the `else` branch for subsequent packets. +- Deterministic probe after 100 seconds idle: + - Before first generated packet, TS2 state was + `TX_TYPE=HBPF_SLT_VTERM`, `TX_TIME=1700000000.0`, + `TX_STREAM_ID=b'\\x00'`, `TX_TGID=0`. + - After first generated packet, state was still + `TX_TYPE=HBPF_SLT_VTERM`, `TX_TIME=1700000000.0`, + `TX_STREAM_ID=b'\\x00'`, but `TX_TGID=9`. +- Because routing and idle checks use `TX_TIME` and `TX_TYPE`, a generated prompt + can look idle at stream start. +- Second deterministic probe: after sending the first generated prompt packet on + MASTER-B TS2 TG9, an inbound group voice packet from MASTER-A to MASTER-B was + still routed immediately to MASTER-B, producing overlapping outbound traffic. + +### Assumptions To Validate +- User clarified that voice prompts should ideally be interruptible. At minimum, + after a prompt has finished, a real voice stream should not be immediately + blocked as "busy". +- Therefore, blindly updating `_slot['TX_TIME']` for generated prompt packets is + not an acceptable fix by itself because normal group-hangtime checks may treat + the prompt as recent TX activity and block post-prompt routing. +- Generated prompts should be treated as low-priority local traffic relative to + real RF/network voice. + +### Unresolved Questions +- Should generated prompts/idents be represented as full HBP TX voice lifecycle + state for dashboard/report purposes, or should they only update `TX_TIME` to + protect the RF slot without creating extra report events? +- What exact interruption trigger should stop a prompt: any RX activity on that + HBP system/slot, any TX routing state change on that slot, or only a higher + priority group/private voice stream? + +### Protocol-Sensitive Areas +- This is generated local voice scheduling/state, not inbound packet parsing. +- It affects collision avoidance between generated prompts and bridged traffic. + +### Inferred Invariants +- A locally generated voice prompt must not permanently or hangtime-block + subsequent real voice traffic. +- Prompt collision avoidance should prefer stopping the prompt over blocking RF + or bridge traffic where practical. +- Report visibility for generated prompts is separate from collision avoidance. + +### Late Entry Sanity Check + +#### Findings +- ETSI TS 102 361-1 describes DMR late entry by carrying LC information in the + embedded field of voice bursts; a six-burst voice superframe uses one sync + burst, four embedded-signalling bursts, and one null/other embedded burst. +- `mk_voice.py` builds generated voice with `bptc.encode_emblc(LC)` and places + the LC fragments into bursts B-E. Burst A carries voice sync; burst F carries + null embedded LC. +- `bridge_master.py` builds `EMB_LC` / `TX_EMB_LC` for target streams and + rewrites only `HBPF_VOICE` bursts with vseq 1-4, corresponding to embedded LC + fragments B-E. The earlier guard now avoids mutating data-sync/control + payloads. +- Therefore, if a real voice stream interrupts a generated prompt and is then + forwarded through the normal routing path, receivers that missed the original + voice header should be able to late-enter once they receive a complete + embedded-LC sequence. + +#### Assumptions +- Generated prompts are local low-priority streams and may be cancelled in favor + of real RF/network voice. +- Interrupting a prompt should resume normal bridge routing for the real stream; + it should not synthesize replacement voice frames outside the existing + header/embedded-LC machinery. + +#### Unresolved Questions +- Whether prompt cancellation should send a terminator for the abandoned prompt + before real traffic starts. This may be cleaner semantically, but it could also + delay the higher-priority stream and defeat interruption. +- Whether generated prompt lifecycle state should be visible to dashboards as a + full TX stream, independent of collision avoidance. + +#### Protocol-Sensitive Areas +- Late-entry behavior depends on preserving or correctly regenerating embedded LC + over bursts B-E. Any prompt-interruption fix must not remove the current + `EMB_LC` / `TX_EMB_LC` handling. +- If the first forwarded real packet is mid-superframe, receiving radios may + need to wait until the next complete embedded LC set before identifying the + call. This is expected late-entry behavior, not necessarily a bridge bug. + +#### Inferred Invariants +- Real voice streams must remain the source of truth for LC/header state after a + prompt is cancelled. +- Prompt cancellation should stop local generated packet production; the + following real stream should use existing voice header and late-entry LC + processing. + +### Resolution +- Added explicit generated-prompt state to HBP slot status: + `TX_PROMPT_ACTIVE`, `TX_PROMPT_CANCEL`, `TX_PROMPT_TOKEN`, + `TX_PROMPT_TIME`, `TX_PROMPT_STREAM_ID`, `TX_PROMPT_TGID` and + `TX_PROMPT_RFS`. +- `sendVoicePacket()` now records prompt activity from the first generated + packet without driving normal group-hangtime fields such as `TX_TIME` / + `TX_TYPE`. +- `sendSpeech()`, `disconnectedVoice()` and `playFileOnRequest()` now use a + per-slot prompt token and stop scheduling generated packets once the slot is + cancelled or taken over. +- HBP real group voice now cancels generated prompt state on the source slot for + local RX and on HBP target slots when a real voice stream is about to be + routed. Data/control packets do not cancel prompts. +- The real stream remains on the existing voice routing path, so voice headers + and B-E embedded LC rewrites continue to provide DMR late-entry behavior. +- Added deterministic tests for first-packet prompt state, prompt cancellation in + the speech loop, and real HBP voice cancelling a prompt while preserving + late-entry embedded LC rewrite. + +## OBP Finished Voice Stream Depends On Reporting Being Enabled + +### Findings +- `bridge_master.py`, `routerOBP.dmrd_received()`, has explicit finished-stream + handling: if `'_fin' in self.STATUS[_stream_id]`, later packets for the same + stream are ignored. +- The OBP voice terminator branch sets `self.STATUS[_stream_id]['_fin'] = True` + only inside `if CONFIG['REPORTS']['REPORT'] and not DATA_STREAM`. +- Deterministic probe: + - With `REPORT=False`, inject OBP voice header, terminator, then another voice + burst with the same stream ID. The HBP target capture count increases from 2 + to 3, and `_fin` is absent. + - With `REPORT=True`, the same late packet is ignored; target capture remains + at 2, and `_fin` is present. + +### Assumptions To Validate +- OBP stream lifecycle state should not depend on whether dashboard/live report + output is enabled. +- A voice terminator should mark the OBP stream finished for local loop-control + and late-packet suppression regardless of report configuration. +- The report event itself should remain gated by `CONFIG['REPORTS']['REPORT']`. + +### Unresolved Questions +- Whether data/control packets can ever legitimately use `HBPF_SLT_VTERM` on the + OBP group path. Current code treats the branch as "voice terminator", so the + minimal fix should preserve the existing `not DATA_STREAM` guard for `_fin`. + +### Protocol-Sensitive Areas +- This is stream lifecycle/loop-control state, not packet byte mutation. +- Changing `_fin` affects whether late packets with the same stream ID are + forwarded after a terminator. + +### Inferred Invariants +- Runtime stream lifecycle must be independent from optional reporting. +- OBP finished-stream suppression is intended to be functional behavior, not a + dashboard side effect. + +### Resolution +- Moved `self.STATUS[_stream_id]['_fin'] = True` out of the report-enabled block + while keeping it guarded by `not self.STATUS[_stream_id].get('DATA_STREAM')`. +- Kept `send_bridgeEvent('GROUP VOICE,END,RX,...')` inside the reporting gate. +- Added deterministic coverage that an OBP voice terminator suppresses later + packets with the same stream ID when reporting is disabled. + +## HBP Voice Terminator Does Not Suppress Late Same-Stream Packets + +### Findings +- `bridge_master.py`, `routerHBP.dmrd_received()`, handles HBP group voice + terminators by logging/reporting call end and resetting `lastSeq` / + `lastData`, then later updates slot state with `RX_TYPE = HBPF_SLT_VTERM` and + `RX_STREAM_ID = _stream_id`. +- Unlike the OBP path, HBP slot state has no finished-stream marker. +- A later HBP voice burst with the same stream ID after the terminator is not + rejected as finished. Because `RX_STREAM_ID` still matches, it is not treated + as a new stream, and because `lastSeq` was reset, duplicate/order checks do + not stop it. +- Deterministic probe: + - Inject HBP voice header, terminator, then another voice burst with the same + stream ID. + - The HBP target capture count increases from 2 to 3, meaning the post- + terminator voice burst is routed. + - Source slot `RX_TYPE` becomes the late packet's vseq (`3`) and `lastSeq` + becomes `3`, effectively reopening activity for the terminated stream. + +### Assumptions To Validate +- A DMR voice terminator should close the voice stream for routing purposes. +- Late packets with the same stream ID after a valid voice terminator should be + suppressed, not routed or allowed to reopen slot activity. +- This should apply to voice streams only; group data/control classification + should keep its existing behavior. + +### Unresolved Questions +- Whether there are any HBP implementations that can legitimately send useful + late voice bursts after a terminator with the same stream ID. The code's OBP + finished-stream guard suggests FreeDMR already treats such packets as invalid + on peer-server links. +- How long the HBP finished marker should live. A per-slot marker can probably be + cleared when the slot's `RX_STREAM_ID` is nulled by the existing 60-second + loop-control cleanup. + +### Protocol-Sensitive Areas +- This is voice stream lifecycle and duplicate/late-packet suppression, not DMR + payload mutation. +- It affects only packets that arrive after a terminator for the same stream ID. + +### Inferred Invariants +- A voice terminator is an end-of-stream signal. +- HBP and OBP should agree that post-terminator voice for the same stream ID is + finished traffic and should not be forwarded. + +### Resolution +- Added per-slot `RX_FINISHED_STREAM_ID` and `RX_FINISHED_STREAM_LOG` state for + HBP systems. +- HBP voice terminators now mark the finished stream ID when the stream is not a + data/control stream. +- HBP group/vcsbk processing now ignores later voice-like packets whose stream + ID matches the finished marker, logging once for the suppressed late stream. +- The finished marker is cleared when the existing 60-second null-stream cleanup + clears `RX_STREAM_ID`, and when a genuine new voice stream begins. +- Added deterministic coverage proving a late HBP same-stream voice burst after + a terminator is suppressed. + +### Live RF Validation Caveat +- Some voice-path behavior in FreeDMR may have been intentionally shaped around + real repeater/radio behavior, dashboard expectations, or interoperability + quirks that are not visible in the deterministic harness. +- DMR terminals may interpret the DMR standards loosely or incompletely, + especially around data packets. FreeDMR should generally prioritize preserving + usable audio flow over rigid protocol purity when those goals conflict. +- The HBP finished-stream suppression and generated-prompt interruption changes + are protocol-defensible and covered by deterministic tests, but they still + require live RF-path testing with a real terminal, repeater and network path. +- Be prepared to revert or narrow these changes if live RF testing shows that a + repeater, radio or dashboard depends on the previous behavior. + +## OBP Target Terminator Leaves Stream Open For Timeout Report + +### Findings +- `bridge_master.py`, `routerHBP.to_target()` and `routerOBP.to_target()`, create + per-stream status entries on OpenBridge targets when forwarding group voice. +- When a voice terminator is forwarded to an OpenBridge target, the target path + rewrites the terminator LC and, if reporting is enabled, emits + `GROUP VOICE,END,TX`. +- The OpenBridge target stream is not marked `_fin` on the forwarded terminator. +- `stream_trimmer_loop()` later treats that target-side OpenBridge stream as + stale and emits `GROUP VOICE,END,RX` for the same stream. +- Deterministic probes: + - HBP -> OBP: after header + terminator, OBP target reports + `GROUP VOICE,START,TX` and `GROUP VOICE,END,TX`; after six seconds, trimmer + adds `GROUP VOICE,END,RX` for the same stream. + - OBP -> OBP shows the same duplicate `END,RX` timeout after an immediate + target `END,TX`. + +### Assumptions To Validate +- A forwarded voice terminator should close the target-side OpenBridge stream + lifecycle. +- Once an immediate target `GROUP VOICE,END,TX` has been emitted for a forwarded + terminator, cleanup should not later emit a timeout-style `GROUP VOICE,END,RX` + for the same target-side stream. +- This should be limited to non-data voice streams, matching the existing + `DATA_STREAM` guards. + +### Validated Assumptions +- User confirmed the timer exists to terminate streams where the RF signal was + lost and the terminal's terminator was also lost. +- Therefore, timeout cleanup should cover missing terminators, not streams where + FreeDMR already forwarded a terminator. + +### Protocol-Sensitive Areas +- This is target-side stream lifecycle/reporting, not packet routing or payload + mutation. +- It affects HBP -> OBP and OBP -> OBP forwarded voice terminators. + +### Inferred Invariants +- OpenBridge target streams should be marked finished when FreeDMR forwards a + voice terminator to that target. +- Timeout cleanup should report missing terminators, not duplicate already-ended + streams that had a terminator. + +### Resolution +- In both OpenBridge target branches, set `_target_status[_stream_id]['_fin'] = + True` when forwarding a non-data voice terminator. +- Kept existing `GROUP VOICE,END,TX` reporting behavior unchanged. +- Added deterministic tests for HBP -> OBP and OBP -> OBP proving the trimmer + does not emit a later duplicate `GROUP VOICE,END,RX` after a forwarded + terminator. + +## OBP Source To OBP Target BCSQ TG Namespace + +### Findings +- `bridge_master.py`, `routerOBP.to_target()`, OpenBridge target branch checks + source-quench state with `_dst_id`: + `(_dst_id in _target_system['_bcsq'])`. +- The same logical branch in `routerHBP.to_target()` checks + `_target['TGID']`, which is the TGID that will be visible to the target after + bridge rewrite. +- If an OBP source stream is bridged from one TG to a different OBP target TG, + a target BCSQ for the rewritten target TG is ignored. +- Deterministic probe: + - Bridge OBP-1 TG9 to OBP-2 TG235. + - Set OBP-2 `_bcsq` to `{235: stream_id}`. + - Inject OBP-1 stream on TG9. + - Packet still forwards to OBP-2 as TG235, proving the BCSQ check used the + wrong TG namespace. + +### Validated Assumptions +- User clarified that OBP-to-OBP should not rewrite TGID; inbound OBP TG should + equal target OBP TG. +- The only expected TG rewrite is HBP <-> OBP when dial-a-TG maps local RF TG9 + to an OBP/FBP reflector TG. +- BCSQ should use the TGID the source server sees and sends on OBP/FBP, not the + TG the client/terminal sees on RF. The quench asks the source server to stop + sending its traffic. + +### Unresolved Questions +- Whether FreeDMR should defensively prevent or normalize OBP-to-OBP bridge + entries that imply TG rewrite. That is a bridge-configuration validity question + rather than a confirmed BCSQ runtime bug. + +### Protocol-Sensitive Areas +- This is optional source-quench behavior. Failure does not corrupt packet bytes, + but it can waste bandwidth and processing by continuing to forward a quenched + stream. +- The TG namespace is source-server/OBP-visible, not RF/client-visible. + +### Inferred Invariants +- OBP-source BCSQ matching should use the inbound OBP TGID and stream ID because + that is the source server's namespace. +- HBP-to-OBP dial-a-TG BCSQ matching should use the OBP/FBP reflector TG, not RF + TG9, because that is the TGID visible to the source server on OBP/FBP. + +### Resolution +- No runtime change. The current OBP-source `_dst_id` BCSQ check is correct for + the intended OBP model where OBP-to-OBP TG rewrite does not occur. +- Treat the deterministic OBP-to-OBP rewrite probe as an invalid scenario unless + bridge validation later decides to explicitly support or reject such entries. + +## Dial-A-TG Product Rationale + +### Findings +- Dial-a-TG exists to let a terminal user access any TG on the FreeDMR network + without explicitly programming that TG into the terminal/codeplug. +- Codeplug generation and DMR terminal programming can be a major barrier for + amateur radio users. Many users are strong RF/electronics engineers but may + not be interested in, or comfortable with, computer-based configuration. +- FreeDMR's amateur-radio use case differs from DMR's intended commercial use + case, so some FreeDMR behavior intentionally optimizes usability and network + access rather than strict commercial fleet/radio programming assumptions. + +### Assumptions +- Dial-a-TG changes should be evaluated against this access/usability goal, not + only against narrow protocol or commercial-radio assumptions. + +### Protocol-Sensitive Areas +- Dial-a-TG intentionally maps local RF control/use of TG9 TS2 to wider-network + TG access on FBP/OBP. +- This makes TG namespace clarity critical: RF/client-visible TGs and + OBP/FBP-visible TGs can intentionally differ. + +### Inferred Invariants +- Dial-a-TG should reduce terminal programming burden for users. +- Maintaining usable audio/network access for amateur users is a first-class + design constraint. + +## FreeDMR Routing Model + +### Findings +- FreeDMR can be understood like a PBX: + - TGs are conference groups that can be connected to. + - DMR IDs are like phone numbers. + - Routing is centered on TGs and DMR IDs. +- FreeDMR is intended to be timeslot agnostic, unlike some systems. +- Timeslots are closer to phone lines: + - a simplex hotspot has one usable line; + - a repeater or duplex hotspot has two usable lines. + +### Assumptions +- Routing changes should preserve TG/DMR-ID centric behavior. +- Timeslot should be treated as an available access path or capacity dimension, + not as the primary identity of a route unless a specific feature explicitly + requires slot scoping. + +### Protocol-Sensitive Areas +- Dial-a-TG control from TS1 affecting TS2 reflector state is consistent with + the PBX/line model: the user can use one line to control which conference is + connected on another line. +- Tests should avoid assuming commercial DMR fleet semantics where timeslot is + always part of the primary routing identity. + +### Inferred Invariants +- TG and DMR ID are the primary routing identities. +- Timeslot handling should preserve capacity/access behavior without making + FreeDMR unnecessarily slot-bound. + +## HBP-Side Tolerance Principle + +### Findings +- FreeDMR should generally be more permissive on the HBP side because HBP + represents direct RF-facing connections to repeaters, duplex hotspots and + simplex hotspots. +- HBP-connected devices may come from several vendors and may run proprietary or + open source implementations with differing interpretations of the protocol. +- RF-facing behavior may vary in timing, late-entry recovery, stream continuity, + data handling and terminator delivery. + +### Assumptions +- HBP-side handling should avoid over-strict enforcement unless needed for + safety, loop prevention, routing correctness or preventing network harm. +- OBP/FBP peer-server handling can be stricter because it is server-to-server and + less directly exposed to varied RF terminal/repeater behavior. + +### Protocol-Sensitive Areas +- Timeout behavior on HBP should remain tolerant of resumed streams after packet + gaps unless live testing proves this causes worse failures. +- Data packet handling may need extra tolerance because terminal implementations + can be loose or incomplete. + +### Inferred Invariants +- Prefer audio continuity and interoperability at the RF-facing HBP boundary. +- Treat strictness on HBP as a deliberate choice requiring evidence. + +## Unit-Unit Private Call Policy + +### Findings +- FreeDMR deliberately does not allow user-to-user unit-unit private calls. +- Amateur Radio is an open service, not a private communications system. +- A unit-unit private call still occupies an RF timeslot even when other + stations cannot hear useful traffic on that slot. +- Users who want directed or pseudo-private routing should use a TG instead, + including the group-call TG that corresponds to their DMR ID if appropriate. +- Unit-unit/private calls are reserved for control purposes, including + dial-a-TG and related control flows. + +### Assumptions +- Future changes should not add general private voice routing unless this policy + is explicitly changed. +- Unit-call handling should continue to distinguish control-plane private calls + from user traffic. + +### Protocol-Sensitive Areas +- DMR allows unit-unit calls, but FreeDMR policy intentionally restricts them in + favor of open group-call behavior. +- Test cases involving private calls should treat them as control operations, + not as a supported user voice routing path. + +### Inferred Invariants +- User voice traffic should be routed as group calls/TGs. +- Private calls are control-plane only in FreeDMR. + +## HBP New Voice Terminator Can Use Stale Data Classification + +### Findings +- `bridge_master.py`, `routerHBP.dmrd_received()`, computes `_data_control` for + each HBP group/vcsbk packet. +- The slot's `RX_DATA_STREAM` / `RX_GROUP_VOICE_STREAM` classification is only + updated at the end of the group branch, after final terminator handling. +- Final terminator handling checks `self.STATUS[_slot].get('RX_DATA_STREAM')` to + decide whether to emit `GROUP VOICE,END,RX` and mark + `RX_FINISHED_STREAM_ID`. +- Deterministic probe: + - Inject group data header on MASTER-A TS2, leaving `RX_DATA_STREAM=True`. + - Inject a new voice stream where the first observed packet is a voice + terminator. + - The new stream emits `GROUP VOICE,START,RX`, but the terminator final-action + path sees stale `RX_DATA_STREAM=True`, so it does not emit voice END and does + not mark `RX_FINISHED_STREAM_ID`. + - A later same-stream voice burst is then routed, increasing the target packet + count from 2 to 3. + +### Assumptions To Validate +- A newly observed HBP stream should set its data-vs-voice classification before + any final-action handling for that same packet. +- If FreeDMR first observes a stream at its terminator, the classification should + come from that terminator packet, not from the previous slot occupant. +- Even if this is an edge case, stale data classification should not suppress + voice lifecycle/finished-stream handling for a new voice stream. + +### Unresolved Questions +- How often real RF/HBP paths deliver a new stream whose first observed packet is + a terminator. It can plausibly occur around packet loss, startup, or missed + earlier packets, but may be rare. +- Whether strict handling should suppress a terminator-only "start" report. This + proposal does not change that existing behavior; it only makes the end/finish + classification match the current packet. + +### Protocol-Sensitive Areas +- This is HBP source slot lifecycle state, not packet mutation. +- DMR terminal behavior may be loose; preserving robust audio/lifecycle behavior + matters more than assuming perfectly ordered full streams. + +### Inferred Invariants +- Per-slot packet classification must describe the current stream before the + current packet's lifecycle side effects are applied. +- Stale data-control state must not leak into a new voice stream. + +### Resolution +- HBP group/vcsbk new-stream handling now sets + `RX_DATA_STREAM = _data_control` and + `RX_GROUP_VOICE_STREAM = not _data_control` before terminator final actions can + run. +- Kept the existing end-of-branch assignment for ongoing packets. +- Added deterministic coverage proving a voice terminator observed after a data + stream marks the new voice stream finished and suppresses later same-stream + bursts. + +## HBP Terminator-Only Voice On Idle Slot Skips Finished Handling + +### Findings +- `bridge_master.py`, `routerHBP.dmrd_received()`, final voice terminator + handling is guarded by: + `(_frame_type == HBPF_DATA_SYNC) and (_dtype_vseq == HBPF_SLT_VTERM) and + (self.STATUS[_slot]['RX_TYPE'] != HBPF_SLT_VTERM)`. +- If the first observed packet for a new stream is a voice terminator while the + slot was idle, the previous slot `RX_TYPE` is already `HBPF_SLT_VTERM`, so + final terminator handling is skipped. +- Deterministic probe: + - Start from idle MASTER-A TS2. + - Inject a new HBP group voice terminator for stream `01020304`. + - The packet is forwarded and `GROUP VOICE,START,RX` is emitted, but no + `GROUP VOICE,END,RX` is emitted and `RX_FINISHED_STREAM_ID` remains null. + - A later same-stream voice burst is then forwarded, increasing target capture + count from 1 to 2 and setting `RX_TYPE` to the late voice vseq. + +### Assumptions To Validate +- If FreeDMR accepts and forwards a terminator-only voice packet as a new voice + stream, it should also close that stream locally and suppress later same-stream + voice. +- The final-action decision should be based on whether the current packet is a + voice terminator for the current stream, not only on the previous slot + `RX_TYPE`. +- This should stay limited to non-data voice streams. + +### Unresolved Questions +- Whether a terminator-only packet should emit both START and END reports, or + whether START should be suppressed when no earlier voice packet was observed. + Existing behavior already emits START, so the minimal fix should only add the + missing end/finished handling. + +### Protocol-Sensitive Areas +- This is HBP source lifecycle/late-packet suppression, not packet mutation. +- A terminator-only first observation can happen when earlier packets were lost + or FreeDMR starts observing mid-stream. + +### Inferred Invariants +- A voice terminator should close the current stream even if the previous slot + state was idle. +- Post-terminator same-stream voice should not be routed. + +### Resolution +- Replaced the final terminator guard's previous-`RX_TYPE` dependency with a + current voice-terminator condition that avoids duplicate final handling for an + already-finished stream. +- Added deterministic coverage for an idle-slot terminator-only HBP voice packet + marking the stream finished and suppressing later same-stream voice. + +## HBP Timeout Cleanup Does Not Mark Stream Finished + +### Findings +- `bridge_master.py`, `stream_trimmer_loop()`, HBP RX timeout cleanup sets + `_slot['RX_TYPE'] = HBPF_SLT_VTERM` and emits `GROUP VOICE,END,RX` for + reportable voice streams. +- It does not set `RX_FINISHED_STREAM_ID`. +- Because `RX_STREAM_ID` remains the timed-out stream ID until the 60-second + null cleanup, a later same-stream voice packet before that cleanup is treated + as a continuation rather than a new/finished stream. +- Deterministic probe: + - Inject HBP group voice stream `01020304`. + - Advance six seconds and run `stream_trimmer_loop()`. + - Timeout emits `GROUP VOICE,END,RX`, leaves `RX_STREAM_ID=01020304`, but + `RX_FINISHED_STREAM_ID` remains null. + - A later same-stream voice burst routes to the target, increasing target + capture count from 1 to 2 and setting `RX_TYPE` to the late voice vseq. + +### Updated Analysis +- User clarified that timeout can represent two different failure modes: + - RF path loss between terminal and base station, where the terminal's + terminator may also be lost. + - Network/server/mesh packet path disruption, where an otherwise valid stream + may have a long packet gap and later resume with the same stream ID. +- In RF-path loss, if the user is still transmitting and comes back into range, + the base station may recover via DMR late entry and may or may not assign a new + HBP stream ID. This needs live RF testing. +- In network-path loss, downstream servers cannot reliably distinguish a truly + ended over from a temporarily missing packet flow. A later same-stream packet + may be legitimate and should probably be forwarded to preserve audio. + +### Revised Assumptions +- Explicit terminators and timeout cleanup should not be treated identically. +- A real terminator is a strong end-of-stream signal and should mark the stream + finished. +- A timeout is an observability/liveness fallback for missing terminators, but + may be a soft end when the underlying stream could resume after network delay. +- FreeDMR should prioritize preserving recovered audio over rigidly enforcing a + timeout as a hard stream close when no terminator was seen. + +### Unresolved Questions +- Live RF testing should determine whether an RF-lost/recovered over uses a new + stream ID or resumes the old one. +- Black-box/network simulation should test whether a packet gap longer than the + HBP timeout can occur in the mesh and then resume with the same stream ID. +- Reporting may remain imperfect in this case: a timeout `END,RX` can be emitted + and later packets for the same stream may still be forwarded without a fresh + `START,RX`. + +### Protocol-Sensitive Areas +- This is HBP source stream lifecycle/late-packet suppression, not packet + mutation. +- It directly follows the operational purpose of timeout cleanup as a substitute + terminator when the real terminator was lost. + +### Inferred Invariants +- An explicit terminator closes a stream; a timeout without terminator is less + certain and may need to remain recoverable. +- Audio continuity after network gaps is more important than making timeout + reports look perfectly final. +- Timeout reports should be understood as "stream appears lost" rather than + definitive proof that the over ended. + +### Resolution +- No runtime change for now. +- Do not mark HBP streams finished on timeout until live RF and network-gap + testing proves this will not block legitimate recovered audio. +- Keep the existing behavior documented as a deliberate soft-timeout tradeoff: + reporting may show an END for a lost stream, but later same-stream packets can + still route before the 60-second null cleanup. + +### Follow-Up Verification +- Rechecked earlier lifecycle fixes against this soft-timeout model. +- HBP timeout cleanup still only sets `RX_TYPE = HBPF_SLT_VTERM`, clears + data/report classification, and optionally emits the timeout report. It does + not set `RX_FINISHED_STREAM_ID`. +- Deterministic probe confirmed HBP timeout remains recoverable: + - one packet routed before timeout; + - timeout report emitted with `RX_FINISHED_STREAM_ID == 0`; + - later same-stream packet before 60-second null cleanup still routes. +- Explicit terminators remain hard end-of-stream signals: + - HBP explicit terminator sets `RX_FINISHED_STREAM_ID` and suppresses later + same-stream voice; + - forwarded HBP -> OBP / OBP -> OBP terminators set target `_fin`; + - OBP source timeout sets `_to`, not `_fin`. +- Conclusion: earlier fixes did not convert timeout cleanup into a hard finished + state. The hard-close behavior is currently limited to real/forwarded + terminators. + +## DMRD Sequence Wrap In Voice Duplicate Control + +### Findings +- `hblink.py` parses the DMRD sequence number as `_seq = _data[4]`, so it is a + one-byte value in the range `0..255`. +- `bridge_master.py`, `routerHBP.dmrd_received()`, HBP group/vcsbk duplicate + control treats sequence numbers as a simple increasing integer: + duplicate when `_seq == lastSeq`, out-of-order when + `_seq < lastSeq and _seq != 1`, and missed packets when + `_seq > lastSeq + 1`. +- `bridge_master.py`, `routerOBP.dmrd_received()`, OBP group/vcsbk duplicate + control uses the same linear comparisons. +- This works until sequence wrap. If the last accepted sequence is `255` and + post-wrap packets `0` and `1` are lost, a valid resumed packet with sequence + `2` is treated as out-of-order because `2 < 255 and 2 != 1`. +- Because the discard path does not advance `lastSeq`, later valid packets `3`, + `4`, and so on remain less than `255` and can continue to be discarded. + +### Assumptions +- DMRD sequence numbers are modulo-256 transport sequencing, not an unbounded + stream counter. +- Packet `0` is a valid DMRD sequence value, not "no sequence"; the current + truthiness checks skip some validation when `_seq == 0`. +- Duplicate and out-of-order suppression is intended to drop repeated or stale + packets, not to mute a stream after a legitimate sequence wrap with packet + loss. + +### Unresolved Questions +- Whether data/control paths should use the same helper if future duplicate + control is added there, while remembering that DMR data packets are + packet-oriented rather than continuous voice streams. + +### Protocol-Sensitive Areas +- This affects packet-control behavior for long voice streams and network gaps, + especially near sequence wrap. +- HBP should remain tolerant of RF-facing packet loss and vendor differences. +- OBP/FBP should also handle modulo sequence wrap because the sequence value is + carried in DMRD packet format, even though server-to-server behavior can be + stricter in other areas. + +### Inferred Invariants +- DMRD sequence comparison should be modulo-aware. +- A post-wrap packet should be treated as forward progress even if one or more + immediately post-wrap packets were lost. +- Duplicate detection should include sequence `0`. + +### Resolution +- Added `dmrd_seq_delta(seq, last_seq)` to calculate sequence progress as + `(seq - last_seq) % 256`. +- Updated HBP and OBP group/vcsbk packet-control paths to classify delta `0` as + duplicate, delta `1` as normal progress, delta `2..127` as forward progress + with missed packets, and delta `128..255` as old/out-of-order. +- Removed the `_seq > 0` guard from hash duplicate checks so sequence `0` cannot + bypass duplicate detection. +- Added deterministic tests for HBP and OBP voice streams crossing `254`, `255`, + then `2`, and for HBP sequence `0` duplicate suppression. + +## HBP/OBP Source Timeout Uses Stream Start Time + +### Findings +- `bridge_master.py`, `routerHBP.dmrd_received()`, HBP group/vcsbk handling has + a source timeout check: + `if self.STATUS[_slot]['RX_START'] + 180 < pkt_time`. +- `bridge_master.py`, `routerOBP.dmrd_received()`, OBP group/vcsbk handling has + the analogous check: + `if self.STATUS[_stream_id]['START'] + 180 < pkt_time`. +- Both branches log that the source/stream should be ignored and then return + before duplicate control, routing, terminator handling, and normal state + updates. +- Because the comparison is against the stream start time, this is a hard + maximum stream duration of 180 seconds. It is not an inactivity timeout. +- A still-active voice stream that continues past 180 seconds without a + terminator will be dropped even if packets continue arriving at normal + cadence. + +### Assumptions +- User confirmed this is deliberate amateur DMR network behavior. +- The 180-second guard is a hard maximum over/stream duration, comparable to a + network-side time-out timer. +- Terminal time-out timers are also advised to be set to 180 seconds. + +### Unresolved Questions +- None for now. + +### Protocol-Sensitive Areas +- This is voice stream lifecycle and loop/source protection, not packet mutation. +- HBP paths are RF-facing and may reflect repeater/hotspot time-out timer + behavior, so changing this could permit overlong RF streams that deployments + currently expect to be cut off. +- OBP/FBP can carry arbitrary forwarded streams, so the operational impact may + differ between HBP and OBP. + +### Inferred Invariants +- A hard stream-age cap should be explicit and documented if retained. +- The current 180-second source timeout should remain based on original stream + start time, not last packet activity. + +### Resolution +- No runtime change. The `START + 180` behavior is intentional and should not be + rewritten as a last-activity timeout. + +## Generated Prompt Cancellation Does Not Send Terminator + +### Findings +- `mk_voice.py`, `pkt_gen()`, builds generated voice prompts as a DMRD stream: + three voice headers, AMBE bursts, and a final voice terminator frame using the + same generated stream ID. +- `bridge_master.py`, `sendSpeech()`, `disconnectedVoice()`, and + `playFileOnRequest()` now stop prompt scheduling when + `_generatedVoiceCancelled()` becomes true. +- Those loops break immediately and do not drain the generator to its final + terminator packet. +- `_cancelGeneratedVoice()` only marks prompt state as cancelled; it does not + send a same-stream terminator for the abandoned prompt. +- A real HBP voice stream can therefore interrupt a generated prompt on an HBP + target slot after the repeater has received a prompt header/burst but before it + receives the generated prompt terminator. + +### Assumptions +- User confirmed that absence of a terminator for an interrupted prompt is not a + major terminal-facing issue. +- DMR is designed for lossy environments, and a missing terminator is an + expected recoverable condition. +- DMR also supports interruption concepts such as priority interruption, so + receivers should tolerate an interrupted stream being superseded. +- Any future fix must not delay the real RF/network voice that caused the + cancellation. + +### Unresolved Questions +- None for now. + +### Protocol-Sensitive Areas +- This is generated local HBP TX lifecycle, not inbound DMR packet parsing. +- The fix would intentionally create or forward a terminator packet for locally + generated voice; it must preserve the stream ID and LC namespace expected by + the target repeater/client. +- Prompt interruption exists to prioritize live RF/network audio, so any + terminator fix must be low latency. + +### Inferred Invariants +- Real RF/network audio should remain higher priority than locally generated + prompts. +- Prompt termination should not mutate the real stream that caused the + interruption. +- Missing generated-prompt terminators are acceptable if interruption preserves + live traffic. + +### Resolution +- No runtime change. Prompt cancellation may abandon the generated prompt without + sending its final terminator. + +## Voice Ident Cancellation Leaves Prompt Cancel State Set + +### Findings +- `bridge_master.py`, `sendSpeech()`, `disconnectedVoice()`, and + `playFileOnRequest()` wrap generated prompt playback with + `_beginGeneratedVoice()` and `_endGeneratedVoice()`. +- `bridge_master.py`, `ident()` sends generated packets directly in its own loop + and calls `sendVoicePacket()` without `_beginGeneratedVoice()` / + `_endGeneratedVoice()` or `_generatedVoiceCancelled()` checks. +- `sendVoicePacket()` returns immediately if `_slot['TX_PROMPT_CANCEL']` is true. +- Real HBP voice can cancel generated prompt state on a target slot by calling + `_cancelGeneratedVoice()`, setting `TX_PROMPT_CANCEL=True` and + `TX_PROMPT_ACTIVE=False`. +- If that happens while a voice ident is active, the ident loop keeps scheduling + packets that `sendVoicePacket()` drops, and no ident cleanup resets + `TX_PROMPT_CANCEL`. +- A later `ident()` run on the same slot can then have all of its packets dropped + immediately because it never begins a new prompt token or clears the old cancel + flag. + +### Assumptions +- Voice ident should follow the same generated-prompt lifecycle as dial-a-TG + prompts and on-demand files. +- Real RF/network voice should still be able to interrupt voice ident. +- A cancelled ident should not permanently suppress later idents. + +### Unresolved Questions +- None for now. + +### Protocol-Sensitive Areas +- This is generated local HBP TX scheduling/state, not inbound DMR parsing. +- The fix should not alter generated packet bytes, destination selection, or the + existing policy that ident only runs when the slot has been idle. + +### Inferred Invariants +- Every generated voice loop that uses `sendVoicePacket()` should own a prompt + token and clear/finish prompt state when it exits. +- Interrupting one generated voice ident must not permanently block future + generated voice idents. + +### Resolution +- Updated `ident()` to use `_beginGeneratedVoice()`, + `_generatedVoiceCancelled()`, and `_endGeneratedVoice()` around its generated + packet loop. +- Removed unused `_stream_id` / `_pkt_time` local assignments from the ident + loop. +- Added deterministic coverage proving a cancelled ident does not leave the slot + in a state that blocks a later ident. + +## HBP New Voice Stream Keeps Previous Duplicate State + +### Findings +- `bridge_master.py`, `routerHBP.dmrd_received()`, HBP group/vcsbk handling uses + per-slot duplicate-control state: `lastSeq`, `lastData`, `loss`, `packets`, + and `crcs`. +- When a new stream is detected with + `_stream_id != self.STATUS[_slot]['RX_STREAM_ID']`, the code resets + `packets`, `loss`, and `crcs`. +- It does not reset `lastSeq` or `lastData` at that point. +- The duplicate/out-of-order block for the current packet then runs against + potentially stale `lastSeq` and `lastData` from the previous stream on the + same slot. +- If the previous stream ended by explicit terminator, final terminator handling + resets `lastSeq` and `lastData`, so the issue is masked. +- If the previous stream ended by timeout, source timeout, collision gap, or any + path that does not run explicit terminator cleanup, the next stream can be + judged against stale sequence/data. +- Example from code logic: previous stream leaves `lastSeq=200`; a new stream + begins with sequence `1`. `dmrd_seq_delta(1, 200)` is `57`, so the packet is + treated as forward progress with missed packets on the old stream rather than + the first packet of a new stream. Other stale values can classify the new + packet as duplicate or out-of-order. + +### Assumptions +- Duplicate-control state is stream-scoped, not slot-lifetime-scoped. +- A new stream on the same HBP slot should begin with `lastSeq=False` and + `lastData=False`, just as OBP initializes per-stream state. +- Resetting duplicate-control state on new stream should not affect explicit + terminator suppression, which is now handled separately by + `RX_FINISHED_STREAM_ID`. + +### Unresolved Questions +- None for now. + +### Protocol-Sensitive Areas +- This is HBP RF-facing packet-control state, not packet mutation. +- HBP should be tolerant of missing terminators and stream transitions after + packet loss. +- The fix should not change OBP, where stream state is already per stream. + +### Inferred Invariants +- Duplicate and sequence state must belong to the current stream. +- Missing a terminator must not let stale duplicate-control state from the old + stream suppress or misclassify the next HBP stream. + +### Resolution +- Updated the HBP group/vcsbk new-stream initialization block to reset + `lastSeq` and `lastData` along with `packets`, `loss`, and `crcs`. +- Added deterministic coverage for a prior HBP stream that times out with + `lastSeq=200`; the next stream on the same slot starts with sequence `1`, + routes to the target, and records no inherited loss. + +## HBP Sequence Gaps on Unreliable Networks + +### Findings +- The DMRD sequence byte is supplied by the HBP client/repeater/hotspot at + packet offset 4 and is passed through `hblink.py` into + `routerHBP.dmrd_received()`. +- The current modulo-256 check treats deltas 1..127 as forward progress, + delta 0 as duplicate, and deltas greater than 127 as stale/out-of-order. +- If a network-side gap exceeds half the 8-bit sequence space, the server + cannot distinguish "very late old packet" from "forward packet after a long + loss". +- Because the out-of-order branch returns before updating `lastSeq`, a + same-stream resume after a greater-than-127 jump may remain muted until the + sequence wraps back near the previous accepted value. + +### Assumptions +- RF-side loss between the terminal and repeater may not produce a sequence jump + at the server if the HBP device only increments the DMRD sequence for packets + it actually sends. +- Network-side loss between an HBP device and the server can produce large + sequence jumps because packets were sent but not received by FreeDMR. +- HBP should remain permissive where practical because it represents direct RF + paths and unreliable access networks. + +### Unresolved Questions +- Whether the HBP duplicate-control path should eventually add a long-gap + recovery rule for same stream IDs after a quiet interval, rather than treating + all greater-than-127 deltas as stale. + +### Protocol-Sensitive Areas +- The sequence byte is only 8 bits, so long-gap direction is inherently + ambiguous without timing, stream lifecycle, or additional protocol context. +- Any recovery rule must avoid accepting genuinely old/reordered packets and + creating audio or loop-control regressions. + +### Inferred Invariants +- Small packet loss and sequence wrap must not suppress a voice stream. +- A new HBP stream must not inherit sequence state from an old stream. +- Very long same-stream gaps require a policy decision: prefer stale-packet + rejection or prefer late audio recovery on lossy access networks. + +## FBP Sequence Gaps on Unreliable Links + +### Findings +- OBP/FBP group voice handling uses the same modulo-256 sequence delta policy + as HBP: delta 0 is duplicate, deltas 1..127 are forward progress, and deltas + greater than 127 are treated as stale/out-of-order. +- OBP/FBP duplicate-control state is keyed by stream ID, so it does not have + the HBP per-slot stale-new-stream problem. +- A same-stream long network outage on an OBP/FBP link can still produce the + same recovery issue: the first packets after the outage may be rejected until + the sequence wraps back near the last accepted value. +- OBP/FBP code updates `LAST` on accepted packets and on some loop/timeout + returns. For the duplicate/out-of-order branch, the useful quiet-time signal + is the elapsed time since the last accepted/routed packet. + +### Assumptions +- Some FreeDMR peer-server links may be unreliable because of RF IP, cellular, + portable, EMCOMM, or less-developed infrastructure use cases. +- Server-to-server links still need stricter loop-control behavior than HBP + edges, but rejecting live resumed audio after a long quiet gap is undesirable. +- Any FBP recovery rule should apply only to stream media packets after loop + and source-selection checks have already accepted this server as the first + source for the stream. + +### Unresolved Questions +- Whether HBP and FBP should share the same long-gap threshold, or FBP should + use a slightly higher threshold to reduce risk from delayed/reordered mesh + packets. + +### Protocol-Sensitive Areas +- BCSQ/source-quench correctness depends on stream ID and TG; recovery should + not bypass existing first-source/loop-control decisions. +- OBP/FBP can carry multiple concurrent streams, so rate and recovery decisions + must remain per stream, not global. + +### Inferred Invariants +- FBP should not discard live recovered audio solely because a lossy link + crossed the half-sequence ambiguity point. +- FBP long-gap recovery must not weaken source-selection, STUN, ACL, TG filter, + or source-quench behavior. + +## FBP Trunk-Wide Long-Gap Diagnostics + +### Findings +- OBP/FBP links can carry many independent streams over one peer connection. +- If long-gap recovery is observed on only some streams, the loss may have been + inherited from an upstream route taken by those streams. +- If long-gap recovery is observed across most or all active streams on the + same OBP/FBP peer connection within a short window, that suggests the local + upstream link or peer connection is struggling. + +### Assumptions +- This signal is diagnostic only and must not influence packet admission, + source selection, loop control, or source-quench decisions. +- Per-stream recovery logging is useful for debugging a stream; trunk-wide + aggregate logging is useful for diagnosing infrastructure or upstream link + trouble. + +### Unresolved Questions +- What threshold should define "most/all streams" on a trunk: all active + streams, a fixed minimum count, or a ratio such as 80% within a short window. +- Whether this should be implemented immediately with long-gap recovery or left + as a later observability improvement. + +### Protocol-Sensitive Areas +- A malicious or delayed packet pattern must not be able to relax loop-control + or routing checks by triggering trunk-wide diagnostics. +- Trunk-wide diagnostics must be rate-limited to avoid log flooding during real + network incidents. + +### Inferred Invariants +- Long-gap recovery remains per stream. +- Trunk-wide detection is warning-only observability. +- Diagnostics must never bypass STUN, HMAC/authentication, ACLs, TG filters, + loop-control, finished-stream suppression, source timeout, rate limiting, or + BCSQ/source-quench behavior. + +## OBP Target LC Missing-Key Logging Uses Wrong System Variable + +### Findings +- `bridge_master.py`, `routerOBP.to_target()`, OpenBridge-target voice rewrite + branch catches missing `T_LC` and `EMB_LC` state with `except KeyError`. +- Both handlers log with `system` instead of `self._system`. +- `system` is not a local variable in `to_target()`. If no module-level + `system` binding exists, the exception handler itself can raise `NameError`. + If a module-level binding does exist from other loops, the log can report the + wrong system. +- The surrounding code intends these `KeyError` paths to be non-fatal: one logs + and continues processing the terminator, the other logs and skips the packet. + +### Assumptions +- Missing LC state is abnormal, but the existing handlers show the intended + behavior is to avoid crashing the router on this condition. +- Changing the log argument from `system` to `self._system` does not alter packet + routing, mutation, or normal successful voice handling. + +### Unresolved Questions +- Whether the terminator missing-`T_LC` path should also skip sending the packet + after logging. That is a separate behavior question and should not be bundled + with the logging fix. + +### Protocol-Sensitive Areas +- This is an error-path logging bug in OBP target voice LC rewrite handling. +- It should not change LC rewrite behavior when `T_LC` / `EMB_LC` are present. + +### Inferred Invariants +- Error-path logging must not introduce a new exception while handling malformed + or inconsistent stream state. +- Logs should identify the router instance handling the packet. + +### Resolution +- Replaced the non-local `system` log argument with `self._system` in the + missing `T_LC` and `EMB_LC` handlers. +- Added deterministic OBP-to-OBP coverage that removes target `EMB_LC` state, + injects a voice burst, and verifies the warning logs without crashing or + forwarding the malformed rewrite. + +## Widened Review: Startup, Config, Support Functions + +### Findings +- `config.py`, `build_config()`, reads `GLOBAL.USE_ACL` with + `config.get(..., fallback=True)` instead of `config.getboolean(...)`. + Therefore a config value such as `USE_ACL: False` becomes the non-empty string + `"False"`, which is truthy in packet ACL checks. +- Packet ACL checks are split across both layers: `hblink.py` performs low-level + admission checks using `self._CONFIG['GLOBAL']['USE_ACL']`, and + `bridge_master.py` checks the same global flag before target forwarding. A + truthy string therefore affects both layers consistently, but incorrectly. +- `config.py` converts `ALIASES.STALE_DAYS` into seconds as `STALE_TIME`. + `bridge_master.py` then schedules alias reloads with + `CONFIG['ALIASES']['STALE_TIME'] * 86400`, multiplying by 86400 twice. + A one-day alias stale interval therefore schedules periodic reload around + 86400 days instead of 1 day. +- `bridge_master.py`, `setAlias()`, assigns reloaded alias dictionaries to local + variables named `peer_ids`, `subscriber_ids`, `talkgroup_ids`, + `local_subscriber_ids`, `server_ids`, and `checksums`. Without a `global` + declaration or updating `CONFIG`, the periodic alias reload does not update + the module-level alias dictionaries used by logging, reports, and routing + helpers. +- `hblink.py` router classes also read aliases from the shared config object: + `CONFIG['_SUB_IDS']`, `CONFIG['_PEER_IDS']`, + `CONFIG['_LOCAL_SUBSCRIBER_IDS']`, and `CONFIG['_SERVER_IDS']`. Therefore an + alias reload fix must update both `bridge_master.py` globals and these shared + config keys; updating only one side would leave the split layers inconsistent. +- `bridge_master.py`, `bridge_reset()`, checks + `if 'OPTIONS' in CONFIG['SYSTEMS'][_system]['OPTIONS']:` after reset. If the + system has no `OPTIONS` key, which can happen after disconnect when no + `_default_options` exists, this raises `KeyError` inside the timed reset loop. +- `hblink.py` deliberately deletes `CONFIG['SYSTEMS'][system]['OPTIONS']` when a + peer disconnects or times out and no `_default_options` exists. This confirms + the missing `OPTIONS` case is part of normal lifecycle behavior, not corrupt + state. + +### Assumptions To Validate +- `GLOBAL.USE_ACL: False` should disable global ACL checks; it should not be + treated as enabled because the string is truthy. +- `ALIASES.STALE_DAYS` is intended to control alias refresh age in days, while + the runtime scheduler should operate on the already-converted seconds value. +- Periodic alias reloads are intended to replace the live alias dictionaries + used by bridge logging/reporting and router helper methods. +- Bridge reset should tolerate sessions with no current `OPTIONS` key and should + not stop the reactor/timed loops. + +### Unresolved Questions +- In `bridge_reset()`, should `_reloadoptions` be set whenever an `OPTIONS` key + exists, or only when current/default options actually need re-parsing? + +### Protocol-Sensitive Areas +- Global ACL parsing affects all packet admission paths. +- Alias reload affects observability and may affect local subscriber lookup + helpers, but should not mutate packet bytes. +- Bridge reset interacts with HBP disconnect/reconnect lifecycle and options + parsing; fixes must preserve the confirmed session-only option semantics. + +### Inferred Invariants +- Boolean config fields must be stored as booleans, not truthy strings. +- Time values should have one unit conversion boundary. +- Periodic reload functions must update the state actually read by production + paths. +- Timed maintenance loops must not crash on missing optional session keys. + +### Resolution +- Updated `config.py` to parse `GLOBAL.USE_ACL` with `getboolean()`. +- Updated the alias reload scheduler to use `CONFIG['ALIASES']['STALE_TIME']` + directly, because `config.py` already converts `STALE_DAYS` to seconds. +- Updated `setAlias()` to assign the reloaded dictionaries to bridge_master + module globals and the shared `CONFIG` alias keys consumed by `hblink.py`. +- Updated `bridge_reset()` to check for the presence of the `OPTIONS` key before + marking `_reloadoptions`. +- Added deterministic/config coverage for global ACL parsing, alias stale-time + units, cross-layer alias reload state, and reset after missing session + `OPTIONS`. + +## Post-Fix Widened Review Status + +### Findings +- A follow-up scan of `config.py`, `bridge_master.py`, `hblink.py`, `API.py`, + `utils.py`, and `log.py` did not identify another confirmed bug with the same + confidence as the fixed config/startup/support issues. +- The current full deterministic suite passes after the widened fixes. + +### Assumptions +- Remaining broad `except` blocks and legacy split-layer structure should be + treated as audit candidates, not bugs, unless a concrete failing path is + demonstrated. + +### Unresolved Questions +- The API layer has not yet had the same level of focused review as packet, + config, startup, alias, and reset handling. +- Long-gap HBP/FBP same-stream recovery remains a known improvement candidate, + not a completed fix. + +### Protocol-Sensitive Areas +- API/reset/options changes can affect live session control and should be tested + carefully if reviewed later. +- Long-gap recovery must not bypass loop-control, ACL, STUN, source-quench, or + rate-limiting behavior. + +### Inferred Invariants +- Confirmed bugs should have a demonstrated code path and a narrowly scoped + regression test before production changes are made. +- Deferred policy or observability work should remain documented separately from + fixed correctness bugs. + +## UDP Black-Box Harness Expansion + +### Findings +- The UDP black-box layer can sensibly mirror HBP-observable deterministic + behavior: startup/config parsing that affects admission, HBP registration, + DMRD routing, packet byte preservation, sequence duplicate/drop behavior, + terminator lifecycle behavior, and generated prompt output. +- OBP/FBP coverage remains out of scope for this increment because it requires + OpenBridge packet/HMAC/protocol-version emulation over UDP. + +### Assumptions +- UDP tests should cover high-value end-to-end behavior, not every deterministic + internal state transition. +- State-only behavior such as alias dictionary replacement and bridge-reset + flags remains better covered by the deterministic harness unless an external + UDP-visible symptom is needed. + +### Unresolved Questions +- Add OpenBridge/FBP UDP peer emulation later for enhanced metadata, BCSQ/STUN, + keepalive, and FBP long-gap behavior. +- Add recorded packet fixture replay and jitter/drop controls around realistic + cadence later. + +### Protocol-Sensitive Areas +- The current UDP expansion exercises HBP only; it must not be interpreted as + FBP/OpenBridge black-box coverage. +- UDP tests assert observable packet fields and bytes, not internal mutable + status dictionaries. + +### Inferred Invariants +- Deterministic tests remain the broad internal regression layer. +- UDP tests are the external integration confidence layer and should stay + loopback-only, opt-in, and isolated from real network traffic. + +### Resolution +- Added UDP scenario config knobs for global ACL fields and static TG lists. +- Added opt-in UDP coverage for global ACL false parsing through packet + admission, HBP data/control payload preservation, voice sequence wrap, + duplicate sequence `0` suppression, and post-terminator late packet + suppression. +- Existing UDP coverage for static routing and local TG9 TS2 dial-a-TG prompt + output remains in place. + +## UDP/FBP Black-Box Harness Expansion + +### Findings +- `hblink.py` OpenBridge/FBP v5 packets are observable over UDP as `DMRE` + envelopes with the DMR header/body, BER/RSSI, embedded protocol version, + timestamp, source server, source repeater, hop count and BLAKE2b hash. +- Enhanced OpenBridge forwarding is gated by recent `BCKA` state, so a useful + black-box FBP peer must send signed keepalive traffic before expecting + forwarded packets. +- FBP source-quench is represented by signed `BCSQ` packets keyed by TGID and + stream ID; the UDP harness can assert the external effect by verifying no + matching `DMRE` leaves FreeDMR for that stream. + +### Assumptions +- Initial FBP black-box coverage should focus on protocol-v5 enhanced FBP, + because that is the current default and carries the richest metadata. +- The harness may construct valid FBP transport envelopes and bridge-control + packets, but only production code should perform route-driven DMR field + rewrites. +- FBP peer emulation should stay loopback-only and opt-in with the existing UDP + test gate. + +### Unresolved Questions +- Add explicit black-box coverage for `BCST` STUN once desired externally + observable behaviour is selected for a multi-peer topology. +- Add older OpenBridge/FBP protocol-version fixtures later so metadata + assertions follow the protocol version actually negotiated for the session. +- Add recorded packet replay and jitter/drop controls after the synthetic FBP + path is stable. + +### Protocol-Sensitive Areas +- FBP packet signing must match the exact protocol version layout. Version 5 + hashes bytes through the hop-count field and includes source repeater + metadata; lower versions differ. +- FBP inbound network ID is carried in the embedded DMR peer-ID field and must + match the configured OpenBridge `NETWORK_ID`. +- Enhanced FBP liveness and source-quench are transport/control state, not DMR + payload mutation. + +### Inferred Invariants +- HBP-to-FBP static TG routing clears the slot bit because OpenBridge traffic is + carried as TS1 over the FBP transport. +- FBP-to-HBP static TG routing rewrites only the target HBP slot/TG fields + required by the active bridge target while preserving stream identity. +- A peer-requested `BCSQ` for a TG/stream suppresses subsequent outbound FBP + packets for that TG/stream without affecting unrelated routing. + +### Resolution +- Extended `tests/harness/udp_blackbox.py` with FBP constants, packet parsing, + v5 `DMRE` packet construction, bridge-control signing, generated OBP config + sections, and a `FbpPeer` loopback emulator. +- Added opt-in UDP tests for HBP-to-FBP static TG routing, FBP-to-HBP static TG + routing, FBP source-quench suppression, and inbound FBP network-ID rejection. +- Updated test and architecture documentation to describe the current FBP UDP + support and run commands. + +## UDP Unreliable-Link Simulation + +### Findings +- The UDP black-box harness can simulate unreliable links at the fake endpoint + send boundary without changing FreeDMR production behaviour. +- FreeDMR's current voice packet-control logic treats DMRD sequence numbers as + modulo-256, forwards forward progress, and discards delayed out-of-order + packets rather than buffering and reordering them. +- FBP trunks can carry arbitrary numbers of streams, so impairment scenarios + should be able to target one stream without implying that the whole trunk is + impaired. + +### Assumptions +- FreeDMR owns stream ID assignment in the routed/network path; unreliable-link + tests should not assume a repeater assigns a replacement stream ID after loss. +- FreeDMR intentionally does not implement a jitter buffer. In real-time AMBE + stream handling, late or out-of-order packets should generally be discarded + rather than reconstructed. +- UDP impairment tests model UDP/IP transport behaviour, not RF late entry, + AMBE FEC recovery, terminal behaviour or MMDVM jitter buffering. + +### Unresolved Questions +- Add burst-loss and blackout scenarios to document current timeout/continuation + behaviour over longer gaps. +- Add a whole-trunk impairment warning scenario later if an observable logging + rule is designed. +- Add multi-stream FBP impairment where one stream is delayed/reordered while + unrelated streams continue normally. + +### Protocol-Sensitive Areas +- Delayed packets must not override loop-control, STUN, ACLs, source-quench or + rate-limiting decisions. +- FBP impairment tests must preserve the correct signed protocol envelope for + the version under test; the impairment layer schedules transport sends and + does not mutate DMR or FBP packet bytes. + +### Inferred Invariants +- Transport simulation and protocol mutation remain separate. +- Reordered packets should be observable as missing/dropped packets at the + destination, not as a buffered corrected sequence. +- A damaged stream should not poison later streams on the same FBP trunk. + +### Resolution +- Added `LinkImpairment` to `tests/harness/udp_blackbox.py` with deterministic + drop, duplicate, jitter and delay scheduling for fake endpoint sends. +- Extended HBP and FBP fake endpoints so `send_stream()` / `send_fbp_stream()` + can apply impairment while preserving packet bytes. +- Added opt-in UDP tests for delayed out-of-order HBP packets and delayed + out-of-order FBP packets. Both assert that sequence `1` arriving after + sequence `2` is not replayed; the FBP test also verifies a following stream on + the same trunk still routes. + +## UDP Real-World Scenario Profiles + +### Findings +- The UDP black-box suite benefits from reusable scenario profiles because many + realistic tests need the same 30 ms voice-over packet sequences and impairment + patterns. +- Prompt interruption is externally observable over UDP: a generated local TG9 + TS2 prompt should not prevent a real HBP voice stream from routing after the + prompt has started. +- FreeDMR is commonly deployed in Docker with the hotspot proxy enabled, but + proxy/firewall behaviour is a separate integration boundary from direct + `bridge_master.py` UDP testing. + +### Assumptions +- Direct UDP tests should remain the current second layer and should not start + the hotspot proxy implicitly. +- Proxy/firewall tests should become a third opt-in layer so direct protocol + regressions remain isolated from packaging/proxy failures. +- Any firewall integration test must avoid changing the developer host firewall + unless it runs in Docker or uses a fake command runner. + +### Unresolved Questions +- Add a reliable voice-ident interruption trigger for the subprocess harness. + The production ident loop currently runs on a long interval, so a direct + generated-prompt interruption test gives faster coverage now. +- Decide whether proxy tests should run through Docker Compose, local subprocess + proxy mode, or both. +- Inspect external proxy/firewall code from the GitLab repo only when needed and + with network access explicitly available. + +### Protocol-Sensitive Areas +- Stream profiles generate DMR packet sequences; route-driven rewrites still + belong only to production code. +- Multi-stream FBP trunk tests should avoid HBP target-slot contention unless + contention is the behaviour under test. +- Prompt interruption tests must assert real routed traffic, not just absence of + prompt packets, because generated speech may have already queued packets. + +### Inferred Invariants +- Reusable stream/impairment profiles should be deterministic and named after + real deployment failure modes where possible. +- A generated prompt or ident must not permanently block real RF-originated + voice. +- Proxy tests are valuable, but they should be opt-in and isolated from the + direct UDP black-box harness. + +### Resolution +- Added `StreamProfile.voice_over()` for reusable 30 ms voice stream packet + sequences with optional header and terminator packets. +- Added named `ImpairmentProfiles` for clean links, provider-style reordering, + mobile flutter drops and duplicate UDP datagrams. +- Added a UDP prompt-interruption test that observes a local TG9 TS2 generated + prompt, injects real HBP voice, and verifies the real stream routes to another + master. +- Added a multi-stream HBP-to-FBP trunk test where one TG stream is reordered + and drops its late packet while another clean TG stream continues over the + same FBP peer. +- Documented a future third Docker/proxy integration layer for packaged + deployments and proxy/firewall behaviour. + +## UDP Hostile Packet Coverage + +### Findings +- The UDP black-box harness can exercise malformed and hostile packet paths + against a real `bridge_master.py` subprocess while keeping all traffic on + loopback. +- HBP short `DMRD` datagrams are expected to be ignored without disconnecting + the emulated repeater; a following valid packet should still route. +- FBP stale timestamp and max-hop enforcement are expected to source-quench the + affected TG/stream rather than forwarding to HBP targets. + +### Assumptions +- Bad FBP hashes should be ignored without a source-quench because the packet + did not authenticate. +- Short malformed FBP packets should be ignored and should not poison the peer + state for later valid traffic. +- Source-quench assertions should check the externally visible BCSQ TG/stream + fields, not internal `_laststrid` state. + +### Unresolved Questions +- Add optional subprocess log assertions later for the warning/error messages + produced by these hostile packet paths. +- Add bad source-server ID, prohibited OpenBridge slot and old protocol-version + cases once the next negative-path batch is selected. + +### Protocol-Sensitive Areas +- FBP hash corruption must mutate only the transport hash, not the DMR payload, + so the test isolates authentication handling. +- Stale timestamp and max-hop tests use valid hashes and valid network IDs so + they specifically exercise post-auth protocol gates. + +### Inferred Invariants +- Malformed/hostile UDP input must not crash the subprocess. +- Rejected FBP packets must not leak traffic to HBP repeaters. +- Authenticated but stale/over-hop FBP streams should produce BCSQ for the + affected TG/stream. + +### Resolution +- Added FBP packet-builder options for explicit timestamp and intentionally + corrupted hash generation. +- Added `FbpPeer.recv_opcode()` to capture bridge-control responses such as + BCSQ. +- Added opt-in UDP tests for short HBP `DMRD`, short FBP `DMRE`, bad FBP hash, + stale FBP timestamp and max-hop FBP handling. + +## UDP FBP Bridge-Control Coverage + +### Findings +- Enhanced OpenBridge/FBP targets require recent authenticated `BCKA` state + before HBP-originated traffic is forwarded to them. +- `BCSQ` is a stream/TG source-quench control and must authenticate before it + mutates suppression state. +- `BCST` STUN is a global OpenBridge traffic gate. It blocks FBP send/receive + paths but does not imply HBP-to-HBP traffic should stop. + +### Assumptions +- Invalid bridge-control hashes should be ignored without changing runtime + state. +- A valid `BCST` is intended to temporarily stop all FBP/OpenBridge traffic + until an operator/API path later clears the stun state. +- Black-box STUN assertions should isolate FBP effects from ordinary HBP bridge + routing, because valid HBP-to-HBP traffic can still be queued. + +### Unresolved Questions +- No un-STUN API path is currently covered; live operator/API semantics remain + deferred. +- Older protocol-version and unsupported-version bridge-control cases are still + future UDP fixture work. +- Subprocess log assertions for rejected bridge-control packets are still + optional future coverage. + +### Protocol-Sensitive Areas +- Bridge-control HMAC input depends on opcode: `BCVE` signs the version byte, + while `BCKA`, `BCSQ`, and `BCST` sign their full control bodies. +- `BCSQ` is scoped to the TGID and stream ID seen on the FBP/OpenBridge side. +- STUN should not override loop-control or reinterpret delayed stream packets. + +### Inferred Invariants +- No authenticated enhanced keepalive means no enhanced HBP-to-FBP forwarding. +- Invalid `BCSQ` does not suppress any later stream. +- Valid STUN blocks OpenBridge send/receive while leaving unrelated HBP routing + behaviour to the normal bridge rules. + +### Resolution +- Added corrupt bridge-control hash support to the UDP FBP control builder. +- Added an invalid BCSQ helper to the fake FBP peer model. +- Added opt-in UDP tests for enhanced keepalive gating, invalid BCSQ rejection, + and BCST STUN blocking OpenBridge traffic in both directions. + +## UDP FBP Protocol-Version Coverage + +### Findings +- `BCVE` is the explicit bridge-control version negotiation path. Downgrades, + unsupported versions and invalid hashes should not change the configured + outbound packet version. +- FBP v5 packets carry source repeater metadata. v4 packets use an older layout + without that field, but v4 is now treated as historical/deprecation context + rather than a protocol target to preserve long term. +- v1 remains important and supported as an open OBP interop protocol used by + other amateur DMR software. The important v1 bridge-instance path is + `bridge.py`, not primarily `bridge_master.py`. +- A signed v1 OpenBridge `DMRD` packet received on a v5-configured link is + refused before normal packet routing and FreeDMR responds with `BCVE`. +- The UDP harness can build both v4 and v5 envelopes while keeping the inner DMR + payload bytes generated by the same `PacketSpec`. It can also build signed v1 + OBP packets for refusal tests. +- `PROTO_VER` is read into `CONFIG['SYSTEMS'][...]['VER']`; historical v4 + behavior is now characterization coverage only. Going forward, expected + protocol support is v1 OBP where appropriate and v5 FBP. +- UDP expected-failure coverage now confirms two remaining protocol-version + issues: unsupported embedded `DMRE` version 6 is not rejected before routing, + and the v4 send layout currently carries the module default version byte + instead of the configured `PROTO_VER` value. +- Recorded packet fixture replay is now available for hex-encoded UDP payloads. + Replay preserves packet bytes and keeps protocol mutation inside FreeDMR. +- Subprocess log capture is available for black-box warning/error assertions. +- True voice-ident black-box interruption remains blocked by the fixed 914 + second production ident loop interval unless a test hook or long-running mode + is introduced. + +### Assumptions +- The generated black-box config uses protocol v5 by default, so failed BCVE + negotiation should leave outbound packets as v5. +- Accepting an inbound v4 packet is current behavior, but it is not a desired + long-term compatibility contract. +- Refusing v1 on a configured v5 link is the intended behavior because the + generated test config sets `PROTO_VER` to the current FBP version. This does + not contradict support for v1 through bridge instances. +- Protocol options and metadata layout should be asserted against the protocol + version carried by the packet or negotiated for the session. + +### Unresolved Questions +- Decide whether unsupported embedded `DMRE` versions should be rejected at the + parser seam before routing. The expected-failure test documents the current + leak. +- Decide whether the v4 send branch should write the configured protocol + version byte rather than the module-level `VER` constant. The + expected-failure test documents the current mismatch; this may become moot if + v4 is removed. +- Future v1 interop testing should inspect `bridge.py`, Docker startup files and + the GitLab wiki for bridge-instance behavior. +- Add a production-supported fast trigger for voice-ident subprocess tests, or + keep real ident coverage in deterministic tests and live/manual testing. + +### Protocol-Sensitive Areas +- v5 hash input includes the source repeater field and hop byte. +- v4 hash input omits source repeater and places the hop byte immediately after + source server metadata. +- `BCVE` signs only the one-byte version payload, not the opcode. +- v1 OBP packets use the older `DMRD` envelope and HMAC over the 53-byte packet + body; a v5-configured receiver refuses them before validating/routing as v1. + +### Inferred Invariants +- Invalid or rejected `BCVE` messages do not mutate the outbound protocol + version. +- v4 inbound packets can currently route to HBP using the v4 metadata layout, + but this is characterization/deprecation context. +- v1 packets on a v5-configured link produce a `BCVE` response and do not route + to HBP targets. +- v1 remains supported where a system is intentionally operating as an OBP + bridge instance. +- Tests must distinguish bridge-control negotiation from per-packet metadata + parsing. +- Recorded fixture replay is transport simulation only; fixture loading must + not rewrite protocol fields. +- Log assertions should supplement packet assertions and should not become the + only evidence of routing behavior. + +### Resolution +- Added a version parameter to the UDP FBP packet builder and fake FBP send + helpers. +- Added invalid `BCVE` generation to the fake FBP peer model. +- Added opt-in UDP tests for BCVE downgrade rejection, unsupported BCVE + rejection, invalid BCVE rejection and inbound v4 packet routing. +- Added signed v1 OBP packet generation and an opt-in UDP test that verifies v1 + traffic on a v5-configured link is rejected with BCVE and does not leak to HBP. +- Added a UDP test documenting current v4 downgrade to the older outbound + layout as characterization/deprecation context. +- Added expected-failure UDP tests for unsupported embedded FBP packet version + rejection and configured-v4 version-byte consistency. +- Added recorded packet fixture replay support and a UDP fixture replay test. +- Added burst-loss and duplicate-UDP profile coverage for HBP streams. +- Added subprocess log capture and log assertions for malformed short FBP + packets and bad FBP hashes. + +## 2026-05-23 - `bridge.py` Backport Scope + +### Findings +- `bridge.py` carries the older conference-bridge voice path and does not + implement `bridge_master.py` dial-a-TG, data-gateway, generated-prompt or + configuration-option handling. +- The shared HBP/OBP group voice packet-control path in `bridge.py` still used + simple integer comparisons for the one-byte DMRD sequence value, so valid + modulo-256 forward progress after wrap could be rejected as out-of-order. +- `bridge.py` HBP slot state retained `lastSeq` and `lastData` across new + streams on the same slot. +- `bridge.py` OBP terminator handling only marked `_fin` when live reporting was + enabled, so late same-stream packets could avoid the finished-stream guard + when reports were disabled. +- `bridge.py` HBP terminator handling logged/report-ended streams but did not + have the finished-stream suppression already added to `bridge_master.py`. + +### Assumptions +- Only behavior already present in `bridge.py` should be corrected: group voice + stream routing, sequence tracking and stream lifecycle. +- `bridge.py` should not gain dial-a-TG, group data, DATA-GATEWAY, prompt, + ident, static-TG, default-reflector or broader FBP negotiation features as + part of this backport. +- The same modulo-256 sequence policy used by `bridge_master.py` applies to + `bridge.py` HBP and OBP voice packets because both receive the same DMRD + sequence byte. + +### Unresolved Questions +- A dedicated `bridge.py` runtime harness could be added later if bridge + instances need the same UDP-level coverage as `bridge_master.py`. +- `bridge.py` still has older reporting and bridge-rule behavior that was not + reviewed in this pass. + +### Protocol-Sensitive Areas +- DMRD sequence numbers are one byte and wrap at 255 to 0. +- A modulo delta greater than 127 is treated as old/out-of-order rather than + forward progress; this preserves the loop-control safety posture discussed + for `bridge_master.py`. +- Voice terminator state must suppress late same-stream packets without + overriding loop-control or adding a jitter buffer. + +### Inferred Invariants +- A new HBP stream must start with fresh duplicate/sequence state. +- OBP finished-stream suppression must not depend on whether the live report + socket is enabled. +- `bridge.py` backports should stay limited to bug fixes for behavior the file + already implements. + +### Resolution +- Added `dmrd_seq_delta()` to `bridge.py` and used it for existing HBP/OBP + duplicate, out-of-order and missed-packet checks. +- Reset HBP duplicate/sequence state on new streams and after voice + terminators. +- Marked OBP streams finished on terminator regardless of reporting state and + reset per-stream sequence tracking. +- Added HBP finished-stream state to suppress late same-stream packets after a + terminator. +- Added a lightweight `tests/test_bridge_backports.py` check for the shared + modulo helper and documented the narrower bridge backport coverage. + +## 2026-05-23 - `API.py` Initial Review + +### Findings +- `API.py`, `FD_APIUserDefinedContext.validateKey()`, writes `dmrid` and every + `peerid` to stdout with `print()`. That bypasses FreeDMR logging and can leak + API authentication activity into daemon stdout or supervisor logs. +- `API.py`, `FD_APIUserDefinedContext.getoptions()`, reads + `CONFIG['SYSTEMS'][system]['OPTIONS']` directly. `hblink.py` deliberately + deletes that key when an HBP session disconnects and no default options are + configured, so an authenticated API `getoptions()` can raise `KeyError` + instead of returning a stable "no current options" value. +- The former `API.py`, `FD_API.getconfig()` and `FD_API.getbridges()`, declared + `_returns=Unicode()` but returned the live `CONFIG` and `BRIDGES` + dictionaries. Those dictionaries contain bytes and nested structures, so the + declared Spyne return type did not match the actual value. +- `bridge_master.py`, `kill_server()`, reads + `CONFIG['GLOBAL']['_KILL_SERVER']` directly, but the key is only set by the + signal handler or `API.py` `killserver()`. No startup default is visible in + `config.py`, so the timed kill-server loop can raise `KeyError` before any API + or signal sets the flag. +- The former `bridge_master.py`, `config_API()`, accepted `_config` but + installed `FD_APIUserDefinedContext(CONFIG, _bridges)` using the module + global. In normal runtime this was probably the same object, but the function + ignored its parameter and was harder to test in isolation. +- The former `api_client.py` used Twisted XML-RPC against port 7080, while + `bridge_master.py` started the Spyne HTTP/JSON API on TCP port 8000. The + sample client did not match the API server configured by FreeDMR. + +### Assumptions +- The API is an optional management surface and should not affect packet routing + unless an authenticated method mutates live session/config state. +- User-level API auth is intended to use the options `KEY` associated with the + connected HBP peer/repeater. +- Although legacy HBlink can host multiple peers on one master, FreeDMR's + intended deployment model is one HBP peer per master. Config defaults and + samples set `MAX_PEERS: 1`, and the proxy maps each external peer ID to its + own backend destination port/master instance. +- System-level API auth is intended to use `GLOBAL.SYSTEM_API_KEY`, loaded or + generated at startup. +- API `getoptions()` should be safe to call even when a peer has disconnected or + has no current session options. + +### Unresolved Questions +- If config/bridge inspection is reintroduced, it should use bounded, + JSON-safe snapshots or a separate worker path so it cannot delay voice + processing in the reactor. + +### Protocol-Sensitive Areas +- API-provided `OPTIONS` feeds the same parser as HBP `RPTO` options and can + change dial-a-TG, static TG, timer, voice-ident and announcement-language + behavior. +- API reset toggles `_reset`, which is consumed by `bridge_reset()` and packet + admission guards. +- API killserver toggles `_KILL_SERVER`, which is consumed by the Twisted timed + shutdown loop. + +### Inferred Invariants +- API methods should not throw internal exceptions for normal disconnected or + no-options session states. +- API authentication details should use FreeDMR logging, not raw stdout. +- Runtime control flags should have false defaults before timed loops read them. +- Public API response declarations should match the objects returned. + +### User-Confirmed API Semantics +- `getoptions()` should return a clear response when no live options are + available. +- API `setoptions()` should receive the full `OPTIONS` string; the API should + not silently add or preserve `KEY=...`. +- User-level `reset(dmrid, key)` was intended to act on the matching HBP + session associated with the supplied DMR ID. Because FreeDMR expects one peer + per master, the current system-level `_reset` action may be the correct + implementation once `validateKey()` has proven the peer belongs to that + master. +- User confirmed nobody is likely using the experimental Spyne API and replacing + it now is preferred because Spyne dependency handling is awkward. +- The API must not delay live voice processing; request handlers should avoid + blocking work and expensive live-state serialization. + +### Resolution +- Replaced the Spyne API layer with a small Twisted HTTP/JSON resource in + `API.py`. +- Removed Spyne imports from `bridge_master.py` and removed Spyne from + `requirements.txt`. +- Kept API operations to small in-memory control-plane mutations: version, + health, reset, options get/set, system kill and resetall. +- Removed live `getconfig()`/`getbridges()` API endpoints to avoid potentially + expensive serialization in the voice process reactor. +- Added a small request-body limit so API calls cannot submit large JSON bodies + that would delay the reactor. +- Added a safe `_KILL_SERVER` default and changed the shutdown loop to read the + flag with `.get()`. +- Updated the sample API client to use HTTP/JSON on port 8000. + +## 2026-05-23 - Support Module Review, Batch 1 + +### Findings +- `AMI.py` appears to be live when `ALLSTAR.ENABLED` is true: `bridge_master.py` + creates `AMIOBJ` and dial-a-TG AllStar control calls `AMIOBJ.send_command()`. +- `bridge_master.py` logs the configured AllStar password when setting up AMI. + If AllStar is current, this is a credential exposure bug. +- `AMI.py`, `AMIClient.lineReceived()`, prints every AMI response line directly + to stdout instead of using the FreeDMR logger. This can leak operational + details and makes service logging inconsistent. +- `AMI.py` stores command and credentials on the nested `AMIClient` class object + before connecting. Closely spaced `send_command()` calls could overwrite each + other's command state before the TCP client sends it. +- `AMI.py`, `AMI.closeConnection()`, references `self.transport`, but the outer + `AMI` object is not the protocol instance and does not own that attribute. +- `utils.py`, `try_download()`, disables TLS certificate verification for alias + downloads with `ssl._create_unverified_context()`. This trades compatibility + for integrity risk on configured HTTPS alias/checksum URLs. +- `const.py` defines `ID_MAX = 16776415`, while `bridge_master.py` now uses + `DMR_ID_MAX = 16777215` for dial-a-TG validation. Because `config.py` uses + `const.ID_MAX` for ACL building, max-ID handling may be inconsistent. +- `read_ambe.py`, `readAMBE.readfiles()`, returns `False` on missing voice pack + files, but startup later iterates `words.keys()`. A missing configured audio + language can fail startup with a secondary attribute error rather than a clear + "missing voice pack" error. +- `mk_voice.py`, `pkt_gen()`, still uses a random stream ID for generated voice + prompts. Collision probability is low, but generated prompts share the live + stream namespace. + +### Assumptions +- AllStar/AMI support is optional but still intended to work when enabled. +- Alias downloads are operational convenience data, but stale or modified alias + data can affect dashboard/reporting clarity and possibly server ID metadata. +- Audio packs may be provided by deployment packaging even when not obvious from + minimal test fixtures. + +### Unresolved Questions +- Was disabled TLS verification for alias downloads intentional because of old + CA or embedded-platform compatibility problems? +- Is `const.ID_MAX = 16776415` intentional, or should shared ACL validation use + the DMR 24-bit maximum `16777215`? +- Should missing configured voice prompt languages be a clear fatal startup + error, or should FreeDMR fall back to a known shipped language? + +### User-Confirmed Status +- AllStar/AMI support is current enough to tidy up. + +### Protocol-Sensitive Areas +- AllStar control is driven from dial-a-TG private-call handling and must not + block or delay voice packet processing. +- Alias and server ID files are read at startup and may influence reporting, + validation and operational visibility. +- DMR ID and TG range constants must match the 24-bit field constraints unless a + narrower range is deliberately reserved by FreeDMR policy. +- Voice prompt packet generation shares packet timing, TG/slot routing and + stream lifecycle semantics with live DMRD traffic. + +### Inferred Invariants +- Runtime logs must not expose configured passphrases or management credentials. +- Optional external control paths should use FreeDMR logging and reactor-safe + Twisted patterns. +- Startup configuration failures should be explicit enough for sysops to fix + without packet-level debugging. + +## 2026-05-23 - Auxiliary Script Review, Batch 2 + +### Findings +- `app_template.py` and `blank_app.py` compile and look like HBlink example + scaffolding rather than current FreeDMR packet-routing services. +- `bridge_all.py` and `bridge_all_master.py` compile, but their ACL checks use + `TG1_ACL` and `TG2_ACL`. Current config parsing creates `TGID_TS1_ACL` and + `TGID_TS2_ACL`, so these scripts appear stale if they are still run. +- `bridge_all.py` and `bridge_all_master.py` use the older sequence-loss check + that was already noted as rollover-sensitive in code comments. +- `report_receiver.py` and `report_sql.py` are external reporting clients and + use `pickle.loads()` on reporting socket payloads. This is acceptable only if + the report source is trusted and local/private. +- `report_sql.py` inserts report events by formatting SQL strings directly. + Event fields originate from the reporting socket and should be parameterized + if this client is current. +- `playback.py`, `playback_file.py` and `play_ambe.py` compile, but they use + blocking `sleep()` calls in Twisted callbacks or packet receive paths. These + are suitable for lab/playback utilities, not live voice-process services. +- `docker-configs/supervisord.conf` starts `playback.py` as a supervised + process by default in the proxy image, so the blocking-playback concern may + matter in packaged deployments if that program is not intentionally enabled. +- `hotspot_proxy_v2.py` is current enough to matter: `hblink.py` has explicit + `PRIN` and `PRBL` handling and sample HBP config enables `PROXY_CONTROL`. +- `hotspot_proxy_v2.py` installs a SIGTERM handler named `sigt()` that only + prints `oooh` and does not stop the reactor. Under supervisor/container + shutdown, the proxy may not terminate cleanly on SIGTERM. +- `hotspot_proxy_v2.py` falls back to an internal default configuration if any + required `[PROXY]` option is missing or invalid. That can start a proxy on + default ports after a config typo instead of failing closed. +- `hdstack/hotspot_proxy_v2.py` is a separate older proxy copy with materially + different behavior and one visible typo path (`_data` in DMRA handling), but + it may be legacy or a special HDStack deployment copy. +- Several Dockerfiles still build from GitHub while `Dockerfile-ci` copies the + local tree. Some images therefore may not include local checkout changes when + built from this repository directory. +- `docker-configs/Dockerfile-hbmonv2` creates user `hbmon` but runs + `chown -R radio: /opt/HBMonv2`; if this Dockerfile is current, that user name + mismatch can fail image build. + +### Assumptions +- The auxiliary HBlink example scripts are less important than + `bridge_master.py`, `hblink.py`, `bridge.py`, `config.py`, proxy and Docker + packaging. +- The reporting socket is normally intended for trusted dashboards/clients, but + it may still be exposed on configured TCP interfaces. +- The top-level `hotspot_proxy_v2.py` is the main packaged proxy; the `hdstack` + copy may exist for a special multi-instance deployment. + +### Unresolved Questions +- Should Dockerfiles that clone from GitHub be kept, or should maintained + Docker builds copy the local source tree like `Dockerfile-ci`? + +### User-Confirmed Status +- `bridge_all.py` and `bridge_all_master.py` are legacy/experimental; leave + them for now. +- `playback.py`, `playback_file.py` and `play_ambe.py` are a combination of lab + tools, experimental code and legacy code; they are low priority. +- `report_receiver.py` and `report_sql.py` are lightly used/current: they feed + `https://freedmr-lh.gb7fr.org.uk/` in one deployment. +- `hdstack/hotspot_proxy_v2.py` is experimental capacity work; leave detailed + review for later. + +### Follow-Up Findings For Current Reporting Clients +- `report_sql.py`, `send_mysql()`, performs reconnect attempts and MySQL writes + directly in the Twisted reactor path. If the database stalls or reconnects + slowly, the reporting client can stop processing its TCP report stream. +- `report_sql.py`, `send_mysql()`, uses string formatting to build the `insert + into feed` SQL statement. Even on a trusted report stream, quotes or commas in + fields can break inserts; parameterized SQL would be safer and simpler. +- `report_sql.py`, `send_mysql()`, closes the cursor only on error, not after + successful inserts. +- `report_sql.py`, `reportClientFactory.buildProtocol()`, returns + `self.proto(db, reactor)` using module globals rather than `self.db` and + `self.reactor`. It works in the current `__main__` path but makes the factory + unnecessarily fragile. +- `report_receiver.py` CLI flags such as `--events 0`, `--config 0` and + `--bridges 0` are parsed as strings, so `"0"` is truthy and enables the + output. These should be real booleans or checked against `"1"`. +- Both report clients use `pickle.loads()` for config/bridge snapshots. User has + confirmed current use is a known deployment, so this should be documented as a + trusted-report-socket assumption rather than changed blindly. + +### Protocol-Sensitive Areas +- Proxy session mapping is part of the intended one-HBP-peer-per-master + architecture and must preserve HBP registration/control packet ordering. +- Proxy `PRIN` and `PRBL` packets affect client IP awareness and dynamic + firewall/source-quench behavior. +- Playback utilities synthesize or replay DMRD voice frames and can disturb + timing assumptions if run in the same reactor as live services. +- Reporting clients consume the same live-report stream used by dashboards; + field shape changes can break external consumers. + +### Inferred Invariants +- Long-running daemons under Docker/supervisor should exit cleanly on SIGTERM. +- Packaged default process lists should avoid optional lab tools unless the + deployment intentionally enables them. +- Config parse failures for network-facing daemons should fail closed rather + than silently opening default listeners. + +## 2026-05-23 - Packaging, Config and Documentation Review, Batch 3 + +### Findings +- `rules_SAMPLE.py` is an empty static-routing skeleton and compiles. +- `systemd-scripts/freedmrrepeater.service` appears stale: it uses + `/opt/FreeDMR` and `./config/hblink.cfg`, while current sample configs and + Docker entrypoints run from `/opt/freedmr` with `freedmr.cfg`. +- `pyvenv.cfg` is tracked at repository root. This makes the source checkout + look like a Python virtual environment and can confuse tooling or developers. +- Current FreeDMR/Docker config samples mostly use current ACL names + `TGID_TS1_ACL` and `TGID_TS2_ACL`. +- `FreeDMR-SAMPLE.cfg` and `FreeDMR-SAMPLE-commented.cfg` still show + `PROTO_VER: 2`, while Docker config documents `PROTO_VER: 5` for FreeDMR FBP + and `PROTO_VER: 1` for OBP/external software. +- `hblink-SAMPLE.cfg` is intentionally HBlink-flavored and lacks current + FreeDMR-only defaults such as API, dial-a-TG option defaults and proxy + control. +- `hdstack/*.cfg`, `loro.cfg` and `playback_file.cfg` appear special-purpose + configs rather than main FreeDMR defaults. +- `docker-configs/Dockerfile-noproxy`, `Dockerfile-proxy` and + `Dockerfile-hdstack` clone from remote GitHub rather than copying the local + tree, so building them from this checkout may not include current local + changes. `Dockerfile-ci` does copy the local source tree. +- `docker-configs/entrypoint-proxy` accepts `BRIDGE_SERVER=1` and runs + `bridge.py -c freedmr.cfg -r rules.py`; `bridge.py` supports `-r`, so this + entrypoint matches the maintained bridge path. +- `docs/api.md`, `docs/testing.md` and `docs/test-harness-design.md` reflect + the new HTTP/JSON API and current deterministic/UDP harness split. + +### Assumptions +- Docker is the preferred general deployment path. +- `Dockerfile-ci` is the maintained local-build path for this working tree. +- `hblink-SAMPLE.cfg` remains useful as an upstream-style reference and should + not be forced into FreeDMR master semantics unless the project wants that. + +### Unresolved Questions +- Should the non-CI Dockerfiles be kept as remote-clone recipes, or updated to + copy local source for reviewable builds? +- Should `FreeDMR-SAMPLE*.cfg` be updated from `PROTO_VER: 2` to the current + documented FreeDMR default of `5`, while retaining notes that OBP/external + bridges use `1`? + +### User-Confirmed Status +- `report_receiver.py` and `report_sql.py` are kind-of current: they are used in + one deployment to feed `https://freedmr-lh.gb7fr.org.uk/`. +- `hdstack/hotspot_proxy_v2.py` is experimental capacity work; defer until the + capacity design is discussed. +- Disabled TLS verification in alias downloads is probably intentional; leave + behavior unchanged for now. +- Docker containers are the recommended deployment path because they reduce + support overhead; the systemd unit may exist but is not the main focus. +- Root `pyvenv.cfg` probably should not be tracked. + +### Protocol-Sensitive Areas +- `PROTO_VER` controls OBP/FBP packet option ordering and available fields; test + fixtures must match the negotiated/configured protocol version. +- The service manager and container entrypoints define which components are + actually live in packaged deployments, including proxy and optional playback. + +### Inferred Invariants +- Maintained sample configs should not encourage obsolete FBP protocol versions + unless backward compatibility is being intentionally demonstrated. +- Runtime packaging should start the same code and config layout that users are + expected to operate. + +### Resolution +- Tidied current AllStar/AMI support without changing dial-a-TG packet logic: + `bridge_master.py` now redacts the configured AMI password in startup logs, + `AMI.py` logs AMI responses through the module logger instead of printing raw + lines, and AMI command/auth state is held on protocol instances rather than + shared on the protocol class. +- Changed `AMI.closeConnection()` to disconnect the Twisted connector it owns + instead of referencing a nonexistent outer `transport` attribute. +- Updated `report_sql.py` so report events schedule database writes with + `reactor.callInThread()`, use a lock around the shared DB connection, execute + the existing feed insert with parameterized SQL, close cursors in `finally`, + and build clients with the factory's `self.db` / `self.reactor`. +- Updated `report_receiver.py` so CLI flags such as `--events 0`, + `--config 0`, `--bridges 0`, and `--stats 0` parse as false. +- Removed tracked root `pyvenv.cfg`. +- Added `tests/test_auxiliary_tools.py` for AMI protocol state, report receiver + flag parsing and report SQL parameterized insert behavior. +- Updated `docs/testing.md` with the auxiliary utility coverage. + +## 2026-05-23 - Proxy And Shared Constant Review + +### Findings +- `hotspot_proxy_v2.py` parsed environment booleans with Python `bool()`. + Because `bool("0")` is true, Docker settings such as `FDPROXY_IPV6=0` enabled + the option instead of disabling it. +- `hotspot_proxy_v2.py` installed a SIGTERM handler that only printed `oooh`. + Under Docker/supervisor shutdown, the proxy could ignore the normal + termination signal until forcibly stopped. +- `hotspot_proxy_v2.py` falls back to an internal default config when `[PROXY]` + is absent or invalid. This looked risky in isolation, but the current Docker + config files do not include a `[PROXY]` section, so changing this would alter + deployment behavior and needs a packaging decision. +- `const.ID_MAX` remains `16776415` while other current code treats the DMR + 24-bit all-call value `16777215` as the upper reserved boundary. The narrower + shared ACL max may be intentional reservation or a historical typo; user was + not sure, so it is left unchanged. + +### Assumptions +- The top-level `hotspot_proxy_v2.py` is the current packaged proxy. +- Docker environment variables named with `=0` are intended to disable the + feature. +- The proxy's default internal configuration is currently part of Docker + startup behavior when no `[PROXY]` section is supplied. + +### Unresolved Questions +- Should proxy config fallback remain the default Docker behavior, or should + Docker configs grow an explicit `[PROXY]` section before the fallback is made + stricter? +- Is `const.ID_MAX = 16776415` a deliberate FreeDMR policy limit, or should ACL + validation use the full 24-bit DMR maximum/reserved all-call boundary? + +### Protocol-Sensitive Areas +- Proxy `PRIN` and `PRBL` are part of client-source awareness and dynamic + blocklisting; changes here affect HBP client admission and abuse handling. +- ACL ID maxima affect which DMR IDs and TGs can be expressed in config, even + when packet parsing itself can carry the full 24-bit field. + +### Inferred Invariants +- `FDPROXY_*` environment booleans should parse like config booleans, not Python + truthiness of non-empty strings. +- Containerized long-running daemons should handle SIGTERM as graceful shutdown. + +### Resolution +- Added `bool_from_env()` in `hotspot_proxy_v2.py` and applied it to + `FDPROXY_IPV6`, `FDPROXY_STATS`, `FDPROXY_CLIENTINFO`, and the commented debug + override path. +- Changed the proxy SIGTERM handler to use the same graceful shutdown handler as + SIGINT. +- Added auxiliary test coverage for proxy environment boolean parsing. diff --git a/docs/test-harness-design.md b/docs/test-harness-design.md new file mode 100644 index 0000000..c0c99f7 --- /dev/null +++ b/docs/test-harness-design.md @@ -0,0 +1,621 @@ +# FreeDMR End-to-End Packet Test Harness Design + +For concise commands to run these tests, see [testing.md](testing.md). + +## Scope + +FreeDMR needs two complementary packet test layers: + +1. An in-process deterministic harness for fast, isolated tests of decoded packet + handling in `bridge_master.py`. +2. A black-box UDP integration harness for realistic process, socket, login, + authentication and packet-cadence tests. + +Both layers now exist as test-only code under `tests/`. The current +implementation is intentionally small: it establishes the harness architecture, +packet builders, captures, dependency isolation, and one smoke scenario per +layer. The scenario set should be expanded incrementally without changing +production behaviour. + +## Current Implementation + +The harness code is split as follows: + +- `tests/harness/deterministic.py` + - `PacketSpec`: synthetic `DMRD` packet builder and decoded argument adapter. + - `parse_dmr_fields()`: shared parser for captured `DMRD` payload assertions. + - `PacketCapture` and `CapturedPacket`: in-process send capture. + - `ReportCapture`, `FakeClock`, `FakeReactor`, and `FakeTransport`: test + doubles for FreeDMR runtime boundaries. + - `DeterministicScenario`: isolated in-process scenario setup for + `bridge_master.py` globals and real `routerHBP` / `routerOBP` instances. + - `minimal_config()`, `active_bridge()`, and `add_openbridge_system()`: + helpers for small test topologies. +- `tests/harness/udp_blackbox.py` + - `DependencySandbox`: chooses an interpreter for starting FreeDMR, or + bootstraps a venv from `requirements.txt` when explicitly enabled. + - `write_bridge_master_config()`: emits a loopback-only subprocess config, + including optional OpenBridge/FBP peer sections. + - `FreeDmrProcess`: starts and stops `bridge_master.py`. + - `HbpRepeater`: UDP HBP client emulator with login, ping, packet send, stream + send, and capture support. + - `FbpPeer`: UDP FBP v5 peer emulator with signed packet sends, keepalive, + version negotiation, STUN and source-quench control helpers. + - `UdpBlackBoxScenario`: process plus two-master loopback topology with + optional FBP peers. +- `tests/test_deterministic_harness.py` + - Packet builder smoke coverage. + - In-process HBP static TG routing smoke coverage, skipped when runtime + dependencies needed to import `bridge_master.py` are unavailable. + - Dial-a-TG TS1 private-call control and status reporting of TS2 reflector + state, including reserved target no-op behavior. +- `tests/test_udp_blackbox_harness.py` + - Opt-in subprocess UDP coverage for two registered repeaters and static TG 91 + routing. + - Opt-in dial-a-TG prompt coverage for a reserved control private call, + asserting local TG9 TS2 announcement packets and no inter-master UDP leak. + - Opt-in FBP v5 coverage for HBP-to-FBP and FBP-to-HBP static TG routing, + source-quench suppression, and network-ID rejection. + +The concise run commands live in [testing.md](testing.md). + +## Layer 1: In-Process Deterministic Harness + +The deterministic harness bypasses UDP sockets and DMR 30 ms slot timing. The +implemented scenario path uses the router seam and supports a parser seam: + +- Parser seam: feed raw `DMRD` bytes directly to + `HBSYSTEM.master_datagramReceived()` or `OPENBRIDGE.datagramReceived()` with a + fake source address and fake transport via `DeterministicScenario.inject_datagram()`. + This tests packet parsing and transport gates without binding sockets. `DMRE` + packet-builder support is planned but not implemented yet. +- Router seam: inject already-decoded packet metadata at the smallest safe seam + around `bridge_master.py`. + +The router seam is implemented and is the default for most scenarios: + +- HBP traffic enters at `routerHBP.dmrd_received(peer_id, rf_src, dst_id, seq, + slot, call_type, frame_type, dtype_vseq, stream_id, data)`. +- OpenBridge traffic enters at `routerOBP.dmrd_received(peer_id, rf_src, dst_id, + seq, slot, call_type, frame_type, dtype_vseq, stream_id, data, hash, hops, + source_server, ber, rssi, source_rptr)`. +- Outbound traffic is captured by replacing each test system's `send_system()` + method. Production routing calls `systems[target].send_system(...)` after + applying intended rewrites, making it the narrow outbound observation point. + +The harness owns test-only state setup: + +- Build a minimal `CONFIG`, `BRIDGES`, `SUB_MAP`, aliases and `systems` map per + scenario. +- Instantiate real `routerHBP` and `routerOBP` objects. +- Replace network-facing sends and report sends with capture objects. +- Provide a fake clock by monkeypatching `bridge_master.time`. +- Generate synthetic `DMRD` payloads while preserving original bytes for + comparison. Recorded fixture loading and `DMRE` fixture generation are planned + extensions. + +This layer treats timing as explicit scenario input. A test can advance the fake +clock by 0.030 seconds per frame, by several seconds for hangtime, or by minutes +for rule timeout checks without sleeping. + +### Bugs Layer 1 Can Detect + +- Packet parsing bugs when tests use the parser seam: incorrect source, + destination, slot, call type, frame type, dtype/voice sequence or stream ID + extraction from raw bytes. +- Routing-rule mistakes: wrong target system, duplicate routing, missed bridge, + wrong active/inactive bridge selection. +- Dial-a-TG state transition bugs: reflector bridge creation, activation, + deactivation, timer reset and single-mode behaviour. +- Dial-a-TG system-scope bugs: control calls on one master should only mutate + that receiving master's TS2 reflector state, not another master's entry in the + same bridge. +- Dial-a-TG control-slot bugs: private calls from TS1 or TS2 should control the + TS2 reflector state so TS1 can disconnect or retune TS2 when TS2 RF is busy. + Status query `5000` follows the same rule and reports TS2 reflector state from + either RF slot. +- Dial-a-TG status-report bugs: private call `5000` should report one active + TS2 reflector for the receiving master. It does not repair inconsistent + multi-active reflector state. +- Dial-a-TG reserved-target bugs: private calls to local/control targets such as + `5`, `6`, `7`, and `9`, and reserved control-range targets `4001..4999`, + should not create, activate or retune reflector state. The `4001..4999` + range should report busy rather than announce a successful link. +- Dial-a-TG AllStar-control bugs: private call `8` is an AllStar mode control + target. When AllStar is disabled it reports busy; when enabled it enters + AllStar mode and schedules reset. It should not create or retune reflector + state or announce a dial-a-TG link. +- Dial-a-TG default-reflector configuration bugs: startup and live options reload + should use the same prohibited default-reflector targets. Reserved/control + targets such as `6`, `7`, and AllStar control target `8` should not create an + active default TS2 reflector at startup. The FreeDMR policy cap should match + RF dial-a-TG handling: `999999` is valid and higher default reflectors are + rejected. Invalid default-reflector options should disable any existing + default reflector for the current session rather than preserving stale TS2 TG9 + reflector state. Invalid startup defaults should be logged and should not + create bridge state; the in-memory effective default should normalize to `0` + without writing back to the config file. System-wide defaults are intended for + sparing use; client requested settings are preferential. +- Static TG configuration bugs: startup and live options reload should reject + prohibited local/control TGs consistently on both TS1 and TS2 after parsing the + configured TG strings to integers. Invalid IDs at or above `16777215` should + be rejected consistently. Simple whitespace should be normalized away. Invalid + tokens inside one static TG list should be skipped without blocking other + valid tokens or the other slot's valid list. A prohibited TS1 static TG should + not be logged as ignored and then created anyway. +- Client options parsing bugs: malformed independent numeric fields such as + `IDENTTG=A`, `VOICE=A`, or `SINGLE=A` should not abort otherwise valid session + options in the same string. `VOICE` and `SINGLE` accept only `0` or `1`. + Empty `DIAL` / `DEFAULT_REFLECTOR` is equivalent to `0` and means no default + reflector. Invalid `TIMER` values should be logged and should not block valid + static TG changes, which should use the current effective timer. +- Voice ident override bugs: `OVERRIDE_IDENT_TG` should be parsed before packet + generation. Valid positive TGs below all-call are used as the destination; + empty or false override values use all-call; malformed, out-of-range, or + local/control TG values are logged and fall back to all-call. +- Voice prompt stream scoping bugs: generated prompt helpers should use the + router instance's `self._system` for stream bookkeeping. A stale module-level + `system` loop variable must not cause prompt packets for one master to mutate + another master's status. +- Voice ident lifecycle bugs: generated ident playback should use the same + prompt token/cancellation lifecycle as other generated voice helpers, so an + interrupted ident cannot leave stale cancel state that blocks later idents. +- Bridge reset lifecycle bugs: resetting a master should leave that master + represented by an inactive bridge entry, preserve unrelated bridge entries, + and keep reflector activation triggers such as `ON=[235]` for `#235` bridges. +- HBP reset/reload admission bugs: packets should be admitted when lifecycle + flags are absent or false, and should be dropped with a one-shot log while the + receiving master is actively resetting or reloading options. +- Data packet reporting bugs: HBP unit data forwarded to OBP should report on + the OBP target system and reporting must not raise after the packet has + already been sent. +- Data packet metadata bugs: HBP unit data forwarded to OpenBridge/FBP should + preserve BER/RSSI send metadata just like the group/voice path, while + DATA-GATEWAY remains a protocol-v1 SMS/GPS path and must not be evaluated as + an FBP peer. +- OBP unit-data FBP metadata bugs: unit data forwarded from one FBP peer to + another should preserve source server, source repeater for protocol versions + that support it, hops, BER and RSSI without treating lower protocol versions + as if they carried every field. +- OpenBridge parser bugs: truncated `DMRE` datagrams should be logged and + discarded before fixed-offset metadata parsing, so malformed UDP input cannot + raise out of the parser. +- OpenBridge bridge-control bugs: generated `BCST` STUN packets should validate + against the same signed bytes on receive and set the traffic gate that + production OpenBridge send/receive paths already check. +- OpenBridge source-quench bugs: dial-a-TG reflector forwarding from HBP to FBP + should apply `BCSQ` using the reflector TG carried on FBP, not local TG9. +- HBP parser bugs: truncated `DMRD` datagrams from connected peers should be + logged and discarded before fixed-offset header parsing reaches decoded packet + handling. +- Data packet HBP target-slot reporting bugs: unit data forwarded to HBP via + `SUB_MAP` should report the explicit target slot `_d_slot`, matching the + captured packet slot bits. +- Group-addressed data reporting bugs: group data headers and data continuation + blocks routed over TG bridges should be reported as data, not as `GROUP VOICE` + lifecycle events. Timeout cleanup should not create voice end events for + data-only state. +- Voice LC rewrite boundary bugs: embedded-LC rewrite should apply only to voice + bursts B-E and must not mutate data-sync/control payload bytes while forwarding + over HBP or FBP paths. +- HBP group/VCSBK rate-control bugs: same-timestamp packet bursts should not + raise during local packet-rate calculation before duplicate/drop handling can + run. +- OBP group voice rate-control bugs: per-stream packet-rate protection should + use elapsed stream duration, not the absolute stream start timestamp. +- OBP voice lifecycle/report-coupling bugs: terminators should mark streams + finished even when live reporting is disabled, so late same-stream packets do + not route because a dashboard option is off. +- OBP voice rewrite error-path bugs: missing target LC state should log with the + correct router name and should not crash while handling malformed or + inconsistent stream state. +- HBP voice lifecycle bugs: terminators should mark the slot stream finished so + late same-stream voice bursts do not route or reopen the ended stream, and + new-stream classification must not inherit stale data state from the previous + slot occupant. Terminator-only first observations on idle slots should still + close the stream locally. +- HBP/OBP voice packet-control bugs: DMRD sequence numbers are one-byte + modulo-256 values. The deterministic harness verifies streams continue after + wrap with packet loss and that sequence `0` duplicates are still rejected. +- HBP stale duplicate-state bugs: new HBP streams should reset per-stream + duplicate state such as `lastSeq` and `lastData` so a stream after a timeout + is not judged against the previous slot occupant. +- OpenBridge target lifecycle bugs: forwarded voice terminators should mark OBP + target streams finished so timeout cleanup only handles missing terminators, + not streams that already ended normally. +- HBP VCSBK reporting bugs: specific VCSBK block data reports should not be + duplicated by generic `OTHER DATA` fallback reports; unknown VCSBK types + should still use the fallback and should not create voice lifecycle reports. +- OBP unit-data loop-control bugs: same-timestamp duplicate OBP sources should + not crash diagnostic packet-rate calculations while first-source loop-control + ignores the later source. +- Enhanced OpenBridge sendability bugs: enhanced OBP targets require recent + `_bcka` keepalive state before receiving forwarded traffic. Missing or stale + keepalive state should suppress HBP-originated voice/data and OBP-originated + data without mutating packet bytes. +- Config/startup support bugs: config booleans should be parsed as booleans + across both `hblink.py` admission and `bridge_master.py` forwarding layers, + alias reload timing should use the already-normalized seconds value, alias + reloads should update both module globals and shared `CONFIG` dictionaries, + and bridge reset should tolerate session keys removed by hblink disconnect + lifecycle. +- Protocol-version-sensitive metadata: packet metadata/options and argument + ordering must be asserted against the protocol version actually in use for the + session. FBP expectations must not be applied to protocol v1 DATA-GATEWAY + traffic. +- Data packet protocol model: data packets are packet-oriented rather than + AMBE2+ audio-style streams, and may be unit addressed or group addressed to a + talkgroup. +- Dial-a-TG echo-target regressions: private call `9990` is intentionally + linkable as an echo/test target, while `9991..9999` remain information + services. +- Dial-a-TG information-service regressions: private calls to `9991..9999` + schedule the requested on-demand AMBE file and also keep the existing generic + silence speech scheduling. They should not create or retune reflector state. +- Dial-a-TG policy-range regressions: the current FreeDMR dial-a-TG link policy + caps link targets at `999999`. `999999` remains linkable; higher private-call + targets should report busy rather than announcing a successful link. +- Private voice lifecycle bugs: private unit calls such as dial-a-TG and AMI + control should not be timed out as `GROUP VOICE` RX lifecycle events. +- Dial-a-TG FBP target regressions: when a linkable reflector target is created, + matching OpenBridge/FBP systems are intentionally added as active route + targets. OpenBridge protocol versions greater than 1 are termed FBP, + FreeDMR Bridge Protocol. The current reflector creation rule excludes + `9990..9999` from FBP target creation. FBP route target lifetime follows + FreeDMR's "everything everywhere" principle: retuning or disconnecting a local + master reflector entry does not deactivate already-created FBP route targets; + source quench provides the selective behavior, and `rule_timer_loop()` clears + disconnected FBP-only route targets. +- Slot handling bugs after decoded metadata is available: wrong target slot, + incorrect slot-bit rewrite and incorrect slot-specific `STATUS` updates. +- Packet rewrite bugs in `bridge_master.py`: destination TG rewrite, stream ID + preservation, source ID preservation, LC rewrite regions and unintended byte + mutation. +- Stream lifecycle bugs in router state: duplicate detection, terminator + handling, stale stream trimming and source-timeout logic when driven by a fake + clock. +- Data-call routing bugs that depend on `SUB_MAP`, configured peer systems and + bridge state. + +### Bugs Layer 1 Cannot Detect + +- UDP socket binding, address-family, packet loss or process startup issues. +- Repeater login/authentication handshake bugs. +- Socket-level UDP receive bugs. Parser-seam tests can cover malformed payload + handling, but they do not prove the OS socket path delivers those bytes. +- Real scheduling bugs caused by Twisted reactor timing, OS buffering or packets + arriving at true 30 ms cadence. +- Interoperability bugs with real clients that depend on exact UDP source + address, port reuse, NAT behaviour or keepalive timing. +- Bugs in final transport serialization performed by production + `send_peers()`, `send_master()` or OpenBridge `send_system()` after the + deterministic capture point. + +## Layer 2: Black-Box UDP Integration Harness + +The UDP harness starts FreeDMR as a subprocess with a generated test +configuration and interacts only through UDP and observable outputs. The current +implementation emulates HBP repeaters/clients and FBP/OpenBridge peer servers. + +Implemented: + +- One or more HBP repeaters/clients, including registration/config handshake and + keepalive ping. +- One or more FBP v5 peer servers, including signed `DMRE` packet sends, + signed `BCKA`, `BCVE`, `BCSQ` and `BCST` bridge-control packets, and capture + of outbound `DMRE` traffic. +- Synthetic `DMRD` packet sends using the shared `PacketSpec`. +- Synthetic FBP v5 packets derived from `PacketSpec`, with the OpenBridge + transport envelope, timestamp, source server, source repeater, hop count, + BER/RSSI and BLAKE2b hash generated by the harness. +- Synthetic FBP v4 packets derived from `PacketSpec`, using the older metadata + layout without a source-repeater field. This is characterization/deprecation + coverage; v4 is historical and is not expected to remain a long-term protocol + contract. +- Synthetic signed v1 OpenBridge `DMRD` packets derived from `PacketSpec`, for + protocol-refusal tests on enhanced/FBP-configured links. +- Recorded packet fixtures loaded from hex-encoded UDP payload files. Replay + preserves bytes and leaves all parsing, routing and mutation to FreeDMR. +- Reusable `StreamProfile` helpers for realistic 30 ms voice-over packet + sequences with optional headers and terminators. +- Optional fixed stream cadence through `HbpRepeater.send_stream(..., + cadence_seconds=...)`, including realistic 0.030 second spacing. +- Deterministic `LinkImpairment` scheduling for fake endpoint sends. It can + model drops, duplicates, jitter, fixed/random delay and explicit per-packet + delay, while keeping runs reproducible through a seed. This is sender-side + UDP impairment only; the harness does not implement a receive-side jitter + buffer. +- Named `ImpairmentProfiles` for common patterns such as clean links, provider + VXLAN-style reordering, mobile flutter drops, burst loss and duplicated UDP + datagrams. +- UDP capture and parsed assertions for received packets. +- Subprocess stdout capture for optional warning/error log assertions. +- Loopback-only generated FreeDMR config with reports, API, AllStar, voice ident + and alias downloads disabled. The generated config supports scenario-level + knobs for global ACL fields, static TG lists and optional FBP peers. +- Black-box HBP coverage for static routing, global ACL startup parsing, + data/control payload preservation, sequence wrap, duplicate sequence `0` + suppression, terminator lifecycle suppression, recorded fixture replay, burst + loss and duplicate UDP profiles, and local generated prompt output for + dial-a-TG reserved controls. +- Black-box FBP coverage for enhanced keepalive/version setup, static TG routing + from HBP to FBP and from FBP to HBP, BCKA gating of enhanced HBP-to-FBP + forwarding, BCSQ source-quench suppression, invalid BCSQ rejection, BCST STUN + gating of OpenBridge send/receive traffic, BCVE downgrade/unsupported/invalid + handling, historical FBP v4 inbound packet layout characterization, signed v1 + OBP refusal on a v5-configured link, and rejection of inbound FBP packets with + a mismatched network ID. +- Black-box unreliable-link coverage for HBP and FBP delayed/out-of-order + packet arrival. Current tests delay sequence `1` behind sequence `2` at a + realistic 30 ms cadence and assert FreeDMR forwards `0,2` while discarding the + late `1`. The FBP case also verifies a following stream on the same trunk + still routes after the impaired stream. +- Black-box multi-stream trunk coverage for HBP-to-FBP output: one stream is + reordered and drops its late packet while a second clean stream on another TG + still traverses the same FBP peer. +- Black-box generated-prompt interruption coverage: a local TG9 TS2 generated + prompt is observed, then real HBP voice is injected and must route to another + master rather than being blocked by the prompt. +- Black-box hostile/negative packet coverage: malformed short HBP `DMRD`, + malformed short FBP `DMRE`, bad FBP hashes, stale FBP timestamps and max-hop + FBP packets are exercised against the subprocess. Bad or malformed packets + must not leak to HBP targets; stale and max-hop FBP packets must return BCSQ + source-quench for the affected TG/stream. Selected negative tests assert the + subprocess log messages as well as packet behavior. +- Runtime dependency resolution through current Python, `FREEDMR_UDP_PYTHON`, or + an opt-in venv bootstrap. The venv bootstrap installs `requirements.txt` into + the test venv and does not modify production code. + +Planned: + +- Additional unreliable-link scenarios: whole-trunk impairment warning and more + simultaneous FBP streams with different impairment profiles. +- Voice-ident interruption coverage with `VOICE_IDENT` enabled, once a reliable + short-trigger mechanism is added to the generated test config. Production + currently starts the ident loop after a fixed 914 second interval, so a fast + subprocess test would need a test hook or a long-running opt-in mode. +- A third opt-in Docker/proxy integration layer for packaged deployments that + run the hotspot proxy by default. Proxy/firewall tests should avoid modifying + real host firewall state unless isolated by Docker or a fake command runner. + Related firewall code may live outside this repo and should be inspected only + when network access is explicitly needed. + +The UDP harness should capture outbound UDP packets using local sockets bound to +the emulated client or peer addresses. Assertions should parse captured UDP +payloads and compare observable behaviour: + +- Which emulated endpoint received traffic. +- Packet counts, order and timing windows. +- Header fields, slot bit, source, destination, stream ID, BER/RSSI and OBP + metadata. +- Keepalive, registration and source-quench behaviour. +- Absence of unintended traffic to real network addresses. + +The subprocess config must bind only to loopback and ephemeral or test-reserved +ports. Test config files should disable production reports, API, voice ident, +AllStar and external alias downloads unless a scenario explicitly covers them. + +The UDP harness can run FreeDMR under: + +- the current Python interpreter, when all runtime dependencies are already + installed; +- an explicit interpreter selected with `FREEDMR_UDP_PYTHON=/path/to/python`; +- an opt-in virtualenv created by the harness when + `FREEDMR_UDP_BOOTSTRAP_VENV=1` is set. + +When bootstrapping is enabled, dependencies are installed from `requirements.txt` +inside the venv. Set `FREEDMR_UDP_VENV_DIR=/path/to/venv` to reuse a persistent +test venv; otherwise a temporary venv is used for the scenario. + +### Bugs Layer 2 Can Detect + +- UDP parsing and raw packet validation bugs in `hblink.py`. +- Authentication, registration, keepalive and peer timeout bugs. +- HMAC/BLAKE2 hash handling for OpenBridge versions. +- Transport serialization bugs after `bridge_master.py` calls `send_system()`. +- Bugs caused by FreeDMR startup config, process lifecycle, Twisted reactor + scheduling or socket binding. +- Cadence-sensitive bugs: packet-rate limiting, duplicate/out-of-order handling + under realistic arrival spacing and jitter. +- Regressions against FreeDMR's real-time discard model: delayed packets should + not be re-emitted in corrected order or override loop-control/source-quench + decisions. +- Robustness bugs in malformed/hostile UDP handling: short datagrams, bad FBP + hashes, stale timestamps and max-hop enforcement should be logged/ignored or + quenched without crashing or forwarding invalid traffic. +- Bridge-control state bugs visible over UDP: missing enhanced keepalive should + suppress enhanced target forwarding, invalid BCSQ must not suppress streams, + and valid BCST STUN should block OpenBridge traffic without being confused + with unrelated HBP-to-HBP routing. +- Version-negotiation bugs visible over UDP: BCVE downgrade, unsupported version + or invalid hash must not mutate the configured outbound behavior, and v4 + packet fixtures characterize the historical v4 metadata layout. +- Known protocol-version issues can be carried as expected-failure black-box + tests until runtime behavior is changed: unsupported embedded `DMRE` versions + are currently not rejected, and the v4 send layout currently carries the + module default version byte instead of the configured `PROTO_VER` value. v4 + is historical/deprecation context, not a desired long-term compatibility + target. +- Protocol-refusal bugs visible over UDP: signed v1 OBP packets on a + v5-configured link should produce BCVE and should not leak to HBP targets. + v1 itself remains supported as an open OBP interop protocol, especially for + external network bridge instances through `bridge.py`; direct + `bridge_master.py` FBP tests only assert refusal when a link is configured for + v5. +- `bridge.py` backport checks are intentionally narrower than the + `bridge_master.py` harness. Current coverage verifies source-level shared + sequence arithmetic and uses `py_compile` for syntax; full packet-path + behavior remains covered through the main deterministic and UDP harnesses + unless a dedicated bridge-instance runtime harness is added. +- Observable interoperability regressions between emulated repeaters, clients + and peer servers. +- Generated voice prompt/ident regressions that are externally visible as + blocked or missing real HBP traffic. + +### Bugs Layer 2 Cannot Detect + +- Internal state transitions that have no observable UDP effect unless extra + reporting or logs are asserted. +- Exact branch-level causes for routing decisions without coupling tests to + logs or report streams. +- RF-side behaviour outside the UDP protocol, such as real radio timing, + repeater firmware quirks and modem-level DMR slot contention. +- AMBE recovery, terminal late entry, MMDVM jitter buffering or RF-path stream + recovery decisions. FreeDMR-owned stream IDs and UDP/IP impairment are the + model under test here. +- Rare internet or NAT behaviour unless the harness is extended beyond loopback. +- Proxy packaging behaviour, hotspot-proxy multiplexing and firewall/iptables + integration until a third Docker/proxy harness is added. + +## Shared Packet and Fixture Model + +Both layers share packet builders and capture parsing today, and should share +fixture readers once recorded fixtures are added: + +- `PacketSpec` represents intent: client/repeater identity, slot, source ID, + destination TG or unit ID, stream ID, sequence, frame type, call type, + dtype/voice sequence, payload bytes and optional frame delay. +- Synthetic fixtures build canonical `DMRD` payload bytes from `PacketSpec`. +- Recorded fixture support is not implemented yet. When added, fixtures should + keep raw bytes plus sidecar metadata describing expected decoded fields and + allowed rewrite regions. +- Transport simulation and protocol mutation are separate. Builders may create + valid transport envelopes; only production code may perform route-driven + rewrites. Tests compare original and captured bytes with explicit allowed + rewrite ranges. + +## Capture and Assertions + +The deterministic harness captures calls to `send_system()` before real network +traffic. The UDP harness captures datagrams at socket boundaries. Both should +produce a common capture record where possible: + +- Target system or endpoint. +- Exact packet bytes at that layer. +- Parsed DMR fields: peer/network ID, source, destination, slot, call type, + frame type, dtype/voice sequence and stream ID. +- Transport metadata when present: source server, source repeater, hops, BER, + RSSI, hash/version fields and UDP address. +- Scenario time or wall-clock receive time. + +Assertions should be grouped by intent: + +- Routing assertions: recipient set, non-recipient set, count and order. +- Byte preservation assertions: unchanged bytes outside allowed rewrite ranges. +- Rewrite assertions: TG, slot bit, LC and transport envelope changes. +- State assertions: `STATUS`, bridge `ACTIVE`, timers, `SUB_MAP`, report events. +- Timing assertions: deterministic fake-clock checks in layer 1, wall-clock + windows in layer 2. + +## Risks and Limitations + +- `bridge_master.py` relies heavily on module globals. Deterministic scenarios + must isolate and restore `CONFIG`, `BRIDGES`, `SUB_MAP`, aliases, `AMIOBJ` and + `hblink.systems`. +- Direct `dmrd_received()` injection bypasses transport gates by design. Any + test claiming login, HMAC, UDP parsing or real cadence coverage belongs in the + UDP layer. +- Minimal synthetic voice payloads may not be sufficient for scenarios that + assert full LC encoding. Recorded fixtures or carefully generated payloads + should be used for those cases. +- Embedded LC can carry information such as embedded GPS and talker alias. The + current harness protects against accidental mutation of data/control packets, + but it does not yet verify future source-to-destination embedded-LC carry-over. +- Generated prompt interruption is covered in both layers for state and + UDP-visible routing. The harness still does not prove RF-side audio behavior + or how a physical repeater/radio reacts to an abandoned prompt without a + terminator. +- Fake-clock tests can hide real scheduling issues. UDP cadence tests should + cover packet-rate and timeout behaviour before relying on the harness for + release confidence. +- Black-box UDP tests are slower and more brittle. They should cover a small + number of high-value flows, while the deterministic layer carries most routing + and rewrite coverage. + +## Current Scenario Coverage + +- `PacketSpec` builds parseable `DMRD` payloads. +- Deterministic HBP group packet routes to an active static TG target. +- Deterministic cross-slot routing tests verify that TS1-to-TS2 routing rewrites + only the slot bit while preserving source ID, destination TG, peer ID, stream + ID and packet bytes outside the expected header bit. +- Deterministic dial-a-TG tests verify that TS1 private calls create, retune, + disconnect and query TS2 reflector state without emitting network traffic. +- Deterministic generated-prompt tests verify first-packet prompt state, prompt + cancellation when real HBP voice wins the same slot, and embedded-LC rewrite + for late entry after cancellation. +- Deterministic status tests verify that `5000` reports one active reflector for + the receiving master even if stale multi-active state exists. +- Deterministic dial-a-TG scope tests verify that disconnecting or retuning on + one master does not mutate another master's reflector entry. +- Deterministic reserved-target tests verify that TS1 private calls to `5`, `6`, + `7` and `9` do not create or retune reflector bridges. +- Deterministic AllStar-control tests verify that target `8` reports busy when + AllStar is disabled, enters AllStar mode when enabled, and never creates or + retunes dial-a-TG reflector state. +- Deterministic reserved control-range tests verify that TS1 private calls to + `4001..4999` do not create or retune reflector bridges and report busy rather + than linked. +- Deterministic echo-target tests verify that TS1 private call `9990` creates + and activates the TS2 reflector state and announces a link. +- Deterministic information-service tests verify that `9991..9999` schedules + both the requested AMBE file and the generic silence prompt without creating a + reflector. +- Deterministic policy-range tests verify that `999999` is still linkable while + `1000000` does not create or retune reflector state and reports busy rather + than linked. +- Deterministic default-reflector tests verify that startup rejects reserved + control targets `6`, `7`, and `8`, while still allowing a linkable default + reflector target to create an active TS2 reflector. They also verify + `999999` remains valid and startup/options reject default reflector targets + above that policy cap, with invalid options disabling any active default + reflector state and invalid startup defaults producing a warning while + normalizing runtime state to `0`. +- Deterministic static-TG configuration tests verify that startup rejects + prohibited TS1 and TS2 static TGs after integer parsing, rejects invalid IDs + at or above `16777215`, and that options reload rejects prohibited or + out-of-range TS1 static TGs rather than creating them after logging the + prohibition. They also verify whitespace normalization and token-level + skipping of invalid static TG tokens while valid tokens still apply. +- Deterministic options parser tests verify malformed independent numeric fields + do not block valid DIAL/static fields, boolean-like options reject values other + than `0` or `1`, empty `DIAL` disables default reflector state, and invalid + `TIMER` values are logged without blocking valid static TG changes. +- Deterministic voice-ident tests verify override destination selection for + valid string TGs, empty/false overrides, malformed values, control TGs, and + all-call. +- Deterministic FBP-target tests verify that linkable dial-a-TG reflector + creation adds active FBP route targets where the current production rule + permits it, and that those route targets remain active across local master + retunes and disconnects until `rule_timer_loop()` removes disconnected + FBP-only reflector bridges. +- UDP black-box HBP repeaters register with FreeDMR and observe static TG 91 + routing over real UDP. +- UDP black-box dial-a-TG tests verify that a reserved control private call + emits a local TG9 TS2 prompt without sending traffic to another master. +- UDP black-box FBP bridge-control tests verify that enhanced targets require + BCKA before HBP-to-FBP forwarding, invalid BCSQ does not suppress a stream, + and valid BCST STUN blocks OpenBridge traffic in both directions. +- UDP black-box FBP version tests verify that BCVE downgrade, unsupported + version and invalid hash do not change outbound packet version, and that + historical v4 packet fixtures currently route using the older metadata layout. + This v4 coverage is characterization/deprecation context. +- UDP black-box OBP-v1 refusal tests verify that a signed v1 packet received on + a v5-configured link receives BCVE and does not route onward. + +## Next Deterministic Scenario Tests + +1. HBP group voice routes to another HBP master on the same TG. + The current smoke test covers a single packet. Extend it to a header, burst + and terminator stream and assert expected LC rewrite regions. + +2. HBP slot rewrite when bridge targets a different slot. + Build `MASTER-A` active on TG 91 slot 1 and `MASTER-B` active on TG 91 slot + 2. Inject a slot 1 packet from `MASTER-A`. Assert captured traffic to + `MASTER-B` has the slot bit flipped to slot 2 while source ID and stream ID + remain unchanged. + +3. Dial-a-TG timeout lifecycle. + Build one master system with default UA timer enabled and an active TS2 + reflector bridge. Advance fake time and run the timer path to assert the + bridge deactivates without emitting network traffic. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..e779e6f --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,258 @@ +# Testing + +FreeDMR currently has two packet test harness layers under `tests/`. + +## Deterministic Harness + +The deterministic harness runs in-process. It bypasses UDP sockets and captures +calls that would otherwise send network traffic. + +Run it with: + +```bash +PYTHONDONTWRITEBYTECODE=1 python -m unittest tests.test_deterministic_harness -v +``` + +If FreeDMR runtime dependencies are not installed, tests that import +`bridge_master.py` are skipped. Pure harness tests still run. + +The deterministic suite includes static TG routing and packet rewrite coverage. +It verifies cross-slot TS1-to-TS2 routing changes only the expected slot bit +while preserving packet identity fields and bytes outside that header bit. + +API controller coverage verifies the experimental HTTP/JSON API performs only +small in-memory control-plane operations, returns a clear no-options response, +preserves caller-supplied `OPTIONS` strings unchanged, validates peer/system +keys, and exposes JSON error responses without requiring Spyne. + +Auxiliary utility coverage verifies small non-packet helpers used by optional +operations: AMI client factories keep per-command state on protocol instances, +report receiver CLI flags parse `0` as false, and the SQL report client uses +the factory-held database object with parameterized inserts. It also covers the +hotspot proxy environment boolean parser so Docker settings such as +`FDPROXY_IPV6=0` disable the feature. + +The deterministic suite includes dial-a-TG coverage. It verifies that private +calls from TS1 can create, retune, disconnect and query the TS2 reflector state. +It verifies these state changes are scoped to the receiving master system. +Status query `5000` reports one active reflector and does not repair stale +multi-active state. +It also verifies reserved local/control targets do not create or retune reflector +bridges, and that reserved control-range targets `4001..4999` report busy rather +than announcing a successful link. Target `8` is covered as an AllStar control +target, not a dial-a-TG link target. Private call `9990` is covered as an +intentional echo/test link target. Information-service targets `9991..9999` +are covered as scheduling both the requested AMBE file and the generic silence +prompt without creating reflector state. The current FreeDMR dial-a-TG policy +cap is covered: `999999` remains linkable and higher targets report busy rather +than linked. Private dial-a-TG timeout coverage verifies private unit calls do +not emit unmatched `GROUP VOICE,END,RX` lifecycle events. Startup default-reflector handling is covered so reserved/control +targets `6`, `7`, and `8` are rejected, `999999` is accepted, and higher targets +are rejected. Invalid default-reflector options disable the effective TS2 TG9 +default reflector for the session; invalid startup defaults are logged and do +not create bridge state, with the in-memory effective default normalized to `0`. +A linkable target can still create an active TS2 default reflector. Static TG startup and options handling is +covered so prohibited local/control TGs are rejected consistently on both TS1 +and TS2, and invalid IDs at or above `16777215` are rejected while linkable +static TGs are still created. Static TG option parsing also covers simple +whitespace normalization and token-level skipping of invalid tokens so valid +tokens in the same TS1 or TS2 list still apply. Client options parsing covers +malformed independent numeric fields so valid DIAL/static fields still apply, +`VOICE` and `SINGLE` accept only `0`/`1`, and empty `DIAL` disables the default +reflector. Invalid `TIMER` values are logged and static TG changes continue +using the current effective timer. Voice ident override coverage verifies valid +override TGs are used, empty/false overrides use all-call, and malformed, +control, or all-call override values are logged and fall back to all-call. +Generated voice prompt helper coverage verifies prompt stream state is attached +to the router instance sending the prompt, even if a stale module-level +`system` variable names another master. This protects dial-a-TG prompts, idents, +disconnected announcements and on-demand files from cross-system status +corruption. Prompt lifecycle coverage also verifies the first generated packet +records prompt activity, real HBP voice cancels a generated prompt instead of +being blocked by it, and late-entry embedded LC rewrite still occurs for the +real voice burst after cancellation. Voice ident lifecycle coverage verifies an +interrupted ident does not leave stale prompt-cancel state that blocks a later +ident. +Bridge reset coverage verifies a reset master remains represented by its own +bridge entry, unrelated master and FBP entries are not duplicated or rewritten, +and `#` reflector activation triggers survive reset. +HBP packet admission coverage verifies reset/reload lifecycle flags are optional +false-default booleans: packets continue when both flags are false or absent, +and packets are dropped with one log record while either lifecycle flag is true. +Data packet coverage verifies HBP unit data forwarded to OBP systems still +emits reporting on the OBP target when reporting is enabled, without changing +the captured packet destination or raising from a reporting side effect. It also +verifies HBP unit data preserves BER/RSSI send metadata when forwarded to +OpenBridge/FBP targets; DATA-GATEWAY remains present for protocol-v1 SMS/GPS +handling and is not treated as an FBP peer. OBP-originated unit data forwarded +to another FBP peer is covered for source server, source repeater, hops, BER and +RSSI metadata preservation. +The OpenBridge parser seam is covered for truncated `DMRE` packets so malformed +UDP input is logged and discarded before fixed-offset parsing can raise. +Enhanced OpenBridge bridge-control coverage verifies a valid generated `BCST` +STUN packet sets the global `STUN` traffic gate. +Dial-a-TG source-quench coverage verifies HBP-to-FBP reflector forwarding checks +`BCSQ` against the reflector TG visible on FBP, not the local TG9 control path. +The HBP master parser seam is covered for truncated `DMRD` packets from a +connected peer so malformed client traffic is discarded before decoded packet +handling. +Unit data forwarded to HBP via `SUB_MAP` is covered for both HBP and OBP +sources; captured packet slot bits and TX report slot metadata must both match +the target HBP slot. +Group-addressed data reporting is covered for HBP and OBP sources; group data +headers and data continuation blocks must emit data `RX/TX` events and must not +generate `GROUP VOICE` timeout lifecycle events, while ordinary group voice still emits +voice start/end reports. HBP group data rate-drop coverage verifies +same-timestamp packet bursts do not divide by zero before duplicate/drop +handling can run. +Data-sync control payload preservation is covered across HBP-to-HBP, HBP-to-FBP, +FBP-to-HBP and FBP-to-FBP forwarding so voice embedded-LC rewrite does not mutate +VCSBK/control payload bytes. +OBP group voice rate-drop coverage verifies per-stream packet-rate protection is +calculated from elapsed stream duration rather than the absolute stream start +timestamp. OBP voice lifecycle coverage verifies a voice terminator marks the +stream finished even when live reporting is disabled, so late packets with the +same stream ID are suppressed independently of dashboard configuration. +OBP voice rewrite error-path coverage verifies missing target embedded-LC state +is logged with the handling router name and does not crash packet processing. +HBP voice lifecycle coverage verifies a voice terminator marks the slot stream +finished, so late same-stream voice bursts are suppressed instead of reopening +or routing the ended stream. It also verifies a new voice terminator observed +after a group data packet uses the current packet's voice classification, not +stale data state from the previous slot occupant, and that an idle-slot +terminator-only voice packet still marks the stream finished. +HBP and OBP voice packet-control coverage verifies DMRD sequence numbers are +handled as modulo-256 values: a stream can route through `254`, `255`, then `2` +with the missing post-wrap packets counted as loss rather than being rejected +as out-of-order. Sequence `0` duplicate handling is also covered. HBP +new-stream duplicate-state coverage verifies a stream following a timed-out +prior stream does not inherit `lastSeq`/`lastData` and false packet loss from +the previous slot occupant. +The `bridge.py` conference-bridge backport has a lightweight source-level test +for the shared modulo-256 sequence helper. Full runtime coverage remains in the +`bridge_master.py` deterministic and UDP harnesses because importing `bridge.py` +requires the deployed FreeDMR runtime dependencies. +OpenBridge target lifecycle coverage verifies forwarded voice terminators mark +target streams finished for HBP-to-OBP and OBP-to-OBP paths, preventing the +timeout trimmer from later emitting duplicate `GROUP VOICE,END,RX` events for +streams that already ended normally. +HBP VCSBK reporting verifies specific VCSBK block RX events are not duplicated +by the generic `OTHER DATA` fallback, while unknown VCSBK types still use the +fallback event. Unknown VCSBK reports are covered for HBP and OBP sources and +must not generate `GROUP VOICE` lifecycle events. +OBP unit-data loop-control coverage verifies same-timestamp duplicate OBP +sources do not raise from diagnostic packet-rate calculation and still mark the +later source as loop-controlled. +Enhanced OpenBridge keepalive coverage verifies missing or stale `_bcka` state +suppresses forwarding to enhanced OBP targets for HBP-originated voice/data and +OBP-originated data, while recent keepalive state permits forwarding. +Config/startup support coverage verifies `GLOBAL.USE_ACL: False` is parsed as a +boolean false, alias stale days are converted to seconds exactly once, periodic +alias reload updates both `bridge_master.py` globals and the shared `CONFIG` +alias dictionaries read by `hblink.py`, and bridge reset tolerates a missing +session `OPTIONS` key after HBP disconnect/timeout lifecycle cleanup. +Linkable dial-a-TG reflector creation is covered for FBP route targets; +OpenBridge protocol +versions greater than 1 are termed FBP, FreeDMR Bridge Protocol. FBP route +targets follow FreeDMR's "everything everywhere" principle and remain active +across local master retunes and disconnects; source quench provides selective +behavior, and `rule_timer_loop()` clears disconnected FBP-only route targets. + +## Black-Box UDP Harness + +The UDP harness starts `bridge_master.py` as a subprocess with a generated +loopback-only test config. It emulates HBP repeaters and FBP/OpenBridge peer +servers over UDP, performs HBP login, sends signed packets/control messages, and +captures outbound UDP packets. + +Current UDP scenarios cover HBP registration/config handshake, static TG +routing, global `USE_ACL: False` startup parsing observed through packet +admission, data-sync/control payload preservation, modulo-256 voice sequence +wrap, sequence `0` duplicate suppression, voice terminator suppression of late +same-stream packets, recorded HBP fixture replay, and a dial-a-TG reserved +control private call that emits a local TG9 TS2 prompt without leaking traffic +to another master. They now also cover FBP v5 static TG routing in both +directions, FBP keepalive/version control setup, BCKA gating of enhanced +HBP-to-FBP forwarding, BCSQ source-quench suppression of HBP-to-FBP forwarding, +rejection of invalid BCSQ, BCVE downgrade/unsupported/invalid-version handling, +current FBP v5 packet handling, historical FBP v4 characterization, and signed +v1 OBP packet refusal on a v5-configured link. v1 remains an important open OBP +interop protocol for external network bridge instances, primarily through +`bridge.py`; the `bridge_master.py` UDP test here only verifies that a +v5-configured FBP link refuses v1 traffic. They also cover rejection of FBP +packets carrying the wrong OpenBridge network ID. Valid BCST STUN is covered as +an OpenBridge traffic gate in both directions; ordinary HBP-to-HBP routing is +not the target of that gate. The UDP +harness also includes deterministic `LinkImpairment` scheduling for fake +endpoint sends; current scenarios use it to delay sequence `1` behind sequence +`2` at a 30 ms cadence, model burst loss and duplicate UDP datagrams, and assert +that late out-of-order or duplicate HBP and FBP packets are discarded rather +than buffered or replayed. Reusable `StreamProfile` and `ImpairmentProfiles` +helpers provide named stream and link patterns for more real-world scenarios. +Current coverage also includes a multi-stream HBP-to-FBP trunk case where one +stream is reordered while another clean stream on the same FBP trunk still +routes, plus a generated prompt interruption case where real HBP voice routes +after a local TG9 TS2 prompt has started. Negative-path coverage includes +malformed short HBP `DMRD`, malformed short FBP `DMRE`, bad FBP BLAKE2b hashes, +stale FBP timestamps and max-hop FBP packets; these must not leak traffic to HBP +targets, and stale/max-hop FBP packets must produce a source-quench response. +Selected malformed packet tests also assert subprocess warning logs. + +Two UDP tests are marked as expected failures because they document current +protocol-version issues rather than fixed behavior: unsupported embedded `DMRE` +packet versions are not yet rejected, and the historical v4 send layout +currently carries the module default version byte instead of the configured +`PROTO_VER` value. v4 is characterization/deprecation context, not a long-term +protocol contract. + +UDP integration tests are opt-in: + +```bash +FREEDMR_RUN_UDP_TESTS=1 \ +PYTHONDONTWRITEBYTECODE=1 \ +python -m unittest tests.test_udp_blackbox_harness -v +``` + +If dependencies are already installed in another Python, point the harness at it: + +```bash +FREEDMR_RUN_UDP_TESTS=1 \ +FREEDMR_UDP_PYTHON=/path/to/python \ +PYTHONDONTWRITEBYTECODE=1 \ +python -m unittest tests.test_udp_blackbox_harness -v +``` + +To let the harness create a virtualenv and install `requirements.txt`: + +```bash +FREEDMR_RUN_UDP_TESTS=1 \ +FREEDMR_UDP_BOOTSTRAP_VENV=1 \ +PYTHONDONTWRITEBYTECODE=1 \ +python -m unittest tests.test_udp_blackbox_harness -v +``` + +The venv bootstrap installs `requirements.txt` into the test virtualenv when the +selected Python does not already have the FreeDMR runtime dependencies. + +To reuse a persistent test virtualenv: + +```bash +FREEDMR_RUN_UDP_TESTS=1 \ +FREEDMR_UDP_BOOTSTRAP_VENV=1 \ +FREEDMR_UDP_VENV_DIR=/tmp/freedmr-blackbox-venv \ +PYTHONDONTWRITEBYTECODE=1 \ +python -m unittest tests.test_udp_blackbox_harness -v +``` + +## Full Test Discovery + +Run all tests with: + +```bash +PYTHONDONTWRITEBYTECODE=1 python -m unittest discover -v +``` + +The black-box UDP tests still skip unless `FREEDMR_RUN_UDP_TESTS=1` is set. + +See [test-harness-design.md](test-harness-design.md) for the harness design and +coverage tradeoffs. diff --git a/hblink.py b/hblink.py index 106fe83..3b3a51f 100755 --- a/hblink.py +++ b/hblink.py @@ -377,8 +377,16 @@ class OPENBRIDGE(DatagramProtocol): return elif _packet[:4] == DMRE: - + if len(_packet) < 56: + h,p = _sockaddr + logger.warning('(%s) FreeBridge packet too short, discarded - OPCODE: %s LENGTH: %s SRC IP: %s SRC PORT: %s', self._system, _packet[:4], len(_packet), h, p) + return + if _packet[55] > 4: + if len(_packet) < 89: + h,p = _sockaddr + logger.warning('(%s) FreeBridge v%s packet too short, discarded - OPCODE: %s LENGTH: %s SRC IP: %s SRC PORT: %s', self._system, _packet[55], _packet[:4], len(_packet), h, p) + return _data = _packet[:53] _ber = _packet[53:54] _rssi = _packet[54:55] @@ -393,6 +401,10 @@ class OPENBRIDGE(DatagramProtocol): _h = blake2b(key=self._config['PASSPHRASE'], digest_size=16) _h.update(_packet[:73]) else: + if len(_packet) < 85: + h,p = _sockaddr + logger.warning('(%s) FreeBridge v%s packet too short, discarded - OPCODE: %s LENGTH: %s SRC IP: %s SRC PORT: %s', self._system, _packet[55], _packet[:4], len(_packet), h, p) + return _data = _packet[:53] _ber = _packet[53:54] _rssi = _packet[54:55] @@ -595,10 +607,10 @@ class OPENBRIDGE(DatagramProtocol): if _packet[:4] == BCST: #_data = _packet[:11] _hash = _packet[4:] - _ckhs = hmac_new(self._config['PASSPHRASE'],_packet[4:],sha1).digest() + _ckhs = hmac_new(self._config['PASSPHRASE'],_packet[:4],sha1).digest() if compare_digest(_hash, _ckhs): - logger.trace('(%s) *BridgeControl* BCST STUN request received for TGID: %s, Stream ID: %s',self._system,int_id(_tgid), int_id(_stream_id)) - self._config['_STUN'] = True + logger.trace('(%s) *BridgeControl* BCST STUN request received',self._system) + self._CONFIG['STUN'] = True else: h,p = _sockaddr logger.warning('(%s) *BridgeControl* BCST invalid STUN, packet discarded - OPCODE: %s DATA: %s HMAC LENGTH: %s HMAC: %s SRC IP: %s SRC PORT: %s', self._system, _packet[:4], repr(_packet[:53]), len(_packet[53:]), repr(_packet[53:]),h,p) @@ -843,6 +855,9 @@ class HBSYSTEM(DatagramProtocol): # Extract the command, which is various length, all but one 4 significant characters -- RPTCL _command = _data[:4] if _command == DMRD: # DMRData -- encapsulated DMR data frame + if len(_data) < 53: + logger.warning('(%s) DMRD packet too short, discarded - LENGTH: %s SRC IP: %s SRC PORT: %s', self._system, len(_data), _sockaddr[0], _sockaddr[1]) + return _peer_id = _data[11:15] if _peer_id in self._peers \ and self._peers[_peer_id]['CONNECTION'] == 'YES' \ @@ -1094,6 +1109,9 @@ class HBSYSTEM(DatagramProtocol): _command = _data[:4] if _command == DMRD: # DMRData -- encapsulated DMR data frame + if len(_data) < 53: + logger.warning('(%s) DMRD packet too short, discarded - LENGTH: %s SRC IP: %s SRC PORT: %s', self._system, len(_data), _sockaddr[0], _sockaddr[1]) + return _peer_id = _data[11:15] if self._config['LOOSE'] or _peer_id == self._config['RADIO_ID']: # Validate the Radio_ID unless using loose validation #_seq = _data[4:5] diff --git a/hotspot_proxy_v2.py b/hotspot_proxy_v2.py index fc56d36..6d1e754 100644 --- a/hotspot_proxy_v2.py +++ b/hotspot_proxy_v2.py @@ -35,6 +35,9 @@ __license__ = 'GNU GPLv3' __maintainer__ = 'Simon Adlem G7RZU' __email__ = 'simon@gb7fr.org.uk' +def bool_from_env(value): + return str(value).strip().lower() in ('1', 'true', 'yes', 'on') + def IsIPv4Address(ip): try: ipaddress.IPv4Address(ip) @@ -362,26 +365,23 @@ if __name__ == '__main__': print('(PROXY)(GLOBAL) SHUTDOWN: PROXY IS TERMINATING WITH SIGNAL {}'.format(str(_signal))) reactor.stop() - def sigt(_signal,_frame): - print('oooh') - #Install signal handlers signal.signal(signal.SIGINT, sig_handler) - signal.signal(signal.SIGTERM, sigt) + signal.signal(signal.SIGTERM, sig_handler) #readState() #If IPv6 is enabled by enivornment variable... - if ListenIP == '' and 'FDPROXY_IPV6' in os.environ and bool(os.environ['FDPROXY_IPV6']): + if ListenIP == '' and bool_from_env(os.environ.get('FDPROXY_IPV6')): ListenIP = '::' #Override static config from Environment if 'FDPROXY_STATS' in os.environ: - Stats = bool(os.environ['FDPROXY_STATS']) + Stats = bool_from_env(os.environ['FDPROXY_STATS']) #if 'FDPROXY_DEBUG' in os.environ: - # Debug = bool(os.environ['FDPROXY_DEBUG']) + # Debug = bool_from_env(os.environ['FDPROXY_DEBUG']) if 'FDPROXY_CLIENTINFO' in os.environ: - ClientInfo = bool(os.environ['FDPROXY_CLIENTINFO']) + ClientInfo = bool_from_env(os.environ['FDPROXY_CLIENTINFO']) if 'FDPROXY_LISTENPORT' in os.environ: ListenPort = int(os.environ['FDPROXY_LISTENPORT']) diff --git a/pyvenv.cfg b/pyvenv.cfg deleted file mode 100644 index 0537ffc..0000000 --- a/pyvenv.cfg +++ /dev/null @@ -1,3 +0,0 @@ -home = /usr/bin -include-system-site-packages = false -version = 3.10.12 diff --git a/report_receiver.py b/report_receiver.py index 97893fd..3371ef6 100644 --- a/report_receiver.py +++ b/report_receiver.py @@ -29,6 +29,9 @@ from reporting_const import * from pprint import pprint +def bool_flag(value): + return str(value).strip().lower() in ('1', 'true', 'yes', 'on') + class reportClient(NetstringReceiver): def stringReceived(self, data): @@ -36,10 +39,10 @@ class reportClient(NetstringReceiver): if data[:1] == REPORT_OPCODES['BRDG_EVENT']: self.bridgeEvent(data[1:].decode('UTF-8')) elif data[:1] == REPORT_OPCODES['CONFIG_SND']: - if cli_args.CONFIG: + if bool_flag(cli_args.CONFIG): self.configSend(data[1:]) elif data[:1] == REPORT_OPCODES['BRIDGE_SND']: - if cli_args.BRIDGES: + if bool_flag(cli_args.BRIDGES): self.bridgeSend(data[1:]) elif data == b'bridge updated': pass @@ -64,12 +67,12 @@ class reportClient(NetstringReceiver): if len(datalist) > 9: event['duration'] = datalist[9] - if cli_args.EVENTS: + if bool_flag(cli_args.EVENTS): pprint(event, compact=True) def bridgeSend(self,data): self.BRIDGES = pickle.loads(data) - if cli_args.STATS: + if bool_flag(cli_args.STATS): print('There are currently {} active bridges in the bridge table:\n'.format(len(self.BRIDGES))) for _bridge in self.BRIDGES.keys(): print('{},'.format({str(_bridge)})) diff --git a/report_sql.py b/report_sql.py index 92ccf99..66824d7 100644 --- a/report_sql.py +++ b/report_sql.py @@ -28,6 +28,7 @@ __email__ = 'simon@gb7fr.org.uk' #It can be used as a skeleton to build logging and monitoring tools. import pickle +import threading from twisted.internet import reactor from twisted.internet.protocol import ReconnectingClientFactory @@ -42,6 +43,7 @@ class reportClient(NetstringReceiver): def __init__(self,db,reactor): self.db = db self.reactor = reactor + self._db_lock = threading.Lock() def stringReceived(self, data): @@ -75,25 +77,39 @@ class reportClient(NetstringReceiver): event['duration'] = datalist[9] - #self.reactor.callInThread(self.send_mysql,event) - self.send_mysql(event) + self.reactor.callInThread(self.send_mysql,event) def send_mysql(self,event): + with self._db_lock: + while not self.db.is_connected(): + try: + self.db.reconnect() + except mysql.connector.Error as err: + print('(MYSQL) error on reconnect: {}'.format(err)) - while not self.db.is_connected(): + print("{} {} {} {} {} {} {} {} {}".format(event['type'],event['event'], event['trx'],event['system'],event['streamid'],event['peerid'],event['subid'],event['slot'],event['dstid'],event['duration'])) + _cursor = self.db.cursor() try: - self.db.reconnect() + _cursor.execute( + "insert into feed values (NULL,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ( + event['type'], + event['event'], + event['trx'], + event['system'], + event['streamid'], + event['peerid'], + event['subid'], + event['slot'], + event['dstid'], + event['duration'], + ) + ) + self.db.commit() except mysql.connector.Error as err: - print('(MYSQL) error on reconnect: {}'.format(err)) - - print("{} {} {} {} {} {} {} {} {}".format(event['type'],event['event'], event['trx'],event['system'],event['streamid'],event['peerid'],event['subid'],event['slot'],event['dstid'],event['duration'])) - _cursor = self.db.cursor() - try: - _cursor.execute("insert into feed values (NULL,'{}','{}','{}','{}','{}','{}','{}','{}','{}','{}')".format(event['type'],event['event'], event['trx'],event['system'],event['streamid'],event['peerid'],event['subid'],event['slot'],event['dstid'],event['duration'])) - self.db.commit() - except mysql.connector.Error as err: - _cursor.close() - print('(MYSQL) error, problem with cursor execute: {}'.format(err)) + print('(MYSQL) error, problem with cursor execute: {}'.format(err)) + finally: + _cursor.close() def bridgeSend(self,data): @@ -116,7 +132,7 @@ class reportClientFactory(ReconnectingClientFactory): print('Connected.') print('Resetting reconnection delay') self.resetDelay() - return self.proto(db,reactor) + return self.proto(self.db,self.reactor) def clientConnectionLost(self, connector, reason): print('Lost connection. Reason:', reason) diff --git a/requirements.txt b/requirements.txt index de11f31..69123e4 100755 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,3 @@ configparser>=3.0.0 resettabletimer>=0.7.0 setproctitle Pyro5 -spyne diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3ed1b7f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""FreeDMR test package.""" diff --git a/tests/harness/__init__.py b/tests/harness/__init__.py new file mode 100644 index 0000000..5d2448e --- /dev/null +++ b/tests/harness/__init__.py @@ -0,0 +1 @@ +"""Test harness helpers for FreeDMR.""" diff --git a/tests/harness/deterministic.py b/tests/harness/deterministic.py new file mode 100644 index 0000000..bbf737f --- /dev/null +++ b/tests/harness/deterministic.py @@ -0,0 +1,491 @@ +"""In-process deterministic packet harness for bridge_master tests. + +This module is test-only. It avoids UDP sockets and replaces production +network sends with capture functions while leaving production modules unchanged. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from types import SimpleNamespace +import copy +import importlib +import unittest + + +DMRD = b"DMRD" +HBPF_VOICE = 0x0 +HBPF_VOICE_SYNC = 0x1 +HBPF_DATA_SYNC = 0x2 +HBPF_SLT_VHEAD = 0x1 +HBPF_SLT_VTERM = 0x2 +ID_MAX = 16776415 +PEER_MAX = 4294967295 + + +def require_bridge_master(): + """Import bridge_master or skip tests when runtime deps are unavailable.""" + + try: + return importlib.import_module("bridge_master") + except ModuleNotFoundError as exc: + raise unittest.SkipTest( + f"bridge_master runtime dependency is not installed: {exc.name}" + ) from exc + + +def bytes_3(value: int | bytes) -> bytes: + if isinstance(value, bytes): + if len(value) != 3: + raise ValueError("expected exactly 3 bytes") + return value + return int(value).to_bytes(3, "big") + + +def bytes_4(value: int | bytes) -> bytes: + if isinstance(value, bytes): + if len(value) != 4: + raise ValueError("expected exactly 4 bytes") + return value + return int(value).to_bytes(4, "big") + + +def int_id(value: int | bytes) -> int: + if isinstance(value, int): + return value + return int.from_bytes(value, "big") + + +def acl_permit_all(max_id: int = ID_MAX) -> tuple[bool, list[tuple[int, int]]]: + return True, [(1, max_id)] + + +def hbp_bits(slot: int, call_type: str, frame_type: int, dtype_vseq: int) -> int: + bits = ((frame_type & 0x3) << 4) | (dtype_vseq & 0xF) + if slot == 2: + bits |= 0x80 + if call_type == "unit": + bits |= 0x40 + return bits + + +def parse_dmr_fields(packet: bytes) -> dict[str, object]: + if len(packet) < 20 or packet[:4] != DMRD: + return {"raw": packet} + + bits = packet[15] + if bits & 0x40: + call_type = "unit" + elif (bits & 0x23) == 0x23: + call_type = "vcsbk" + else: + call_type = "group" + + return { + "opcode": packet[:4], + "seq": packet[4], + "rf_src": packet[5:8], + "dst_id": packet[8:11], + "peer_id": packet[11:15], + "bits": bits, + "slot": 2 if bits & 0x80 else 1, + "call_type": call_type, + "frame_type": (bits & 0x30) >> 4, + "dtype_vseq": bits & 0xF, + "stream_id": packet[16:20], + "dmr_payload": packet[20:53], + "ber": packet[53:54], + "rssi": packet[54:55], + } + + +@dataclass(frozen=True) +class PacketSpec: + peer_id: int | bytes = 1001 + rf_src: int | bytes = 3120001 + dst_id: int | bytes = 91 + slot: int = 2 + stream_id: int | bytes = 0x01020304 + seq: int = 0 + call_type: str = "group" + frame_type: int = HBPF_VOICE + dtype_vseq: int = 0 + payload: bytes = b"\x00" * 33 + ber: bytes = b"\x00" + rssi: bytes = b"\x00" + delay: float = 0.0 + + def data(self) -> bytes: + if len(self.payload) != 33: + raise ValueError("DMR payload must be exactly 33 bytes") + return b"".join( + [ + DMRD, + bytes([self.seq & 0xFF]), + bytes_3(self.rf_src), + bytes_3(self.dst_id), + bytes_4(self.peer_id), + bytes([hbp_bits(self.slot, self.call_type, self.frame_type, self.dtype_vseq)]), + bytes_4(self.stream_id), + self.payload, + self.ber, + self.rssi, + ] + ) + + def decoded_args(self) -> tuple[bytes, bytes, bytes, int, int, str, int, int, bytes, bytes]: + return ( + bytes_4(self.peer_id), + bytes_3(self.rf_src), + bytes_3(self.dst_id), + self.seq & 0xFF, + self.slot, + self.call_type, + self.frame_type, + self.dtype_vseq, + bytes_4(self.stream_id), + self.data(), + ) + + def decoded_obp_args( + self, + packet_hash: bytes = b"", + hops: bytes = b"", + source_server: int | bytes = 9990, + source_rptr: int | bytes = 0, + ) -> tuple[bytes, bytes, bytes, int, int, str, int, int, bytes, bytes, bytes, bytes, bytes, bytes, bytes, bytes]: + return ( + bytes_4(self.peer_id), + bytes_3(self.rf_src), + bytes_3(self.dst_id), + self.seq & 0xFF, + self.slot, + self.call_type, + self.frame_type, + self.dtype_vseq, + bytes_4(self.stream_id), + self.data(), + packet_hash, + hops, + bytes_4(source_server), + self.ber, + self.rssi, + bytes_4(source_rptr), + ) + + +@dataclass +class CapturedPacket: + target_system: str + packet: bytes + hops: bytes | None = None + ber: bytes = b"\x00" + rssi: bytes = b"\x00" + source_server: bytes = b"\x00\x00\x00\x00" + source_rptr: bytes = b"\x00\x00\x00\x00" + fields: dict[str, object] = field(init=False) + + def __post_init__(self) -> None: + self.fields = parse_dmr_fields(self.packet) + + +class PacketCapture: + def __init__(self) -> None: + self.packets: list[CapturedPacket] = [] + + def recorder(self, target_system: str): + def record( + packet: bytes, + hops: bytes | None = b"", + ber: bytes = b"\x00", + rssi: bytes = b"\x00", + source_server: bytes = b"\x00\x00\x00\x00", + source_rptr: bytes = b"\x00\x00\x00\x00", + ) -> None: + self.packets.append( + CapturedPacket( + target_system=target_system, + packet=packet, + hops=hops, + ber=ber, + rssi=rssi, + source_server=source_server, + source_rptr=source_rptr, + ) + ) + + return record + + def for_system(self, system: str) -> list[CapturedPacket]: + return [packet for packet in self.packets if packet.target_system == system] + + +class ReportCapture: + def __init__(self) -> None: + self.events: list[bytes] = [] + + def send_bridgeEvent(self, data: bytes) -> None: + self.events.append(data) + + +class FakeClock: + def __init__(self, start: float = 1_700_000_000.0) -> None: + self.now = float(start) + + def time(self) -> float: + return self.now + + def advance(self, seconds: float) -> float: + self.now += seconds + return self.now + + +class FakeReactor: + def __init__(self) -> None: + self.later: list[tuple[float, object, tuple, dict]] = [] + self.thread_calls: list[tuple[object, tuple, dict]] = [] + + def callLater(self, delay, func, *args, **kwargs): + self.later.append((delay, func, args, kwargs)) + return SimpleNamespace(cancel=lambda: None, active=lambda: True) + + def callInThread(self, func, *args, **kwargs): + self.thread_calls.append((func, args, kwargs)) + + def callFromThread(self, func, *args, **kwargs): + return func(*args, **kwargs) + + +class FakeTransport: + def __init__(self) -> None: + self.writes: list[tuple[bytes, tuple[str, int] | None]] = [] + + def write(self, packet: bytes, sockaddr=None) -> None: + self.writes.append((packet, sockaddr)) + + +def minimal_config(system_names: tuple[str, ...] = ("MASTER-A", "MASTER-B")) -> dict: + config = { + "GLOBAL": { + "SERVER_ID": bytes_4(9990), + "USE_ACL": False, + "TG1_ACL": acl_permit_all(), + "TG2_ACL": acl_permit_all(), + "SUB_ACL": acl_permit_all(), + "GEN_STAT_BRIDGES": False, + "DATA_GATEWAY": False, + "VALIDATE_SERVER_IDS": False, + }, + "REPORTS": {"REPORT": False}, + "ALIASES": {"PATH": "./", "SUB_MAP_FILE": ""}, + "ALLSTAR": {"ENABLED": False}, + "SYSTEMS": {}, + "_SUB_IDS": {}, + "_PEER_IDS": {}, + "_LOCAL_SUBSCRIBER_IDS": {}, + "_SERVER_IDS": {}, + "CHECKSUMS": {}, + } + for name in system_names: + config["SYSTEMS"][name] = { + "MODE": "MASTER", + "ENABLED": True, + "REPEAT": True, + "MAX_PEERS": 1, + "IP": "127.0.0.1", + "PORT": 0, + "PASSPHRASE": b"", + "GROUP_HANGTIME": 0, + "USE_ACL": False, + "REG_ACL": acl_permit_all(PEER_MAX), + "SUB_ACL": acl_permit_all(), + "TG1_ACL": acl_permit_all(), + "TG2_ACL": acl_permit_all(), + "DEFAULT_UA_TIMER": 1, + "SINGLE_MODE": True, + "VOICE_IDENT": False, + "TS1_STATIC": "", + "TS2_STATIC": "", + "DEFAULT_REFLECTOR": 0, + "GENERATOR": 0, + "ANNOUNCEMENT_LANGUAGE": "en_GB", + "ALLOW_UNREG_ID": True, + "PROXY_CONTROL": False, + "OVERRIDE_IDENT_TG": False, + "PEERS": {}, + } + return config + + +def add_openbridge_system(config: dict, name: str = "OBP-1", network_id: int = 1) -> dict: + config["SYSTEMS"][name] = { + "MODE": "OPENBRIDGE", + "ENABLED": True, + "NETWORK_ID": bytes_4(network_id), + "IP": "127.0.0.1", + "PORT": 0, + "PASSPHRASE": b"test-passphrase\x00\x00\x00\x00\x00\x00", + "TARGET_IP": "127.0.0.1", + "TARGET_PORT": 0, + "TARGET_SOCK": ("127.0.0.1", 0), + "USE_ACL": False, + "SUB_ACL": acl_permit_all(), + "TG1_ACL": acl_permit_all(), + "TG2_ACL": acl_permit_all(), + "RELAX_CHECKS": True, + "ENHANCED_OBP": False, + "VER": 5, + } + return config + + +def active_bridge( + name: str, + tg_id: int, + entries: tuple[tuple[str, int], ...], + timeout_minutes: int = 1, +) -> dict[str, list[dict]]: + tg_bytes = bytes_3(tg_id) + return { + name: [ + { + "SYSTEM": system, + "TS": slot, + "TGID": tg_bytes, + "ACTIVE": True, + "TIMEOUT": timeout_minutes * 60, + "TO_TYPE": "ON", + "OFF": [], + "ON": [tg_bytes], + "RESET": [], + "TIMER": 0, + } + for system, slot in entries + ] + } + + +class DeterministicScenario: + def __init__(self, config: dict | None = None, bridges: dict | None = None) -> None: + self.config = config or minimal_config() + self.bridges = bridges or {} + self.clock = FakeClock() + self.capture = PacketCapture() + self.reports: dict[str, ReportCapture] = {} + self.transports: dict[str, FakeTransport] = {} + self.reactor = FakeReactor() + self.bm = None + self._saved_attrs: dict[str, object] = {} + self._saved_systems: dict | None = None + + def __enter__(self): + self.bm = require_bridge_master() + self._saved_systems = dict(self.bm.systems) + + for attr in ( + "CONFIG", + "BRIDGES", + "SUB_MAP", + "peer_ids", + "subscriber_ids", + "talkgroup_ids", + "local_subscriber_ids", + "server_ids", + "checksums", + "reactor", + "time", + "words", + ): + if hasattr(self.bm, attr): + self._saved_attrs[attr] = getattr(self.bm, attr) + + self.bm.CONFIG = self.config + self.bm.BRIDGES = copy.deepcopy(self.bridges) + self.bm.SUB_MAP = {} + self.bm.peer_ids = {} + self.bm.subscriber_ids = {} + self.bm.talkgroup_ids = {} + self.bm.local_subscriber_ids = {} + self.bm.server_ids = {} + self.bm.checksums = {} + self.bm.words = {"en_GB": {"silence": b"", "busy": b"", "notlinked": b"", "linkedto": b"", "to": b""}} + self.bm.reactor = self.reactor + self.bm.time = self.clock.time + + self.bm.systems.clear() + for system_name, system_config in self.config["SYSTEMS"].items(): + report = ReportCapture() + self.reports[system_name] = report + if system_config["MODE"] == "MASTER": + system = self.bm.routerHBP(system_name, self.config, report) + elif system_config["MODE"] == "OPENBRIDGE": + system = self.bm.routerOBP(system_name, self.config, report) + else: + continue + system.send_system = self.capture.recorder(system_name) + transport = FakeTransport() + system.transport = transport + self.transports[system_name] = transport + self.bm.systems[system_name] = system + + return self + + def __exit__(self, exc_type, exc, tb) -> None: + if self.bm is None: + return + self.bm.systems.clear() + if self._saved_systems is not None: + self.bm.systems.update(self._saved_systems) + + for attr in ( + "CONFIG", + "BRIDGES", + "SUB_MAP", + "peer_ids", + "subscriber_ids", + "talkgroup_ids", + "local_subscriber_ids", + "server_ids", + "checksums", + "reactor", + "time", + "words", + ): + if attr in self._saved_attrs: + setattr(self.bm, attr, self._saved_attrs[attr]) + elif hasattr(self.bm, attr): + delattr(self.bm, attr) + + @property + def systems(self): + return self.bm.systems + + @property + def bridge_state(self): + return self.bm.BRIDGES + + def inject_hbp(self, system_name: str, packet: PacketSpec) -> None: + self.systems[system_name].dmrd_received(*packet.decoded_args()) + + def inject_obp(self, system_name: str, packet: PacketSpec) -> None: + self.systems[system_name].dmrd_received(*packet.decoded_obp_args()) + + def inject_datagram(self, system_name: str, packet: bytes, sockaddr=("127.0.0.1", 50000)) -> None: + self.systems[system_name].datagramReceived(packet, sockaddr) + + def register_peer( + self, + system_name: str, + peer_id: int | bytes = 1001, + sockaddr=("127.0.0.1", 50000), + callsign: bytes = b"TEST ", + ) -> bytes: + peer = bytes_4(peer_id) + self.config["SYSTEMS"][system_name]["PEERS"][peer] = { + "CONNECTION": "YES", + "SOCKADDR": sockaddr, + "CALLSIGN": callsign, + "RADIO_ID": peer, + "LAST_PING": self.clock.time(), + } + return peer diff --git a/tests/harness/udp_blackbox.py b/tests/harness/udp_blackbox.py new file mode 100644 index 0000000..fd0e2d9 --- /dev/null +++ b/tests/harness/udp_blackbox.py @@ -0,0 +1,1014 @@ +"""Black-box UDP integration harness for FreeDMR. + +This module is test-only. It starts bridge_master.py with a generated loopback +configuration and interacts with it over UDP as emulated HBP repeaters. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from hashlib import blake2b, sha1 +from hmac import new as hmac_new +from pathlib import Path +import os +import random +import socket +import subprocess +import sys +import tempfile +import threading +import time +import unittest +import venv + +from tests.harness.deterministic import ( + HBPF_DATA_SYNC, + HBPF_SLT_VHEAD, + HBPF_SLT_VTERM, + HBPF_VOICE, + PacketSpec, + bytes_3, + bytes_4, + parse_dmr_fields, +) + + +RPTL = b"RPTL" +RPTACK = b"RPTACK" +RPTK = b"RPTK" +RPTC = b"RPTC" +RPTPING = b"RPTPING" +MSTPONG = b"MSTPONG" +DMRD = b"DMRD" +DMRE = b"DMRE" +BCKA = b"BCKA" +BCSQ = b"BCSQ" +BCST = b"BCST" +BCVE = b"BCVE" +FBP_VERSION = 5 +FBP_PASSPHRASE = b"test-passphrase".ljust(20, b"\x00")[:20] +REQUIRED_RUNTIME_MODULES = ("bitarray", "twisted", "dmr_utils3", "setproctitle") + + +@dataclass(frozen=True) +class LinkImpairment: + """Deterministic test-only impairment for packets sent by fake UDP peers.""" + + drop_rate: float = 0.0 + duplicate_rate: float = 0.0 + delay_range: tuple[float, float] = (0.0, 0.0) + jitter_range: tuple[float, float] = (0.0, 0.0) + drop_indices: frozenset[int] = frozenset() + duplicate_indices: frozenset[int] = frozenset() + delay_by_index: dict[int, float] = field(default_factory=dict) + seed: int = 1 + + def schedule( + self, + packets: list[bytes], + *, + cadence_seconds: float | None = None, + ) -> list[tuple[float, bytes]]: + rng = random.Random(self.seed) + scheduled = [] + for index, packet in enumerate(packets): + if index in self.drop_indices or rng.random() < self.drop_rate: + continue + + base_time = index * (cadence_seconds or 0.0) + jitter = rng.uniform(*self.jitter_range) + delay = self.delay_by_index.get(index, rng.uniform(*self.delay_range)) + send_at = max(0.0, base_time + jitter + delay) + scheduled.append((send_at, packet)) + + if index in self.duplicate_indices or rng.random() < self.duplicate_rate: + scheduled.append((send_at + 0.001, packet)) + + return sorted(scheduled, key=lambda item: item[0]) + + +class ImpairmentProfiles: + @staticmethod + def clean() -> LinkImpairment: + return LinkImpairment() + + @staticmethod + def provider_vxlan_reorder(delayed_index: int = 1, delay: float = 0.08) -> LinkImpairment: + return LinkImpairment(delay_by_index={delayed_index: delay}) + + @staticmethod + def mobile_flutter(*drop_indices: int) -> LinkImpairment: + return LinkImpairment(drop_indices=frozenset(drop_indices)) + + @staticmethod + def duplicate_udp(*duplicate_indices: int) -> LinkImpairment: + return LinkImpairment(duplicate_indices=frozenset(duplicate_indices)) + + @staticmethod + def burst_loss(start: int, count: int) -> LinkImpairment: + return LinkImpairment(drop_indices=frozenset(range(start, start + count))) + + +@dataclass(frozen=True) +class StreamProfile: + packets: list[PacketSpec] + cadence_seconds: float = 0.03 + + @classmethod + def voice_over( + cls, + *, + peer_id: int | bytes, + rf_src: int | bytes = 3120001, + dst_id: int | bytes = 91, + slot: int = 2, + stream_id: int | bytes = 0x01020304, + start_seq: int = 0, + voice_bursts: int = 1, + include_header: bool = False, + include_terminator: bool = False, + ) -> "StreamProfile": + packets = [] + seq = start_seq + if include_header: + packets.append( + PacketSpec( + peer_id=peer_id, + rf_src=rf_src, + dst_id=dst_id, + slot=slot, + stream_id=stream_id, + seq=seq, + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VHEAD, + ) + ) + seq = (seq + 1) % 256 + + for index in range(voice_bursts): + packets.append( + PacketSpec( + peer_id=peer_id, + rf_src=rf_src, + dst_id=dst_id, + slot=slot, + stream_id=stream_id, + seq=seq, + frame_type=HBPF_VOICE, + dtype_vseq=index % 6, + ) + ) + seq = (seq + 1) % 256 + + if include_terminator: + packets.append( + PacketSpec( + peer_id=peer_id, + rf_src=rf_src, + dst_id=dst_id, + slot=slot, + stream_id=stream_id, + seq=seq, + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VTERM, + ) + ) + + return cls(packets=packets) + + +def send_scheduled_packets( + send_packet, + packets: list[bytes], + *, + cadence_seconds: float | None = None, + impairment: LinkImpairment | None = None, +) -> None: + if impairment is None: + for index, packet in enumerate(packets): + if index and cadence_seconds is not None: + time.sleep(cadence_seconds) + send_packet(packet) + return + + started_at = time.monotonic() + for send_at, packet in impairment.schedule(packets, cadence_seconds=cadence_seconds): + wait = started_at + send_at - time.monotonic() + if wait > 0: + time.sleep(wait) + send_packet(packet) + + +@dataclass(frozen=True) +class RecordedPacketFixture: + """Small line-oriented recorded packet fixture. + + Fixture files contain one hex-encoded UDP payload per non-empty line. Lines + starting with ``#`` are ignored. The format is intentionally transport-only: + replay preserves bytes and leaves protocol mutation to FreeDMR. + """ + + packets: list[bytes] + + @classmethod + def from_file(cls, path: Path) -> "RecordedPacketFixture": + packets = [] + for line in path.read_text(encoding="ascii").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + packets.append(bytes.fromhex(stripped)) + return cls(packets) + + def replay( + self, + send_packet, + *, + cadence_seconds: float | None = None, + impairment: LinkImpairment | None = None, + ) -> None: + send_scheduled_packets( + send_packet, + self.packets, + cadence_seconds=cadence_seconds, + impairment=impairment, + ) + + +def require_udp_integration_enabled() -> None: + if os.environ.get("FREEDMR_RUN_UDP_TESTS") != "1": + raise unittest.SkipTest("set FREEDMR_RUN_UDP_TESTS=1 to run black-box UDP tests") + + +def _venv_python(venv_dir: Path) -> Path: + if os.name == "nt": + return venv_dir / "Scripts" / "python.exe" + return venv_dir / "bin" / "python" + + +def python_has_runtime_deps(python_executable: str | Path) -> bool: + return _runtime_import_check(python_executable).returncode == 0 + + +def _runtime_import_check(python_executable: str | Path) -> subprocess.CompletedProcess: + imports = "; ".join(f"import {module}" for module in REQUIRED_RUNTIME_MODULES) + return subprocess.run( + [str(python_executable), "-c", imports], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +class DependencySandbox: + """Resolve or create a Python runtime that can start bridge_master.py.""" + + def __init__(self, repo_root: Path) -> None: + self.repo_root = repo_root + self._tempdir: tempfile.TemporaryDirectory | None = None + + def cleanup(self) -> None: + if self._tempdir is not None: + self._tempdir.cleanup() + self._tempdir = None + + def resolve_python(self) -> str: + explicit_python = os.environ.get("FREEDMR_UDP_PYTHON") + if explicit_python: + if not python_has_runtime_deps(explicit_python): + raise unittest.SkipTest( + "FREEDMR_UDP_PYTHON does not have FreeDMR runtime dependencies" + ) + return explicit_python + + if python_has_runtime_deps(sys.executable): + return sys.executable + + if os.environ.get("FREEDMR_UDP_BOOTSTRAP_VENV") != "1": + raise unittest.SkipTest( + "missing FreeDMR runtime dependencies; set " + "FREEDMR_UDP_BOOTSTRAP_VENV=1 to install them in a test venv" + ) + + return str(self._bootstrap_venv_python()) + + def _bootstrap_venv_python(self) -> Path: + venv_dir_env = os.environ.get("FREEDMR_UDP_VENV_DIR") + if venv_dir_env: + venv_dir = Path(venv_dir_env).expanduser().resolve() + else: + self._tempdir = tempfile.TemporaryDirectory(prefix="freedmr-udp-venv-") + venv_dir = Path(self._tempdir.name) + + python_executable = _venv_python(venv_dir) + if not python_executable.exists(): + venv.EnvBuilder(with_pip=True).create(venv_dir) + + if not python_has_runtime_deps(python_executable): + requirements = self.repo_root / "requirements.txt" + subprocess.check_call( + [str(python_executable), "-m", "pip", "install", "-r", str(requirements)] + ) + + import_check = _runtime_import_check(python_executable) + if import_check.returncode != 0: + raise RuntimeError( + "test venv was created but FreeDMR dependencies still fail to import:\n" + + import_check.stderr + ) + + return python_executable + + +def free_udp_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def parse_udp_dmr_fields(packet: bytes) -> dict[str, object]: + if packet[:4] != DMRE: + return parse_dmr_fields(packet) + + fields = parse_dmr_fields(DMRD + packet[4:53]) + fields.update( + { + "opcode": packet[:4], + "ber": packet[53:54], + "rssi": packet[54:55], + "fbp_version": packet[55] if len(packet) > 55 else None, + } + ) + if len(packet) >= 89 and packet[55] > 4: + fields.update( + { + "timestamp": packet[56:64], + "source_server": packet[64:68], + "source_rptr": packet[68:72], + "hops": packet[72], + "hash": packet[73:89], + } + ) + elif len(packet) >= 85: + fields.update( + { + "timestamp": packet[56:64], + "source_server": packet[64:68], + "source_rptr": b"\x00\x00\x00\x00", + "hops": packet[68], + "hash": packet[69:85], + } + ) + return fields + + +def fbp_packet_from_spec( + packet: PacketSpec, + *, + network_id: int | bytes, + fbp_version: int = FBP_VERSION, + source_server: int | bytes = 9991, + source_rptr: int | bytes = 1001, + hops: int = 0, + timestamp_ns: int | None = None, + corrupt_hash: bool = False, + passphrase: bytes = FBP_PASSPHRASE, +) -> bytes: + dmr = packet.data() + body = b"".join( + [ + DMRE, + dmr[4:11], + bytes_4(network_id), + dmr[15:53], + packet.ber, + packet.rssi, + bytes([fbp_version]), + (time.time_ns() if timestamp_ns is None else timestamp_ns).to_bytes(8, "big"), + bytes_4(source_server), + ] + ) + if fbp_version > 4: + body += bytes_4(source_rptr) + bytes([hops]) + else: + body += bytes([hops]) + digest = blake2b(body, key=passphrase, digest_size=16).digest() + if corrupt_hash: + digest = bytes([digest[0] ^ 0xFF]) + digest[1:] + return body + digest + + +def obp_v1_packet_from_spec( + packet: PacketSpec, + *, + network_id: int | bytes, + corrupt_hash: bool = False, + passphrase: bytes = FBP_PASSPHRASE, +) -> bytes: + dmr = packet.data() + body = b"".join([DMRD, dmr[4:11], bytes_4(network_id), dmr[15:53]]) + digest = hmac_new(passphrase, body, sha1).digest() + if corrupt_hash: + digest = bytes([digest[0] ^ 0xFF]) + digest[1:] + return body + digest + + +def fbp_control_packet( + opcode: bytes, + payload: bytes = b"", + *, + corrupt_hash: bool = False, + passphrase: bytes = FBP_PASSPHRASE, +) -> bytes: + body = opcode + payload + if opcode == BCVE: + digest_payload = payload + else: + digest_payload = body + digest = hmac_new(passphrase, digest_payload, sha1).digest() + if corrupt_hash: + digest = bytes([digest[0] ^ 0xFF]) + digest[1:] + return body + digest + + +def repeater_config_packet(radio_id: bytes, callsign: str = "TST") -> bytes: + return b"".join( + [ + radio_id, + callsign.encode("ascii", "ignore").ljust(8)[:8], + b"439000000", + b"430000000", + b"10", + b"01", + b"000.0000", + b"000.00000", + b"000", + b"FreeDMR Test".ljust(20), + b"UDP Harness".ljust(19), + b"2", + b"http://localhost".ljust(124), + b"freedmr-test".ljust(40), + b"freedmr-test".ljust(40), + ] + ) + + +def write_bridge_master_config( + path: Path, + system_ports: dict[str, int], + *, + fbp_system_ports: dict[str, int] | None = None, + fbp_target_ports: dict[str, int] | None = None, + fbp_network_ids: dict[str, int] | None = None, + fbp_proto_versions: dict[str, int] | None = None, + global_use_acl: bool = False, + global_sub_acl: str = "PERMIT:ALL", + global_tg1_acl: str = "PERMIT:ALL", + global_tg2_acl: str = "PERMIT:ALL", + ts1_static: str = "", + ts2_static: str = "91", +) -> None: + global_use_acl_text = "True" if global_use_acl else "False" + systems = [] + for name, port in system_ports.items(): + systems.append( + f""" +[{name}] +MODE: MASTER +ENABLED: True +REPEAT: False +MAX_PEERS: 4 +IP: 127.0.0.1 +PORT: {port} +PASSPHRASE: +GROUP_HANGTIME: 0 +USE_ACL: False +REG_ACL: PERMIT:ALL +SUB_ACL: PERMIT:ALL +TGID_TS1_ACL: PERMIT:ALL +TGID_TS2_ACL: PERMIT:ALL +DEFAULT_UA_TIMER: 1 +SINGLE_MODE: True +VOICE_IDENT: False +TS1_STATIC: {ts1_static} +TS2_STATIC: {ts2_static} +DEFAULT_REFLECTOR: 0 +ANNOUNCEMENT_LANGUAGE: en_GB +GENERATOR: 0 +ALLOW_UNREG_ID: True +PROXY_CONTROL: False +OVERRIDE_IDENT_TG: +""" + ) + + for name, port in (fbp_system_ports or {}).items(): + target_port = (fbp_target_ports or {})[name] + network_id = (fbp_network_ids or {})[name] + proto_version = (fbp_proto_versions or {}).get(name, FBP_VERSION) + systems.append( + f""" +[{name}] +MODE: OPENBRIDGE +ENABLED: True +NETWORK_ID: {network_id} +IP: 127.0.0.1 +PORT: {port} +PASSPHRASE: test-passphrase +TARGET_IP: 127.0.0.1 +TARGET_PORT: {target_port} +USE_ACL: False +SUB_ACL: PERMIT:ALL +TGID_ACL: PERMIT:ALL +RELAX_CHECKS: True +ENHANCED_OBP: True +PROTO_VER: {proto_version} +""" + ) + + path.write_text( + f"""[GLOBAL] +PATH: ./ +PING_TIME: 1 +MAX_MISSED: 3 +USE_ACL: {global_use_acl_text} +REG_ACL: PERMIT:ALL +SUB_ACL: {global_sub_acl} +TGID_TS1_ACL: {global_tg1_acl} +TGID_TS2_ACL: {global_tg2_acl} +GEN_STAT_BRIDGES: False +ALLOW_NULL_PASSPHRASE: True +ANNOUNCEMENT_LANGUAGES: en_GB +SERVER_ID: 9990 +DATA_GATEWAY: False +VALIDATE_SERVER_IDS: False +DEBUG_BRIDGES: False +ENABLE_API: False + +[REPORTS] +REPORT: False +REPORT_INTERVAL: 60 +REPORT_PORT: 0 +REPORT_CLIENTS: 127.0.0.1 + +[LOGGER] +LOG_FILE: /dev/null +LOG_HANDLERS: console +LOG_LEVEL: INFO +LOG_NAME: FreeDMR-Test + +[ALIASES] +TRY_DOWNLOAD: False +PATH: ./ +PEER_FILE: +SUBSCRIBER_FILE: +TGID_FILE: +LOCAL_SUBSCRIBER_FILE: +STALE_DAYS: 1 +SUB_MAP_FILE: +SERVER_ID_FILE: +CHECKSUM_FILE: +KEYS_FILE: + +[ALLSTAR] +ENABLED: False +USER: test +PASS: test +SERVER: 127.0.0.1 +PORT: 5038 +NODE: 0 +{''.join(systems)} +""", + encoding="utf-8", + ) + + +@dataclass +class UdpCapture: + packet: bytes + sockaddr: tuple[str, int] + received_at: float + fields: dict[str, object] = field(init=False) + + def __post_init__(self) -> None: + self.fields = parse_udp_dmr_fields(self.packet) + + +class HbpRepeater: + def __init__(self, master_port: int, radio_id: int, timeout: float = 2.0) -> None: + self.master = ("127.0.0.1", master_port) + self.radio_id = bytes_4(radio_id) + self.timeout = timeout + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.bind(("127.0.0.1", 0)) + self.sock.settimeout(timeout) + self.captures: list[UdpCapture] = [] + + @property + def sockaddr(self) -> tuple[str, int]: + return self.sock.getsockname() + + def close(self) -> None: + self.sock.close() + + def send(self, packet: bytes) -> None: + self.sock.sendto(packet, self.master) + + def recv(self, timeout: float | None = None) -> UdpCapture: + old_timeout = self.sock.gettimeout() + if timeout is not None: + self.sock.settimeout(timeout) + try: + packet, sockaddr = self.sock.recvfrom(4096) + finally: + if timeout is not None: + self.sock.settimeout(old_timeout) + capture = UdpCapture(packet, sockaddr, time.monotonic()) + self.captures.append(capture) + return capture + + def drain(self, seconds: float = 0.2) -> list[UdpCapture]: + deadline = time.monotonic() + seconds + captures = [] + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + return captures + try: + captures.append(self.recv(timeout=remaining)) + except TimeoutError: + return captures + except socket.timeout: + return captures + + def login(self) -> None: + self.send(RPTL + self.radio_id) + challenge = self.recv() + if not challenge.packet.startswith(RPTACK): + raise AssertionError(f"expected RPTACK challenge, got {challenge.packet!r}") + + self.send(RPTK + self.radio_id) + auth = self.recv() + if not auth.packet.startswith(RPTACK): + raise AssertionError(f"expected RPTACK auth, got {auth.packet!r}") + + self.send(RPTC + repeater_config_packet(self.radio_id)) + configured = self.recv() + if not configured.packet.startswith(RPTACK): + raise AssertionError(f"expected RPTACK config, got {configured.packet!r}") + + def ping(self) -> None: + self.send(RPTPING + b"\x00\x00\x00" + self.radio_id) + pong = self.recv() + if not pong.packet.startswith(MSTPONG): + raise AssertionError(f"expected MSTPONG, got {pong.packet!r}") + + def send_dmr(self, packet: PacketSpec) -> None: + self.send(packet.data()) + + def replay_fixture( + self, + fixture: RecordedPacketFixture, + *, + cadence_seconds: float | None = None, + impairment: LinkImpairment | None = None, + ) -> None: + fixture.replay( + self.send, + cadence_seconds=cadence_seconds, + impairment=impairment, + ) + + def send_stream( + self, + packets: list[PacketSpec], + cadence_seconds: float | None = None, + impairment: LinkImpairment | None = None, + ) -> None: + send_scheduled_packets( + self.send, + [packet.data() for packet in packets], + cadence_seconds=cadence_seconds, + impairment=impairment, + ) + + +class FbpPeer: + def __init__(self, obp_port: int, network_id: int, timeout: float = 2.0) -> None: + self.master = ("127.0.0.1", obp_port) + self.network_id = network_id + self.timeout = timeout + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.bind(("127.0.0.1", 0)) + self.sock.settimeout(timeout) + self.captures: list[UdpCapture] = [] + + @property + def sockaddr(self) -> tuple[str, int]: + return self.sock.getsockname() + + def close(self) -> None: + self.sock.close() + + def send(self, packet: bytes) -> None: + self.sock.sendto(packet, self.master) + + def recv(self, timeout: float | None = None) -> UdpCapture: + old_timeout = self.sock.gettimeout() + if timeout is not None: + self.sock.settimeout(timeout) + try: + packet, sockaddr = self.sock.recvfrom(4096) + finally: + if timeout is not None: + self.sock.settimeout(old_timeout) + capture = UdpCapture(packet, sockaddr, time.monotonic()) + self.captures.append(capture) + return capture + + def drain(self, seconds: float = 0.2) -> list[UdpCapture]: + deadline = time.monotonic() + seconds + captures = [] + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + return captures + try: + captures.append(self.recv(timeout=remaining)) + except TimeoutError: + return captures + except socket.timeout: + return captures + + def recv_dmre(self, timeout: float = 2.0) -> UdpCapture: + deadline = time.monotonic() + timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise socket.timeout("timed out waiting for DMRE") + capture = self.recv(timeout=remaining) + if capture.packet[:4] == DMRE: + return capture + + def recv_opcode(self, opcode: bytes, timeout: float = 2.0) -> UdpCapture: + deadline = time.monotonic() + timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise socket.timeout(f"timed out waiting for {opcode!r}") + capture = self.recv(timeout=remaining) + if capture.packet[: len(opcode)] == opcode: + return capture + + def send_bcka(self) -> None: + self.send(fbp_control_packet(BCKA)) + + def send_bcve(self, version: int = FBP_VERSION) -> None: + self.send(fbp_control_packet(BCVE, bytes([version]))) + + def send_invalid_bcve(self, version: int = FBP_VERSION) -> None: + self.send(fbp_control_packet(BCVE, bytes([version]), corrupt_hash=True)) + + def send_bcst(self) -> None: + self.send(fbp_control_packet(BCST)) + + def send_bcsq(self, tgid: int | bytes, stream_id: int | bytes) -> None: + self.send(fbp_control_packet(BCSQ, bytes_3(tgid) + bytes_4(stream_id))) + + def send_invalid_bcsq(self, tgid: int | bytes, stream_id: int | bytes) -> None: + self.send( + fbp_control_packet( + BCSQ, + bytes_3(tgid) + bytes_4(stream_id), + corrupt_hash=True, + ) + ) + + def send_fbp( + self, + packet: PacketSpec, + *, + network_id: int | bytes | None = None, + fbp_version: int = FBP_VERSION, + source_server: int | bytes = 9991, + source_rptr: int | bytes = 1001, + hops: int = 0, + timestamp_ns: int | None = None, + corrupt_hash: bool = False, + ) -> None: + self.send( + fbp_packet_from_spec( + packet, + network_id=self.network_id if network_id is None else network_id, + fbp_version=fbp_version, + source_server=source_server, + source_rptr=source_rptr, + hops=hops, + timestamp_ns=timestamp_ns, + corrupt_hash=corrupt_hash, + ) + ) + + def send_obp_v1( + self, + packet: PacketSpec, + *, + network_id: int | bytes | None = None, + corrupt_hash: bool = False, + ) -> None: + self.send( + obp_v1_packet_from_spec( + packet, + network_id=self.network_id if network_id is None else network_id, + corrupt_hash=corrupt_hash, + ) + ) + + def replay_fixture( + self, + fixture: RecordedPacketFixture, + *, + cadence_seconds: float | None = None, + impairment: LinkImpairment | None = None, + ) -> None: + fixture.replay( + self.send, + cadence_seconds=cadence_seconds, + impairment=impairment, + ) + + def send_fbp_stream( + self, + packets: list[PacketSpec], + *, + cadence_seconds: float | None = None, + impairment: LinkImpairment | None = None, + network_id: int | bytes | None = None, + fbp_version: int = FBP_VERSION, + source_server: int | bytes = 9991, + source_rptr: int | bytes = 1001, + hops: int = 0, + timestamp_ns: int | None = None, + corrupt_hash: bool = False, + ) -> None: + raw_packets = [ + fbp_packet_from_spec( + packet, + network_id=self.network_id if network_id is None else network_id, + fbp_version=fbp_version, + source_server=source_server, + source_rptr=source_rptr, + hops=hops, + timestamp_ns=timestamp_ns, + corrupt_hash=corrupt_hash, + ) + for packet in packets + ] + send_scheduled_packets( + self.send, + raw_packets, + cadence_seconds=cadence_seconds, + impairment=impairment, + ) + + +class FreeDmrProcess: + def __init__(self, repo_root: Path, config_path: Path, python_executable: str) -> None: + self.repo_root = repo_root + self.config_path = config_path + self.python_executable = python_executable + self.proc: subprocess.Popen | None = None + self._output_lines: list[str] = [] + self._output_lock = threading.Lock() + self._reader: threading.Thread | None = None + + def __enter__(self): + self.proc = subprocess.Popen( + [self.python_executable, "bridge_master.py", "-c", str(self.config_path), "-l", "INFO"], + cwd=str(self.repo_root), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + self._reader = threading.Thread(target=self._read_output, daemon=True) + self._reader.start() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + if self.proc is None: + return + if self.proc.poll() is None: + self.proc.terminate() + try: + self.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait(timeout=5) + if self.proc.stdout is not None: + self.proc.stdout.close() + if self._reader is not None: + self._reader.join(timeout=1) + + def _read_output(self) -> None: + if self.proc is None or self.proc.stdout is None: + return + for line in self.proc.stdout: + with self._output_lock: + self._output_lines.append(line) + + def output(self) -> str: + with self._output_lock: + return "".join(self._output_lines) + + def wait_for_log(self, text: str, timeout: float = 2.0) -> str: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + output = self.output() + if text in output: + return output + time.sleep(0.05) + raise AssertionError(f"log text {text!r} not found in output:\n{self.output()}") + + def wait_for_start(self, timeout: float = 8.0) -> None: + assert self.proc is not None + deadline = time.monotonic() + timeout + ready_at = time.monotonic() + 0.5 + while time.monotonic() < deadline: + if self.proc.poll() is not None: + raise RuntimeError("FreeDMR exited before startup:\n" + self.output()) + # bridge_master does not provide a dedicated readiness signal. Give + # Twisted enough time to bind loopback UDP sockets, then let the + # first client login be the real readiness check. + if time.monotonic() >= ready_at: + return + time.sleep(0.05) + raise TimeoutError("FreeDMR did not reach startup wait window") + + +class UdpBlackBoxScenario: + def __init__( + self, + repo_root: Path | None = None, + *, + global_use_acl: bool = False, + global_sub_acl: str = "PERMIT:ALL", + global_tg1_acl: str = "PERMIT:ALL", + global_tg2_acl: str = "PERMIT:ALL", + ts1_static: str = "", + ts2_static: str = "91", + fbp_systems: dict[str, int] | None = None, + fbp_proto_versions: dict[str, int] | None = None, + ) -> None: + self.repo_root = repo_root or Path(__file__).resolve().parents[2] + self.tempdir = tempfile.TemporaryDirectory(prefix="freedmr-udp-test-") + self.config_path = Path(self.tempdir.name) / "freedmr-test.cfg" + self.system_ports = {"MASTER-A": free_udp_port(), "MASTER-B": free_udp_port()} + self.fbp_network_ids = fbp_systems or {} + self.fbp_proto_versions = fbp_proto_versions or {} + self.fbp_system_ports = {name: free_udp_port() for name in self.fbp_network_ids} + self.fbp_peers: dict[str, FbpPeer] = {} + self.process: FreeDmrProcess | None = None + self.deps = DependencySandbox(self.repo_root) + self.config_options = { + "global_use_acl": global_use_acl, + "global_sub_acl": global_sub_acl, + "global_tg1_acl": global_tg1_acl, + "global_tg2_acl": global_tg2_acl, + "ts1_static": ts1_static, + "ts2_static": ts2_static, + } + + def __enter__(self): + python_executable = self.deps.resolve_python() + self.fbp_peers = { + name: FbpPeer(self.fbp_system_ports[name], network_id) + for name, network_id in self.fbp_network_ids.items() + } + write_bridge_master_config( + self.config_path, + self.system_ports, + fbp_system_ports=self.fbp_system_ports, + fbp_target_ports={ + name: peer.sockaddr[1] for name, peer in self.fbp_peers.items() + }, + fbp_network_ids=self.fbp_network_ids, + fbp_proto_versions=self.fbp_proto_versions, + **self.config_options, + ) + self.process = FreeDmrProcess(self.repo_root, self.config_path, python_executable) + self.process.__enter__() + self.process.wait_for_start() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + if self.process is not None: + self.process.__exit__(exc_type, exc, tb) + for peer in self.fbp_peers.values(): + peer.close() + self.deps.cleanup() + self.tempdir.cleanup() + + def repeater(self, system_name: str, radio_id: int) -> HbpRepeater: + return HbpRepeater(self.system_ports[system_name], radio_id) + + def fbp_peer(self, system_name: str) -> FbpPeer: + return self.fbp_peers[system_name] diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..e96d611 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,162 @@ +import io +import json +import sys +import types +import unittest + + +def install_dmr_utils_stub(): + if "dmr_utils3.utils" in sys.modules: + return None + dmr_utils3 = types.ModuleType("dmr_utils3") + utils = types.ModuleType("dmr_utils3.utils") + + def bytes_4(value): + return int(value).to_bytes(4, "big") + + utils.bytes_4 = bytes_4 + sys.modules["dmr_utils3"] = dmr_utils3 + sys.modules["dmr_utils3.utils"] = utils + return ("dmr_utils3", "dmr_utils3.utils") + + +class FakeRequest: + def __init__(self, path, payload=None): + self.postpath = [part.encode("utf-8") for part in path.strip("/").split("/") if part] + self.content = io.BytesIO( + b"" if payload is None else json.dumps(payload).encode("utf-8") + ) + self.code = None + self.headers = {} + + def setResponseCode(self, code): + self.code = code + + def setHeader(self, name, value): + self.headers[name] = value + + def getHeader(self, name): + if name == "content-length": + return str(len(self.content.getvalue())) + return None + + +class APITest(unittest.TestCase): + def setUp(self): + try: + import twisted.web.resource # noqa: F401 + except ModuleNotFoundError as exc: + self.skipTest(f"Twisted is not installed: {exc}") + self.stubbed_modules = install_dmr_utils_stub() + import API + + self.api = API + self.peer_id = (1234567).to_bytes(4, "big") + self.config = { + "GLOBAL": {"SYSTEM_API_KEY": "system-secret", "_KILL_SERVER": False}, + "SYSTEMS": { + "MASTER-A": { + "MODE": "MASTER", + "PEERS": {self.peer_id: {}}, + "_opt_key": "peer-secret", + }, + "OBP-A": { + "MODE": "OPENBRIDGE", + "PEERS": {}, + }, + }, + } + self.bridges = {} + self.controller = API.FD_APIController(self.config, self.bridges) + + def tearDown(self): + if self.stubbed_modules: + for module in self.stubbed_modules: + sys.modules.pop(module, None) + + def test_getoptions_returns_clear_no_options_response(self): + result = self.controller.getoptions("MASTER-A") + + self.assertEqual( + result, + {"connected": True, "has_options": False, "options": ""}, + ) + + def test_getoptions_decodes_byte_options_for_json(self): + self.config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = b"KEY=peer-secret;TS1=91" + + result = self.controller.getoptions("MASTER-A") + + self.assertEqual(result["options"], "KEY=peer-secret;TS1=91") + self.assertTrue(result["has_options"]) + + def test_setoptions_stores_full_options_string_unchanged(self): + options = "KEY=peer-secret;TS1=91;DIAL=2350" + + self.controller.options("MASTER-A", options) + + self.assertEqual(self.config["SYSTEMS"]["MASTER-A"]["OPTIONS"], options) + + def test_user_reset_is_allowed_only_for_matching_peer_key(self): + system = self.controller.validateKey(1234567, "peer-secret") + + self.assertEqual(system, "MASTER-A") + self.controller.reset(system) + self.assertTrue(self.config["SYSTEMS"]["MASTER-A"]["_reset"]) + self.assertFalse(self.controller.validateKey(1234567, "wrong")) + + def test_system_kill_sets_existing_control_flag(self): + self.assertTrue(self.controller.validateSystemKey("system-secret")) + + self.controller.killserver() + + self.assertTrue(self.config["GLOBAL"]["_KILL_SERVER"]) + + def test_options_get_endpoint_returns_json(self): + resource = self.api.make_api_resource(self.config, self.bridges) + request = FakeRequest( + "/api/v1/options/get", + {"dmrid": 1234567, "key": "peer-secret"}, + ) + + body = resource.render_POST(request) + + self.assertEqual(request.code, 200) + self.assertEqual( + json.loads(body.decode("utf-8")), + {"ok": True, "connected": True, "has_options": False, "options": ""}, + ) + + def test_options_get_endpoint_rejects_bad_key(self): + resource = self.api.make_api_resource(self.config, self.bridges) + request = FakeRequest( + "/api/v1/options/get", + {"dmrid": 1234567, "key": "wrong"}, + ) + + body = resource.render_POST(request) + + self.assertEqual(request.code, 401) + self.assertEqual( + json.loads(body.decode("utf-8")), + {"ok": False, "error": "invalid_credentials"}, + ) + + def test_endpoint_rejects_large_request_body(self): + resource = self.api.make_api_resource(self.config, self.bridges) + request = FakeRequest( + "/api/v1/options/set", + {"dmrid": 1234567, "key": "peer-secret", "options": "A" * 9000}, + ) + + body = resource.render_POST(request) + + self.assertEqual(request.code, 413) + self.assertEqual( + json.loads(body.decode("utf-8")), + {"ok": False, "error": "request_too_large"}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_auxiliary_tools.py b/tests/test_auxiliary_tools.py new file mode 100644 index 0000000..5fb48d4 --- /dev/null +++ b/tests/test_auxiliary_tools.py @@ -0,0 +1,165 @@ +import importlib +import io +import sys +import types +import unittest +from contextlib import redirect_stdout + + +class AuxiliaryToolTests(unittest.TestCase): + def test_report_receiver_bool_flag(self): + import report_receiver + + self.assertTrue(report_receiver.bool_flag("1")) + self.assertTrue(report_receiver.bool_flag("true")) + self.assertTrue(report_receiver.bool_flag("yes")) + self.assertFalse(report_receiver.bool_flag("0")) + self.assertFalse(report_receiver.bool_flag("")) + self.assertFalse(report_receiver.bool_flag(None)) + + def test_ami_factory_builds_protocol_with_instance_state(self): + try: + import AMI + except ModuleNotFoundError as exc: + self.skipTest(str(exc)) + + factory = AMI.AMI.AMIClientFactory( + AMI.AMI.AMIClient, + b"user", + b"secret", + b"1234", + b"ilink 3 2350", + ) + protocol = factory.buildProtocol(None) + + self.assertEqual(protocol.username, b"user") + self.assertEqual(protocol.secret, b"secret") + self.assertEqual(protocol.nodenum, b"1234") + self.assertEqual(protocol.command, b"ilink 3 2350") + + def test_report_sql_uses_factory_db_and_parameterized_insert(self): + self._install_mysql_stub() + try: + import report_sql + report_sql = importlib.reload(report_sql) + except ModuleNotFoundError as exc: + self.skipTest(str(exc)) + + fake_db = _FakeDB() + fake_reactor = object() + factory = report_sql.reportClientFactory(report_sql.reportClient, fake_db, fake_reactor) + with redirect_stdout(io.StringIO()): + client = factory.buildProtocol(None) + + self.assertIs(client.db, fake_db) + self.assertIs(client.reactor, fake_reactor) + + event = { + "type": "GROUP VOICE", + "event": "START", + "trx": "RX", + "system": "SYSTEM", + "streamid": "1234", + "peerid": "5678", + "subid": "9012", + "slot": "2", + "dstid": "2350", + "duration": "0", + } + with redirect_stdout(io.StringIO()): + client.send_mysql(event) + + statement, params = fake_db.cursor_obj.executed + self.assertIn("%s", statement) + self.assertEqual(params[0], "GROUP VOICE") + self.assertEqual(params[8], "2350") + self.assertTrue(fake_db.committed) + self.assertTrue(fake_db.cursor_obj.closed) + + def test_proxy_environment_bool_parser(self): + saved_modules = self._install_proxy_stubs() + try: + import hotspot_proxy_v2 + hotspot_proxy_v2 = importlib.reload(hotspot_proxy_v2) + + self.assertTrue(hotspot_proxy_v2.bool_from_env("1")) + self.assertTrue(hotspot_proxy_v2.bool_from_env("true")) + self.assertTrue(hotspot_proxy_v2.bool_from_env("yes")) + self.assertFalse(hotspot_proxy_v2.bool_from_env("0")) + self.assertFalse(hotspot_proxy_v2.bool_from_env("")) + self.assertFalse(hotspot_proxy_v2.bool_from_env(None)) + finally: + self._restore_modules(saved_modules) + + def _install_mysql_stub(self): + mysql_module = types.ModuleType("mysql") + connector_module = types.ModuleType("mysql.connector") + + class ConnectorError(Exception): + pass + + connector_module.Error = ConnectorError + connector_module.errorcode = types.SimpleNamespace( + ER_ACCESS_DENIED_ERROR=1045, + ER_BAD_DB_ERROR=1049, + ) + mysql_module.connector = connector_module + sys.modules["mysql"] = mysql_module + sys.modules["mysql.connector"] = connector_module + + def _install_proxy_stubs(self): + stubbed = ["dmr_utils3", "dmr_utils3.utils", "Pyro5", "Pyro5.api"] + saved_modules = {name: sys.modules.get(name) for name in stubbed + ["hotspot_proxy_v2"]} + + dmr_utils3_module = types.ModuleType("dmr_utils3") + dmr_utils3_utils_module = types.ModuleType("dmr_utils3.utils") + dmr_utils3_utils_module.int_id = lambda value: int.from_bytes(value, "big") + dmr_utils3_module.utils = dmr_utils3_utils_module + pyro5_module = types.ModuleType("Pyro5") + pyro5_api_module = types.ModuleType("Pyro5.api") + pyro5_api_module.Proxy = object + pyro5_module.api = pyro5_api_module + sys.modules["dmr_utils3"] = dmr_utils3_module + sys.modules["dmr_utils3.utils"] = dmr_utils3_utils_module + sys.modules["Pyro5"] = pyro5_module + sys.modules["Pyro5.api"] = pyro5_api_module + sys.modules.pop("hotspot_proxy_v2", None) + return saved_modules + + def _restore_modules(self, saved_modules): + for name, module in saved_modules.items(): + if module is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + +class _FakeCursor: + def __init__(self): + self.executed = None + self.closed = False + + def execute(self, statement, params): + self.executed = (statement, params) + + def close(self): + self.closed = True + + +class _FakeDB: + def __init__(self): + self.cursor_obj = _FakeCursor() + self.committed = False + + def is_connected(self): + return True + + def cursor(self): + return self.cursor_obj + + def commit(self): + self.committed = True + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_bridge_backports.py b/tests/test_bridge_backports.py new file mode 100644 index 0000000..e8d3f53 --- /dev/null +++ b/tests/test_bridge_backports.py @@ -0,0 +1,32 @@ +import ast +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def load_bridge_helper(name): + source = (ROOT / "bridge.py").read_text() + module = ast.parse(source) + for node in module.body: + if isinstance(node, ast.FunctionDef) and node.name == name: + namespace = {} + exec(compile(ast.Module([node], []), "bridge.py", "exec"), namespace) + return namespace[name] + raise AssertionError(f"bridge.py helper not found: {name}") + + +class BridgeBackportTests(unittest.TestCase): + def test_dmrd_seq_delta_is_modulo_256(self): + dmrd_seq_delta = load_bridge_helper("dmrd_seq_delta") + + self.assertIsNone(dmrd_seq_delta(1, False)) + self.assertEqual(dmrd_seq_delta(2, 1), 1) + self.assertEqual(dmrd_seq_delta(0, 255), 1) + self.assertEqual(dmrd_seq_delta(2, 255), 3) + self.assertEqual(dmrd_seq_delta(250, 2), 248) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_deterministic_harness.py b/tests/test_deterministic_harness.py new file mode 100644 index 0000000..b45b2e8 --- /dev/null +++ b/tests/test_deterministic_harness.py @@ -0,0 +1,2862 @@ +from hashlib import sha1 +from hmac import new as hmac_new +import os +import tempfile +import unittest + +import config as freedmr_config + +from tests.harness.deterministic import ( + HBPF_DATA_SYNC, + HBPF_SLT_VHEAD, + HBPF_SLT_VTERM, + HBPF_VOICE, + DeterministicScenario, + PacketSpec, + add_openbridge_system, + active_bridge, + bytes_3, + bytes_4, + minimal_config, +) + + +class PacketSpecTest(unittest.TestCase): + def test_packet_spec_builds_parseable_dmrd_payload(self): + packet = PacketSpec( + peer_id=1234, + rf_src=3120001, + dst_id=91, + slot=2, + stream_id=0x01020304, + seq=7, + ) + + data = packet.data() + + self.assertEqual(data[:4], b"DMRD") + self.assertEqual(data[4], 7) + self.assertEqual(data[5:8], bytes_3(3120001)) + self.assertEqual(data[8:11], bytes_3(91)) + self.assertEqual(data[11:15], bytes_4(1234)) + self.assertEqual(data[16:20], bytes_4(0x01020304)) + self.assertEqual(len(data), 55) + + +class DeterministicHarnessTest(unittest.TestCase): + def test_config_global_use_acl_false_is_boolean_and_stale_time_is_seconds(self): + config_text = """ +[GLOBAL] +USE_ACL: False + +[ALIASES] +TRY_DOWNLOAD: False +STALE_DAYS: 1 + +[MASTER-A] +MODE: MASTER +ENABLED: True +DEFAULT_REFLECTOR: 0 +""" + fd, path = tempfile.mkstemp(suffix=".cfg") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(config_text) + + parsed = freedmr_config.build_config(path) + finally: + os.unlink(path) + + self.assertIs(parsed["GLOBAL"]["USE_ACL"], False) + self.assertEqual(parsed["ALIASES"]["STALE_TIME"], 86400) + + def test_set_alias_updates_bridge_master_globals_and_shared_hblink_config(self): + with DeterministicScenario() as scenario: + bm = scenario.bm + + bm.setAlias( + {1001: "Peer"}, + {3120001: "Subscriber"}, + {91: "Talkgroup"}, + {3120002: "Local"}, + {"9990": "Server"}, + {"peer_ids": "checksum"}, + ) + + self.assertEqual(bm.peer_ids, {1001: "Peer"}) + self.assertEqual(bm.subscriber_ids, {3120001: "Subscriber"}) + self.assertEqual(bm.talkgroup_ids, {91: "Talkgroup"}) + self.assertEqual(bm.local_subscriber_ids, {3120002: "Local"}) + self.assertEqual(bm.server_ids, {"9990": "Server"}) + self.assertEqual(bm.checksums, {"peer_ids": "checksum"}) + self.assertEqual(scenario.config["_PEER_IDS"], bm.peer_ids) + self.assertEqual(scenario.config["_SUB_IDS"], bm.subscriber_ids) + self.assertEqual( + scenario.config["_LOCAL_SUBSCRIBER_IDS"], + bm.local_subscriber_ids, + ) + self.assertEqual(scenario.config["_SERVER_IDS"], bm.server_ids) + self.assertEqual(scenario.config["CHECKSUMS"], bm.checksums) + + def test_bridge_reset_tolerates_missing_options_after_disconnect(self): + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + + with DeterministicScenario(bridges=bridges) as scenario: + scenario.config["SYSTEMS"]["MASTER-A"].pop("OPTIONS", None) + scenario.config["SYSTEMS"]["MASTER-A"]["_reset"] = True + + scenario.bm.bridge_reset() + + self.assertFalse(scenario.config["SYSTEMS"]["MASTER-A"]["_reset"]) + self.assertNotIn( + "_reloadoptions", + scenario.config["SYSTEMS"]["MASTER-A"], + ) + + def private_call(self, dst_id, slot=1, stream_id=0x01020304): + return PacketSpec( + dst_id=dst_id, + slot=slot, + stream_id=stream_id, + call_type="unit", + frame_type=HBPF_VOICE, + dtype_vseq=0, + ) + + def private_call_terminator(self, dst_id, slot=1, stream_id=0x01020304): + return PacketSpec( + dst_id=dst_id, + slot=slot, + stream_id=stream_id, + call_type="unit", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VTERM, + ) + + def reflector_bridge(self, bridge_name, active=True): + tg = int(bridge_name[1:]) + return { + bridge_name: [ + { + "SYSTEM": "MASTER-A", + "TS": 2, + "TGID": bytes_3(9), + "ACTIVE": active, + "TIMEOUT": 60, + "TO_TYPE": "ON", + "OFF": [], + "ON": [bytes_3(tg)], + "RESET": [], + "TIMER": 0, + }, + { + "SYSTEM": "MASTER-B", + "TS": 2, + "TGID": bytes_3(9), + "ACTIVE": False, + "TIMEOUT": 60, + "TO_TYPE": "ON", + "OFF": [], + "ON": [bytes_3(tg)], + "RESET": [], + "TIMER": 0, + }, + ] + } + + def run_ident_with_override(self, override_ident_tg): + config = minimal_config(("MASTER-A",)) + config["SYSTEMS"]["MASTER-A"]["VOICE_IDENT"] = True + config["SYSTEMS"]["MASTER-A"]["OVERRIDE_IDENT_TG"] = override_ident_tg + + with DeterministicScenario(config=config) as scenario: + scenario.register_peer("MASTER-A", callsign=b"TEST ") + scenario.clock.advance(31) + scenario.bm.words["en_GB"].update( + { + "this-is": b"this-is", + "freedmr": b"freedmr", + "T": b"T", + "E": b"E", + "S": b"S", + } + ) + + destinations = [] + old_pkt_gen = scenario.bm.pkt_gen + old_sleep = scenario.bm.sleep + try: + scenario.bm.pkt_gen = ( + lambda source_id, dst_id, peer_id, slot, say: destinations.append(dst_id) + or iter([PacketSpec(dst_id=dst_id, slot=2, stream_id=0x01020309).data()]) + ) + scenario.bm.sleep = lambda seconds: None + scenario.bm.ident() + finally: + scenario.bm.pkt_gen = old_pkt_gen + scenario.bm.sleep = old_sleep + + return destinations + + def test_hbp_group_packet_routes_to_active_bridge_target(self): + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + + with DeterministicScenario(bridges=bridges) as scenario: + packet = PacketSpec(dst_id=91, slot=2, seq=0, dtype_vseq=0) + scenario.inject_hbp("MASTER-A", packet) + + captured = scenario.capture.for_system("MASTER-B") + + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["dst_id"], bytes_3(91)) + self.assertEqual(captured[0].fields["slot"], 2) + self.assertEqual(captured[0].fields["stream_id"], bytes_4(0x01020304)) + self.assertEqual(scenario.capture.for_system("MASTER-A"), []) + + def test_hbp_group_packet_rewrites_only_slot_for_cross_slot_route(self): + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 1), + ("MASTER-B", 2), + ), + ) + + with DeterministicScenario(bridges=bridges) as scenario: + packet = PacketSpec(dst_id=91, slot=1, seq=3, dtype_vseq=0) + scenario.inject_hbp("MASTER-A", packet) + + captured = scenario.capture.for_system("MASTER-B") + + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["seq"], 3) + self.assertEqual(captured[0].fields["rf_src"], bytes_3(3120001)) + self.assertEqual(captured[0].fields["dst_id"], bytes_3(91)) + self.assertEqual(captured[0].fields["peer_id"], bytes_4(1001)) + self.assertEqual(captured[0].fields["slot"], 2) + self.assertEqual(captured[0].fields["stream_id"], bytes_4(0x01020304)) + self.assertEqual(captured[0].packet[:15], packet.data()[:15]) + self.assertEqual(captured[0].packet[16:], packet.data()[16:]) + self.assertNotEqual(captured[0].packet[15], packet.data()[15]) + self.assertEqual(scenario.capture.for_system("MASTER-A"), []) + + def test_startup_static_tgs_reject_reserved_control_targets(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["TS1_STATIC"] = "8,16777215,A93,91" + config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "7,16777216,B94,92" + + with DeterministicScenario(config=config) as scenario: + scenario.bm.make_static_tgs() + + self.assertNotIn("8", scenario.bridge_state) + self.assertNotIn("7", scenario.bridge_state) + self.assertNotIn("16777215", scenario.bridge_state) + self.assertNotIn("16777216", scenario.bridge_state) + self.assertNotIn("A93", scenario.bridge_state) + self.assertNotIn("B94", scenario.bridge_state) + self.assertIn("91", scenario.bridge_state) + self.assertIn("92", scenario.bridge_state) + + ts1_entry = next( + entry for entry in scenario.bridge_state["91"] if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 1 + ) + ts2_entry = next( + entry for entry in scenario.bridge_state["92"] if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 2 + ) + + self.assertTrue(ts1_entry["ACTIVE"]) + self.assertTrue(ts2_entry["ACTIVE"]) + + def test_options_static_ts1_rejects_reserved_control_targets(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TS1=8,16777215,A93,91;TS2=7,16777216,B94,92;DIAL=0;TIMER=1" + + with DeterministicScenario(config=config) as scenario: + scenario.bm.options_config() + + self.assertNotIn("8", scenario.bridge_state) + self.assertNotIn("7", scenario.bridge_state) + self.assertNotIn("16777215", scenario.bridge_state) + self.assertNotIn("16777216", scenario.bridge_state) + self.assertNotIn("A93", scenario.bridge_state) + self.assertNotIn("B94", scenario.bridge_state) + self.assertIn("91", scenario.bridge_state) + self.assertIn("92", scenario.bridge_state) + + ts1_entry = next( + entry for entry in scenario.bridge_state["91"] if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 1 + ) + ts2_entry = next( + entry for entry in scenario.bridge_state["92"] if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 2 + ) + + self.assertTrue(ts1_entry["ACTIVE"]) + self.assertTrue(ts2_entry["ACTIVE"]) + + def test_options_static_tg_whitespace_is_normalized(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TS1=91, 92;TS2=93, 94;DIAL=0;TIMER=1" + + with DeterministicScenario(config=config) as scenario: + scenario.bm.options_config() + + self.assertIn("91", scenario.bridge_state) + self.assertIn("92", scenario.bridge_state) + self.assertIn("93", scenario.bridge_state) + self.assertIn("94", scenario.bridge_state) + + def test_options_invalid_ident_tg_does_not_block_valid_dial(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["OVERRIDE_IDENT_TG"] = 9 + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "IDENTTG=A;DIAL=91;TIMER=1" + + with DeterministicScenario(config=config) as scenario: + scenario.bm.options_config() + + master_entry = next( + entry for entry in scenario.bridge_state["#91"] if entry["SYSTEM"] == "MASTER-A" + ) + + self.assertTrue(master_entry["ACTIVE"]) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"], 91) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["OVERRIDE_IDENT_TG"], 9) + + def test_options_invalid_voice_does_not_block_valid_static_tg(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["VOICE_IDENT"] = True + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "VOICE=A;TS1=91;DIAL=0;TIMER=1" + + with DeterministicScenario(config=config) as scenario: + scenario.bm.options_config() + + ts1_entry = next( + entry for entry in scenario.bridge_state["91"] if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 1 + ) + + self.assertTrue(ts1_entry["ACTIVE"]) + self.assertTrue(config["SYSTEMS"]["MASTER-A"]["VOICE_IDENT"]) + + def test_options_invalid_single_does_not_block_valid_static_tg(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["SINGLE_MODE"] = True + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "SINGLE=A;TS2=92;DIAL=0;TIMER=1" + + with DeterministicScenario(config=config) as scenario: + scenario.bm.options_config() + + ts2_entry = next( + entry for entry in scenario.bridge_state["92"] if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 2 + ) + + self.assertTrue(ts2_entry["ACTIVE"]) + self.assertTrue(config["SYSTEMS"]["MASTER-A"]["SINGLE_MODE"]) + + def test_options_boolean_fields_reject_values_other_than_zero_or_one(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["VOICE_IDENT"] = False + config["SYSTEMS"]["MASTER-A"]["SINGLE_MODE"] = True + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "VOICE=2;SINGLE=2;DIAL=0;TIMER=1" + + with DeterministicScenario(config=config) as scenario: + scenario.bm.options_config() + + self.assertFalse(config["SYSTEMS"]["MASTER-A"]["VOICE_IDENT"]) + self.assertTrue(config["SYSTEMS"]["MASTER-A"]["SINGLE_MODE"]) + + def test_options_empty_dial_disables_default_reflector(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"] = 91 + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "DIAL=;TIMER=1" + + with DeterministicScenario(config=config) as scenario: + scenario.bm.make_default_reflectors() + scenario.bm.options_config() + + master_entry = next( + entry for entry in scenario.bridge_state["#91"] if entry["SYSTEM"] == "MASTER-A" + ) + + self.assertFalse(master_entry["ACTIVE"]) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"], 0) + + def test_options_invalid_timer_does_not_block_valid_static_tgs(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"] = 3 + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TIMER=A;TS1=91;TS2=92;DIAL=0" + + with DeterministicScenario(config=config) as scenario: + with self.assertLogs(scenario.bm.logger, level="DEBUG") as logs: + scenario.bm.options_config() + + ts1_entry = next( + entry for entry in scenario.bridge_state["91"] if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 1 + ) + ts2_entry = next( + entry for entry in scenario.bridge_state["92"] if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 2 + ) + + self.assertTrue(ts1_entry["ACTIVE"]) + self.assertTrue(ts2_entry["ACTIVE"]) + self.assertEqual(ts1_entry["TIMEOUT"], 180) + self.assertEqual(ts2_entry["TIMEOUT"], 180) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"], 3) + self.assertIn("DEFAULT_UA_TIMER is not an integer", "\n".join(logs.output)) + + def test_ident_override_accepts_valid_string_tg(self): + destinations = self.run_ident_with_override("91") + + self.assertEqual(destinations, [bytes_3(91)]) + + def test_ident_override_empty_or_false_uses_all_call(self): + self.assertEqual(self.run_ident_with_override(""), [bytes_3(16777215)]) + self.assertEqual(self.run_ident_with_override(False), [bytes_3(16777215)]) + self.assertEqual(self.run_ident_with_override(0), [bytes_3(16777215)]) + + def test_ident_override_rejects_control_tg(self): + with self.assertLogs("bridge_master", level="WARNING") as logs: + destinations = self.run_ident_with_override(9) + + self.assertEqual(destinations, [bytes_3(16777215)]) + self.assertIn("invalid OVERRIDE_IDENT_TG 9", "\n".join(logs.output)) + + def test_ident_override_rejects_malformed_tg(self): + with self.assertLogs("bridge_master", level="WARNING") as logs: + destinations = self.run_ident_with_override("A91") + + self.assertEqual(destinations, [bytes_3(16777215)]) + self.assertIn("invalid OVERRIDE_IDENT_TG A91", "\n".join(logs.output)) + + def test_ident_override_rejects_all_call(self): + with self.assertLogs("bridge_master", level="WARNING") as logs: + destinations = self.run_ident_with_override(16777215) + + self.assertEqual(destinations, [bytes_3(16777215)]) + self.assertIn("invalid OVERRIDE_IDENT_TG 16777215", "\n".join(logs.output)) + + def test_ident_cancellation_does_not_block_later_ident(self): + config = minimal_config(("MASTER-A",)) + config["SYSTEMS"]["MASTER-A"]["VOICE_IDENT"] = True + + with DeterministicScenario(config=config) as scenario: + scenario.register_peer("MASTER-A", callsign=b"TEST ") + scenario.bm.words["en_GB"].update( + { + "this-is": b"this-is", + "freedmr": b"freedmr", + "T": b"T", + "E": b"E", + "S": b"S", + } + ) + original_pkt_gen = scenario.bm.pkt_gen + original_send_voice_packet = scenario.bm.sendVoicePacket + original_sleep = scenario.bm.sleep + stream_id = 0x01020309 + first_ident = [ + PacketSpec(dst_id=16777215, slot=2, stream_id=stream_id, seq=1).data(), + PacketSpec(dst_id=16777215, slot=2, stream_id=stream_id, seq=2).data(), + ] + second_ident = [ + PacketSpec(dst_id=16777215, slot=2, stream_id=stream_id + 1, seq=1).data() + ] + generated = [iter(first_ident), iter(second_ident)] + sent_packets = [] + + def send_then_cancel(system, pkt, source_id, dest_id, slot): + original_send_voice_packet(system, pkt, source_id, dest_id, slot) + sent_packets.append(pkt) + if len(sent_packets) == 1: + scenario.bm._cancelGeneratedVoice(slot) + + try: + scenario.bm.pkt_gen = lambda source_id, dst_id, peer_id, slot, say: generated.pop(0) + scenario.bm.sendVoicePacket = send_then_cancel + scenario.bm.sleep = lambda seconds: None + + scenario.clock.advance(31) + scenario.bm.ident() + slot = scenario.systems["MASTER-A"].STATUS[2] + self.assertTrue(slot["TX_PROMPT_CANCEL"]) + self.assertFalse(slot["TX_PROMPT_ACTIVE"]) + + scenario.clock.advance(31) + scenario.bm.ident() + finally: + scenario.bm.pkt_gen = original_pkt_gen + scenario.bm.sendVoicePacket = original_send_voice_packet + scenario.bm.sleep = original_sleep + + captured = scenario.capture.for_system("MASTER-A") + self.assertEqual(len(sent_packets), 2) + self.assertEqual(len(captured), 2) + self.assertEqual(captured[0].fields["stream_id"], bytes_4(stream_id)) + self.assertEqual(captured[1].fields["stream_id"], bytes_4(stream_id + 1)) + + def test_dial_a_tg_private_call_on_slot_1_activates_ts2_reflector(self): + with DeterministicScenario() as scenario: + scenario.inject_hbp("MASTER-A", self.private_call(235, slot=1)) + + bridge = scenario.bridge_state["#235"] + source_entry = [entry for entry in bridge if entry["SYSTEM"] == "MASTER-A"][0] + + self.assertEqual(source_entry["TS"], 2) + self.assertEqual(source_entry["TGID"], bytes_3(9)) + self.assertTrue(source_entry["ACTIVE"]) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_private_call_on_slot_1_retunes_active_ts2_reflector(self): + bridges = {} + bridges.update(self.reflector_bridge("#235", active=True)) + + with DeterministicScenario(bridges=bridges) as scenario: + scenario.inject_hbp("MASTER-A", self.private_call(236, slot=1)) + + old_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "MASTER-A" + ][0] + new_entry = [ + entry for entry in scenario.bridge_state["#236"] if entry["SYSTEM"] == "MASTER-A" + ][0] + + self.assertEqual(old_entry["TS"], 2) + self.assertFalse(old_entry["ACTIVE"]) + self.assertEqual(new_entry["TS"], 2) + self.assertTrue(new_entry["ACTIVE"]) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_disconnect_on_slot_1_deactivates_active_ts2_reflector(self): + bridges = {} + bridges.update(self.reflector_bridge("#235", active=True)) + + with DeterministicScenario(bridges=bridges) as scenario: + scenario.inject_hbp("MASTER-A", self.private_call(4000, slot=1)) + + source_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "MASTER-A" + ][0] + + self.assertEqual(source_entry["TS"], 2) + self.assertFalse(source_entry["ACTIVE"]) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_disconnect_is_scoped_to_receiving_master(self): + bridges = { + "#235": [ + { + "SYSTEM": "MASTER-A", + "TS": 2, + "TGID": bytes_3(9), + "ACTIVE": True, + "TIMEOUT": 60, + "TO_TYPE": "ON", + "OFF": [bytes_3(4000)], + "ON": [bytes_3(235)], + "RESET": [], + "TIMER": 111, + }, + { + "SYSTEM": "MASTER-B", + "TS": 2, + "TGID": bytes_3(9), + "ACTIVE": True, + "TIMEOUT": 60, + "TO_TYPE": "ON", + "OFF": [bytes_3(4000)], + "ON": [bytes_3(235)], + "RESET": [], + "TIMER": 222, + }, + ] + } + + with DeterministicScenario(bridges=bridges) as scenario: + scenario.inject_hbp("MASTER-A", self.private_call(4000, slot=1)) + + master_a_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "MASTER-A" + ][0] + master_b_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "MASTER-B" + ][0] + + self.assertFalse(master_a_entry["ACTIVE"]) + self.assertEqual(master_a_entry["TIMER"], scenario.clock.now) + self.assertTrue(master_b_entry["ACTIVE"]) + self.assertEqual(master_b_entry["TIMER"], 222) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_reserved_target_on_slot_1_does_not_create_reflector(self): + for target in (5, 6, 7): + with self.subTest(target=target): + with DeterministicScenario() as scenario: + scenario.inject_hbp("MASTER-A", self.private_call(target, slot=1)) + + self.assertNotIn("#{}".format(target), scenario.bridge_state) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_reserved_target_on_slot_1_does_not_retune_active_reflector(self): + for target in (5, 6, 7): + with self.subTest(target=target): + bridges = {} + bridges.update(self.reflector_bridge("#235", active=True)) + + with DeterministicScenario(bridges=bridges) as scenario: + scenario.inject_hbp("MASTER-A", self.private_call(target, slot=1)) + + source_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "MASTER-A" + ][0] + + self.assertTrue(source_entry["ACTIVE"]) + self.assertNotIn("#{}".format(target), scenario.bridge_state) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_local_tg_9_on_slot_1_does_not_create_or_retune_reflector(self): + bridges = {} + bridges.update(self.reflector_bridge("#235", active=True)) + + with DeterministicScenario(bridges=bridges) as scenario: + scenario.inject_hbp("MASTER-A", self.private_call(9, slot=1)) + + source_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "MASTER-A" + ][0] + + self.assertTrue(source_entry["ACTIVE"]) + self.assertNotIn("#9", scenario.bridge_state) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_allstar_target_8_reports_busy_when_disabled(self): + bridges = {} + bridges.update(self.reflector_bridge("#235", active=True)) + + with DeterministicScenario(bridges=bridges) as scenario: + spoken = [] + scenario.bm.CONFIG["ALLSTAR"]["ENABLED"] = False + scenario.bm.words["en_GB"].update( + { + "busy": b"busy", + "linkedto": b"linkedto", + "silence": b"silence", + "to": b"to", + } + ) + scenario.bm.pkt_gen = lambda source_id, dst_id, peer_id, slot, say: spoken.append(list(say)) or iter(()) + scenario.inject_hbp("MASTER-A", self.private_call(8, slot=1)) + scenario.inject_hbp("MASTER-A", self.private_call_terminator(8, slot=1)) + + source_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "MASTER-A" + ][0] + + self.assertFalse(scenario.bm.systems["MASTER-A"].STATUS[1]["_allStarMode"]) + self.assertTrue(source_entry["ACTIVE"]) + self.assertNotIn("#8", scenario.bridge_state) + self.assertTrue(spoken) + self.assertIn(b"busy", spoken[-1]) + self.assertNotIn(b"linkedto", spoken[-1]) + self.assertEqual(scenario.reactor.later, []) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_allstar_target_8_enters_allstar_mode_when_enabled(self): + bridges = {} + bridges.update(self.reflector_bridge("#235", active=True)) + + with DeterministicScenario(bridges=bridges) as scenario: + spoken = [] + scenario.bm.CONFIG["ALLSTAR"]["ENABLED"] = True + scenario.bm.words["en_GB"].update( + { + "all-star-link-mode": b"all-star-link-mode", + "linkedto": b"linkedto", + "silence": b"silence", + "to": b"to", + } + ) + scenario.bm.pkt_gen = lambda source_id, dst_id, peer_id, slot, say: spoken.append(list(say)) or iter(()) + scenario.inject_hbp("MASTER-A", self.private_call(8, slot=1)) + scenario.inject_hbp("MASTER-A", self.private_call_terminator(8, slot=1)) + + source_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "MASTER-A" + ][0] + + self.assertTrue(scenario.bm.systems["MASTER-A"].STATUS[1]["_allStarMode"]) + self.assertTrue(source_entry["ACTIVE"]) + self.assertNotIn("#8", scenario.bridge_state) + self.assertTrue(spoken) + self.assertIn(b"all-star-link-mode", spoken[-1]) + self.assertNotIn(b"linkedto", spoken[-1]) + self.assertEqual(len(scenario.reactor.later), 1) + self.assertEqual(scenario.reactor.later[0][0], 30) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_reserved_control_range_reports_busy_without_retune(self): + bridges = {} + bridges.update(self.reflector_bridge("#235", active=True)) + + with DeterministicScenario(bridges=bridges) as scenario: + spoken = [] + scenario.bm.words["en_GB"].update( + { + "busy": b"busy", + "linkedto": b"linkedto", + "silence": b"silence", + "to": b"to", + } + ) + scenario.bm.pkt_gen = lambda source_id, dst_id, peer_id, slot, say: spoken.append(list(say)) or iter(()) + scenario.inject_hbp("MASTER-A", self.private_call(4001, slot=1)) + scenario.inject_hbp("MASTER-A", self.private_call_terminator(4001, slot=1)) + + source_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "MASTER-A" + ][0] + + self.assertTrue(source_entry["ACTIVE"]) + self.assertNotIn("#4001", scenario.bridge_state) + self.assertTrue(spoken) + self.assertIn(b"busy", spoken[-1]) + self.assertNotIn(b"linkedto", spoken[-1]) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_echo_target_9990_is_linkable(self): + with DeterministicScenario() as scenario: + spoken = [] + scenario.bm.words["en_GB"].update( + { + "0": b"0", + "9": b"9", + "linkedto": b"linkedto", + "silence": b"silence", + "to": b"to", + } + ) + scenario.bm.pkt_gen = lambda source_id, dst_id, peer_id, slot, say: spoken.append(list(say)) or iter(()) + scenario.inject_hbp("MASTER-A", self.private_call(9990, slot=1)) + scenario.inject_hbp("MASTER-A", self.private_call_terminator(9990, slot=1)) + + source_entry = [ + entry for entry in scenario.bridge_state["#9990"] if entry["SYSTEM"] == "MASTER-A" + ][0] + + self.assertEqual(source_entry["TS"], 2) + self.assertEqual(source_entry["TGID"], bytes_3(9)) + self.assertTrue(source_entry["ACTIVE"]) + self.assertEqual(source_entry["TIMEOUT"], 60) + self.assertTrue(spoken) + self.assertIn(b"linkedto", spoken[-1]) + self.assertEqual(spoken[-1].count(b"9"), 3) + self.assertIn(b"0", spoken[-1]) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_policy_max_999999_is_linkable(self): + with DeterministicScenario() as scenario: + spoken = [] + scenario.bm.words["en_GB"].update( + { + "9": b"9", + "linkedto": b"linkedto", + "silence": b"silence", + "to": b"to", + } + ) + scenario.bm.pkt_gen = lambda source_id, dst_id, peer_id, slot, say: spoken.append(list(say)) or iter(()) + scenario.inject_hbp("MASTER-A", self.private_call(999999, slot=1)) + scenario.inject_hbp("MASTER-A", self.private_call_terminator(999999, slot=1)) + + source_entry = [ + entry for entry in scenario.bridge_state["#999999"] if entry["SYSTEM"] == "MASTER-A" + ][0] + + self.assertEqual(source_entry["TS"], 2) + self.assertTrue(source_entry["ACTIVE"]) + self.assertTrue(spoken) + self.assertIn(b"linkedto", spoken[-1]) + self.assertEqual(spoken[-1].count(b"9"), 6) + self.assertEqual(scenario.capture.packets, []) + + def test_startup_default_reflector_rejects_reserved_control_targets(self): + for default_reflector in (6, 7, 8): + with self.subTest(default_reflector=default_reflector): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"] = default_reflector + + with DeterministicScenario(config=config) as scenario: + with self.assertLogs(scenario.bm.logger, level="WARNING"): + scenario.bm.make_default_reflectors() + + self.assertNotIn(f"#{default_reflector}", scenario.bridge_state) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"], 0) + + def test_startup_default_reflector_accepts_linkable_target(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"] = 91 + + with DeterministicScenario(config=config) as scenario: + scenario.bm.make_default_reflectors() + + master_entry = next( + entry for entry in scenario.bridge_state["#91"] if entry["SYSTEM"] == "MASTER-A" + ) + + self.assertTrue(master_entry["ACTIVE"]) + self.assertEqual(master_entry["TS"], 2) + self.assertEqual(master_entry["ON"], [bytes_3(91)]) + + def test_startup_default_reflector_accepts_policy_max_999999(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"] = 999999 + + with DeterministicScenario(config=config) as scenario: + scenario.bm.make_default_reflectors() + + master_entry = next( + entry for entry in scenario.bridge_state["#999999"] if entry["SYSTEM"] == "MASTER-A" + ) + + self.assertTrue(master_entry["ACTIVE"]) + self.assertEqual(master_entry["TS"], 2) + self.assertEqual(master_entry["ON"], [bytes_3(999999)]) + + def test_startup_default_reflector_rejects_above_policy_max(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"] = 1000000 + + with DeterministicScenario(config=config) as scenario: + with self.assertLogs(scenario.bm.logger, level="WARNING"): + scenario.bm.make_default_reflectors() + + self.assertNotIn("#1000000", scenario.bridge_state) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"], 0) + + def test_startup_default_reflector_logs_invalid_value(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"] = 1000000 + + with DeterministicScenario(config=config) as scenario: + with self.assertLogs(scenario.bm.logger, level="WARNING") as logs: + scenario.bm.make_default_reflectors() + + self.assertIn("MASTER-A default dial-a-tg 1000000 is invalid", "\n".join(logs.output)) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"], 0) + + def test_options_default_reflector_rejects_above_policy_max(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "DIAL=1000000;TIMER=1" + + with DeterministicScenario(config=config) as scenario: + scenario.bm.options_config() + + self.assertNotIn("#1000000", scenario.bridge_state) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"], 0) + + def test_options_invalid_default_reflector_disables_active_default(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"] = 91 + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "DIAL=1000000;TIMER=1" + + with DeterministicScenario(config=config) as scenario: + scenario.bm.make_default_reflectors() + active_entry = next( + entry for entry in scenario.bridge_state["#91"] if entry["SYSTEM"] == "MASTER-A" + ) + self.assertTrue(active_entry["ACTIVE"]) + + scenario.bm.options_config() + + disabled_entry = next( + entry for entry in scenario.bridge_state["#91"] if entry["SYSTEM"] == "MASTER-A" + ) + + self.assertFalse(disabled_entry["ACTIVE"]) + self.assertEqual(config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"], 0) + + def test_dial_a_tg_above_policy_max_reports_busy_without_retune(self): + bridges = {} + bridges.update(self.reflector_bridge("#235", active=True)) + + with DeterministicScenario(bridges=bridges) as scenario: + spoken = [] + scenario.bm.words["en_GB"].update( + { + "busy": b"busy", + "linkedto": b"linkedto", + "silence": b"silence", + "to": b"to", + } + ) + scenario.bm.pkt_gen = lambda source_id, dst_id, peer_id, slot, say: spoken.append(list(say)) or iter(()) + scenario.inject_hbp("MASTER-A", self.private_call(1000000, slot=1)) + scenario.inject_hbp("MASTER-A", self.private_call_terminator(1000000, slot=1)) + + source_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "MASTER-A" + ][0] + + self.assertTrue(source_entry["ACTIVE"]) + self.assertNotIn("#1000000", scenario.bridge_state) + self.assertTrue(spoken) + self.assertIn(b"busy", spoken[-1]) + self.assertNotIn(b"linkedto", spoken[-1]) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_linkable_target_creates_active_fbp_target(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + add_openbridge_system(config, "OBP-1", network_id=3001) + + with DeterministicScenario(config=config) as scenario: + scenario.inject_hbp("MASTER-A", self.private_call(235, slot=1)) + + bridge = scenario.bridge_state["#235"] + source_entry = [entry for entry in bridge if entry["SYSTEM"] == "MASTER-A"][0] + fbp_entry = [entry for entry in bridge if entry["SYSTEM"] == "OBP-1"][0] + + self.assertTrue(source_entry["ACTIVE"]) + self.assertEqual(source_entry["TS"], 2) + self.assertEqual(source_entry["TGID"], bytes_3(9)) + self.assertTrue(fbp_entry["ACTIVE"]) + self.assertEqual(fbp_entry["TS"], 1) + self.assertEqual(fbp_entry["TGID"], bytes_3(235)) + self.assertEqual(fbp_entry["TO_TYPE"], "NONE") + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_bcsq_uses_reflector_tg_for_fbp_target(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + add_openbridge_system(config, "OBP-1", network_id=3001) + stream_id = bytes_4(0x01020344) + + with DeterministicScenario(config=config) as scenario: + scenario.inject_hbp("MASTER-A", self.private_call(235, slot=1)) + config["SYSTEMS"]["OBP-1"]["_bcsq"] = {bytes_3(235): stream_id} + + scenario.inject_hbp( + "MASTER-A", + PacketSpec(dst_id=9, slot=2, stream_id=stream_id), + ) + + self.assertEqual(scenario.capture.for_system("OBP-1"), []) + + def test_dial_a_tg_echo_target_9990_does_not_create_fbp_target(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + add_openbridge_system(config, "OBP-1", network_id=3001) + + with DeterministicScenario(config=config) as scenario: + scenario.inject_hbp("MASTER-A", self.private_call(9990, slot=1)) + + bridge = scenario.bridge_state["#9990"] + systems = [entry["SYSTEM"] for entry in bridge] + + self.assertIn("MASTER-A", systems) + self.assertIn("MASTER-B", systems) + self.assertNotIn("OBP-1", systems) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_information_service_schedules_file_and_silence_prompt(self): + with DeterministicScenario() as scenario: + generated = [] + scenario.bm.pkt_gen = lambda source_id, dst_id, peer_id, slot, say: generated.append(list(say)) or iter(()) + scenario.inject_hbp("MASTER-A", self.private_call(9991, slot=1)) + scenario.inject_hbp("MASTER-A", self.private_call_terminator(9991, slot=1)) + + scheduled = [call[0] for call in scenario.reactor.thread_calls] + + self.assertNotIn("#9991", scenario.bridge_state) + self.assertIn(scenario.bm.playFileOnRequest, scheduled) + self.assertIn(scenario.bm.sendSpeech, scheduled) + self.assertEqual(generated, [[b""]]) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_private_call_timeout_does_not_emit_group_voice_end(self): + config = minimal_config(("MASTER-A",)) + config["REPORTS"]["REPORT"] = True + + with DeterministicScenario(config=config) as scenario: + scenario.clock.advance(100) + scenario.inject_hbp("MASTER-A", self.private_call(235, slot=1)) + scenario.clock.advance(6) + scenario.bm.stream_trimmer_loop() + + self.assertFalse( + any( + event.startswith(b"GROUP VOICE,END,RX") + for event in scenario.reports["MASTER-A"].events + ) + ) + + def test_send_speech_tracks_stream_on_router_system_not_global_system(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + packet = PacketSpec(dst_id=9, slot=2, stream_id=0x01020309).data() + stream_id = bytes_4(0x01020309) + old_sleep = None + old_global_system = None + had_global_system = False + + with DeterministicScenario(config=config) as scenario: + old_sleep = scenario.bm.sleep + had_global_system = hasattr(scenario.bm, "system") + if had_global_system: + old_global_system = scenario.bm.system + try: + scenario.bm.sleep = lambda seconds: None + scenario.bm.system = "MASTER-B" + + scenario.bm.sendSpeech(scenario.systems["MASTER-A"], iter([packet])) + finally: + scenario.bm.sleep = old_sleep + if had_global_system: + scenario.bm.system = old_global_system + else: + delattr(scenario.bm, "system") + + self.assertIn(stream_id, scenario.systems["MASTER-A"].STATUS) + self.assertNotIn(stream_id, scenario.systems["MASTER-B"].STATUS) + captured = scenario.capture.for_system("MASTER-A") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["dst_id"], bytes_3(9)) + self.assertEqual(captured[0].fields["slot"], 2) + + def test_generated_voice_first_packet_records_prompt_activity(self): + config = minimal_config(("MASTER-A",)) + packet = PacketSpec(dst_id=9, slot=2, stream_id=0x01020309).data() + stream_id = bytes_4(0x01020309) + + with DeterministicScenario(config=config) as scenario: + slot = scenario.systems["MASTER-A"].STATUS[2] + scenario.bm.sendVoicePacket( + scenario.systems["MASTER-A"], + packet, + bytes_3(5000), + bytes_3(9), + slot, + ) + + captured = scenario.capture.for_system("MASTER-A") + + self.assertEqual(len(captured), 1) + self.assertTrue(slot["TX_PROMPT_ACTIVE"]) + self.assertFalse(slot["TX_PROMPT_CANCEL"]) + self.assertEqual(slot["TX_PROMPT_STREAM_ID"], stream_id) + self.assertEqual(slot["TX_PROMPT_TGID"], bytes_3(9)) + self.assertEqual(slot["TX_PROMPT_RFS"], bytes_3(5000)) + self.assertEqual(slot["TX_PROMPT_TIME"], scenario.clock.time()) + + def test_send_speech_stops_when_generated_prompt_is_cancelled(self): + config = minimal_config(("MASTER-A",)) + packets = iter( + [ + PacketSpec(dst_id=9, slot=2, stream_id=0x01020309, seq=1).data(), + PacketSpec(dst_id=9, slot=2, stream_id=0x01020309, seq=2).data(), + ] + ) + + with DeterministicScenario(config=config) as scenario: + original_send_voice_packet = scenario.bm.sendVoicePacket + original_sleep = scenario.bm.sleep + + def send_then_cancel(system, pkt, source_id, dest_id, slot): + original_send_voice_packet(system, pkt, source_id, dest_id, slot) + scenario.bm._cancelGeneratedVoice(slot) + + try: + scenario.bm.sendVoicePacket = send_then_cancel + scenario.bm.sleep = lambda seconds: None + scenario.bm.sendSpeech(scenario.systems["MASTER-A"], packets) + finally: + scenario.bm.sendVoicePacket = original_send_voice_packet + scenario.bm.sleep = original_sleep + + captured = scenario.capture.for_system("MASTER-A") + slot = scenario.systems["MASTER-A"].STATUS[2] + + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["seq"], 1) + self.assertTrue(slot["TX_PROMPT_CANCEL"]) + self.assertFalse(slot["TX_PROMPT_ACTIVE"]) + + def test_real_hbp_voice_cancels_generated_prompt_and_keeps_late_entry_lc_rewrite(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + prompt_packet = PacketSpec(dst_id=9, slot=2, stream_id=0x01020309).data() + real_packet = PacketSpec( + dst_id=91, + slot=2, + stream_id=0x01020304, + seq=7, + call_type="group", + frame_type=HBPF_VOICE, + dtype_vseq=1, + payload=b"\xff" * 33, + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + target_slot = scenario.systems["MASTER-B"].STATUS[2] + scenario.bm.sendVoicePacket( + scenario.systems["MASTER-B"], + prompt_packet, + bytes_3(5000), + bytes_3(9), + target_slot, + ) + + scenario.inject_hbp("MASTER-A", real_packet) + + captured = scenario.capture.for_system("MASTER-B") + + self.assertEqual(len(captured), 2) + self.assertEqual(captured[0].fields["dst_id"], bytes_3(9)) + self.assertEqual(captured[1].fields["dst_id"], bytes_3(91)) + self.assertEqual(captured[1].fields["dtype_vseq"], 1) + self.assertNotEqual( + captured[1].fields["dmr_payload"], + real_packet.data()[20:53], + ) + self.assertTrue(target_slot["TX_PROMPT_CANCEL"]) + self.assertFalse(target_slot["TX_PROMPT_ACTIVE"]) + + def test_dial_a_tg_retune_keeps_created_fbp_targets_active(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + add_openbridge_system(config, "OBP-1", network_id=3001) + + with DeterministicScenario(config=config) as scenario: + scenario.inject_hbp("MASTER-A", self.private_call(235, slot=1)) + scenario.inject_hbp("MASTER-A", self.private_call(236, slot=1, stream_id=0x01020305)) + + old_master_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "MASTER-A" + ][0] + old_fbp_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "OBP-1" + ][0] + new_master_entry = [ + entry for entry in scenario.bridge_state["#236"] if entry["SYSTEM"] == "MASTER-A" + ][0] + new_fbp_entry = [ + entry for entry in scenario.bridge_state["#236"] if entry["SYSTEM"] == "OBP-1" + ][0] + + self.assertFalse(old_master_entry["ACTIVE"]) + self.assertTrue(new_master_entry["ACTIVE"]) + self.assertTrue(old_fbp_entry["ACTIVE"]) + self.assertTrue(new_fbp_entry["ACTIVE"]) + self.assertEqual(old_fbp_entry["TO_TYPE"], "NONE") + self.assertEqual(new_fbp_entry["TO_TYPE"], "NONE") + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_disconnect_keeps_created_fbp_targets_active(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + add_openbridge_system(config, "OBP-1", network_id=3001) + + with DeterministicScenario(config=config) as scenario: + scenario.inject_hbp("MASTER-A", self.private_call(235, slot=1)) + scenario.inject_hbp("MASTER-A", self.private_call(4000, slot=1, stream_id=0x01020305)) + + master_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "MASTER-A" + ][0] + fbp_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "OBP-1" + ][0] + + self.assertFalse(master_entry["ACTIVE"]) + self.assertTrue(fbp_entry["ACTIVE"]) + self.assertEqual(fbp_entry["TO_TYPE"], "NONE") + self.assertEqual(scenario.capture.packets, []) + + def test_rule_timer_loop_removes_disconnected_fbp_only_reflector(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + add_openbridge_system(config, "OBP-1", network_id=3001) + + with DeterministicScenario(config=config) as scenario: + scenario.inject_hbp("MASTER-A", self.private_call(235, slot=1)) + scenario.inject_hbp("MASTER-A", self.private_call(4000, slot=1, stream_id=0x01020305)) + + self.assertIn("#235", scenario.bridge_state) + + scenario.bm.rule_timer_loop() + + self.assertNotIn("#235", scenario.bridge_state) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_status_on_slot_1_reports_active_ts2_reflector(self): + bridges = {} + bridges.update(self.reflector_bridge("#235", active=True)) + + with DeterministicScenario(bridges=bridges) as scenario: + spoken = [] + scenario.bm.words["en_GB"].update( + { + "2": b"2", + "3": b"3", + "5": b"5", + "linkedto": b"linkedto", + "notlinked": b"notlinked", + "silence": b"silence", + "to": b"to", + } + ) + scenario.bm.pkt_gen = lambda source_id, dst_id, peer_id, slot, say: spoken.append(list(say)) or iter(()) + scenario.inject_hbp("MASTER-A", self.private_call(5000, slot=1)) + scenario.inject_hbp("MASTER-A", self.private_call_terminator(5000, slot=1)) + + source_entry = [ + entry for entry in scenario.bridge_state["#235"] if entry["SYSTEM"] == "MASTER-A" + ][0] + + self.assertTrue(source_entry["ACTIVE"]) + self.assertEqual(source_entry["TS"], 2) + self.assertTrue(spoken) + self.assertIn(b"linkedto", spoken[-1]) + self.assertIn(b"2", spoken[-1]) + self.assertIn(b"3", spoken[-1]) + self.assertIn(b"5", spoken[-1]) + self.assertNotIn(b"notlinked", spoken[-1]) + self.assertEqual(scenario.capture.packets, []) + + def test_dial_a_tg_status_reports_only_one_active_reflector(self): + bridges = {} + bridges.update(self.reflector_bridge("#235", active=True)) + bridges.update(self.reflector_bridge("#236", active=True)) + + with DeterministicScenario(bridges=bridges) as scenario: + spoken = [] + scenario.bm.words["en_GB"].update( + { + "2": b"2", + "3": b"3", + "5": b"5", + "6": b"6", + "linkedto": b"linkedto", + "notlinked": b"notlinked", + "silence": b"silence", + "to": b"to", + } + ) + scenario.bm.pkt_gen = lambda source_id, dst_id, peer_id, slot, say: spoken.append(list(say)) or iter(()) + scenario.inject_hbp("MASTER-A", self.private_call(5000, slot=1)) + scenario.inject_hbp("MASTER-A", self.private_call_terminator(5000, slot=1)) + + self.assertTrue(spoken) + self.assertEqual(spoken[-1].count(b"linkedto"), 1) + self.assertIn(b"5", spoken[-1]) + self.assertNotIn(b"6", spoken[-1]) + self.assertNotIn(b"notlinked", spoken[-1]) + self.assertEqual(scenario.capture.packets, []) + + def test_remove_bridge_system_keeps_reset_system_identity(self): + config = minimal_config(("MASTER-A", "MASTER-B", "MASTER-C")) + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ("MASTER-C", 2), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + original_b = dict(scenario.bridge_state["91"][1]) + original_c = dict(scenario.bridge_state["91"][2]) + + scenario.bm.remove_bridge_system("MASTER-A") + + entries = scenario.bridge_state["91"] + self.assertEqual([entry["SYSTEM"] for entry in entries], ["MASTER-A", "MASTER-B", "MASTER-C"]) + self.assertFalse(entries[0]["ACTIVE"]) + self.assertEqual(entries[0]["TS"], 2) + self.assertEqual(entries[0]["TGID"], bytes_3(91)) + self.assertEqual(entries[0]["ON"], [bytes_3(91)]) + self.assertEqual(entries[1], original_b) + self.assertEqual(entries[2], original_c) + + def test_remove_bridge_system_preserves_reflector_activation_trigger(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + add_openbridge_system(config, "OBP-1", network_id=3001) + bridges = { + "#235": [ + { + "SYSTEM": "MASTER-A", + "TS": 2, + "TGID": bytes_3(9), + "ACTIVE": True, + "TIMEOUT": 60, + "TO_TYPE": "OFF", + "OFF": [], + "ON": [bytes_3(235)], + "RESET": [bytes_3(5000)], + "TIMER": 123, + }, + { + "SYSTEM": "MASTER-B", + "TS": 2, + "TGID": bytes_3(9), + "ACTIVE": True, + "TIMEOUT": 60, + "TO_TYPE": "ON", + "OFF": [], + "ON": [bytes_3(235)], + "RESET": [], + "TIMER": 456, + }, + { + "SYSTEM": "OBP-1", + "TS": 1, + "TGID": bytes_3(235), + "ACTIVE": True, + "TIMEOUT": "", + "TO_TYPE": "NONE", + "OFF": [], + "ON": [], + "RESET": [], + "TIMER": 789, + }, + ] + } + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + original_master_b = dict(scenario.bridge_state["#235"][1]) + original_obp = dict(scenario.bridge_state["#235"][2]) + + scenario.bm.remove_bridge_system("MASTER-A") + + entries = scenario.bridge_state["#235"] + self.assertEqual([entry["SYSTEM"] for entry in entries], ["MASTER-A", "MASTER-B", "OBP-1"]) + self.assertFalse(entries[0]["ACTIVE"]) + self.assertEqual(entries[0]["TO_TYPE"], "ON") + self.assertEqual(entries[0]["TGID"], bytes_3(9)) + self.assertEqual(entries[0]["ON"], [bytes_3(235)]) + self.assertEqual(entries[0]["RESET"], [bytes_3(5000)]) + self.assertEqual(entries[1], original_master_b) + self.assertEqual(entries[2], original_obp) + + def test_hbp_reset_guard_allows_packets_when_lifecycle_flags_false(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["_reset"] = False + config["SYSTEMS"]["MASTER-A"]["_reloadoptions"] = False + config["SYSTEMS"]["MASTER-A"]["_resetlog"] = False + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + scenario.inject_hbp("MASTER-A", PacketSpec(dst_id=91, slot=2)) + + self.assertEqual(len(scenario.capture.for_system("MASTER-B")), 1) + self.assertFalse(config["SYSTEMS"]["MASTER-A"]["_resetlog"]) + + def test_hbp_reset_guard_drops_packets_and_logs_once_while_reset_active(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["_reset"] = True + config["SYSTEMS"]["MASTER-A"]["_reloadoptions"] = False + config["SYSTEMS"]["MASTER-A"]["_resetlog"] = False + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + with self.assertLogs(scenario.bm.logger, level="INFO") as logs: + scenario.inject_hbp("MASTER-A", PacketSpec(dst_id=91, slot=2)) + scenario.inject_hbp("MASTER-A", PacketSpec(dst_id=91, slot=2, stream_id=0x01020305)) + + self.assertEqual(scenario.capture.for_system("MASTER-B"), []) + self.assertTrue(config["SYSTEMS"]["MASTER-A"]["_resetlog"]) + self.assertEqual("\n".join(logs.output).count("disallow transmission"), 1) + + def test_hbp_reset_guard_drops_packets_and_logs_once_while_reload_active(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["_reset"] = False + config["SYSTEMS"]["MASTER-A"]["_reloadoptions"] = True + config["SYSTEMS"]["MASTER-A"]["_resetlog"] = False + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + with self.assertLogs(scenario.bm.logger, level="INFO") as logs: + scenario.inject_hbp("MASTER-A", PacketSpec(dst_id=91, slot=2)) + scenario.inject_hbp("MASTER-A", PacketSpec(dst_id=91, slot=2, stream_id=0x01020305)) + + self.assertEqual(scenario.capture.for_system("MASTER-B"), []) + self.assertTrue(config["SYSTEMS"]["MASTER-A"]["_resetlog"]) + self.assertEqual("\n".join(logs.output).count("disallow transmission"), 1) + + def test_hbp_unit_data_to_obp_reports_on_target_system(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + config["REPORTS"]["REPORT"] = True + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("OBP-1", 1), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=1000001, + slot=2, + stream_id=0x01020304, + call_type="unit", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=6, + ) + + scenario.inject_hbp("MASTER-A", packet) + + captured = scenario.capture.for_system("OBP-1") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["dst_id"], bytes_3(1000001)) + self.assertEqual(len(scenario.reports["OBP-1"].events), 1) + self.assertIn(b"UNIT DATA,DATA,TX,OBP-1", scenario.reports["OBP-1"].events[0]) + self.assertFalse( + any( + event.startswith(b"UNIT DATA,DATA,TX,OBP-1") + for event in scenario.reports["MASTER-A"].events + ) + ) + + def test_hbp_unit_data_to_obp_preserves_ber_rssi_metadata(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("OBP-1", 1), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=1000001, + slot=2, + stream_id=0x01020304, + call_type="unit", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=6, + ber=b"B", + rssi=b"R", + ) + + scenario.inject_hbp("MASTER-A", packet) + + captured = scenario.capture.for_system("OBP-1") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].ber, b"B") + self.assertEqual(captured[0].rssi, b"R") + self.assertEqual(captured[0].fields["ber"], b"") + self.assertEqual(captured[0].fields["rssi"], b"") + + def test_hbp_unit_data_to_enhanced_obp_without_keepalive_is_dropped(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + config["SYSTEMS"]["OBP-1"]["ENHANCED_OBP"] = True + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("OBP-1", 1), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=1000001, + slot=2, + stream_id=0x01020304, + call_type="unit", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=6, + ) + + scenario.inject_hbp("MASTER-A", packet) + + self.assertEqual(scenario.capture.for_system("OBP-1"), []) + + def test_hbp_unit_data_to_enhanced_obp_with_stale_keepalive_is_dropped(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + config["SYSTEMS"]["OBP-1"]["ENHANCED_OBP"] = True + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("OBP-1", 1), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + config["SYSTEMS"]["OBP-1"]["_bcka"] = scenario.clock.time() - 61 + packet = PacketSpec( + dst_id=1000001, + slot=2, + stream_id=0x01020304, + call_type="unit", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=6, + ) + + scenario.inject_hbp("MASTER-A", packet) + + self.assertEqual(scenario.capture.for_system("OBP-1"), []) + + def test_hbp_unit_data_to_enhanced_obp_with_recent_keepalive_is_forwarded(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + config["SYSTEMS"]["OBP-1"]["ENHANCED_OBP"] = True + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("OBP-1", 1), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + config["SYSTEMS"]["OBP-1"]["_bcka"] = scenario.clock.time() - 5 + packet = PacketSpec( + dst_id=1000001, + slot=2, + stream_id=0x01020304, + call_type="unit", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=6, + ) + + scenario.inject_hbp("MASTER-A", packet) + + self.assertEqual(len(scenario.capture.for_system("OBP-1")), 1) + + def test_obp_unit_data_to_enhanced_obp_without_keepalive_is_dropped(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + add_openbridge_system(config, "OBP-2", network_id=3002) + config["SYSTEMS"]["OBP-2"]["ENHANCED_OBP"] = True + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("OBP-1", 1), + ("OBP-2", 1), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=1000001, + slot=1, + stream_id=0x01020304, + call_type="unit", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=6, + ) + + scenario.inject_obp("OBP-1", packet) + + self.assertEqual(scenario.capture.for_system("OBP-2"), []) + + def test_obp_unit_data_to_fbp_preserves_source_repeater_metadata(self): + config = minimal_config(()) + add_openbridge_system(config, "OBP-1", network_id=3001) + add_openbridge_system(config, "OBP-2", network_id=3002) + + with DeterministicScenario(config=config) as scenario: + packet = PacketSpec( + dst_id=1000001, + slot=1, + stream_id=0x01020304, + call_type="unit", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=6, + ber=b"B", + rssi=b"R", + ) + + scenario.systems["OBP-1"].dmrd_received( + *packet.decoded_obp_args( + hops=b"\x05", + source_server=7654321, + source_rptr=1234567, + ) + ) + + captured = scenario.capture.for_system("OBP-2") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].hops, b"\x05") + self.assertEqual(captured[0].source_server, bytes_4(7654321)) + self.assertEqual(captured[0].source_rptr, bytes_4(1234567)) + self.assertEqual(captured[0].ber, b"B") + self.assertEqual(captured[0].rssi, b"R") + + def test_obp_parser_discards_dmre_shorter_than_version_byte(self): + config = minimal_config(()) + add_openbridge_system(config, "OBP-1", network_id=3001) + + with DeterministicScenario(config=config) as scenario: + with self.assertLogs("hblink", level="WARNING") as logs: + scenario.inject_datagram("OBP-1", b"DMRE") + + self.assertIn("packet too short", "\n".join(logs.output)) + self.assertEqual(scenario.capture.for_system("OBP-1"), []) + + def test_obp_parser_discards_truncated_dmre_after_version_byte(self): + config = minimal_config(()) + add_openbridge_system(config, "OBP-1", network_id=3001) + + with DeterministicScenario(config=config) as scenario: + packet = b"DMRE" + (b"\x00" * 51) + b"\x05" + (b"\x00" * 10) + + with self.assertLogs("hblink", level="WARNING") as logs: + scenario.inject_datagram("OBP-1", packet) + + self.assertIn("v5 packet too short", "\n".join(logs.output)) + self.assertEqual(scenario.capture.for_system("OBP-1"), []) + + def test_hbp_master_parser_discards_truncated_dmrd_from_connected_peer(self): + config = minimal_config(("MASTER-A",)) + + with DeterministicScenario(config=config) as scenario: + sockaddr = ("127.0.0.1", 50000) + scenario.register_peer("MASTER-A", peer_id=1001, sockaddr=sockaddr) + packet = b"DMRD" + b"\x01" + bytes_3(3120001) + bytes_3(123) + bytes_4(1001) + + with self.assertLogs("hblink", level="WARNING") as logs: + scenario.inject_datagram("MASTER-A", packet, sockaddr=sockaddr) + + self.assertIn("DMRD packet too short", "\n".join(logs.output)) + self.assertEqual(scenario.capture.for_system("MASTER-A"), []) + + def test_obp_bridge_control_bcst_sets_global_stun_flag(self): + config = minimal_config(()) + add_openbridge_system(config, "OBP-1", network_id=3001) + config["SYSTEMS"]["OBP-1"]["ENHANCED_OBP"] = True + + with DeterministicScenario(config=config) as scenario: + packet = b"BCST" + hmac_new( + config["SYSTEMS"]["OBP-1"]["PASSPHRASE"], + b"BCST", + sha1, + ).digest() + + scenario.inject_datagram("OBP-1", packet) + + self.assertTrue(config["STUN"]) + self.assertNotIn("_STUN", config["SYSTEMS"]["OBP-1"]) + + def test_hbp_group_data_reports_as_data_without_voice_timeout_events(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["REPORTS"]["REPORT"] = True + bridges = active_bridge( + "123", + 123, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=123, + slot=2, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=6, + ) + + scenario.inject_hbp("MASTER-A", packet) + scenario.clock.advance(6) + scenario.bm.stream_trimmer_loop() + + self.assertIn( + b"DATA HEADER,DATA,RX,MASTER-A,16909060,1001,3120001,2,123", + scenario.reports["MASTER-A"].events, + ) + self.assertIn( + b"DATA HEADER,DATA,TX,MASTER-B,16909060,1001,3120001,2,123", + scenario.reports["MASTER-B"].events, + ) + self.assertFalse( + any( + event.startswith(b"GROUP VOICE") + for event in ( + scenario.reports["MASTER-A"].events + + scenario.reports["MASTER-B"].events + ) + ) + ) + + def test_hbp_group_rate_drop_handles_zero_duration(self): + config = minimal_config(("MASTER-A",)) + + with DeterministicScenario(config=config) as scenario: + packet = PacketSpec( + dst_id=123, + slot=2, + stream_id=0x01020304, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=6, + ) + + for _ in range(19): + scenario.inject_hbp("MASTER-A", packet) + + self.assertEqual( + scenario.systems["MASTER-A"].STATUS[2]["RX_STREAM_ID"], + bytes_4(0x01020304), + ) + + def test_obp_group_data_reports_as_data_without_voice_timeout_events(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + config["REPORTS"]["REPORT"] = True + bridges = active_bridge( + "123", + 123, + ( + ("OBP-1", 1), + ("MASTER-A", 2), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=123, + slot=1, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=6, + ) + + with self.assertLogs(scenario.bm.logger, level="INFO") as logs: + scenario.inject_obp("OBP-1", packet) + scenario.clock.advance(6) + scenario.bm.stream_trimmer_loop() + + self.assertIn("*DATA HEADER*", "\n".join(logs.output)) + self.assertNotIn("*CALL START*", "\n".join(logs.output)) + self.assertIn( + b"DATA HEADER,DATA,RX,OBP-1,16909060,1001,3120001,1,123", + scenario.reports["OBP-1"].events, + ) + self.assertIn( + b"DATA HEADER,DATA,TX,MASTER-A,16909060,1001,3120001,2,123", + scenario.reports["MASTER-A"].events, + ) + self.assertFalse( + any( + event.startswith(b"GROUP VOICE") + for event in ( + scenario.reports["OBP-1"].events + + scenario.reports["MASTER-A"].events + ) + ) + ) + + def test_hbp_group_data_continuation_reports_as_data(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["REPORTS"]["REPORT"] = True + bridges = active_bridge( + "123", + 123, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=123, + slot=2, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=8, + ) + + scenario.inject_hbp("MASTER-A", packet) + scenario.clock.advance(6) + scenario.bm.stream_trimmer_loop() + + self.assertIn( + b"VCSBK 3/4 DATA BLOCK,DATA,RX,MASTER-A,16909060,1001,3120001,2,123", + scenario.reports["MASTER-A"].events, + ) + self.assertIn( + b"VCSBK 3/4 DATA BLOCK,DATA,TX,MASTER-B,16909060,1001,3120001,2,123", + scenario.reports["MASTER-B"].events, + ) + self.assertFalse( + any( + event.startswith(b"GROUP VOICE") + for event in ( + scenario.reports["MASTER-A"].events + + scenario.reports["MASTER-B"].events + ) + ) + ) + + def test_obp_group_data_continuation_reports_as_data(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + config["REPORTS"]["REPORT"] = True + bridges = active_bridge( + "123", + 123, + ( + ("OBP-1", 1), + ("MASTER-A", 2), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=123, + slot=1, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=7, + ) + + scenario.inject_obp("OBP-1", packet) + scenario.clock.advance(6) + scenario.bm.stream_trimmer_loop() + + self.assertIn( + b"VCSBK 1/2 DATA BLOCK,DATA,RX,OBP-1,16909060,1001,3120001,1,123", + scenario.reports["OBP-1"].events, + ) + self.assertIn( + b"VCSBK 1/2 DATA BLOCK,DATA,TX,MASTER-A,16909060,1001,3120001,2,123", + scenario.reports["MASTER-A"].events, + ) + self.assertFalse( + any( + event.startswith(b"GROUP VOICE") + for event in ( + scenario.reports["OBP-1"].events + + scenario.reports["MASTER-A"].events + ) + ) + ) + + def test_hbp_to_hbp_data_sync_control_payload_is_not_emb_lc_rewritten(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + bridges = active_bridge( + "123", + 123, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + payload = bytes(range(33)) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=123, + slot=2, + call_type="vcsbk", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=3, + payload=payload, + ) + + scenario.inject_hbp("MASTER-A", packet) + + captured = scenario.capture.for_system("MASTER-B") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["dmr_payload"], payload) + + def test_hbp_to_obp_data_sync_control_payload_is_not_emb_lc_rewritten(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + bridges = active_bridge( + "123", + 123, + ( + ("MASTER-A", 2), + ("OBP-1", 1), + ), + ) + payload = bytes(range(33)) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=123, + slot=2, + call_type="vcsbk", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=3, + payload=payload, + ) + + scenario.inject_hbp("MASTER-A", packet) + + captured = scenario.capture.for_system("OBP-1") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["dmr_payload"], payload) + + def test_obp_to_hbp_data_sync_control_payload_is_not_emb_lc_rewritten(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + bridges = active_bridge( + "123", + 123, + ( + ("OBP-1", 1), + ("MASTER-A", 2), + ), + ) + payload = bytes(range(33)) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=123, + slot=1, + call_type="vcsbk", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=3, + payload=payload, + ) + + scenario.inject_obp("OBP-1", packet) + + captured = scenario.capture.for_system("MASTER-A") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["dmr_payload"], payload) + + def test_obp_to_obp_data_sync_control_payload_is_not_emb_lc_rewritten(self): + config = minimal_config(()) + add_openbridge_system(config, "OBP-1", network_id=3001) + add_openbridge_system(config, "OBP-2", network_id=3002) + bridges = active_bridge( + "123", + 123, + ( + ("OBP-1", 1), + ("OBP-2", 1), + ), + ) + payload = bytes(range(33)) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=123, + slot=1, + call_type="vcsbk", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=3, + payload=payload, + ) + + scenario.inject_obp("OBP-1", packet) + + captured = scenario.capture.for_system("OBP-2") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["dmr_payload"], payload) + + def test_hbp_vcsbk_data_reports_specific_rx_event_without_generic_duplicate(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["REPORTS"]["REPORT"] = True + bridges = active_bridge( + "123", + 123, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=123, + slot=2, + call_type="vcsbk", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=7, + ) + + scenario.inject_hbp("MASTER-A", packet) + + self.assertIn( + b"VCSBK 1/2 DATA BLOCK,DATA,RX,MASTER-A,16909060,1001,3120001,2,123", + scenario.reports["MASTER-A"].events, + ) + self.assertIn( + b"VCSBK 1/2 DATA BLOCK,DATA,TX,MASTER-B,16909060,1001,3120001,2,123", + scenario.reports["MASTER-B"].events, + ) + self.assertFalse( + any( + event.startswith(b"OTHER DATA,DATA,RX") + for event in scenario.reports["MASTER-A"].events + ) + ) + + def test_hbp_vcsbk_unknown_type_reports_other_data_rx(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["REPORTS"]["REPORT"] = True + bridges = active_bridge( + "123", + 123, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=123, + slot=2, + call_type="vcsbk", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=5, + ) + + scenario.inject_hbp("MASTER-A", packet) + scenario.clock.advance(6) + scenario.bm.stream_trimmer_loop() + + self.assertIn( + b"OTHER DATA,DATA,RX,MASTER-A,16909060,1001,3120001,2,123", + scenario.reports["MASTER-A"].events, + ) + self.assertIn( + b"OTHER DATA,DATA,TX,MASTER-B,16909060,1001,3120001,2,123", + scenario.reports["MASTER-B"].events, + ) + self.assertFalse( + any( + event.startswith(b"GROUP VOICE") + for event in ( + scenario.reports["MASTER-A"].events + + scenario.reports["MASTER-B"].events + ) + ) + ) + + def test_obp_vcsbk_unknown_type_reports_other_data_without_voice_events(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + config["REPORTS"]["REPORT"] = True + bridges = active_bridge( + "123", + 123, + ( + ("OBP-1", 1), + ("MASTER-A", 2), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=123, + slot=1, + call_type="vcsbk", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=5, + ) + + scenario.inject_obp("OBP-1", packet) + scenario.clock.advance(6) + scenario.bm.stream_trimmer_loop() + + self.assertIn( + b"OTHER DATA,DATA,RX,OBP-1,16909060,1001,3120001,1,123", + scenario.reports["OBP-1"].events, + ) + self.assertIn( + b"OTHER DATA,DATA,TX,MASTER-A,16909060,1001,3120001,2,123", + scenario.reports["MASTER-A"].events, + ) + self.assertFalse( + any( + event.startswith(b"GROUP VOICE") + for event in ( + scenario.reports["OBP-1"].events + + scenario.reports["MASTER-A"].events + ) + ) + ) + + def test_obp_group_loop_control_logs_packet_rate_not_duration(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + add_openbridge_system(config, "OBP-2", network_id=3002) + + with DeterministicScenario(config=config) as scenario: + packet = PacketSpec( + dst_id=123, + slot=1, + stream_id=0x01020304, + call_type="group", + frame_type=HBPF_VOICE, + dtype_vseq=0, + ) + + scenario.inject_obp("OBP-1", packet) + scenario.inject_obp("OBP-2", packet) + scenario.clock.advance(2) + + with self.assertLogs(scenario.bm.logger, level="DEBUG") as logs: + scenario.inject_obp("OBP-2", packet) + + self.assertIn("PACKET RATE 0.50/s", "\n".join(logs.output)) + self.assertNotIn("PACKET RATE 2.00/s", "\n".join(logs.output)) + + def test_obp_group_rate_drop_uses_elapsed_duration(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + + with DeterministicScenario(config=config) as scenario: + rate_drops = [] + scenario.systems["OBP-1"].proxy_BadPeer = lambda: rate_drops.append(True) + + stream_id = 0x01020304 + first_packet = PacketSpec( + dst_id=123, + slot=1, + stream_id=stream_id, + seq=0, + call_type="group", + frame_type=HBPF_VOICE, + dtype_vseq=0, + payload=bytes([0]) * 33, + ) + scenario.inject_obp("OBP-1", first_packet) + scenario.clock.advance(0.5) + + with self.assertLogs(scenario.bm.logger, level="WARNING") as logs: + for seq in range(1, 20): + packet = PacketSpec( + dst_id=123, + slot=1, + stream_id=stream_id, + seq=seq, + call_type="group", + frame_type=HBPF_VOICE, + dtype_vseq=0, + payload=bytes([seq]) * 33, + ) + scenario.inject_obp("OBP-1", packet) + + self.assertEqual(rate_drops, [True]) + self.assertIn("*PacketControl* RATE DROP!", "\n".join(logs.output)) + + def test_obp_voice_terminator_marks_stream_finished_when_reports_disabled(self): + config = minimal_config(("MASTER-B",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + config["REPORTS"]["REPORT"] = False + bridges = active_bridge( + "91", + 91, + ( + ("OBP-1", 1), + ("MASTER-B", 2), + ), + ) + stream_id = 0x01020304 + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + header = PacketSpec( + dst_id=91, + slot=1, + stream_id=stream_id, + seq=1, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VHEAD, + ) + terminator = PacketSpec( + dst_id=91, + slot=1, + stream_id=stream_id, + seq=2, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VTERM, + payload=b"\x01" * 33, + ) + late_packet = PacketSpec( + dst_id=91, + slot=1, + stream_id=stream_id, + seq=3, + call_type="group", + frame_type=HBPF_VOICE, + dtype_vseq=3, + payload=b"\x02" * 33, + ) + + scenario.inject_obp("OBP-1", header) + scenario.inject_obp("OBP-1", terminator) + before_late_packet = len(scenario.capture.for_system("MASTER-B")) + scenario.inject_obp("OBP-1", late_packet) + captured = scenario.capture.for_system("MASTER-B") + + self.assertEqual(before_late_packet, 2) + self.assertEqual(len(captured), 2) + self.assertIn( + "_fin", + scenario.systems["OBP-1"].STATUS[bytes_4(stream_id)], + ) + + def test_hbp_to_obp_terminator_marks_target_finished_without_timeout_report(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + config["REPORTS"]["REPORT"] = True + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("OBP-1", 1), + ), + ) + stream_id = 0x01020304 + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + header = PacketSpec( + dst_id=91, + slot=2, + stream_id=stream_id, + seq=1, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VHEAD, + ) + terminator = PacketSpec( + dst_id=91, + slot=2, + stream_id=stream_id, + seq=2, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VTERM, + payload=b"\x01" * 33, + ) + + scenario.inject_hbp("MASTER-A", header) + scenario.inject_hbp("MASTER-A", terminator) + scenario.clock.advance(6) + scenario.bm.stream_trimmer_loop() + + self.assertIn( + "_fin", + scenario.systems["OBP-1"].STATUS[bytes_4(stream_id)], + ) + self.assertIn( + b"GROUP VOICE,END,TX,OBP-1,16909060,1001,3120001,1,91,0.00", + scenario.reports["OBP-1"].events, + ) + self.assertFalse( + any( + event.startswith(b"GROUP VOICE,END,RX,OBP-1") + for event in scenario.reports["OBP-1"].events + ) + ) + + def test_obp_to_obp_terminator_marks_target_finished_without_timeout_report(self): + config = minimal_config(()) + add_openbridge_system(config, "OBP-1", network_id=3001) + add_openbridge_system(config, "OBP-2", network_id=3002) + config["REPORTS"]["REPORT"] = True + bridges = active_bridge( + "91", + 91, + ( + ("OBP-1", 1), + ("OBP-2", 1), + ), + ) + stream_id = 0x01020304 + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + header = PacketSpec( + dst_id=91, + slot=1, + stream_id=stream_id, + seq=1, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VHEAD, + ) + terminator = PacketSpec( + dst_id=91, + slot=1, + stream_id=stream_id, + seq=2, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VTERM, + payload=b"\x01" * 33, + ) + + scenario.inject_obp("OBP-1", header) + scenario.inject_obp("OBP-1", terminator) + scenario.clock.advance(6) + scenario.bm.stream_trimmer_loop() + + self.assertIn( + "_fin", + scenario.systems["OBP-2"].STATUS[bytes_4(stream_id)], + ) + self.assertIn( + b"GROUP VOICE,END,TX,OBP-2,16909060,1001,3120001,1,91,0.00", + scenario.reports["OBP-2"].events, + ) + self.assertFalse( + any( + event.startswith(b"GROUP VOICE,END,RX,OBP-2") + for event in scenario.reports["OBP-2"].events + ) + ) + + def test_hbp_voice_terminator_suppresses_late_same_stream_voice(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + stream_id = 0x01020304 + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + header = PacketSpec( + dst_id=91, + slot=2, + stream_id=stream_id, + seq=1, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VHEAD, + ) + terminator = PacketSpec( + dst_id=91, + slot=2, + stream_id=stream_id, + seq=2, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VTERM, + payload=b"\x01" * 33, + ) + late_packet = PacketSpec( + dst_id=91, + slot=2, + stream_id=stream_id, + seq=3, + call_type="group", + frame_type=HBPF_VOICE, + dtype_vseq=3, + payload=b"\x02" * 33, + ) + + scenario.inject_hbp("MASTER-A", header) + scenario.inject_hbp("MASTER-A", terminator) + before_late_packet = len(scenario.capture.for_system("MASTER-B")) + scenario.inject_hbp("MASTER-A", late_packet) + captured = scenario.capture.for_system("MASTER-B") + source_slot = scenario.systems["MASTER-A"].STATUS[2] + + self.assertEqual(before_late_packet, 2) + self.assertEqual(len(captured), 2) + self.assertEqual(source_slot["RX_FINISHED_STREAM_ID"], bytes_4(stream_id)) + self.assertEqual(source_slot["RX_TYPE"], HBPF_SLT_VTERM) + + def test_hbp_new_voice_terminator_after_data_stream_marks_finished(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + stream_id = 0x01020302 + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + data_header = PacketSpec( + dst_id=91, + slot=2, + stream_id=0x01020301, + seq=1, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=6, + ) + terminator = PacketSpec( + dst_id=91, + slot=2, + stream_id=stream_id, + seq=2, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VTERM, + payload=b"\x01" * 33, + ) + late_packet = PacketSpec( + dst_id=91, + slot=2, + stream_id=stream_id, + seq=3, + call_type="group", + frame_type=HBPF_VOICE, + dtype_vseq=3, + payload=b"\x02" * 33, + ) + + scenario.inject_hbp("MASTER-A", data_header) + self.assertTrue(scenario.systems["MASTER-A"].STATUS[2]["RX_DATA_STREAM"]) + + scenario.inject_hbp("MASTER-A", terminator) + before_late_packet = len(scenario.capture.for_system("MASTER-B")) + scenario.inject_hbp("MASTER-A", late_packet) + captured = scenario.capture.for_system("MASTER-B") + source_slot = scenario.systems["MASTER-A"].STATUS[2] + + self.assertEqual(before_late_packet, 2) + self.assertEqual(len(captured), 2) + self.assertFalse(source_slot["RX_DATA_STREAM"]) + self.assertEqual(source_slot["RX_FINISHED_STREAM_ID"], bytes_4(stream_id)) + self.assertEqual(source_slot["RX_TYPE"], HBPF_SLT_VTERM) + + def test_hbp_idle_slot_voice_terminator_marks_finished(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["REPORTS"]["REPORT"] = True + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + stream_id = 0x01020304 + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + scenario.clock.advance(100) + terminator = PacketSpec( + dst_id=91, + slot=2, + stream_id=stream_id, + seq=2, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VTERM, + payload=b"\x01" * 33, + ) + late_packet = PacketSpec( + dst_id=91, + slot=2, + stream_id=stream_id, + seq=3, + call_type="group", + frame_type=HBPF_VOICE, + dtype_vseq=3, + payload=b"\x02" * 33, + ) + + scenario.inject_hbp("MASTER-A", terminator) + before_late_packet = len(scenario.capture.for_system("MASTER-B")) + scenario.inject_hbp("MASTER-A", late_packet) + captured = scenario.capture.for_system("MASTER-B") + source_slot = scenario.systems["MASTER-A"].STATUS[2] + + self.assertEqual(before_late_packet, 1) + self.assertEqual(len(captured), 1) + self.assertEqual(source_slot["RX_FINISHED_STREAM_ID"], bytes_4(stream_id)) + self.assertEqual(source_slot["RX_TYPE"], HBPF_SLT_VTERM) + self.assertTrue( + any( + event.startswith(b"GROUP VOICE,END,RX,MASTER-A") + for event in scenario.reports["MASTER-A"].events + ) + ) + + def test_hbp_group_voice_still_reports_voice_lifecycle(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["REPORTS"]["REPORT"] = True + bridges = active_bridge( + "123", + 123, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=123, + slot=2, + call_type="group", + dtype_vseq=0, + ) + + scenario.inject_hbp("MASTER-A", packet) + scenario.clock.advance(6) + scenario.bm.stream_trimmer_loop() + + self.assertTrue( + any( + event.startswith(b"GROUP VOICE,START,RX,MASTER-A") + for event in scenario.reports["MASTER-A"].events + ) + ) + self.assertTrue( + any( + event.startswith(b"GROUP VOICE,END,RX,MASTER-A") + for event in scenario.reports["MASTER-A"].events + ) + ) + self.assertTrue( + any( + event.startswith(b"GROUP VOICE,START,TX,MASTER-B") + for event in scenario.reports["MASTER-B"].events + ) + ) + self.assertTrue( + any( + event.startswith(b"GROUP VOICE,END,TX,MASTER-B") + for event in scenario.reports["MASTER-B"].events + ) + ) + + def test_hbp_voice_sequence_wrap_with_loss_routes_forward_progress(self): + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + stream_id = 0x01020304 + packets = [ + PacketSpec(dst_id=91, slot=2, stream_id=stream_id, seq=254, dtype_vseq=0), + PacketSpec(dst_id=91, slot=2, stream_id=stream_id, seq=255, dtype_vseq=1), + PacketSpec(dst_id=91, slot=2, stream_id=stream_id, seq=2, dtype_vseq=2), + ] + + with DeterministicScenario(bridges=bridges) as scenario: + for packet in packets: + scenario.inject_hbp("MASTER-A", packet) + + captured = scenario.capture.for_system("MASTER-B") + self.assertEqual(len(captured), 3) + self.assertEqual(captured[-1].packet[4], 2) + source_slot = scenario.systems["MASTER-A"].STATUS[2] + self.assertEqual(source_slot["lastSeq"], 2) + self.assertEqual(source_slot["loss"], 1) + + def test_obp_voice_sequence_wrap_with_loss_routes_forward_progress(self): + config = minimal_config() + add_openbridge_system(config, "OBP-1", network_id=3001) + add_openbridge_system(config, "OBP-2", network_id=3002) + bridges = active_bridge( + "91", + 91, + ( + ("OBP-1", 1), + ("OBP-2", 1), + ), + ) + stream_id = 0x01020304 + packets = [ + PacketSpec(dst_id=91, slot=1, stream_id=stream_id, seq=254, dtype_vseq=0), + PacketSpec(dst_id=91, slot=1, stream_id=stream_id, seq=255, dtype_vseq=1), + PacketSpec(dst_id=91, slot=1, stream_id=stream_id, seq=2, dtype_vseq=2), + ] + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + for packet in packets: + scenario.inject_obp("OBP-1", packet) + + captured = scenario.capture.for_system("OBP-2") + self.assertEqual(len(captured), 3) + self.assertEqual(captured[-1].packet[4], 2) + source_status = scenario.systems["OBP-1"].STATUS[bytes_4(stream_id)] + self.assertEqual(source_status["lastSeq"], 2) + self.assertEqual(source_status["loss"], 1) + + def test_obp_target_missing_emb_lc_logs_without_crashing(self): + config = minimal_config() + add_openbridge_system(config, "OBP-1", network_id=3001) + add_openbridge_system(config, "OBP-2", network_id=3002) + bridges = active_bridge( + "91", + 91, + ( + ("OBP-1", 1), + ("OBP-2", 1), + ), + ) + stream_id = 0x01020304 + header = PacketSpec( + dst_id=91, + slot=1, + stream_id=stream_id, + seq=0, + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VHEAD, + ) + voice = PacketSpec( + dst_id=91, + slot=1, + stream_id=stream_id, + seq=1, + frame_type=HBPF_VOICE, + dtype_vseq=1, + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + scenario.inject_obp("OBP-1", header) + del scenario.systems["OBP-2"].STATUS[bytes_4(stream_id)]["EMB_LC"] + + with self.assertLogs(scenario.bm.logger, level="WARNING") as logs: + scenario.inject_obp("OBP-1", voice) + + self.assertIn( + "(OBP-1) KeyError - EMB_LC, skipping", + "\n".join(logs.output), + ) + captured = scenario.capture.for_system("OBP-2") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["dtype_vseq"], HBPF_SLT_VHEAD) + + def test_hbp_voice_sequence_zero_duplicate_is_dropped(self): + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + first_packet = PacketSpec( + dst_id=91, + slot=2, + stream_id=0x01020304, + seq=0, + dtype_vseq=0, + ) + duplicate_packet = PacketSpec( + dst_id=91, + slot=2, + stream_id=0x01020304, + seq=0, + dtype_vseq=1, + payload=b"\x01" * 33, + ) + + with DeterministicScenario(bridges=bridges) as scenario: + scenario.inject_hbp("MASTER-A", first_packet) + scenario.inject_hbp("MASTER-A", duplicate_packet) + + captured = scenario.capture.for_system("MASTER-B") + self.assertEqual(len(captured), 1) + source_slot = scenario.systems["MASTER-A"].STATUS[2] + self.assertEqual(source_slot["lastSeq"], 0) + self.assertEqual(source_slot["loss"], 1) + + def test_hbp_new_stream_after_timeout_resets_duplicate_state(self): + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + previous_packet = PacketSpec( + dst_id=91, + slot=2, + stream_id=0x01020304, + seq=200, + dtype_vseq=0, + ) + next_packet = PacketSpec( + dst_id=91, + slot=2, + stream_id=0x01020305, + seq=1, + dtype_vseq=0, + ) + + with DeterministicScenario(bridges=bridges) as scenario: + scenario.inject_hbp("MASTER-A", previous_packet) + source_slot = scenario.systems["MASTER-A"].STATUS[2] + self.assertEqual(source_slot["lastSeq"], 200) + + scenario.clock.advance(6) + scenario.bm.stream_trimmer_loop() + scenario.inject_hbp("MASTER-A", next_packet) + + captured = scenario.capture.for_system("MASTER-B") + self.assertEqual(len(captured), 2) + self.assertEqual(captured[-1].fields["stream_id"], bytes_4(0x01020305)) + self.assertEqual(source_slot["lastSeq"], 1) + self.assertEqual(source_slot["loss"], 0) + + def test_hbp_group_voice_to_enhanced_obp_without_keepalive_is_dropped(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + config["SYSTEMS"]["OBP-1"]["ENHANCED_OBP"] = True + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("OBP-1", 1), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + packet = PacketSpec( + dst_id=91, + slot=2, + stream_id=0x01020304, + call_type="group", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VHEAD, + ) + + scenario.inject_hbp("MASTER-A", packet) + + self.assertEqual(scenario.capture.for_system("OBP-1"), []) + + def test_hbp_unit_data_to_hbp_reports_actual_target_slot(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["REPORTS"]["REPORT"] = True + + with DeterministicScenario(config=config) as scenario: + scenario.clock.advance(10) + scenario.bm.SUB_MAP = { + bytes_3(1234567): ("MASTER-B", 2, scenario.clock.time()) + } + packet = PacketSpec( + dst_id=1234567, + slot=1, + stream_id=0x01020304, + call_type="unit", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=6, + ) + + scenario.inject_hbp("MASTER-A", packet) + + captured = scenario.capture.for_system("MASTER-B") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["slot"], 2) + self.assertIn( + b"UNIT DATA,DATA,TX,MASTER-B,16909060,1001,3120001,2,1234567", + scenario.reports["MASTER-B"].events, + ) + + def test_obp_unit_data_to_hbp_reports_actual_target_slot(self): + config = minimal_config(("MASTER-B",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + config["REPORTS"]["REPORT"] = True + + with DeterministicScenario(config=config) as scenario: + scenario.clock.advance(10) + scenario.bm.SUB_MAP = { + bytes_3(1234567): ("MASTER-B", 2, scenario.clock.time()) + } + packet = PacketSpec( + dst_id=1234567, + slot=1, + stream_id=0x01020304, + call_type="unit", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=6, + ) + + scenario.inject_obp("OBP-1", packet) + + captured = scenario.capture.for_system("MASTER-B") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].fields["slot"], 2) + self.assertIn( + b"UNIT DATA,DATA,TX,MASTER-B,16909060,1001,3120001,2,1234567", + scenario.reports["MASTER-B"].events, + ) + + def test_obp_unit_data_loop_control_handles_zero_duration_packet_rate(self): + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-1", network_id=3001) + add_openbridge_system(config, "OBP-2", network_id=3002) + + with DeterministicScenario(config=config) as scenario: + packet = PacketSpec( + dst_id=1000001, + slot=1, + stream_id=0x01020304, + call_type="unit", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=6, + ) + + scenario.inject_obp("OBP-1", packet) + scenario.inject_obp("OBP-2", packet) + + stream_id = bytes_4(0x01020304) + self.assertTrue(scenario.systems["OBP-2"].STATUS[stream_id]["LOOPLOG"]) + self.assertEqual(scenario.capture.for_system("MASTER-A"), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_udp_blackbox_harness.py b/tests/test_udp_blackbox_harness.py new file mode 100644 index 0000000..2d006b4 --- /dev/null +++ b/tests/test_udp_blackbox_harness.py @@ -0,0 +1,1223 @@ +import socket +import tempfile +import time +import unittest +from pathlib import Path + +from tests.harness.deterministic import ( + HBPF_DATA_SYNC, + HBPF_SLT_VHEAD, + HBPF_SLT_VTERM, + HBPF_VOICE, + PacketSpec, + bytes_3, + bytes_4, +) +from tests.harness.udp_blackbox import ( + ImpairmentProfiles, + RecordedPacketFixture, + StreamProfile, + UdpBlackBoxScenario, + require_udp_integration_enabled, +) + + +class UdpBlackBoxHarnessTest(unittest.TestCase): + def private_call(self, dst_id, peer_id, slot=1, stream_id=0x01020304): + return PacketSpec( + peer_id=peer_id, + dst_id=dst_id, + slot=slot, + stream_id=stream_id, + call_type="unit", + frame_type=HBPF_VOICE, + dtype_vseq=0, + ) + + def private_call_terminator(self, dst_id, peer_id, slot=1, stream_id=0x01020304): + return PacketSpec( + peer_id=peer_id, + dst_id=dst_id, + slot=slot, + stream_id=stream_id, + call_type="unit", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VTERM, + ) + + def test_two_registered_repeaters_observe_static_tg_route(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario() as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + try: + master_a.login() + master_b.login() + + master_a.send_dmr(PacketSpec(peer_id=1001, rf_src=3120001, dst_id=91, slot=2)) + captured = master_b.recv(timeout=2.0) + finally: + master_a.close() + master_b.close() + + self.assertEqual(captured.packet[:4], b"DMRD") + self.assertEqual(captured.fields["dst_id"], bytes_3(91)) + self.assertEqual(captured.fields["slot"], 2) + self.assertEqual(captured.fields["stream_id"], bytes_4(0x01020304)) + + def test_global_use_acl_false_does_not_apply_deny_acl(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario( + global_use_acl=False, + global_sub_acl="DENY:3120001", + ) as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + try: + master_a.login() + master_b.login() + + master_a.send_dmr( + PacketSpec(peer_id=1001, rf_src=3120001, dst_id=91, slot=2) + ) + captured = master_b.recv(timeout=2.0) + finally: + master_a.close() + master_b.close() + + self.assertEqual(captured.fields["rf_src"], bytes_3(3120001)) + self.assertEqual(captured.fields["dst_id"], bytes_3(91)) + + def test_hbp_data_sync_control_payload_is_preserved(self): + require_udp_integration_enabled() + + payload = bytes(range(33)) + with UdpBlackBoxScenario() as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + try: + master_a.login() + master_b.login() + + master_a.send_dmr( + PacketSpec( + peer_id=1001, + rf_src=3120001, + dst_id=91, + slot=2, + frame_type=HBPF_DATA_SYNC, + dtype_vseq=7, + payload=payload, + ) + ) + captured = master_b.recv(timeout=2.0) + finally: + master_a.close() + master_b.close() + + self.assertEqual(captured.fields["frame_type"], HBPF_DATA_SYNC) + self.assertEqual(captured.fields["dtype_vseq"], 7) + self.assertEqual(captured.fields["dmr_payload"], payload) + + def test_hbp_malformed_short_dmrd_is_ignored_and_later_packet_routes(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario() as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + try: + master_a.login() + master_b.login() + + master_a.send(b"DMRD" + b"\x00" * 10) + master_a.send_dmr( + PacketSpec(peer_id=1001, rf_src=3120001, dst_id=91, slot=2) + ) + captured = master_b.recv(timeout=2.0) + finally: + master_a.close() + master_b.close() + + self.assertEqual(captured.fields["rf_src"], bytes_3(3120001)) + self.assertEqual(captured.fields["dst_id"], bytes_3(91)) + + def test_hbp_voice_sequence_wrap_routes_forward_progress(self): + require_udp_integration_enabled() + + stream_id = 0x01020304 + packets = [ + PacketSpec(peer_id=1001, dst_id=91, slot=2, stream_id=stream_id, seq=254), + PacketSpec( + peer_id=1001, + dst_id=91, + slot=2, + stream_id=stream_id, + seq=255, + dtype_vseq=1, + ), + PacketSpec( + peer_id=1001, + dst_id=91, + slot=2, + stream_id=stream_id, + seq=2, + dtype_vseq=2, + ), + ] + + with UdpBlackBoxScenario() as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + try: + master_a.login() + master_b.login() + + for packet in packets: + master_a.send_dmr(packet) + + captured = [master_b.recv(timeout=2.0) for _ in packets] + finally: + master_a.close() + master_b.close() + + self.assertEqual([packet.fields["seq"] for packet in captured], [254, 255, 2]) + self.assertEqual(captured[-1].fields["stream_id"], bytes_4(stream_id)) + + def test_hbp_link_impairment_discards_late_out_of_order_packet(self): + require_udp_integration_enabled() + + stream_id = 0x01020308 + packets = [ + PacketSpec(peer_id=1001, dst_id=91, slot=2, stream_id=stream_id, seq=0), + PacketSpec( + peer_id=1001, + dst_id=91, + slot=2, + stream_id=stream_id, + seq=1, + dtype_vseq=1, + ), + PacketSpec( + peer_id=1001, + dst_id=91, + slot=2, + stream_id=stream_id, + seq=2, + dtype_vseq=2, + ), + ] + impairment = ImpairmentProfiles.provider_vxlan_reorder() + + with UdpBlackBoxScenario() as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + try: + master_a.login() + master_b.login() + + master_a.send_stream( + packets, + cadence_seconds=0.03, + impairment=impairment, + ) + captured = [master_b.recv(timeout=2.0) for _ in range(2)] + leaked = master_b.drain(seconds=0.4) + finally: + master_a.close() + master_b.close() + + self.assertEqual([packet.fields["seq"] for packet in captured], [0, 2]) + self.assertEqual(leaked, []) + + def test_hbp_duplicate_sequence_zero_is_dropped(self): + require_udp_integration_enabled() + + first = PacketSpec(peer_id=1001, dst_id=91, slot=2, seq=0, payload=b"\x11" * 33) + duplicate = PacketSpec( + peer_id=1001, + dst_id=91, + slot=2, + seq=0, + payload=b"\x22" * 33, + ) + + with UdpBlackBoxScenario() as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + try: + master_a.login() + master_b.login() + + master_a.send_dmr(first) + captured = master_b.recv(timeout=2.0) + master_a.send_dmr(duplicate) + leaked = master_b.drain(seconds=0.4) + finally: + master_a.close() + master_b.close() + + self.assertEqual(captured.fields["seq"], 0) + self.assertEqual(captured.fields["dmr_payload"], b"\x11" * 33) + self.assertEqual(leaked, []) + + def test_hbp_recorded_fixture_replay_routes_preserved_packet(self): + require_udp_integration_enabled() + + packet = PacketSpec( + peer_id=1001, + rf_src=3120001, + dst_id=91, + slot=2, + stream_id=0x0102031C, + payload=b"\x33" * 33, + ) + with tempfile.TemporaryDirectory(prefix="freedmr-fixture-") as tempdir: + fixture_path = Path(tempdir) / "hbp.hex" + fixture_path.write_text( + "# recorded HBP packet\n" + packet.data().hex() + "\n", + encoding="ascii", + ) + fixture = RecordedPacketFixture.from_file(fixture_path) + + with UdpBlackBoxScenario() as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + try: + master_a.login() + master_b.login() + + master_a.replay_fixture(fixture) + captured = master_b.recv(timeout=2.0) + finally: + master_a.close() + master_b.close() + + self.assertEqual(captured.fields["stream_id"], bytes_4(0x0102031C)) + self.assertEqual(captured.fields["dmr_payload"], b"\x33" * 33) + + def test_hbp_burst_loss_profile_routes_later_forward_progress(self): + require_udp_integration_enabled() + + stream_id = 0x0102031D + profile = StreamProfile.voice_over( + peer_id=1001, + dst_id=91, + slot=2, + stream_id=stream_id, + voice_bursts=4, + ) + + with UdpBlackBoxScenario() as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + try: + master_a.login() + master_b.login() + + master_a.send_stream( + profile.packets, + cadence_seconds=profile.cadence_seconds, + impairment=ImpairmentProfiles.burst_loss(1, 2), + ) + captured = [master_b.recv(timeout=2.0) for _ in range(2)] + leaked = master_b.drain(seconds=0.4) + finally: + master_a.close() + master_b.close() + + self.assertEqual([packet.fields["seq"] for packet in captured], [0, 3]) + self.assertEqual(leaked, []) + + def test_hbp_duplicate_udp_profile_drops_duplicate_and_routes_next_packet(self): + require_udp_integration_enabled() + + stream_id = 0x0102031E + profile = StreamProfile.voice_over( + peer_id=1001, + dst_id=91, + slot=2, + stream_id=stream_id, + voice_bursts=2, + ) + + with UdpBlackBoxScenario() as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + try: + master_a.login() + master_b.login() + + master_a.send_stream( + profile.packets, + cadence_seconds=profile.cadence_seconds, + impairment=ImpairmentProfiles.duplicate_udp(0), + ) + captured = [master_b.recv(timeout=2.0) for _ in range(2)] + leaked = master_b.drain(seconds=0.4) + finally: + master_a.close() + master_b.close() + + self.assertEqual([packet.fields["seq"] for packet in captured], [0, 1]) + self.assertEqual(leaked, []) + + def test_hbp_voice_terminator_suppresses_late_same_stream_packet(self): + require_udp_integration_enabled() + + stream_id = 0x01020304 + header = PacketSpec( + peer_id=1001, + dst_id=91, + slot=2, + stream_id=stream_id, + seq=0, + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VHEAD, + ) + terminator = PacketSpec( + peer_id=1001, + dst_id=91, + slot=2, + stream_id=stream_id, + seq=1, + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VTERM, + ) + late_voice = PacketSpec( + peer_id=1001, + dst_id=91, + slot=2, + stream_id=stream_id, + seq=2, + frame_type=HBPF_VOICE, + dtype_vseq=1, + ) + + with UdpBlackBoxScenario() as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + try: + master_a.login() + master_b.login() + + master_a.send_dmr(header) + master_a.send_dmr(terminator) + captured = [master_b.recv(timeout=2.0) for _ in range(2)] + master_a.send_dmr(late_voice) + leaked = master_b.drain(seconds=0.4) + finally: + master_a.close() + master_b.close() + + self.assertEqual( + [packet.fields["dtype_vseq"] for packet in captured], + [HBPF_SLT_VHEAD, HBPF_SLT_VTERM], + ) + self.assertEqual(leaked, []) + + def test_dial_a_tg_reserved_control_emits_local_tg9_ts2_prompt(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario() as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + try: + master_a.login() + master_b.login() + + master_a.send_dmr(self.private_call(4001, peer_id=1001, slot=1)) + master_a.send_dmr(self.private_call_terminator(4001, peer_id=1001, slot=1)) + + captured = master_a.recv(timeout=4.0) + leaked = master_b.drain(seconds=0.4) + finally: + master_a.close() + master_b.close() + + self.assertEqual(captured.packet[:4], b"DMRD") + self.assertEqual(captured.fields["rf_src"], bytes_3(5000)) + self.assertEqual(captured.fields["dst_id"], bytes_3(9)) + self.assertEqual(captured.fields["slot"], 2) + self.assertEqual(leaked, []) + + def test_real_hbp_voice_interrupts_generated_prompt_and_routes(self): + require_udp_integration_enabled() + + real_stream = 0x0102030B + with UdpBlackBoxScenario() as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + try: + master_a.login() + master_b.login() + + master_a.send_dmr(self.private_call(4001, peer_id=1001, slot=1)) + master_a.send_dmr(self.private_call_terminator(4001, peer_id=1001, slot=1)) + prompt = master_a.recv(timeout=4.0) + + real_voice = StreamProfile.voice_over( + peer_id=1001, + rf_src=3120001, + dst_id=91, + slot=2, + stream_id=real_stream, + voice_bursts=1, + ) + master_a.send_stream(real_voice.packets) + routed = master_b.recv(timeout=2.0) + finally: + master_a.close() + master_b.close() + + self.assertEqual(prompt.fields["rf_src"], bytes_3(5000)) + self.assertEqual(prompt.fields["dst_id"], bytes_3(9)) + self.assertEqual(prompt.fields["slot"], 2) + self.assertEqual(routed.fields["rf_src"], bytes_3(3120001)) + self.assertEqual(routed.fields["dst_id"], bytes_3(91)) + self.assertEqual(routed.fields["stream_id"], bytes_4(real_stream)) + + def test_hbp_static_tg_routes_to_fbp_v5_peer(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_a.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + + master_a.send_dmr( + PacketSpec(peer_id=1001, rf_src=3120001, dst_id=91, slot=2) + ) + captured = fbp_peer.recv_dmre(timeout=2.0) + finally: + master_a.close() + + self.assertEqual(captured.packet[:4], b"DMRE") + self.assertEqual(captured.fields["fbp_version"], 5) + self.assertEqual(captured.fields["peer_id"], bytes_4(9990)) + self.assertEqual(captured.fields["rf_src"], bytes_3(3120001)) + self.assertEqual(captured.fields["dst_id"], bytes_3(91)) + self.assertEqual(captured.fields["slot"], 1) + + def test_fbp_enhanced_keepalive_gates_hbp_to_fbp_forwarding(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_a.login() + fbp_peer.drain(seconds=0.2) + + master_a.send_dmr( + PacketSpec( + peer_id=1001, + rf_src=3120001, + dst_id=91, + slot=2, + stream_id=0x01020312, + ) + ) + missing_before_keepalive = [ + capture + for capture in fbp_peer.drain(seconds=0.4) + if capture.packet[:4] == b"DMRE" + ] + + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + master_a.send_dmr( + PacketSpec( + peer_id=1001, + rf_src=3120001, + dst_id=91, + slot=2, + stream_id=0x01020313, + ) + ) + captured_after_keepalive = fbp_peer.recv_dmre(timeout=2.0) + finally: + master_a.close() + + self.assertEqual(missing_before_keepalive, []) + self.assertEqual(captured_after_keepalive.fields["stream_id"], bytes_4(0x01020313)) + + def test_fbp_bcve_downgrade_does_not_change_outbound_packet_version(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_a.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve(version=4) + fbp_peer.drain(seconds=0.2) + + master_a.send_dmr( + PacketSpec( + peer_id=1001, + rf_src=3120001, + dst_id=91, + slot=2, + stream_id=0x01020317, + ) + ) + captured = fbp_peer.recv_dmre(timeout=2.0) + finally: + master_a.close() + + self.assertEqual(captured.fields["fbp_version"], 5) + self.assertEqual(captured.fields["source_rptr"], bytes_4(1001)) + + def test_fbp_bcve_unsupported_version_does_not_change_outbound_packet_version(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_a.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve(version=6) + fbp_peer.drain(seconds=0.2) + + master_a.send_dmr( + PacketSpec( + peer_id=1001, + rf_src=3120001, + dst_id=91, + slot=2, + stream_id=0x01020318, + ) + ) + captured = fbp_peer.recv_dmre(timeout=2.0) + finally: + master_a.close() + + self.assertEqual(captured.fields["fbp_version"], 5) + self.assertEqual(captured.fields["source_rptr"], bytes_4(1001)) + + def test_fbp_invalid_bcve_does_not_change_outbound_packet_version(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_a.login() + fbp_peer.send_bcka() + fbp_peer.send_invalid_bcve(version=4) + fbp_peer.drain(seconds=0.2) + + master_a.send_dmr( + PacketSpec( + peer_id=1001, + rf_src=3120001, + dst_id=91, + slot=2, + stream_id=0x01020319, + ) + ) + captured = fbp_peer.recv_dmre(timeout=2.0) + finally: + master_a.close() + + self.assertEqual(captured.fields["fbp_version"], 5) + self.assertEqual(captured.fields["source_rptr"], bytes_4(1001)) + + def test_fbp_v4_packet_routes_to_hbp_using_v4_metadata_layout(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_b = scenario.repeater("MASTER-B", 1002) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_b.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + + fbp_peer.send_fbp( + PacketSpec( + peer_id=3001, + rf_src=3120001, + dst_id=91, + slot=1, + stream_id=0x0102031A, + ), + fbp_version=4, + source_server=9991, + source_rptr=1001, + ) + captured = master_b.recv(timeout=2.0) + finally: + master_b.close() + + self.assertEqual(captured.packet[:4], b"DMRD") + self.assertEqual(captured.fields["rf_src"], bytes_3(3120001)) + self.assertEqual(captured.fields["dst_id"], bytes_3(91)) + self.assertEqual(captured.fields["slot"], 2) + self.assertEqual(captured.fields["stream_id"], bytes_4(0x0102031A)) + + @unittest.expectedFailure + def test_fbp_unsupported_embedded_packet_version_is_rejected_without_hbp_leak(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_b = scenario.repeater("MASTER-B", 1002) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_b.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + + fbp_peer.send_fbp( + PacketSpec( + peer_id=3001, + rf_src=3120001, + dst_id=91, + slot=1, + stream_id=0x0102031F, + ), + fbp_version=6, + source_server=9991, + source_rptr=1001, + ) + with self.assertRaises(socket.timeout): + master_b.recv(timeout=0.5) + finally: + master_b.close() + + def test_fbp_v4_packet_downgrades_session_to_v4_layout_for_compatibility(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_a.login() + master_b.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + + fbp_peer.send_fbp( + PacketSpec( + peer_id=3001, + rf_src=3120001, + dst_id=91, + slot=1, + stream_id=0x01020320, + ), + fbp_version=4, + source_server=9991, + ) + master_b.recv(timeout=2.0) + + master_a.send_dmr( + PacketSpec( + peer_id=1001, + rf_src=3120002, + dst_id=91, + slot=2, + stream_id=0x01020321, + ) + ) + captured = fbp_peer.recv_dmre(timeout=2.0) + finally: + master_a.close() + master_b.close() + + self.assertEqual(captured.packet[:4], b"DMRE") + self.assertEqual(len(captured.packet), 85) + self.assertEqual(captured.fields["source_rptr"], b"\x00\x00\x00\x00") + + @unittest.expectedFailure + def test_fbp_configured_proto_v4_outbound_packet_carries_v4_version_byte(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario( + fbp_systems={"OBP-1": 3001}, + fbp_proto_versions={"OBP-1": 4}, + ) as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_a.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve(version=4) + fbp_peer.drain(seconds=0.2) + + master_a.send_dmr( + PacketSpec( + peer_id=1001, + rf_src=3120002, + dst_id=91, + slot=2, + stream_id=0x01020322, + ) + ) + captured = fbp_peer.recv_dmre(timeout=2.0) + finally: + master_a.close() + + self.assertEqual(len(captured.packet), 85) + self.assertEqual(captured.fields["fbp_version"], 4) + + def test_obp_v1_packet_on_fbp_link_is_rejected_with_bcve_without_hbp_leak(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_b = scenario.repeater("MASTER-B", 1002) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_b.login() + + fbp_peer.send_obp_v1( + PacketSpec( + peer_id=3001, + rf_src=3120001, + dst_id=91, + slot=1, + stream_id=0x0102031B, + ) + ) + version_response = fbp_peer.recv_opcode(b"BCVE", timeout=2.0) + with self.assertRaises(socket.timeout): + master_b.recv(timeout=0.5) + finally: + master_b.close() + + self.assertEqual(version_response.packet[:4], b"BCVE") + self.assertEqual(version_response.packet[4], 5) + + def test_fbp_invalid_source_quench_does_not_suppress_hbp_to_fbp_stream(self): + require_udp_integration_enabled() + + stream_id = 0x01020314 + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_a.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + fbp_peer.send_invalid_bcsq(91, stream_id) + + master_a.send_dmr( + PacketSpec( + peer_id=1001, + rf_src=3120001, + dst_id=91, + slot=2, + stream_id=stream_id, + ) + ) + captured = fbp_peer.recv_dmre(timeout=2.0) + finally: + master_a.close() + + self.assertEqual(captured.fields["stream_id"], bytes_4(stream_id)) + self.assertEqual(captured.fields["dst_id"], bytes_3(91)) + + def test_fbp_stun_blocks_hbp_to_fbp_and_fbp_to_hbp_traffic(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_a.login() + master_b.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + + fbp_peer.send_bcst() + + master_a.send_dmr( + PacketSpec( + peer_id=1001, + rf_src=3120001, + dst_id=91, + slot=2, + stream_id=0x01020315, + ) + ) + leaked_to_fbp = [ + capture + for capture in fbp_peer.drain(seconds=0.4) + if capture.packet[:4] == b"DMRE" + ] + master_b.drain(seconds=0.2) + + fbp_peer.send_fbp( + PacketSpec( + peer_id=3001, + rf_src=3120001, + dst_id=91, + slot=1, + stream_id=0x01020316, + ), + source_server=9991, + source_rptr=1001, + ) + with self.assertRaises(socket.timeout): + master_b.recv(timeout=0.5) + finally: + master_a.close() + master_b.close() + + self.assertEqual(leaked_to_fbp, []) + + def test_fbp_trunk_routes_clean_stream_while_another_stream_is_reordered(self): + require_udp_integration_enabled() + + stream_a = 0x0102030C + stream_b = 0x0102030D + stream_a_profile = StreamProfile.voice_over( + peer_id=1001, + rf_src=3120001, + dst_id=91, + slot=2, + stream_id=stream_a, + voice_bursts=3, + ) + stream_b_profile = StreamProfile.voice_over( + peer_id=1002, + rf_src=3120002, + dst_id=235, + slot=2, + stream_id=stream_b, + voice_bursts=2, + ) + + with UdpBlackBoxScenario( + ts2_static="91,235", + fbp_systems={"OBP-1": 3001}, + ) as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + master_b = scenario.repeater("MASTER-B", 1002) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_a.login() + master_b.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + + master_a.send_dmr(stream_a_profile.packets[0]) + time.sleep(stream_a_profile.cadence_seconds) + master_b.send_dmr(stream_b_profile.packets[0]) + time.sleep(stream_a_profile.cadence_seconds) + master_a.send_dmr(stream_a_profile.packets[2]) + time.sleep(stream_a_profile.cadence_seconds) + master_b.send_dmr(stream_b_profile.packets[1]) + time.sleep(stream_a_profile.cadence_seconds) + master_a.send_dmr(stream_a_profile.packets[1]) + + captured = [fbp_peer.recv_dmre(timeout=2.0) for _ in range(4)] + leaked = [ + capture + for capture in fbp_peer.drain(seconds=0.4) + if capture.packet[:4] == b"DMRE" + ] + finally: + master_a.close() + master_b.close() + + seqs_by_stream = {} + for capture in captured: + seqs_by_stream.setdefault(capture.fields["stream_id"], []).append( + capture.fields["seq"] + ) + + self.assertEqual(seqs_by_stream[bytes_4(stream_a)], [0, 2]) + self.assertEqual(seqs_by_stream[bytes_4(stream_b)], [0, 1]) + self.assertEqual(leaked, []) + + def test_fbp_static_tg_routes_to_hbp_repeater(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_b = scenario.repeater("MASTER-B", 1002) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_b.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + + fbp_peer.send_fbp( + PacketSpec( + peer_id=3001, + rf_src=3120001, + dst_id=91, + slot=1, + stream_id=0x01020305, + ), + source_server=9991, + source_rptr=1001, + ) + captured = master_b.recv(timeout=2.0) + finally: + master_b.close() + + self.assertEqual(captured.packet[:4], b"DMRD") + self.assertEqual(captured.fields["rf_src"], bytes_3(3120001)) + self.assertEqual(captured.fields["dst_id"], bytes_3(91)) + self.assertEqual(captured.fields["slot"], 2) + self.assertEqual(captured.fields["stream_id"], bytes_4(0x01020305)) + + def test_fbp_source_quench_suppresses_hbp_to_fbp_stream(self): + require_udp_integration_enabled() + + stream_id = 0x01020306 + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_a = scenario.repeater("MASTER-A", 1001) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_a.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + fbp_peer.send_bcsq(91, stream_id) + + master_a.send_dmr( + PacketSpec( + peer_id=1001, + rf_src=3120001, + dst_id=91, + slot=2, + stream_id=stream_id, + ) + ) + leaked = [ + capture + for capture in fbp_peer.drain(seconds=0.5) + if capture.packet[:4] == b"DMRE" + ] + finally: + master_a.close() + + self.assertEqual(leaked, []) + + def test_fbp_rejects_packet_with_wrong_network_id(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_b = scenario.repeater("MASTER-B", 1002) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_b.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + + fbp_peer.send_fbp( + PacketSpec(dst_id=91, slot=1, stream_id=0x01020307), + network_id=3002, + source_server=9991, + source_rptr=1001, + ) + with self.assertRaises(socket.timeout): + master_b.recv(timeout=0.5) + finally: + master_b.close() + + def test_fbp_rejects_bad_hash_without_leaking_to_hbp(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_b = scenario.repeater("MASTER-B", 1002) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_b.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + + fbp_peer.send_fbp( + PacketSpec(peer_id=3001, dst_id=91, slot=1, stream_id=0x0102030E), + source_server=9991, + source_rptr=1001, + corrupt_hash=True, + ) + with self.assertRaises(socket.timeout): + master_b.recv(timeout=0.5) + scenario.process.wait_for_log("FreeBridge HMAC failed", timeout=2.0) + finally: + master_b.close() + + def test_fbp_stale_timestamp_is_source_quenched_without_hbp_leak(self): + require_udp_integration_enabled() + + stream_id = 0x0102030F + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_b = scenario.repeater("MASTER-B", 1002) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_b.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + + fbp_peer.send_fbp( + PacketSpec( + peer_id=3001, + dst_id=91, + slot=1, + stream_id=stream_id, + ), + source_server=9991, + source_rptr=1001, + timestamp_ns=1, + ) + quench = fbp_peer.recv_opcode(b"BCSQ", timeout=2.0) + with self.assertRaises(socket.timeout): + master_b.recv(timeout=0.5) + finally: + master_b.close() + + self.assertEqual(quench.packet[4:7], bytes_3(91)) + self.assertEqual(quench.packet[7:11], bytes_4(stream_id)) + + def test_fbp_max_hops_is_source_quenched_without_hbp_leak(self): + require_udp_integration_enabled() + + stream_id = 0x01020310 + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_b = scenario.repeater("MASTER-B", 1002) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_b.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + + fbp_peer.send_fbp( + PacketSpec( + peer_id=3001, + dst_id=91, + slot=1, + stream_id=stream_id, + ), + source_server=9991, + source_rptr=1001, + hops=10, + ) + quench = fbp_peer.recv_opcode(b"BCSQ", timeout=2.0) + with self.assertRaises(socket.timeout): + master_b.recv(timeout=0.5) + finally: + master_b.close() + + self.assertEqual(quench.packet[4:7], bytes_3(91)) + self.assertEqual(quench.packet[7:11], bytes_4(stream_id)) + + def test_fbp_malformed_short_dmre_is_ignored_and_later_packet_routes(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_b = scenario.repeater("MASTER-B", 1002) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_b.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + + fbp_peer.send(b"DMRE" + b"\x00" * 10) + scenario.process.wait_for_log("FreeBridge packet too short", timeout=2.0) + fbp_peer.send_fbp( + PacketSpec(peer_id=3001, dst_id=91, slot=1, stream_id=0x01020311), + source_server=9991, + source_rptr=1001, + ) + captured = master_b.recv(timeout=2.0) + finally: + master_b.close() + + self.assertEqual(captured.fields["dst_id"], bytes_3(91)) + self.assertEqual(captured.fields["stream_id"], bytes_4(0x01020311)) + + def test_fbp_link_impairment_does_not_buffer_or_replay_late_packets(self): + require_udp_integration_enabled() + + stream_a = 0x01020309 + stream_b = 0x0102030A + impaired_packets = [ + PacketSpec(peer_id=3001, dst_id=91, slot=1, stream_id=stream_a, seq=0), + PacketSpec( + peer_id=3001, + dst_id=91, + slot=1, + stream_id=stream_a, + seq=1, + dtype_vseq=1, + ), + PacketSpec( + peer_id=3001, + dst_id=91, + slot=1, + stream_id=stream_a, + seq=2, + dtype_vseq=2, + ), + ] + following_packet = PacketSpec( + peer_id=3001, + dst_id=91, + slot=1, + stream_id=stream_b, + seq=0, + rf_src=3120001, + ) + + with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: + master_b = scenario.repeater("MASTER-B", 1002) + fbp_peer = scenario.fbp_peer("OBP-1") + try: + master_b.login() + fbp_peer.send_bcka() + fbp_peer.send_bcve() + fbp_peer.drain(seconds=0.2) + + fbp_peer.send_fbp_stream( + impaired_packets, + cadence_seconds=0.03, + impairment=ImpairmentProfiles.provider_vxlan_reorder(), + source_server=9991, + source_rptr=1001, + ) + impaired_captures = [master_b.recv(timeout=2.0) for _ in range(2)] + leaked = master_b.drain(seconds=0.4) + + fbp_peer.send_fbp( + following_packet, + source_server=9991, + source_rptr=1001, + ) + following_capture = master_b.recv(timeout=2.0) + finally: + master_b.close() + + self.assertEqual([packet.fields["seq"] for packet in impaired_captures], [0, 2]) + self.assertEqual(leaked, []) + self.assertEqual(following_capture.fields["stream_id"], bytes_4(stream_b)) + self.assertEqual(following_capture.fields["seq"], 0) + + +if __name__ == "__main__": + unittest.main()