Updates to timers, freedmr2 removal

master
Simon 1 month ago
parent af0e1faf4d
commit 865d5a314f

@ -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:
@ -1330,6 +1381,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

@ -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),

File diff suppressed because it is too large Load Diff

@ -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?

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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.

@ -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,

@ -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):

@ -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",))

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

Loading…
Cancel
Save

Powered by TurnKey Linux.