From 865d5a314fdedba6db9995e6c81572448976c05d Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 17 Jun 2026 17:28:57 +0100 Subject: [PATCH] Updates to timers, freedmr2 removal --- bridge_master.py | 188 +- config.py | 35 +- docs/codex-notes.md | 5300 ----------------- docs/freedmr-2-architecture-decisions.md | 660 -- docs/freedmr-2/00-glossary.md | 65 - docs/freedmr-2/01-system-model.md | 41 - docs/freedmr-2/02-state-model.md | 251 - docs/freedmr-2/03-subscription-model.md | 66 - docs/freedmr-2/04-packet-and-stream-model.md | 61 - docs/freedmr-2/05-data-packet-policy.md | 15 - docs/freedmr-2/06-mesh-model.md | 40 - docs/freedmr-2/07-reporting-model.md | 203 - docs/freedmr-2/08-api-control-model.md | 52 - docs/freedmr-2/09-security-model.md | 56 - docs/freedmr-2/10-runtime-and-concurrency.md | 69 - .../freedmr-2/11-testing-and-release-gates.md | 100 - docs/freedmr-2/12-migration-plan.md | 56 - docs/freedmr-2/13-worker-process-scaling.md | 289 - docs/freedmr-2/README.md | 28 - ...01-protected-model-not-hblink-structure.md | 24 - .../adr/0002-keep-twisted-initially.md | 22 - ...003-subscription-model-replaces-bridges.md | 22 - ...g-v2-replaces-legacy-dashboard-protocol.md | 22 - .../adr/0005-mqtt-reporting-transport.md | 22 - ...hboard-and-global-lastherd-are-separate.md | 22 - .../adr/0007-synthetic-lc-service-options.md | 22 - .../adr/0008-data-packet-forwarding-policy.md | 22 - ...thentication-without-default-encryption.md | 22 - .../adr/0010-api-is-control-plane-only.md | 22 - ...ocess-actor-model-over-no-gil-threading.md | 22 - ...sting-gates-for-protocol-visible-change.md | 22 - .../0013-worker-process-capacity-scaling.md | 87 - docs/test-harness-design.md | 34 +- docs/testing.md | 16 +- docs/v1x-codex-changelog.md | 22 +- tests/harness/deterministic.py | 9 + tests/harness/udp_blackbox.py | 4 + tests/test_deterministic_harness.py | 139 + tests/test_udp_blackbox_harness.py | 80 +- 39 files changed, 446 insertions(+), 7786 deletions(-) delete mode 100644 docs/codex-notes.md delete mode 100644 docs/freedmr-2-architecture-decisions.md delete mode 100644 docs/freedmr-2/00-glossary.md delete mode 100644 docs/freedmr-2/01-system-model.md delete mode 100644 docs/freedmr-2/02-state-model.md delete mode 100644 docs/freedmr-2/03-subscription-model.md delete mode 100644 docs/freedmr-2/04-packet-and-stream-model.md delete mode 100644 docs/freedmr-2/05-data-packet-policy.md delete mode 100644 docs/freedmr-2/06-mesh-model.md delete mode 100644 docs/freedmr-2/07-reporting-model.md delete mode 100644 docs/freedmr-2/08-api-control-model.md delete mode 100644 docs/freedmr-2/09-security-model.md delete mode 100644 docs/freedmr-2/10-runtime-and-concurrency.md delete mode 100644 docs/freedmr-2/11-testing-and-release-gates.md delete mode 100644 docs/freedmr-2/12-migration-plan.md delete mode 100644 docs/freedmr-2/13-worker-process-scaling.md delete mode 100644 docs/freedmr-2/README.md delete mode 100644 docs/freedmr-2/adr/0001-protected-model-not-hblink-structure.md delete mode 100644 docs/freedmr-2/adr/0002-keep-twisted-initially.md delete mode 100644 docs/freedmr-2/adr/0003-subscription-model-replaces-bridges.md delete mode 100644 docs/freedmr-2/adr/0004-reporting-v2-replaces-legacy-dashboard-protocol.md delete mode 100644 docs/freedmr-2/adr/0005-mqtt-reporting-transport.md delete mode 100644 docs/freedmr-2/adr/0006-local-dashboard-and-global-lastherd-are-separate.md delete mode 100644 docs/freedmr-2/adr/0007-synthetic-lc-service-options.md delete mode 100644 docs/freedmr-2/adr/0008-data-packet-forwarding-policy.md delete mode 100644 docs/freedmr-2/adr/0009-mesh-authentication-without-default-encryption.md delete mode 100644 docs/freedmr-2/adr/0010-api-is-control-plane-only.md delete mode 100644 docs/freedmr-2/adr/0011-process-actor-model-over-no-gil-threading.md delete mode 100644 docs/freedmr-2/adr/0012-testing-gates-for-protocol-visible-change.md delete mode 100644 docs/freedmr-2/adr/0013-worker-process-capacity-scaling.md diff --git a/bridge_master.py b/bridge_master.py index ba3844c..9cb1578 100644 --- a/bridge_master.py +++ b/bridge_master.py @@ -103,6 +103,19 @@ DIAL_A_TG_PROHIBITED_DEFAULTS = [ 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999, ] DIAL_A_TG_MAX = 999999 +UA_TIMER_UNLIMITED = 35791394 +TIMER_OPTION_KEYS = ( + 'DEFAULT_UA_TIMER', + 'TG_TIMER', + 'DIAL_TIMER', + 'TS1_TIMER', + 'TS2_TIMER', + 'TS1_TG_TIMER', + 'TS2_TG_TIMER', + 'TS1_DIAL_TIMER', + 'TS2_DIAL_TIMER', +) +TIMER_EXPLICIT_KEY = '_TIMER_OPTIONS_SET' DMR_ID_MAX = 16777215 EMB_LC_TA_HEADER = 0x04 EMB_LC_TA_BLOCKS = {0x05: 1, 0x06: 2, 0x07: 3} @@ -182,6 +195,25 @@ def parse_default_reflector_option(value, default=None): return default return int(value) +def normalize_ua_timer(timer): + return UA_TIMER_UNLIMITED if timer == 0 else timer + +def route_timer_from(timer_config, slot, route_type): + slot_key = 'TS{}_TIMER'.format(slot) + specific_key = 'TS{}_{}_TIMER'.format(slot, route_type) + route_key = '{}_TIMER'.format(route_type) + explicit_keys = timer_config.get(TIMER_EXPLICIT_KEY) + if (explicit_keys is None or specific_key in explicit_keys) and specific_key in timer_config: + return timer_config[specific_key] + if (explicit_keys is None or slot_key in explicit_keys) and slot_key in timer_config: + return timer_config[slot_key] + if (explicit_keys is None or route_key in explicit_keys) and route_key in timer_config: + return timer_config[route_key] + return timer_config.get('DEFAULT_UA_TIMER', 10) + +def get_route_timer(system, slot, route_type): + return route_timer_from(CONFIG['SYSTEMS'][system], slot, route_type) + 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: @@ -414,16 +446,18 @@ def make_bridges(_rules): if e['SYSTEM'] == _confsystem and e['TS'] == 2: ts2 = True if _bridge[0:1] != '#': - _tmout = CONFIG['SYSTEMS'][_confsystem]['DEFAULT_UA_TIMER'] if ts1 == False: + _tmout = get_route_timer(_confsystem, 1, 'TG') _rules[_bridge].append({'SYSTEM': _confsystem, 'TS': 1, 'TGID': bytes_3(int(_bridge)),'ACTIVE': False,'TIMEOUT': _tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [bytes_3(int(_bridge)),],'RESET': [], 'TIMER': time()}) if ts2 == False: + _tmout = get_route_timer(_confsystem, 2, 'TG') _rules[_bridge].append({'SYSTEM': _confsystem, 'TS': 2, 'TGID': bytes_3(int(_bridge)),'ACTIVE': False,'TIMEOUT': _tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [bytes_3(int(_bridge)),],'RESET': [], 'TIMER': time()}) else: - _tmout = CONFIG['SYSTEMS'][_confsystem]['DEFAULT_UA_TIMER'] if ts1 == False: + _tmout = get_route_timer(_confsystem, 1, 'DIAL') _rules[_bridge].append({'SYSTEM': _confsystem, 'TS': 1, 'TGID': bytes_3(9),'ACTIVE': False,'TIMEOUT': _tmout * 60,'TO_TYPE': 'ON','OFF': [bytes_3(4000)],'ON': [],'RESET': [], 'TIMER': time()}) if ts2 == False: + _tmout = get_route_timer(_confsystem, 2, 'DIAL') _rules[_bridge].append({'SYSTEM': _confsystem, 'TS': 2, 'TGID': bytes_3(9),'ACTIVE': False,'TIMEOUT': _tmout * 60,'TO_TYPE': 'ON','OFF': [bytes_3(4000)],'ON': [],'RESET': [], 'TIMER': time()}) return _rules @@ -441,14 +475,20 @@ def make_single_bridge(_tgid,_sourcesystem,_slot,_tmout): #_tmout = CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER'] if _system == _sourcesystem: if _slot == 1: - BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 1, 'TGID': _tgid,'ACTIVE': True,'TIMEOUT': _tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time() + (_tmout * 60)}) - BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 2, 'TGID': _tgid,'ACTIVE': False,'TIMEOUT': _tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time()}) + _ts1_tmout = _tmout if _tmout is not None else get_route_timer(_system, 1, 'TG') + _ts2_tmout = get_route_timer(_system, 2, 'TG') + BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 1, 'TGID': _tgid,'ACTIVE': True,'TIMEOUT': _ts1_tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time() + (_ts1_tmout * 60)}) + BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 2, 'TGID': _tgid,'ACTIVE': False,'TIMEOUT': _ts2_tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time()}) else: - BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 2, 'TGID': _tgid,'ACTIVE': True,'TIMEOUT': _tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time() + (_tmout * 60)}) - BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 1, 'TGID': _tgid,'ACTIVE': False,'TIMEOUT': _tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time()}) + _ts2_tmout = _tmout if _tmout is not None else get_route_timer(_system, 2, 'TG') + _ts1_tmout = get_route_timer(_system, 1, 'TG') + BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 2, 'TGID': _tgid,'ACTIVE': True,'TIMEOUT': _ts2_tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time() + (_ts2_tmout * 60)}) + BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 1, 'TGID': _tgid,'ACTIVE': False,'TIMEOUT': _ts1_tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time()}) else: - BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 1, 'TGID': _tgid,'ACTIVE': False,'TIMEOUT': _tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time()}) - BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 2, 'TGID': _tgid,'ACTIVE': False,'TIMEOUT': _tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time()}) + _ts1_tmout = get_route_timer(_system, 1, 'TG') + _ts2_tmout = get_route_timer(_system, 2, 'TG') + BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 1, 'TGID': _tgid,'ACTIVE': False,'TIMEOUT': _ts1_tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time()}) + BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 2, 'TGID': _tgid,'ACTIVE': False,'TIMEOUT': _ts2_tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time()}) if _system[0:3] == 'OBP' and (int_id(_tgid) >= 79 and (int_id(_tgid) < 9990 or int_id(_tgid) > 9999)): BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 1, 'TGID': _tgid,'ACTIVE': True,'TIMEOUT': '','TO_TYPE': 'NONE','OFF': [],'ON': [],'RESET': [], 'TIMER': time()}) @@ -460,9 +500,10 @@ def make_stat_bridge(_tgid): for _system in CONFIG['SYSTEMS']: if _system[0:3] != 'OBP': if CONFIG['SYSTEMS'][_system]['MODE'] == 'MASTER': - _tmout = CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER'] - BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 1, 'TGID': _tgid,'ACTIVE': False,'TIMEOUT': _tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time()}) - BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 2, 'TGID': _tgid,'ACTIVE': False,'TIMEOUT': _tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time()}) + _ts1_tmout = get_route_timer(_system, 1, 'TG') + _ts2_tmout = get_route_timer(_system, 2, 'TG') + BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 1, 'TGID': _tgid,'ACTIVE': False,'TIMEOUT': _ts1_tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time()}) + BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 2, 'TGID': _tgid,'ACTIVE': False,'TIMEOUT': _ts2_tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time()}) if _system[0:3] == 'OBP': BRIDGES[_tgid_s].append({'SYSTEM': _system, 'TS': 1, 'TGID': _tgid,'ACTIVE': True,'TIMEOUT': '','TO_TYPE': 'STAT','OFF': [],'ON': [],'RESET': [], 'TIMER': time()}) @@ -491,7 +532,7 @@ def make_default_reflectors(): for slot, key in ((1, 'DEFAULT_DIAL_TS1'), (2, 'DEFAULT_DIAL_TS2')): _default_reflector = CONFIG['SYSTEMS'][system].get(key, 0) if valid_dial_a_tg_reflector(_default_reflector): - make_default_reflector(_default_reflector,CONFIG['SYSTEMS'][system]['DEFAULT_UA_TIMER'],system,slot) + make_default_reflector(_default_reflector,get_route_timer(system,slot,'DIAL'),system,slot) elif int(_default_reflector) > 0: logger.warning('(ROUTER) %s %s %s is invalid or prohibited, ignoring', system, key, _default_reflector) CONFIG['SYSTEMS'][system][key] = 0 @@ -502,11 +543,10 @@ def make_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) + make_static_tg(tg,1,get_route_timer(system,1,'TG'),system) for tg in parse_static_tgs(CONFIG['SYSTEMS'][system]['TS2_STATIC']): - make_static_tg(tg,2,_tmout,system) + make_static_tg(tg,2,get_route_timer(system,2,'TG'),system) def make_static_tg(tg,ts,_tmout,system): #_tmout = CONFIG['SYSTEMS'][system]['DEFAULT_UA_TIMER'] @@ -569,7 +609,8 @@ def reset_all_reflector_system(_tmout,resetSystem,resetSlot=None): logger.trace('RST: for %s in BRIDGES[%s]',bridgesystem,bridge) if bridgesystem['SYSTEM'] == resetSystem and (resetSlot is None or bridgesystem['TS'] == resetSlot): logger.trace('RST: MATCH: setting inactive for %s',bridgesystem['SYSTEM']) - bridgetemp.append({'SYSTEM': resetSystem, 'TS': bridgesystem['TS'], 'TGID': bytes_3(9),'ACTIVE': False,'TIMEOUT': _tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [bytes_3(int(bridge[1:])),],'RESET': [], 'TIMER': time() + (_tmout * 60)}) + _entry_tmout = _tmout if resetSlot is not None else get_route_timer(resetSystem, bridgesystem['TS'], 'DIAL') + bridgetemp.append({'SYSTEM': resetSystem, 'TS': bridgesystem['TS'], 'TGID': bytes_3(9),'ACTIVE': False,'TIMEOUT': _entry_tmout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [bytes_3(int(bridge[1:])),],'RESET': [], 'TIMER': time() + (_entry_tmout * 60)}) else: logger.trace('RST: NO MATCH: using existing: %s',bridgesystem) bridgetemp.append(bridgesystem) @@ -593,7 +634,7 @@ def make_single_reflector(_tgid,_tmout,_sourcesystem,_slot=2): #_tmout = CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER'] for _ts in (1, 2): _active = _system == _sourcesystem and _ts == _slot - _entry_timeout = _tmout if _system == _sourcesystem else CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER'] + _entry_timeout = _tmout if _system == _sourcesystem and _ts == _slot else get_route_timer(_system, _ts, 'DIAL') BRIDGES[_bridge].append({'SYSTEM': _system, 'TS': _ts, 'TGID': bytes_3(9),'ACTIVE': _active,'TIMEOUT': _entry_timeout * 60,'TO_TYPE': 'ON','OFF': [],'ON': [_tgid,],'RESET': [], 'TIMER': time() + (_entry_timeout * 60) if _active else time()}) if _system[0:3] == 'OBP' and (int_id(_tgid) >= 79 and (int_id(_tgid) < 9990 or int_id(_tgid) > 9999)): BRIDGES[_bridge].append({'SYSTEM': _system, 'TS': 1, 'TGID': _tgid,'ACTIVE': True,'TIMEOUT': '','TO_TYPE': 'NONE','OFF': [],'ON': [],'RESET': [], 'TIMER': time()}) @@ -640,18 +681,20 @@ def remove_bridge_system(remsystem): for bridge in bt: BRIDGES[bridge] = bt[bridge] -def update_timeout(system,_tmout): +def update_timeout(system,_tmout,slot=None,route_type=None): _bridgestemp = {} - _bridgetemp = {} for _bridge in BRIDGES: + _bridgetemp = [] for _bridgesystem in BRIDGES[_bridge]: - if _bridgesystem['SYSTEM'] != system: - continue - else: - if _bridge not in _bridgestemp: - _bridgestemp[_bridge] = [] + if ( + _bridgesystem['SYSTEM'] == system and + (slot is None or _bridgesystem['TS'] == slot) and + (route_type != 'DIAL' or _bridge[0:1] == '#') and + (route_type != 'TG' or _bridge[0:1] != '#') + ): _bridgesystem['TIMEOUT'] = _tmout * 60 - _bridgestemp[_bridge].append(_bridgesystem) + _bridgetemp.append(_bridgesystem) + _bridgestemp[_bridge] = _bridgetemp BRIDGES.update(_bridgestemp) @@ -766,7 +809,7 @@ def bridgeDebug(): for dialslot in (1, 2): if dialroll[dialslot] <= 1 or CONFIG['SYSTEMS'][system]['MODE'] != 'MASTER': continue - _tmout = CONFIG['SYSTEMS'][system]['DEFAULT_UA_TIMER'] + _tmout = get_route_timer(system,dialslot,'DIAL') logger.warning('(BRIDGEDEBUG) system %s has more than one active dial bridge on TS%s (%s) - fixing',system, dialslot, dialroll[dialslot]) times = {} for _bridge in BRIDGES: @@ -991,13 +1034,13 @@ def sendVoicePacket(self,pkt,_source_id,_dest_id,_slot): self.send_system(pkt) -def sendSpeech(self,speech): +def sendSpeech(self,speech,_slot_id=2): 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] + _slot = systems[system].STATUS[_slot_id] _prompt_token = _beginGeneratedVoice(_slot) while True: if _generatedVoiceCancelled(_slot, _prompt_token): @@ -1059,7 +1102,7 @@ def disconnectedVoice(system): logger.debug('(%s) disconnected voice thread end',system) _endGeneratedVoice(_slot, _prompt_token) -def playFileOnRequest(self,fileNumber): +def playFileOnRequest(self,fileNumber,_slot_id=2): system = self._system _lang = CONFIG['SYSTEMS'][system]['ANNOUNCEMENT_LANGUAGE'] _nine = bytes_3(9) @@ -1072,9 +1115,9 @@ def playFileOnRequest(self,fileNumber): except IOError: logger.warning('(%s) cannot read file for number %s',system,fileNumber) return - speech = pkt_gen(_source_id, _nine, bytes_4(9), 1, _say) + speech = pkt_gen(_source_id, _nine, bytes_4(9), _slot_id - 1, _say) sleep(1) - _slot = systems[system].STATUS[2] + _slot = systems[system].STATUS[_slot_id] _prompt_token = _beginGeneratedVoice(_slot) while True: if _generatedVoiceCancelled(_slot, _prompt_token): @@ -1290,6 +1333,14 @@ def options_config(): _options['DEFAULT_DIAL_TS2'] = start_ref_value if 'RelinkTime' in _options: _options['DEFAULT_UA_TIMER'] = _options.pop('RelinkTime') + if 'TS1Timer' in _options: + _options['TS1_TIMER'] = _options.pop('TS1Timer') + if 'TS2Timer' in _options: + _options['TS2_TIMER'] = _options.pop('TS2Timer') + if 'TGTimer' in _options: + _options['TG_TIMER'] = _options.pop('TGTimer') + if 'DialTimer' in _options: + _options['DIAL_TIMER'] = _options.pop('DialTimer') if 'TS1_1' in _options: _options['TS1_STATIC'] = _options.pop('TS1_1') if 'TS1_2' in _options: @@ -1329,6 +1380,10 @@ def options_config(): if 'UserLink' in _options: _options.pop('UserLink') + + _provided_timer_keys = { + _timer_key for _timer_key in TIMER_OPTION_KEYS if _timer_key in _options + } if 'TS1_STATIC' not in _options: _options['TS1_STATIC'] = False @@ -1346,6 +1401,9 @@ def options_config(): if 'DEFAULT_UA_TIMER' not in _options: _options['DEFAULT_UA_TIMER'] = CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER'] + for _timer_key in TIMER_OPTION_KEYS: + if _timer_key not in _options: + _options[_timer_key] = CONFIG['SYSTEMS'][_system].get(_timer_key, CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER']) if 'DIAL_A_TG' not in _options: _options['DIAL_A_TG'] = int(CONFIG['SYSTEMS'][_system].get('DIAL_A_TG', True)) if 'DYNAMIC_TG_ROUTING' not in _options: @@ -1366,6 +1424,21 @@ def options_config(): logger.debug('(OPTIONS) %s - DEFAULT_UA_TIMER is not an integer, ignoring',_system) _default_ua_timer = CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER'] + _timer_values = {} + _invalid_timer_keys = set() + for _timer_key in TIMER_OPTION_KEYS: + _timer_value = parse_int_option(_options[_timer_key], default=None) + if _timer_value is None: + logger.debug('(OPTIONS) %s - %s is not an integer, ignoring',_system,_timer_key) + _invalid_timer_keys.add(_timer_key) + _timer_value = CONFIG['SYSTEMS'][_system].get(_timer_key, CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER']) + _timer_values[_timer_key] = normalize_ua_timer(_timer_value) + _timer_values[TIMER_EXPLICIT_KEY] = ( + set(CONFIG['SYSTEMS'][_system].get(TIMER_EXPLICIT_KEY, set())) | + (_provided_timer_keys - _invalid_timer_keys) + ) + _default_ua_timer = _timer_values['DEFAULT_UA_TIMER'] + if 'VOICE' in _options: _voice_ident = parse_bool_option(_options['VOICE'], default=None) if _voice_ident is None: @@ -1422,9 +1495,20 @@ def options_config(): _tmout = _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) + _timer_changed = any( + _timer_values[_timer_key] != CONFIG['SYSTEMS'][_system].get(_timer_key, CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER']) + for _timer_key in TIMER_OPTION_KEYS + ) or _timer_values[TIMER_EXPLICIT_KEY] != CONFIG['SYSTEMS'][_system].get(TIMER_EXPLICIT_KEY, set()) + if ('_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']) or _timer_changed: + logger.debug('(OPTIONS) %s Updating timers for existing bridges.',_system) + for _route_type in ('TG', 'DIAL'): + for _timer_slot in (1, 2): + update_timeout( + _system, + route_timer_from(_timer_values, _timer_slot, _route_type), + slot=_timer_slot, + route_type=_route_type, + ) for _default_slot, _default_key, _default_value in ( (1, 'DEFAULT_DIAL_TS1', _default_dial_ts1), @@ -1433,15 +1517,15 @@ def options_config(): if _default_value != CONFIG['SYSTEMS'][_system].get(_default_key, 0): if _default_value > 0 and not valid_dial_a_tg_reflector(_default_value): logger.debug('(OPTIONS) %s %s is in prohibited list, ignoring change',_system,_default_key) - reset_all_reflector_system(_tmout,_system,_default_slot) + reset_all_reflector_system(route_timer_from(_timer_values,_default_slot,'DIAL'),_system,_default_slot) _default_value = 0 elif _default_value > 0: logger.debug('(OPTIONS) %s %s changed, updating',_system,_default_key) - reset_all_reflector_system(_tmout,_system,_default_slot) - make_default_reflector(_default_value,_tmout,_system,_default_slot) + reset_all_reflector_system(route_timer_from(_timer_values,_default_slot,'DIAL'),_system,_default_slot) + make_default_reflector(_default_value,route_timer_from(_timer_values,_default_slot,'DIAL'),_system,_default_slot) else: logger.debug('(OPTIONS) %s %s disabled, updating',_system,_default_key) - reset_all_reflector_system(_tmout,_system,_default_slot) + reset_all_reflector_system(route_timer_from(_timer_values,_default_slot,'DIAL'),_system,_default_slot) if _default_slot == 1: _default_dial_ts1 = _default_value else: @@ -1460,24 +1544,26 @@ def options_config(): _tmout = _default_ua_timer logger.debug('(OPTIONS) %s TS1 static TGs changed, updating',_system) for tg in parse_static_tgs(CONFIG['SYSTEMS'][_system]['TS1_STATIC']): - reset_static_tg(tg,1,_tmout,_system) + reset_static_tg(tg,1,route_timer_from(_timer_values,1,'TG'),_system) for tg in parse_static_tgs(_options['TS1_STATIC']): - make_static_tg(tg,1,_tmout,_system) + make_static_tg(tg,1,route_timer_from(_timer_values,1,'TG'),_system) ts2 = [] if ('_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']) or (_options['TS2_STATIC'] != CONFIG['SYSTEMS'][_system]['TS2_STATIC']): _tmout = _default_ua_timer logger.debug('(OPTIONS) %s TS2 static TGs changed, updating',_system) for tg in parse_static_tgs(CONFIG['SYSTEMS'][_system]['TS2_STATIC']): - reset_static_tg(tg,2,_tmout,_system) + reset_static_tg(tg,2,route_timer_from(_timer_values,2,'TG'),_system) for tg in parse_static_tgs(_options['TS2_STATIC']): - make_static_tg(tg,2,_tmout,_system) + make_static_tg(tg,2,route_timer_from(_timer_values,2,'TG'),_system) CONFIG['SYSTEMS'][_system]['TS1_STATIC'] = _options['TS1_STATIC'] CONFIG['SYSTEMS'][_system]['TS2_STATIC'] = _options['TS2_STATIC'] CONFIG['SYSTEMS'][_system]['DEFAULT_DIAL_TS1'] = _default_dial_ts1 CONFIG['SYSTEMS'][_system]['DEFAULT_DIAL_TS2'] = _default_dial_ts2 CONFIG['SYSTEMS'][_system]['DEFAULT_REFLECTOR'] = _default_dial_ts2 - CONFIG['SYSTEMS'][_system]['DEFAULT_UA_TIMER'] = _default_ua_timer + for _timer_key in TIMER_OPTION_KEYS: + CONFIG['SYSTEMS'][_system][_timer_key] = _timer_values[_timer_key] + CONFIG['SYSTEMS'][_system][TIMER_EXPLICIT_KEY] = _timer_values[TIMER_EXPLICIT_KEY] CONFIG['SYSTEMS'][_system]['DIAL_A_TG'] = _dial_a_tg CONFIG['SYSTEMS'][_system]['DYNAMIC_TG_ROUTING'] = _dynamic_tg_routing @@ -2669,8 +2755,9 @@ class routerHBP(HBSYSTEM): if _dial_tg_enabled and _dial_tg_in_range and _int_dst_id != 8 and _int_dst_id != 9: _bridgename = ''.join(['#',str(_int_dst_id)]) 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,_dial_tg_slot) + _dial_tmout = get_route_timer(self._system,_dial_tg_slot,'DIAL') + logger.info('(%s) [A] Dial-A-TG for TG %s does not exist. Creating as User Activated. Timeout: %s',self._system, _int_dst_id,_dial_tmout) + make_single_reflector(_dst_id,_dial_tmout,self._system,_dial_tg_slot) if _dial_tg_can_link or _dial_tg_disconnect: for _bridge in BRIDGES: @@ -2809,7 +2896,7 @@ class routerHBP(HBSYSTEM): #Information services elif _int_dst_id >= 9991 and _int_dst_id <= 9999: self.STATUS[_slot]['_stopTgAnnounce'] = True - reactor.callInThread(playFileOnRequest,self,_int_dst_id) + reactor.callInThread(playFileOnRequest,self,_int_dst_id,_dial_tg_slot) #playFileOnRequest(self,_int_dst_id) @@ -2827,9 +2914,9 @@ class routerHBP(HBSYSTEM): _say.append(words[_lang][num]) if _say: - speech = pkt_gen(bytes_3(5000), _nine, bytes_4(9), 1, _say) + speech = pkt_gen(bytes_3(5000), _nine, bytes_4(9), _dial_tg_slot - 1, _say) #call speech in a thread as it contains sleep() and hence could block the reactor - reactor.callInThread(sendSpeech,self,speech) + reactor.callInThread(sendSpeech,self,speech,_dial_tg_slot) # Mark status variables for use later self.STATUS[_slot]['RX_PEER'] = _peer_id @@ -2911,8 +2998,9 @@ class routerHBP(HBSYSTEM): #Create default bridge for unknown TG if CONFIG['SYSTEMS'][self._system].get('DYNAMIC_TG_ROUTING', True) and int_id(_dst_id) >= 5 and int_id(_dst_id) != 9 and int_id(_dst_id) != 4000 and int_id(_dst_id) != 5000 and (str(int_id(_dst_id)) not in BRIDGES): - logger.info('(%s) Bridge for TG %s does not exist. Creating as User Activated. Timeout %s',self._system, int_id(_dst_id),CONFIG['SYSTEMS'][self._system]['DEFAULT_UA_TIMER']) - make_single_bridge(_dst_id,self._system,_slot,CONFIG['SYSTEMS'][self._system]['DEFAULT_UA_TIMER']) + _tg_tmout = get_route_timer(self._system,_slot,'TG') + logger.info('(%s) Bridge for TG %s does not exist. Creating as User Activated. Timeout %s',self._system, int_id(_dst_id),_tg_tmout) + make_single_bridge(_dst_id,self._system,_slot,_tg_tmout) self.STATUS[_slot]['packets'] = self.STATUS[_slot]['packets'] +1 diff --git a/config.py b/config.py index 552ef22..c182b82 100755 --- a/config.py +++ b/config.py @@ -44,6 +44,39 @@ __license__ = 'GNU GPLv3' __maintainer__ = 'Simon Adlem, G7RZU' __email__ = 'simon@gb7fr.org.uk' +def master_timer_config(config, section): + default_timer = config.getint(section, 'DEFAULT_UA_TIMER', fallback=10) + tg_timer = config.getint(section, 'TG_TIMER', fallback=default_timer) + dial_timer = config.getint(section, 'DIAL_TIMER', fallback=default_timer) + ts1_timer = config.getint(section, 'TS1_TIMER', fallback=default_timer) + ts2_timer = config.getint(section, 'TS2_TIMER', fallback=default_timer) + + return { + '_TIMER_OPTIONS_SET': { + key for key in ( + 'DEFAULT_UA_TIMER', + 'TG_TIMER', + 'DIAL_TIMER', + 'TS1_TIMER', + 'TS2_TIMER', + 'TS1_TG_TIMER', + 'TS2_TG_TIMER', + 'TS1_DIAL_TIMER', + 'TS2_DIAL_TIMER', + ) + if config.has_option(section, key) + }, + 'DEFAULT_UA_TIMER': default_timer, + 'TG_TIMER': tg_timer, + 'DIAL_TIMER': dial_timer, + 'TS1_TIMER': ts1_timer, + 'TS2_TIMER': ts2_timer, + 'TS1_TG_TIMER': config.getint(section, 'TS1_TG_TIMER', fallback=ts1_timer if config.has_option(section, 'TS1_TIMER') else tg_timer), + 'TS2_TG_TIMER': config.getint(section, 'TS2_TG_TIMER', fallback=ts2_timer if config.has_option(section, 'TS2_TIMER') else tg_timer), + 'TS1_DIAL_TIMER': config.getint(section, 'TS1_DIAL_TIMER', fallback=ts1_timer if config.has_option(section, 'TS1_TIMER') else dial_timer), + 'TS2_DIAL_TIMER': config.getint(section, 'TS2_DIAL_TIMER', fallback=ts2_timer if config.has_option(section, 'TS2_TIMER') else dial_timer), + } + # Processing of ALS goes here. It's separated from the acl_build function because this # code is hblink config-file format specific, and acl_build is abstracted def process_acls(_config): @@ -319,7 +352,7 @@ def build_config(_config_file): 'SUB_ACL': config.get(section, 'SUB_ACL', fallback=''), 'TG1_ACL': config.get(section, 'TGID_TS1_ACL', fallback=''), 'TG2_ACL': config.get(section, 'TGID_TS2_ACL', fallback=''), - 'DEFAULT_UA_TIMER': config.getint(section, 'DEFAULT_UA_TIMER', fallback=10), + **master_timer_config(config, section), 'SINGLE_MODE': config.getboolean(section, 'SINGLE_MODE', fallback=True), 'VOICE_IDENT': config.getboolean(section, 'VOICE_IDENT', fallback=True), 'DIAL_A_TG': config.getboolean(section, 'DIAL_A_TG', fallback=True), diff --git a/docs/codex-notes.md b/docs/codex-notes.md deleted file mode 100644 index e078e23..0000000 --- a/docs/codex-notes.md +++ /dev/null @@ -1,5300 +0,0 @@ -# Codex Notes - -## Current Analysis: Options Static TG Validation - -### Findings - -- `bridge_master.py` attempts to validate `TS1_STATIC` and `TS2_STATIC` - options before parsing them, but the current validation is ineffective. -- The whitespace removal calls use `re.sub(...)` without assigning the result, - so whitespace remains in the option value. -- The pattern `"![\d\,]"` matches a literal exclamation mark followed by a - digit or comma. It does not mean "any character except digit or comma". -- Invalid values such as `TS1=91,A92` can reach `int(tg)` and raise - `ValueError`. -- That exception is caught by the broad `except Exception` around the whole - options block, so the options update can abort partway through. - -### Assumptions - -- Static TG option strings are intended to contain only decimal TG numbers - separated by commas. -- Simple whitespace in static TG option strings is acceptable and should be - normalized away, even though it is not strictly correct option syntax. -- Malformed static TG tokens should be rejected cleanly without creating new - bridge state for that token. -- Valid fields in the same options string should still be applied when another - field is invalid, because there is no good feedback mechanism for rejected - options. -- TS1 and TS2 static TG options should be treated independently. -- The existing broad exception handler is not intended as the normal validation - path for malformed user options. - -### Unresolved Questions - -- Should an invalid new options value leave the previous `CONFIG['SYSTEMS'][...] - ['TS1_STATIC']` / `['TS2_STATIC']` value unchanged? -- Resolved: in a mixed list like `91,A92`, valid token `91` should still be - applied while invalid token `A92` is skipped. - -### Protocol-Sensitive Areas - -- Static TG configuration affects bridge membership and therefore routing - decisions for HBP packets. -- TS1 and TS2 static TG handling must remain symmetrical unless there is an - intentional FreeDMR-specific reason for divergence. -- Static TG validation must not change packet bytes. It only controls whether - bridge state is created or reset. -- Control/local targets such as `6`, `7`, `8`, `9`, `9990`, and `9991..9999` - should not accidentally become static routes. - -### Inferred Invariants - -- Static TG option parsing should normalize transport/config text before - mutating bridge state. -- A malformed static TG option should not prevent other valid option fields from - applying. -- Static TGs must pass the shared static TG validator before either - `make_static_tg()` or `reset_static_tg()` is called. -- Config and runtime option paths should use the same validation rules for TS1 - and TS2. -- TS1 and TS2 static option lists should be parsed independently so a malformed - TS1 token does not block valid TS2 changes, and vice versa. - -### Resolution - -- Confirmed intended behavior: static TG options are parsed at token level. -- Simple whitespace is normalized away. -- Invalid, prohibited or out-of-range tokens are skipped. -- Valid tokens in the same TS1 or TS2 list still apply. -- TS1 and TS2 are independent; invalid tokens on one slot do not block valid - tokens on the other slot. - -## Current Analysis: Invalid Default Reflector Persisted After Rejection - -### Findings - -- `options_config()` rejects invalid non-zero `DEFAULT_REFLECTOR` / `DIAL` - values for bridge creation when `valid_dial_a_tg_reflector()` returns false. -- After logging that the default dial-a-TG is prohibited, the function still - writes the raw option value into - `CONFIG['SYSTEMS'][_system]['DEFAULT_REFLECTOR']`. -- This means an invalid value such as `DIAL=1000000` can be "ignored" for bridge - state but retained in system configuration. -- Other code, such as disconnected voice announcement logic, reads - `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']` directly and may treat any - positive value as linked/default reflector state. - -### Assumptions - -- If a default reflector option is rejected, no default reflector should remain - set for that master during the current session. -- Rejected options should not leave `CONFIG` saying a reflector is configured - when no corresponding reflector bridge was created. -- If an invalid default reflector is supplied while an old valid default - reflector is active, the old default should be disabled rather than preserved. -- The runtime config should reflect the effective routing state, not merely the - last raw option value seen. - -### Unresolved Questions - -- Should this behavior be visible to clients via an error/status path, or is the - existing log message sufficient? -- Should rejected `DEFAULT_REFLECTOR` values preserve the old timer/static TG - changes from the same options string, or should the entire options update be - rejected atomically? - -### Protocol-Sensitive Areas - -- Default reflector state is dial-a-TG reflector state on TS2 and affects where - a repeater appears linked after startup or options reload. -- Voice announcements use TG9 TS2 prompts and may report misleading reflector - state if configuration and bridge state diverge. -- A rejected default reflector must not create or retune reflector bridge state. -- The FreeDMR dial-a-TG policy cap remains `999999`; higher values are not valid - link targets. - -### Inferred Invariants - -- A rejected default reflector must be stored as effective - `DEFAULT_REFLECTOR = 0`. -- `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']` should match the active or - intended default reflector policy state used by bridge creation. -- Default reflector validation should be applied before both bridge mutation and - config mutation. -- RF dial-a-TG and configured default reflector paths should use the same - policy bounds. - -### Resolution - -- Confirmed intended behavior: an invalid default reflector option means no - default reflector should be set for the current session. -- Production handling should reset TS2 TG9 reflector state for the affected - master and persist effective `DEFAULT_REFLECTOR = 0`. -- Tests should assert both absence of the invalid reflector bridge and - deactivation of any previously active default reflector. - -## Current Analysis: Invalid Startup Default Reflector Remains In Config - -### Findings - -- `make_default_reflectors()` uses `valid_dial_a_tg_reflector()` before creating - a startup default reflector bridge. -- When `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']` is invalid, prohibited, - or above the FreeDMR dial-a-TG policy cap, no bridge is created. -- Invalid startup default reflectors should be logged because otherwise the - config-file value is silently ignored. -- The invalid configured value remains in - `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']`. -- Other code paths, including disconnected voice announcement logic, read - `DEFAULT_REFLECTOR` directly and treat positive values as configured/default - reflector state. - -### Assumptions - -- Startup configuration should follow the same effective-state rule as options - reload: invalid default reflector means no default reflector for the current - session. -- If startup rejects a default reflector, the effective runtime - `DEFAULT_REFLECTOR` should become `0`. -- The server should not announce or expose an invalid positive default reflector - value after rejecting it for bridge creation. - -### Unresolved Questions - -- Should invalid startup `DEFAULT_REFLECTOR` values be rewritten only in runtime - `CONFIG`, or should any persisted/generated config output also normalize them? -- Should startup log invalid default reflectors at debug level, warning level, or - leave existing logging style unchanged? - -### Protocol-Sensitive Areas - -- Default reflectors connect TS2 TG9 reflector state at startup. -- System-wide defaults are intended for sparing use and ideally should not be - used; they remain available for scenarios that require them. -- Client-requested settings should be treated as preferential over system-wide - defaults. -- A positive `DEFAULT_REFLECTOR` value can affect voice announcements and API or - reporting output even when bridge state was not created. -- Invalid startup defaults must not create, activate, or retune reflector bridge - state. - -### Resolution - -- Invalid positive startup default reflectors are logged at warning level and - still do not create reflector bridge state. -- Confirmed intended behavior: invalid startup default reflectors normalize the - in-memory effective `DEFAULT_REFLECTOR` to `0` for the current session. This - does not write back to the config file. - -### Inferred Invariants - -- Runtime `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']` should describe the - effective default reflector state for the session. -- Startup and live options should apply the same dial-a-TG default reflector - policy. -- Rejecting an invalid startup default reflector should leave no TS2 TG9 default - reflector active for that system. - -## Current Analysis: Options Numeric Fields Converted Before Validation - -### Findings - -- In `options_config()`, some numeric option fields are converted with `int()` - before the later validation block that checks `.isdigit()`. -- `OVERRIDE_IDENT_TG` is converted and assigned before the code reaches the - validation that logs "`OVERRIDE_IDENT_TG is not an integer`". -- `VOICE` and `SINGLE` are also converted before any field-specific numeric - validation. -- A malformed option such as `IDENTTG=A` or `VOICE=A` can raise `ValueError` - inside the broad per-system `except Exception` block. -- That broad exception can abort processing of otherwise valid fields in the - same options string, which conflicts with the confirmed tolerant-options - approach used for static TG tokens. - -### Assumptions - -- Malformed optional numeric fields should not abort processing of otherwise - valid fields in the same options string. -- Invalid numeric option fields should be ignored individually and logged. -- Valid fields such as `DIAL`, `TIMER`, `TS1`, or `TS2` should still apply when - an unrelated optional numeric field is malformed. -- The broad exception handler should remain a last-resort guard, not the normal - validation path. - -### Unresolved Questions - -- Should invalid `VOICE`, `SINGLE`, and `OVERRIDE_IDENT_TG` each preserve their - previous effective value? -- Should invalid values for these fields be logged at debug level, matching - existing option validation, or warning level? -- Should boolean-like fields accept only `0` and `1`, or continue accepting any - integer where Python truthiness determines the effective boolean? - -### Protocol-Sensitive Areas - -- `OVERRIDE_IDENT_TG` controls where voice ident packets are sent. -- Voice ident traffic uses generated DMR packets and should not be redirected to - an invalid TG because of malformed options. -- Options parsing should not partially abort before dial-a-TG/default/static - routing changes are considered. - -### Inferred Invariants - -- Options fields should be normalized and validated before mutating runtime - config. -- Invalid independent option fields should not block valid independent option - fields. -- Numeric option parsing should avoid `ValueError` as normal control flow. - -### Resolution - -- Client session options now validate numeric fields before mutating runtime - config. -- Invalid `VOICE`, `SINGLE`, and `OVERRIDE_IDENT_TG` values are ignored - independently and do not block valid fields in the same options string. -- `VOICE` and `SINGLE` accept only `0` and `1`. -- Empty `DIAL` / `DEFAULT_REFLECTOR` is parsed as `0`, meaning no default - reflector. - -## Current Analysis: Voice Ident Override TG Range Check - -### Findings - -- `ident()` checks `OVERRIDE_IDENT_TG` before sending generated voice ident - packets. -- The upper-bound expression is malformed: - `int(CONFIG['SYSTEMS'][system]['OVERRIDE_IDENT_TG'] < 16777215)`. -- The comparison happens before `int(...)`, so string config values such as - `"9"` can be compared directly to integer `16777215`, raising `TypeError`. -- Numeric config values avoid the type error, but the expression becomes - `int(True)` or `int(False)`, so the second half of the range check is only - `1` or `0`. -- `bytes_3(CONFIG['SYSTEMS'][system]['OVERRIDE_IDENT_TG'])` is then called with - the raw config value rather than the parsed integer. - -### Assumptions - -- `OVERRIDE_IDENT_TG` is intended to direct voice ident packets to a configured - TG instead of all-call. -- Valid override TGs should be positive and less than the DMR all-call value - `16777215`. -- Empty string, `False`, or `0` should mean no override and should fall back to - all-call. -- Invalid override values should not crash `ident()`; they should fall back to - all-call and ideally be logged. -- Local/control TGs should be rejected for ident override. - -### Unresolved Questions - -- Should invalid startup `OVERRIDE_IDENT_TG` be normalized in runtime config, or - only ignored at ident send time? -- Resolved: invalid ident override values should be logged. - -### Protocol-Sensitive Areas - -- Voice ident sends generated DMR packets and chooses the destination TG for - those packets. -- All-call `16777215` is the fallback destination when no override is active. -- This path must not mutate packet bytes outside the intended generated voice - packet destination. - -### Inferred Invariants - -- Voice ident destination selection should parse and validate the override TG - before packet generation. -- Invalid override TG values should not prevent voice ident from running. -- Empty or disabled override values should use all-call. - -### Resolution - -- `OVERRIDE_IDENT_TG` is parsed before use. -- Valid positive TGs below all-call are used as the generated voice ident - destination. -- Empty string, `False`, and `0` use all-call. -- Malformed, out-of-range, or local/control TG values are logged and use - all-call. - -## Current Analysis: Invalid TIMER Still Used Raw In Static TG Branches - -### Findings - -- `options_config()` now parses `DEFAULT_UA_TIMER` / `TIMER` into - `_default_ua_timer` and falls back to the current effective timer when the - option is malformed. -- Later in the same function, the TS1 and TS2 static TG update branches still - use `int(_options['DEFAULT_UA_TIMER'])` instead of `_default_ua_timer`. -- A client option string such as `TIMER=A;TS1=91` logs that the timer is invalid, - but then can still raise `ValueError` when the static TG branch runs. -- That aborts otherwise valid static TG changes in the same options string. - -### Assumptions - -- Invalid `TIMER` should be ignored independently for the session and should use - the current effective `DEFAULT_UA_TIMER`. -- Valid static TG changes in the same options string should still apply. -- The raw `DEFAULT_UA_TIMER` option value should not be converted again after - `_default_ua_timer` has been parsed and normalized. - -### Unresolved Questions - -- None for the immediate fix if the previously confirmed tolerant options model - applies. - -### Protocol-Sensitive Areas - -- The timer value controls bridge timeout state but should not block static TG - routing updates when malformed. -- Static TG updates must use the effective parsed timer, not raw client text. - -### Inferred Invariants - -- Once an option field is parsed into an effective value, subsequent bridge - mutations should use that effective value. -- Invalid independent option fields should not trigger `ValueError` as normal - control flow. -- Invalid option fields should be logged. - -### Resolution - -- TS1 and TS2 static TG updates now use the parsed effective `_default_ua_timer` - rather than the raw option string. -- Invalid `TIMER` values are logged and valid static TG updates in the same - options string still apply with the current effective timer. - -### Clarified Configuration Model - -- Client-supplied options are session-scoped. They should persist only for the - duration of that client session. -- FreeDMR has a disconnect/timeout routine that resets session-scoped options to - system configured defaults. -- Startup config-file defaults and client options are different layers and - should not be treated identically. -- If a startup config directive has an invalid field and that directive has a - defined system fallback default, FreeDMR should use the fallback as if the - directive had not been present and log that the startup default setting was - invalid. -- If a startup config directive is invalid and has no system default fallback, - FreeDMR should exit with an error. -- For client options, invalid fields should not become persisted configuration; - they should apply only if valid for the current session. -- Rejected configuration or client-option errors should be logged. Normalizing, - ignoring, or falling back silently is not acceptable because many rejected - options have no direct user feedback path. - -### Startup Fallback Mapping Observed In `config.py` - -- `DEFAULT_UA_TIMER` has a startup fallback of `10`. -- `SINGLE_MODE` has a startup fallback of `True`. -- `VOICE_IDENT` has a startup fallback of `True`. -- `TS1_STATIC` has a startup fallback of empty string. -- `TS2_STATIC` has a startup fallback of empty string. -- Empty `TS1_STATIC` and `TS2_STATIC` values are valid and mean no static TGs - for that slot. -- `OVERRIDE_IDENT_TG` has a startup fallback of `False`. -- `DEFAULT_REFLECTOR` is read with `getint()` and no fallback in `config.py`; - however the runtime policy now treats invalid positive values as disabled - default reflector state for the current session. -- Empty string, integer `0`, and boolean false for `DEFAULT_REFLECTOR` are - equivalent and mean no default reflector is set. -- `OPTIONS` is session-scoped and is reset by the HBP connection lifecycle to - `_default_options` when present, or removed otherwise. - -## Voice Prompt Packet System Scoping - -### Findings - -- `sendVoicePacket(self, pkt, _source_id, _dest_id, _slot)` indexes - `systems[system].STATUS`, but `system` is not a function argument or local - variable. -- `sendSpeech(self, speech)` also indexes `systems[system].STATUS[2]` without a - local `system`. -- The call sites pass a router instance as `self`, for example - `reactor.callInThread(sendSpeech, self, speech)` and - `reactor.callFromThread(sendVoicePacket, systems[system], ...)`. -- In imported deterministic tests this can raise `NameError` if those functions - execute directly. In the full process it can be worse: module-level `for - system in ...` loops may leave a stale global `system`, causing prompt stream - state to be written to the wrong router. - -### Assumptions To Validate - -- Generated voice packets should update the status for the router instance being - sent on, i.e. `self._system`. -- Dial-a-TG voice prompts, disconnected announcements, on-demand files, and - idents should all be emitted on TG9 slot 2 unless their existing call path - intentionally selects a different destination. -- A voice prompt should not mutate stream status for another master just because - a global loop variable currently names that system. - -### Unresolved Questions - -- Should invalid prompt packet/state errors be caught and logged inside the - thread helpers, or should existing Twisted thread exception logging remain the - only failure report? - -### Protocol-Sensitive Areas - -- Voice prompt packet bytes and destination TG must remain unchanged unless the - production code intentionally generates them that way. -- The fix should be transport/state scoping only; it should not alter dial-a-TG - routing decisions or prompt content. - -### Inferred Invariants - -- Helper functions that receive a router instance should derive the system name - from `self._system`, not from a module-global loop variable. -- Prompt stream lifecycle bookkeeping must be attached to the same system that - sends the prompt packet. - -### Resolution - -- `sendVoicePacket()` and `sendSpeech()` now bind `system = self._system` before - accessing `systems[system].STATUS`. -- Deterministic coverage exercises `sendSpeech()` with a deliberately stale - module-level `system` value and verifies stream state and captured prompt - packets stay on the router instance's own system. - -## Bridge Reset System Replacement - -### Findings - -- `remove_bridge_system(remsystem)` loops `for system in CONFIG['SYSTEMS']` and - rebuilds every bridge once per configured system. -- When it finds an entry where `bridgesystem['SYSTEM'] == remsystem`, it appends - a replacement entry with `'SYSTEM': system`, using the outer loop variable - rather than `remsystem`. -- Because `bt[bridge]` is overwritten on every outer-loop pass, the final bridge - state depends on the last configured system. In a two-master topology, - removing `MASTER-A` can leave two `MASTER-B` entries and no `MASTER-A` entry. -- Additional deterministic probes show the same identity corruption with three - masters and with OpenBridge present: the replacement entry can become - `MASTER-C` or even an `OBP-*` system depending on config iteration order. -- Neighboring reset helpers do not support this as intentional behavior: - `reset_static_tg()` and `reset_all_reflector_system()` preserve the target - system identity in replacement entries. - -### Assumptions To Validate - -- Bridge reset for a disconnected or timed-out master should keep that master's - bridge entry present but inactive, preserving its slot, TGID, timeout and - normal dial-a-TG activation trigger. -- Bridge reset should not create duplicate entries for another master. -- Bridge reset should not remove or deactivate unrelated systems' bridge entries. - -### Unresolved Questions - -- Should reset entries keep the previous `ON` list exactly as-is, or should they - keep the current behavior of setting `ON` to the entry's `TGID`? - -### Protocol-Sensitive Areas - -- This affects bridge state only; packet bytes should not be changed. -- Incorrect reset state can change which systems receive later TG traffic and - can prevent the reset master from reactivating the intended TG. - -### Inferred Invariants - -- A reset of system `X` must leave bridge entries for system `X` named `X`. -- The number of entries for unrelated systems should not change during reset. - -### Resolution - -- `remove_bridge_system()` now rebuilds each bridge once instead of once per - configured system. -- Replacement entries preserve `SYSTEM` as `remsystem`, set `ACTIVE` false, set - `TO_TYPE` to `ON`, reset the timer, and preserve existing `ON`, `OFF`, and - `RESET` trigger lists. -- Deterministic coverage verifies reset identity preservation for normal bridges - and trigger preservation for `#` reflector bridges with an FBP peer present. - -## HBP Packet Reset/Reload Guard - -### Findings - -- `routerHBP.dmrd_received()` checks reset/reload state before parsing a packet: - `CONFIG['SYSTEMS'][self._system]['_reset'] or - CONFIG['SYSTEMS'][_system]['_reloadoptions']`. -- `_system` is assigned later in the same function by bridge iteration loops, - so Python treats it as a local variable. Referencing it in the guard before - assignment raises `UnboundLocalError` when the `_reset` key exists and does - not short-circuit safely. -- The `return` is outside the `if` block but inside the `try`. If both - `_reset` and `_reloadoptions` existed and were false, the function would still - return and drop the packet. -- The current code only reaches normal packet parsing when a `KeyError` is - raised by missing state keys. That makes normal operation depend on absent - lifecycle keys rather than their boolean values. - -### Assumptions To Validate - -- HBP packets should be dropped only while the receiving system's `_reset` or - `_reloadoptions` flag is true. -- The guard should inspect `CONFIG['SYSTEMS'][self._system]` for both flags. -- When a packet is dropped due reset/reload, it should be logged once using - `_resetlog`, preserving the current intent. -- When both flags are false or absent, packet handling should continue normally. - -### Unresolved Questions - -- Should reset and reload use separate one-shot log flags, or is the existing - shared `_resetlog` flag sufficient? - -### Protocol-Sensitive Areas - -- This is a packet admission guard. The fix should not mutate packet bytes or - alter routing after a packet is admitted. -- Incorrectly dropping during false reset/reload state can look like a silent - repeater or TG routing failure. - -### Inferred Invariants - -- Lifecycle flags should be treated as optional booleans with false defaults. -- A packet should be rejected during reset/reload only when the relevant flag is - explicitly true. -- Rejected packet admission during reset/reload should remain logged. - -### Resolution - -- The HBP packet admission guard now reads `_reset` and `_reloadoptions` from - `CONFIG['SYSTEMS'][self._system]` with false-default `dict.get()`. -- The guard returns only when reset or reload is active, and logs the rejection - once through `_resetlog`. -- Deterministic coverage verifies false lifecycle flags admit packets, while - active reset or reload drops packets and logs exactly once. - -## HBP Unit Data To OBP Reporting Target - -### Findings - -- `routerHBP.sendDataToOBP()` sends the packet to `systems[_target]` correctly. -- When reporting is enabled, it then calls - `systems[system]._report.send_bridgeEvent(...)`, but `system` is not a - function argument or local variable in `sendDataToOBP()`. -- In a deterministic HBP unit-data-to-OBP path with reporting enabled, the - packet is captured for the OBP target and then the function raises - `NameError: name 'system' is not defined`. -- If a module-level `system` happens to exist in the full process, the report - event could be attached to the wrong system's report object instead of raising. - -### Assumptions To Validate - -- The TX report for `sendDataToOBP()` should be emitted on the target OBP - system's report object, matching the packet destination and the event payload. -- Reporting should not be able to raise after a packet send and interrupt the - rest of packet handling. -- This fix is reporting-only and should not change packet bytes, routing, stream - state, or source-quench behavior. - -### Unresolved Questions - -- None for the minimal fix; this appears to be a direct variable-name error. - -### Protocol-Sensitive Areas - -- The packet send path already uses `_target`; only the reporting object should - change. -- The event payload already names `_target`, so preserving it avoids report - semantic changes. - -### Inferred Invariants - -- Reporting side effects should use the same target system as the packet send - unless a caller explicitly reports on the source router. -- Enabling `CONFIG['REPORTS']['REPORT']` must not change packet routing - behavior or raise after a successful send. - -### Resolution - -- `routerHBP.sendDataToOBP()` now reports through `systems[_target]._report`, - matching the packet send target and existing event payload. -- Deterministic coverage verifies HBP unit data forwarded to OBP captures the - packet on the OBP target and records the TX report on that target report - object without raising. - -### Data Packet Review Scope - -- Per user direction, continue evaluating data packet processing before moving - to voice packet path debugging. - -## OBP Data Gateway Metadata Argument Order - -### Findings - -- In `routerOBP.dmrd_received()`, the DATA-GATEWAY forwarding path calls - `sendDataToOBP('DATA-GATEWAY', ..., _source_rptr, _ber, _rssi)`. -- `routerOBP.sendDataToOBP()` expects positional arguments after `_slot` as - `_hops, _source_server, _ber, _rssi, _source_rptr`. -- This shifts metadata fields: - `_source_rptr` is sent as hops, `_ber` is sent as source server, `_rssi` is - sent as BER, RSSI falls back to zero, and source repeater falls back to zero. -- A deterministic probe with distinctive metadata captured: - `hops=b'\x01\x02\x03\x05'`, `source_server=b'\x12'`, - `ber=b'4'`, `source_rptr=b'\x00\x00\x00\x00'` for the DATA-GATEWAY target. - -### Assumptions To Validate - -- DATA-GATEWAY forwarding from OBP should preserve incoming `_hops`, - `_source_server`, `_ber`, `_rssi`, and `_source_rptr` exactly as received - unless production code intentionally rewrites them. -- The DATA-GATEWAY path should match the later OBP-to-OBP forwarding call that - already passes `_hops, _source_server, _ber, _rssi`. -- Because this is DATA-GATEWAY forwarding, preserving source repeater metadata is - preferable to falling back to zeros. - -### Unresolved Questions - -- Should `routerOBP.sendDataToOBP()` calls always use keyword arguments for - protocol metadata to prevent this class of positional-order bug? - -### Protocol-Sensitive Areas - -- This affects OBP metadata, not DMR payload bytes. The packet bytes should - remain unchanged except for existing slot-bit handling. -- Hops, source server, source repeater, BER, and RSSI can affect loop control, - observability and downstream data handling. - -### Inferred Invariants - -- Transport metadata forwarding must not be confused with protocol mutation. -- DATA-GATEWAY forwarding should not corrupt metadata by positional argument - shifts. - -### Validation Result - -- Rejected as a bug. User confirmed DATA-GATEWAY forwarding targets SMS/GPS - packet processing by KF1EEL and is protocol v1, not FBP. -- Protocol metadata expectations depend on protocol version; DATA-GATEWAY should - not be evaluated as though it were an FBP peer. -- Do not change this DATA-GATEWAY call based on FBP metadata preservation - assumptions. -- General protocol invariant confirmed by user: protocol options and argument - order must match the protocol version actually in use for that session. - -## Unit Data To HBP Reporting Slot - -### Findings - -- Both `routerOBP.sendDataToHBP()` and `routerHBP.sendDataToHBP()` accept a - target slot argument named `_d_slot`. -- Callers calculate `_tmp_bits` before calling the helper, so the packet bytes - are rewritten to the target slot when needed. -- The helpers log and report the TX event with a hardcoded slot value of `1`. -- Deterministic probes show the packet is captured on slot 2 when SUB_MAP points - to slot 2, but the report event says slot 1: - `UNIT DATA,DATA,TX,MASTER-B,...,1,1234567`. -- This reproduces for both HBP-originated unit data and OBP-originated unit data - forwarded to an HBP master via `SUB_MAP`. - -### Assumptions To Validate - -- Unit data TX reports should identify the actual target HBP slot `_d_slot`. -- The packet bytes should remain unchanged; the existing caller-side slot bit - rewrite is already producing the expected target slot. -- This is reporting/observability only and should not alter data routing, - DATA-GATEWAY behavior, or protocol-version-specific metadata. - -### Unresolved Questions - -- Should the debug log wording also be changed from "on slot 1" to include - `_d_slot`, or should only the reporting payload change? - -### Protocol-Sensitive Areas - -- This is independent of OBP protocol version because the affected target is - HBP and `_d_slot` is already an explicit HBP target slot. -- Reporting should reflect the actual transport target without mutating packet - payload bytes. - -### Inferred Invariants - -- If a helper receives `_d_slot`, reporting and logs should not hardcode slot 1. -- Data packet tests should distinguish correct packet slot bits from incorrect - report metadata. - -### Resolution - -- `routerOBP.sendDataToHBP()` and `routerHBP.sendDataToHBP()` now use `_d_slot` - in both debug logging and `UNIT DATA,DATA,TX` report payloads. -- Deterministic coverage verifies HBP-originated and OBP-originated unit data - forwarded to HBP slot 2 via `SUB_MAP` captures slot 2 packet bits and reports - slot 2. - -## OBP Unit CSBK Data Stream Classification - -### Findings - -- HBP unit data classification treats `_dtype_vseq == 3` as data when the packet - stream differs from the slot's current `RX_STREAM_ID`. -- HBP unit data handling does not update the slot `RX_STREAM_ID` in the data - branch, so repeated unit CSBK packets with the same stream continue through - the data forwarding path. -- OBP unit data classification treats `_dtype_vseq == 3` as data only when - `_stream_id not in self.STATUS`. -- Once the first OBP unit CSBK packet creates `self.STATUS[_stream_id]`, - subsequent unit CSBK packets with the same stream do not enter the data path - and are not forwarded via `SUB_MAP`. -- Deterministic probe: two HBP unit CSBK packets with the same stream both - forward to the HBP `SUB_MAP` target; two OBP unit CSBK packets with the same - stream forward only the first packet. - -### Assumptions To Validate - -- Repeated unit CSBK packets with the same stream ID should still be treated as - data packets and forwarded, unless duplicate control rejects the exact packet. -- OBP and HBP should not diverge on this classification solely because OBP uses - per-stream `STATUS` and HBP uses per-slot `STATUS`. -- If this is intentional first-packet-only OBP behavior for a protocol version, - the deterministic harness should document it rather than change it. - -### Unresolved Questions - -- Is `_dtype_vseq == 3` intended to represent only an initial unit CSBK in OBP, - or can a valid data transaction contain multiple non-identical CSBK packets - with the same stream ID? - -### Protocol-Sensitive Areas - -- This affects data packet admission to the forwarding path, not packet byte - mutation. -- Duplicate suppression should remain separate from protocol classification: - exact duplicate packets may be dropped, but distinct CSBK data packets should - not be lost just because their stream already exists if the protocol allows - them. - -### Inferred Invariants - -- Data packet classification should be explicit about whether stream state is a - first-packet guard or only duplicate/loop-control state. -- OBP/HBP behavioral differences need protocol-version justification. - -### Deeper Analysis - -- ETSI TS 102 361-2 describes CSBK preamble use as a way to improve successful - delivery to mobile stations that are scanning or using sleep mode for battery - saving. -- User confirmed a DMR terminal can send more than one CSBK, usually as a - preamble to wake battery-operated terminals, but only one is required for the - route. -- In FreeDMR's OBP path, the first unit CSBK creates per-stream `STATUS` and - can be forwarded through DATA-GATEWAY, other OpenBridge/FBP peers, `SUB_MAP`, - or hotspot matching. -- Subsequent data header/block packets (`_dtype_vseq` 6, 7, 8) are still always - classified as unit data for the same stream. Therefore, the OBP first-CSBK - gate does not block the actual data payload path. -- Forwarding only the first CSBK may be intentional: it is enough to establish - routing/wake-up behavior and avoids multiplying preamble bursts across the - network. -- HBP's different behavior appears partly caused by its per-slot data state: the - unit-data branch does not set `RX_STREAM_ID`, so repeated CSBKs continue to - satisfy `_stream_id != self.STATUS[_slot]['RX_STREAM_ID']`. - -### Validation Result - -- Do not change this yet. The observed OBP/HBP difference is real, but not yet a - confirmed bug. -- Treat OBP first-CSBK-only forwarding as likely intentional until a recorded - fixture or live test shows a valid data transaction needing multiple distinct - CSBKs forwarded over OBP for correct behavior. -- Add future fixture coverage before changing this classification logic. - -### Follow-Up Verification - -- Code parsing in `hblink.py` extracts `_stream_id = _data[16:20]` for every - DMRD packet before passing it into `dmrd_received()`. -- FreeDMR does not appear to model a single transaction-level stream ID for the - whole SMS/GPS data exchange. Each DMRD packet carries its own stream ID, and - data routing decisions are made per received packet. -- OBP unit data handling always classifies `_dtype_vseq` 6, 7 and 8 as data - regardless of whether `_stream_id` already exists, so data header/block packets - with distinct stream IDs are naturally handled independently. -- OBP unit CSBK handling classifies `_dtype_vseq == 3` as data only when that - packet's `_stream_id` is not already present in `self.STATUS`. -- Deterministic probe: two OBP unit CSBK packets with distinct stream IDs both - forward via `SUB_MAP`; two with the same stream ID only forward the first. -- This supports the interpretation that repeated CSBK preambles are expected to - be distinct packets with distinct stream IDs in this code path, and the - existing OBP first-CSBK-per-stream gate is likely a duplicate/preamble - suppression mechanism rather than a transaction-level routing bug. -- User confirmed the underlying protocol model: data packets are packet-oriented - and are not a stream in the same way AMBE2+ audio is a stream. -- User also confirmed DMR data is not always unit-to-unit; the standard permits - data packets to be sent as group calls to a talkgroup. - -## HBP Group-Addressed Data Activates TG Bridge State - -### Findings - -- HBP `routerHBP.dmrd_received()` handles `group` and `vcsbk` packets in the - group-call path. -- For a new group packet, it logs `_dtype_vseq == 6` as `DATA HEADER`, but then - runs the same unknown-TG bridge creation block used for voice-like group calls: - `make_single_bridge(_dst_id, self._system, _slot, DEFAULT_UA_TIMER)`. -- Deterministic probe: a single HBP group data header to TG123 creates bridge - `123` with `MASTER-A` slot 2 active. -- If bridge `123` already exists and is active, the same group data header is - routed to target systems, so group-addressed data routing through existing TG - bridges appears intentional or at least supported. -- OBP group data to TG123 does not create a bridge in the same probe. - -### Assumptions To Validate - -- Group-addressed data packets may legitimately route over existing active TG - bridge state. -- Unknown group-addressed data packets should not necessarily create new - user-activated voice/TG bridge state just because the TG is unknown. -- If unknown group data is intended to create a bridge, that should be treated as - an explicit design decision because it can activate TG state without voice. - -### Unresolved Questions - -- Should group-addressed data headers (`_call_type == 'group'` and - `_dtype_vseq == 6`) create user-activated bridges for unknown TGs, or should - automatic bridge creation be restricted to voice-like group calls? -- Should OBP and HBP differ here, or should both follow the same group-data - bridge-creation policy? - -### Protocol-Sensitive Areas - -- Data packets are packet-oriented, so stream-style bridge activation semantics - may not be appropriate. -- Group data can be a valid TG-addressed packet, but routing it over an existing - bridge is different from creating new active bridge state. - -### Inferred Invariants - -- Packet routing and bridge-state activation should be considered separately. -- A data packet should not create voice/TG lifecycle state unless that is an - explicit FreeDMR policy. - -### Validation Result - -- Leave unchanged for now. User believes this behavior is deliberate. -- Likely rationale: allow routing of GPS packets sent as group calls to a local - APRS bridge without exposing them to the entire network, because they are - local-to-server. -- Do not treat HBP unknown group-data bridge creation as a confirmed bug unless - a later fixture or operational test disproves this policy. - -## Enhanced OBP Missing-Keepalive Gate - -### Findings - -- `kaReporting()` logs enhanced OpenBridge targets with no `_bcka` as not - sendable: `not sending to system ... as KeepAlive never seen`. -- `routerOBP.to_target()` applies that rule for OBP-originated group/voice - traffic: if `ENHANCED_OBP` is true and `_bcka` is missing or older than 60 - seconds, the target is skipped. -- `routerHBP.to_target()` only skips enhanced OpenBridge targets when `_bcka` - exists and is stale; if `_bcka` is missing, HBP-originated group/voice traffic - is still forwarded. -- `routerOBP.sendDataToOBP()` and `routerHBP.sendDataToOBP()` have the same - lenient missing-`_bcka` check, so unit data can be forwarded to enhanced - OpenBridge targets that have never sent a keepalive. -- Deterministic probes confirmed: - HBP unit data -> enhanced OBP without `_bcka` sends one packet; HBP group - voice -> enhanced OBP without `_bcka` sends one packet; OBP unit data -> - enhanced OBP without `_bcka` sends one packet; OBP group voice -> enhanced - OBP without `_bcka` sends no packet. - -### Assumptions To Validate - -- For `ENHANCED_OBP` targets, missing `_bcka` means the peer has not completed - the enhanced keepalive requirement and should not receive traffic. -- The intended rule should be the same for HBP-originated and OBP-originated - traffic, and for voice-like group packets and unit data packets. -- Periodic `kaReporting()` warnings are sufficient operational logging for - missing/stale enhanced keepalive suppression; per-packet skip logging would be - noisy. - -### Unresolved Questions - -- Should the strict missing-or-stale keepalive gate be applied everywhere an - enhanced OpenBridge target is selected, or is there a deliberate exception for - HBP-originated traffic/data? - -### Protocol-Sensitive Areas - -- Enhanced OBP keepalive state is transport/session state, not DMR payload - mutation. -- Data packet forwarding must preserve packet bytes except intentional slot or - transport metadata rewrites. - -### Inferred Invariants - -- Enhanced OpenBridge targets should not receive traffic until the session has - valid recent keepalive state. -- The same sendability predicate should be used by voice and data paths unless - an explicit protocol-version rule says otherwise. - -### Validation Result - -- User confirmed the strict missing-or-stale `_bcka` gate is intended for - enhanced OBP targets. The inconsistency likely came from protocol development - and was missed. -- Applied the strict gate in `routerHBP.to_target()`, - `routerOBP.sendDataToOBP()`, and `routerHBP.sendDataToOBP()` so the data - helpers and HBP-originated voice path match the existing OBP-originated voice - behavior. -- Added deterministic coverage for HBP unit data with missing, stale and recent - keepalive; OBP unit data with missing keepalive; and HBP group voice with - missing keepalive. - -### Remaining Risk - -- The black-box UDP harness does not yet exercise enhanced OBP keepalive - negotiation end to end. Current coverage validates the in-process routing - predicate and packet capture behavior. - -## Group-Addressed Data Reported As Group Voice - -### Findings - -- HBP `routerHBP.dmrd_received()` recognizes a group data header when - `_call_type == 'group'` and `_dtype_vseq == 6`; it logs `*DATA HEADER*` and - emits `DATA HEADER,DATA,RX`. -- The same HBP group data header then enters the normal group routing path. The - target-side `to_target()` methods emit `GROUP VOICE,START,TX` for a new target - stream regardless of `_dtype_vseq`. -- OBP `routerOBP.dmrd_received()` does not special-case group data header - receive reporting; a group data header is reported as - `GROUP VOICE,START,RX`. -- `stream_trimmer_loop()` later emits `GROUP VOICE,END,RX` and/or - `GROUP VOICE,END,TX` for timed-out group data state, because the timeout - reporter is keyed on stream/slot state and does not distinguish data headers - from voice streams. -- Deterministic probes with reporting enabled confirmed: - HBP group data to HBP target reports `DATA HEADER,DATA,RX` on the source, but - `GROUP VOICE,START,TX` on the target; after timeout the source gets - `GROUP VOICE,END,RX` and the target gets `GROUP VOICE,END,TX`. -- OBP group data reports `GROUP VOICE,START,RX` on the OBP source and - `GROUP VOICE,START,TX` on the target. - -### Assumptions To Validate - -- Group-addressed data routing may be intentional, but report events should not - identify data packets as voice calls. -- Consumers of bridge reports expect event names and categories to describe the - packet class accurately enough to avoid showing phantom voice calls. -- If a group data header creates temporary stream/slot state, timeout reporting - should either use data-specific end events or avoid voice end events for that - data-only state. - -### Unresolved Questions - -- What report event names should be used for group-addressed data TX and OBP RX? - Possible names include `GROUP DATA HEADER,DATA,TX/RX` or reusing - `DATA HEADER,DATA,TX/RX`. -- Should timeout cleanup emit a data-specific end event for group data, or - should data headers avoid creating reportable voice lifecycle state? - -### Protocol-Sensitive Areas - -- This is reporting and stream/slot observability, not packet forwarding or - payload mutation. -- Group-addressed data is valid DMR traffic and may intentionally route over - existing TG bridges. - -### Inferred Invariants - -- Routing a packet over a TG bridge does not imply the report event should be - `GROUP VOICE`. -- Voice lifecycle reports should only describe AMBE voice-like streams, not - packet-oriented data headers. - -### Validation Result - -- User agreed this should change, with the caveat that the dashboard may have - limitations and will need later compatibility testing. -- Added `group_data_event_name()` for report classification of group/vcsbk data - events. -- HBP and OBP group data headers now report `DATA HEADER,DATA,RX/TX` instead of - `GROUP VOICE,START,RX/TX`. -- HBP slot state and OBP stream state now track data-only report state so - `stream_trimmer_loop()` suppresses `GROUP VOICE,END,RX/TX` for data-only - timeouts. -- Deterministic coverage verifies HBP and OBP group data reporting does not - emit `GROUP VOICE` events, and that normal HBP group voice still emits voice - start/end lifecycle reports. - -### Remaining Risk - -- The dashboard is the primary report consumer and may assume the older - `GROUP VOICE` event names. Dashboard compatibility remains to be tested - outside this codebase. - -## HBP VCSBK Data RX Duplicate Reports - -### Findings - -- HBP `routerHBP.dmrd_received()` handles `_call_type == 'vcsbk'` in the group - call/data path. -- On a new VCSBK stream, the first `_call_type != 'group'` branch logs `*VCSBK*` - and emits `OTHER DATA,DATA,RX`. -- Immediately afterwards, the dedicated VCSBK block handler emits a second, - more specific report for `_dtype_vseq == 7` or `_dtype_vseq == 8`: - `VCSBK 1/2 DATA BLOCK,DATA,RX` or `VCSBK 3/4 DATA BLOCK,DATA,RX`. -- Deterministic probes confirmed HBP VCSBK dtype 7 and dtype 8 each produce two - RX report events on the source system. -- The target-side report is already specific (`VCSBK 1/2 DATA BLOCK,DATA,TX` or - `VCSBK 3/4 DATA BLOCK,DATA,TX`) because `to_target()` uses - `group_data_event_name()`. -- OBP-originated VCSBK dtype 7 and dtype 8 now produce only the specific RX - event and the specific TX event. - -### Assumptions To Validate - -- `OTHER DATA,DATA,RX` is intended as a fallback for VCSBK/other data types that - do not have a more specific report name. -- HBP VCSBK dtype 7/8 should emit only the specific VCSBK block RX event, not - both a generic and specific data event. -- Removing the duplicate generic HBP source report is reporting-only and should - not alter routing, packet bytes, or bridge activation behavior. - -### Unresolved Questions - -- Should any other VCSBK dtype values continue to report as `OTHER DATA,DATA,RX` - on first receipt? - -### Protocol-Sensitive Areas - -- VCSBK data block classification is protocol/data reporting, not AMBE voice - lifecycle. -- This change would affect dashboard/report consumers but not DMR payload - forwarding. - -### Inferred Invariants - -- A single received packet should normally produce one RX report event for its - packet class. -- Specific data report names should take precedence over generic fallback names. - -### Validation Result - -- User agreed that specific VCSBK events should take precedence. -- HBP `OTHER DATA,DATA,RX` fallback now only emits when - `group_data_event_name()` has no specific event name. -- Deterministic coverage verifies HBP VCSBK dtype 7 emits the specific - `VCSBK 1/2 DATA BLOCK,DATA,RX/TX` events without a generic RX duplicate. -- Deterministic coverage also verifies an unknown VCSBK dtype still emits - `OTHER DATA,DATA,RX`. - -## Unknown VCSBK Reported As Group Voice - -### Findings - -- Unknown HBP VCSBK data types currently emit `OTHER DATA,DATA,RX` on the - source, but because `group_data_event_name()` returns `None` for unknown VCSBK - values, the same packet is treated as voice-like for target and timeout - reporting. -- Deterministic probe for HBP `_call_type == 'vcsbk'` and `_dtype_vseq == 5` - produced source reports: - `OTHER DATA,DATA,RX` followed by `GROUP VOICE,END,RX` on timeout. -- The target for the same HBP packet produced `GROUP VOICE,START,TX` followed by - `GROUP VOICE,END,TX` on timeout. -- OBP-originated unknown VCSBK currently produces only `GROUP VOICE,START,RX` on - source and `GROUP VOICE,START,TX` on target, followed by voice timeout events. -- This is parallel to the group-data reporting issue, but applies to generic - VCSBK fallback data rather than known VCSBK block types. - -### Assumptions To Validate - -- `vcsbk` packets are data/control signalling for report classification, even - when `_dtype_vseq` is not one of the known block values currently named by the - code. -- Unknown VCSBK types should use `OTHER DATA,DATA,RX/TX` instead of any - `GROUP VOICE` lifecycle event. -- Timeout cleanup should suppress `GROUP VOICE,END,RX/TX` for unknown VCSBK - state just as it does for known group/vcsbk data. - -### Unresolved Questions - -- Are there any VCSBK `_dtype_vseq` values that dashboard/report consumers - intentionally expect to appear as `GROUP VOICE`? - -### Protocol-Sensitive Areas - -- VCSBK is a signalling/data classification issue; packet bytes and routing - should remain unchanged. -- Dashboard compatibility remains a risk for report event names. - -### Inferred Invariants - -- Unknown data/control packet types should fall back to generic data reporting, - not voice lifecycle reporting. -- `group_data_event_name()` currently doubles as both the event-name selector - and the data-vs-voice lifecycle classifier; unknown VCSBK exposes that - coupling. - -### Specification Check - -- ETSI TS 102 361-1 V2.6.1 separates voice burst format from data/control burst - format. Voice bursts carry vocoder payload plus either sync or embedded - signalling in the centre field. -- The embedded signalling defined for voice bursts is LC, RC, privacy-related - signalling, or null embedded message. This is not a CSBK/data continuation - Slot Type burst. -- Data/control bursts use the general data burst format and contain a Slot Type - PDU whose Data Type defines the 196 information bits. -- The Data Type table lists CSBK, Data Header, Rate 1/2 Data Continuation, and - Rate 3/4 Data Continuation as data/control burst types. -- Therefore, the packets currently classified by the HomeBrew bit parser as - `vcsbk`/data-sync data types should not be reported as part of a voice stream. - Unknown VCSBK data types should fall back to generic data/control reporting, - not `GROUP VOICE` lifecycle reporting. - -### Validation Result - -- User approved the fix after the specification check. -- Added `is_group_data_control()` to classify group/vcsbk data-control packets - independently from their specific report event name. -- Added `group_data_report_name()` so known group/vcsbk data uses the specific - event name and unknown VCSBK falls back to `OTHER DATA`. -- HBP and OBP unknown VCSBK now emit `OTHER DATA,DATA,RX/TX` and suppress - `GROUP VOICE` timeout lifecycle events. -- Deterministic coverage verifies HBP and OBP unknown VCSBK fallback reporting - produces no `GROUP VOICE` events. - -## OBP Unit Data Loop-Control Zero-Division - -### Findings - -- `routerOBP.dmrd_received()` unit-data handling creates or updates - `self.STATUS[_stream_id]`, increments `packets`, then performs OBP - first-source loop-control selection immediately in the same receive path. -- If another OBP system has the earlier `1ST` timestamp for the same - `_stream_id` and `_dst_id`, the losing source enters the `self._system != fi` - branch. -- That branch calculates `call_duration = pkt_time - - self.STATUS[_stream_id]['START']`, initializes `packet_rate = 0`, but then - divides by `call_duration` whenever `packets` exists: - `packet_rate = self.STATUS[_stream_id]['packets'] / call_duration`. -- Deterministic probe: inject the same OBP unit data packet with the same stream - ID into `OBP-1`, then into `OBP-2` without advancing the harness clock. The - second injection raises `ZeroDivisionError` at the packet-rate calculation. -- The HBP/OBP group loop-control path has a similar unguarded packet-rate - calculation, but the immediate two-source group probe did not crash because - that path only reaches the calculation after the stream already exists on the - receiving OBP source. - -### Assumptions To Validate - -- A zero-duration packet-rate sample should be reported as `0` rather than - crashing packet processing. -- Loop-control should still ignore the later source and update `LAST` as it - does today. -- This is a logging/diagnostic calculation; guarding it should not change - routing, packet bytes, or first-source selection behavior. - -### Unresolved Questions - -- Should the same duration guard be applied to all packet-rate calculations in - loop-control/rate-drop paths, including the OBP group path and HBP group - rate-drop path, as a defensive cleanup? - -### Protocol-Sensitive Areas - -- This is loop-control diagnostics for data forwarding, not protocol mutation. -- The first-source `1ST` selection remains the source-quench/loop-control - behavior under review. - -### Inferred Invariants - -- Packet processing must not fail because multiple packets share the same - timestamp. -- Diagnostic packet-rate calculations must be optional and guarded. - -### Validation Result - -- User approved the zero-duration guard. -- Added `call_duration` truthiness checks before calculating diagnostic - `packet_rate` in OBP unit-data and OBP group loop-control ignore paths. -- Deterministic coverage reproduces the same-timestamp two-OBP unit-data case - and verifies the later source is loop-controlled without raising. - -## OBP Group Packet Rate Drop Uses Absolute Start Time - -### Findings - -- `routerOBP.dmrd_received()` group/vcsbk packet-rate protection checks - `self.STATUS[_stream_id]['packets'] > 18` and then divides packet count by - `self.STATUS[_stream_id]['START']`. -- `START` is an absolute timestamp captured from `pkt_time`, not an elapsed - duration. -- With normal epoch-like times, `packets / START` is effectively zero, so the - `> 25` packet-rate threshold will not fire. -- HBP `routerHBP.dmrd_received()` has the analogous rate-drop check, but divides - by elapsed time: `pkt_time - self.STATUS[_slot]['RX_START']`. -- Deterministic probe: 19 OBP group packets over about 0.095 seconds did not - call `proxy_BadPeer()`. The equivalent HBP probe logged `RATE DROP` and set - the slot `LAST` field. -- `hblink.HBSYSTEM.proxy_BadPeer()` iterates `_peers` and emits `PRBL` proxy - blacklist packets for connected HBP client/repeater peers. -- `hotspot_proxy_v2.py` consumes `PRBL` by looking up the peer in `peerTrack` - and blacklisting the tracked client source host. -- `routerOBP` is an OpenBridge peer system, not a hotspot proxy client session; - it does not have a valid HBP `_peers` set for this purpose and should not use - proxy client blacklisting as its OBP/FBP packet-rate response. -- If the OBP elapsed-time divisor is corrected while leaving - `self.proxy_BadPeer()` in place, the rate-drop path is likely to fail at - runtime rather than cleanly dropping/controlling the offending OBP stream. -- User clarified that using the proxy as an IP-level block point can be - deliberate: the proxy can install blacklist entries into iptables, and a - malicious flood from one IP may need to be blocked for both client and OBP - traffic. -- The remaining concern is therefore not the policy of using the proxy as a - blanket block point; it is that `routerOBP` inherits from `OPENBRIDGE`, while - `proxy_BadPeer()` is only defined on `HBSYSTEM`, and OBP - `dmrd_received()` is not currently passed the source `_sockaddr`. - -### Assumptions To Validate - -- OBP group/vcsbk rate-drop should use elapsed stream duration: - `pkt_time - self.STATUS[_stream_id]['START']`. -- A zero or near-zero elapsed duration should not crash; if the threshold cannot - be evaluated safely, the packet should not be rate-dropped solely by the - diagnostic calculation. -- The existing OBP enforcement policy of asking the proxy to blacklist a flood - source may be intentional and operationally useful. -- The current OBP call site still needs validation because it calls an HBP-only - helper and does not have the inbound OBP source socket address in - `routerOBP.dmrd_received()`. -- The intended OBP/FBP response should be either local packet/stream drop only, - local drop plus enhanced-OBP source quench, or local drop plus a correctly - targeted proxy blacklist request. - -### Unresolved Questions - -- Should the OBP rate-drop response send enhanced-OBP BCSQ source quench when - `ENHANCED_OBP` is enabled? -- If proxy blacklisting is retained for OBP floods, should the blacklist target - be the validated inbound `_sockaddr`, the configured `TARGET_SOCK`, or a - separate proxy-control address? -- Should the threshold stay at the existing 25 packets/second over more than 18 - packets as a crash-protection guard, or should it be configurable later? - -### Protocol-Sensitive Areas - -- This is packet-rate control and bad-peer handling, not packet mutation. -- Changing the divisor can make an existing protection effective, which may - alter operational behavior under bursty traffic. -- `proxy_BadPeer()` currently belongs to the HBP/hotspot proxy control plane. - Reusing the proxy as a shared IP block point may be valid, but OBP needs an - explicit, correctly targeted path rather than an HBP-only method call. -- BCSQ is an enhanced-OBP source-quench mechanism; using it here would be a - protocol-visible overload signal, not just local loop protection. - -### Inferred Invariants - -- Packet-rate thresholds must be calculated against elapsed stream duration, not - absolute timestamps. -- Rate-limit calculations must guard zero elapsed duration. -- OBP/FBP overload handling must not depend on HBP client proxy state. - -### Status - -- Deferred by user. -- Do not change this path yet. The proxy/IP-blocking intent may be deliberate - because the proxy can feed blacklisted IPs into iptables and may be a useful - shared block point for malicious client and OBP floods. -- Return later with a focused review of whether `routerOBP` can correctly reach - that proxy block path as implemented, and whether the rate calculation should - be repaired without changing the intended block policy. - -## HBP Unit Data To FBP Drops BER/RSSI Metadata - -### Findings - -- `routerHBP.dmrd_received()` extracts `_ber = _data[53:54]` and - `_rssi = _data[54:55]` for inbound HBP packets. -- HBP group/voice forwarding to OpenBridge passes `_ber` and `_rssi` through - `routerHBP.to_target()` into `systems[_target['SYSTEM']].send_system(...)`. -- HBP unit-data forwarding uses `routerHBP.sendDataToOBP()`, whose signature - accepts `_ber` and `_rssi`, and then passes them into - `systems[_target].send_system(...)`. -- The two HBP unit-data call sites pass only `_source_rptr` as the positional - argument after `_slot`: - `self.sendDataToOBP(..., _bits, _slot, _source_rptr)`. -- Because of the function signature, that value is bound to `_hops`, while - `_ber` and `_rssi` use their default zero values. -- `routerHBP.sendDataToOBP()` ignores the `_hops` argument and resets - `_source_server` and `_source_rptr` internally, so the current extra - positional argument is effectively confusing but harmless. The BER/RSSI loss - is the observable issue. -- Deterministic probe with inbound HBP unit data carrying `ber=b'B'` and - `rssi=b'R'` captured an OBP send call with `ber=b'\x00'` and - `rssi=b'\x00'`. - -### Assumptions To Validate - -- For normal FBP/OpenBridge peer forwarding, HBP-originated unit-data packets - should preserve inbound BER/RSSI metadata just like HBP-originated group/voice - packets do. -- Passing BER/RSSI into `send_system()` is protocol-version safe because - `OPENBRIDGE.send_system()` decides what fields are encoded based on the target - session protocol version. -- DATA-GATEWAY remains protocol-version-specific. Passing BER/RSSI is harmless - for a v1 DATA-GATEWAY target because v1 packet construction will not encode - the FBP metadata fields. - -### Unresolved Questions - -- Should the fix touch both HBP unit-data call sites, including DATA-GATEWAY, or - only the normal FBP peer forwarding call? -- Should `routerHBP.sendDataToOBP()` drop the unused `_hops` parameter or should - we keep the signature stable and use keyword arguments at the call sites? - -### Protocol-Sensitive Areas - -- This is transport/session metadata, not DMR payload mutation. -- Protocol option order must continue to match the protocol version actually - used by the target OpenBridge session. -- DATA-GATEWAY is not FBP by design; changes must not reinterpret it as FBP. - -### Inferred Invariants - -- DMR payload bytes must be preserved unless intentionally rewritten. -- HBP BER/RSSI metadata should not be silently zeroed when forwarding to a - protocol version that can carry it. -- Positional metadata arguments are fragile in these paths; keyword arguments - are safer for reviewable fixes. - -### Validation Result - -- User confirmed this is intentional future consistency work that was not - completed in the data path. -- Severity is low because BER/RSSI metadata is not central to data routing, but - preserving it should be done for consistency with the voice/group path. -- User confirmed DATA-GATEWAY may or may not be obsolete, but should remain in - place for now. -- Updated the HBP unit-data DATA-GATEWAY and normal OpenBridge/FBP call sites to - pass `_ber` and `_rssi` as keyword arguments, avoiding the previous positional - `_source_rptr` confusion. -- Added deterministic coverage proving HBP unit data with nonzero BER/RSSI is - forwarded to an OpenBridge target with that send metadata preserved while the - captured DMRD packet bytes remain metadata-free at the deterministic capture - point. - -## HBP Group/VCSBK Rate Drop Zero-Division - -### Findings - -- `routerHBP.dmrd_received()` handles group and VCSBK packets on a per-slot - stream state. -- On a new group/VCSBK stream it sets `self.STATUS[_slot]['RX_START'] = - pkt_time`. -- Later in the same branch, before duplicate/out-of-order filtering, the packet - rate guard checks: - `self.STATUS[_slot]['packets'] / (pkt_time - self.STATUS[_slot]['RX_START'])`. -- If many packets are processed with the same `pkt_time` as the stream start, - the elapsed duration is zero and this branch raises `ZeroDivisionError`. -- Deterministic probe: injecting 19 same-timestamp HBP group data headers on the - same stream raises `ZeroDivisionError: division by zero`. -- The final call-end packet-rate logging path already guards zero elapsed - duration before dividing, and the earlier OBP loop-control diagnostic path was - already fixed to do the same. - -### Assumptions To Validate - -- The HBP packet-rate guard is intended to protect FreeDMR from excessive packet - rate, including accidental loop/flood conditions, not to crash when packets - arrive inside one timestamp quantum. -- If elapsed duration is zero, rate cannot be evaluated safely; the packet - should continue through existing duplicate/drop logic rather than rate-drop - solely from an undefined calculation. -- The existing HBP rate-drop policy should remain unchanged when elapsed - duration is nonzero: more than 18 packets and more than 25 packets/second - logs `RATE DROP`, updates `LAST`, and returns. - -### Unresolved Questions - -- Should extremely small nonzero durations be clamped to a minimum interval, or - is a simple zero guard sufficient for now? - -### Protocol-Sensitive Areas - -- This is local overload protection, not DMR payload mutation. -- HBP traffic has a practical slot-cadence limit, but test harnesses and bursty - runtime scheduling can still produce same-timestamp packet processing. - -### Inferred Invariants - -- Packet-rate calculations must never divide by zero. -- Existing HBP rate-drop behavior should be preserved for measurable elapsed - durations. -- Duplicate/out-of-order filtering should still run when rate cannot be - computed safely. - -### Validation Result - -- User approved the zero-duration guard. -- Added a `call_duration` guard to the HBP group/VCSBK rate-drop calculation. -- Added deterministic coverage for 19 same-timestamp HBP group data headers on - one stream; the path no longer raises and stream state remains intact. - -## OBP Unit Data To FBP v5+ Drops Source Repeater Metadata - -### Findings - -- `routerOBP.sendDataToOBP()` accepts `_source_rptr` and passes it to the target - OpenBridge `send_system()` call: - `systems[_target].send_system(..., _hops, _ber, _rssi, _source_server, - _source_rptr)`. -- `routerOBP.dmrd_received()` receives `_source_rptr` as decoded metadata from - `OPENBRIDGE.datagramReceived()` only for FBP protocol versions above 4. -- `OPENBRIDGE.send_system()` serializes source server for FBP v4 and above, but - serializes source repeater only when the target OpenBridge session version is - above 4. -- The normal OBP-to-OBP unit-data forwarding call for protocol versions greater - than 1 passes `_hops`, `_source_server`, `_ber`, and `_rssi`, but omits - `_source_rptr`. -- Source server is preserved by this path. The observed loss is source repeater - metadata for target sessions whose version can carry it. -- For target sessions with version 4 or lower, passing `_source_rptr` would not - change serialized packets because `OPENBRIDGE.send_system()` does not encode - source repeater for those versions. -- Deterministic probe: OBP-1 unit data to OBP-2 with - `hops=b'\x05'`, source server `7654321`, BER `b'B'`, RSSI `b'R'`, and source - repeater `1234567` captured the OBP-2 send metadata as: - hops preserved, source server preserved, BER/RSSI preserved, but - `source_rptr=b'\x00\x00\x00\x00'`. - -### Assumptions To Validate - -- For normal FBP/OpenBridge peer forwarding, OBP-originated unit-data packets - should preserve `_source_rptr` only when the target session protocol version - supports it. Passing it into `send_system()` is expected to be safe because - `send_system()` already gates serialization by target `VER`. -- This is distinct from the DATA-GATEWAY path. DATA-GATEWAY remains a - protocol-v1 SMS/GPS path and should not be evaluated as FBP metadata - forwarding. -- Using keyword arguments for the normal OBP-to-OBP unit-data forwarding call is - the smallest safe fix and avoids repeating positional metadata mistakes. - -### Unresolved Questions - -- Should DATA-GATEWAY remain unchanged exactly as currently implemented, despite - its positional argument oddity, until a separate protocol-v1 review is done? - -### Protocol-Sensitive Areas - -- Source repeater is transport/session metadata, not DMR payload mutation. -- Protocol options and order must match the protocol version in use for the - target session. -- FBP metadata preservation should not imply DATA-GATEWAY is FBP. -- OBP v1 and lower FBP versions do not support source repeater metadata. - -### Inferred Invariants - -- Normal FBP v5+ forwarding should preserve source repeater metadata it receives - unless production code intentionally rewrites it. -- Source server preservation is already present in this unit-data path. -- DATA-GATEWAY protocol-v1 behavior should remain isolated from FBP metadata - expectations. - -### Validation Result - -- User confirmed this makes sense for unit data only. -- Updated the normal OBP-to-OBP unit-data forwarding call to pass - `_source_rptr` through to `routerOBP.sendDataToOBP()`. -- Left DATA-GATEWAY unchanged. -- Added deterministic coverage proving OBP-originated unit data forwarded to - another FBP peer preserves source server, source repeater, hops, BER and RSSI - send metadata. - -## OpenBridge DMRE Parser Accepts Truncated Packets Into Indexing - -### Findings - -- `OPENBRIDGE.datagramReceived()` handles `DMRE` packets by reading - `_packet[55]` to choose the embedded protocol layout. -- It then indexes fixed offsets for v5+ (`_packet[72]`, `_packet[73:89]`) or - v4 (`_packet[68]`, `_packet[69:85]`) without first checking that the datagram - is long enough for that layout. -- Deterministic parser-seam probe: injecting `b"DMRE"` into an OpenBridge system - raises `IndexError: index out of range` at `_packet[55]`. -- A truncated `DMRE` packet long enough to contain byte 55 but shorter than the - full v5+ or v4 layout also raises `IndexError` when later fixed offsets are - accessed. -- Truncated `DMRD` v1 packets do not raise in the same simple probe because the - code computes an HMAC over the available bytes and rejects them before - indexing decoded fields. The observed crash is specific to `DMRE` parsing. - -### Assumptions To Validate - -- Malformed/truncated OpenBridge/FBP UDP packets should be logged and discarded, - not allowed to raise out of `datagramReceived()`. -- The correct minimum lengths are the layouts already implied by - `OPENBRIDGE.send_system()`: 89 bytes for v5+ `DMRE`, 85 bytes for v4 `DMRE`. -- This should be implemented as a parser guard only; it should not change - routing, metadata order, HMAC/BLAKE2 verification, or packet mutation for - valid packets. - -### Unresolved Questions - -- Should short-packet logs be warning-level like existing HMAC failures, or - debug-level to avoid noisy logs under scanning/flood attempts? - -### Protocol-Sensitive Areas - -- This is the raw UDP/parser seam in `hblink.py`, before `bridge_master.py` - decoded packet handling. -- Length guards must respect the active embedded protocol version. OBP v1, - FBP v4, and FBP v5+ have different packet layouts. - -### Inferred Invariants - -- Parser code should validate minimum length before fixed-offset indexing. -- Invalid transport packets should be rejected before decoded packet handling. -- Valid packet bytes and metadata must remain unchanged. - -### Validation Result - -- User approved adding parser guards. -- Added `DMRE` length checks in `OPENBRIDGE.datagramReceived()` before reading - byte 55, and before reading the fixed v5+ or v4 metadata offsets. -- Short `DMRE` packets are logged and discarded without reaching decoded packet - handling. -- Added deterministic parser-seam coverage for packets shorter than the version - byte and packets truncated after the version byte. - -## HBP Master DMRD Parser Accepts Truncated Packets Into Indexing - -### Findings - -- `HBSYSTEM.master_datagramReceived()` handles `DMRD` packets by first slicing - `_peer_id = _data[11:15]` and validating that the peer is connected and the - source socket matches the tracked peer. -- After that peer/socket check passes, it indexes fixed fields: - `_seq = _data[4]`, `_bits = _data[15]`, and `_stream_id = _data[16:20]`. -- There is no minimum length check before those fixed-offset indexes. -- Deterministic parser-seam probe: register peer `1001` on `MASTER-A`, then - inject a 15-byte `DMRD` packet containing the matching peer ID at bytes - 11..14. The parser raises `IndexError: index out of range` at `_data[15]`. -- This is not an arbitrary unauthenticated packet path in the reproduced case; - the packet must pass the tracked peer/socket check. - -### Assumptions To Validate - -- Malformed/truncated HBP `DMRD` packets from a connected peer should be logged - and discarded, not allowed to raise out of `master_datagramReceived()`. -- The minimum safe length for HBP `DMRD` parser indexing is 20 bytes, because - the parser reads through `_data[16:20]` and decoded packet handling expects - the 53-byte DMRD header/payload plus optional BER/RSSI later. -- A conservative 20-byte parser guard is enough to prevent fixed-offset parser - exceptions while preserving existing behavior for packets that reach decoded - packet handling. - -### Unresolved Questions - -- Should malformed-packet logging be rate-limited later if noisy connected peers - send repeated short packets? - -### Protocol-Sensitive Areas - -- This is HBP UDP parser admission, before `bridge_master.py` decoded packet - routing. -- Rejecting malformed packets must not change valid packet bytes, peer tracking - or authentication behavior. - -### Inferred Invariants - -- Parser code should validate minimum length before fixed-offset indexing. -- Invalid transport packets from connected peers should be rejected before - decoded packet handling. - -### Validation Result - -- User approved the parser guard. -- Added a 53-byte minimum length check for HBP `DMRD` packets in both - `master_datagramReceived()` and `peer_datagramReceived()`. -- Short HBP `DMRD` packets are logged and discarded before peer validation - proceeds into fixed-offset packet parsing. -- Added deterministic parser-seam coverage for a short `DMRD` packet from a - connected/tracked master peer. - -## OpenBridge BCST STUN Receiver Hashes The Wrong Bytes - -### Findings - -- `OPENBRIDGE.send_bcst()` sends a Bridge Control STUN packet as: - `BCST + HMAC_SHA1(passphrase, BCST)`. -- `OPENBRIDGE.datagramReceived()` receives `BCST` by setting - `_hash = _packet[4:]`, then calculating - `HMAC_SHA1(passphrase, _packet[4:])`. -- That means the receiver compares `HMAC(BCST)` with `HMAC(HMAC(BCST))`, so a - valid packet generated by `send_bcst()` is rejected. -- Deterministic parser-seam probe: build `BCST + HMAC(passphrase, BCST)` and - inject it into an enhanced OpenBridge system. The packet logs - `BCST invalid STUN` and does not set `_STUN`. -- If the current receive check ever did pass, the trace log references `_tgid` - and `_stream_id`, but `BCST` does not define those variables in that branch. - The practical observed bug is the hash mismatch, which prevents the success - branch. - -### Assumptions To Validate - -- `BCST` receive validation should mirror `send_bcst()` and verify - `HMAC_SHA1(passphrase, BCST)`. -- A valid `BCST` should set the same stun state that the current success branch - intends to set. -- The success trace log should not reference TGID or stream ID because `BCST` - carries only the opcode plus HMAC. - -### Unresolved Questions - -- STUN currently has no observed timeout/clear path. It remains a conceptual - temporary quench mechanism and should be reviewed separately before relying on - it operationally. - -### Protocol-Sensitive Areas - -- This is enhanced OpenBridge bridge-control traffic, not DMR payload routing. -- The hash input must match the sender and must not accidentally change other - Bridge Control opcodes (`BCKA`, `BCSQ`, `BCVE`). - -### Inferred Invariants - -- Bridge Control receive validation should hash the same byte sequence the send - helper signs. -- STUN packets do not carry TGID or stream ID fields. -- A valid STUN request should set the `STUN` flag that existing OpenBridge - traffic gates already check. - -### Validation Result - -- User confirmed STUN was conceptual but requested making it internally - consistent. Intended concept: one server can tell another to temporarily stop - sending any FBP traffic. -- Updated `BCST` receive validation to verify `HMAC_SHA1(passphrase, BCST)`, - matching `send_bcst()`. -- Updated the success branch to log without TGID/stream ID fields. -- Updated the success branch to set `self._CONFIG['STUN'] = True`, matching the - existing send/receive stun gates that check for `STUN` in the global config. -- Added deterministic parser-seam coverage proving a valid generated `BCST` - sets the global `STUN` flag and no longer writes the unused `_STUN` key. - -## OpenBridge BCSQ Target TGID Key Mismatch - -### Findings -- `hblink.py`, `OPENBRIDGE.datagramReceived()`, `BCSQ` branch stores received - source-quench state in the OpenBridge peer system config as: - `_config['_bcsq'][_tgid] = _stream_id`. -- `bridge_master.py`, `routerOBP.to_target()`, checks that quench map with the - source packet destination `_dst_id`: - `(_dst_id in _target_system['_bcsq'])` and - `(_target_system['_bcsq'][_dst_id] == _stream_id)`. -- `bridge_master.py`, `routerHBP.to_target()`, uses inconsistent keys in the - equivalent check: - `(_dst_id in _target_system['_bcsq'])` but then indexes - `_target_system['_bcsq'][_target['TGID']]`. -- For same-TG bridge rules, `_dst_id == _target['TGID']`, so the bug is masked. - For cross-TG bridge rules, the membership test and lookup can disagree. If - `_dst_id` is present but `_target['TGID']` is not, the code can raise - `KeyError`; if only `_target['TGID']` is present, source quench is not applied. - -### Assumptions -- BCSQ is intended to suppress further forwarding of a specific stream to the - OpenBridge system that sent the quench. -- User clarified that the only FreeDMR talkgroup rewrite is for dial-a-TG. In - that case, the HBP-side source is local TG9/TS2 and the OpenBridge target sees - the selected reflector TG. -- User clarified BCSQ semantics: source quench asks a peer to stop sending any - more packets for a given stream ID on a given TG. It is optional; failure to - quench is not fatal and mainly costs a small amount of bandwidth/processing. -- Therefore, the severity is lower than a routing/drop bug. The important - correctness property is that any local BCSQ check must compare the stream ID - and TGID in the same namespace as the BCSQ sender intended. - -### Unresolved Questions -- Should the stream-trimmer cleanup remove BCSQ entries only after the matching - local stream expires, or should received BCSQ entries also have an independent - expiry? Current cleanup removes entries whose stored stream ID matches a - removed local OpenBridge stream. - -### Protocol-Sensitive Areas -- `BCSQ` packet format is `BCSQ + TGID(3) + stream_id(4) + HMAC`. -- BCSQ semantics interact with dial-a-TG TG rewrite logic. The optional quench - may be ineffective if FreeDMR stores a quench under one TG namespace and - checks it under another. - -### Inferred Invariants -- Source quench state must be checked with the same TGID namespace it is stored - under. -- Source quench is an optimization/control hint, not a required condition for - correctness of stream routing. -- A BCSQ match should drop forwarding only for the matching TGID and stream ID, - not all traffic to that OpenBridge target. - -### Resolution -- User confirmed that for dial-a-TG, the BCSQ TGID should be the reflector TG, - not local TG9. -- Updated `bridge_master.py`, `routerHBP.to_target()`, so HBP-to-OpenBridge - source-quench checks test and index `_target_system['_bcsq']` with - `_target['TGID']`. -- Added deterministic dial-a-TG coverage proving a TG9/TS2 HBP reflector stream - is not forwarded to an FBP target when that target has source-quenched the - reflector TG and stream ID. - -## OpenBridge STUN Has No Clear Or Expiry Path - -### Findings -- `hblink.py`, `OPENBRIDGE.datagramReceived()`, `BCST` receive branch sets the - global config flag `self._CONFIG['STUN'] = True`. -- `hblink.py`, `OPENBRIDGE.send_system()`, `OPENBRIDGE.datagramReceived()` v1 - `DMRD` path, and `OPENBRIDGE.datagramReceived()` `DMRE` path all gate traffic - with `if 'STUN' in self._CONFIG`. -- Repository-wide search finds no code path that removes `STUN`, changes it - back to false, or expires it by time. -- `BCST` has no duration field in the current packet format: - `BCST + HMAC_SHA1(passphrase, BCST)`. -- Current behavior after a valid `BCST` is therefore effectively permanent - until process restart or external mutation of the in-memory config. - -### Assumptions To Validate -- User previously described the intended concept as one server asking another to - temporarily stop sending any FBP traffic. -- User clarified the likely intended design: a sysop/API operation would - un-stun the link. In that design, lack of automatic expiry is not necessarily - a bug; the missing piece is the operator/API clear path. -- Because `BCST` has no duration field, automatic expiry would be a local policy - change rather than protocol behavior. -- This should remain a global FBP traffic gate, not per-TG or per-stream; BCSQ - already covers the per-TG/per-stream source-quench case. - -### Unresolved Questions -- Should the eventual sysop/API un-stun operation clear global `STUN` or a - per-link/per-peer stun state if the feature is later scoped more narrowly? -- Should STUN block only outbound FBP sends, or also inbound FBP processing? The - current gates block both outbound `send_system()` and inbound DMR packet - handling. -- Should the flag remain global across all OpenBridge systems, or should it be - scoped to the peer that sent `BCST`? The current code uses global - `self._CONFIG['STUN']`. - -### Protocol-Sensitive Areas -- `BCST` is enhanced OpenBridge bridge-control traffic and currently carries no - duration or peer identity beyond the authenticated UDP source and configured - passphrase. -- Changing the traffic gate from a boolean/existence check to a timestamp must - preserve the current "block all FBP traffic while active" behavior if that is - confirmed. - -### Inferred Invariants -- STUN is distinct from BCSQ: STUN is all FBP traffic, BCSQ is one stream on one - TG. -- Valid `BCST` should never mutate DMR payload bytes. -- If STUN is operator-cleared, the missing production behavior is an explicit - management/API clear operation, not an automatic timer. - -## OBP Group Loop-Control Logs Duration As Packet Rate - -### Findings -- `bridge_master.py`, `routerOBP.dmrd_received()`, group/vcsbk loop-control - branch calculates: - `call_duration = pkt_time - self.STATUS[_stream_id]['START']`. -- It then calculates a guarded `packet_rate`: - `packet_rate = self.STATUS[_stream_id]['packets'] / call_duration`. -- The debug log string says `PACKET RATE %0.2f/s`, but the argument passed is - `call_duration`, not `packet_rate`. -- The analogous OBP unit-data loop-control branch directly above passes - `packet_rate` correctly. - -### Assumptions To Validate -- The log is intended to report packet rate, not duration, because the message - text says `PACKET RATE` and the code already computes `packet_rate`. -- This is a diagnostics-only bug. It does not affect packet routing, mutation, - source selection, BCSQ, or rate-drop enforcement. -- User clarified that data calls do not have a meaningful packet rate in this - sense because each packet is classed as its own stream; packet-rate assertions - should use voice stream fixtures, not data packet fixtures. - -### Unresolved Questions -- No protocol question identified. This is local logging correctness. - -### Protocol-Sensitive Areas -- None beyond avoiding any change to packet handling behavior. - -### Inferred Invariants -- Diagnostic logs should report the metric named by the log message. -- Fixing the argument should not change any control-flow decisions. - -### Resolution -- Updated the OBP group/vcsbk loop-control debug log to pass `packet_rate` - instead of `call_duration`. -- Added deterministic coverage that creates a two-second loop-controlled OBP - group voice stream and verifies the log reports the calculated `0.50/s` - packet rate, not the two-second duration as `2.00/s`. - -## OBP Group Data RX Log Says Call Start - -### Findings -- `bridge_master.py`, `routerOBP.dmrd_received()`, group/vcsbk new-stream branch - computes `_data_control = is_group_data_control(_call_type, _dtype_vseq)` and - reports data packets correctly with `DATA HEADER,DATA,RX`, - `VCSBK 1/2 DATA BLOCK,DATA,RX`, `VCSBK 3/4 DATA BLOCK,DATA,RX`, or - `OTHER DATA,DATA,RX`. -- The same branch always emits an info log labelled `*CALL START*` before the - report decision, even when `_data_control` is true. -- The HBP group path distinguishes group data header logging from voice start: - `_dtype_vseq == 6` logs `*DATA HEADER*`, while voice-like group packets log - `*CALL START*`. -- User clarified that data packets are packet-oriented and should not be treated - as voice streams for rate/lifecycle semantics. - -### Assumptions To Validate -- OBP group data logs should match the already-correct report classification and - should not say `*CALL START*` for data-control packets. -- This is diagnostics/logging only; reports, routing, packet bytes, stream state - and lifecycle suppression are already handled separately. - -### Unresolved Questions -- Should OBP group data logs include source server/hops metadata exactly like the - current `CALL START` log, or should they match the simpler HBP data-header log - shape? - -### Protocol-Sensitive Areas -- This must not change `DATA_STREAM` classification, reporting events, packet - forwarding, or LC construction. - -### Inferred Invariants -- Logs should not label DMR data-control packets as voice call lifecycle events. -- OBP and HBP logging should use consistent data-vs-voice terminology where the - packet classification is already known. - -### Resolution -- User confirmed the change should be made, with the caveat that dashboard - consumers may be sensitive to live report/socket event text. -- Updated the OBP group/vcsbk RX info log to use the already-computed data event - label for data-control packets and `CALL START` only for voice-like packets. -- Added deterministic coverage proving an OBP group data header logs - `*DATA HEADER*` and no longer logs `*CALL START*`; existing report-socket - assertions still verify `DATA HEADER,DATA,RX/TX` payloads. - -## Raw Parser May Not Classify VCSBK 3/4 Data Blocks - -### Findings -- `bridge_master.py` has explicit group/vcsbk report handling for - `_call_type == 'vcsbk'` and `_dtype_vseq == 8`, labelled - `VCSBK 3/4 DATA BLOCK`. -- The raw packet parsers in `hblink.py` derive `_call_type` from the DMRD bits - with: - `elif (_bits & 0x23) == 0x23: _call_type = 'vcsbk'`. -- For a data-sync packet with `_dtype_vseq == 8`, the low nibble is `0x8`; with - `HBPF_DATA_SYNC` in bits 4..5, `_bits & 0x23` evaluates to `0x20`, not - `0x23`. That means the raw parser classifies it as `group`, not `vcsbk`. -- The deterministic in-process harness can inject `call_type='vcsbk'` directly, - so current VCSBK 3/4 report tests may cover `bridge_master.py` behavior without - proving a real UDP/HBP or UDP/OBP packet can reach that branch. -- Unit data handling is not affected by this specific observation because unit - data is classified first by the unit bit and then handles dtype 8 inside the - unit-data branch. - -### Assumptions To Validate -- If FreeDMR expects group-addressed VCSBK 3/4 data blocks to arrive from real - HBP/OBP packets, the raw parser should classify the relevant bit pattern as - `vcsbk` so the existing report/routing classification can run. -- The parser predicate may have been intentionally written for only certain CSBK - or VCSBK bit patterns; changing it without confirming HomeBrew/DMR bit - semantics could misclassify normal group traffic. -- This is parser classification, not packet mutation. -- User raised an operational risk: earlier hackish data-over-OBP/FBP changes may - depend on this classification. A naive fix could affect whether data traverses - FBP or whether unknown group-addressed data creates local bridge state. - -### Unresolved Questions -- Is `_dtype_vseq == 8` genuinely reachable for group/vcsbk packets in the - HomeBrew/OpenBridge bit layout, or is the `VCSBK 3/4 DATA BLOCK` branch legacy - or only relevant to direct/internal calls? -- Should the deterministic harness include a raw-datagram parser test for VCSBK - 3/4 once the intended bit pattern is confirmed? - -### Protocol-Sensitive Areas -- This is at the HBP/OBP raw DMRD parser seam in `hblink.py`. -- Incorrectly broadening the `vcsbk` predicate could change voice/group/data - classification before packets reach `bridge_master.py`. -- Existing active bridge forwarding likely still works for both `group` and - `vcsbk` because `bridge_master.py` routes both through the group/vcsbk path. - The more sensitive behavior is automatic unknown-TG bridge creation, which is - currently restricted to `_call_type == 'group'`. - -### Inferred Invariants -- Parser-level `_call_type` classification should make production-reachable all - packet classes that `bridge_master.py` intentionally handles. -- In-process direct injection tests should not be mistaken for raw UDP parser - coverage when the question is bit-level classification. -- Any parser classification change must prove that existing data-over-FBP - traversal still works, including the local group-addressed data behavior - previously left intentional. - -### Reclassification -- User clarified that the `unit`/`group` distinction is fundamentally the DMR - addressing mode: private/unit-unit bit set, or group/TG bit unset. Data/control - subtype is orthogonal to that addressing mode. -- Therefore, changing the raw parser to classify group-addressed data/control - packets as `vcsbk` would conflate addressing mode with data subtype and could - break intentional data-over-FBP behavior. -- Leave raw parser `_call_type` classification unchanged. Data/control handling - should be derived from `_frame_type` / `_dtype_vseq` helpers while preserving - group-vs-unit addressing. - -## Group-Addressed Data Continuation Blocks Reported As Voice - -### Findings -- `bridge_master.py`, `group_data_event_name()`, treats group-addressed - `_dtype_vseq == 6` as `DATA HEADER`. -- The same helper only treats `_dtype_vseq == 7` and `_dtype_vseq == 8` as data - when `_call_type == 'vcsbk'`. -- User clarified that `_call_type == 'group'` means group/TG addressing, not - voice. A group-addressed packet can still carry DMR data/control subtypes. -- Therefore, a group-addressed data continuation block that reaches - `bridge_master.py` as `_call_type == 'group'` and `_dtype_vseq == 7` or `8` - is currently classified as voice-like by `is_group_data_control()`. -- Consequences would match the earlier group-data report bug: RX/TX reports can - use `GROUP VOICE`, timeout cleanup can emit voice lifecycle events, and logs - can say `CALL START` for a packet-oriented data block. - -### Assumptions To Validate -- Group-addressed data continuation blocks with dtype 7 or 8 should be classified - as data/control for reporting and timeout lifecycle, while preserving - `_call_type == 'group'` for TG-addressed routing and bridge creation behavior. -- Existing event labels `VCSBK 1/2 DATA BLOCK` and `VCSBK 3/4 DATA BLOCK` may be - legacy terminology. If the dashboard expects those names, changing labels could - be more disruptive than only broadening when they apply. -- This should not change raw parser classification or packet routing. - -### Unresolved Questions -- Should dtype 7/8 group-addressed data use the existing - `VCSBK 1/2 DATA BLOCK` / `VCSBK 3/4 DATA BLOCK` report labels for dashboard - compatibility, or should labels be renamed to more generic data continuation - terminology later? - -### Protocol-Sensitive Areas -- This is data-vs-voice reporting/lifecycle classification, not DMR payload - mutation. -- It must preserve the group-addressing behavior that lets data traverse - TG/FBP bridge paths. - -### Inferred Invariants -- `_call_type == 'group'` should not imply voice. -- Data/control classification should be based on `_frame_type` and - `_dtype_vseq`, without breaking group-vs-unit addressing semantics. - -### Resolution -- User approved broadening the helper-level classification. -- Updated `group_data_event_name()` so group-addressed dtype 7 and 8 packets use - the existing `VCSBK 1/2 DATA BLOCK` and `VCSBK 3/4 DATA BLOCK` data event - names. -- Raw parser `_call_type` classification remains unchanged; group-vs-unit - addressing is preserved. -- Added deterministic HBP and OBP coverage for group-addressed dtype 7/8 packets - proving they report as data and do not emit `GROUP VOICE` lifecycle events. -- Consolidated HBP known VCSBK RX reporting into the helper-driven new-stream - branch so specific VCSBK reports are still emitted once, without reintroducing - the older generic/specific duplicate report behavior. - -## Voice Path: OBP Group Stream Rate Limit Uses Absolute Start Time - -### Findings -- `bridge_master.py`, `routerOBP.dmrd_received()`, group/vcsbk stream duplicate - and rate-control branch, calculates the rate-drop predicate as - `self.STATUS[_stream_id]['packets'] / self.STATUS[_stream_id]['START'] > 25`. -- `START` is an absolute timestamp captured from `time()`, not elapsed stream - duration. In normal operation this divides by a large epoch value, so the - calculated rate is effectively zero. -- The surrounding code and earlier user clarification say this guard is intended - to stop packet floods from overwhelming FreeDMR. As written, it cannot trigger - for realistic packet counts. -- HBP group voice uses elapsed duration (`pkt_time - RX_START`) for the - comparable packet-rate guard. - -### Assumptions To Validate -- The OBP/FBP guard should measure packets per elapsed stream duration, not - packets per absolute timestamp. -- The threshold should remain the existing `> 25` for now; changing policy is - separate from fixing the calculation. -- The existing `packets > 18` warm-up should remain, so short startup bursts are - not judged before enough packets have arrived. -- The deferred question about whether `proxy_BadPeer()` is the right response to - an OBP flood remains deferred; this analysis only covers the broken rate - calculation. - -### Unresolved Questions -- Confirm whether this OBP rate-drop action should still call `proxy_BadPeer()` - after the calculation is corrected, or whether that should be revisited later - as previously deferred. - -### Protocol-Sensitive Areas -- This is transport/rate-control behavior, not DMR payload parsing or mutation. -- OBP/FBP can carry multiple arbitrary streams, so this guard is only per stream - and does not represent an overall link-rate limit. - -### Inferred Invariants -- Packet-rate protection should be based on elapsed time for the specific stream. -- Fixing the denominator should not change TG routing, slot handling, LC rewrite, - or packet bytes. - -### Resolution -- User confirmed proceeding with the narrow denominator fix. -- Updated `routerOBP.dmrd_received()` so the group/vcsbk rate-drop check computes - `call_duration = pkt_time - self.STATUS[_stream_id]['START']` and divides - packet count by elapsed duration. -- Kept the existing `> 25` threshold, `packets > 18` warm-up, and - `proxy_BadPeer()` action unchanged. -- Added deterministic OBP group voice coverage proving the rate-drop path fires - for a high-rate stream when elapsed duration is short. - -## Voice LC Rewrite Applied To Data-Sync Control Packets - -### Findings -- `bridge_master.py`, `routerOBP.to_target()`, rewrites embedded LC for any - packet where `_dtype_vseq in [1,2,3,4]`. -- `bridge_master.py`, `routerHBP.to_target()`, has the same condition in both - the OpenBridge-target and HBP-target branches. -- These branches are intended for voice bursts B-E, but they do not check - `_frame_type`. -- A deterministic probe with an HBP-originated `vcsbk` packet using - `_frame_type == HBPF_DATA_SYNC` and `_dtype_vseq == 3` captured a forwarded - HBP packet whose DMR payload bytes were changed: - `000102030405060708090a0b0c0d00c122b4b2131415161718191a1b1c1d1e1f20` - instead of the original - `000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20`. -- That mutation is the embedded-LC rewrite being applied to a data-sync/control - packet, not a voice burst. -- ETSI TS 102 361-1 distinguishes voice bursts containing embedded signalling - from data/control bursts. Search result snippets from the ETSI TS 102 361-1 - PDF describe a voice burst containing embedded signalling and separately state - that data/control bursts use data SYNC or RC signalling rather than embedded - LC. - -### Assumptions To Validate -- Embedded LC rewrite should only apply to real voice burst frames, not - data-sync/control packets. -- Voice header and voice terminator rewrite remain governed by - `_frame_type == HBPF_DATA_SYNC` plus `_dtype_vseq == HBPF_SLT_VHEAD` or - `HBPF_SLT_VTERM`, because those are voice call signalling bursts. -- VCSBK/CSBK/data-control packets should preserve their DMR payload bytes while - still allowing transport/header fields such as target TG and slot bit to be - rewritten by bridge routing when that is already intentional. - -### Unresolved Questions -- Future work: evaluate carrying source embedded LC to the destination instead - of always regenerating it. Embedded LC may carry embedded GPS and talker alias - data, which FreeDMR does not currently relay. - -### Protocol-Sensitive Areas -- This is a packet mutation bug at the boundary between voice LC rewrite and - data/control forwarding. -- The fix must not disable intended TG/LC rewrite for voice calls that are mapped - from one bridge TG to another. -- It must preserve the previously discussed data-over-FBP behavior and group-vs- - unit addressing classification. - -### Inferred Invariants -- Data/control payload bytes are not embedded-LC containers and should not be - modified by voice LC rewrite code. -- Voice LC rewrite should be gated by both frame class and dtype/voice sequence. - -### Resolution -- User confirmed embedded-LC rewrite should not apply to data-sync/control - packets. -- `mk_voice.py` shows generated voice uses `HBPF_VOICE_SYNC` for burst A with - vseq `0`, and `HBPF_VOICE` for bursts B-F; embedded LC is inserted only for - bursts B-E, vseq `1..4`. -- Updated all four embedded-LC rewrite branches in `routerOBP.to_target()` and - `routerHBP.to_target()` to require `_frame_type == HBPF_VOICE` and - `_dtype_vseq in [1,2,3,4]`. -- Added deterministic payload-preservation coverage for data-sync/control - packets across HBP-to-HBP, HBP-to-FBP, FBP-to-HBP and FBP-to-FBP forwarding. - -## Private Unit Voice Timeout Reported As Group Voice - -### Findings -- `bridge_master.py`, `routerHBP.dmrd_received()`, private AMI voice branch and - private dial-a-TG branch update slot RX state (`RX_TYPE`, `RX_TGID`, - `RX_TIME`, `RX_STREAM_ID`) for unit/private calls. -- Those private branches set `self.STATUS[_slot]['VOICE_STREAM'] = _voice_call`, - but `_voice_call` is initialized to `False` and is never set to `True`. -- `VOICE_STREAM` is not initialized in `routerHBP.__init__()` and is not read by - `stream_trimmer_loop()`. -- `stream_trimmer_loop()` emits `GROUP VOICE,END,RX` for any HBP slot whose - `RX_TYPE` is not terminator and whose `RX_DATA_STREAM` flag is false. -- Therefore a private dial-a-TG/AMI unit voice packet that does not receive a - terminator can be timed out as `GROUP VOICE,END,RX`, even though there was no - matching `GROUP VOICE,START,RX`. -- Deterministic probe: after 100 seconds of idle time, inject a private unit - voice call to `235`, advance 6 seconds, and run `stream_trimmer_loop()`. The - report event is: - `GROUP VOICE,END,RX,MASTER-A,16909060,1001,3120001,1,235,100.00`. -- The duration is also wrong for the private call because private branches do not - set `RX_START` on new private streams; the timeout report used the slot's old - initialization/start value. - -### Assumptions To Validate -- Dial-a-TG and AMI private unit voice calls should not emit `GROUP VOICE` - lifecycle reports. -- HBP timeout reporting should emit `GROUP VOICE,END,RX` only for RX state that - originated from a group voice stream that would have emitted - `GROUP VOICE,START,RX`. -- Private unit calls may still mark the RF slot busy until timeout/terminator; - the issue is lifecycle reporting and duration classification, not slot - occupancy. - -### Unresolved Questions -- Should private unit voice timeout produce a different report event, or should - it simply suppress `GROUP VOICE` timeout reporting? -- Should the existing `VOICE_STREAM` key be completed and used as the explicit - trimmer gate, or should a clearer `RX_VOICE_STREAM` / `RX_GROUP_VOICE_STREAM` - key be introduced? - -### Protocol-Sensitive Areas -- This is report/lifecycle classification for HBP slot state, not packet - mutation. -- Dial-a-TG private control behavior and voice prompts should remain unchanged. - -### Inferred Invariants -- `GROUP VOICE,END,RX` should correspond to an actual group voice RX lifecycle. -- Timeout cleanup should not infer group voice from `RX_TYPE != VTERM` alone. - -### Resolution -- User approved correcting this even if the old behavior was a dashboard - workaround. -- Added explicit HBP slot state `RX_GROUP_VOICE_STREAM`, initialized false. -- Private AMI and dial-a-TG unit voice branches now mark - `RX_GROUP_VOICE_STREAM` false when they update slot RX state. -- The group/vcsbk branch marks `RX_GROUP_VOICE_STREAM` true only when the packet - is not data/control. -- `stream_trimmer_loop()` now emits `GROUP VOICE,END,RX` only when - `RX_GROUP_VOICE_STREAM` is true, then clears the flag with the timeout cleanup. -- Added deterministic coverage proving a private dial-a-TG unit voice timeout no - longer emits an unmatched `GROUP VOICE,END,RX` report. - -## HBP-To-HBP Target TX State Uses Source RX Stream Predicate - -### Findings -- `bridge_master.py`, `routerHBP.to_target()`, HBP-target branch, initializes - target TX state when `_stream_id != self.STATUS[_slot]['RX_STREAM_ID']`. -- That predicate describes whether the source HBP slot sees a new RX stream, not - whether the target HBP slot needs a new TX stream. -- The analogous `routerOBP.to_target()` HBP-target branch correctly checks the - target slot: `_target_status[_target['TS']]['TX_STREAM_ID'] != _stream_id`. -- Deterministic reproduction: - - MASTER-A sends stream `01020304` on TG123 to MASTER-B. - - After `STREAM_TO`, MASTER-C sends stream `01020305` on TG123 to MASTER-B. - - After another gap, MASTER-A sends another packet on stream `01020304`. - - The forwarded packet to MASTER-B carries stream `01020304`, but - MASTER-B's `TX_STREAM_ID` and `TX_RFS` remain `01020305` / MASTER-C. - - For a voice burst B-E packet, the forwarded DMR payload is rewritten using - MASTER-B's stale `TX_EMB_LC` generated for MASTER-C, not MASTER-A. -- This can create stale target lifecycle state, stale TX reports/durations, and - wrong embedded LC source information when a target slot has moved to another - stream while the original source later resumes the same stream id. - -### Validated Assumptions -- User clarified this interleaved/resumed-stream scenario should never happen in - normal DMR operation. DMR channel access and hang-time mechanisms should - prevent an older stream from being resumed after a different stream has - interspersed on the same target path. -- Stream IDs are generated locally from inbound call data and are not expected to - be reused in this interleaved way. - -### Unresolved Questions -- None for now. Leave this path unchanged unless a real fixture or live trace - proves valid stream interleaving occurs. - -### Protocol-Sensitive Areas -- This affects target-side stream state and LC rewrite metadata for HBP targets. -- It does not change routing eligibility or packet source/destination header - fields. - -### Inferred Invariants -- Target TX state should describe the packet currently being sent to that target. -- Source RX stream state and target TX stream state are related but independent. -- LC rewrite state must be regenerated when the target TX stream id changes. - -### Resolution -- No runtime change. Treat the deterministic reproduction as an invalid scenario, - not a confirmed production bug. -- Keep the note as a protocol-sensitive area to revisit only if recorded traffic - shows valid resumed/interleaved stream behavior. - -## OBP Target Timeout Reports Wrong Direction And TG After Dial-A-TG Rewrite - -### Findings -- `bridge_master.py`, `routerHBP.to_target()`, OpenBridge-target branch creates - target stream state with `TGID: _dst_id`, even though the outbound packet and - `GROUP VOICE,START,TX` report use `_target['TGID']`. -- `bridge_master.py`, `routerOBP.to_target()`, OpenBridge-target branch does the - same for OBP-to-OBP targets. -- `stream_trimmer_loop()` handles all OpenBridge stream timeouts as - `GROUP VOICE,END,RX` and reports `_stream['TGID']`. -- Deterministic reproduction with dial-a-TG: - - Private call links reflector `#235`. - - HBP source sends group voice on local TS2 TG9, which is forwarded to FBP - target TG235. - - OBP target reports start as: - `GROUP VOICE,START,TX,OBP-1,16909060,1001,3120001,1,235`. - - If no terminator arrives and the OBP target stream times out, the report is: - `GROUP VOICE,END,RX,OBP-1,16909060,1001,3120001,1,9,0.00`. -- The timeout event is wrong in two ways for the target-side stream: direction is - `RX` instead of `TX`, and TG is local TG9 instead of the reflector TG235 that - was sent to the FBP peer. -- Immediate terminator handling in `to_target()` already emits - `GROUP VOICE,END,TX` with `_target['TGID']`; the mismatch only affects timeout - cleanup when no terminator is observed. - -### Validated Assumptions -- User clarified that the report side intentionally reports HBP/RF-side TG9 for - dial-a-TG reflector traffic so the repeater dashboard shows TG9 active to RF. -- The FBP-visible reflector TG is not necessarily the desired TG for this - dashboard report path. - -### Unresolved Questions -- There remains broader inconsistency between immediate terminator TX reports and - timeout RX reports for target-side FBP streams, but this is not currently - treated as a bug because the dashboard policy is intentional. - -### Protocol-Sensitive Areas -- This is report/lifecycle state for OpenBridge/FBP target streams, not packet - routing or DMR payload mutation. -- BCSQ/loop-control logic also reads OpenBridge stream `TGID`; changing that key - directly could affect source-quench behavior. A separate report TG field may be - safer. - -### Inferred Invariants -- For dial-a-TG dashboard reporting, RF-side TG9 activity is a first-class - observability requirement. - -### Resolution -- No runtime change. Treat this as intentional reporting policy for dashboard - compatibility rather than a confirmed bug. - -## Generated Voice First Packet Does Not Mark HBP TX Activity - -### Findings -- `bridge_master.py`, `sendVoicePacket()`, handles generated voice packets for - dial-a-TG prompts, disconnected announcements, on-demand AMBE files and voice - idents. -- On the first packet of a generated stream, it creates - `systems[system].STATUS[_stream_id]` and sets `_slot['TX_TGID']`, but it does - not update `_slot['TX_TIME']`, `_slot['TX_TYPE']`, `_slot['TX_STREAM_ID']`, - `_slot['TX_START']`, or `_slot['TX_RFS']`. -- `_slot['TX_TIME']` is only updated in the `else` branch for subsequent packets. -- Deterministic probe after 100 seconds idle: - - Before first generated packet, TS2 state was - `TX_TYPE=HBPF_SLT_VTERM`, `TX_TIME=1700000000.0`, - `TX_STREAM_ID=b'\\x00'`, `TX_TGID=0`. - - After first generated packet, state was still - `TX_TYPE=HBPF_SLT_VTERM`, `TX_TIME=1700000000.0`, - `TX_STREAM_ID=b'\\x00'`, but `TX_TGID=9`. -- Because routing and idle checks use `TX_TIME` and `TX_TYPE`, a generated prompt - can look idle at stream start. -- Second deterministic probe: after sending the first generated prompt packet on - MASTER-B TS2 TG9, an inbound group voice packet from MASTER-A to MASTER-B was - still routed immediately to MASTER-B, producing overlapping outbound traffic. - -### Assumptions To Validate -- User clarified that voice prompts should ideally be interruptible. At minimum, - after a prompt has finished, a real voice stream should not be immediately - blocked as "busy". -- Therefore, blindly updating `_slot['TX_TIME']` for generated prompt packets is - not an acceptable fix by itself because normal group-hangtime checks may treat - the prompt as recent TX activity and block post-prompt routing. -- Generated prompts should be treated as low-priority local traffic relative to - real RF/network voice. - -### Unresolved Questions -- Should generated prompts/idents be represented as full HBP TX voice lifecycle - state for dashboard/report purposes, or should they only update `TX_TIME` to - protect the RF slot without creating extra report events? -- What exact interruption trigger should stop a prompt: any RX activity on that - HBP system/slot, any TX routing state change on that slot, or only a higher - priority group/private voice stream? - -### Protocol-Sensitive Areas -- This is generated local voice scheduling/state, not inbound packet parsing. -- It affects collision avoidance between generated prompts and bridged traffic. - -### Inferred Invariants -- A locally generated voice prompt must not permanently or hangtime-block - subsequent real voice traffic. -- Prompt collision avoidance should prefer stopping the prompt over blocking RF - or bridge traffic where practical. -- Report visibility for generated prompts is separate from collision avoidance. - -### Late Entry Sanity Check - -#### Findings -- ETSI TS 102 361-1 describes DMR late entry by carrying LC information in the - embedded field of voice bursts; a six-burst voice superframe uses one sync - burst, four embedded-signalling bursts, and one null/other embedded burst. -- `mk_voice.py` builds generated voice with `bptc.encode_emblc(LC)` and places - the LC fragments into bursts B-E. Burst A carries voice sync; burst F carries - null embedded LC. -- `bridge_master.py` builds `EMB_LC` / `TX_EMB_LC` for target streams and - rewrites only `HBPF_VOICE` bursts with vseq 1-4, corresponding to embedded LC - fragments B-E. The earlier guard now avoids mutating data-sync/control - payloads. -- Therefore, if a real voice stream interrupts a generated prompt and is then - forwarded through the normal routing path, receivers that missed the original - voice header should be able to late-enter once they receive a complete - embedded-LC sequence. - -#### Assumptions -- Generated prompts are local low-priority streams and may be cancelled in favor - of real RF/network voice. -- Interrupting a prompt should resume normal bridge routing for the real stream; - it should not synthesize replacement voice frames outside the existing - header/embedded-LC machinery. - -#### Unresolved Questions -- Whether prompt cancellation should send a terminator for the abandoned prompt - before real traffic starts. This may be cleaner semantically, but it could also - delay the higher-priority stream and defeat interruption. -- Whether generated prompt lifecycle state should be visible to dashboards as a - full TX stream, independent of collision avoidance. - -#### Protocol-Sensitive Areas -- Late-entry behavior depends on preserving or correctly regenerating embedded LC - over bursts B-E. Any prompt-interruption fix must not remove the current - `EMB_LC` / `TX_EMB_LC` handling. -- If the first forwarded real packet is mid-superframe, receiving radios may - need to wait until the next complete embedded LC set before identifying the - call. This is expected late-entry behavior, not necessarily a bridge bug. - -#### Inferred Invariants -- Real voice streams must remain the source of truth for LC/header state after a - prompt is cancelled. -- Prompt cancellation should stop local generated packet production; the - following real stream should use existing voice header and late-entry LC - processing. - -### Resolution -- Added explicit generated-prompt state to HBP slot status: - `TX_PROMPT_ACTIVE`, `TX_PROMPT_CANCEL`, `TX_PROMPT_TOKEN`, - `TX_PROMPT_TIME`, `TX_PROMPT_STREAM_ID`, `TX_PROMPT_TGID` and - `TX_PROMPT_RFS`. -- `sendVoicePacket()` now records prompt activity from the first generated - packet without driving normal group-hangtime fields such as `TX_TIME` / - `TX_TYPE`. -- `sendSpeech()`, `disconnectedVoice()` and `playFileOnRequest()` now use a - per-slot prompt token and stop scheduling generated packets once the slot is - cancelled or taken over. -- HBP real group voice now cancels generated prompt state on the source slot for - local RX and on HBP target slots when a real voice stream is about to be - routed. Data/control packets do not cancel prompts. -- The real stream remains on the existing voice routing path, so voice headers - and B-E embedded LC rewrites continue to provide DMR late-entry behavior. -- Added deterministic tests for first-packet prompt state, prompt cancellation in - the speech loop, and real HBP voice cancelling a prompt while preserving - late-entry embedded LC rewrite. - -## OBP Finished Voice Stream Depends On Reporting Being Enabled - -### Findings -- `bridge_master.py`, `routerOBP.dmrd_received()`, has explicit finished-stream - handling: if `'_fin' in self.STATUS[_stream_id]`, later packets for the same - stream are ignored. -- The OBP voice terminator branch sets `self.STATUS[_stream_id]['_fin'] = True` - only inside `if CONFIG['REPORTS']['REPORT'] and not DATA_STREAM`. -- Deterministic probe: - - With `REPORT=False`, inject OBP voice header, terminator, then another voice - burst with the same stream ID. The HBP target capture count increases from 2 - to 3, and `_fin` is absent. - - With `REPORT=True`, the same late packet is ignored; target capture remains - at 2, and `_fin` is present. - -### Assumptions To Validate -- OBP stream lifecycle state should not depend on whether dashboard/live report - output is enabled. -- A voice terminator should mark the OBP stream finished for local loop-control - and late-packet suppression regardless of report configuration. -- The report event itself should remain gated by `CONFIG['REPORTS']['REPORT']`. - -### Unresolved Questions -- Whether data/control packets can ever legitimately use `HBPF_SLT_VTERM` on the - OBP group path. Current code treats the branch as "voice terminator", so the - minimal fix should preserve the existing `not DATA_STREAM` guard for `_fin`. - -### Protocol-Sensitive Areas -- This is stream lifecycle/loop-control state, not packet byte mutation. -- Changing `_fin` affects whether late packets with the same stream ID are - forwarded after a terminator. - -### Inferred Invariants -- Runtime stream lifecycle must be independent from optional reporting. -- OBP finished-stream suppression is intended to be functional behavior, not a - dashboard side effect. - -### Resolution -- Moved `self.STATUS[_stream_id]['_fin'] = True` out of the report-enabled block - while keeping it guarded by `not self.STATUS[_stream_id].get('DATA_STREAM')`. -- Kept `send_bridgeEvent('GROUP VOICE,END,RX,...')` inside the reporting gate. -- Added deterministic coverage that an OBP voice terminator suppresses later - packets with the same stream ID when reporting is disabled. - -## HBP Voice Terminator Does Not Suppress Late Same-Stream Packets - -### Findings -- `bridge_master.py`, `routerHBP.dmrd_received()`, handles HBP group voice - terminators by logging/reporting call end and resetting `lastSeq` / - `lastData`, then later updates slot state with `RX_TYPE = HBPF_SLT_VTERM` and - `RX_STREAM_ID = _stream_id`. -- Unlike the OBP path, HBP slot state has no finished-stream marker. -- A later HBP voice burst with the same stream ID after the terminator is not - rejected as finished. Because `RX_STREAM_ID` still matches, it is not treated - as a new stream, and because `lastSeq` was reset, duplicate/order checks do - not stop it. -- Deterministic probe: - - Inject HBP voice header, terminator, then another voice burst with the same - stream ID. - - The HBP target capture count increases from 2 to 3, meaning the post- - terminator voice burst is routed. - - Source slot `RX_TYPE` becomes the late packet's vseq (`3`) and `lastSeq` - becomes `3`, effectively reopening activity for the terminated stream. - -### Assumptions To Validate -- A DMR voice terminator should close the voice stream for routing purposes. -- Late packets with the same stream ID after a valid voice terminator should be - suppressed, not routed or allowed to reopen slot activity. -- This should apply to voice streams only; group data/control classification - should keep its existing behavior. - -### Unresolved Questions -- Whether there are any HBP implementations that can legitimately send useful - late voice bursts after a terminator with the same stream ID. The code's OBP - finished-stream guard suggests FreeDMR already treats such packets as invalid - on peer-server links. -- How long the HBP finished marker should live. A per-slot marker can probably be - cleared when the slot's `RX_STREAM_ID` is nulled by the existing 60-second - loop-control cleanup. - -### Protocol-Sensitive Areas -- This is voice stream lifecycle and duplicate/late-packet suppression, not DMR - payload mutation. -- It affects only packets that arrive after a terminator for the same stream ID. - -### Inferred Invariants -- A voice terminator is an end-of-stream signal. -- HBP and OBP should agree that post-terminator voice for the same stream ID is - finished traffic and should not be forwarded. - -### Resolution -- Added per-slot `RX_FINISHED_STREAM_ID` and `RX_FINISHED_STREAM_LOG` state for - HBP systems. -- HBP voice terminators now mark the finished stream ID when the stream is not a - data/control stream. -- HBP group/vcsbk processing now ignores later voice-like packets whose stream - ID matches the finished marker, logging once for the suppressed late stream. -- The finished marker is cleared when the existing 60-second null-stream cleanup - clears `RX_STREAM_ID`, and when a genuine new voice stream begins. -- Added deterministic coverage proving a late HBP same-stream voice burst after - a terminator is suppressed. - -### Live RF Validation Caveat -- Some voice-path behavior in FreeDMR may have been intentionally shaped around - real repeater/radio behavior, dashboard expectations, or interoperability - quirks that are not visible in the deterministic harness. -- DMR terminals may interpret the DMR standards loosely or incompletely, - especially around data packets. FreeDMR should generally prioritize preserving - usable audio flow over rigid protocol purity when those goals conflict. -- The HBP finished-stream suppression and generated-prompt interruption changes - are protocol-defensible and covered by deterministic tests, but they still - require live RF-path testing with a real terminal, repeater and network path. -- Be prepared to revert or narrow these changes if live RF testing shows that a - repeater, radio or dashboard depends on the previous behavior. - -## OBP Target Terminator Leaves Stream Open For Timeout Report - -### Findings -- `bridge_master.py`, `routerHBP.to_target()` and `routerOBP.to_target()`, create - per-stream status entries on OpenBridge targets when forwarding group voice. -- When a voice terminator is forwarded to an OpenBridge target, the target path - rewrites the terminator LC and, if reporting is enabled, emits - `GROUP VOICE,END,TX`. -- The OpenBridge target stream is not marked `_fin` on the forwarded terminator. -- `stream_trimmer_loop()` later treats that target-side OpenBridge stream as - stale and emits `GROUP VOICE,END,RX` for the same stream. -- Deterministic probes: - - HBP -> OBP: after header + terminator, OBP target reports - `GROUP VOICE,START,TX` and `GROUP VOICE,END,TX`; after six seconds, trimmer - adds `GROUP VOICE,END,RX` for the same stream. - - OBP -> OBP shows the same duplicate `END,RX` timeout after an immediate - target `END,TX`. - -### Assumptions To Validate -- A forwarded voice terminator should close the target-side OpenBridge stream - lifecycle. -- Once an immediate target `GROUP VOICE,END,TX` has been emitted for a forwarded - terminator, cleanup should not later emit a timeout-style `GROUP VOICE,END,RX` - for the same target-side stream. -- This should be limited to non-data voice streams, matching the existing - `DATA_STREAM` guards. - -### Validated Assumptions -- User confirmed the timer exists to terminate streams where the RF signal was - lost and the terminal's terminator was also lost. -- Therefore, timeout cleanup should cover missing terminators, not streams where - FreeDMR already forwarded a terminator. - -### Protocol-Sensitive Areas -- This is target-side stream lifecycle/reporting, not packet routing or payload - mutation. -- It affects HBP -> OBP and OBP -> OBP forwarded voice terminators. - -### Inferred Invariants -- OpenBridge target streams should be marked finished when FreeDMR forwards a - voice terminator to that target. -- Timeout cleanup should report missing terminators, not duplicate already-ended - streams that had a terminator. - -### Resolution -- In both OpenBridge target branches, set `_target_status[_stream_id]['_fin'] = - True` when forwarding a non-data voice terminator. -- Kept existing `GROUP VOICE,END,TX` reporting behavior unchanged. -- Added deterministic tests for HBP -> OBP and OBP -> OBP proving the trimmer - does not emit a later duplicate `GROUP VOICE,END,RX` after a forwarded - terminator. - -## OBP Source To OBP Target BCSQ TG Namespace - -### Findings -- `bridge_master.py`, `routerOBP.to_target()`, OpenBridge target branch checks - source-quench state with `_dst_id`: - `(_dst_id in _target_system['_bcsq'])`. -- The same logical branch in `routerHBP.to_target()` checks - `_target['TGID']`, which is the TGID that will be visible to the target after - bridge rewrite. -- If an OBP source stream is bridged from one TG to a different OBP target TG, - a target BCSQ for the rewritten target TG is ignored. -- Deterministic probe: - - Bridge OBP-1 TG9 to OBP-2 TG235. - - Set OBP-2 `_bcsq` to `{235: stream_id}`. - - Inject OBP-1 stream on TG9. - - Packet still forwards to OBP-2 as TG235, proving the BCSQ check used the - wrong TG namespace. - -### Validated Assumptions -- User clarified that OBP-to-OBP should not rewrite TGID; inbound OBP TG should - equal target OBP TG. -- The only expected TG rewrite is HBP <-> OBP when dial-a-TG maps local RF TG9 - to an OBP/FBP reflector TG. -- BCSQ should use the TGID the source server sees and sends on OBP/FBP, not the - TG the client/terminal sees on RF. The quench asks the source server to stop - sending its traffic. - -### Unresolved Questions -- Whether FreeDMR should defensively prevent or normalize OBP-to-OBP bridge - entries that imply TG rewrite. That is a bridge-configuration validity question - rather than a confirmed BCSQ runtime bug. - -### Protocol-Sensitive Areas -- This is optional source-quench behavior. Failure does not corrupt packet bytes, - but it can waste bandwidth and processing by continuing to forward a quenched - stream. -- The TG namespace is source-server/OBP-visible, not RF/client-visible. - -### Inferred Invariants -- OBP-source BCSQ matching should use the inbound OBP TGID and stream ID because - that is the source server's namespace. -- HBP-to-OBP dial-a-TG BCSQ matching should use the OBP/FBP reflector TG, not RF - TG9, because that is the TGID visible to the source server on OBP/FBP. - -### Resolution -- No runtime change. The current OBP-source `_dst_id` BCSQ check is correct for - the intended OBP model where OBP-to-OBP TG rewrite does not occur. -- Treat the deterministic OBP-to-OBP rewrite probe as an invalid scenario unless - bridge validation later decides to explicitly support or reject such entries. - -## Dial-A-TG Product Rationale - -### Findings -- Dial-a-TG exists to let a terminal user access any TG on the FreeDMR network - without explicitly programming that TG into the terminal/codeplug. -- Codeplug generation and DMR terminal programming can be a major barrier for - amateur radio users. Many users are strong RF/electronics engineers but may - not be interested in, or comfortable with, computer-based configuration. -- FreeDMR's amateur-radio use case differs from DMR's intended commercial use - case, so some FreeDMR behavior intentionally optimizes usability and network - access rather than strict commercial fleet/radio programming assumptions. - -### Assumptions -- Dial-a-TG changes should be evaluated against this access/usability goal, not - only against narrow protocol or commercial-radio assumptions. - -### Protocol-Sensitive Areas -- Dial-a-TG intentionally maps local RF control/use of TG9 TS2 to wider-network - TG access on FBP/OBP. -- This makes TG namespace clarity critical: RF/client-visible TGs and - OBP/FBP-visible TGs can intentionally differ. - -### Inferred Invariants -- Dial-a-TG should reduce terminal programming burden for users. -- Maintaining usable audio/network access for amateur users is a first-class - design constraint. - -## FreeDMR Routing Model - -### Findings -- FreeDMR can be understood like a PBX: - - TGs are conference groups that can be connected to. - - DMR IDs are like phone numbers. - - Routing is centered on TGs and DMR IDs. -- FreeDMR is intended to be timeslot agnostic, unlike some systems. -- Timeslots are closer to phone lines: - - a simplex hotspot has one usable line; - - a repeater or duplex hotspot has two usable lines. - -### Assumptions -- Routing changes should preserve TG/DMR-ID centric behavior. -- Timeslot should be treated as an available access path or capacity dimension, - not as the primary identity of a route unless a specific feature explicitly - requires slot scoping. - -### Protocol-Sensitive Areas -- Dial-a-TG control from TS1 affecting TS2 reflector state is consistent with - the PBX/line model: the user can use one line to control which conference is - connected on another line. -- Tests should avoid assuming commercial DMR fleet semantics where timeslot is - always part of the primary routing identity. - -### Inferred Invariants -- TG and DMR ID are the primary routing identities. -- Timeslot handling should preserve capacity/access behavior without making - FreeDMR unnecessarily slot-bound. - -## HBP-Side Tolerance Principle - -### Findings -- FreeDMR should generally be more permissive on the HBP side because HBP - represents direct RF-facing connections to repeaters, duplex hotspots and - simplex hotspots. -- HBP-connected devices may come from several vendors and may run proprietary or - open source implementations with differing interpretations of the protocol. -- RF-facing behavior may vary in timing, late-entry recovery, stream continuity, - data handling and terminator delivery. - -### Assumptions -- HBP-side handling should avoid over-strict enforcement unless needed for - safety, loop prevention, routing correctness or preventing network harm. -- OBP/FBP peer-server handling can be stricter because it is server-to-server and - less directly exposed to varied RF terminal/repeater behavior. - -### Protocol-Sensitive Areas -- Timeout behavior on HBP should remain tolerant of resumed streams after packet - gaps unless live testing proves this causes worse failures. -- Data packet handling may need extra tolerance because terminal implementations - can be loose or incomplete. - -### Inferred Invariants -- Prefer audio continuity and interoperability at the RF-facing HBP boundary. -- Treat strictness on HBP as a deliberate choice requiring evidence. - -## Unit-Unit Private Call Policy - -### Findings -- FreeDMR deliberately does not allow user-to-user unit-unit private calls. -- Amateur Radio is an open service, not a private communications system. -- A unit-unit private call still occupies an RF timeslot even when other - stations cannot hear useful traffic on that slot. -- Users who want directed or pseudo-private routing should use a TG instead, - including the group-call TG that corresponds to their DMR ID if appropriate. -- Unit-unit/private calls are reserved for control purposes, including - dial-a-TG and related control flows. - -### Assumptions -- Future changes should not add general private voice routing unless this policy - is explicitly changed. -- Unit-call handling should continue to distinguish control-plane private calls - from user traffic. - -### Protocol-Sensitive Areas -- DMR allows unit-unit calls, but FreeDMR policy intentionally restricts them in - favor of open group-call behavior. -- Test cases involving private calls should treat them as control operations, - not as a supported user voice routing path. - -### Inferred Invariants -- User voice traffic should be routed as group calls/TGs. -- Private calls are control-plane only in FreeDMR. - -## HBP New Voice Terminator Can Use Stale Data Classification - -### Findings -- `bridge_master.py`, `routerHBP.dmrd_received()`, computes `_data_control` for - each HBP group/vcsbk packet. -- The slot's `RX_DATA_STREAM` / `RX_GROUP_VOICE_STREAM` classification is only - updated at the end of the group branch, after final terminator handling. -- Final terminator handling checks `self.STATUS[_slot].get('RX_DATA_STREAM')` to - decide whether to emit `GROUP VOICE,END,RX` and mark - `RX_FINISHED_STREAM_ID`. -- Deterministic probe: - - Inject group data header on MASTER-A TS2, leaving `RX_DATA_STREAM=True`. - - Inject a new voice stream where the first observed packet is a voice - terminator. - - The new stream emits `GROUP VOICE,START,RX`, but the terminator final-action - path sees stale `RX_DATA_STREAM=True`, so it does not emit voice END and does - not mark `RX_FINISHED_STREAM_ID`. - - A later same-stream voice burst is then routed, increasing the target packet - count from 2 to 3. - -### Assumptions To Validate -- A newly observed HBP stream should set its data-vs-voice classification before - any final-action handling for that same packet. -- If FreeDMR first observes a stream at its terminator, the classification should - come from that terminator packet, not from the previous slot occupant. -- Even if this is an edge case, stale data classification should not suppress - voice lifecycle/finished-stream handling for a new voice stream. - -### Unresolved Questions -- How often real RF/HBP paths deliver a new stream whose first observed packet is - a terminator. It can plausibly occur around packet loss, startup, or missed - earlier packets, but may be rare. -- Whether strict handling should suppress a terminator-only "start" report. This - proposal does not change that existing behavior; it only makes the end/finish - classification match the current packet. - -### Protocol-Sensitive Areas -- This is HBP source slot lifecycle state, not packet mutation. -- DMR terminal behavior may be loose; preserving robust audio/lifecycle behavior - matters more than assuming perfectly ordered full streams. - -### Inferred Invariants -- Per-slot packet classification must describe the current stream before the - current packet's lifecycle side effects are applied. -- Stale data-control state must not leak into a new voice stream. - -### Resolution -- HBP group/vcsbk new-stream handling now sets - `RX_DATA_STREAM = _data_control` and - `RX_GROUP_VOICE_STREAM = not _data_control` before terminator final actions can - run. -- Kept the existing end-of-branch assignment for ongoing packets. -- Added deterministic coverage proving a voice terminator observed after a data - stream marks the new voice stream finished and suppresses later same-stream - bursts. - -## HBP Terminator-Only Voice On Idle Slot Skips Finished Handling - -### Findings -- `bridge_master.py`, `routerHBP.dmrd_received()`, final voice terminator - handling is guarded by: - `(_frame_type == HBPF_DATA_SYNC) and (_dtype_vseq == HBPF_SLT_VTERM) and - (self.STATUS[_slot]['RX_TYPE'] != HBPF_SLT_VTERM)`. -- If the first observed packet for a new stream is a voice terminator while the - slot was idle, the previous slot `RX_TYPE` is already `HBPF_SLT_VTERM`, so - final terminator handling is skipped. -- Deterministic probe: - - Start from idle MASTER-A TS2. - - Inject a new HBP group voice terminator for stream `01020304`. - - The packet is forwarded and `GROUP VOICE,START,RX` is emitted, but no - `GROUP VOICE,END,RX` is emitted and `RX_FINISHED_STREAM_ID` remains null. - - A later same-stream voice burst is then forwarded, increasing target capture - count from 1 to 2 and setting `RX_TYPE` to the late voice vseq. - -### Assumptions To Validate -- If FreeDMR accepts and forwards a terminator-only voice packet as a new voice - stream, it should also close that stream locally and suppress later same-stream - voice. -- The final-action decision should be based on whether the current packet is a - voice terminator for the current stream, not only on the previous slot - `RX_TYPE`. -- This should stay limited to non-data voice streams. - -### Unresolved Questions -- Whether a terminator-only packet should emit both START and END reports, or - whether START should be suppressed when no earlier voice packet was observed. - Existing behavior already emits START, so the minimal fix should only add the - missing end/finished handling. - -### Protocol-Sensitive Areas -- This is HBP source lifecycle/late-packet suppression, not packet mutation. -- A terminator-only first observation can happen when earlier packets were lost - or FreeDMR starts observing mid-stream. - -### Inferred Invariants -- A voice terminator should close the current stream even if the previous slot - state was idle. -- Post-terminator same-stream voice should not be routed. - -### Resolution -- Replaced the final terminator guard's previous-`RX_TYPE` dependency with a - current voice-terminator condition that avoids duplicate final handling for an - already-finished stream. -- Added deterministic coverage for an idle-slot terminator-only HBP voice packet - marking the stream finished and suppressing later same-stream voice. - -## HBP Timeout Cleanup Does Not Mark Stream Finished - -### Findings -- `bridge_master.py`, `stream_trimmer_loop()`, HBP RX timeout cleanup sets - `_slot['RX_TYPE'] = HBPF_SLT_VTERM` and emits `GROUP VOICE,END,RX` for - reportable voice streams. -- It does not set `RX_FINISHED_STREAM_ID`. -- Because `RX_STREAM_ID` remains the timed-out stream ID until the 60-second - null cleanup, a later same-stream voice packet before that cleanup is treated - as a continuation rather than a new/finished stream. -- Deterministic probe: - - Inject HBP group voice stream `01020304`. - - Advance six seconds and run `stream_trimmer_loop()`. - - Timeout emits `GROUP VOICE,END,RX`, leaves `RX_STREAM_ID=01020304`, but - `RX_FINISHED_STREAM_ID` remains null. - - A later same-stream voice burst routes to the target, increasing target - capture count from 1 to 2 and setting `RX_TYPE` to the late voice vseq. - -### Updated Analysis -- User clarified that timeout can represent two different failure modes: - - RF path loss between terminal and base station, where the terminal's - terminator may also be lost. - - Network/server/mesh packet path disruption, where an otherwise valid stream - may have a long packet gap and later resume with the same stream ID. -- In RF-path loss, if the user is still transmitting and comes back into range, - the base station may recover via DMR late entry and may or may not assign a new - HBP stream ID. This needs live RF testing. -- In network-path loss, downstream servers cannot reliably distinguish a truly - ended over from a temporarily missing packet flow. A later same-stream packet - may be legitimate and should probably be forwarded to preserve audio. - -### Revised Assumptions -- Explicit terminators and timeout cleanup should not be treated identically. -- A real terminator is a strong end-of-stream signal and should mark the stream - finished. -- A timeout is an observability/liveness fallback for missing terminators, but - may be a soft end when the underlying stream could resume after network delay. -- FreeDMR should prioritize preserving recovered audio over rigidly enforcing a - timeout as a hard stream close when no terminator was seen. - -### Unresolved Questions -- Live RF testing should determine whether an RF-lost/recovered over uses a new - stream ID or resumes the old one. -- Black-box/network simulation should test whether a packet gap longer than the - HBP timeout can occur in the mesh and then resume with the same stream ID. -- Reporting may remain imperfect in this case: a timeout `END,RX` can be emitted - and later packets for the same stream may still be forwarded without a fresh - `START,RX`. - -### Protocol-Sensitive Areas -- This is HBP source stream lifecycle/late-packet suppression, not packet - mutation. -- It directly follows the operational purpose of timeout cleanup as a substitute - terminator when the real terminator was lost. - -### Inferred Invariants -- An explicit terminator closes a stream; a timeout without terminator is less - certain and may need to remain recoverable. -- Audio continuity after network gaps is more important than making timeout - reports look perfectly final. -- Timeout reports should be understood as "stream appears lost" rather than - definitive proof that the over ended. - -### Resolution -- No runtime change for now. -- Do not mark HBP streams finished on timeout until live RF and network-gap - testing proves this will not block legitimate recovered audio. -- Keep the existing behavior documented as a deliberate soft-timeout tradeoff: - reporting may show an END for a lost stream, but later same-stream packets can - still route before the 60-second null cleanup. - -### Follow-Up Verification -- Rechecked earlier lifecycle fixes against this soft-timeout model. -- HBP timeout cleanup still only sets `RX_TYPE = HBPF_SLT_VTERM`, clears - data/report classification, and optionally emits the timeout report. It does - not set `RX_FINISHED_STREAM_ID`. -- Deterministic probe confirmed HBP timeout remains recoverable: - - one packet routed before timeout; - - timeout report emitted with `RX_FINISHED_STREAM_ID == 0`; - - later same-stream packet before 60-second null cleanup still routes. -- Explicit terminators remain hard end-of-stream signals: - - HBP explicit terminator sets `RX_FINISHED_STREAM_ID` and suppresses later - same-stream voice; - - forwarded HBP -> OBP / OBP -> OBP terminators set target `_fin`; - - OBP source timeout sets `_to`, not `_fin`. -- Conclusion: earlier fixes did not convert timeout cleanup into a hard finished - state. The hard-close behavior is currently limited to real/forwarded - terminators. - -## DMRD Sequence Wrap In Voice Duplicate Control - -### Findings -- `hblink.py` parses the DMRD sequence number as `_seq = _data[4]`, so it is a - one-byte value in the range `0..255`. -- `bridge_master.py`, `routerHBP.dmrd_received()`, HBP group/vcsbk duplicate - control treats sequence numbers as a simple increasing integer: - duplicate when `_seq == lastSeq`, out-of-order when - `_seq < lastSeq and _seq != 1`, and missed packets when - `_seq > lastSeq + 1`. -- `bridge_master.py`, `routerOBP.dmrd_received()`, OBP group/vcsbk duplicate - control uses the same linear comparisons. -- This works until sequence wrap. If the last accepted sequence is `255` and - post-wrap packets `0` and `1` are lost, a valid resumed packet with sequence - `2` is treated as out-of-order because `2 < 255 and 2 != 1`. -- Because the discard path does not advance `lastSeq`, later valid packets `3`, - `4`, and so on remain less than `255` and can continue to be discarded. - -### Assumptions -- DMRD sequence numbers are modulo-256 transport sequencing, not an unbounded - stream counter. -- Packet `0` is a valid DMRD sequence value, not "no sequence"; the current - truthiness checks skip some validation when `_seq == 0`. -- Duplicate and out-of-order suppression is intended to drop repeated or stale - packets, not to mute a stream after a legitimate sequence wrap with packet - loss. - -### Unresolved Questions -- Whether data/control paths should use the same helper if future duplicate - control is added there, while remembering that DMR data packets are - packet-oriented rather than continuous voice streams. - -### Protocol-Sensitive Areas -- This affects packet-control behavior for long voice streams and network gaps, - especially near sequence wrap. -- HBP should remain tolerant of RF-facing packet loss and vendor differences. -- OBP/FBP should also handle modulo sequence wrap because the sequence value is - carried in DMRD packet format, even though server-to-server behavior can be - stricter in other areas. - -### Inferred Invariants -- DMRD sequence comparison should be modulo-aware. -- A post-wrap packet should be treated as forward progress even if one or more - immediately post-wrap packets were lost. -- Duplicate detection should include sequence `0`. - -### Resolution -- Added `dmrd_seq_delta(seq, last_seq)` to calculate sequence progress as - `(seq - last_seq) % 256`. -- Updated HBP and OBP group/vcsbk packet-control paths to classify delta `0` as - duplicate, delta `1` as normal progress, delta `2..127` as forward progress - with missed packets, and delta `128..255` as old/out-of-order. -- Removed the `_seq > 0` guard from hash duplicate checks so sequence `0` cannot - bypass duplicate detection. -- Added deterministic tests for HBP and OBP voice streams crossing `254`, `255`, - then `2`, and for HBP sequence `0` duplicate suppression. - -## HBP/OBP Source Timeout Uses Stream Start Time - -### Findings -- `bridge_master.py`, `routerHBP.dmrd_received()`, HBP group/vcsbk handling has - a source timeout check: - `if self.STATUS[_slot]['RX_START'] + 180 < pkt_time`. -- `bridge_master.py`, `routerOBP.dmrd_received()`, OBP group/vcsbk handling has - the analogous check: - `if self.STATUS[_stream_id]['START'] + 180 < pkt_time`. -- Both branches log that the source/stream should be ignored and then return - before duplicate control, routing, terminator handling, and normal state - updates. -- Because the comparison is against the stream start time, this is a hard - maximum stream duration of 180 seconds. It is not an inactivity timeout. -- A still-active voice stream that continues past 180 seconds without a - terminator will be dropped even if packets continue arriving at normal - cadence. - -### Assumptions -- User confirmed this is deliberate amateur DMR network behavior. -- The 180-second guard is a hard maximum over/stream duration, comparable to a - network-side time-out timer. -- Terminal time-out timers are also advised to be set to 180 seconds. - -### Unresolved Questions -- None for now. - -### Protocol-Sensitive Areas -- This is voice stream lifecycle and loop/source protection, not packet mutation. -- HBP paths are RF-facing and may reflect repeater/hotspot time-out timer - behavior, so changing this could permit overlong RF streams that deployments - currently expect to be cut off. -- OBP/FBP can carry arbitrary forwarded streams, so the operational impact may - differ between HBP and OBP. - -### Inferred Invariants -- A hard stream-age cap should be explicit and documented if retained. -- The current 180-second source timeout should remain based on original stream - start time, not last packet activity. - -### Resolution -- No runtime change. The `START + 180` behavior is intentional and should not be - rewritten as a last-activity timeout. - -## Generated Prompt Cancellation Does Not Send Terminator - -### Findings -- `mk_voice.py`, `pkt_gen()`, builds generated voice prompts as a DMRD stream: - three voice headers, AMBE bursts, and a final voice terminator frame using the - same generated stream ID. -- `bridge_master.py`, `sendSpeech()`, `disconnectedVoice()`, and - `playFileOnRequest()` now stop prompt scheduling when - `_generatedVoiceCancelled()` becomes true. -- Those loops break immediately and do not drain the generator to its final - terminator packet. -- `_cancelGeneratedVoice()` only marks prompt state as cancelled; it does not - send a same-stream terminator for the abandoned prompt. -- A real HBP voice stream can therefore interrupt a generated prompt on an HBP - target slot after the repeater has received a prompt header/burst but before it - receives the generated prompt terminator. - -### Assumptions -- User confirmed that absence of a terminator for an interrupted prompt is not a - major terminal-facing issue. -- DMR is designed for lossy environments, and a missing terminator is an - expected recoverable condition. -- DMR also supports interruption concepts such as priority interruption, so - receivers should tolerate an interrupted stream being superseded. -- Any future fix must not delay the real RF/network voice that caused the - cancellation. - -### Unresolved Questions -- None for now. - -### Protocol-Sensitive Areas -- This is generated local HBP TX lifecycle, not inbound DMR packet parsing. -- The fix would intentionally create or forward a terminator packet for locally - generated voice; it must preserve the stream ID and LC namespace expected by - the target repeater/client. -- Prompt interruption exists to prioritize live RF/network audio, so any - terminator fix must be low latency. - -### Inferred Invariants -- Real RF/network audio should remain higher priority than locally generated - prompts. -- Prompt termination should not mutate the real stream that caused the - interruption. -- Missing generated-prompt terminators are acceptable if interruption preserves - live traffic. - -### Resolution -- No runtime change. Prompt cancellation may abandon the generated prompt without - sending its final terminator. - -## Voice Ident Cancellation Leaves Prompt Cancel State Set - -### Findings -- `bridge_master.py`, `sendSpeech()`, `disconnectedVoice()`, and - `playFileOnRequest()` wrap generated prompt playback with - `_beginGeneratedVoice()` and `_endGeneratedVoice()`. -- `bridge_master.py`, `ident()` sends generated packets directly in its own loop - and calls `sendVoicePacket()` without `_beginGeneratedVoice()` / - `_endGeneratedVoice()` or `_generatedVoiceCancelled()` checks. -- `sendVoicePacket()` returns immediately if `_slot['TX_PROMPT_CANCEL']` is true. -- Real HBP voice can cancel generated prompt state on a target slot by calling - `_cancelGeneratedVoice()`, setting `TX_PROMPT_CANCEL=True` and - `TX_PROMPT_ACTIVE=False`. -- If that happens while a voice ident is active, the ident loop keeps scheduling - packets that `sendVoicePacket()` drops, and no ident cleanup resets - `TX_PROMPT_CANCEL`. -- A later `ident()` run on the same slot can then have all of its packets dropped - immediately because it never begins a new prompt token or clears the old cancel - flag. - -### Assumptions -- Voice ident should follow the same generated-prompt lifecycle as dial-a-TG - prompts and on-demand files. -- Real RF/network voice should still be able to interrupt voice ident. -- A cancelled ident should not permanently suppress later idents. - -### Unresolved Questions -- None for now. - -### Protocol-Sensitive Areas -- This is generated local HBP TX scheduling/state, not inbound DMR parsing. -- The fix should not alter generated packet bytes, destination selection, or the - existing policy that ident only runs when the slot has been idle. - -### Inferred Invariants -- Every generated voice loop that uses `sendVoicePacket()` should own a prompt - token and clear/finish prompt state when it exits. -- Interrupting one generated voice ident must not permanently block future - generated voice idents. - -### Resolution -- Updated `ident()` to use `_beginGeneratedVoice()`, - `_generatedVoiceCancelled()`, and `_endGeneratedVoice()` around its generated - packet loop. -- Removed unused `_stream_id` / `_pkt_time` local assignments from the ident - loop. -- Added deterministic coverage proving a cancelled ident does not leave the slot - in a state that blocks a later ident. - -## HBP New Voice Stream Keeps Previous Duplicate State - -### Findings -- `bridge_master.py`, `routerHBP.dmrd_received()`, HBP group/vcsbk handling uses - per-slot duplicate-control state: `lastSeq`, `lastData`, `loss`, `packets`, - and `crcs`. -- When a new stream is detected with - `_stream_id != self.STATUS[_slot]['RX_STREAM_ID']`, the code resets - `packets`, `loss`, and `crcs`. -- It does not reset `lastSeq` or `lastData` at that point. -- The duplicate/out-of-order block for the current packet then runs against - potentially stale `lastSeq` and `lastData` from the previous stream on the - same slot. -- If the previous stream ended by explicit terminator, final terminator handling - resets `lastSeq` and `lastData`, so the issue is masked. -- If the previous stream ended by timeout, source timeout, collision gap, or any - path that does not run explicit terminator cleanup, the next stream can be - judged against stale sequence/data. -- Example from code logic: previous stream leaves `lastSeq=200`; a new stream - begins with sequence `1`. `dmrd_seq_delta(1, 200)` is `57`, so the packet is - treated as forward progress with missed packets on the old stream rather than - the first packet of a new stream. Other stale values can classify the new - packet as duplicate or out-of-order. - -### Assumptions -- Duplicate-control state is stream-scoped, not slot-lifetime-scoped. -- A new stream on the same HBP slot should begin with `lastSeq=False` and - `lastData=False`, just as OBP initializes per-stream state. -- Resetting duplicate-control state on new stream should not affect explicit - terminator suppression, which is now handled separately by - `RX_FINISHED_STREAM_ID`. - -### Unresolved Questions -- None for now. - -### Protocol-Sensitive Areas -- This is HBP RF-facing packet-control state, not packet mutation. -- HBP should be tolerant of missing terminators and stream transitions after - packet loss. -- The fix should not change OBP, where stream state is already per stream. - -### Inferred Invariants -- Duplicate and sequence state must belong to the current stream. -- Missing a terminator must not let stale duplicate-control state from the old - stream suppress or misclassify the next HBP stream. - -### Resolution -- Updated the HBP group/vcsbk new-stream initialization block to reset - `lastSeq` and `lastData` along with `packets`, `loss`, and `crcs`. -- Added deterministic coverage for a prior HBP stream that times out with - `lastSeq=200`; the next stream on the same slot starts with sequence `1`, - routes to the target, and records no inherited loss. - -## HBP Sequence Gaps on Unreliable Networks - -### Findings -- The DMRD sequence byte is supplied by the HBP client/repeater/hotspot at - packet offset 4 and is passed through `hblink.py` into - `routerHBP.dmrd_received()`. -- The current modulo-256 check treats deltas 1..127 as forward progress, - delta 0 as duplicate, and deltas greater than 127 as stale/out-of-order. -- If a network-side gap exceeds half the 8-bit sequence space, the server - cannot distinguish "very late old packet" from "forward packet after a long - loss". -- Because the out-of-order branch returns before updating `lastSeq`, a - same-stream resume after a greater-than-127 jump may remain muted until the - sequence wraps back near the previous accepted value. - -### Assumptions -- RF-side loss between the terminal and repeater may not produce a sequence jump - at the server if the HBP device only increments the DMRD sequence for packets - it actually sends. -- Network-side loss between an HBP device and the server can produce large - sequence jumps because packets were sent but not received by FreeDMR. -- HBP should remain permissive where practical because it represents direct RF - paths and unreliable access networks. - -### Unresolved Questions -- Whether the HBP duplicate-control path should eventually add a long-gap - recovery rule for same stream IDs after a quiet interval, rather than treating - all greater-than-127 deltas as stale. - -### Protocol-Sensitive Areas -- The sequence byte is only 8 bits, so long-gap direction is inherently - ambiguous without timing, stream lifecycle, or additional protocol context. -- Any recovery rule must avoid accepting genuinely old/reordered packets and - creating audio or loop-control regressions. - -### Inferred Invariants -- Small packet loss and sequence wrap must not suppress a voice stream. -- A new HBP stream must not inherit sequence state from an old stream. -- Very long same-stream gaps require a policy decision: prefer stale-packet - rejection or prefer late audio recovery on lossy access networks. - -## FBP Sequence Gaps on Unreliable Links - -### Findings -- OBP/FBP group voice handling uses the same modulo-256 sequence delta policy - as HBP: delta 0 is duplicate, deltas 1..127 are forward progress, and deltas - greater than 127 are treated as stale/out-of-order. -- OBP/FBP duplicate-control state is keyed by stream ID, so it does not have - the HBP per-slot stale-new-stream problem. -- A same-stream long network outage on an OBP/FBP link can still produce the - same recovery issue: the first packets after the outage may be rejected until - the sequence wraps back near the last accepted value. -- OBP/FBP code updates `LAST` on accepted packets and on some loop/timeout - returns. For the duplicate/out-of-order branch, the useful quiet-time signal - is the elapsed time since the last accepted/routed packet. - -### Assumptions -- Some FreeDMR peer-server links may be unreliable because of RF IP, cellular, - portable, EMCOMM, or less-developed infrastructure use cases. -- Server-to-server links still need stricter loop-control behavior than HBP - edges, but rejecting live resumed audio after a long quiet gap is undesirable. -- Any FBP recovery rule should apply only to stream media packets after loop - and source-selection checks have already accepted this server as the first - source for the stream. - -### Unresolved Questions -- Whether HBP and FBP should share the same long-gap threshold, or FBP should - use a slightly higher threshold to reduce risk from delayed/reordered mesh - packets. - -### Protocol-Sensitive Areas -- BCSQ/source-quench correctness depends on stream ID and TG; recovery should - not bypass existing first-source/loop-control decisions. -- OBP/FBP can carry multiple concurrent streams, so rate and recovery decisions - must remain per stream, not global. - -### Inferred Invariants -- FBP should not discard live recovered audio solely because a lossy link - crossed the half-sequence ambiguity point. -- FBP long-gap recovery must not weaken source-selection, STUN, ACL, TG filter, - or source-quench behavior. - -## FBP Trunk-Wide Long-Gap Diagnostics - -### Findings -- OBP/FBP links can carry many independent streams over one peer connection. -- If long-gap recovery is observed on only some streams, the loss may have been - inherited from an upstream route taken by those streams. -- If long-gap recovery is observed across most or all active streams on the - same OBP/FBP peer connection within a short window, that suggests the local - upstream link or peer connection is struggling. - -### Assumptions -- This signal is diagnostic only and must not influence packet admission, - source selection, loop control, or source-quench decisions. -- Per-stream recovery logging is useful for debugging a stream; trunk-wide - aggregate logging is useful for diagnosing infrastructure or upstream link - trouble. - -### Unresolved Questions -- What threshold should define "most/all streams" on a trunk: all active - streams, a fixed minimum count, or a ratio such as 80% within a short window. -- Whether this should be implemented immediately with long-gap recovery or left - as a later observability improvement. - -### Protocol-Sensitive Areas -- A malicious or delayed packet pattern must not be able to relax loop-control - or routing checks by triggering trunk-wide diagnostics. -- Trunk-wide diagnostics must be rate-limited to avoid log flooding during real - network incidents. - -### Inferred Invariants -- Long-gap recovery remains per stream. -- Trunk-wide detection is warning-only observability. -- Diagnostics must never bypass STUN, HMAC/authentication, ACLs, TG filters, - loop-control, finished-stream suppression, source timeout, rate limiting, or - BCSQ/source-quench behavior. - -## OBP Target LC Missing-Key Logging Uses Wrong System Variable - -### Findings -- `bridge_master.py`, `routerOBP.to_target()`, OpenBridge-target voice rewrite - branch catches missing `T_LC` and `EMB_LC` state with `except KeyError`. -- Both handlers log with `system` instead of `self._system`. -- `system` is not a local variable in `to_target()`. If no module-level - `system` binding exists, the exception handler itself can raise `NameError`. - If a module-level binding does exist from other loops, the log can report the - wrong system. -- The surrounding code intends these `KeyError` paths to be non-fatal: one logs - and continues processing the terminator, the other logs and skips the packet. - -### Assumptions -- Missing LC state is abnormal, but the existing handlers show the intended - behavior is to avoid crashing the router on this condition. -- Changing the log argument from `system` to `self._system` does not alter packet - routing, mutation, or normal successful voice handling. - -### Unresolved Questions -- Whether the terminator missing-`T_LC` path should also skip sending the packet - after logging. That is a separate behavior question and should not be bundled - with the logging fix. - -### Protocol-Sensitive Areas -- This is an error-path logging bug in OBP target voice LC rewrite handling. -- It should not change LC rewrite behavior when `T_LC` / `EMB_LC` are present. - -### Inferred Invariants -- Error-path logging must not introduce a new exception while handling malformed - or inconsistent stream state. -- Logs should identify the router instance handling the packet. - -### Resolution -- Replaced the non-local `system` log argument with `self._system` in the - missing `T_LC` and `EMB_LC` handlers. -- Added deterministic OBP-to-OBP coverage that removes target `EMB_LC` state, - injects a voice burst, and verifies the warning logs without crashing or - forwarding the malformed rewrite. - -## Widened Review: Startup, Config, Support Functions - -### Findings -- `config.py`, `build_config()`, reads `GLOBAL.USE_ACL` with - `config.get(..., fallback=True)` instead of `config.getboolean(...)`. - Therefore a config value such as `USE_ACL: False` becomes the non-empty string - `"False"`, which is truthy in packet ACL checks. -- Packet ACL checks are split across both layers: `hblink.py` performs low-level - admission checks using `self._CONFIG['GLOBAL']['USE_ACL']`, and - `bridge_master.py` checks the same global flag before target forwarding. A - truthy string therefore affects both layers consistently, but incorrectly. -- `config.py` converts `ALIASES.STALE_DAYS` into seconds as `STALE_TIME`. - `bridge_master.py` then schedules alias reloads with - `CONFIG['ALIASES']['STALE_TIME'] * 86400`, multiplying by 86400 twice. - A one-day alias stale interval therefore schedules periodic reload around - 86400 days instead of 1 day. -- `bridge_master.py`, `setAlias()`, assigns reloaded alias dictionaries to local - variables named `peer_ids`, `subscriber_ids`, `talkgroup_ids`, - `local_subscriber_ids`, `server_ids`, and `checksums`. Without a `global` - declaration or updating `CONFIG`, the periodic alias reload does not update - the module-level alias dictionaries used by logging, reports, and routing - helpers. -- `hblink.py` router classes also read aliases from the shared config object: - `CONFIG['_SUB_IDS']`, `CONFIG['_PEER_IDS']`, - `CONFIG['_LOCAL_SUBSCRIBER_IDS']`, and `CONFIG['_SERVER_IDS']`. Therefore an - alias reload fix must update both `bridge_master.py` globals and these shared - config keys; updating only one side would leave the split layers inconsistent. -- `bridge_master.py`, `bridge_reset()`, checks - `if 'OPTIONS' in CONFIG['SYSTEMS'][_system]['OPTIONS']:` after reset. If the - system has no `OPTIONS` key, which can happen after disconnect when no - `_default_options` exists, this raises `KeyError` inside the timed reset loop. -- `hblink.py` deliberately deletes `CONFIG['SYSTEMS'][system]['OPTIONS']` when a - peer disconnects or times out and no `_default_options` exists. This confirms - the missing `OPTIONS` case is part of normal lifecycle behavior, not corrupt - state. - -### Assumptions To Validate -- `GLOBAL.USE_ACL: False` should disable global ACL checks; it should not be - treated as enabled because the string is truthy. -- `ALIASES.STALE_DAYS` is intended to control alias refresh age in days, while - the runtime scheduler should operate on the already-converted seconds value. -- Periodic alias reloads are intended to replace the live alias dictionaries - used by bridge logging/reporting and router helper methods. -- Bridge reset should tolerate sessions with no current `OPTIONS` key and should - not stop the reactor/timed loops. - -### Unresolved Questions -- In `bridge_reset()`, should `_reloadoptions` be set whenever an `OPTIONS` key - exists, or only when current/default options actually need re-parsing? - -### Protocol-Sensitive Areas -- Global ACL parsing affects all packet admission paths. -- Alias reload affects observability and may affect local subscriber lookup - helpers, but should not mutate packet bytes. -- Bridge reset interacts with HBP disconnect/reconnect lifecycle and options - parsing; fixes must preserve the confirmed session-only option semantics. - -### Inferred Invariants -- Boolean config fields must be stored as booleans, not truthy strings. -- Time values should have one unit conversion boundary. -- Periodic reload functions must update the state actually read by production - paths. -- Timed maintenance loops must not crash on missing optional session keys. - -### Resolution -- Updated `config.py` to parse `GLOBAL.USE_ACL` with `getboolean()`. -- Updated the alias reload scheduler to use `CONFIG['ALIASES']['STALE_TIME']` - directly, because `config.py` already converts `STALE_DAYS` to seconds. -- Updated `setAlias()` to assign the reloaded dictionaries to bridge_master - module globals and the shared `CONFIG` alias keys consumed by `hblink.py`. -- Updated `bridge_reset()` to check for the presence of the `OPTIONS` key before - marking `_reloadoptions`. -- Added deterministic/config coverage for global ACL parsing, alias stale-time - units, cross-layer alias reload state, and reset after missing session - `OPTIONS`. - -## Post-Fix Widened Review Status - -### Findings -- A follow-up scan of `config.py`, `bridge_master.py`, `hblink.py`, `API.py`, - `utils.py`, and `log.py` did not identify another confirmed bug with the same - confidence as the fixed config/startup/support issues. -- The current full deterministic suite passes after the widened fixes. - -### Assumptions -- Remaining broad `except` blocks and legacy split-layer structure should be - treated as audit candidates, not bugs, unless a concrete failing path is - demonstrated. - -### Unresolved Questions -- The API layer has not yet had the same level of focused review as packet, - config, startup, alias, and reset handling. -- Long-gap HBP/FBP same-stream recovery remains a known improvement candidate, - not a completed fix. - -### Protocol-Sensitive Areas -- API/reset/options changes can affect live session control and should be tested - carefully if reviewed later. -- Long-gap recovery must not bypass loop-control, ACL, STUN, source-quench, or - rate-limiting behavior. - -### Inferred Invariants -- Confirmed bugs should have a demonstrated code path and a narrowly scoped - regression test before production changes are made. -- Deferred policy or observability work should remain documented separately from - fixed correctness bugs. - -## UDP Black-Box Harness Expansion - -### Findings -- The UDP black-box layer can sensibly mirror HBP-observable deterministic - behavior: startup/config parsing that affects admission, HBP registration, - DMRD routing, packet byte preservation, sequence duplicate/drop behavior, - terminator lifecycle behavior, and generated prompt output. -- OBP/FBP coverage remains out of scope for this increment because it requires - OpenBridge packet/HMAC/protocol-version emulation over UDP. - -### Assumptions -- UDP tests should cover high-value end-to-end behavior, not every deterministic - internal state transition. -- State-only behavior such as alias dictionary replacement and bridge-reset - flags remains better covered by the deterministic harness unless an external - UDP-visible symptom is needed. - -### Unresolved Questions -- Add OpenBridge/FBP UDP peer emulation later for enhanced metadata, BCSQ/STUN, - keepalive, and FBP long-gap behavior. -- Add recorded packet fixture replay and jitter/drop controls around realistic - cadence later. - -### Protocol-Sensitive Areas -- The current UDP expansion exercises HBP only; it must not be interpreted as - FBP/OpenBridge black-box coverage. -- UDP tests assert observable packet fields and bytes, not internal mutable - status dictionaries. - -### Inferred Invariants -- Deterministic tests remain the broad internal regression layer. -- UDP tests are the external integration confidence layer and should stay - loopback-only, opt-in, and isolated from real network traffic. - -### Resolution -- Added UDP scenario config knobs for global ACL fields and static TG lists. -- Added opt-in UDP coverage for global ACL false parsing through packet - admission, HBP data/control payload preservation, voice sequence wrap, - duplicate sequence `0` suppression, and post-terminator late packet - suppression. -- Existing UDP coverage for static routing and local TG9 TS2 dial-a-TG prompt - output remains in place. - -## UDP/FBP Black-Box Harness Expansion - -### Findings -- `hblink.py` OpenBridge/FBP v5 packets are observable over UDP as `DMRE` - envelopes with the DMR header/body, BER/RSSI, embedded protocol version, - timestamp, source server, source repeater, hop count and BLAKE2b hash. -- Enhanced OpenBridge forwarding is gated by recent `BCKA` state, so a useful - black-box FBP peer must send signed keepalive traffic before expecting - forwarded packets. -- FBP source-quench is represented by signed `BCSQ` packets keyed by TGID and - stream ID; the UDP harness can assert the external effect by verifying no - matching `DMRE` leaves FreeDMR for that stream. - -### Assumptions -- Initial FBP black-box coverage should focus on protocol-v5 enhanced FBP, - because that is the current default and carries the richest metadata. -- The harness may construct valid FBP transport envelopes and bridge-control - packets, but only production code should perform route-driven DMR field - rewrites. -- FBP peer emulation should stay loopback-only and opt-in with the existing UDP - test gate. - -### Unresolved Questions -- Add explicit black-box coverage for `BCST` STUN once desired externally - observable behaviour is selected for a multi-peer topology. -- Add older OpenBridge/FBP protocol-version fixtures later so metadata - assertions follow the protocol version actually negotiated for the session. -- Add recorded packet replay and jitter/drop controls after the synthetic FBP - path is stable. - -### Protocol-Sensitive Areas -- FBP packet signing must match the exact protocol version layout. Version 5 - hashes bytes through the hop-count field and includes source repeater - metadata; lower versions differ. -- FBP inbound network ID is carried in the embedded DMR peer-ID field and must - match the configured OpenBridge `NETWORK_ID`. -- Enhanced FBP liveness and source-quench are transport/control state, not DMR - payload mutation. - -### Inferred Invariants -- HBP-to-FBP static TG routing clears the slot bit because OpenBridge traffic is - carried as TS1 over the FBP transport. -- FBP-to-HBP static TG routing rewrites only the target HBP slot/TG fields - required by the active bridge target while preserving stream identity. -- A peer-requested `BCSQ` for a TG/stream suppresses subsequent outbound FBP - packets for that TG/stream without affecting unrelated routing. - -### Resolution -- Extended `tests/harness/udp_blackbox.py` with FBP constants, packet parsing, - v5 `DMRE` packet construction, bridge-control signing, generated OBP config - sections, and a `FbpPeer` loopback emulator. -- Added opt-in UDP tests for HBP-to-FBP static TG routing, FBP-to-HBP static TG - routing, FBP source-quench suppression, and inbound FBP network-ID rejection. -- Updated test and architecture documentation to describe the current FBP UDP - support and run commands. - -## UDP Unreliable-Link Simulation - -### Findings -- The UDP black-box harness can simulate unreliable links at the fake endpoint - send boundary without changing FreeDMR production behaviour. -- FreeDMR's current voice packet-control logic treats DMRD sequence numbers as - modulo-256, forwards forward progress, and discards delayed out-of-order - packets rather than buffering and reordering them. -- FBP trunks can carry arbitrary numbers of streams, so impairment scenarios - should be able to target one stream without implying that the whole trunk is - impaired. - -### Assumptions -- FreeDMR owns stream ID assignment in the routed/network path; unreliable-link - tests should not assume a repeater assigns a replacement stream ID after loss. -- FreeDMR intentionally does not implement a jitter buffer. In real-time AMBE - stream handling, late or out-of-order packets should generally be discarded - rather than reconstructed. -- UDP impairment tests model UDP/IP transport behaviour, not RF late entry, - AMBE FEC recovery, terminal behaviour or MMDVM jitter buffering. - -### Unresolved Questions -- Add burst-loss and blackout scenarios to document current timeout/continuation - behaviour over longer gaps. -- Add a whole-trunk impairment warning scenario later if an observable logging - rule is designed. -- Add multi-stream FBP impairment where one stream is delayed/reordered while - unrelated streams continue normally. - -### Protocol-Sensitive Areas -- Delayed packets must not override loop-control, STUN, ACLs, source-quench or - rate-limiting decisions. -- FBP impairment tests must preserve the correct signed protocol envelope for - the version under test; the impairment layer schedules transport sends and - does not mutate DMR or FBP packet bytes. - -### Inferred Invariants -- Transport simulation and protocol mutation remain separate. -- Reordered packets should be observable as missing/dropped packets at the - destination, not as a buffered corrected sequence. -- A damaged stream should not poison later streams on the same FBP trunk. - -### Resolution -- Added `LinkImpairment` to `tests/harness/udp_blackbox.py` with deterministic - drop, duplicate, jitter and delay scheduling for fake endpoint sends. -- Extended HBP and FBP fake endpoints so `send_stream()` / `send_fbp_stream()` - can apply impairment while preserving packet bytes. -- Added opt-in UDP tests for delayed out-of-order HBP packets and delayed - out-of-order FBP packets. Both assert that sequence `1` arriving after - sequence `2` is not replayed; the FBP test also verifies a following stream on - the same trunk still routes. - -## UDP Real-World Scenario Profiles - -### Findings -- The UDP black-box suite benefits from reusable scenario profiles because many - realistic tests need the same 30 ms voice-over packet sequences and impairment - patterns. -- Prompt interruption is externally observable over UDP: a generated local TG9 - TS2 prompt should not prevent a real HBP voice stream from routing after the - prompt has started. -- FreeDMR is commonly deployed in Docker with the hotspot proxy enabled, but - proxy/firewall behaviour is a separate integration boundary from direct - `bridge_master.py` UDP testing. - -### Assumptions -- Direct UDP tests should remain the current second layer and should not start - the hotspot proxy implicitly. -- Proxy/firewall tests should become a third opt-in layer so direct protocol - regressions remain isolated from packaging/proxy failures. -- Any firewall integration test must avoid changing the developer host firewall - unless it runs in Docker or uses a fake command runner. - -### Unresolved Questions -- Add a reliable voice-ident interruption trigger for the subprocess harness. - The production ident loop currently runs on a long interval, so a direct - generated-prompt interruption test gives faster coverage now. -- Decide whether proxy tests should run through Docker Compose, local subprocess - proxy mode, or both. -- Inspect external proxy/firewall code from the GitLab repo only when needed and - with network access explicitly available. - -### Protocol-Sensitive Areas -- Stream profiles generate DMR packet sequences; route-driven rewrites still - belong only to production code. -- Multi-stream FBP trunk tests should avoid HBP target-slot contention unless - contention is the behaviour under test. -- Prompt interruption tests must assert real routed traffic, not just absence of - prompt packets, because generated speech may have already queued packets. - -### Inferred Invariants -- Reusable stream/impairment profiles should be deterministic and named after - real deployment failure modes where possible. -- A generated prompt or ident must not permanently block real RF-originated - voice. -- Proxy tests are valuable, but they should be opt-in and isolated from the - direct UDP black-box harness. - -### Resolution -- Added `StreamProfile.voice_over()` for reusable 30 ms voice stream packet - sequences with optional header and terminator packets. -- Added named `ImpairmentProfiles` for clean links, provider-style reordering, - mobile flutter drops and duplicate UDP datagrams. -- Added a UDP prompt-interruption test that observes a local TG9 TS2 generated - prompt, injects real HBP voice, and verifies the real stream routes to another - master. -- Added a multi-stream HBP-to-FBP trunk test where one TG stream is reordered - and drops its late packet while another clean TG stream continues over the - same FBP peer. -- Documented a future third Docker/proxy integration layer for packaged - deployments and proxy/firewall behaviour. - -## UDP Hostile Packet Coverage - -### Findings -- The UDP black-box harness can exercise malformed and hostile packet paths - against a real `bridge_master.py` subprocess while keeping all traffic on - loopback. -- HBP short `DMRD` datagrams are expected to be ignored without disconnecting - the emulated repeater; a following valid packet should still route. -- FBP stale timestamp and max-hop enforcement are expected to source-quench the - affected TG/stream rather than forwarding to HBP targets. - -### Assumptions -- Bad FBP hashes should be ignored without a source-quench because the packet - did not authenticate. -- Short malformed FBP packets should be ignored and should not poison the peer - state for later valid traffic. -- Source-quench assertions should check the externally visible BCSQ TG/stream - fields, not internal `_laststrid` state. - -### Unresolved Questions -- Add optional subprocess log assertions later for the warning/error messages - produced by these hostile packet paths. -- Add bad source-server ID, prohibited OpenBridge slot and old protocol-version - cases once the next negative-path batch is selected. - -### Protocol-Sensitive Areas -- FBP hash corruption must mutate only the transport hash, not the DMR payload, - so the test isolates authentication handling. -- Stale timestamp and max-hop tests use valid hashes and valid network IDs so - they specifically exercise post-auth protocol gates. - -### Inferred Invariants -- Malformed/hostile UDP input must not crash the subprocess. -- Rejected FBP packets must not leak traffic to HBP repeaters. -- Authenticated but stale/over-hop FBP streams should produce BCSQ for the - affected TG/stream. - -### Resolution -- Added FBP packet-builder options for explicit timestamp and intentionally - corrupted hash generation. -- Added `FbpPeer.recv_opcode()` to capture bridge-control responses such as - BCSQ. -- Added opt-in UDP tests for short HBP `DMRD`, short FBP `DMRE`, bad FBP hash, - stale FBP timestamp and max-hop FBP handling. - -## UDP FBP Bridge-Control Coverage - -### Findings -- Enhanced OpenBridge/FBP targets require recent authenticated `BCKA` state - before HBP-originated traffic is forwarded to them. -- `BCSQ` is a stream/TG source-quench control and must authenticate before it - mutates suppression state. -- `BCST` STUN is a global OpenBridge traffic gate. It blocks FBP send/receive - paths but does not imply HBP-to-HBP traffic should stop. - -### Assumptions -- Invalid bridge-control hashes should be ignored without changing runtime - state. -- A valid `BCST` is intended to temporarily stop all FBP/OpenBridge traffic - until an operator/API path later clears the stun state. -- Black-box STUN assertions should isolate FBP effects from ordinary HBP bridge - routing, because valid HBP-to-HBP traffic can still be queued. - -### Unresolved Questions -- No un-STUN API path is currently covered; live operator/API semantics remain - deferred. -- Older protocol-version and unsupported-version bridge-control cases are still - future UDP fixture work. -- Subprocess log assertions for rejected bridge-control packets are still - optional future coverage. - -### Protocol-Sensitive Areas -- Bridge-control HMAC input depends on opcode: `BCVE` signs the version byte, - while `BCKA`, `BCSQ`, and `BCST` sign their full control bodies. -- `BCSQ` is scoped to the TGID and stream ID seen on the FBP/OpenBridge side. -- STUN should not override loop-control or reinterpret delayed stream packets. - -### Inferred Invariants -- No authenticated enhanced keepalive means no enhanced HBP-to-FBP forwarding. -- Invalid `BCSQ` does not suppress any later stream. -- Valid STUN blocks OpenBridge send/receive while leaving unrelated HBP routing - behaviour to the normal bridge rules. - -### Resolution -- Added corrupt bridge-control hash support to the UDP FBP control builder. -- Added an invalid BCSQ helper to the fake FBP peer model. -- Added opt-in UDP tests for enhanced keepalive gating, invalid BCSQ rejection, - and BCST STUN blocking OpenBridge traffic in both directions. - -## UDP FBP Protocol-Version Coverage - -### Findings -- `BCVE` is the explicit bridge-control version negotiation path. Downgrades, - unsupported versions and invalid hashes should not change the configured - outbound packet version. -- FBP v5 packets carry source repeater metadata. v4 packets use an older layout - without that field, but v4 is now treated as historical/deprecation context - rather than a protocol target to preserve long term. -- v1 remains important and supported as an open OBP interop protocol used by - other amateur DMR software. The important v1 bridge-instance path is - `bridge.py`, not primarily `bridge_master.py`. -- A signed v1 OpenBridge `DMRD` packet received on a v5-configured link is - refused before normal packet routing and FreeDMR responds with `BCVE`. -- The UDP harness can build both v4 and v5 envelopes while keeping the inner DMR - payload bytes generated by the same `PacketSpec`. It can also build signed v1 - OBP packets for refusal tests. -- `PROTO_VER` is read into `CONFIG['SYSTEMS'][...]['VER']`; historical v4 - behavior is now characterization coverage only. Going forward, expected - protocol support is v1 OBP where appropriate and v5 FBP. -- UDP expected-failure coverage now confirms two remaining protocol-version - issues: unsupported embedded `DMRE` version 6 is not rejected before routing, - and the v4 send layout currently carries the module default version byte - instead of the configured `PROTO_VER` value. -- Recorded packet fixture replay is now available for hex-encoded UDP payloads. - Replay preserves packet bytes and keeps protocol mutation inside FreeDMR. -- Subprocess log capture is available for black-box warning/error assertions. -- True voice-ident black-box interruption remains blocked by the fixed 914 - second production ident loop interval unless a test hook or long-running mode - is introduced. - -### Assumptions -- The generated black-box config uses protocol v5 by default, so failed BCVE - negotiation should leave outbound packets as v5. -- Accepting an inbound v4 packet is current behavior, but it is not a desired - long-term compatibility contract. -- Refusing v1 on a configured v5 link is the intended behavior because the - generated test config sets `PROTO_VER` to the current FBP version. This does - not contradict support for v1 through bridge instances. -- Protocol options and metadata layout should be asserted against the protocol - version carried by the packet or negotiated for the session. - -### Unresolved Questions -- Decide whether unsupported embedded `DMRE` versions should be rejected at the - parser seam before routing. The expected-failure test documents the current - leak. -- Decide whether the v4 send branch should write the configured protocol - version byte rather than the module-level `VER` constant. The - expected-failure test documents the current mismatch; this may become moot if - v4 is removed. -- Future v1 interop testing should inspect `bridge.py`, Docker startup files and - the GitLab wiki for bridge-instance behavior. -- Add a production-supported fast trigger for voice-ident subprocess tests, or - keep real ident coverage in deterministic tests and live/manual testing. - -### Protocol-Sensitive Areas -- v5 hash input includes the source repeater field and hop byte. -- v4 hash input omits source repeater and places the hop byte immediately after - source server metadata. -- `BCVE` signs only the one-byte version payload, not the opcode. -- v1 OBP packets use the older `DMRD` envelope and HMAC over the 53-byte packet - body; a v5-configured receiver refuses them before validating/routing as v1. - -### Inferred Invariants -- Invalid or rejected `BCVE` messages do not mutate the outbound protocol - version. -- v4 inbound packets can currently route to HBP using the v4 metadata layout, - but this is characterization/deprecation context. -- v1 packets on a v5-configured link produce a `BCVE` response and do not route - to HBP targets. -- v1 remains supported where a system is intentionally operating as an OBP - bridge instance. -- Tests must distinguish bridge-control negotiation from per-packet metadata - parsing. -- Recorded fixture replay is transport simulation only; fixture loading must - not rewrite protocol fields. -- Log assertions should supplement packet assertions and should not become the - only evidence of routing behavior. - -### Resolution -- Added a version parameter to the UDP FBP packet builder and fake FBP send - helpers. -- Added invalid `BCVE` generation to the fake FBP peer model. -- Added opt-in UDP tests for BCVE downgrade rejection, unsupported BCVE - rejection, invalid BCVE rejection and inbound v4 packet routing. -- Added signed v1 OBP packet generation and an opt-in UDP test that verifies v1 - traffic on a v5-configured link is rejected with BCVE and does not leak to HBP. -- Added a UDP test documenting current v4 downgrade to the older outbound - layout as characterization/deprecation context. -- Added expected-failure UDP tests for unsupported embedded FBP packet version - rejection and configured-v4 version-byte consistency. -- Added recorded packet fixture replay support and a UDP fixture replay test. -- Added burst-loss and duplicate-UDP profile coverage for HBP streams. -- Added subprocess log capture and log assertions for malformed short FBP - packets and bad FBP hashes. - -## 2026-05-23 - `bridge.py` Backport Scope - -### Findings -- `bridge.py` carries the older conference-bridge voice path and does not - implement `bridge_master.py` dial-a-TG, data-gateway, generated-prompt or - configuration-option handling. -- The shared HBP/OBP group voice packet-control path in `bridge.py` still used - simple integer comparisons for the one-byte DMRD sequence value, so valid - modulo-256 forward progress after wrap could be rejected as out-of-order. -- `bridge.py` HBP slot state retained `lastSeq` and `lastData` across new - streams on the same slot. -- `bridge.py` OBP terminator handling only marked `_fin` when live reporting was - enabled, so late same-stream packets could avoid the finished-stream guard - when reports were disabled. -- `bridge.py` HBP terminator handling logged/report-ended streams but did not - have the finished-stream suppression already added to `bridge_master.py`. - -### Assumptions -- Only behavior already present in `bridge.py` should be corrected: group voice - stream routing, sequence tracking and stream lifecycle. -- `bridge.py` should not gain dial-a-TG, group data, DATA-GATEWAY, prompt, - ident, static-TG, default-reflector or broader FBP negotiation features as - part of this backport. -- The same modulo-256 sequence policy used by `bridge_master.py` applies to - `bridge.py` HBP and OBP voice packets because both receive the same DMRD - sequence byte. - -### Unresolved Questions -- A dedicated `bridge.py` runtime harness could be added later if bridge - instances need the same UDP-level coverage as `bridge_master.py`. -- `bridge.py` still has older reporting and bridge-rule behavior that was not - reviewed in this pass. - -### Protocol-Sensitive Areas -- DMRD sequence numbers are one byte and wrap at 255 to 0. -- A modulo delta greater than 127 is treated as old/out-of-order rather than - forward progress; this preserves the loop-control safety posture discussed - for `bridge_master.py`. -- Voice terminator state must suppress late same-stream packets without - overriding loop-control or adding a jitter buffer. - -### Inferred Invariants -- A new HBP stream must start with fresh duplicate/sequence state. -- OBP finished-stream suppression must not depend on whether the live report - socket is enabled. -- `bridge.py` backports should stay limited to bug fixes for behavior the file - already implements. - -### Resolution -- Added `dmrd_seq_delta()` to `bridge.py` and used it for existing HBP/OBP - duplicate, out-of-order and missed-packet checks. -- Reset HBP duplicate/sequence state on new streams and after voice - terminators. -- Marked OBP streams finished on terminator regardless of reporting state and - reset per-stream sequence tracking. -- Added HBP finished-stream state to suppress late same-stream packets after a - terminator. -- Added a lightweight `tests/test_bridge_backports.py` check for the shared - modulo helper and documented the narrower bridge backport coverage. - -## 2026-05-23 - `API.py` Initial Review - -### Findings -- `API.py`, `FD_APIUserDefinedContext.validateKey()`, writes `dmrid` and every - `peerid` to stdout with `print()`. That bypasses FreeDMR logging and can leak - API authentication activity into daemon stdout or supervisor logs. -- `API.py`, `FD_APIUserDefinedContext.getoptions()`, reads - `CONFIG['SYSTEMS'][system]['OPTIONS']` directly. `hblink.py` deliberately - deletes that key when an HBP session disconnects and no default options are - configured, so an authenticated API `getoptions()` can raise `KeyError` - instead of returning a stable "no current options" value. -- The former `API.py`, `FD_API.getconfig()` and `FD_API.getbridges()`, declared - `_returns=Unicode()` but returned the live `CONFIG` and `BRIDGES` - dictionaries. Those dictionaries contain bytes and nested structures, so the - declared Spyne return type did not match the actual value. -- `bridge_master.py`, `kill_server()`, reads - `CONFIG['GLOBAL']['_KILL_SERVER']` directly, but the key is only set by the - signal handler or `API.py` `killserver()`. No startup default is visible in - `config.py`, so the timed kill-server loop can raise `KeyError` before any API - or signal sets the flag. -- The former `bridge_master.py`, `config_API()`, accepted `_config` but - installed `FD_APIUserDefinedContext(CONFIG, _bridges)` using the module - global. In normal runtime this was probably the same object, but the function - ignored its parameter and was harder to test in isolation. -- The former `api_client.py` used Twisted XML-RPC against port 7080, while - `bridge_master.py` started the Spyne HTTP/JSON API on TCP port 8000. The - sample client did not match the API server configured by FreeDMR. - -### Assumptions -- The API is an optional management surface and should not affect packet routing - unless an authenticated method mutates live session/config state. -- User-level API auth is intended to use the options `KEY` associated with the - connected HBP peer/repeater. -- Although legacy HBlink can host multiple peers on one master, FreeDMR's - intended deployment model is one HBP peer per master. Config defaults and - samples set `MAX_PEERS: 1`, and the proxy maps each external peer ID to its - own backend destination port/master instance. -- System-level API auth is intended to use `GLOBAL.SYSTEM_API_KEY`, loaded or - generated at startup. -- API `getoptions()` should be safe to call even when a peer has disconnected or - has no current session options. - -### Unresolved Questions -- If config/bridge inspection is reintroduced, it should use bounded, - JSON-safe snapshots or a separate worker path so it cannot delay voice - processing in the reactor. - -### Protocol-Sensitive Areas -- API-provided `OPTIONS` feeds the same parser as HBP `RPTO` options and can - change dial-a-TG, static TG, timer, voice-ident and announcement-language - behavior. -- API reset toggles `_reset`, which is consumed by `bridge_reset()` and packet - admission guards. -- API killserver toggles `_KILL_SERVER`, which is consumed by the Twisted timed - shutdown loop. - -### Inferred Invariants -- API methods should not throw internal exceptions for normal disconnected or - no-options session states. -- API authentication details should use FreeDMR logging, not raw stdout. -- Runtime control flags should have false defaults before timed loops read them. -- Public API response declarations should match the objects returned. - -### User-Confirmed API Semantics -- `getoptions()` should return a clear response when no live options are - available. -- API `setoptions()` should receive the full `OPTIONS` string; the API should - not silently add or preserve `KEY=...`. -- User-level `reset(dmrid, key)` was intended to act on the matching HBP - session associated with the supplied DMR ID. Because FreeDMR expects one peer - per master, the current system-level `_reset` action may be the correct - implementation once `validateKey()` has proven the peer belongs to that - master. -- User confirmed nobody is likely using the experimental Spyne API and replacing - it now is preferred because Spyne dependency handling is awkward. -- The API must not delay live voice processing; request handlers should avoid - blocking work and expensive live-state serialization. - -### Resolution -- Replaced the Spyne API layer with a small Twisted HTTP/JSON resource in - `API.py`. -- Removed Spyne imports from `bridge_master.py` and removed Spyne from - `requirements.txt`. -- Kept API operations to small in-memory control-plane mutations: version, - health, reset, options get/set, system kill and resetall. -- Removed live `getconfig()`/`getbridges()` API endpoints to avoid potentially - expensive serialization in the voice process reactor. -- Added a small request-body limit so API calls cannot submit large JSON bodies - that would delay the reactor. -- Added a safe `_KILL_SERVER` default and changed the shutdown loop to read the - flag with `.get()`. -- Updated the sample API client to use HTTP/JSON on port 8000. - -## 2026-05-23 - Support Module Review, Batch 1 - -### Findings -- `AMI.py` appears to be live when `ALLSTAR.ENABLED` is true: `bridge_master.py` - creates `AMIOBJ` and dial-a-TG AllStar control calls `AMIOBJ.send_command()`. -- `bridge_master.py` logs the configured AllStar password when setting up AMI. - If AllStar is current, this is a credential exposure bug. -- `AMI.py`, `AMIClient.lineReceived()`, prints every AMI response line directly - to stdout instead of using the FreeDMR logger. This can leak operational - details and makes service logging inconsistent. -- `AMI.py` stores command and credentials on the nested `AMIClient` class object - before connecting. Closely spaced `send_command()` calls could overwrite each - other's command state before the TCP client sends it. -- `AMI.py`, `AMI.closeConnection()`, references `self.transport`, but the outer - `AMI` object is not the protocol instance and does not own that attribute. -- `utils.py`, `try_download()`, disables TLS certificate verification for alias - downloads with `ssl._create_unverified_context()`. This trades compatibility - for integrity risk on configured HTTPS alias/checksum URLs. -- `const.py` defines `ID_MAX = 16776415`, while `bridge_master.py` now uses - `DMR_ID_MAX = 16777215` for dial-a-TG validation. Because `config.py` uses - `const.ID_MAX` for ACL building, max-ID handling may be inconsistent. -- `read_ambe.py`, `readAMBE.readfiles()`, returns `False` on missing voice pack - files, but startup later iterates `words.keys()`. A missing configured audio - language can fail startup with a secondary attribute error rather than a clear - "missing voice pack" error. -- `mk_voice.py`, `pkt_gen()`, still uses a random stream ID for generated voice - prompts. Collision probability is low, but generated prompts share the live - stream namespace. - -### Assumptions -- AllStar/AMI support is optional but still intended to work when enabled. -- Alias downloads are operational convenience data, but stale or modified alias - data can affect dashboard/reporting clarity and possibly server ID metadata. -- Audio packs may be provided by deployment packaging even when not obvious from - minimal test fixtures. - -### Unresolved Questions -- Was disabled TLS verification for alias downloads intentional because of old - CA or embedded-platform compatibility problems? -- Is `const.ID_MAX = 16776415` intentional, or should shared ACL validation use - the DMR 24-bit maximum `16777215`? -- Should missing configured voice prompt languages be a clear fatal startup - error, or should FreeDMR fall back to a known shipped language? - -### User-Confirmed Status -- AllStar/AMI support is current enough to tidy up. - -### Protocol-Sensitive Areas -- AllStar control is driven from dial-a-TG private-call handling and must not - block or delay voice packet processing. -- Alias and server ID files are read at startup and may influence reporting, - validation and operational visibility. -- DMR ID and TG range constants must match the 24-bit field constraints unless a - narrower range is deliberately reserved by FreeDMR policy. -- Voice prompt packet generation shares packet timing, TG/slot routing and - stream lifecycle semantics with live DMRD traffic. - -### Inferred Invariants -- Runtime logs must not expose configured passphrases or management credentials. -- Optional external control paths should use FreeDMR logging and reactor-safe - Twisted patterns. -- Startup configuration failures should be explicit enough for sysops to fix - without packet-level debugging. - -## 2026-05-23 - Auxiliary Script Review, Batch 2 - -### Findings -- `app_template.py` and `blank_app.py` compile and look like HBlink example - scaffolding rather than current FreeDMR packet-routing services. -- `bridge_all.py` and `bridge_all_master.py` compile, but their ACL checks use - `TG1_ACL` and `TG2_ACL`. Current config parsing creates `TGID_TS1_ACL` and - `TGID_TS2_ACL`, so these scripts appear stale if they are still run. -- `bridge_all.py` and `bridge_all_master.py` use the older sequence-loss check - that was already noted as rollover-sensitive in code comments. -- `report_receiver.py` and `report_sql.py` are external reporting clients and - use `pickle.loads()` on reporting socket payloads. This is acceptable only if - the report source is trusted and local/private. -- `report_sql.py` inserts report events by formatting SQL strings directly. - Event fields originate from the reporting socket and should be parameterized - if this client is current. -- `playback.py`, `playback_file.py` and `play_ambe.py` compile, but they use - blocking `sleep()` calls in Twisted callbacks or packet receive paths. These - are suitable for lab/playback utilities, not live voice-process services. -- `docker-configs/supervisord.conf` starts `playback.py` as a supervised - process by default in the proxy image, so the blocking-playback concern may - matter in packaged deployments if that program is not intentionally enabled. -- `hotspot_proxy_v2.py` is current enough to matter: `hblink.py` has explicit - `PRIN` and `PRBL` handling and sample HBP config enables `PROXY_CONTROL`. -- `hotspot_proxy_v2.py` installs a SIGTERM handler named `sigt()` that only - prints `oooh` and does not stop the reactor. Under supervisor/container - shutdown, the proxy may not terminate cleanly on SIGTERM. -- `hotspot_proxy_v2.py` falls back to an internal default configuration if any - required `[PROXY]` option is missing or invalid. That can start a proxy on - default ports after a config typo instead of failing closed. -- `hdstack/hotspot_proxy_v2.py` is a separate older proxy copy with materially - different behavior and one visible typo path (`_data` in DMRA handling), but - it may be legacy or a special HDStack deployment copy. -- Several Dockerfiles still build from GitHub while `Dockerfile-ci` copies the - local tree. Some images therefore may not include local checkout changes when - built from this repository directory. -- `docker-configs/Dockerfile-hbmonv2` creates user `hbmon` but runs - `chown -R radio: /opt/HBMonv2`; if this Dockerfile is current, that user name - mismatch can fail image build. - -### Assumptions -- The auxiliary HBlink example scripts are less important than - `bridge_master.py`, `hblink.py`, `bridge.py`, `config.py`, proxy and Docker - packaging. -- The reporting socket is normally intended for trusted dashboards/clients, but - it may still be exposed on configured TCP interfaces. -- The top-level `hotspot_proxy_v2.py` is the main packaged proxy; the `hdstack` - copy may exist for a special multi-instance deployment. - -### Unresolved Questions -- Should Dockerfiles that clone from GitHub be kept, or should maintained - Docker builds copy the local source tree like `Dockerfile-ci`? - -### User-Confirmed Status -- `bridge_all.py` and `bridge_all_master.py` are legacy/experimental; leave - them for now. -- `playback.py`, `playback_file.py` and `play_ambe.py` are a combination of lab - tools, experimental code and legacy code; they are low priority. -- `report_receiver.py` and `report_sql.py` are lightly used/current: they feed - `https://freedmr-lh.gb7fr.org.uk/` in one deployment. -- `hdstack/hotspot_proxy_v2.py` is experimental capacity work; leave detailed - review for later. - -### Follow-Up Findings For Current Reporting Clients -- `report_sql.py`, `send_mysql()`, performs reconnect attempts and MySQL writes - directly in the Twisted reactor path. If the database stalls or reconnects - slowly, the reporting client can stop processing its TCP report stream. -- `report_sql.py`, `send_mysql()`, uses string formatting to build the `insert - into feed` SQL statement. Even on a trusted report stream, quotes or commas in - fields can break inserts; parameterized SQL would be safer and simpler. -- `report_sql.py`, `send_mysql()`, closes the cursor only on error, not after - successful inserts. -- `report_sql.py`, `reportClientFactory.buildProtocol()`, returns - `self.proto(db, reactor)` using module globals rather than `self.db` and - `self.reactor`. It works in the current `__main__` path but makes the factory - unnecessarily fragile. -- `report_receiver.py` CLI flags such as `--events 0`, `--config 0` and - `--bridges 0` are parsed as strings, so `"0"` is truthy and enables the - output. These should be real booleans or checked against `"1"`. -- Both report clients use `pickle.loads()` for config/bridge snapshots. User has - confirmed current use is a known deployment, so this should be documented as a - trusted-report-socket assumption rather than changed blindly. - -### Protocol-Sensitive Areas -- Proxy session mapping is part of the intended one-HBP-peer-per-master - architecture and must preserve HBP registration/control packet ordering. -- Proxy `PRIN` and `PRBL` packets affect client IP awareness and dynamic - firewall/source-quench behavior. -- Playback utilities synthesize or replay DMRD voice frames and can disturb - timing assumptions if run in the same reactor as live services. -- Reporting clients consume the same live-report stream used by dashboards; - field shape changes can break external consumers. - -### Inferred Invariants -- Long-running daemons under Docker/supervisor should exit cleanly on SIGTERM. -- Packaged default process lists should avoid optional lab tools unless the - deployment intentionally enables them. -- Config parse failures for network-facing daemons should fail closed rather - than silently opening default listeners. - -## 2026-05-23 - Packaging, Config and Documentation Review, Batch 3 - -### Findings -- `rules_SAMPLE.py` is an empty static-routing skeleton and compiles. -- `systemd-scripts/freedmrrepeater.service` appears stale: it uses - `/opt/FreeDMR` and `./config/hblink.cfg`, while current sample configs and - Docker entrypoints run from `/opt/freedmr` with `freedmr.cfg`. -- `pyvenv.cfg` is tracked at repository root. This makes the source checkout - look like a Python virtual environment and can confuse tooling or developers. -- Current FreeDMR/Docker config samples mostly use current ACL names - `TGID_TS1_ACL` and `TGID_TS2_ACL`. -- `FreeDMR-SAMPLE.cfg` and `FreeDMR-SAMPLE-commented.cfg` still show - `PROTO_VER: 2`, while Docker config documents `PROTO_VER: 5` for FreeDMR FBP - and `PROTO_VER: 1` for OBP/external software. -- `hblink-SAMPLE.cfg` is intentionally HBlink-flavored and lacks current - FreeDMR-only defaults such as API, dial-a-TG option defaults and proxy - control. -- `hdstack/*.cfg`, `loro.cfg` and `playback_file.cfg` appear special-purpose - configs rather than main FreeDMR defaults. -- `docker-configs/Dockerfile-noproxy`, `Dockerfile-proxy` and - `Dockerfile-hdstack` clone from remote GitHub rather than copying the local - tree, so building them from this checkout may not include current local - changes. `Dockerfile-ci` does copy the local source tree. -- `docker-configs/entrypoint-proxy` accepts `BRIDGE_SERVER=1` and runs - `bridge.py -c freedmr.cfg -r rules.py`; `bridge.py` supports `-r`, so this - entrypoint matches the maintained bridge path. -- `docs/api.md`, `docs/testing.md` and `docs/test-harness-design.md` reflect - the new HTTP/JSON API and current deterministic/UDP harness split. - -### Assumptions -- Docker is the preferred general deployment path. -- `Dockerfile-ci` is the maintained local-build path for this working tree. -- `hblink-SAMPLE.cfg` remains useful as an upstream-style reference and should - not be forced into FreeDMR master semantics unless the project wants that. - -### Unresolved Questions -- Should the non-CI Dockerfiles be kept as remote-clone recipes, or updated to - copy local source for reviewable builds? -- Should `FreeDMR-SAMPLE*.cfg` be updated from `PROTO_VER: 2` to the current - documented FreeDMR default of `5`, while retaining notes that OBP/external - bridges use `1`? - -### User-Confirmed Status -- `report_receiver.py` and `report_sql.py` are kind-of current: they are used in - one deployment to feed `https://freedmr-lh.gb7fr.org.uk/`. -- `hdstack/hotspot_proxy_v2.py` is experimental capacity work; defer until the - capacity design is discussed. -- Disabled TLS verification in alias downloads is probably intentional; leave - behavior unchanged for now. -- Docker containers are the recommended deployment path because they reduce - support overhead; the systemd unit may exist but is not the main focus. -- Root `pyvenv.cfg` probably should not be tracked. - -### Protocol-Sensitive Areas -- `PROTO_VER` controls OBP/FBP packet option ordering and available fields; test - fixtures must match the negotiated/configured protocol version. -- The service manager and container entrypoints define which components are - actually live in packaged deployments, including proxy and optional playback. - -### Inferred Invariants -- Maintained sample configs should not encourage obsolete FBP protocol versions - unless backward compatibility is being intentionally demonstrated. -- Runtime packaging should start the same code and config layout that users are - expected to operate. - -### Resolution -- Tidied current AllStar/AMI support without changing dial-a-TG packet logic: - `bridge_master.py` now redacts the configured AMI password in startup logs, - `AMI.py` logs AMI responses through the module logger instead of printing raw - lines, and AMI command/auth state is held on protocol instances rather than - shared on the protocol class. -- Changed `AMI.closeConnection()` to disconnect the Twisted connector it owns - instead of referencing a nonexistent outer `transport` attribute. -- Updated `report_sql.py` so report events schedule database writes with - `reactor.callInThread()`, use a lock around the shared DB connection, execute - the existing feed insert with parameterized SQL, close cursors in `finally`, - and build clients with the factory's `self.db` / `self.reactor`. -- Updated `report_receiver.py` so CLI flags such as `--events 0`, - `--config 0`, `--bridges 0`, and `--stats 0` parse as false. -- Removed tracked root `pyvenv.cfg`. -- Added `tests/test_auxiliary_tools.py` for AMI protocol state, report receiver - flag parsing and report SQL parameterized insert behavior. -- Updated `docs/testing.md` with the auxiliary utility coverage. - -## 2026-05-23 - Proxy And Shared Constant Review - -### Findings -- `hotspot_proxy_v2.py` parsed environment booleans with Python `bool()`. - Because `bool("0")` is true, Docker settings such as `FDPROXY_IPV6=0` enabled - the option instead of disabling it. -- `hotspot_proxy_v2.py` installed a SIGTERM handler that only printed `oooh`. - Under Docker/supervisor shutdown, the proxy could ignore the normal - termination signal until forcibly stopped. -- `hotspot_proxy_v2.py` falls back to an internal default config when `[PROXY]` - is absent or invalid. This looked risky in isolation, but the current Docker - config files do not include a `[PROXY]` section, so changing this would alter - deployment behavior and needs a packaging decision. -- `const.ID_MAX` remains `16776415` while other current code treats the DMR - 24-bit all-call value `16777215` as the upper reserved boundary. The narrower - shared ACL max may be intentional reservation or a historical typo; user was - not sure, so it is left unchanged. - -### Assumptions -- The top-level `hotspot_proxy_v2.py` is the current packaged proxy. -- Docker environment variables named with `=0` are intended to disable the - feature. -- The proxy's default internal configuration is currently part of Docker - startup behavior when no `[PROXY]` section is supplied. - -### Unresolved Questions -- Should proxy config fallback remain the default Docker behavior, or should - Docker configs grow an explicit `[PROXY]` section before the fallback is made - stricter? -- Is `const.ID_MAX = 16776415` a deliberate FreeDMR policy limit, or should ACL - validation use the full 24-bit DMR maximum/reserved all-call boundary? - -### Protocol-Sensitive Areas -- Proxy `PRIN` and `PRBL` are part of client-source awareness and dynamic - blocklisting; changes here affect HBP client admission and abuse handling. -- ACL ID maxima affect which DMR IDs and TGs can be expressed in config, even - when packet parsing itself can carry the full 24-bit field. - -### Inferred Invariants -- `FDPROXY_*` environment booleans should parse like config booleans, not Python - truthiness of non-empty strings. -- Containerized long-running daemons should handle SIGTERM as graceful shutdown. - -### Resolution -- Added `bool_from_env()` in `hotspot_proxy_v2.py` and applied it to - `FDPROXY_IPV6`, `FDPROXY_STATS`, `FDPROXY_CLIENTINFO`, and the commented debug - override path. -- Changed the proxy SIGTERM handler to use the same graceful shutdown handler as - SIGINT. -- Added auxiliary test coverage for proxy environment boolean parsing. - -## 2026-05-23 - Voice Embedded LC Carry-Over - -### Findings -- `bridge_master.py` regenerated embedded LC for every voice burst B-E on every - forwarded voice stream, even when the outbound TG matched the inbound packet - destination TG. -- That unconditional regeneration preserves late-entry group voice LC for - dial-a-TG/TG-mapped forwarding, but it also overwrites non-routing embedded LC - payloads that may carry Talker Alias or in-call GPS information. - -### Assumptions -- The only production packet fields that require embedded-LC regeneration in the - current bridge path are routing identity fields, especially a changed target - TG. -- Same-TG forwarding should preserve DMR payload bytes unless another explicit - production rewrite is required. -- Dial-a-TG/reflector forwarding still requires embedded LC to be regenerated - for the reflector TG seen by the destination side. - -### Unresolved Questions -- A fuller embedded-LC assembler/classifier may still be useful later so - FreeDMR can preserve Talker Alias/GPS cycles even across TG-mapped forwarding. - The narrow change here does not buffer or reorder live voice bursts. -- Real RF testing should confirm that preserving same-TG embedded LC does not - expose dashboard or terminal interoperability assumptions from earlier - generated-LC behavior. - -### Protocol-Sensitive Areas -- Voice burst B-E payload bits 116..148 carry embedded LC fragments. Rewriting - them is a protocol mutation, not transport simulation. -- Full LC voice header/terminator rewrite remains unchanged. -- Dial-a-TG and TG-mapped bridge targets still need regenerated embedded LC so - late-entry receivers see the destination TG FreeDMR is actually transmitting. - -### Inferred Invariants -- Preserve packet bytes unless FreeDMR intentionally rewrites them for routing. -- Same-TG forwarding should not destroy embedded Talker Alias/GPS-like payloads. -- TG-mapped forwarding should prefer routing correctness over opaque embedded-LC - preservation. - -### Resolution -- Added a small `target_requires_emb_lc_rewrite()` helper in `bridge_master.py` - and gated the four voice burst B-E embedded-LC rewrite sites on a changed - target TG. -- Added deterministic harness coverage for HBP-to-HBP, HBP-to-FBP, FBP-to-HBP - and FBP-to-FBP same-TG voice embedded-LC payload preservation. -- Added deterministic coverage that TG-mapped forwarding still rewrites the - payload and destination TG. -- Added black-box UDP coverage that same-TG HBP voice forwarding preserves the - DMR payload observed by another registered repeater. -- Updated test and harness architecture documentation. - -## 2026-05-23 - In-Call TA/GPS Log Observability - -### Findings -- `dmr_utils3.decode_emblc()` does not match the MMDVMHost embedded-LC bit - mapping closely enough to use for user-facing Talker Alias/GPS diagnostics. - A simple encode/decode fixture changed the TA LC bytes. -- MMDVMHost's `CDMREmbeddedData` implementation unpacks embedded LC by arranging - the four 32-bit burst fragments down 16-bit columns, then extracting the 72 LC - payload bits from the data rows. - -### Assumptions -- System-log TA/GPS visibility should be passive observability. It must not - change packet routing, duplicate/out-of-order handling, or packet rewrite - behaviour. -- Logging should only happen for accepted voice packets after packet-control - filters, so duplicate or late packets do not produce misleading log entries. -- The first useful diagnostic output is decoded TA text/progress, GPS decimal - latitude/longitude, and raw LC hex for comparison against external tools. - -### Unresolved Questions -- The local decoder currently follows MMDVMHost's column unpacking and payload - extraction but does not yet port its Hamming(16,11,4) correction or five-bit - CRC validation. If noisy embedded LC from real RF produces false positives, - porting those checks is the next step. -- Talker Alias vendor/profile differences may need additional text-format - handling after live RF testing. - -### Protocol-Sensitive Areas -- Embedded LC fragments are taken from accepted voice bursts B-E only. -- Talker Alias FLCOs are treated as `0x04` header and `0x05..0x07` blocks. -- GPS Info FLCO is treated as `0x08`; logged coordinates are decoded from the - embedded LC payload bits and raw LC hex is included for validation. - -### Inferred Invariants -- Observability must not mutate packet bytes. -- Embedded-LC diagnostic decode should be based on MMDVMHost's mapping, not - `dmr_utils3.decode_emblc()`. -- Logs should contain enough raw data to compare against MMDVMHost or RF-side - captures. - -### Resolution -- Added a local MMDVMHost-style `decode_embedded_lc()` helper in - `bridge_master.py` for logging only. -- Added accepted-packet logging for in-call Talker Alias and GPS embedded LC in - both HBP and OBP receive paths. -- Added deterministic and UDP black-box tests that verify TA and GPS log output. -- Updated testing and harness architecture documentation. - -## 2026-05-23 - Standalone MMDVMHost Embedded LC Codec - -### Findings -- A correct embedded LC implementation needs more than the column unpacking used - for passive logging. MMDVMHost also applies Hamming(16,11,4) row correction, - column parity, and the DMR 5-bit checksum. -- Keeping this work in a standalone module lets FreeDMR prove codec behaviour - before changing packet forwarding or LC rewrite paths. - -### Assumptions -- `freedmr_dmr_codec.py` should be FreeDMR-owned code, not a drop-in replacement - for all of `dmr_utils3` yet. -- The first useful API should operate on complete embedded LC cycles and DMR - payload embedded-LC slices, because that is the boundary needed for future - selective TA/GPS preservation. -- Existing packet paths should not be switched to the new codec until the module - has direct tests and at least initial live/fixture validation. - -### Unresolved Questions -- The standalone codec has synthetic and internally checked fixtures, but should - still be compared with real MMDVMHost captures or known-good RF samples before - it becomes the production rewrite encoder. -- Full LC, slot type, and other BPTC helpers may also be worth moving into this - module later, but only after embedded LC is proven. - -### Protocol-Sensitive Areas -- Embedded LC encode/decode uses the MMDVMHost `CDMREmbeddedData` matrix mapping. -- Hamming(16,11,4) correction is applied row-by-row before column parity and - checksum validation. -- The codec returns four 32-bit embedded LC fragments suitable for voice bursts - B-E and helpers to insert/extract those fragments from 33-byte DMR payloads. - -### Inferred Invariants -- Codec tests must run independently of `bridge_master.py` routing state. -- A single bit error in an embedded LC row should be corrected; uncorrectable row - errors should be rejected. -- Future packet-path integration should preserve the existing real-time no-jitter - behaviour unless explicitly revisited. - -### Resolution -- Added `freedmr_dmr_codec.py` with MMDVMHost-style embedded LC encode/decode, - Hamming(16,11,4), column parity, 5-bit checksum, and DMR payload slice helpers. -- Added `tests/test_freedmr_dmr_codec.py` covering TA/GPS round trips, expected - encoded fragment placement, single-bit correction, uncorrectable error - rejection, and payload insertion/extraction helpers. -- Updated test and harness documentation. The new module is not wired into - packet forwarding yet. - -## 2026-05-23 - Standalone Full LC and Slot Type Codec - -### Findings -- The next production-relevant codec operations after embedded LC are full LC - header/terminator BPTC generation and slot type encode/decode. These are the - operations FreeDMR already uses for generated voice, dial-a-TG/TG-mapped LC - rewrite, and frame classification. -- Full LC generation depends on RS(12,9) parity, DMR header/terminator parity - masks, Hamming(15,11,3), Hamming(13,9,3), and the BPTC(196,96) interleaver. -- Slot type generation depends on Golay(20,8,7). The decoder can safely correct - up to three bits in the standalone layer before any production path chooses to - use it. - -### Assumptions -- This remains a proving layer. `bridge_master.py`, `bridge.py`, and - `mk_voice.py` should continue using their existing runtime codec paths until - we explicitly decide to integrate the new module. -- The compatibility baseline for this step is the current FreeDMR/dmr_utils3 - output, because changing generated LC bytes would be a production behaviour - change. -- LC classification should expose routing-relevant fields without deciding - routing policy. - -### Unresolved Questions -- The full LC decoder currently mirrors the existing FreeDMR behaviour by - extracting the LC payload from the BPTC matrix. It does not yet apply full - production-grade correction/validation to noisy received headers. -- Real MMDVMHost or RF capture vectors should still be added before using this - module as the production encoder/decoder. - -### Protocol-Sensitive Areas -- Full LC header and terminator parity masks differ. A wrong mask changes - whether receivers treat the LC as a voice header or terminator. -- Slot type bits carry color code and DMR data type; mutating them can change - packet class interpretation. -- LC byte layout is preserved as options, target/destination ID, then source ID - for the current FreeDMR voice paths. - -### Inferred Invariants -- Standalone codec tests must not require `bridge_master.py` globals or network - harness setup. -- Codec helpers must not rewrite packet bytes by themselves; they only encode or - decode caller-provided fields. -- Future integration should compare output byte-for-byte against existing - production behaviour before replacing `dmr_utils3` calls. - -### Resolution -- Extended `freedmr_dmr_codec.py` with full LC header/terminator encode/decode, - RS(12,9) LC parity, Hamming(15,11,3), Hamming(13,9,3), Golay(20,8,7) slot - type encode/decode and a small LC classifier. -- Extended `tests/test_freedmr_dmr_codec.py` with fixtures matching the current - codec output, parity-mask checks, group/unit LC classification, slot type - fixtures, three-bit correction and uncorrectable slot type rejection. -- Updated test and architecture documentation. Runtime packet forwarding is not - changed by this step. - -## 2026-05-23 - Embedded LC Logging Uses Standalone Codec - -### Findings -- `bridge_master.py` still had a local embedded-LC unpacker for TA/GPS logging - after the standalone codec module was added. -- That local helper did not perform the Hamming, column parity, or checksum - validation now available in `freedmr_dmr_codec.py`. - -### Assumptions -- TA/GPS logging is observability only. Decode failures must not interrupt voice - packet handling or change routing. -- Moving logging decode onto the standalone codec is a safe first production - integration because it does not rewrite packet bytes. -- Existing hand-coded TA/GPS test payloads should be replaced with payloads - generated by the standalone encoder so tests exercise valid embedded-LC - cycles. - -### Unresolved Questions -- Live RF may produce noisy embedded LC that is now rejected instead of being - logged from a best-effort unpack. That is preferable for avoiding false - positives, but should be watched during RF testing. -- Future work may replace `dmr_utils3` LC generation in packet rewrite paths, - but that is not part of this change. - -### Protocol-Sensitive Areas -- Embedded LC validation is stricter than the previous logging-only unpacker. - This affects only whether a TA/GPS log line is emitted. -- Packet forwarding, LC rewrite decisions, and payload bytes are unchanged by - this integration. - -### Inferred Invariants -- Codec decode errors in observability paths are non-fatal. -- TA/GPS logging should only use complete B-E embedded-LC cycles. -- Test fixtures for codec-sensitive logging should be generated through the - codec layer, not handwritten as raw payload fragments. - -### Resolution -- Switched `bridge_master.py` TA/GPS embedded-LC logging to - `freedmr_dmr_codec.decode_embedded_lc()`. -- Added non-fatal debug logging for embedded-LC validation failures. -- Updated deterministic and UDP TA/GPS fixture setup to generate payloads with - `freedmr_dmr_codec.encode_embedded_lc()`. -- Added codec tests that compare full LC header/terminator and routing-style - embedded LC output with the current `dmr_utils3` encoders before any rewrite - path replacement. -- Updated test and harness documentation. - -## 2026-05-23 - UDP Harness Startup Readiness - -### Findings -- The UDP black-box subprocess could be alive but not yet bound to HBP UDP - sockets when the first emulated repeater sent `RPTL`. -- The failure mode was a timeout waiting for the login challenge, before any - DMR packet or TA/GPS fixture was sent. -- Waiting a few seconds before login allowed the same subprocess to answer, - confirming this was a harness readiness race rather than packet-path logic. - -### Assumptions -- Retrying only the initial HBP login challenge is safe for the harness because - it models a client retrying while the server is still starting. -- Authentication/configuration failures after a challenge is received should - still fail immediately. - -### Unresolved Questions -- `bridge_master.py` still has no explicit "UDP sockets bound" readiness log. - A future harness improvement could detect readiness more directly if the - process emits such a message. - -### Protocol-Sensitive Areas -- This affects test transport timing only. It does not change FreeDMR packet - handling, HBP authentication, routing or rewrite logic. - -### Inferred Invariants -- UDP black-box tests should not assume that subprocess creation means UDP - sockets are already bound. -- Login retry belongs in the test client, not in production code. - -### Resolution -- Updated `HbpRepeater.login()` in the UDP harness to retry the initial `RPTL` - challenge for a bounded startup window. -- Verified a baseline UDP route and the TA/GPS UDP log scenarios pass after the - retry change. -- Updated testing and harness architecture documentation. - -## 2026-05-23 - Runtime LC Generation Uses FreeDMR Codec - -### Findings -- Runtime LC generation was still routed through `dmr_utils3.bptc` even after - `freedmr_dmr_codec.py` had byte-compatible full LC and embedded LC encoders. -- The production call sites only need `encode_header_lc()`, - `encode_terminator_lc()` and `encode_emblc()`, so a small compatibility - surface avoids broader packet logic edits. - -### Assumptions -- Replacing encode calls is acceptable only because codec tests compare the new - output byte-for-byte with the current `dmr_utils3` output for the runtime - function names. -- Decode paths can remain on `dmr_utils3` until they are ported and validated - separately. -- `bridge.py` and `mk_voice.py` should move with `bridge_master.py` because - they already use the same supported LC generation operations. - -### Unresolved Questions -- Real RF testing should still watch generated prompts and dial-a-TG/TG-mapped - LC rewrite behavior, because this is production voice-path code even though - the bytes are covered by compatibility tests. -- Full noisy received LC correction/validation is not yet used in production - decode paths. - -### Protocol-Sensitive Areas -- Full LC header/terminator bytes and embedded LC fragments must remain - byte-compatible unless FreeDMR intentionally changes the protocol behavior. -- `encode_emblc()` keeps the historical dict keyed by voice burst sequence - `1..4` so existing rewrite code can index by `_dtype_vseq`. - -### Inferred Invariants -- Runtime LC generation must preserve existing `dmr_utils3` byte output during - this migration. -- The codec module may offer compatibility names, but routing and rewrite - policy stay in `bridge_master.py` / `bridge.py`. - -### Resolution -- Added `encode_header_lc()`, `encode_terminator_lc()` and - `encode_emblc()` compatibility functions to `freedmr_dmr_codec.py`. -- Switched `bridge_master.py`, `bridge.py` and `mk_voice.py` LC generation - imports from `dmr_utils3.bptc` to `freedmr_dmr_codec`. -- Added a runtime-compatibility codec test that checks those function names - against the current `dmr_utils3` encoder output. -- Verified codec tests, deterministic LC rewrite/generated-prompt tests, full - non-UDP discovery, and focused UDP voice/prompt/TA/GPS scenarios. - -## 2026-05-23 - Runtime Voice Decode Helpers Use FreeDMR Codec - -### Findings -- After moving LC generation onto `freedmr_dmr_codec.py`, `bridge_master.py` and - `bridge.py` still used `dmr_utils3.decode.voice_head_term()` for full LC - extraction and `dmr_utils3.decode.voice()` for embedded-LC fragment extraction. -- Those decode helpers are part of the same codec surface as the LC work already - ported. - -### Assumptions -- The replacement must preserve the existing dict return shape: - `voice_head_term()` returns `LC`, `CC`, `DTYPE`, `SYNC`; `voice()` returns - `AMBE`, `CC`, `LCSS`, `EMBED`. -- Full received-LC correction/validation remains out of scope for this step; the - goal is byte-compatible replacement of the current helper behavior. -- `dmr_utils3` can remain for unrelated utilities/constants while these codec - operations move to FreeDMR-owned code. - -### Unresolved Questions -- Whether to port more received full-LC correction from MMDVMHost before using - decoded noisy headers more aggressively. -- Whether to remove compatibility tests against `dmr_utils3` after enough - external vectors or recorded RF fixtures exist. - -### Protocol-Sensitive Areas -- `voice_head_term()` feeds stream LC state used for TG rewrite and reporting. -- `voice()` feeds embedded-LC TA/GPS logging and must preserve the burst - fragment bit slice exactly. - -### Inferred Invariants -- Runtime decode helper return values must remain compatible with the old call - sites until packet logic is deliberately refactored. -- Replacing helpers must not change routing, stream lifecycle, packet mutation - decisions or generated voice bytes. - -### Resolution -- Added `voice_head_term()`, `voice_sync()` and `voice()` helpers to - `freedmr_dmr_codec.py`. -- Switched `bridge_master.py` and `bridge.py` from `dmr_utils3.decode` to - `freedmr_dmr_codec` for those helpers. -- Added codec tests comparing `voice_head_term()` and `voice()` outputs against - the current `dmr_utils3` behavior on known DMR payload fixtures. -- Verified focused deterministic voice/LC paths, bridge backport coverage, full - non-UDP discovery, and focused UDP voice/prompt/TA/GPS scenarios. - -## 2026-05-23 - Active Runtime dmr_utils3 Helper Cleanup - -### Findings -- Active runtime modules still used `dmr_utils3.utils` for `bytes_3()`, - `bytes_4()`, `int_id()` and `get_alias()` after packet codec operations had - moved to `freedmr_dmr_codec.py`. -- `mk_voice.py` also used fixed DMR bit constants from `dmr_utils3.const`. -- The helper implementations are small and deterministic, and the constants - are fixed bit patterns already covered by generated voice prompt tests. - -### Assumptions -- Replacing these helpers is behaviour-preserving because the FreeDMR versions - intentionally match the observed `dmr_utils3` implementations. -- `const.py` remains the source for FreeDMR/HBP constants such as ACL bounds and - the existing late-entry `LC_OPT`; the DMR prompt-generation bit constants live - with codec helpers to avoid changing those older meanings. -- Legacy/lab scripts may continue to import `dmr_utils3` until they are updated - or retired. - -### Unresolved Questions -- Whether to update legacy tools such as playback/app templates now, or leave - them until their current use is confirmed. -- Whether `requirements.txt` should keep `dmr_utils3` while legacy tools still - import it, even though active runtime and harness paths no longer need it. - -### Protocol-Sensitive Areas -- `mk_voice.py` generated prompt frames rely on the exact LC option, sync, - embedded-LC and slot-type bit patterns. -- `int_id()` is used widely for packet fields and logging; it must keep treating - byte strings as big-endian integer IDs. - -### Inferred Invariants -- Codec/helper replacement must not alter routing, stream lifecycle, packet - mutation decisions, prompt packet bytes or UDP harness behaviour. -- Test fixtures should not require `dmr_utils3` once fixed vectors exist. - -### Resolution -- Added FreeDMR-owned `bytes_3()`, `bytes_4()`, `int_id()` and `get_alias()` to - `utils.py`. -- Added the prompt-generation DMR bit constants to `freedmr_dmr_codec.py`. -- Switched active runtime imports in `bridge_master.py`, `bridge.py`, - `hblink.py`, `API.py`, `hotspot_proxy_v2.py`, - `hdstack/hotspot_proxy_v2.py`, `config.py` and `mk_voice.py`. -- Removed `dmr_utils3` from the codec tests and UDP harness runtime dependency - check, replacing comparisons with fixed vectors. -- Added direct utility-helper tests and updated test/harness documentation. - -## 2026-05-23 - Consistency Review After Codec Cleanup - -### Findings -- Active runtime imports are now internally consistent in that `dmr_utils3` is - no longer used by `bridge_master.py`, `bridge.py`, `hblink.py`, `API.py`, - `mk_voice.py`, `config.py` or the proxy modules. -- The compatibility choice of importing `freedmr_dmr_codec` as both `bptc` and - `decode` kept the initial diff small, but it was visually odd because both - names referred to the same module. -- `LC_OPT` existed in two places with different values: `const.py` kept the - existing FreeDMR/HBP late-entry value, while `freedmr_dmr_codec.py` provided - the older DMR prompt-generation value previously imported from - `dmr_utils3.const`. This was behaviour-preserving but a readability hazard. -- The new embedded-LC observation helpers in `bridge_master.py` are logically - grouped, but the block would be easier to scan with a short section comment - and a couple of wrapped long lines. -- The utility helper implementations in `utils.py` intentionally mirror - `dmr_utils3.utils`, but the existing file comment says "modified" helpers, - which is now slightly misleading for the compatibility helpers. - -### Assumptions -- Preserving old names at call sites was preferred for the first cleanup pass - because it reduced production diff size. -- Legacy/lab tools can continue to import `dmr_utils3` until their status is - revisited. - -### Unresolved Questions -- Whether the older `const.py` late-entry `LC_OPT` value should eventually be - renamed too. It was left unchanged in this cleanup because it is a broad - wildcard-imported runtime constant. - -### Protocol-Sensitive Areas -- Any cleanup around `LC_OPT` must preserve `mk_voice.py` prompt bytes and the - existing `const.py` late-entry behaviour. -- Any cleanup around `bptc`/`decode` aliases must be import/name-only and not - change packet routing or codec output. - -### Inferred Invariants -- Human-readable cleanup should stay mechanical unless a real behaviour bug is - identified. -- Compatibility vectors and UDP black-box tests remain the guardrails for codec - and prompt-generation cleanup. - -### Resolution -- Replaced the dual `bptc`/`decode` compatibility imports with a single - `dmr_codec` import in `bridge_master.py`, `bridge.py`, `mk_voice.py` and the - codec compatibility test. -- Added a short section comment for embedded-LC observation in - `bridge_master.py` and wrapped the longest log/decode lines in that helper - block. -- Updated the `utils.py` module comment to say the helper functions mirror the - old `dmr_utils3` surface rather than implying they are all modified copies. -- Renamed the generated-prompt LC option constant to - `GROUP_VOICE_LC_OPT`; the byte value remains `b'\x00\x00\x00'`. -- Left `const.py` `LC_OPT = b'\x00\x00\x20'` unchanged for the existing - late-entry fallback behaviour. -- Verified py_compile, full non-UDP discovery, codec/helper tests and focused - UDP prompt/routing smoke tests. - -## 2026-05-23 - OVCM and Late-Entry LC Option Review - -### Findings -- ETSI defines OVCM as Open Voice Channel Mode: a service option that allows - configured non-addressed users to monitor and participate in an active voice - call. -- MMDVMHost represents OVCM as bit `0x04` in the voice LC options byte. Its - generated/default LC constructor starts with zero options unless OVCM - configuration changes that bit. -- The current FreeDMR prompt generator uses `GROUP_VOICE_LC_OPT = - b'\x00\x00\x00'`, matching MMDVM/MMDVMHost default generated group voice LC - behaviour. -- The older FreeDMR late-entry fallback `LC_OPT = b'\x00\x00\x20'` was inherited - through HBLink-era constants. Local git history does not explain what the - `0x20` bit was intended to signal. - -### Assumptions -- Setting OVCM may be useful for interoperability with commercial DMR - implementations even if many amateur terminals and servers ignore it. -- Jonathan/MMDVM may have used OVCM deliberately for commercial compatibility, - but that needs confirmation from upstream history or mailing-list discussion. - -### Unresolved Questions -- Whether FreeDMR should set OVCM on generated/fallback group voice LC, leave it - clear, or preserve the historical `0x20` value until live RF testing proves a - safer default. -- Whether any commercial hardware connected to FreeDMR-derived paths relies on - the historical `0x20` option bit. - -### Protocol-Sensitive Areas -- Changing LC options affects generated prompts, late-entry fallback LC, - embedded LC rewrites, and potentially how commercial DMR equipment treats - channel busy/monitor behaviour. -- OVCM is not the same as the historical `0x20` value; treating them as - equivalent would be a protocol assumption not supported by the checked code. - -### Inferred Invariants -- If OVCM is introduced, it should be named explicitly and represented by the - known `0x04` service-option bit. -- The late-entry fallback LC option value should have a single source of truth - and tests should pin whichever byte pattern is intentionally selected. - -### Follow-Up Research From User -- Independent research agrees that `LC_OPT = b'\x00\x00\x20'` should not be - blindly preserved as the semantic default. It appears to set one of the - reserved service-option bits, not OVCM. -- For DMR Group Voice Channel User LC, the first three bytes are understood as - FLCO, FID and Service Options. Under the MMDVMHost-compatible interpretation, - service option bits are: emergency `0x80`, privacy `0x40`, reserved `0x30`, - broadcast `0x08`, OVCM `0x04`, priority `0x03`. -- The likely safe policy is: preserve real decoded inbound LC bytes unchanged; - use `0x00` for normal synthetic group voice LC; keep `0x20` only as an - explicit, documented HBLink legacy compatibility option during transition. -- This is a protocol-cleanliness/late-entry correctness issue, not a direct - dial-a-TG TG9/slot2 cause, because the destination TG and slot are carried - separately from the service-options byte. -- User later found that MMDVMHost originally implemented OVCM as `0x20` and - corrected it to `0x04` four days later. This supports treating FreeDMR/HBLink - `0x20` as a likely copied early OVCM bit mistake rather than an intentional - modern service option. -- More detailed research: MMDVMHost commit `6bababe` ("Add OVCM support", - 2019-10-11) initially used `0x20` in `CDMRLC::getOVCM()/setOVCM()`. Commit - `0711a2b` ("Fix issue in DMRLC.cpp", 2019-10-15) changed the same logic to - `0x04`. Later MMDVMHost behaviour changed again after Motorola compatibility - reports: do not remove OVCM unless configured, allow directional generation, - and eventually add force-off mode. This reinforces two FreeDMR rules: decoded - inbound LC should be passed through unchanged, while synthetic LC should use - standards-clean defaults unless a compatibility option is explicitly selected. - -### Resolution -- Added named LC service-option constants and `build_group_voice_lc()` to - `freedmr_dmr_codec.py`. -- Switched only synthetic late-entry/fallback LC construction in - `bridge_master.py` and `bridge.py` to generate normal group voice LC with - service options `0x00`. -- Left decoded inbound voice-header LC preservation unchanged. -- Kept `LC_SERVICE_OPTIONS_HBLINK_LEGACY = 0x20` as an explicit compatibility - value for tests or future interop switches, but removed active runtime use of - the old `LC_OPT` fallback. -- Documented `const.py` `LC_OPT` as a legacy compatibility alias rather than an - active synthetic LC default. -- Added codec and deterministic harness tests for normal synthetic LC fallback - and real inbound LC preservation. - -## 2026-05-23 - Bridge Table Storage and Activation Critique - -### Findings -- `BRIDGES` is currently a global dict keyed by bridge name/TG, where each value - is a list of mutable per-system dict entries. -- The same per-system bridge entries combine static configuration, derived - defaults, dynamic session state, timer state, activation triggers and - reporting-facing state. -- Packet routing and in-band activation scan the full bridge table repeatedly to - find entries matching `(SYSTEM, TS, TGID, ACTIVE)`. -- Dial-a-TG, default reflector, static TG and reset paths often replace list - entries by rebuilding temporary lists, while timer paths mutate entries in - place. -- Bridge names encode semantics: normal TGs use string names such as `"91"`, - reflectors use prefixed names such as `"#4400"`, and some code toggles between - those two forms to route between TG and reflector sides. - -### Assumptions -- The current bridge semantics are validated and should not be changed as part - of storage cleanup. -- Any replacement structure must preserve the existing reporting/API shape until - dashboard and API consumers are migrated. - -### Unresolved Questions -- Whether FreeDMR 2.0 should expose the existing raw `BRIDGES` structure to the - dashboard/API, or publish a compatibility view generated from an internal - bridge store. -- Whether bridge updates will remain single-reactor-thread operations, or - whether future multi-process work will require explicit ownership/snapshot - rules. - -### Protocol-Sensitive Areas -- Dial-a-TG and reflector bridge entries intentionally rewrite the RF-visible TG - and network-visible TG differently; a new structure must keep those two - identities distinct. -- Timer semantics differ for `ON`, `OFF`, `NONE` and `STAT`; these are routing - state transitions, not merely presentation flags. - -### Inferred Invariants -- A bridge route is effectively keyed by bridge identity plus local endpoint - `(system, slot)` and RF-visible `TGID`. -- Runtime packet routing needs fast lookup by `(system, slot, tgid)` rather than - repeated full-table scans. -- Configuration/default entries and per-session dynamic activation state should - be separable even if exported as the historical dict shape. - -### Follow-Up Assumption From User -- FreeDMR 2.0 should move away from treating configured `MASTER` stanzas as the - key for client/repeater state. The desired key is the client DMR ID, because a - single master/listener UDP port should be able to serve an arbitrary number of - client connections directly, replacing the proxy. -- This means a future bridge store should distinguish listener/system identity - from client session identity. Existing `SYSTEM`-keyed behaviour may need a - compatibility layer while routing state moves toward `(client_id, slot, tgid)` - and listener state remains tied to the UDP master. - -## 2026-05-23 - FreeDMR 2.0 Rewrite vs Iterative Upgrade - -### Findings -- `bridge_master.py` is currently about 3.4k lines and `hblink.py` about 1.6k - lines. The runtime identity model is widely coupled to `SYSTEM`, - `CONFIG['SYSTEMS']`, `systems[...]`, `PEERS`, `STATUS` and global `BRIDGES`. -- `hblink.py` owns Homebrew/OpenBridge transport, login/auth/config/options and - DMRD packet decoding. `bridge_master.py` owns routing, bridge activation, - dial-a-TG, reports, generated voice, timers, packet-control and much of the - runtime state. -- The desired FreeDMR 2.0 model changes the central identity from configured - master/system stanza to client DMR ID behind a shared UDP listener. That is an - architectural change, not a local bugfix. -- The current deterministic and UDP harnesses make staged replacement more - practical than it would have been before the test work. - -### Assumptions -- Packet/routing behaviour is validated and should be preserved unless a - FreeDMR 2.0 feature explicitly requires a change. -- Proxy removal means direct multi-client handling must be designed at the - listener/session layer rather than bolted onto the current one-client-ish - `MASTER` abstraction. - -### Unresolved Questions -- Whether FreeDMR 2.0 should keep Twisted for the first architecture migration - or move transport to asyncio/another event loop at the same time. -- Whether dashboard/API consumers can tolerate a compatibility payload generated - from a new internal state model. - -### Protocol-Sensitive Areas -- Homebrew login/auth, peer timeout, options parsing, keepalive handling and - DMRD decoding must remain byte-compatible if `hblink.py` is replaced. -- Dial-a-TG, reflector rewrite, BCSQ/source quench, loop-control and stream - timeout behaviour must remain covered by deterministic and UDP tests during - migration. - -### Inferred Invariants -- Do not do a big-bang rewrite of packet semantics. -- Do build a new listener/session/bridge-store core beside the current code, - migrate behaviours behind tests, and keep compatibility adapters until the - old `SYSTEM`-centric shape can be retired. - -### Follow-Up Assumption From User -- The FreeDMR-specific conceptual model is the protected asset: packet model, - dial-a-TG behaviour, protocol operation, loop control, source-quench/mesh - behaviour, and the practical tolerance learned from real global servers and - RF links. -- HBLink-era structure is not the protected asset where it prevents scale. The - assumptions around configured master/system identity, single-process state and - proxy-mediated client fan-out may be replaced to improve scalability, - performance and new functionality. - -## 2026-05-24 - Bridge Store Data Structure Research - -### Findings -- The packet plane should keep bridge/routing lookups in local memory. CPython - `dict`/`set` remain the right hot-path foundation because average lookup, - insert and delete are O(1) with suitable hash keys. -- `heapq` is a good built-in fit for timer expiry because it provides a min-heap - priority queue over a plain list. Timer updates should use lazy invalidation - rather than arbitrary heap removal. -- `typing.NamedTuple` keys provide readable attribute names while retaining - tuple-like hash/lookup performance. A local microbenchmark over 10k lookups - showed raw tuple and `NamedTuple` keys essentially equal; frozen dataclass keys - were materially slower. -- `dataclass(slots=True)` is a good fit for mutable state records because it is - readable, avoids per-instance `__dict__`, and keeps field ownership explicit. -- External sorted-map modules such as `sortedcontainers` are useful if sorted - range queries become central, but they are not necessary for packet routing - lookup and add a dependency. - -### Assumptions -- Bridge routing decisions are read far more often than they are mutated. -- Packet handlers need direct lookup by client/session identity, slot and - RF-visible TG, not full-table scans. -- External state stores can publish control-plane updates, but packet routing - must not block on them. - -### Unresolved Questions -- Whether route indexes should be per-worker only or whether a coordinator will - assign clients/TGs to workers in FreeDMR 2.0. -- Whether dashboard/API compatibility should continue to expose a generated - legacy `BRIDGES` dict or move to a new schema. - -### Protocol-Sensitive Areas -- Dial-a-TG must keep RF-visible TG9/TS2 and network reflector TG identity - separate. -- Loop-control and source-quench state must remain per stream/TG/source path and - cannot be hidden behind eventually consistent external state. - -### Inferred Invariants -- Use `NamedTuple` or tuple keys for hot indexes. -- Use slotted dataclasses for human-readable mutable records. -- Maintain multiple indexes that point to the same `BridgeEntry` objects rather - than one nested dict that forces scans. -- Use an append-only/lazy-invalidated timer heap for expiry, with the entry - itself carrying the authoritative expiry generation. - -### Follow-Up Assumption From User -- FreeDMR 2.0 does not need to preserve the `"#"` bridge-name convention - internally. Reflectors and conventional TG routes may use separate tables or - explicit route kinds if that is cleaner and faster. Any `"#"` form can be a - compatibility/export detail rather than the authoritative internal identity. - -## 2026-05-24 - Reporting and Dashboard Transport Direction - -### Findings -- The existing live dashboard is per-server and real-time, while the global - lastheard service is central and non-real-time. These should remain separate - reporting paths. -- HTTP/SSE or HTTP/WebSocket can replace the current custom reporting feed for - dashboard data, but the packet worker must not block on browser clients, - dashboard rendering, global collectors or external reporting stores. -- The packet process already uses Twisted, so serving a small local HTTP API is - technically feasible, but frontend/static hosting and long-lived dashboard - fanout are cleaner in a separate reporting service or sidecar. - -### Assumptions -- The live dashboard mainly needs one-way real-time updates plus a state - snapshot; it does not need every DMR packet. -- Dashboard control actions can use ordinary authenticated HTTP API calls unless - a future UI genuinely needs bidirectional streaming. - -### Unresolved Questions -- Whether the first implementation should embed the local reporting HTTP/SSE - endpoint in the FreeDMR process or run it as a sidecar fed by an internal - queue/socket/event bus. -- What authentication model should be used for local dashboard control APIs, - especially if dashboards are exposed beyond localhost/reverse proxy. - -### Protocol-Sensitive Areas -- Reporting must be observational. Packet routing, dial-a-TG, loop control and - mesh behaviour must continue if reporting is down, slow or disconnected. -- Global lastheard export should use call summaries/events, not packet-plane - traffic or local dashboard state. - -### Inferred Invariants -- Packet workers emit structured events and maintain/report local snapshots, but - never wait for dashboard consumers. -- Prefer `GET /api/state` plus SSE `GET /api/events/stream` for the live - dashboard; use HTTP POST/PATCH APIs for commands. -- WebSocket is optional and should be reserved for bidirectional dashboard - interactions that cannot be handled cleanly by HTTP API plus SSE. - -### Follow-Up Decision -- Architectural decisions, requirements and open questions for FreeDMR 2.0 are - now recorded in `docs/freedmr-2-architecture-decisions.md`. -- MQTT is selected as the preferred external live reporting transport, with - retained topics for current state and non-retained topics for transient - events. HTTP/SSE remains a possible gateway/fallback for dashboards, not the - primary reporting protocol decision. -- Pressure-test conclusion: MQTT emission is acceptable only through a - non-blocking bounded local queue and an independent publisher worker. Voice - stability takes precedence over reporting completeness; under pressure, - reporting events should be dropped/coalesced rather than delaying packet - handling. -- A network MQTT broker can also support global dashboard/lastheard collection - by receiving a curated subset of summary events from each server. This must be - separated from local live reporting so central outages do not affect local - packet handling or local dashboard state. -- Preferred refinement: a separate global-exporter process subscribes to the - same local real-time MQTT feed as the dashboard, filters/summarizes events and - publishes to the network broker/collector. Core FreeDMR publishes only the - local feed and is not responsible for global curation. -- Reporting fanout invariant: FreeDMR core emits each event once to the local - MQTT publisher/broker path. Additional dashboards/exporters/automation consume - from MQTT and must not add packet-process load. -- General FreeDMR 2.0 performance principle: expensive processing should be - considered for separate processes because CPython is constrained by the GIL - for CPU-bound Python code. Offload boundaries must be asynchronous and must - not add packet-path waits. -- Runtime migration decision: do not replace Twisted yet. Keep Twisted's - single-threaded reactor as a safety boundary while extracting and testing the - protocol/routing/subscription core. Consider asyncio only once Twisted is a - thin transport shell. -- Data packet policy: FreeDMR must keep forwarding DMR data packets, but core - should not become the GPS/SMS application processor. Data services should - connect via FBP or similar interfaces; `DATA_GATEWAY` is an earlier expression - of a data-oriented FBP link. Narrow exceptions may exist for SMS-based - dial-a-TG control or sysop alerts. -- No regression of data support is permitted. Preserve `SUB_MAP`/last-known - location semantics for routing data addressed to a DMR ID toward the last - known HBP/client location. -- Mesh security requirement: FreeDMR should support PKI-backed FBP peer - admission through Bridge Control (`BCXX`). Signed server identity should bind - server ID/public key/validity to the observed endpoint; if the IP changes, the - peer must re-authenticate before traffic is forwarded. Expensive validation is - control-plane only; per-packet checks use cached authenticated session state. -- Mesh auth renewal should be soft where safe: when renewal is due, trigger - asynchronous re-authentication and allow a bounded grace period so voice is not - interrupted by timing alone. Hard stops remain appropriate for revocation, - explicit auth failure, endpoint mismatch outside policy or grace expiry. -- Mesh PKI operational model: sign a sysop/server membership key when the server - joins the network; revoke that key when they leave or are compromised. Runtime - endpoint/session bindings are renewed separately without re-signing membership - each time. -- Mesh security option: distribute signed server membership keys peer-to-peer - over `BCXX` as bounded/rate-limited key gossip. Each server builds its own - validated key table and applies local policy to traffic by originating source - server, including traffic arriving indirectly through the mesh. This supports - FreeDMR's autonomous peer-network/zero-trust model, but requires replay, - revocation, expiry, serial and flood controls. -- Core security/legal principle: FreeDMR may sign/authenticate traffic and - control messages but should not encrypt amateur-radio or mesh traffic by - default. The security model is authenticity/integrity/membership validation - and local policy, not secrecy, because amateur radio is public and IP backhaul - may itself traverse amateur radio links. -- Project philosophy: FreeDMR should remain open-source, open, readable and - intentionally simple enough to encourage community implementation and - experimentation. HBLink proved an open DMR server could exist outside - commercial gatekeeping; FreeDMR extends that to an autonomous peer network - without central control. -- Mesh philosophy: FreeDMR's mesh thinking is influenced by Bob Bruninga's APRS, - Spanning Tree Protocol and similar distributed-network approaches. The project - is partly technical and partly diplomacy; design choices must respect - autonomy, interoperability and trust between independent sysops. -- Deployment/ethos principle: FreeDMR is successful because it works as a - best-effort amateur-radio/hacker system on cheap VPS/Raspberry Pi-class - hardware. FreeDMR 2.0 should improve clarity, quality and scalability without - losing approachability, low-cost deployment or experimental ham spirit. -- Network freedom principle: FreeDMR exists to lower barriers to global-scale - amateur ROIP experimentation. It changed a landscape where DMR server software - and server-level network membership were often closed or gatekept by personal - or team approval. -- Listing distinction: FreeDMR controls public listing/discovery for Pi-Star and - other HBP clients, not all private experimentation. Private servers can run - under their own DMR ID and be gatewayed by an existing sysop who vouches for - the traffic. Public listing carries additional operational requirements. -- Server identity hierarchy: FreeDMR server IDs are 4-digit DMR IDs and server - sub-IDs are 5-digit IDs, giving a sysop up to 10 IDs for backend/failover - deployments. One signed key verification should cover the base server ID and - authorized sub-IDs, subject to local policy and session/endpoint binding. -- Vouching/accountability model: an individual 7-digit DMR ID can be used for a - private server and traffic may pass if a directly connected/listed sysop - allows it. The vouching sysop is accountable for forwarded traffic; peers may - stop peering if that traffic harms the network. -- Analogue network bridges may connect as HBP clients and are permitted more - liberally than on many networks, but they can be problematic and effectively - listen-only. They should be subject to local policy, listing expectations and - peer accountability because they may consume resources without adding value. -- FreeDMR works with/supports the DVSwitch community for analogue bridging. - YSF/NXDN interworking is generally a better digital match because AMBE-family - audio can avoid transcoding; analogue/unlike-codec transcoding can create poor - audio artifacts. -- Analogue bridges often use audio mixing/conference behaviour, which is a poor - fit for DMR's one-audio-source-at-a-time stream and contention model. -- Analogue repeater heritage explains the mismatch: constant carriers, pips, - CWID and courtesy tones can be mixed into output audio, and source identity is - often weak compared with DMR's explicit DMR ID. -- Specific analogue bridge failure mode: an analogue repeater feed can keep the - DMR stream open between analogue overs, play courtesy tones and carry the next - analogue user in the same held stream. This can hold a TG open and prevent - digital stations from breaking in until the analogue carrier/timer drops. - -## 1.x dial-a-TG slot-local control update - -Findings: -- Current `bridge_master.py` forced all dial-a-TG private-call control packets - to mutate TS2 reflector state by setting `_dial_tg_slot = 2`, even when the - control packet arrived on TS1. -- With dial-a-TG enabled on TS1, cross-slot TS1 -> TS2 control is no longer safe: - TS1 must be able to own its own dial-a-TG state. -- Conventional TG dynamic routing is the group-call counterpart to dial-a-TG - reflector routing: unknown conventional group TGs can be created as user - activated bridges by `make_single_bridge()`. - -Assumptions: -- Private-call dial-a-TG control should now affect the slot on which the - private call is received. -- TS2 private-call control still controls TS2. -- TS1 private-call control no longer retunes, disconnects or reports TS2 - reflector state. -- Voice prompts remain RF-visible as TG9 TS2, matching the existing prompt - policy. -- `DEFAULT_REFLECTOR` remains the existing TS2 default-reflector compatibility - setting. Per-slot defaults are handled by the newer - `DEFAULT_DIAL_TS1`/`DEFAULT_DIAL_TS2` settings. - -Implementation notes: -- Added explicit per-master `DIAL_A_TG` and `DYNAMIC_TG_ROUTING` booleans, - both defaulting to `True` for compatibility. -- Peer OPTIONS aliases `DIALTG=0/1` and `DYNAMIC=0/1` update these booleans for - the current session. -- Generated reflector bridges now include both TS1 and TS2 HBP entries, with - only the controlled slot active. -- `bridgeDebug()` now treats one active dial bridge per system per slot as the - valid state, instead of one active dial bridge per system globally. - -Protocol-sensitive areas: -- TG9 remains the RF-visible dial-a-TG presentation TG for both slots. -- HBP <-> FBP dial-a-TG rewrite remains the only intended TG rewrite. -- Prompt/ident traffic is still generated on TG9 TS2. -- Source quench must continue to use the FBP/reflector TG namespace, not local - RF TG9. - -Inferred invariants: -- At most one active dial-a-TG reflector per system per slot. -- Disabling `DIAL_A_TG` must prevent private-call reflector creation/retune. -- Disabling `DYNAMIC_TG_ROUTING` must prevent automatic creation of unknown - conventional TG bridges, while existing/static bridge behaviour remains - otherwise unchanged. - -Unresolved questions: -- Whether disabling `DYNAMIC_TG_ROUTING` should also deactivate already-created - conventional user-activated bridges, or only prevent new automatic creation. - -## 1.x per-slot default dial-a-TG options - -Findings: -- `DEFAULT_REFLECTOR` was the historical TS2 dial-a-TG default setting. -- Slot-local dial-a-TG control needs slot-local startup/session defaults so TS1 - can have its own default dial reflector state. - -Assumptions: -- `DEFAULT_REFLECTOR` is deprecated, but remains a compatibility alias for TS2. -- `DEFAULT_DIAL_TS1` and `DEFAULT_DIAL_TS2` are the canonical per-slot default - dial-a-TG settings. -- If both `DEFAULT_REFLECTOR` and `DEFAULT_DIAL_TS2` are present in config, - `DEFAULT_DIAL_TS2` is preferred. -- Client OPTIONS aliases `DIAL`, `StartRef` and `DEFAULT_REFLECTOR` continue to - control TS2 only. -- If client OPTIONS include both canonical `DEFAULT_DIAL_TS2` and deprecated TS2 - aliases, `DEFAULT_DIAL_TS2` is preferred. - -Implementation notes: -- `config.py` now reads `DEFAULT_DIAL_TS1` with fallback `0`, and - `DEFAULT_DIAL_TS2` with fallback to deprecated `DEFAULT_REFLECTOR`. -- Runtime `CONFIG['SYSTEMS'][system]['DEFAULT_REFLECTOR']` is kept equal to the - effective TS2 default for compatibility with existing callers. -- `make_default_reflector()` now accepts a slot and creates/activates the - default reflector on that slot. -- `make_default_reflectors()` applies startup defaults independently for TS1 and - TS2, logs invalid positive defaults, and normalizes invalid runtime values to - `0`. -- `options_config()` accepts `DEFAULT_DIAL_TS1` and `DEFAULT_DIAL_TS2`; legacy - `DIAL`, `StartRef` and `DEFAULT_REFLECTOR` map to `DEFAULT_DIAL_TS2` only - when the canonical value is not present. - -Protocol-sensitive areas: -- Voice prompts remain generated on TG9 TS2. -- The compatibility `DEFAULT_REFLECTOR` value must continue to describe TS2, - not TS1. -- Default dial reflector activation must use the same validation policy as live - RF dial-a-TG activation. - -Inferred invariants: -- Empty, `0` or false default dial values mean no default reflector. -- Invalid positive defaults must not create bridge state. -- TS1 and TS2 default dial state are independent. -- `DEFAULT_DIAL_TS2` takes precedence over deprecated `DEFAULT_REFLECTOR`. - -Unresolved questions: -- Whether sample configs should keep showing `DEFAULT_REFLECTOR` for one release - as a visible deprecated alias, or remove it after downstream configs have - migrated. - -## Reporting check for slot-local default dial-a-TG changes - -Findings: -- The report socket opcode model is unchanged: `BRIDGE_SND` still sends pickled - `BRIDGES`, and `BRDG_EVENT` still sends comma-separated bridge event strings. -- The per-slot default-dial changes do not add new report event names or change - the existing event field order. -- `DEFAULT_REFLECTOR` remains in runtime config as the effective TS2 default, - reducing risk for existing config/API/report consumers. -- Voice prompts remain TG9 TS2, preserving the current prompt/dashboard policy. -- TS1 dial-a-TG can now legitimately create active `#reflector` entries with - `TS == 1`; this is the main dashboard-facing shape change. - -Assumptions: -- The dashboard treats bridge entries as dictionaries and reads `SYSTEM`, `TS`, - `TGID` and `ACTIVE`, rather than assuming all `#reflector` entries are TS2. -- The dashboard can tolerate seeing TG9 activity on slot 1 for dial-a-TG because - TS1 dial-a-TG is now intentional behavior. - -Protocol-sensitive areas: -- HBP/RF-side dial-a-TG reports should continue to show RF-visible TG9. -- FBP/OpenBridge-side target reports should continue to show the reflector TG - visible on the peer side. -- Source quench remains keyed to the peer-visible TG namespace, not the - dashboard display TG. - -Inferred invariants: -- Reporting must not steer routing. -- Report event field order must remain stable for FreeDMR 1.x dashboard - compatibility. -- `BRIDGE_SND` consumers must inspect `TS` rather than infer slot from bridge - name. - -Unresolved questions: -- The actual dashboard should be live-tested against active TS1 dial-a-TG - reflector state before release, because dashboards may have undocumented - assumptions about `#reflector` entries being TS2-only. diff --git a/docs/freedmr-2-architecture-decisions.md b/docs/freedmr-2-architecture-decisions.md deleted file mode 100644 index 8edbff3..0000000 --- a/docs/freedmr-2-architecture-decisions.md +++ /dev/null @@ -1,660 +0,0 @@ -# FreeDMR 2.0 Architecture Decisions - -This file records architectural decisions, requirements, assumptions and open -questions driven out during design discussion. It is intended as source material -for a later formal FreeDMR 2.0 design document. - -## Project Philosophy - -FreeDMR is open-source, open, intentionally understandable and intentionally -simple enough to encourage community implementation, experimentation and -operation by radio amateurs. - -HBLink proved that a DMR server could be written in an open, readable way -without DMR being gatekept by commercial vendors. FreeDMR takes the next step: -it proves that a DMR network can be built this way without central control. -Before HBLink and FreeDMR, DMR server software and server-level network -membership were typically closed, gatekept or dependent on personal/team -approval. FreeDMR exists in part to lower that barrier and give radio amateurs -choice and freedom to experiment with global-scale ROIP networking. -FreeDMR does not need to gatekeep all private experimentation. The project -controls public listing: the process by which servers are shared with Pi-Star -and other HBP hotspots as legitimate public access servers. A sysop can run a -private server under their own DMR ID and arrange gatewaying with an existing -sysop, who effectively vouches for that traffic. Public listing has additional -requirements such as connectivity quality, sysop contactability and basic -operational expectations. - -The FreeDMR mesh design is influenced by the late Bob Bruninga's APRS ideas, -Spanning Tree Protocol and related distributed-network approaches. The project -also has a social purpose: bringing together communities and people connected -to earlier amateur-radio networking work. FreeDMR is therefore both a technical -system and a diplomacy project; design choices must respect operational -autonomy, interoperability and trust between independent sysops. - -FreeDMR is successful because it works in the amateur-radio sense: it is best -effort, experimental, approachable and deployable on ordinary low-cost systems -such as cheap VPS instances and Raspberry Pi-class hardware. It is not intended -to be a safety-assured commercial system. FreeDMR 2.0 should improve quality, -clarity and scalability without losing the ham-spirit/hacker-philosophy traits -that made the network useful and welcoming. - -Design implications: - -- Prefer clear, inspectable protocols over opaque mechanisms. -- Keep the implementation understandable by competent sysops and contributors. -- Keep the barrier to compatible implementations low where possible. -- Preserve low-cost deployment and modest hardware requirements. -- Avoid architectural choices that make FreeDMR dependent on heavyweight - infrastructure for ordinary single-server operation. -- Treat reliability as best-effort resilience appropriate to amateur radio, not - as commercial safety assurance. -- Preserve server autonomy and local policy. -- Avoid unnecessary central control. -- Distinguish private operation, vouched/gatewayed traffic and public listing. -- Security should protect authenticity and network integrity without hiding - amateur-radio traffic. - -## Protected Model - -The protected asset is the FreeDMR operating model, not the old HBLink-derived -object structure. - -Preserve: - -- packet model and protocol behaviour -- dial-a-TG semantics -- TG/DMR-ID centric routing -- loop control -- source quench -- mesh behaviour -- practical RF/network tolerance learned from live servers and real RF links -- "everything everywhere" principle, subject to documented exceptions - -Replace or redesign where useful: - -- configured `MASTER` stanza as primary runtime identity -- proxy-mediated client fan-out -- global mutable `BRIDGES` structure as authoritative state -- custom dashboard/reporting socket protocol -- packet-path coupling to dashboard/API/report consumers - -## Layer Model - -FreeDMR 2.0 should be described as layered: - -- **Access layer**: client/server access protocols such as HBP today and - possible future non-trunk client protocols. Owns login/auth/options/keepalive, - client sessions, slot state and RF-facing TG presentation. -- **Subscription layer**: talkgroup conference membership. Owns direct TG - subscriptions, dial-a-TG subscriptions, static/default/user-activated - subscriptions, expiry and RF-visible TG to conference TG mapping. -- **Mesh layer**: inter-server FBP/OBP/trunk-style behaviour. Owns loop control, - source quench, hop/version handling and inter-server conference traffic. -- **Reporting layer**: local dashboard, API observers, logs, global lastheard - export and state snapshots. Reporting is observational and must not steer - packet handling. - -## Reactor and Runtime Migration - -Do not replace Twisted as part of the first FreeDMR 2.0 architecture work. - -Decision: - -- Keep Twisted's single-threaded reactor as a safety boundary initially. -- Extract and test the protocol/routing/subscription core behind deterministic - interfaces. -- Introduce explicit process/message boundaries only after the state model is - clear. -- Consider asyncio or another event loop only once Twisted has become a thin - transport shell around tested core logic. - -Rationale: - -- The current packet behaviour is subtle and validated through real RF/network - deployment. -- Replacing the event loop while also replacing the state model would mix too - many sources of behavioural change. -- Twisted's single-threaded reactor helps preserve current ordering assumptions - while bridge/subscription and reporting boundaries are made explicit. -- The first migration target is architectural clarity and scalability, not event - loop novelty. - -## Identity Model - -The configured master/listener is not the client identity. - -FreeDMR 2.0 should move toward: - -- listener identity: UDP socket/service instance -- client identity: DMR peer/client ID -- subscription identity: client ID + slot + RF-visible TG + conference TG -- mesh identity: server/peer/network ID - -Server identity hierarchy: - -- FreeDMR server IDs are 4-digit DMR IDs. -- Server sub-IDs are 5-digit IDs derived from the server ID space. -- Each sysop/server identity may therefore cover up to 10 server sub-IDs for - backend components, larger deployments, failover or fault-tolerant layouts. -- Identity verification should cover the base server ID and its authorized - sub-IDs rather than requiring unrelated credentials for each sub-ID. - -A single master/listener UDP port should serve an arbitrary number of clients -directly, replacing the proxy where possible. - -## Talkgroup Subscription Model - -Conceptually, each TG is a conference bridge. Clients subscribe to conference -TGs. FreeDMR does not primarily decide where to send user traffic; users choose -the traffic they want to hear by subscription. - -Subscriptions can be: - -- direct TG: RF-visible TG equals conference TG -- dial-a-TG: RF-visible TG is currently TG9, conference TG is the selected TG -- alias/rewrite: RF-visible TG may be any configured TG, conference TG is the - FreeDMR network identity - -Example: - -```python -TalkgroupSubscription( - client_id=2345001, - slot=2, - conference_tg=4400, - rf_tg=9, - mode="dial", - active=True, -) -``` - -The invariant is: - -```text -conference_tg = FreeDMR network/conference identity -rf_tg = client-facing RF presentation identity -``` - -This makes arbitrary TG rewrites possible without making TG9 structurally -special. - -## Bridge Table Replacement - -The legacy `BRIDGES` dict should be replaced internally by subscription-oriented -state and indexes. The `"#"` reflector naming convention does not need to be -preserved internally; it can be a compatibility/export detail. - -Recommended hot-path structures: - -- `dict` / `set` for O(1)-style local lookups -- `typing.NamedTuple` keys for readable hash keys -- `dataclass(slots=True)` records for mutable subscription/session state -- `heapq` for expiry timers using lazy invalidation - -Recommended indexes: - -```python -subscriptions_by_conference_tg[conference_tg] -> set[SubscriptionKey] -subscription_by_rf[(client_id, slot, rf_tg)] -> SubscriptionKey -subscriptions_by_client_slot[(client_id, slot)] -> set[SubscriptionKey] -expiry_heap -> (expires_at, generation, SubscriptionKey) -``` - -Packet handlers should not scan all subscriptions/bridges to find routing -targets. - -## Packet Plane vs Control Plane - -The packet plane is delay-sensitive. - -Packet-plane rules: - -- local in-memory hot state only -- no external database round trips -- no blocking API/dashboard/report calls -- no cross-process lock waits -- no dependency on reporting consumers being connected - -External stores may be used for: - -- config distribution -- API/dashboard state -- control-plane coordination -- snapshots -- global lastheard export -- optional clustering/multi-process coordination - -General performance principle: - -- Expensive processing should be considered for offload to separate processes - because CPython execution is constrained by the GIL for CPU-bound Python code. -- Offload is appropriate for reporting fanout, global export, dashboard - aggregation, historical database writes, heavy analytics, expensive - transcoding/codec experiments and non-critical maintenance jobs. -- Offload boundaries must be asynchronous from the packet path. If an offload - worker is slow or unavailable, packet handling must continue with local state. -- Do not offload hot-path routing decisions if doing so would add inter-process, - network or lock waits to every packet. - -## DMR Data Packet Policy - -FreeDMR must maintain DMR data packet forwarding support. - -Decision: - -- FreeDMR should forward supported DMR data packets according to the same - conference/subscription and mesh principles as other traffic. -- There must be no regression in existing data packet forwarding support. -- FreeDMR core should not become an application-level DMR data processor. -- GPS, SMS and similar application processing should be implemented by systems - connected via FBP or another mesh/access-adjacent interface. -- `DATA_GATEWAY` is understood as an earlier expression of this model: an FBP - link that carries data-oriented traffic rather than ordinary voice traffic. -- Existing `SUB_MAP` behaviour is intentional: data addressed to a DMR ID can be - routed toward the last known HBP/client location for that DMR ID. - -Core FreeDMR may inspect/classify data packets only as needed for: - -- packet admission and protocol validation -- routing/subscription decisions -- loop control and source quench -- reporting/logging -- preserving packet bytes and metadata across FBP/HBP boundaries -- maintaining the subscriber location map needed for data-client routing - -Possible narrow exceptions: - -- dial-a-TG control via DMR SMS -- DMR SMS alerts from a server to a sysop - -Any such exceptions must be explicit control-plane features and must not turn -FreeDMR core into a general GPS/SMS application processor. - -## Mesh Peer Authentication - -FreeDMR should only accept mesh/FBP traffic from servers that can be validated -as legitimate members of the network. - -Core principle: - -- FreeDMR may sign/authenticate traffic and control messages, but should not - encrypt amateur-radio traffic or mesh traffic by default. -- Amateur radio is public in most jurisdictions and encryption is often not - permitted. FreeDMR users may also carry IP backhaul over amateur radio links. -- FreeDMR's security model is authenticity, integrity, membership validation and - local policy enforcement, not secrecy. -- This follows the existing FreeDMR principle, agreed historically by project - maintainers, that the network has nothing to hide and should remain cleartext. - -Identity/listing distinction: - -- Signed mesh identity should prove a server/sysop identity or a vouching - relationship. It should not automatically imply public listing. -- Public listing is a directory/discovery decision for clients and HBP hotspots. -- A public access server may need stronger operational requirements than a - private or gatewayed server. -- Local sysops may still choose whether to carry/vouch for traffic from private - servers, even when those servers are not publicly listed. -- If an individual 7-digit DMR ID is used as a server identity, traffic may pass - when a directly connected/listed sysop chooses to allow and gateway it. -- The vouching sysop is accountable to their peers for traffic they forward. If - that traffic harms the network, peers may choose to stop peering with the - vouching server. This preserves a self-policing social mechanism without - requiring central control for all private experimentation. - -Analogue network bridges: - -- Analogue ROIP/network bridges commonly connect as if they are DMR clients via - HBP. -- FreeDMR permits this and is generally more permissive than many other DMR - networks. -- FreeDMR works with/supports the DVSwitch community on this. DVSwitch provides - a common mechanism by which analogue networks can be bridged into DMR-style - access. -- These bridges are operationally sensitive: technical limitations can make - them effectively listen-only, consuming CPU and bandwidth while adding little - value if they do not contribute actual two-way user activity. -- Analogue bridges are often implemented using audio mixing/conference style - behaviour. This is a poor fit for DMR and similar digital modes, which enforce - one audio source at a time and rely on stream, hang-time and contention - behaviour rather than mixed audio. -- This mismatch comes partly from analogue repeater heritage: analogue systems - may maintain a continuous transmit carrier and mix notification sounds such as - pips, CWID and courtesy tones into the output audio. Analogue systems also - often have little or no strong source identity, whereas DMR traffic carries a - DMR ID. -- A common failure mode is that a feed from an analogue repeater keeps the DMR - stream open between analogue overs, plays courtesy/notification tones and then - carries the next analogue user in the same held stream. This can hold the TG - open and prevent a digital station from breaking in until the analogue - repeater times out and its carrier drops. -- Analogue bridges should therefore be subject to local sysop policy, public - listing expectations and peer accountability. Permitted does not mean - automatically valuable or immune from peering/listing consequences. - -Other digital network bridges: - -- Digital voice networks such as YSF and NXDN are generally a better technical - match for DMR than analogue networks because they also use AMBE-family vocoder - audio. -- AMBE-to-AMBE interworking can be lossless at the codec level and avoids - transcoding artifacts. -- Transcoding from analogue or unlike codecs can degrade audio quality - significantly and should be treated carefully. - -Desired direction: - -- Add PKI-backed mesh peer admission to the Bridge Control (`BCXX`) mechanism. -- A peer server presents public identity material signed by a FreeDMR network - master key or trusted network CA. -- The authenticated identity must bind at least: - - server ID - - authorized server sub-IDs - - public key - - validity period - - permitted protocol/features where useful -- Runtime admission should bind the authenticated server identity to the - observed transport endpoint, including IP address. -- If the observed IP address changes, the FBP peer must perform a new key - exchange/authentication step before its traffic is forwarded. -- Network membership should be represented by a signed sysop/server key that is - issued when the sysop/server joins the network and revoked when they leave or - are compromised. Runtime endpoint/session bindings are renewed separately and - do not require re-signing the long-lived membership key. -- One successful verification of the signed identity should authorize the - covered server ID and declared/authorized sub-IDs for that sysop, subject to - local policy and endpoint/session binding. - -Packet-plane rule: - -- Expensive signature/certificate validation happens during control-plane - admission or re-admission, not for every DMR packet. -- Per-packet mesh traffic should use a cached authenticated peer/session state - check keyed by server ID and endpoint. - -Initial conceptual flow: - -```text -FBP peer connects/sends keepalive - -> BC auth exchange presents signed server identity/public key - -> FreeDMR validates signature against trusted network key - -> FreeDMR binds server_id + endpoint + protocol features to peer session - -> DMR traffic is accepted only while that authenticated binding is valid -``` - -Security requirements: - -- Reject unauthenticated FBP traffic by default once this mode is enabled. -- Reject traffic where server ID, key identity and source endpoint do not match - the authenticated binding. -- Expire authenticated bindings and require renewal. -- Support soft renewal: when an authenticated binding reaches its renewal - timestamp, schedule asynchronous re-authentication while allowing a bounded - grace period so in-flight voice is not interrupted purely by renewal timing. -- Hard-stop forwarding only for explicit authentication failure, revoked - identity/key, endpoint mismatch outside policy, expired grace period, or - policy requiring immediate re-authentication. -- Log authentication failure reasons clearly without leaking private material. -- Provide a controlled transition mode for existing networks while PKI is rolled - out. - -Open questions: - -- Whether to use X.509 certificates, raw Ed25519 public keys with signed - metadata, or another compact identity format. -- How network master keys/CAs are generated, rotated and revoked. -- Whether peer authorization policy should live in config, MQTT/control-plane - state, or a signed network membership list. -- How to handle legitimate dynamic-IP servers without weakening endpoint - binding. -- What renewal and grace-period defaults best preserve voice continuity without - weakening mesh admission. - -### Distributed Key Gossip Option - -FreeDMR may also use a peer-to-peer signed-key dissemination mechanism over the -Bridge Control (`BCXX`) out-of-band channel. - -Concept: - -- Each server periodically advertises the signed server public keys/membership - documents it knows to its direct FBP peers. -- Peers validate the signatures and build a local table of legitimate server - identities as knowledge propagates through the mesh. -- Each server uses its local signed-key table and local policy to decide whether - to route or reject packets that originated from a given source server, even - when that source server is not directly connected. - -Rationale: - -- FreeDMR is a peer network, not hub-and-spoke or master/slave. -- Servers are autonomous and independently operated. -- Direct FBP peers should not be blindly trusted to make correct routing - decisions on behalf of the local server. -- Open-source, human-readable code deliberately lowers the barrier to - modification, so each server must be able to protect itself from incorrect or - malicious upstream forwarding decisions. - -Security requirements for key gossip: - -- Only signed membership documents are accepted; peers cannot create trust by - merely repeating a key. -- Membership documents need issuer, subject server ID, public key fingerprint, - authorized sub-IDs, validity period, serial/version and signature. -- Revocation data must propagate by the same or a stronger mechanism. -- Each server must enforce local policy after validation. A valid signed key - proves membership, not mandatory carriage. -- Key gossip must be rate-limited and bounded so it cannot become a BCXX flood - or memory-growth vector. -- Received membership data must be replay-resistant enough to handle expiry, - superseded serials and revoked keys. -- The packet path must use cached key/policy state; signature validation and - gossip processing are control-plane work. - -This complements direct-peer endpoint authentication. Direct-peer auth proves -the connected FBP peer is legitimate for this session; distributed signed-key -knowledge lets the local server make autonomous decisions about traffic whose -source server is elsewhere in the mesh. - -## Reporting Protocol Decision - -FreeDMR 2.0 should define a structured reporting event protocol and use MQTT as -the preferred external live reporting transport. - -Rationale: - -- MQTT is already familiar in DMR network dashboard/reporting contexts. -- BrandMeister uses MQTT, providing a useful precedent for dashboard consumers. -- MQTT topics map naturally to server/client/subscription/call state. -- Retained messages are useful for current state snapshots. -- Last Will and Testament can represent server/reporting disconnects. -- MQTT-over-WebSocket allows browser dashboards to subscribe directly when the - broker supports it. - -Constraints: - -- MQTT publishing must be asynchronous from the packet worker. -- Packet routing must continue if the MQTT broker/dashboard is down. -- Event generation must be state-change/summary oriented, not per DMR frame. -- The event schema is the compatibility contract; internal Python objects are - not. -- Local live dashboard and central global lastheard remain separate paths. -- Voice stability takes precedence over reporting completeness. If the system - must choose between dropping/reporting-losing events and delaying packet - handling, it must drop or coalesce reporting events. - -Implementation requirement: - -```text -packet path -> non-blocking local event queue -> MQTT publisher worker -``` - -The packet path must not call an MQTT broker synchronously. The local event -queue should be bounded. On overflow, the publisher layer should drop or -coalesce low-priority events and emit a later reporting-health event rather than -blocking packet handling. - -Suggested event priority: - -- retain/coalesce latest state: server/client/slot/subscription state -- keep best effort: call start/end summaries -- drop first under pressure: high-volume debug/warning/statistical updates - -MQTT publishing should support reconnect with exponential backoff and should -refresh retained state after reconnect so a dashboard can recover even if -transient events were missed. - -Suggested MQTT namespace: - -```text -freedmr/v2/{server_id}/state -freedmr/v2/{server_id}/client/{client_id}/state -freedmr/v2/{server_id}/client/{client_id}/slot/{slot}/activity -freedmr/v2/{server_id}/subscription/{subscription_id}/state -freedmr/v2/{server_id}/call/{stream_id}/start -freedmr/v2/{server_id}/call/{stream_id}/end -freedmr/v2/{server_id}/mesh/{peer_id}/state -freedmr/v2/{server_id}/event -``` - -Use retained messages for current state: - -```text -server state -client state -slot activity -subscription state -mesh peer state -``` - -Use non-retained messages for transient events: - -```text -call start/end -loop-control event -source-quench event -packet-rate/loss summary -warnings -``` - -Example event: - -```json -{ - "version": 2, - "event_id": 1849281, - "type": "call.started", - "timestamp": 1710000000.123, - "server_id": 234099, - "client_id": 2345001, - "slot": 2, - "conference_tg": 4400, - "rf_tg": 9, - "source_id": 2351234, - "stream_id": 16909060, - "access": "hbp" -} -``` - -Dashboard delivery options: - -- preferred: dashboard subscribes to MQTT over WebSocket -- alternative: local reporting sidecar translates MQTT to SSE/HTTP -- control actions should use authenticated HTTP APIs unless a future UI needs - bidirectional streaming - -## Local Dashboard vs Global Lastheard - -Each FreeDMR server has its own local live dashboard. The global lastheard -service is centrally hosted and non-real-time. - -Local dashboard: - -- consumes local MQTT live state/events -- displays current client/repeater traffic -- must tolerate reconnects and missed transient events by reloading retained - state topics - -Global lastheard: - -- consumes call summaries or batched exports -- should not depend on packet-plane or dashboard delivery -- should tolerate central outage via spool/retry - -Possible MQTT global feed: - -- Each server publishes local live dashboard topics to a local broker or local - reporting service. -- Prefer a separate exporter process for the curated global feed. The exporter - subscribes to the same local real-time MQTT feed as the dashboard, filters and - summarizes what is needed, then publishes to the network MQTT broker or writes - to the global collector. -- The exporter publishes only summary topics needed for the 30-day database, - such as call end summaries, client/server presence, selected mesh health and - selected subscription changes. -- Raw packet events and high-volume live slot updates should not be exported to - the global broker by default. -- Central broker, global dashboard or exporter failure must not back up into - local packet processing or local dashboard state. - -Preferred flow: - -```text -FreeDMR core -> local MQTT feed -> local dashboard - -> global-exporter process -> network MQTT/collector -``` - -Core publishing invariant: - -- FreeDMR core emits each reporting event once to its configured local MQTT - broker/publisher queue. -- Fanout to dashboards, exporters, automation and global collectors is handled - by the MQTT broker and separate subscriber processes. -- Adding more reporting consumers must not increase FreeDMR packet-process work - beyond the single local event emission. - -Suggested global MQTT subjects: - -```text -freedmr/v2/global/{server_id}/call/end -freedmr/v2/global/{server_id}/client/state -freedmr/v2/global/{server_id}/server/state -freedmr/v2/global/{server_id}/mesh/state -``` - -## Reporting Event Types - -Initial event families: - -```text -server.started -server.stopping -client.connected -client.disconnected -client.options_changed -subscription.activated -subscription.deactivated -subscription.expired -call.started -call.ended -call.lost -mesh.peer_up -mesh.peer_down -mesh.source_quench -loop.detected -packet.rate_limited -``` - -## Open Questions - -- Which MQTT broker should be packaged by default: Mosquitto, EMQX, NATS MQTT - compatibility, or another option? -- Should MQTT be mandatory for FreeDMR 2.0 dashboards, or optional with an - embedded/local fallback? -- What authentication/authorization model should protect MQTT topics and - dashboard control APIs? -- What retained-topic expiry policy should be used to prevent stale state? -- Should global lastheard consume MQTT directly or use a separate HTTP/queue - exporter fed from reporting events? -- Should FreeDMR expose a legacy `BRIDGES` compatibility view during migration? diff --git a/docs/freedmr-2/00-glossary.md b/docs/freedmr-2/00-glossary.md deleted file mode 100644 index 884fa28..0000000 --- a/docs/freedmr-2/00-glossary.md +++ /dev/null @@ -1,65 +0,0 @@ -# FreeDMR 2 Glossary - -This glossary defines FreeDMR 2 terms. Legacy terms such as `SYSTEM`, `MASTER`, `BRIDGES`, and `#` reflector names describe current implementation details or compatibility views, not the primary FreeDMR 2 model. - -**Access layer**: The part of FreeDMR that accepts client/repeater protocols such as HBP. It owns login, authentication, options, keepalive, access sessions, RF-facing slots, and RF-visible TG presentation. - -**Access session**: A live connection-like relationship between FreeDMR and a client/repeater. It is identified by client DMR ID and transport/session metadata, not by a configured listener stanza. - -**Listener**: A UDP socket/service endpoint that accepts one or more access sessions. A configured `MASTER` is a legacy listener-like concept, not the client identity. - -**Client/repeater**: An HBP hotspot, repeater, gateway, bridge, or future access-side system connected to FreeDMR. - -**Client DMR ID**: The DMR ID used by a client/repeater access session. Future routing should key primarily on client DMR ID, slot, and RF-visible TG. - -**RF-visible TG**: The talkgroup number seen by the RF terminal or access-side device. In dial-a-TG this may be TG9 while the network conference TG is different. - -**Conference TG**: The FreeDMR network talkgroup identity. Conceptually this is the conference bridge to which clients subscribe. - -**Subscription**: Membership of a client slot/RF TG presentation in a conference TG. - -**Static subscription**: A subscription created by configuration or policy and normally present for the session. - -**Dial-a-TG subscription**: A subscription created by dial-a-TG control. It maps an RF-visible TG, traditionally TG9, to a selected conference TG. - -**Default reflector**: A configured or client-requested default dial-a-TG style subscription for a session. Empty string, integer 0, or boolean false means no default reflector. - -**Stream**: A voice call flow identified by stream metadata. AMBE voice is a stream; DMR data packets are packet-oriented and should not be treated as AMBE streams. - -**Source server**: The server that originated or advertised the packet into the mesh, according to the FBP/OBP protocol version in use. - -**Source repeater**: The access-side repeater/client identity carried when the protocol version supports it. - -**Mesh peer**: Another FreeDMR/OpenBridge/FBP peer server connected through the mesh layer. - -**Server ID**: A FreeDMR server identity. Server IDs are treated separately from client DMR IDs. - -**Server sub-ID**: A subordinate server identity authorized under a server/sysop identity, for example backend or fault-tolerant deployments. - -**Bridge-control message**: An out-of-band FBP/OBP control message, such as source quench or STUN. - -**Packet plane**: The delay-sensitive path that receives, parses, routes, mutates where necessary, and sends DMR packets. - -**Control plane**: Authenticated configuration, API, bridge-control, admission, and policy operations. - -**Reporting plane**: Observational events and state export for dashboards, logs, lastheard, monitoring, and operators. - -**Compatibility/export state**: A derived view that presents old FreeDMR/HBLink/dashboard shapes to consumers. It is not authoritative state. - -**HBP**: Homebrew Protocol, used by many hotspots/repeaters on the access side. - -**OBP**: OpenBridge Protocol version 1. It remains important as an open interop path where intentionally configured. - -**FBP**: FreeDMR Bridge Protocol. Any OpenBridge-derived peer protocol version higher than 1 is termed FBP for FreeDMR clarity. - -**DATA-GATEWAY**: A historical/early expression of a data-oriented FBP link. FreeDMR 2 should preserve data forwarding without making the core a GPS/SMS application processor. - -**Source quench / BCSQ**: A bridge-control hint asking a peer to suppress a stream/TG toward us. It is optional and per stream/TG. - -**STUN / BCST**: A broader bridge-control gate intended to stop all FBP traffic from a peer under the current conceptual model. - -**OVCM**: Open Voice Channel Mode. ETSI service option bit 0x04 when explicitly used. HBLink legacy 0x20 is compatibility history, not standards-clean OVCM. - -**Synthetic LC**: A generated Link Control value used when FreeDMR has to create fallback LC information. - -**Real inbound LC**: LC decoded from received traffic. It should be preserved unchanged unless FreeDMR deliberately rewrites it. diff --git a/docs/freedmr-2/01-system-model.md b/docs/freedmr-2/01-system-model.md deleted file mode 100644 index 36e4562..0000000 --- a/docs/freedmr-2/01-system-model.md +++ /dev/null @@ -1,41 +0,0 @@ -# FreeDMR 2 System Model - -FreeDMR 2 is a layered system. The layers are design boundaries, not necessarily separate processes at first. - -## Access Layer - -The access layer owns HBP and future client/repeater protocols. It handles login, authentication, options, keepalive, access sessions, RF-facing slot state, and RF-visible TG presentation. - -A configured listener is not the client identity. A single listener should eventually support multiple clients directly, replacing proxy-mediated fan-out where possible. - -## Subscription Layer - -The subscription layer owns talkgroup conference membership. It handles direct TG subscriptions, dial-a-TG subscriptions, static subscriptions, default reflectors, user/API/SMS activated subscriptions, expiry, and RF-visible TG to conference TG mapping. - -Packet routing should consume subscription state. It should not need to know whether a subscription came from static config, dial-a-TG, API, SMS, or a future UI. - -## Mesh Layer - -The mesh layer owns FBP/OBP/trunk-style inter-server traffic. It handles loop control, source quench, hop/version handling, bridge control, source server/repeater metadata, and conference traffic between servers. - -FreeDMR remains a peer network, not hub-and-spoke. Local sysops retain local routing and policy autonomy. - -## Packet/Stream Layer - -The packet/stream layer owns packet parsing, stream lifecycle, sequence handling, terminators, LC/embedded LC handling, data-vs-voice classification, and packet mutation boundaries. - -Raw packet bytes are immutable input until an explicit named rewrite operation occurs. - -## Reporting Layer - -The reporting layer is observational only. It emits state and events to local dashboards, global lastheard exporters, logs, and monitoring consumers. - -Reporting must not steer packet routing. - -## Control/API Layer - -The control/API layer provides explicit authenticated operations for sysop and control-plane actions. It should operate on access sessions, subscriptions, mesh peers, and reporting state without blocking the packet path. - -## Critical Invariant - -Reporting, dashboards, APIs, databases, exporters, and monitoring consumers must not block or steer packet handling. diff --git a/docs/freedmr-2/02-state-model.md b/docs/freedmr-2/02-state-model.md deleted file mode 100644 index b650460..0000000 --- a/docs/freedmr-2/02-state-model.md +++ /dev/null @@ -1,251 +0,0 @@ -# FreeDMR 2 State Model - -The FreeDMR 2 state model separates listener identity, client identity, subscription state, stream state, mesh peer state, and reporting/export views. The legacy `BRIDGES` dict is not the authoritative FreeDMR 2 model. - -Recommended hot-path structures: - -- `NamedTuple` or tuple keys for hot dict/set indexes. -- `dataclass(slots=True)` for mutable state records. -- `heapq` expiry queue with lazy invalidation. -- Local in-memory packet-plane state. -- No packet-path dependency on external databases or reporting consumers. - -Suggested indexes: - -```python -subscriptions_by_conference_tg[conference_tg] -> set[SubscriptionKey] -subscription_by_rf[(client_id, slot, rf_tg)] -> SubscriptionKey -subscriptions_by_client_slot[(client_id, slot)] -> set[SubscriptionKey] -expiry_heap -> (expires_at, generation, SubscriptionKey) -``` - -## AccessSession - -Purpose: Represents a live client/repeater session. - -Owner: Access layer. - -Key: Client DMR ID plus listener/session endpoint data. - -Mutable fields: Authentication state, options, keepalive time, endpoint, active slots, supported protocol features. - -Expiry/timer behaviour: Keepalive/session timeout expires the session and resets session-scoped options to system defaults. - -Packet-plane rules: Read for packet admission, option interpretation, slot state, and source identity. Writes only minimal hot state such as last packet/keepalive. - -Control-plane rules: API may read and update bounded session options. - -Reporting/export view: Client connected/disconnected/options/state events. - -Compatibility mapping: Current configured `MASTER`/`SYSTEM` session fields and peer status entries. - -## Listener - -Purpose: Owns a UDP socket/service endpoint. - -Owner: Access layer or transport shell. - -Key: Listener name or bind address/port. - -Mutable fields: Socket state, configured admission policy, active access sessions. - -Expiry/timer behaviour: None beyond transport lifecycle. - -Packet-plane rules: Receives and sends packets; should not be treated as client identity. - -Control-plane rules: Configuration and lifecycle only. - -Reporting/export view: Listener up/down and session counts. - -Compatibility mapping: Current `MASTER` stanza. - -## ClientSlotState - -Purpose: Tracks per-client per-slot RF-facing state. - -Owner: Access and subscription layers. - -Key: `(client_id, slot)`. - -Mutable fields: RF-visible TG activity, current stream, hang/timeout state, default reflector, static and dial subscriptions. - -Expiry/timer behaviour: Stream timers, dial/default reflector expiry, session reset on disconnect. - -Packet-plane rules: Read for RF-visible TG mapping and stream admission. Writes current stream and observed activity. - -Control-plane rules: API may activate/deactivate subscriptions and defaults. - -Reporting/export view: Slot activity and active subscription state. - -Compatibility mapping: Existing slot options, dial-a-TG state, timeout fields. - -## TalkgroupSubscription - -Purpose: Represents membership of a client slot/RF TG in a conference TG. - -Owner: Subscription layer. - -Key: Stable `SubscriptionKey`, likely `(client_id, slot, rf_tg, conference_tg, mode)`. - -Mutable fields: Active flag, source, expiry, generation, priority/policy metadata. - -Expiry/timer behaviour: Static subscriptions normally session-bound; dial/default/user subscriptions may expire. - -Packet-plane rules: Read heavily for routing. Writes should be explicit activation/deactivation/expiry only. - -Control-plane rules: API and bridge control may create/remove/update subscriptions subject to policy. - -Reporting/export view: Subscription activated/deactivated/expired events. - -Compatibility mapping: Current `BRIDGES` entries and `#` reflector export names. - -## StreamState - -Purpose: Tracks voice stream lifecycle and packet ordering. - -Owner: Packet/stream layer. - -Key: Stream ID plus source identity and direction namespace where needed. - -Mutable fields: Source ID, destination/conference TG, RF-visible TG when relevant, slot, last sequence, last packet time, LC state, source server/repeater metadata, loop-control state. - -Expiry/timer behaviour: Explicit terminator is strong end; timeout is softer, especially on HBP. - -Packet-plane rules: Read/write in packet path. Must be local and fast. - -Control-plane rules: Normally read-only, except explicit reset/debug operations. - -Reporting/export view: Call started/ended/lost events. - -Compatibility mapping: Current stream tracking dicts and report socket call state. - -## MeshPeerState - -Purpose: Tracks a peer server/link. - -Owner: Mesh layer. - -Key: Peer/server ID and authenticated endpoint/session where available. - -Mutable fields: Protocol version, endpoint, auth state, last seen, stun/quench state, supported metadata layout, send/receive counters. - -Expiry/timer behaviour: Peer keepalive/control timeout; auth renewal timers. - -Packet-plane rules: Read for admission, protocol layout, and source metadata. Writes last-seen counters and cached safety state only. - -Control-plane rules: API/BCXX may stun, clear stun, authenticate, or update policy. - -Reporting/export view: Peer up/down/stun/source-quench events. - -Compatibility mapping: Current OBP/FBP peer entries. - -## BridgeControlState - -Purpose: Holds bridge-control effects such as source quench, STUN, and authentication state. - -Owner: Mesh/control layer. - -Key: Peer ID plus control scope, for example `(peer_id, stream_id, conference_tg)` for BCSQ. - -Mutable fields: Active flag, reason, expiry, generation, authenticated issuer. - -Expiry/timer behaviour: Source quench and soft controls should expire; hard policy blocks may persist until cleared. - -Packet-plane rules: Read for admission/suppression. Writes only when handling bridge-control packets. - -Control-plane rules: API/BCXX may set or clear controls. - -Reporting/export view: Mesh control events. - -Compatibility mapping: Current BCSQ/BCST handling. - -## ReportingState - -Purpose: Tracks reporting pipeline health and retained current state. - -Owner: Reporting layer. - -Key: Event family or retained state identity. - -Mutable fields: Queue depth, dropped counts, publisher connected state, last emitted state. - -Expiry/timer behaviour: Retained state refresh and reconnect backoff. - -Packet-plane rules: Packet path may enqueue non-blocking events only. - -Control-plane rules: API may read reporting health. - -Reporting/export view: Native v2 reporting state. - -Compatibility mapping: Current dashboard socket state, preferably through a sidecar adapter. - -## CompatibilityExportState - -Purpose: Derived legacy-shaped view for old dashboard/API/HBLink-compatible consumers. - -Owner: Compatibility adapter. - -Key: Consumer-specific. - -Mutable fields: Cached translated state. - -Expiry/timer behaviour: Follows source state; may drop stale export entries. - -Packet-plane rules: Must not be read by packet routing. - -Control-plane rules: May expose old-compatible admin views if required. - -Reporting/export view: Legacy compatibility only. - -Compatibility mapping: `BRIDGES`, `SYSTEM`, `MASTER`, and `#` reflector names. - -## Worker Ownership Considerations - -Authoritative packet-plane state must have one owner. Reporting/export state is derived and must not drive routing. External stores may distribute snapshots or control-plane updates, but they are not per-packet routing dependencies. - -Process boundaries must preserve the same state ownership rules as in-process modules. - -AccessSession: - -- Classification: Access/session state with packet-plane admission impact. -- Likely owner: Transport/listener process initially. -- Future ownership: May be assigned to a routing worker once admitted. - -ClientSlotState: - -- Classification: Packet-plane authoritative state. -- Likely owner: Single routing owner for that client/slot. - -TalkgroupSubscription: - -- Classification: Packet-plane authoritative state. -- Likely owner: Single routing/subscription owner. - -StreamState: - -- Classification: Packet-plane authoritative state. -- Likely owner: Single stream owner; all packets for a given stream should be handled by one owner. - -MeshPeerState: - -- Classification: Split transport/session state and routing policy state. -- Likely owner: Transport/session owner for socket/auth/session facts; routing policy owner for cached packet decisions. -- Rule: Authenticated peer/session state must be cached locally for packet decisions. - -BridgeControlState: - -- Classification: Control-plane input with packet-plane effect. -- Likely owner: Relevant packet/routing owner for active BCSQ/STUN effects. -- Rule: BCSQ/STUN state used by the packet path must be local to the relevant packet owner. - -ReportingState: - -- Classification: Reporting/export snapshot and event state. -- Likely owner: Reporting worker. -- Rule: Not authoritative for packet routing. - -CompatibilityExportState: - -- Classification: Derived compatibility state. -- Likely owner: Compatibility adapter/export worker. -- Rule: Never authoritative. diff --git a/docs/freedmr-2/03-subscription-model.md b/docs/freedmr-2/03-subscription-model.md deleted file mode 100644 index 44aeb54..0000000 --- a/docs/freedmr-2/03-subscription-model.md +++ /dev/null @@ -1,66 +0,0 @@ -# FreeDMR 2 Subscription Model - -The subscription model is the centrepiece of FreeDMR 2. - -Conceptually, each TG is a conference bridge. Client systems subscribe to conference TGs. FreeDMR routes traffic according to active subscriptions, not according to the legacy shape of the `BRIDGES` dict. - -Definitions: - -- `conference_tg`: FreeDMR network/conference identity. -- `rf_tg`: Client-facing RF presentation identity. - -Examples: - -Direct TG: - -```text -rf_tg == conference_tg -``` - -Dial-a-TG: - -```text -rf_tg == 9 -conference_tg == selected reflector/TG -``` - -Alias/rewrite: - -```text -rf_tg may differ from conference_tg by policy/configuration -``` - -Example subscription: - -```python -TalkgroupSubscription( - client_id=2345001, - slot=2, - rf_tg=9, - conference_tg=4400, - mode="dial", - active=True, -) -``` - -## Routing Invariant - -Packet routing should not need to know whether a subscription came from static config, default reflector, dial-a-TG, API, SMS control, or a future UI action. Those are subscription sources, not routing modes. - -## Dial-a-TG Rationale - -Dial-a-TG exists so terminal users can access arbitrary FreeDMR TGs without programming every TG into the terminal/codeplug. It is an amateur-radio usability feature and should be evaluated against that goal, not only against commercial DMR fleet assumptions. - -Control of dial-a-TG from TS1 as well as TS2 is intentional. If TS2 is blocked by unwanted traffic, a user can transmit private-call control on TS1 to disconnect or change the TS2 reflector/TG state. - -Voice prompts should remain RF-visible as TG9 slot 2 unless that policy is deliberately changed. - -## FreeDMR Routing Model - -- TGs are conference groups. -- DMR IDs are like phone numbers. -- Timeslots are access/capacity paths, more like phone lines. -- FreeDMR is intended to be relatively timeslot agnostic. -- TS1 control affecting TS2 reflector state is consistent with the FreeDMR PBX/line model. - -This model also allows future arbitrary RF TG aliases, not only the traditional TG9 dial-a-TG rewrite. diff --git a/docs/freedmr-2/04-packet-and-stream-model.md b/docs/freedmr-2/04-packet-and-stream-model.md deleted file mode 100644 index a199765..0000000 --- a/docs/freedmr-2/04-packet-and-stream-model.md +++ /dev/null @@ -1,61 +0,0 @@ -# Packet and Stream Model - -## Packet Mutation Boundaries - -Raw DMR packet bytes should be treated as immutable input until an explicit rewrite operation. Transport simulation and protocol mutation must remain separate. - -Packet mutation must be named, explicit, and testable. FreeDMR should preserve packet bytes unless it intentionally rewrites them. - -Protocol-sensitive rewrite areas include: - -- Slot bit rewrite. -- TG rewrite. -- Stream ID preservation. -- Source ID preservation. -- Voice header LC rewrite. -- Terminator LC rewrite. -- Embedded LC rewrite. - -Voice header/terminator LC and embedded LC must be handled carefully. Embedded LC rewrite should apply only to voice bursts B-E, not data/control packets. - -Same-TG voice forwarding should preserve embedded LC payloads where possible. TG-mapped forwarding may regenerate embedded LC for routing correctness. - -Data/control packets are packet-oriented and not AMBE voice streams. Group-addressed data is valid and can be routed as data, not reported as voice. Data/control classification must remain separate from group-vs-unit addressing. - -Unit/private calls are control-plane only in FreeDMR. Do not introduce general private voice routing unless project policy changes. - -## Sequence and Lifecycle Principles - -DMRD sequence numbers are one byte and modulo-256. - -- Delta `0`: duplicate. -- Delta `1`: normal progress. -- Delta `2..127`: forward progress with loss. -- Delta `128..255`: stale or out-of-order. - -Explicit voice terminator is a strong end-of-stream signal. Timeout without terminator is softer and may remain recoverable on HBP to preserve audio continuity. - -HBP should be more tolerant because it is RF-facing and real deployments include imperfect terminals, repeaters, RF paths, cellular links, and RF IP links. FBP/OBP can be stricter because it is server-to-server, but should still preserve audio where possible on unreliable links. - -Loop-control safety must not be overridden by tolerance for delayed or out-of-order packets. - -## LC and OVCM - -For DMR Group Voice Channel User LC, the first bytes are: - -- FLCO -- FID -- Service Options - -Normal synthetic group voice LC should use service options `0x00`. - -OVCM is `0x04` if explicitly required. - -HBLink legacy `0x20` should be documented as legacy/compatibility only. It is not standards-clean OVCM and should not be used as a new synthetic/system-generated traffic marker. - -Decoded real inbound LC must be preserved unchanged unless there is a deliberate reason to rewrite. Synthetic/fallback LC generation must be explicit and tested. FreeDMR routing metadata should be used for routing state, not magic bits in synthetic LC. - -## Open Questions - -- Exact live RF behaviour after long HBP gaps still needs validation with real repeaters and terminals. -- Some prompt/late-entry behaviour may need live testing because terminal interpretation of DMR standards can be loose or incomplete. diff --git a/docs/freedmr-2/05-data-packet-policy.md b/docs/freedmr-2/05-data-packet-policy.md deleted file mode 100644 index 1aedce4..0000000 --- a/docs/freedmr-2/05-data-packet-policy.md +++ /dev/null @@ -1,15 +0,0 @@ -# Data Packet Policy - -No regression of DMR data support is permitted. - -FreeDMR should forward supported DMR data packets according to conference/subscription and mesh rules. The FreeDMR core should not become a general GPS/SMS application processor. - -GPS, SMS, and similar application processing should be implemented by systems connected via FBP or another mesh/access-adjacent interface. `DATA-GATEWAY` is understood as an earlier expression of this model. - -Existing `SUB_MAP` / last-known-location behaviour is intentional: data addressed to a DMR ID can be routed toward the last known HBP/client location. - -Narrow exceptions may exist for SMS-based dial-a-TG control or DMR SMS alerts to the sysop. - -Data/control classification must be separate from group-vs-unit addressing. A group-addressed data packet is not automatically a voice stream. - -The packet layer may inspect data packets for admission, routing, loop/source-quench safety, reporting, and metadata preservation. Application-level GPS/SMS semantics should live outside the core unless a specific control-plane feature requires it. diff --git a/docs/freedmr-2/06-mesh-model.md b/docs/freedmr-2/06-mesh-model.md deleted file mode 100644 index 88feb96..0000000 --- a/docs/freedmr-2/06-mesh-model.md +++ /dev/null @@ -1,40 +0,0 @@ -# Mesh Model - -FreeDMR is a peer network, not hub-and-spoke. Local sysops retain policy autonomy. - -The guiding principle remains "everything everywhere", subject to source quench, STUN, ACLs, local policy, authentication, loop-control, and documented exceptions. - -## Loop Control and Bridge Control - -Loop control, source selection, duplicate suppression, source quench, and STUN are packet-plane safety mechanisms. - -Source quench is a control hint to suppress a stream/TG toward a peer. It is optional and scoped per stream/TG. - -STUN is a broader FBP/OpenBridge traffic gate. Under the current conceptual model, `BCST`/STUN applies to all FBP traffic from that peer until cleared or expired by policy. - -`BCSQ` is per stream/TG. - -`BCST`/STUN is all FBP traffic. - -## Protocol Versions and Metadata - -Source server and source repeater metadata must be preserved according to the protocol version actually in use for that session. - -OBP/FBP protocol version controls metadata layout and option order. - -Protocol v1 OBP remains an important open interop path where intentionally configured. FBP v5 is the current richer peer-server protocol target. FBP v4 is historical/deprecation context unless explicitly retained. - -## TG Namespace Rule - -HBP/RF-visible TG and FBP/OBP-visible conference TG can intentionally differ, especially with dial-a-TG. - -Source quench must use the TG namespace visible to the peer sending or receiving the quench. - -For HBP-to-FBP dial-a-TG, `BCSQ` should use the FBP/reflector TG, not local RF TG9. - -For OBP-source traffic, `BCSQ` should use the inbound OBP TG because that is the source-server namespace. - -## Open Questions - -- Final FBP v5 identity/auth fields still need a concrete wire-format decision. -- STUN recovery policy needs an operator workflow, likely API-driven. diff --git a/docs/freedmr-2/07-reporting-model.md b/docs/freedmr-2/07-reporting-model.md deleted file mode 100644 index f5f2a27..0000000 --- a/docs/freedmr-2/07-reporting-model.md +++ /dev/null @@ -1,203 +0,0 @@ -# Reporting Model - -Decision: FreeDMR 2 replaces the legacy dashboard/report socket model with a new structured reporting event model. - -The existing dashboard is not a compatibility constraint for the FreeDMR 2 core. It must be updated separately or supported by an optional out-of-process adapter. - -Reporting is observational only. Packet routing must not depend on dashboard/report consumers. - -The v2 event schema is the compatibility contract. Raw `BRIDGES`/`SYSTEM` state should not be exposed as the primary v2 API. Old dashboard/report socket event names should not shape the FreeDMR 2 core. - -Any old dashboard compatibility must live in a sidecar/adapter, not inside packet routing. FreeDMR 1.x/current code remains live until FreeDMR 2 is ready, so FreeDMR 2 can make a clean reporting break. - -## Preferred Transport - -MQTT is the preferred external live reporting transport. - -Architecture: - -```text -packet path -> non-blocking bounded local event queue -> MQTT publisher worker -> local broker/feed -``` - -Constraints: - -- MQTT publishing must be asynchronous from the packet worker. -- Use a bounded queue. -- The bounded local event queue is the only coupling from packet path to reporting worker. -- Drop or coalesce low-priority events under pressure. -- Emit a later reporting-health event rather than blocking packet handling. -- Voice stability takes precedence over reporting completeness. -- Reconnect with exponential backoff. -- Refresh retained state after reconnect. -- Reporting backpressure must be visible through reporting-health events but must not delay DMR packets. - -Reporting is the first major candidate for out-of-process execution. The MQTT publisher should be an independent worker or sidecar where practical. The global lastheard exporter should be a separate process. Dashboard aggregation should not run in the packet hot path. - -Reporting worker crash must not affect packet routing. Reporting worker restart should refresh retained state after reconnect. - -## Local Dashboard and Global Lastheard - -Local dashboard: - -- Consumes local MQTT live state/events. -- Displays live client/repeater/server traffic. -- Recovers from retained state after reconnect. - -Global lastheard: - -- Central/non-real-time. -- Consumes summaries, not packet-plane traffic. -- Should preferably be fed by a separate exporter process. -- Central outage must not affect local packet handling or local dashboard. - -Preferred flow: - -```text -FreeDMR core -> local MQTT feed -> local dashboard - -> global-exporter process -> network MQTT/collector -``` - -## Initial Event Families - -- `server.started` -- `server.stopping` -- `client.connected` -- `client.disconnected` -- `client.options_changed` -- `subscription.activated` -- `subscription.deactivated` -- `subscription.expired` -- `call.started` -- `call.ended` -- `call.lost` -- `mesh.peer_up` -- `mesh.peer_down` -- `mesh.source_quench` -- `mesh.stun` -- `loop.detected` -- `packet.rate_limited` -- `reporting.queue_overflow` -- `reporting.publisher_disconnected` -- `reporting.publisher_reconnected` -- `reporting.events_dropped` - -## Suggested MQTT Topics - -```text -freedmr/v2/{server_id}/state -freedmr/v2/{server_id}/client/{client_id}/state -freedmr/v2/{server_id}/client/{client_id}/slot/{slot}/activity -freedmr/v2/{server_id}/subscription/{subscription_id}/state -freedmr/v2/{server_id}/call/{stream_id}/start -freedmr/v2/{server_id}/call/{stream_id}/end -freedmr/v2/{server_id}/mesh/{peer_id}/state -freedmr/v2/{server_id}/event -``` - -Use retained messages for current state and non-retained messages for transient events. - -## Example Events - -```json -{ - "event": "server.started", - "server_id": "2345", - "version": "2.0-dev", - "time": "2026-05-24T12:00:00Z" -} -``` - -```json -{ - "event": "client.connected", - "server_id": "2345", - "client_id": 2345001, - "listener": "hbp-public", - "endpoint": "198.51.100.10:62031", - "time": "2026-05-24T12:00:01Z" -} -``` - -```json -{ - "event": "subscription.activated", - "server_id": "2345", - "subscription_id": "2345001-2-9-4400", - "client_id": 2345001, - "slot": 2, - "rf_tg": 9, - "conference_tg": 4400, - "mode": "dial", - "source": "dial-a-tg", - "time": "2026-05-24T12:00:02Z" -} -``` - -```json -{ - "event": "call.started", - "server_id": "2345", - "stream_id": 12345678, - "client_id": 2345001, - "slot": 2, - "source_id": 2345678, - "rf_tg": 9, - "conference_tg": 4400, - "source": "hbp", - "time": "2026-05-24T12:00:03Z" -} -``` - -```json -{ - "event": "call.ended", - "server_id": "2345", - "stream_id": 12345678, - "reason": "terminator", - "duration_ms": 18420, - "packets": 614, - "time": "2026-05-24T12:00:21Z" -} -``` - -```json -{ - "event": "call.lost", - "server_id": "2345", - "stream_id": 12345678, - "reason": "timeout", - "last_seen_ms_ago": 7000, - "time": "2026-05-24T12:00:28Z" -} -``` - -```json -{ - "event": "mesh.source_quench", - "server_id": "2345", - "peer_id": "2350", - "stream_id": 12345678, - "conference_tg": 4400, - "reason": "duplicate-source", - "time": "2026-05-24T12:00:04Z" -} -``` - -```json -{ - "event": "reporting.queue_overflow", - "server_id": "2345", - "dropped_events": 42, - "queue_limit": 2048, - "policy": "drop-low-priority", - "time": "2026-05-24T12:00:05Z" -} -``` - -## Open Questions - -- Broker packaging and defaults: Mosquitto, embedded broker, external broker, or optional dependency. -- Exact retained-state expiry policy. -- Authentication model for MQTT clients. -- Whether legacy dashboard compatibility is a supplied sidecar or a separate dashboard migration task. diff --git a/docs/freedmr-2/08-api-control-model.md b/docs/freedmr-2/08-api-control-model.md deleted file mode 100644 index 019afb0..0000000 --- a/docs/freedmr-2/08-api-control-model.md +++ /dev/null @@ -1,52 +0,0 @@ -# API and Control Model - -The current HTTP/JSON API is experimental and should be treated as a starting point, not a fixed FreeDMR 2 contract. - -## Current API Principles - -- Local administration and automation. -- Not for public internet exposure. -- Small in-memory operations only. -- Bounded request bodies. -- No expensive live serialization of internal state. -- No blocking packet path. -- User-level authentication by connected peer/client session key. -- System-level authentication by system API key. - -The API should bind to localhost by default unless explicitly configured otherwise. - -## FreeDMR 2 Direction - -API operations should be bounded control-plane operations over access sessions, subscriptions, mesh peers, and reporting state. - -Destructive system actions such as kill, resetall, and STUN clear should be separately enableable. Audit logs are required. Keys and secrets must never be logged. - -Dashboard controls should use authenticated HTTP API operations unless a future UI genuinely needs bidirectional streaming. - -Compatibility with the old API should be an adapter concern if needed. - -The API may eventually run as a separate control-plane worker or sidecar. API requests should become `ControlCommand` messages sent to the owner of the relevant state. - -The API must not directly mutate packet-plane state it does not own. Destructive operations must go through explicit owner-handled commands. API worker failure should not stop existing packet routing, although new control actions may fail until the worker recovers. - -## Suggested v2 Operations - -```text -GET /api/v2/health -GET /api/v2/version -GET /api/v2/state -GET /api/v2/client/{client_id} -GET /api/v2/client/{client_id}/slot/{slot} -POST /api/v2/client/{client_id}/slot/{slot}/subscriptions -DELETE /api/v2/client/{client_id}/slot/{slot}/subscriptions/{subscription_id} -POST /api/v2/mesh/{peer_id}/stun -DELETE /api/v2/mesh/{peer_id}/stun -POST /api/v2/system/reset -POST /api/v2/system/stop -``` - -## Packet-Path Rule - -API handlers must not perform work that delays packet routing. Expensive state export, dashboard compatibility, global reporting, and administrative analysis should use snapshots, bounded queues, or separate processes. - -No API path should force expensive live serialization of packet-plane state. diff --git a/docs/freedmr-2/09-security-model.md b/docs/freedmr-2/09-security-model.md deleted file mode 100644 index d0b79ff..0000000 --- a/docs/freedmr-2/09-security-model.md +++ /dev/null @@ -1,56 +0,0 @@ -# Security Model - -Core principle: FreeDMR may sign/authenticate traffic and control messages. FreeDMR should not encrypt amateur-radio or mesh traffic by default. - -The security model is authenticity, integrity, membership validation, and local policy, not secrecy. Amateur radio is public, and users may provide IP backhaul over amateur-radio links where encryption rules matter. - -## Mesh Authentication - -Preferred direction: - -- PKI-backed FBP peer admission through Bridge Control / BCXX. -- Signed server/sysop identity. -- Bind server ID, authorized sub-IDs, public key, validity, and features where useful. -- Bind authenticated identity to observed endpoint/IP. -- If endpoint changes, peer must re-authenticate. -- Expensive signature/cert validation is control-plane work. -- Packet-plane uses cached authenticated session state. -- Soft renewal should avoid interrupting in-flight voice when safe. -- Hard stop on revocation, explicit failure, endpoint mismatch outside policy, grace expiry, or local policy. - -## Identity and Listing - -Signed identity proves membership/identity, not mandatory carriage. Public listing is separate from mesh identity. - -Local sysops may choose whether to carry or vouch for traffic. A valid signed key does not override local policy. - -Vouching sysop accountability is part of FreeDMR's social trust model. A sysop allowing problematic traffic onto the mesh may see other peers stop peering with them. - -One verification of a key may cover the server ID and authorized sub-IDs for that sysop/server deployment. - -## Distributed Key Gossip Option - -Signed membership documents may be gossiped over bounded/rate-limited BCXX. - -Peers validate signatures and build local key tables. Revocation, expiry, serials, and replay protection are required. - -Key gossip cannot create trust by mere repetition. The packet path must use cached key/policy state. - -This supports autonomous routing decisions for packets that originated from a server even when that source server is not directly connected. - -## Analogue and Digital Bridge Policy - -Analogue ROIP bridges may connect as HBP clients. Permitted does not mean automatically valuable. - -Analogue bridges can be operationally sensitive because mixed or continuous analogue audio is a poor fit for DMR one-source-at-a-time stream behaviour. They may hold a TG open, play tones, or prevent digital users from breaking in until a carrier/timer drops. - -Analogue bridges should be subject to local policy, listing expectations, and peer accountability. - -YSF/NXDN and other AMBE-family networks are often a better technical match than analogue or unlike-codec transcoding, because they can avoid lossy audio translation. - -## Open Questions - -- X.509 certificates versus simpler Ed25519 signed membership documents. -- Exact revocation and renewal distribution process. -- Default grace period for soft re-authentication. -- How much key gossip should be enabled by default. diff --git a/docs/freedmr-2/10-runtime-and-concurrency.md b/docs/freedmr-2/10-runtime-and-concurrency.md deleted file mode 100644 index bf5061a..0000000 --- a/docs/freedmr-2/10-runtime-and-concurrency.md +++ /dev/null @@ -1,69 +0,0 @@ -# Runtime and Concurrency - -Decision: Do not replace Twisted as the first FreeDMR 2 architecture move. - -## Rationale - -Current packet behaviour is subtle. Twisted's single-threaded reactor is currently a safety boundary. Replacing the event loop and the state model at the same time mixes too many changes. - -The first goal is architectural clarity and testability, not event-loop novelty. - -## Immediate Runtime Strategy - -- Keep Twisted initially as the transport shell. -- Use Twisted's single-threaded reactor as a safety boundary while the core is extracted. -- Do not replace the event loop and state model at the same time. -- Extract the protocol/routing/subscription core behind deterministic interfaces. -- Keep packet-plane state local and deterministic. -- No blocking work in reactor callbacks. -- No dashboard/API/database/MQTT waits in the packet path. -- Single-owner state is preferred. -- Explicit messages/events are preferred over shared mutable dictionaries across threads/processes. - -## Eventual Capacity Strategy - -FreeDMR 2 should support worker-process scaling once state ownership and message boundaries are explicit and tested. - -The purpose of worker processes is not merely performance. It is also: - -- Clearer state ownership. -- Failure isolation. -- Safer concurrency. -- Testable boundaries. -- Future capacity scaling. - -Prefer process/actor ownership over shared-memory no-GIL threading for authoritative routing state. No-GIL Python does not remove the need for clear ownership of mutable packet-plane state. - -Offload non-packet-path work first, including reporting, MQTT publishing, global export, SQL writes, dashboard aggregation, alias refresh, analytics, and lab/codec work. - -Routing-core workers are a later stage. Multi-worker sharding should only be considered after single-worker message-boundary behaviour is proven. - -Twisted can remain the transport shell while reporting/export/control workers move out-of-process. - -## Ownership Split - -Twisted parent/transport process may own: - -- UDP sockets. -- HBP/FBP packet receive/send. -- Timers. -- Process supervision. - -Routing core should eventually own: - -- Stream state. -- Subscription state. -- Dial-a-TG state. -- Loop-control state. -- Duplicate suppression. -- Routing decisions. - -Migration must be staged and covered by tests. FreeDMR should remain deployable on ordinary low-cost systems such as cheap VPS instances and Raspberry Pi-class hardware. - -See `13-worker-process-scaling.md` for the eventual worker-process capacity model. - -## External Databases - -External stores can be useful for configuration, reporting snapshots, global lastheard, operator UI, and coordination. They should not sit in the packet hot path. - -Packet-plane state should stay local and in memory unless a future design proves a bounded, non-blocking alternative. diff --git a/docs/freedmr-2/11-testing-and-release-gates.md b/docs/freedmr-2/11-testing-and-release-gates.md deleted file mode 100644 index 4abfb43..0000000 --- a/docs/freedmr-2/11-testing-and-release-gates.md +++ /dev/null @@ -1,100 +0,0 @@ -# Testing and Release Gates - -FreeDMR 2 must preserve behaviour through tests before changing architecture. The existing deterministic harness, UDP black-box harness, codec tests, support tests, and future live RF validation form the release gate structure. - -## Test Commands - -General test run: - -```bash -python -m unittest discover -v -``` - -Focused support/codec tests: - -```bash -python -m unittest tests.test_freedmr_dmr_codec tests.test_utils -v -``` - -UDP black-box tests: - -```bash -FREEDMR_RUN_UDP_TESTS=1 python -m unittest tests.test_udp_blackbox_harness -v -``` - -UDP black-box tests with venv bootstrap: - -```bash -FREEDMR_RUN_UDP_TESTS=1 \ -FREEDMR_UDP_BOOTSTRAP_VENV=1 \ -FREEDMR_UDP_VENV_DIR=/tmp/freedmr-blackbox-venv3 \ -PYTHONDONTWRITEBYTECODE=1 \ -python -m unittest tests.test_udp_blackbox_harness -v -``` - -## Release Gates - -Level 0: Codec/unit/config/support tests. Must pass for every commit. - -Level 1: Deterministic packet/state harness. Must pass before merge to main. - -Level 2: Black-box UDP harness. Must pass before release candidate. - -Level 3: Live RF / real repeater / real peer validation. Required before changing protocol-visible behaviour. - -## What Each Layer Proves - -Deterministic harness: - -- Good for packet parsing seams, routing state, dial-a-TG state, fake-clock expiry, rewrite boundaries, LC/embedded LC tests, data-vs-voice classification, and reporting event generation. -- Bypasses real UDP, socket binding, subprocess startup, and Twisted timing. - -UDP black-box harness: - -- Good for subprocess startup, HBP login, UDP parsing, FBP signing, bridge-control, malformed/hostile packets, cadence, packet ordering, and link impairment. -- Cannot prove RF-side modem/radio behaviour. - -Live RF validation: - -- Required for protocol-visible changes, prompt/ident behaviour, late entry, OVCM/LC options, repeater/radio compatibility, and real terminal quirks. - -## Required Assertions - -Tests should assert: - -- Route recipients and non-recipients. -- Packet byte preservation outside allowed rewrite regions. -- Explicit rewrite ranges. -- Stream lifecycle. -- Subscription state. -- Reporting events. -- Source quench/STUN behaviour. -- Absence of unintended traffic. - -## Worker/process-boundary Release Gates - -Before moving packet-plane behaviour across a process boundary: - -- Deterministic in-process behaviour must already be covered. -- The same scenario must be covered through message-boundary tests. -- The same scenario must be covered through UDP black-box tests where observable. -- Packet bytes must be compared before and after crossing the process boundary. -- Route recipient/non-recipient sets must match. -- Allowed rewrite regions must match. -- Source quench/STUN/loop-control behaviour must match. -- Failure injection must prove worker crash/restart does not replay stale packets. -- Reporting/control worker backpressure must not block packet routing. -- Live RF validation is required for protocol-visible behaviour. - -Suggested future test categories: - -- Reporting worker crash during active call. -- Global exporter outage. -- API worker unavailable during normal traffic. -- Routing worker restart while stream active. -- Routing worker backpressure. -- Queue overflow from packet process to reporting worker. -- Stale `PacketReceived` replay prevention. -- Duplicate packet prevention after worker restart. -- Stream ownership handoff/drain test. -- Coordinator restart test, if a coordinator is introduced. diff --git a/docs/freedmr-2/12-migration-plan.md b/docs/freedmr-2/12-migration-plan.md deleted file mode 100644 index 423ee2f..0000000 --- a/docs/freedmr-2/12-migration-plan.md +++ /dev/null @@ -1,56 +0,0 @@ -# Migration Plan - -Constraints: - -- No big-bang rewrite of packet semantics. -- Current FreeDMR remains live until FreeDMR 2 is tested. -- Build FreeDMR 2 core beside current code where practical. -- Preserve behaviours behind tests. -- Compatibility adapters are allowed, but old internal shape should not define the new core. -- Worker-process scaling is a design direction, not a reason for a big-bang rewrite. - -## Stages - -Stage 0: Stabilise current code, harness, docs, codec, and known behaviour. - -Stage 1: Distil architecture, glossary, state model, and reporting event contract. - -Stage 2: Extract packet/codec helpers and deterministic routing/subscription seams. - -Stage 3: Introduce explicit internal message/event objects in-process. - -Stage 4: Implement new subscription store in parallel with compatibility export if needed. - -Stage 5: Move reporting/MQTT publisher to an independent worker/sidecar. - -Stage 6: Move global lastheard exporter, SQL writes, dashboard aggregation, and non-critical analytics to workers. - -Stage 7: Define v2 API/control-plane operations over sessions, subscriptions, mesh state, and reporting health, and express them as owner-handled `ControlCommand` messages. - -Stage 8: Introduce listener/client session model supporting multiple clients per listener. - -Stage 9: Introduce mesh auth/BCXX identity admission into the control plane. - -Stage 10: Experiment with a single routing-core worker behind the already-tested message interface. - -Stage 11: Evaluate multi-routing-worker sharding only after single-worker routing is stable and covered. - -Stage 12: Cut over only after deterministic, UDP, and live RF validation. - -## Non-Goals - -- Do not add general user-to-user private voice routing. -- Do not make FreeDMR core a GPS/SMS application processor. -- Do not make reporting/dashboard consumers part of packet routing. -- Do not encrypt amateur-radio traffic by default. -- Do not replace Twisted before extracting and test-covering the core. -- Do not preserve the old dashboard protocol inside the FreeDMR 2 packet core. -- Do not make worker-process scaling an immediate rewrite requirement. -- Do not use Redis, Postgres, MQTT, dashboards, or APIs for live per-packet routing decisions. - -## Open Questions - -- Exact cut-over mechanism from current `BRIDGES` state to subscription state. -- Whether old dashboard compatibility is shipped as part of FreeDMR 2 or maintained with the dashboard. -- Final mesh authentication wire format and key distribution policy. -- Live RF validation matrix for repeaters, hotspots, terminals, and analogue/digital bridges. diff --git a/docs/freedmr-2/13-worker-process-scaling.md b/docs/freedmr-2/13-worker-process-scaling.md deleted file mode 100644 index 79a2a14..0000000 --- a/docs/freedmr-2/13-worker-process-scaling.md +++ /dev/null @@ -1,289 +0,0 @@ -# Worker Process Scaling - -## Decision - -FreeDMR 2 should be designed so that, after the protocol/routing/subscription core has been extracted and tested, selected parts of the system can be moved into separate worker processes to improve capacity, isolate failures, and avoid the practical single-thread/GIL limits of one Python process. - -This is not a first-stage rewrite requirement. - -The first stage remains: - -- Keep Twisted initially. -- Extract the deterministic core. -- Make state ownership explicit. -- Preserve packet behaviour through tests. - -But the FreeDMR 2 state model must not block a later move to worker processes. - -## Why Worker Processes - -CPython single-process execution has practical limits for CPU-bound Python code. - -Twisted's single reactor is useful as an initial safety boundary, but one reactor process should not be assumed to be the final capacity architecture. - -Worker processes provide stronger ownership and failure boundaries than shared-memory threads. Process/message boundaries are safer for FreeDMR routing state than no-GIL shared mutable dictionaries. - -Worker processes fit FreeDMR's need for explicit ownership of routing, stream, subscription, loop-control, and reporting state. - -Worker-process scaling must not make ordinary small FreeDMR deployments heavyweight or hard to run. A single-server deployment on a cheap VPS or Raspberry Pi-class system must remain supported. - -## What Should Be Offloaded First - -Low-risk early offload candidates: - -- MQTT/reporting publisher. -- Global lastheard exporter. -- Dashboard aggregation. -- SQL/database writing. -- Historical analytics. -- Alias download/refresh. -- Expensive codec experiments. -- Packet capture/replay analysis. -- Non-critical maintenance jobs. -- Future transcoding/bridge adjuncts. -- Future network-analysis or observability tools. - -These workers must be asynchronous from the packet path. - -If they are slow, blocked, crashed, overloaded, or absent, packet routing must continue. - -Reporting completeness is secondary to voice stability. - -## What Should Not Be Offloaded Early - -Do not initially offload hot-path routing decisions if doing so would add IPC, network, database, lock, queue, or back-pressure waits to every DMR packet. - -Specifically keep local and deterministic until the model is proven: - -- Stream admission. -- Duplicate suppression. -- Loop control. -- Source quench checks. -- Dial-a-TG state mutation. -- Subscription lookup. -- Slot/TG rewrite decisions. -- Voice/data classification. -- Packet mutation/rewrite. -- HBP RF-facing tolerance logic. -- Protocol-version-sensitive FBP/OBP metadata handling. - -The packet plane must continue to use local in-memory state and must not depend on external databases, MQTT, dashboards, APIs, or reporting consumers. - -## Possible Long-Term Worker Architecture - -### Transport/listener Process - -Owns: - -- UDP sockets. -- HBP receive/send. -- FBP/OBP receive/send. -- Raw packet admission. -- Socket identity. -- Keepalive. -- Low-level protocol parsing. -- Forwarding packet events to the owning routing component. - -### Routing Core Worker - -Owns: - -- Subscription state. -- Stream state. -- Dial-a-TG state. -- Loop-control state. -- Source-quench state. -- Duplicate suppression. -- Packet routing decisions. -- Explicit packet rewrite decisions. -- Authoritative packet-plane state for its assigned clients/streams/TGs. - -### Reporting Worker - -Owns: - -- MQTT publishing. -- Retained state refresh. -- Reporting event queue. -- Dashboard event fanout. -- Reporting health events. -- Drop/coalesce policy under pressure. - -### Global Exporter Worker - -Owns: - -- Subscribing to local reporting feed. -- Filtering and summarising local events. -- Publishing curated summaries to global lastheard/network collector. -- Retry/spool policy for central outage. - -### Control/API Worker or Control-Plane Adapter - -Owns: - -- Sysop/API requests. -- Validating control-plane credentials. -- Converting API requests into explicit state-change commands. -- Receiving `ControlResult` messages. -- Never directly mutating packet-plane state it does not own. - -### Optional Future Codec/Transcode/Analysis Workers - -Own: - -- Expensive or experimental codec work. -- Transcoding adjuncts. -- Packet replay analysis. -- Offline diagnostics. -- Future lab features. - -These must remain outside the live packet hot path unless explicitly proven safe. - -## State Ownership Rules - -- Every mutable authoritative state object must have exactly one owner. -- Other processes may hold snapshots or caches, but only the owner mutates authoritative state. -- Do not use `multiprocessing.Manager().dict()` or shared mutable proxy objects as the main architecture. -- Do not recreate a cross-process global `BRIDGES`-style mutable structure. -- Use explicit messages/events instead of pretending cross-process state is a normal Python dict. -- Packet bytes crossing process boundaries should be immutable. -- Packet mutation must remain explicit, named, and testable. -- A process boundary must not hide unclear ownership. -- State ownership must be visible in tests and documentation. - -## Message Boundary - -Likely internal message families: - -| Message | Plane | -| --- | --- | -| `PacketReceived` | packet-plane | -| `PacketAccepted` | packet-plane | -| `PacketDropped` | packet-plane | -| `RouteDecision` | packet-plane | -| `PacketToSend` | packet-plane | -| `PacketMutated` | packet-plane | -| `StreamStarted` | packet-plane | -| `StreamEnded` | packet-plane | -| `StreamLost` | packet-plane | -| `SubscriptionActivated` | control-plane | -| `SubscriptionDeactivated` | control-plane | -| `SubscriptionExpired` | packet-plane | -| `SourceQuenchReceived` | packet-plane | -| `SourceQuenchSendRequested` | packet-plane | -| `StunActivated` | control-plane | -| `StunCleared` | control-plane | -| `ReportingEvent` | reporting-plane | -| `ControlCommand` | control-plane | -| `ControlResult` | control-plane | -| `WorkerStarted` | worker/supervision-plane | -| `WorkerStopping` | worker/supervision-plane | -| `WorkerHealth` | worker/supervision-plane | -| `WorkerBackpressure` | worker/supervision-plane | -| `WorkerCrashed` | worker/supervision-plane | -| `WorkerRestarted` | worker/supervision-plane | - -Packet-plane messages must be compact, bounded, and safe for high frequency use. - -## Partitioning / Sharding Options - -Possible future sharding models, without choosing one prematurely: - -- By client/repeater DMR ID. -- By listener/access socket. -- By conference TG. -- By source server / mesh peer. -- By stream ID. -- Hybrid model. - -Constraints: - -- All packets for a given live stream must be processed in order by the same stream owner. -- Dial-a-TG state for one client/slot must have one owner. -- Subscription state for one client/slot must have one owner. -- Loop-control/source-quench state must be consistent for a given TG/stream/source path. -- Cross-worker routing must not reintroduce duplicate packets or loops. -- Worker assignment must be observable and testable. -- Worker assignment must not depend on dashboard/reporting state. -- Sharding must preserve the FreeDMR "everything everywhere" mesh principle, subject to existing source quench, STUN, ACL, policy, and authentication rules. - -## Coordinator Model - -FreeDMR 2 may eventually need a lightweight coordinator. - -The coordinator may: - -- Assign clients/sessions/TGs to workers. -- Distribute subscription snapshots. -- Manage worker health. -- Restart workers. -- Publish control-plane updates. -- Provide routing-worker discovery. -- Coordinate graceful drain/restart. - -The coordinator must not: - -- Synchronously participate in every packet routing decision. -- Become a single blocking dependency for live voice. -- Hide packet-plane state in an external database. -- Make ordinary small deployments require clustered infrastructure. - -Single-process/small-server deployment must remain supported. A coordinator should be optional or internal for simple deployments. - -## Failure Behaviour - -- Reporting worker failure: packet routing continues. -- Global exporter failure: local service continues. -- Dashboard aggregation failure: packet routing continues. -- API/control worker failure: existing packet routing continues, but new control actions may fail. -- Alias refresh worker failure: current aliases remain in use. -- Analytics worker failure: packet routing continues. -- Routing worker failure: affected sessions/streams are dropped or restarted according to explicit policy. -- Transport/listener failure: affected sockets/sessions are lost until restart. -- Worker restart must not replay stale DMR packets. -- Retained/reporting state may be refreshed after recovery. -- In-flight voice may be lost during worker crash, but failure must not poison the mesh or produce loops. -- Backpressure from non-packet workers must not propagate into the packet path. - -## Tests Required Before Worker Split - -Before moving any packet-plane component into a separate process, require: - -- Deterministic harness coverage of the state machine. -- UDP black-box coverage of the same behaviour. -- Message-boundary tests proving packet bytes and route decisions are preserved. -- Failure-injection tests for worker timeout/crash/restart. -- Queue-backpressure tests proving reporting/control workers cannot block packets. -- Tests proving no stale packet replay after worker restart. -- Tests proving source quench, STUN, loop-control, and duplicate suppression are preserved across the boundary. -- Live RF validation for protocol-visible behaviour. - -Worker split must not be considered complete until deterministic, UDP, and live RF validation agree for protocol-visible paths. - -## Migration Path - -Stage A: Extract pure/deterministic routing/subscription state behind explicit interfaces. - -Stage B: Introduce internal message/event objects in-process. - -Stage C: Move reporting/MQTT/global export to separate processes. - -Stage D: Move slow maintenance, alias refresh, SQL/global lastheard, and analytics work to workers. - -Stage E: Experiment with routing core as a child process behind the same message interface. - -Stage F: Evaluate multi-routing-worker sharding only after the single routing-worker process model is stable and fully tested. - -Stage G: Only after the above, consider whether transport/listener processes should be split by listener, client set, protocol, or deployment role. - -## Explicit Non-Goals - -- Do not introduce shared mutable cross-process `BRIDGES`-like state. -- Do not depend on Redis/Postgres/MQTT for per-packet routing decisions. -- Do not require heavyweight infrastructure for ordinary single-server deployments. -- Do not use worker processes to hide unclear state ownership. -- Do not move protocol-sensitive packet mutation across process boundaries until byte-preservation and rewrite tests prove equivalence. -- Do not assume no-GIL Python solves FreeDMR's state ownership problem. -- Do not replace Twisted and introduce worker sharding in the same step. -- Do not make reporting, dashboard, SQL, global lastheard, or API availability part of the packet routing path. diff --git a/docs/freedmr-2/README.md b/docs/freedmr-2/README.md deleted file mode 100644 index ab299d6..0000000 --- a/docs/freedmr-2/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# FreeDMR 2 Architecture Notes - -This directory contains the distilled FreeDMR 2 design notes. The older notes in `docs/codex-notes.md`, `docs/freedmr-2-architecture-decisions.md`, and the test/API docs remain source material and engineering history. - -FreeDMR 2 is not a blind rewrite of packet behaviour. The protected asset is the FreeDMR operating model: DMR packet semantics, dial-a-TG, TG/DMR-ID-centric routing, loop control, source quench, mesh behaviour, RF-side tolerance, data forwarding, and practical amateur-radio interoperability. - -Recommended reading order: - -1. `00-glossary.md` -2. `01-system-model.md` -3. `02-state-model.md` -4. `03-subscription-model.md` -5. `04-packet-and-stream-model.md` -6. `05-data-packet-policy.md` -7. `06-mesh-model.md` -8. `07-reporting-model.md` -9. `08-api-control-model.md` -10. `09-security-model.md` -11. `10-runtime-and-concurrency.md` -12. `13-worker-process-scaling.md` -13. `11-testing-and-release-gates.md` -14. `12-migration-plan.md` - -Architecture Decision Records live in `adr/`. They record proposed FreeDMR 2 decisions separately from implementation work. - -Current FreeDMR 1.x remains live until FreeDMR 2 is tested. Compatibility adapters are allowed where useful, but old HBLink object layout, dashboard socket assumptions, and legacy `BRIDGES` structure should not define the FreeDMR 2 core. - -FreeDMR 2 keeps Twisted initially but is designed for eventual worker-process scaling. Non-packet-path workers such as reporting/global export move first; packet-plane routing workers are a later stage after state ownership and message-boundary tests are proven. diff --git a/docs/freedmr-2/adr/0001-protected-model-not-hblink-structure.md b/docs/freedmr-2/adr/0001-protected-model-not-hblink-structure.md deleted file mode 100644 index f106b88..0000000 --- a/docs/freedmr-2/adr/0001-protected-model-not-hblink-structure.md +++ /dev/null @@ -1,24 +0,0 @@ -# ADR 0001: Protected Model, Not HBLink Structure - -## Status -Proposed - -## Context -FreeDMR current code is HBLink-derived and centred around `bridge_master.py`, `hblink.py`, configured `MASTER`/`SYSTEM` stanzas, global `BRIDGES`, Twisted, and the current dashboard/report model. - -The valuable part is the operating model learned from real deployments: packet semantics, dial-a-TG, TG/DMR-ID-centric routing, loop control, source quench, mesh behaviour, RF-side tolerance, data forwarding, and practical amateur-radio interoperability. - -## Decision -The protected asset is FreeDMR behaviour/model, not the HBLink-derived object layout. - -## Rationale -HBLink-era structure blocks clarity, scaling, multi-client listeners, and testability. FreeDMR 2 should preserve validated packet behaviour while allowing cleaner internal models. - -## Consequences -FreeDMR 2 may replace legacy internal structures. Behaviour changes still require tests and live validation where protocol-visible. - -## Compatibility -Compatibility views may expose old shapes such as `BRIDGES`, `MASTER`, `SYSTEM`, or `#` reflector names, but they are adapters, not authoritative core state. - -## Testing Requirements -Regression tests must cover routing, dial-a-TG, loop control, source quench, data forwarding, and packet rewrite behaviour before internal models are replaced. diff --git a/docs/freedmr-2/adr/0002-keep-twisted-initially.md b/docs/freedmr-2/adr/0002-keep-twisted-initially.md deleted file mode 100644 index 4578c5f..0000000 --- a/docs/freedmr-2/adr/0002-keep-twisted-initially.md +++ /dev/null @@ -1,22 +0,0 @@ -# ADR 0002: Keep Twisted Initially - -## Status -Proposed - -## Context -Twisted currently provides UDP transport, timers, and a single-threaded reactor boundary. Packet behaviour is subtle and production-proven. - -## Decision -Keep Twisted initially as a transport safety boundary; consider replacement only after it is a thin shell. - -## Rationale -Replacing the event loop and state model at the same time creates avoidable risk. Extracting the routing/subscription core first gives deterministic test coverage and clearer future migration options. - -## Consequences -FreeDMR 2 starts with evolutionary architecture work, not an event-loop rewrite. Twisted callbacks must remain non-blocking. - -## Compatibility -Current deployment and transport behaviour can remain familiar while the core model is extracted. - -## Testing Requirements -Deterministic core tests and UDP black-box tests must cover behaviour before any later Twisted replacement is considered. diff --git a/docs/freedmr-2/adr/0003-subscription-model-replaces-bridges.md b/docs/freedmr-2/adr/0003-subscription-model-replaces-bridges.md deleted file mode 100644 index cfd997e..0000000 --- a/docs/freedmr-2/adr/0003-subscription-model-replaces-bridges.md +++ /dev/null @@ -1,22 +0,0 @@ -# ADR 0003: Subscription Model Replaces BRIDGES - -## Status -Proposed - -## Context -The legacy `BRIDGES` dict mixes configuration, runtime state, reflector naming, routing, and export concerns. FreeDMR 2 models each TG as a conference bridge to which clients subscribe. - -## Decision -Use subscription-oriented internal state instead of legacy `BRIDGES` as the authoritative FreeDMR 2 model. - -## Rationale -Subscription state directly represents the FreeDMR user model: clients subscribe to TGs they want to hear. It supports direct TGs, dial-a-TG, default reflectors, API control, and future aliases without hard-coding routing modes. - -## Consequences -The packet path can use indexed subscription lookups. Existing dashboard/config expectations need compatibility export or migration. - -## Compatibility -`BRIDGES` and `#` reflector names may be generated as compatibility/export state where required, but routing should not depend on them. - -## Testing Requirements -Tests must assert subscription activation, expiry, direct TG routing, dial-a-TG mapping, default reflector behaviour, and absence of unintended recipients. diff --git a/docs/freedmr-2/adr/0004-reporting-v2-replaces-legacy-dashboard-protocol.md b/docs/freedmr-2/adr/0004-reporting-v2-replaces-legacy-dashboard-protocol.md deleted file mode 100644 index eef6c20..0000000 --- a/docs/freedmr-2/adr/0004-reporting-v2-replaces-legacy-dashboard-protocol.md +++ /dev/null @@ -1,22 +0,0 @@ -# ADR 0004: Reporting v2 Replaces Legacy Dashboard Protocol - -## Status -Proposed - -## Context -The old dashboard/report socket has shaped parts of the current implementation and has caused operational friction. FreeDMR 1.x remains live while FreeDMR 2 is developed. - -## Decision -FreeDMR 2 reporting is a new structured event contract. The old dashboard/report socket is not a compatibility constraint inside the core. - -## Rationale -Reporting must be observational only. A clean event schema avoids leaking legacy `BRIDGES`/`SYSTEM` state into the new packet core. - -## Consequences -The dashboard must be updated or served by an adapter. The core can emit stable v2 events without preserving legacy report names. - -## Compatibility -Old dashboard support belongs in a sidecar or adapter, not in packet routing. - -## Testing Requirements -Tests must assert expected v2 events for server, client, subscription, call, mesh, loop, and reporting-health changes. diff --git a/docs/freedmr-2/adr/0005-mqtt-reporting-transport.md b/docs/freedmr-2/adr/0005-mqtt-reporting-transport.md deleted file mode 100644 index 827d1e1..0000000 --- a/docs/freedmr-2/adr/0005-mqtt-reporting-transport.md +++ /dev/null @@ -1,22 +0,0 @@ -# ADR 0005: MQTT Reporting Transport - -## Status -Proposed - -## Context -FreeDMR needs live local dashboard state and non-real-time global lastheard feeds without blocking packet handling. - -## Decision -MQTT is the preferred external live reporting transport, fed through a non-blocking bounded queue and independent publisher. - -## Rationale -MQTT is lightweight, familiar in radio/network operations, supports topics, retained state, last-will messages, and network fanout. A local broker lets extra consumers attach without adding work to the packet process. - -## Consequences -FreeDMR gains a broker dependency or optional integration. Reporting completeness is best-effort under pressure. - -## Compatibility -Legacy dashboard consumers need an adapter or dashboard update. Packet routing must continue if MQTT or consumers are unavailable. - -## Testing Requirements -Tests must cover enqueue, overflow/drop policy, publisher disconnect/reconnect events, retained state refresh, and packet-path non-blocking behaviour. diff --git a/docs/freedmr-2/adr/0006-local-dashboard-and-global-lastherd-are-separate.md b/docs/freedmr-2/adr/0006-local-dashboard-and-global-lastherd-are-separate.md deleted file mode 100644 index 95998cb..0000000 --- a/docs/freedmr-2/adr/0006-local-dashboard-and-global-lastherd-are-separate.md +++ /dev/null @@ -1,22 +0,0 @@ -# ADR 0006: Local Dashboard and Global Lastheard Are Separate - -## Status -Proposed - -## Context -Each server has its own live dashboard. The global lastheard service is centrally hosted and non-real-time. - -## Decision -Local dashboard consumes local live feed; global lastheard consumes curated summaries via exporter/collector. - -## Rationale -Local live visibility must survive central outages. Global aggregation should not add packet-process load or require the core to do database/export work. - -## Consequences -A separate exporter process may be needed for global feeds. The broker handles fanout. - -## Compatibility -Existing global lastheard behaviour should be migrated to consume summaries rather than packet-plane events. - -## Testing Requirements -Tests should confirm local reporting works without global exporter connectivity and that exporter failure does not affect packet handling. diff --git a/docs/freedmr-2/adr/0007-synthetic-lc-service-options.md b/docs/freedmr-2/adr/0007-synthetic-lc-service-options.md deleted file mode 100644 index 12a3690..0000000 --- a/docs/freedmr-2/adr/0007-synthetic-lc-service-options.md +++ /dev/null @@ -1,22 +0,0 @@ -# ADR 0007: Synthetic LC Service Options - -## Status -Proposed - -## Context -Legacy HBLink used `0x20` in synthetic LC service options. Later MMDVMHost evidence indicates `0x20` was an early OVCM bit-position mistake; standards-clean OVCM is `0x04`. - -## Decision -Synthetic group voice LC uses normal service options `0x00` by default. OVCM is `0x04` if explicitly selected. HBLink `0x20` is legacy compatibility only. Real inbound LC is preserved. - -## Rationale -FreeDMR should not set reserved service-option bits in newly generated LC. Real inbound LC should not be rewritten without a deliberate reason. - -## Consequences -Generated fallback LC becomes cleaner. Some legacy interop assumptions may need live RF testing. - -## Compatibility -`0x20` may remain as a named legacy compatibility option, not as the default or as a traffic marker. - -## Testing Requirements -Codec/unit tests must assert synthetic LC defaults to `0x00`, OVCM uses `0x04`, real inbound LC is preserved, and `0x20` is only used when explicitly configured for compatibility. diff --git a/docs/freedmr-2/adr/0008-data-packet-forwarding-policy.md b/docs/freedmr-2/adr/0008-data-packet-forwarding-policy.md deleted file mode 100644 index 0fecded..0000000 --- a/docs/freedmr-2/adr/0008-data-packet-forwarding-policy.md +++ /dev/null @@ -1,22 +0,0 @@ -# ADR 0008: Data Packet Forwarding Policy - -## Status -Proposed - -## Context -FreeDMR currently supports DMR data forwarding, including data gateway concepts and last-known-location routing. GPS/SMS application processing is better handled outside the core. - -## Decision -FreeDMR continues forwarding DMR data packets but does not become a general GPS/SMS application processor. - -## Rationale -Data support is part of network interoperability. Application processing would add complexity and CPU cost to the packet process and is better done by FBP-connected systems or sidecars. - -## Consequences -The core must preserve data packet routing semantics while keeping application parsing out of the hot path. - -## Compatibility -Existing `SUB_MAP` / last-known-location behaviour remains intentional. `DATA-GATEWAY` remains a supported concept where useful. - -## Testing Requirements -Tests must cover group-addressed data, unit-addressed data, last-known-location routing, data-vs-voice reporting, and preservation of data forwarding over FBP. diff --git a/docs/freedmr-2/adr/0009-mesh-authentication-without-default-encryption.md b/docs/freedmr-2/adr/0009-mesh-authentication-without-default-encryption.md deleted file mode 100644 index 5a83a84..0000000 --- a/docs/freedmr-2/adr/0009-mesh-authentication-without-default-encryption.md +++ /dev/null @@ -1,22 +0,0 @@ -# ADR 0009: Mesh Authentication Without Default Encryption - -## Status -Proposed - -## Context -FreeDMR is an amateur-radio network. In many jurisdictions amateur-radio traffic must not be encrypted, and IP backhaul may itself use amateur-radio links. - -## Decision -Use authenticity, integrity, membership validation, and local policy; do not encrypt amateur-radio mesh traffic by default. - -## Rationale -Signing and authentication protect the mesh from impersonation and unauthorized traffic while preserving FreeDMR's open, inspectable, amateur-radio character. - -## Consequences -Traffic remains visible. Security focuses on who is allowed to inject or carry traffic, not secrecy. - -## Compatibility -Existing cleartext FBP/OBP interop remains possible. New authenticated admission can be introduced through bridge-control mechanisms and cached session state. - -## Testing Requirements -Tests must cover valid identity, invalid signature, revocation, endpoint change requiring re-authentication, grace expiry, and local policy overriding signed membership. diff --git a/docs/freedmr-2/adr/0010-api-is-control-plane-only.md b/docs/freedmr-2/adr/0010-api-is-control-plane-only.md deleted file mode 100644 index 1aa233d..0000000 --- a/docs/freedmr-2/adr/0010-api-is-control-plane-only.md +++ /dev/null @@ -1,22 +0,0 @@ -# ADR 0010: API Is Control Plane Only - -## Status -Proposed - -## Context -The API is useful for local administration, automation, and dashboard control. FreeDMR is a live voice stream program, so API work must not delay packet processing. - -## Decision -API operations are bounded control-plane actions and must not perform heavy serialization or block packet routing. - -## Rationale -Control operations should act on sessions, subscriptions, mesh peers, and reporting state. Heavy views should come from snapshots or reporting feeds. - -## Consequences -The API remains small and predictable. Complex dashboard state should not be assembled synchronously from hot packet state. - -## Compatibility -Old API endpoints may be adapted if needed, but the v2 API should not expose raw legacy internals as its primary contract. - -## Testing Requirements -Tests must cover auth, bounded request bodies, destructive-action gating, audit logging, and packet-path independence under API load. diff --git a/docs/freedmr-2/adr/0011-process-actor-model-over-no-gil-threading.md b/docs/freedmr-2/adr/0011-process-actor-model-over-no-gil-threading.md deleted file mode 100644 index 1af7e6f..0000000 --- a/docs/freedmr-2/adr/0011-process-actor-model-over-no-gil-threading.md +++ /dev/null @@ -1,22 +0,0 @@ -# ADR 0011: Process/Actor Model Over No-GIL Threading - -## Status -Proposed - -## Context -FreeDMR may need more concurrency for reporting, export, analysis, or future scaling. Shared mutable routing state is risky. - -## Decision -If FreeDMR 2 needs concurrency beyond the reactor, prefer explicit parent/child or actor-style ownership boundaries over shared-memory no-GIL threading for routing state. - -## Rationale -Single-owner state and explicit messages are easier for sysops and contributors to reason about. They reduce race risks in delay-sensitive packet handling. - -## Consequences -Some features may require serialization and message protocols between processes. This is clearer than shared locks around routing dictionaries. - -## Compatibility -Twisted can remain the initial transport shell while workers handle reporting/export or expensive tasks. - -## Testing Requirements -Tests must cover worker failure, queue overflow, restart behaviour, message ordering where required, and packet handling continuing when a non-critical worker fails. diff --git a/docs/freedmr-2/adr/0012-testing-gates-for-protocol-visible-change.md b/docs/freedmr-2/adr/0012-testing-gates-for-protocol-visible-change.md deleted file mode 100644 index 30dac2f..0000000 --- a/docs/freedmr-2/adr/0012-testing-gates-for-protocol-visible-change.md +++ /dev/null @@ -1,22 +0,0 @@ -# ADR 0012: Testing Gates for Protocol-Visible Change - -## Status -Proposed - -## Context -FreeDMR behaviour was validated through real global servers, RF links, and community use. Protocol-visible changes can affect repeaters, terminals, dashboards, and peer servers. - -## Decision -Protocol-visible changes require deterministic, UDP, and live RF validation according to release gates. - -## Rationale -Deterministic tests catch state and rewrite errors. UDP black-box tests catch transport/subprocess/protocol integration issues. Live RF catches terminal/repeater quirks that harnesses cannot prove. - -## Consequences -Some changes take longer to release. The risk of breaking real deployments is reduced. - -## Compatibility -FreeDMR 1.x remains live while FreeDMR 2 behaviour is validated. Changes can be staged behind compatibility adapters. - -## Testing Requirements -Level 0 unit/support tests, Level 1 deterministic harness, Level 2 UDP black-box harness, and Level 3 live RF validation are required according to the risk and protocol visibility of the change. diff --git a/docs/freedmr-2/adr/0013-worker-process-capacity-scaling.md b/docs/freedmr-2/adr/0013-worker-process-capacity-scaling.md deleted file mode 100644 index 1cb17c5..0000000 --- a/docs/freedmr-2/adr/0013-worker-process-capacity-scaling.md +++ /dev/null @@ -1,87 +0,0 @@ -# ADR 0013: Worker Process Capacity Scaling - -## Status -Proposed - -## Context - -FreeDMR currently relies heavily on a single Python process, Twisted reactor callbacks, and in-memory mutable state inherited from the HBLink-era architecture. - -This is useful as an initial safety boundary because it avoids many shared-memory races, but it also creates practical capacity limits and makes some kinds of expensive work unsafe in the packet path. - -CPython's GIL and single-reactor execution mean that CPU-bound work, reporting fanout, SQL/global export, analytics, and future codec/transcoding work should not be assumed to scale inside one process. - -At the same time, FreeDMR's packet/routing state is subtle and protocol-sensitive. Moving it prematurely across process boundaries could introduce latency, ordering bugs, stale packet replay, duplicate packets, routing loops, broken source quench, or incorrect packet mutation. - -## Decision - -FreeDMR 2 will be designed for eventual worker-process scaling, but the first migration stage will keep Twisted as the transport shell and extract/test the routing/subscription core in-process. - -The first worker-process targets are non-packet-path or low-risk side effects: - -- Reporting/MQTT publisher. -- Global lastheard exporter. -- SQL/database writes. -- Dashboard aggregation. -- Alias refresh. -- Analytics. -- Packet replay/diagnostics. -- Future codec/transcoding adjuncts. - -Packet-plane routing may move behind a process/message boundary later, but only after state ownership, subscription lookup, stream lifecycle, loop control, source quench, and packet mutation semantics are covered by deterministic, UDP, and live RF tests. - -FreeDMR 2 prefers explicit process/actor ownership boundaries over shared-memory threading or no-GIL Python for authoritative routing state. - -## Rationale - -- Worker processes provide clearer ownership and failure boundaries. -- Explicit messages are easier to test than shared mutable dictionaries. -- Reporting/export failures must not affect packet routing. -- FreeDMR should be able to scale beyond one reactor process without making ordinary small deployments complex. -- No-GIL Python does not remove the need for state ownership discipline. -- A process model better matches FreeDMR's distributed-system nature: packet events, routing decisions, control messages, and reporting events are already conceptually separate. - -## Consequences - -Positive: - -- Clearer state ownership. -- Improved future capacity. -- Safer isolation of reporting/export/database work. -- Better failure containment. -- Better testability of message boundaries. -- Easier future sharding by client, TG, stream, listener, or mesh peer. - -Negative: - -- More implementation complexity. -- Message schemas must be designed and versioned. -- IPC adds latency and failure modes. -- Routing-worker split requires strong tests. -- Worker supervision and restart policy become part of the system design. - -## Compatibility - -FreeDMR 1.x/current code remains live until FreeDMR 2 is ready. - -Initial FreeDMR 2 worker work should not change packet semantics. - -Legacy dashboard/reporting compatibility, if required, belongs in reporting/export adapters, not in the packet core. - -A single-process deployment must remain supported for small servers. - -## Testing Requirements - -Before any packet-plane worker split: - -- Deterministic harness coverage of the state machine. -- Message-boundary equivalence tests. -- UDP black-box equivalence tests. -- Packet byte preservation tests. -- Allowed rewrite-region tests. -- Source quench/STUN/loop-control tests. -- Duplicate/out-of-order tests. -- Worker crash/restart tests. -- Stale packet replay prevention tests. -- Queue backpressure tests. -- Live RF validation for protocol-visible behaviour. diff --git a/docs/test-harness-design.md b/docs/test-harness-design.md index 6150d87..71eb6d0 100644 --- a/docs/test-harness-design.md +++ b/docs/test-harness-design.md @@ -49,13 +49,16 @@ The harness code is split as follows: - Packet builder smoke coverage. - In-process HBP static TG routing smoke coverage, skipped when runtime dependencies needed to import `bridge_master.py` are unavailable. - - Dial-a-TG TS1 private-call control and status reporting of TS2 reflector - state, including reserved target no-op behavior. + - Dial-a-TG TS1 and TS2 private-call control, independent per-slot reflector + state, reserved target no-op behavior, same-slot prompt generation, and + slot/route-specific timer selection. - `tests/test_udp_blackbox_harness.py` - Opt-in subprocess UDP coverage for two registered repeaters and static TG 91 routing. - Opt-in dial-a-TG prompt coverage for a reserved control private call, - asserting local TG9 TS2 announcement packets and no inter-master UDP leak. + asserting local TG9 same-slot announcement packets and no inter-master UDP + leak. + - Opt-in startup coverage for slot/route-specific timer directives. - Opt-in FBP v5 coverage for HBP-to-FBP and FBP-to-HBP static TG routing, source-quench suppression, and network-ID rejection. @@ -110,14 +113,14 @@ for rule timeout checks without sleeping. - Dial-a-TG state transition bugs: reflector bridge creation, activation, deactivation, timer reset and single-mode behaviour. - Dial-a-TG system-scope bugs: control calls on one master should only mutate - that receiving master's TS2 reflector state, not another master's entry in the - same bridge. -- Dial-a-TG control-slot bugs: private calls from TS1 or TS2 should control the - TS2 reflector state so TS1 can disconnect or retune TS2 when TS2 RF is busy. - Status query `5000` follows the same rule and reports TS2 reflector state from - either RF slot. + that receiving master's reflector state for the controlling slot, not another + master's entry in the same bridge. +- Dial-a-TG control-slot bugs: private calls from TS1 should control TS1 + reflector state and private calls from TS2 should control TS2 reflector state. + Status query `5000` follows the same rule and reports the reflector state for + the controlling slot. - Dial-a-TG status-report bugs: private call `5000` should report one active - TS2 reflector for the receiving master. It does not repair inconsistent + reflector for the receiving master and controlling slot. It does not repair inconsistent multi-active reflector state. - Dial-a-TG reserved-target bugs: private calls to local/control targets such as `5`, `6`, `7`, and `9`, and reserved control-range targets `4001..4999`, @@ -153,6 +156,9 @@ for rule timeout checks without sleeping. Empty `DIAL` / `DEFAULT_REFLECTOR` is equivalent to `0` and means no TS2 default reflector. Invalid `TIMER` values should be logged and should not block valid static TG changes, which should use the current effective timer. + Specific timers can be configured independently by slot and route type: + exact slot/route timer first, then slot timer, then route timer, then legacy + `TIMER`. - Voice ident override bugs: `OVERRIDE_IDENT_TG` should be parsed before packet generation. Valid positive TGs below all-call are used as the destination; empty or false override values use all-call; malformed, out-of-range, or @@ -363,7 +369,7 @@ Implemented: - Black-box multi-stream trunk coverage for HBP-to-FBP output: one stream is reordered and drops its late packet while a second clean stream on another TG still traverses the same FBP peer. -- Black-box generated-prompt interruption coverage: a local TG9 TS2 generated +- Black-box generated-prompt interruption coverage: a local same-slot TG9 generated prompt is observed, then real HBP voice is injected and must route to another master rather than being blocked by the prompt. - Black-box hostile/negative packet coverage: malformed short HBP `DMRD`, @@ -612,7 +618,8 @@ Assertions should be grouped by intent: do not block valid default-dial/static fields, boolean-like options reject values other than `0` or `1`, empty `DIAL` disables TS2 default reflector state, and invalid `TIMER` values are logged without blocking valid static TG - changes. + changes. They also verify independent conventional TG and dial-a-TG timers by + slot, timer precedence, and ignored invalid specific timer fields. - Deterministic voice-ident tests verify override destination selection for valid string TGs, empty/false overrides, malformed values, control TGs, and all-call. @@ -624,7 +631,8 @@ Assertions should be grouped by intent: - UDP black-box HBP repeaters register with FreeDMR and observe static TG 91 routing over real UDP. - UDP black-box dial-a-TG tests verify that a reserved control private call - emits a local TG9 TS2 prompt without sending traffic to another master. + emits a local TG9 prompt on the controlling slot without sending traffic to + another master. - UDP black-box FBP bridge-control tests verify that enhanced targets require BCKA before HBP-to-FBP forwarding, invalid BCSQ does not suppress a stream, and valid BCST STUN blocks OpenBridge traffic in both directions. diff --git a/docs/testing.md b/docs/testing.md index 69e86f5..a320b80 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -51,6 +51,7 @@ used by `bridge_master.py` and `bridge.py` now use compatibility functions in The deterministic suite includes dial-a-TG coverage. It verifies that private calls from TS1 create, retune, disconnect and query TS1 reflector state, while private calls from TS2 control TS2 reflector state. TS1 no longer controls TS2. +It also verifies generated dial-a-TG prompts use TG9 on the controlling slot. Default dial-a-TG startup and OPTIONS handling is covered through canonical per-slot `DEFAULT_DIAL_TS1` and `DEFAULT_DIAL_TS2`; deprecated `DEFAULT_REFLECTOR`, `DIAL` and `StartRef` remain TS2 compatibility aliases. @@ -83,7 +84,11 @@ tokens in the same TS1 or TS2 list still apply. Client options parsing covers malformed independent numeric fields so valid default-dial/static fields still apply, `VOICE` and `SINGLE` accept only `0`/`1`, and empty `DIAL` disables the default TS2 reflector. Invalid `TIMER` values are logged and static TG changes continue -using the current effective timer. Voice ident override coverage verifies valid +using the current effective timer. Timer coverage verifies independent +conventional TG and dial-a-TG timers by slot, with precedence from exact +slot/route timer, then slot timer, then route timer, then legacy `TIMER`. +Invalid specific timer fields are logged and ignored so valid broader timer +fields still apply. Voice ident override coverage verifies valid override TGs are used, empty/false overrides use all-call, and malformed, control, or all-call override values are logged and fall back to all-call. Generated voice prompt helper coverage verifies prompt stream state is attached @@ -204,9 +209,10 @@ payload preservation, in-call Talker Alias/GPS log observability, modulo-256 voice sequence wrap, sequence `0` duplicate suppression, voice terminator suppression of late same-stream packets, recorded HBP fixture replay, and a dial-a-TG reserved -control private call that emits a local TG9 TS2 prompt without leaking traffic -to another master. They now also cover FBP v5 static TG routing in both -directions, FBP keepalive/version control setup, BCKA gating of enhanced +control private call that emits a local TG9 prompt on the controlling slot +without leaking traffic to another master. They now also cover FBP v5 static TG +routing in both directions, startup acceptance of slot/route-specific timer +directives, FBP keepalive/version control setup, BCKA gating of enhanced HBP-to-FBP forwarding, BCSQ source-quench suppression of HBP-to-FBP forwarding, rejection of invalid BCSQ, BCVE downgrade/unsupported/invalid-version handling, current FBP v5 packet handling, historical FBP v4 characterization, and signed @@ -226,7 +232,7 @@ helpers provide named stream and link patterns for more real-world scenarios. Current coverage also includes a multi-stream HBP-to-FBP trunk case where one stream is reordered while another clean stream on the same FBP trunk still routes, plus a generated prompt interruption case where real HBP voice routes -after a local TG9 TS2 prompt has started. Negative-path coverage includes +after a local same-slot TG9 prompt has started. Negative-path coverage includes malformed short HBP `DMRD`, malformed short FBP `DMRE`, bad FBP BLAKE2b hashes, stale FBP timestamps and max-hop FBP packets; these must not leak traffic to HBP targets, and stale/max-hop FBP packets must produce a source-quench response. diff --git a/docs/v1x-codex-changelog.md b/docs/v1x-codex-changelog.md index 2995ed5..221eb37 100644 --- a/docs/v1x-codex-changelog.md +++ b/docs/v1x-codex-changelog.md @@ -29,8 +29,12 @@ - Made dial-a-TG private-call control slot-local: TS1 controls TS1, TS2 controls TS2. TS1 no longer retunes or disconnects TS2. -- Preserved voice prompts on TG9 TS2. +- Changed dial-a-TG voice prompts to follow the controlling slot: TS1 control + emits TG9 on TS1, and TS2 control emits TG9 on TS2. - Preserved TG9 as the RF-visible dial-a-TG talkgroup for both slots. +- Added independent timeout controls for conventional TG and dial-a-TG routes, + including per-slot overrides. Timer precedence is exact slot/route timer, + then slot timer, then route timer, then legacy `TIMER`. - Rejected reserved/control targets consistently for live dial-a-TG and default startup/session configuration. - Preserved the current FreeDMR dial-a-TG policy cap of `999999`. @@ -83,7 +87,9 @@ - Kept bridge event CSV field order unchanged. - Kept `DEFAULT_REFLECTOR` in runtime config as the effective TS2 default for compatibility with existing config/API/report consumers. -- Kept prompt/ident generated audio visible as TG9 TS2. +- Dial-a-TG generated audio is now visible as TG9 on the controlling RF slot. + Other generated audio such as idents and disconnected announcements remains + on TG9 TS2. - The latest per-slot default-dial changes do not introduce new report event names or new report event fields. - Expected dashboard impact is low if the dashboard reads event fields and @@ -123,15 +129,15 @@ ## Documentation - Added and updated harness, testing, API and architecture documentation. -- Added FreeDMR 2 design/ADR documents separately, without changing current - 1.x runtime behavior. -- Maintained `docs/codex-notes.md` as the engineering notebook for findings, - assumptions, protocol-sensitive areas, invariants and unresolved questions. +- Added a concise user-facing OPTIONS cheat sheet suitable for wiki use. +- Removed FreeDMR 2 design/ADR artifacts from the FreeDMR1 repository. FreeDMR2 + design work now belongs in the separate FreeDMR2 repository only. ## Validation - Current non-UDP test discovery passes. -- Focused UDP black-box tests for TS1 dial-a-TG and disabled dynamic TG routing - pass when local UDP sockets are permitted. +- Focused UDP black-box tests for TS1 dial-a-TG same-slot prompts, generated + prompt interruption and slot/route-specific timer config pass when local UDP + sockets are permitted. - Live RF validation is still required before treating protocol-visible behavior changes as release-ready. diff --git a/tests/harness/deterministic.py b/tests/harness/deterministic.py index 953f4bd..3aa1bea 100644 --- a/tests/harness/deterministic.py +++ b/tests/harness/deterministic.py @@ -301,7 +301,16 @@ def minimal_config(system_names: tuple[str, ...] = ("MASTER-A", "MASTER-B")) -> "SUB_ACL": acl_permit_all(), "TG1_ACL": acl_permit_all(), "TG2_ACL": acl_permit_all(), + "_TIMER_OPTIONS_SET": set(), "DEFAULT_UA_TIMER": 1, + "TG_TIMER": 1, + "DIAL_TIMER": 1, + "TS1_TIMER": 1, + "TS2_TIMER": 1, + "TS1_TG_TIMER": 1, + "TS2_TG_TIMER": 1, + "TS1_DIAL_TIMER": 1, + "TS2_DIAL_TIMER": 1, "SINGLE_MODE": True, "VOICE_IDENT": False, "DIAL_A_TG": True, diff --git a/tests/harness/udp_blackbox.py b/tests/harness/udp_blackbox.py index 7c74b12..a5dd9a3 100644 --- a/tests/harness/udp_blackbox.py +++ b/tests/harness/udp_blackbox.py @@ -469,6 +469,7 @@ def write_bridge_master_config( ts2_static: str = "91", dial_a_tg: bool = True, dynamic_tg_routing: bool = True, + master_extra_config: str = "", ) -> None: global_use_acl_text = "True" if global_use_acl else "False" dial_a_tg_text = "True" if dial_a_tg else "False" @@ -506,6 +507,7 @@ GENERATOR: 0 ALLOW_UNREG_ID: True PROXY_CONTROL: False OVERRIDE_IDENT_TG: +{master_extra_config} """ ) @@ -972,6 +974,7 @@ class UdpBlackBoxScenario: ts2_static: str = "91", dial_a_tg: bool = True, dynamic_tg_routing: bool = True, + master_extra_config: str = "", fbp_systems: dict[str, int] | None = None, fbp_proto_versions: dict[str, int] | None = None, ) -> None: @@ -994,6 +997,7 @@ class UdpBlackBoxScenario: "ts2_static": ts2_static, "dial_a_tg": dial_a_tg, "dynamic_tg_routing": dynamic_tg_routing, + "master_extra_config": master_extra_config, } def __enter__(self): diff --git a/tests/test_deterministic_harness.py b/tests/test_deterministic_harness.py index aa84a61..abb61f8 100644 --- a/tests/test_deterministic_harness.py +++ b/tests/test_deterministic_harness.py @@ -20,6 +20,7 @@ from tests.harness.deterministic import ( bytes_4, minimal_config, ) +from tests.harness.udp_blackbox import fbp_control_packet, fbp_packet_from_spec def embedded_lc_payloads(lc): @@ -562,6 +563,89 @@ DEFAULT_DIAL_TS2: 92 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_options_timers_can_be_separate_by_slot_and_route_type(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = ( + "TIMER=10;TG_TIMER=20;DIAL_TIMER=30;TS1_TIMER=40;" + "TS1_TG_TIMER=5;TS2_DIAL_TIMER=6;" + "TS1=91;TS2=92;DEFAULT_DIAL_TS1=235;DEFAULT_DIAL_TS2=236" + ) + + with DeterministicScenario(config=config) as scenario: + scenario.bm.options_config() + + ts1_tg = next( + entry for entry in scenario.bridge_state["91"] + if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 1 + ) + ts2_tg = next( + entry for entry in scenario.bridge_state["92"] + if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 2 + ) + ts1_dial = next( + entry for entry in scenario.bridge_state["#235"] + if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 1 + ) + ts2_dial = next( + entry for entry in scenario.bridge_state["#236"] + if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 2 + ) + + self.assertEqual(ts1_tg["TIMEOUT"], 5 * 60) + self.assertEqual(ts2_tg["TIMEOUT"], 20 * 60) + self.assertEqual(ts1_dial["TIMEOUT"], 40 * 60) + self.assertEqual(ts2_dial["TIMEOUT"], 6 * 60) + + def test_options_invalid_specific_timer_falls_back_without_blocking_valid_fields(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = ( + "TIMER=10;TG_TIMER=20;TS1_TG_TIMER=A;TS1=91;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 + ) + + self.assertTrue(ts1_entry["ACTIVE"]) + self.assertEqual(ts1_entry["TIMEOUT"], 20 * 60) + self.assertIn("TS1_TG_TIMER is not an integer", "\n".join(logs.output)) + + def test_options_timer_update_preserves_other_system_bridge_entries(self): + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TIMER=2;DIAL=0" + bridges = active_bridge( + "91", + 91, + ( + ("MASTER-A", 2), + ("MASTER-B", 2), + ), + ) + + with DeterministicScenario(config=config, bridges=bridges) as scenario: + original_master_b = next( + dict(entry) for entry in scenario.bridge_state["91"] + if entry["SYSTEM"] == "MASTER-B" and entry["TS"] == 2 + ) + + scenario.bm.options_config() + + entries = scenario.bridge_state["91"] + master_a = next( + entry for entry in entries if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 2 + ) + master_b = next( + entry for entry in entries if entry["SYSTEM"] == "MASTER-B" and entry["TS"] == 2 + ) + + self.assertEqual(master_a["TIMEOUT"], 2 * 60) + self.assertEqual(master_b, original_master_b) + def test_ident_override_accepts_valid_string_tg(self): destinations = self.run_ident_with_override("91") @@ -759,6 +843,27 @@ DEFAULT_DIAL_TS2: 92 self.assertNotIn(b"linkedto", spoken[-1]) self.assertEqual(scenario.capture.packets, []) + def test_dial_a_tg_prompt_generation_uses_controlling_slot(self): + for rf_slot, generated_slot in ((1, 0), (2, 1)): + with self.subTest(rf_slot=rf_slot): + with DeterministicScenario() as scenario: + generated_slots = [] + scenario.bm.words["en_GB"].update( + { + "busy": b"busy", + "silence": b"silence", + } + ) + scenario.bm.pkt_gen = ( + lambda source_id, dst_id, peer_id, slot, say: + generated_slots.append(slot) or iter(()) + ) + + scenario.inject_hbp("MASTER-A", self.private_call(4001, slot=rf_slot)) + scenario.inject_hbp("MASTER-A", self.private_call_terminator(4001, slot=rf_slot)) + + self.assertEqual(generated_slots, [generated_slot]) + def test_dial_a_tg_disconnect_on_slot_1_does_not_deactivate_ts2_reflector(self): bridges = { "#235": [ @@ -1887,6 +1992,40 @@ DEFAULT_DIAL_TS2: 92 self.assertIn("v5 packet too short", "\n".join(logs.output)) self.assertEqual(scenario.capture.for_system("OBP-1"), []) + def test_obp_corrupt_unsupported_embedded_version_does_not_poison_session_version(self): + config = minimal_config(()) + add_openbridge_system(config, "OBP-1", network_id=3001) + + with DeterministicScenario(config=config) as scenario: + packet = fbp_packet_from_spec( + PacketSpec( + peer_id=3001, + rf_src=3120001, + dst_id=91, + slot=1, + stream_id=0x01020344, + ), + network_id=3001, + fbp_version=6, + source_server=9991, + source_rptr=1001, + corrupt_hash=True, + passphrase=config["SYSTEMS"]["OBP-1"]["PASSPHRASE"], + ) + + scenario.inject_datagram("OBP-1", packet) + scenario.inject_datagram( + "OBP-1", + fbp_control_packet( + b"BCVE", + b"\x05", + passphrase=config["SYSTEMS"]["OBP-1"]["PASSPHRASE"], + ), + ) + + self.assertEqual(config["SYSTEMS"]["OBP-1"]["VER"], 5) + 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",)) diff --git a/tests/test_udp_blackbox_harness.py b/tests/test_udp_blackbox_harness.py index c7ca613..4759612 100644 --- a/tests/test_udp_blackbox_harness.py +++ b/tests/test_udp_blackbox_harness.py @@ -79,6 +79,36 @@ class UdpBlackBoxHarnessTest(unittest.TestCase): self.assertEqual(captured.fields["slot"], 2) self.assertEqual(captured.fields["stream_id"], bytes_4(0x01020304)) + def test_startup_accepts_slot_and_route_specific_timer_config(self): + require_udp_integration_enabled() + + with UdpBlackBoxScenario( + ts1_static="91", + ts2_static="92", + master_extra_config=( + "TG_TIMER: 2\n" + "DIAL_TIMER: 3\n" + "TS1_TIMER: 4\n" + "TS2_TIMER: 5\n" + "TS1_TG_TIMER: 6\n" + "TS2_DIAL_TIMER: 7\n" + ), + ) 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=1)) + captured = master_b.recv(timeout=2.0) + finally: + master_a.close() + master_b.close() + + self.assertEqual(captured.fields["dst_id"], bytes_3(91)) + self.assertEqual(captured.fields["slot"], 1) + def test_global_use_acl_false_does_not_apply_deny_acl(self): require_udp_integration_enabled() @@ -526,7 +556,7 @@ class UdpBlackBoxHarnessTest(unittest.TestCase): ) self.assertEqual(leaked, []) - def test_dial_a_tg_reserved_control_emits_local_tg9_ts2_prompt(self): + def test_dial_a_tg_reserved_control_emits_local_tg9_same_slot_prompt(self): require_udp_integration_enabled() with UdpBlackBoxScenario() as scenario: @@ -548,7 +578,7 @@ class UdpBlackBoxHarnessTest(unittest.TestCase): 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(captured.fields["slot"], 1) self.assertEqual(leaked, []) def test_dial_a_tg_slot_1_routes_local_tg9_to_fbp_reflector_tg(self): @@ -640,7 +670,7 @@ class UdpBlackBoxHarnessTest(unittest.TestCase): 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(prompt.fields["slot"], 1) 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)) @@ -833,7 +863,6 @@ class UdpBlackBoxHarnessTest(unittest.TestCase): 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() @@ -863,6 +892,49 @@ class UdpBlackBoxHarnessTest(unittest.TestCase): finally: master_b.close() + def test_fbp_unsupported_embedded_packet_version_does_not_poison_bcve_session_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() + fbp_peer.drain(seconds=0.2) + + fbp_peer.send_fbp( + PacketSpec( + peer_id=3001, + rf_src=3120001, + dst_id=91, + slot=1, + stream_id=0x01020345, + ), + fbp_version=6, + source_server=9991, + source_rptr=1001, + ) + fbp_peer.send_bcve(version=5) + fbp_peer.drain(seconds=0.2) + + master_a.send_dmr( + PacketSpec( + peer_id=1001, + rf_src=3120001, + dst_id=91, + slot=2, + stream_id=0x01020346, + ) + ) + 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_downgrades_session_to_v4_layout_for_compatibility(self): require_udp_integration_enabled()