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/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()