Add network direct dial support

master
Simon 3 weeks ago
parent 3027bb885e
commit 2fa05ae0a3

@ -61,6 +61,7 @@ SINGLE_MODE: True
VOICE_IDENT: True VOICE_IDENT: True
DIAL_A_TG: True DIAL_A_TG: True
DYNAMIC_TG_ROUTING: True DYNAMIC_TG_ROUTING: True
NETWORK_DIRECT_DIAL_SLOT: 0
TS1_STATIC: TS1_STATIC:
TS2_STATIC: TS2_STATIC:
DEFAULT_DIAL_TS1: 0 DEFAULT_DIAL_TS1: 0

@ -104,6 +104,7 @@ DIAL_A_TG_PROHIBITED_DEFAULTS = [
] ]
DIAL_A_TG_MAX = 999999 DIAL_A_TG_MAX = 999999
UA_TIMER_UNLIMITED = 35791394 UA_TIMER_UNLIMITED = 35791394
MAX_OPTIONS_LENGTH = 255
TIMER_OPTION_KEYS = ( TIMER_OPTION_KEYS = (
'DEFAULT_UA_TIMER', 'DEFAULT_UA_TIMER',
'TG_TIMER', 'TG_TIMER',
@ -117,6 +118,7 @@ TIMER_OPTION_KEYS = (
) )
TIMER_EXPLICIT_KEY = '_TIMER_OPTIONS_SET' TIMER_EXPLICIT_KEY = '_TIMER_OPTIONS_SET'
DMR_ID_MAX = 16777215 DMR_ID_MAX = 16777215
NETWORK_DIRECT_DIAL_SLOTS = (0, 1, 2)
OBP_RATE_DROP_MIN_DURATION = 2.0 OBP_RATE_DROP_MIN_DURATION = 2.0
OBP_RATE_DROP_MIN_PACKETS = 50 OBP_RATE_DROP_MIN_PACKETS = 50
OBP_RATE_DROP_MAX_PPS = 50 OBP_RATE_DROP_MAX_PPS = 50
@ -198,6 +200,12 @@ def parse_default_reflector_option(value, default=None):
return default return default
return int(value) return int(value)
def parse_network_direct_dial_slot(value, default=None):
parsed = parse_int_option(value, default=None)
if parsed in NETWORK_DIRECT_DIAL_SLOTS:
return parsed
return default
def normalize_ua_timer(timer): def normalize_ua_timer(timer):
return UA_TIMER_UNLIMITED if timer == 0 else timer return UA_TIMER_UNLIMITED if timer == 0 else timer
@ -228,6 +236,81 @@ def valid_ident_override_tg(tg):
def target_requires_emb_lc_rewrite(_dst_id, _target_tgid): def target_requires_emb_lc_rewrite(_dst_id, _target_tgid):
return _dst_id != _target_tgid return _dst_id != _target_tgid
def active_reflector_for_system_slot(system, slot):
for bridge in BRIDGES:
if bridge[0:1] != '#':
continue
for bridgesystem in BRIDGES[bridge]:
if (
bridgesystem['SYSTEM'] == system
and bridgesystem['TS'] == slot
and bridgesystem['ACTIVE'] == True
):
return bridge
return None
def slot_available_for_network_direct_dial(system, slot, pkt_time):
status = systems[system].STATUS[slot]
hangtime = CONFIG['SYSTEMS'][system]['GROUP_HANGTIME']
if status['RX_TYPE'] != HBPF_SLT_VTERM and (pkt_time - status['RX_TIME']) < STREAM_TO:
return False, 'RX active'
if status['TX_TYPE'] != HBPF_SLT_VTERM and (pkt_time - status['TX_TIME']) < STREAM_TO:
return False, 'TX active'
if (pkt_time - status['RX_TIME']) < hangtime:
return False, 'RX hangtime'
if (pkt_time - status['TX_TIME']) < hangtime:
return False, 'TX hangtime'
active_reflector = active_reflector_for_system_slot(system, slot)
default_reflector = CONFIG['SYSTEMS'][system].get('DEFAULT_DIAL_TS{}'.format(slot), 0)
if active_reflector and active_reflector != '#{}'.format(default_reflector):
return False, 'user selected {}'.format(active_reflector)
return True, ''
def activate_network_direct_dial(_dst_id, pkt_time):
target_tg = int_id(_dst_id)
for system in systems:
if CONFIG['SYSTEMS'][system]['MODE'] != 'MASTER':
continue
slot = CONFIG['SYSTEMS'][system].get('NETWORK_DIRECT_DIAL_SLOT', 0)
if slot not in (1, 2):
continue
if not any(int_id(peer_id) == target_tg for peer_id in CONFIG['SYSTEMS'][system]['PEERS']):
continue
slot_free, reason = slot_available_for_network_direct_dial(system, slot, pkt_time)
if not slot_free:
logger.info(
'(%s) Network Direct Dial to TG %s not activated for %s TS%s: %s',
system, target_tg, system, slot, reason
)
continue
route_timer = get_route_timer(system, slot, 'DIAL')
if '#{}'.format(target_tg) not in BRIDGES:
reset_all_reflector_system(route_timer, system, slot)
logger.info(
'(%s) Network Direct Dial creating TG %s as TG9 TS%s. Timeout %s',
system, target_tg, slot, route_timer
)
make_single_reflector(_dst_id, route_timer, system, slot)
else:
reset_all_reflector_system(route_timer, system, slot)
for bridge_system in BRIDGES['#{}'.format(target_tg)]:
if bridge_system['SYSTEM'] == system and bridge_system['TS'] == slot:
bridge_system['ACTIVE'] = True
bridge_system['TIMER'] = pkt_time + bridge_system['TIMEOUT']
bridge_system['TO_TYPE'] = 'ON'
logger.info(
'(%s) Network Direct Dial activated TG %s as TG9 TS%s. Timeout %s',
system, target_tg, slot, route_timer
)
break
# In-call embedded LC observation. This is log-only and must not affect routing # In-call embedded LC observation. This is log-only and must not affect routing
# or packet mutation. # or packet mutation.
@ -1274,6 +1357,13 @@ def options_config():
_options = {} _options = {}
CONFIG['SYSTEMS'][_system]['OPTIONS'] = CONFIG['SYSTEMS'][_system]['OPTIONS'].rstrip('\x00') CONFIG['SYSTEMS'][_system]['OPTIONS'] = CONFIG['SYSTEMS'][_system]['OPTIONS'].rstrip('\x00')
CONFIG['SYSTEMS'][_system]['OPTIONS'] = CONFIG['SYSTEMS'][_system]['OPTIONS'].encode('ascii', 'ignore').decode() CONFIG['SYSTEMS'][_system]['OPTIONS'] = CONFIG['SYSTEMS'][_system]['OPTIONS'].encode('ascii', 'ignore').decode()
if len(CONFIG['SYSTEMS'][_system]['OPTIONS']) > MAX_OPTIONS_LENGTH:
logger.warning(
'(OPTIONS) %s overlength options (%s bytes), ignoring',
_system,
len(CONFIG['SYSTEMS'][_system]['OPTIONS']),
)
continue
CONFIG['SYSTEMS'][_system]['OPTIONS'] = re.sub("\'","",CONFIG['SYSTEMS'][_system]['OPTIONS']) CONFIG['SYSTEMS'][_system]['OPTIONS'] = re.sub("\'","",CONFIG['SYSTEMS'][_system]['OPTIONS'])
CONFIG['SYSTEMS'][_system]['OPTIONS'] = re.sub("\"","",CONFIG['SYSTEMS'][_system]['OPTIONS']) CONFIG['SYSTEMS'][_system]['OPTIONS'] = re.sub("\"","",CONFIG['SYSTEMS'][_system]['OPTIONS'])
for x in CONFIG['SYSTEMS'][_system]['OPTIONS'].split(";"): for x in CONFIG['SYSTEMS'][_system]['OPTIONS'].split(";"):
@ -1316,6 +1406,20 @@ def options_config():
_options['DIAL_A_TG'] = _options.pop('DIALTG') _options['DIAL_A_TG'] = _options.pop('DIALTG')
if 'DYNAMIC' in _options: if 'DYNAMIC' in _options:
_options['DYNAMIC_TG_ROUTING'] = _options.pop('DYNAMIC') _options['DYNAMIC_TG_ROUTING'] = _options.pop('DYNAMIC')
if 'DIAL1' in _options:
_options['DEFAULT_DIAL_TS1'] = _options.pop('DIAL1')
if 'DIAL2' in _options:
_options['DEFAULT_DIAL_TS2'] = _options.pop('DIAL2')
if 'TGTO1' in _options:
_options['TS1_TG_TIMER'] = _options.pop('TGTO1')
if 'TGTO2' in _options:
_options['TS2_TG_TIMER'] = _options.pop('TGTO2')
if 'DIALTO1' in _options:
_options['TS1_DIAL_TIMER'] = _options.pop('DIALTO1')
if 'DIALTO2' in _options:
_options['TS2_DIAL_TIMER'] = _options.pop('DIALTO2')
if 'NDD_SLOT' in _options:
_options['NETWORK_DIRECT_DIAL_SLOT'] = _options.pop('NDD_SLOT')
if 'default_dial_ts1' in _options: if 'default_dial_ts1' in _options:
_options['DEFAULT_DIAL_TS1'] = _options.pop('default_dial_ts1') _options['DEFAULT_DIAL_TS1'] = _options.pop('default_dial_ts1')
if 'default_dial_ts2' in _options: if 'default_dial_ts2' in _options:
@ -1411,6 +1515,8 @@ def options_config():
_options['DIAL_A_TG'] = int(CONFIG['SYSTEMS'][_system].get('DIAL_A_TG', True)) _options['DIAL_A_TG'] = int(CONFIG['SYSTEMS'][_system].get('DIAL_A_TG', True))
if 'DYNAMIC_TG_ROUTING' not in _options: if 'DYNAMIC_TG_ROUTING' not in _options:
_options['DYNAMIC_TG_ROUTING'] = int(CONFIG['SYSTEMS'][_system].get('DYNAMIC_TG_ROUTING', True)) _options['DYNAMIC_TG_ROUTING'] = int(CONFIG['SYSTEMS'][_system].get('DYNAMIC_TG_ROUTING', True))
if 'NETWORK_DIRECT_DIAL_SLOT' not in _options:
_options['NETWORK_DIRECT_DIAL_SLOT'] = CONFIG['SYSTEMS'][_system].get('NETWORK_DIRECT_DIAL_SLOT', 0)
_default_dial_ts1 = parse_default_reflector_option(_options['DEFAULT_DIAL_TS1'], default=None) _default_dial_ts1 = parse_default_reflector_option(_options['DEFAULT_DIAL_TS1'], default=None)
if _default_dial_ts1 is None: if _default_dial_ts1 is None:
@ -1480,6 +1586,11 @@ def options_config():
if _dynamic_tg_routing is None: if _dynamic_tg_routing is None:
logger.debug('(OPTIONS) %s - DYNAMIC_TG_ROUTING is not 0 or 1, ignoring',_system) logger.debug('(OPTIONS) %s - DYNAMIC_TG_ROUTING is not 0 or 1, ignoring',_system)
_dynamic_tg_routing = CONFIG['SYSTEMS'][_system].get('DYNAMIC_TG_ROUTING', True) _dynamic_tg_routing = CONFIG['SYSTEMS'][_system].get('DYNAMIC_TG_ROUTING', True)
_network_direct_dial_slot = parse_network_direct_dial_slot(_options['NETWORK_DIRECT_DIAL_SLOT'], default=None)
if _network_direct_dial_slot is None:
logger.debug('(OPTIONS) %s - NETWORK_DIRECT_DIAL_SLOT is not 0, 1, or 2, ignoring',_system)
_network_direct_dial_slot = CONFIG['SYSTEMS'][_system].get('NETWORK_DIRECT_DIAL_SLOT', 0)
if 'TS1_STATIC' not in _options or 'TS2_STATIC' not in _options or 'DEFAULT_DIAL_TS1' not in _options or 'DEFAULT_DIAL_TS2' not in _options or 'DEFAULT_UA_TIMER' not in _options: if 'TS1_STATIC' not in _options or 'TS2_STATIC' not in _options or 'DEFAULT_DIAL_TS1' not in _options or 'DEFAULT_DIAL_TS2' not in _options or 'DEFAULT_UA_TIMER' not in _options:
logger.debug('(OPTIONS) %s - Required field missing, ignoring',_system) logger.debug('(OPTIONS) %s - Required field missing, ignoring',_system)
@ -1541,6 +1652,9 @@ def options_config():
if _dynamic_tg_routing != CONFIG['SYSTEMS'][_system].get('DYNAMIC_TG_ROUTING', True): if _dynamic_tg_routing != CONFIG['SYSTEMS'][_system].get('DYNAMIC_TG_ROUTING', True):
logger.debug('(OPTIONS) %s DYNAMIC_TG_ROUTING changed to %s',_system,_dynamic_tg_routing) logger.debug('(OPTIONS) %s DYNAMIC_TG_ROUTING changed to %s',_system,_dynamic_tg_routing)
if _network_direct_dial_slot != CONFIG['SYSTEMS'][_system].get('NETWORK_DIRECT_DIAL_SLOT', 0):
logger.debug('(OPTIONS) %s NETWORK_DIRECT_DIAL_SLOT changed to %s',_system,_network_direct_dial_slot)
ts1 = [] ts1 = []
if ('_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']) or (_options['TS1_STATIC'] != CONFIG['SYSTEMS'][_system]['TS1_STATIC']): if ('_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']) or (_options['TS1_STATIC'] != CONFIG['SYSTEMS'][_system]['TS1_STATIC']):
@ -1569,6 +1683,7 @@ def options_config():
CONFIG['SYSTEMS'][_system][TIMER_EXPLICIT_KEY] = _timer_values[TIMER_EXPLICIT_KEY] CONFIG['SYSTEMS'][_system][TIMER_EXPLICIT_KEY] = _timer_values[TIMER_EXPLICIT_KEY]
CONFIG['SYSTEMS'][_system]['DIAL_A_TG'] = _dial_a_tg CONFIG['SYSTEMS'][_system]['DIAL_A_TG'] = _dial_a_tg
CONFIG['SYSTEMS'][_system]['DYNAMIC_TG_ROUTING'] = _dynamic_tg_routing CONFIG['SYSTEMS'][_system]['DYNAMIC_TG_ROUTING'] = _dynamic_tg_routing
CONFIG['SYSTEMS'][_system]['NETWORK_DIRECT_DIAL_SLOT'] = _network_direct_dial_slot
if '_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']: if '_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']:
CONFIG['SYSTEMS'][_system]['_reloadoptions'] = False CONFIG['SYSTEMS'][_system]['_reloadoptions'] = False
@ -2176,6 +2291,9 @@ class routerOBP(OPENBRIDGE):
if int_id(_dst_id) >= 5 and int_id(_dst_id) != 9 and (str(int_id(_dst_id)) not in BRIDGES): if int_id(_dst_id) >= 5 and int_id(_dst_id) != 9 and (str(int_id(_dst_id)) not in BRIDGES):
logger.debug('(%s) Bridge for STAT TG %s does not exist. Creating',self._system, int_id(_dst_id)) logger.debug('(%s) Bridge for STAT TG %s does not exist. Creating',self._system, int_id(_dst_id))
make_stat_bridge(_dst_id) make_stat_bridge(_dst_id)
if not _data_control:
activate_network_direct_dial(_dst_id, pkt_time)
_sysIgnore = deque() _sysIgnore = deque()
for _bridge in BRIDGES: for _bridge in BRIDGES:

@ -29,6 +29,7 @@ change.
import configparser import configparser
import sys import sys
import const import const
import logging
import socket import socket
import ipaddress import ipaddress
@ -44,6 +45,20 @@ __license__ = 'GNU GPLv3'
__maintainer__ = 'Simon Adlem, G7RZU' __maintainer__ = 'Simon Adlem, G7RZU'
__email__ = 'simon@gb7fr.org.uk' __email__ = 'simon@gb7fr.org.uk'
logger = logging.getLogger(__name__)
def config_network_direct_dial_slot(config, section):
try:
slot = config.getint(section, 'NETWORK_DIRECT_DIAL_SLOT', fallback=0)
except ValueError:
logger.warning('(%s) NETWORK_DIRECT_DIAL_SLOT is not an integer, defaulting to 0', section)
return 0
if slot not in (0, 1, 2):
logger.warning('(%s) NETWORK_DIRECT_DIAL_SLOT is not 0, 1, or 2, defaulting to 0', section)
return 0
return slot
def master_timer_config(config, section): def master_timer_config(config, section):
default_timer = config.getint(section, 'DEFAULT_UA_TIMER', fallback=10) default_timer = config.getint(section, 'DEFAULT_UA_TIMER', fallback=10)
tg_timer = config.getint(section, 'TG_TIMER', fallback=default_timer) tg_timer = config.getint(section, 'TG_TIMER', fallback=default_timer)
@ -357,6 +372,7 @@ def build_config(_config_file):
'VOICE_IDENT': config.getboolean(section, 'VOICE_IDENT', fallback=True), 'VOICE_IDENT': config.getboolean(section, 'VOICE_IDENT', fallback=True),
'DIAL_A_TG': config.getboolean(section, 'DIAL_A_TG', fallback=True), 'DIAL_A_TG': config.getboolean(section, 'DIAL_A_TG', fallback=True),
'DYNAMIC_TG_ROUTING': config.getboolean(section, 'DYNAMIC_TG_ROUTING', fallback=True), 'DYNAMIC_TG_ROUTING': config.getboolean(section, 'DYNAMIC_TG_ROUTING', fallback=True),
'NETWORK_DIRECT_DIAL_SLOT': config_network_direct_dial_slot(config, section),
'TS1_STATIC': config.get(section,'TS1_STATIC', fallback=''), 'TS1_STATIC': config.get(section,'TS1_STATIC', fallback=''),
'TS2_STATIC': config.get(section,'TS2_STATIC', fallback=''), 'TS2_STATIC': config.get(section,'TS2_STATIC', fallback=''),
'DEFAULT_DIAL_TS1': config.getint(section, 'DEFAULT_DIAL_TS1', fallback=0), 'DEFAULT_DIAL_TS1': config.getint(section, 'DEFAULT_DIAL_TS1', fallback=0),

@ -61,6 +61,7 @@ SINGLE_MODE: True
VOICE_IDENT: True VOICE_IDENT: True
DIAL_A_TG: True DIAL_A_TG: True
DYNAMIC_TG_ROUTING: True DYNAMIC_TG_ROUTING: True
NETWORK_DIRECT_DIAL_SLOT: 0
TS1_STATIC: TS1_STATIC:
TS2_STATIC: TS2_STATIC:
DEFAULT_DIAL_TS1: 0 DEFAULT_DIAL_TS1: 0

@ -0,0 +1,49 @@
# Codex Notes
## Network Direct Dial
Findings:
- HBP `RPTO` options are variable-length in DMRGateway and null-padded options
are already trimmed in FreeDMR.
- DMRGateway bounds normal Homebrew `DMRD` packets to fixed length and uses a
300-byte stack buffer for outbound `RPTO`; a 255-byte receive limit in
FreeDMR is conservative and compatible.
- FreeDMR can implement repeater-ID network direct dial without a new packet
forwarding path by activating an existing `#<tg>` dial route and reusing the
current TG9 rewrite logic.
- FreeDMR1 still treats HBP `SLOTS` as repeater-advertised metadata from the
RPTC config packet or legacy PEER config. It is not currently a master-side
routing policy knob for dynamic/dial behavior.
Assumptions:
- `NETWORK_DIRECT_DIAL_SLOT=0` means disabled; `1` presents TG9 TS1; `2`
presents TG9 TS2 for DMO/simplex clients.
- Network direct dial is client/session-overridable through `OPTIONS`; the
compact alias is `NDD_SLOT`.
- RF-originated traffic must not activate network direct dial.
- Invalid `NETWORK_DIRECT_DIAL_SLOT` values in config should fall back to `0`
and log a warning; invalid OPTIONS values should leave the current session
value unchanged.
Unresolved questions:
- Whether to add a later local CW `N` notification for blocked network direct
dial attempts.
- Whether a broader HBP bounds-validation pass should reject all non-exact
`DMRD` lengths rather than only guarding short packets.
- Whether FreeDMR2 should promote HBP `SLOTS`/DMO capability into an explicit
access-session capability model. FreeDMR1 should not infer this for network
direct dial.
Protocol-sensitive areas:
- Network direct dial is a narrow exception to the normal rule that network
audio does not change local RF-facing dial state.
- It must not override active or hangtime user-selected dial-a-TG state.
- It must preserve existing packet rewrite boundaries by using the established
TG9 reflector bridge path.
Inferred invariants:
- General private voice routing remains disabled.
- Repeater/client-directed group-call access is allowed only when explicitly
configured and the selected RF slot is idle.
- Overlength `OPTIONS` should be acknowledged but ignored to avoid client
reconnect loops while protecting parser state.

@ -16,6 +16,10 @@ KEY=secret;TS1=91,92;TS2=235;DEFAULT_DIAL_TS1=4400;DEFAULT_DIAL_TS2=2350;TIMER=1
Keep key names exact. Avoid spaces around key names. Commas inside TG lists may Keep key names exact. Avoid spaces around key names. Commas inside TG lists may
have simple whitespace. have simple whitespace.
FreeDMR accepts `OPTIONS` strings up to 255 bytes. Longer strings are rejected
and the current/default session settings remain in use. Use the compact aliases
below where possible on embedded clients.
## Common Examples ## Common Examples
Static TGs on both slots: Static TGs on both slots:
@ -66,6 +70,18 @@ Use a user API key:
KEY=my-secret-key KEY=my-secret-key
``` ```
Allow network users to direct-dial this repeater/hotspot ID onto local TG9:
```text
NDD_SLOT=1
```
For DMO/simplex hotspots, use slot 2:
```text
NDD_SLOT=2
```
## Supported Fields ## Supported Fields
| Field | Meaning | | Field | Meaning |
@ -84,6 +100,7 @@ KEY=my-secret-key
| `TS2_TG_TIMER` | Slot 2 conventional TG timeout in minutes. | | `TS2_TG_TIMER` | Slot 2 conventional TG timeout in minutes. |
| `TS1_DIAL_TIMER` | Slot 1 dial-a-TG timeout in minutes. | | `TS1_DIAL_TIMER` | Slot 1 dial-a-TG timeout in minutes. |
| `TS2_DIAL_TIMER` | Slot 2 dial-a-TG timeout in minutes. | | `TS2_DIAL_TIMER` | Slot 2 dial-a-TG timeout in minutes. |
| `NETWORK_DIRECT_DIAL_SLOT` | Network direct dial slot. `0` disables it, `1` presents on TG9 TS1, `2` presents on TG9 TS2. |
| `DIALTG` | Enable/disable dial-a-TG private-call control. Use `1` or `0`. | | `DIALTG` | Enable/disable dial-a-TG private-call control. Use `1` or `0`. |
| `DYNAMIC` | Enable/disable automatic dynamic conventional TG routing. Use `1` or `0`. | | `DYNAMIC` | Enable/disable automatic dynamic conventional TG routing. Use `1` or `0`. |
| `VOICE` | Enable/disable voice ident. Use `1` or `0`. | | `VOICE` | Enable/disable voice ident. Use `1` or `0`. |
@ -111,6 +128,26 @@ These are still accepted for compatibility:
| `TS2_1` ... `TS2_9` | DMR+ style slot-2 static TG fields. Combined into `TS2`. | | `TS2_1` ... `TS2_9` | DMR+ style slot-2 static TG fields. Combined into `TS2`. |
| `UserLink` | Accepted and ignored. | | `UserLink` | Accepted and ignored. |
## Compact Aliases
These aliases are preferred in HBP `OPTIONS` strings where space is limited:
| Alias | Current meaning |
| --- | --- |
| `DIAL1` | Alias for `DEFAULT_DIAL_TS1`. |
| `DIAL2` | Alias for `DEFAULT_DIAL_TS2`. |
| `TGTO1` | Alias for `TS1_TG_TIMER`. |
| `TGTO2` | Alias for `TS2_TG_TIMER`. |
| `DIALTO1` | Alias for `TS1_DIAL_TIMER`. |
| `DIALTO2` | Alias for `TS2_DIAL_TIMER`. |
| `NDD_SLOT` | Alias for `NETWORK_DIRECT_DIAL_SLOT`. |
Example compact string:
```text
KEY=secret;TS1=91,92;TS2=235;DIAL1=4400;DIAL2=2350;TGTO1=10;TGTO2=10;DIALTO1=30;DIALTO2=30;NDD_SLOT=1
```
## Dial-a-TG Notes ## Dial-a-TG Notes
- Dial-a-TG uses RF-visible TG9 on the relevant slot. - Dial-a-TG uses RF-visible TG9 on the relevant slot.
@ -118,6 +155,14 @@ These are still accepted for compatibility:
- Private-call control on slot 1 controls slot 1. - Private-call control on slot 1 controls slot 1.
- Private-call control on slot 2 controls slot 2. - Private-call control on slot 2 controls slot 2.
- Voice prompts for dial-a-TG are sent on TG9 on the controlling slot. - Voice prompts for dial-a-TG are sent on TG9 on the controlling slot.
- Network direct dial is a narrow exception where network-originated group
traffic to the repeater/hotspot's own ID may activate local TG9 on the
configured slot. It does not enable general private voice routing.
- Network direct dial does not override an active or hangtime user-selected
dial-a-TG route.
- Invalid `NETWORK_DIRECT_DIAL_SLOT` values in server config fall back to `0`
and are logged. Invalid `NDD_SLOT`/`NETWORK_DIRECT_DIAL_SLOT` values in
client `OPTIONS` are ignored and the current session value remains in use.
- `DEFAULT_REFLECTOR`, `DIAL`, and `StartRef` only affect the slot-2 default. - `DEFAULT_REFLECTOR`, `DIAL`, and `StartRef` only affect the slot-2 default.
Use `DEFAULT_DIAL_TS1` for slot 1. Use `DEFAULT_DIAL_TS1` for slot 1.

@ -143,6 +143,12 @@ for rule timeout checks without sleeping.
in-memory effective default should normalize to `0` without writing back to in-memory effective default should normalize to `0` without writing back to
the config file. System-wide defaults are intended for sparing use; client the config file. System-wide defaults are intended for sparing use; client
requested settings are preferential. requested settings are preferential.
- Network direct dial behaviour: when `NETWORK_DIRECT_DIAL_SLOT`/`NDD_SLOT` is
set to `1` or `2`, network-originated group traffic to the connected
repeater/hotspot ID may activate local TG9 on that slot. Tests must verify it
is disabled by default, does not affect RF-originated traffic, does not
override active/hangtime user-selected dial-a-TG state, and uses the existing
TG9 rewrite path rather than a new packet mutation path.
- Static TG configuration bugs: startup and live options reload should reject - Static TG configuration bugs: startup and live options reload should reject
prohibited local/control TGs consistently on both TS1 and TS2 after parsing the prohibited local/control TGs consistently on both TS1 and TS2 after parsing the
configured TG strings to integers. Invalid IDs at or above `16777215` should configured TG strings to integers. Invalid IDs at or above `16777215` should

@ -91,6 +91,12 @@ Invalid specific timer fields are logged and ignored so valid broader timer
fields still apply. Voice ident override coverage verifies valid fields still apply. Voice ident override coverage verifies valid
override TGs are used, empty/false overrides use all-call, and malformed, 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. control, or all-call override values are logged and fall back to all-call.
Network direct dial coverage verifies the feature is disabled by default,
routes network-originated calls to a connected repeater/hotspot ID as RF-visible
TG9 on the configured slot, supports TS2 for DMO/simplex clients, does not
override a user-selected dial-a-TG route, can replace an idle configured default
dial route, and is not activated by RF-originated traffic. OPTIONS coverage also
verifies compact aliases and overlength `OPTIONS` rejection.
Generated voice prompt helper coverage verifies prompt stream state is attached Generated voice prompt helper coverage verifies prompt stream state is attached
to the router instance sending the prompt, even if a stale module-level to the router instance sending the prompt, even if a stale module-level
`system` variable names another master. This protects dial-a-TG prompts, idents, `system` variable names another master. This protects dial-a-TG prompts, idents,
@ -117,6 +123,16 @@ to another FBP peer is covered for source server, source repeater, hops, BER and
RSSI metadata preservation. RSSI metadata preservation.
The OpenBridge parser seam is covered for truncated `DMRE` packets so malformed The OpenBridge parser seam is covered for truncated `DMRE` packets so malformed
UDP input is logged and discarded before fixed-offset parsing can raise. UDP input is logged and discarded before fixed-offset parsing can raise.
UDP black-box coverage includes FBP-originated network direct dial to a
connected HBP client, asserting the observed RF-facing packet is TG9 on the
configured slot.
FBP protocol-version coverage asserts that a configured v4 link can still emit
v4 layout when explicitly negotiated/configured, but an established v5 session
is not downgraded by a later v4 packet.
OBP/FBP rate-drop threshold behavior is asserted in the deterministic harness,
where fake time makes the packet-rate calculation exact. UDP black-box coverage
keeps a sustained high-rate FBP load case, but treats it as an observable
stability/proxy-error check rather than a precise rate-accounting assertion.
Enhanced OpenBridge bridge-control coverage verifies a valid generated `BCST` Enhanced OpenBridge bridge-control coverage verifies a valid generated `BCST`
STUN packet sets the global `STUN` traffic gate. STUN packet sets the global `STUN` traffic gate.
Dial-a-TG source-quench coverage verifies HBP-to-FBP reflector forwarding checks Dial-a-TG source-quench coverage verifies HBP-to-FBP reflector forwarding checks

@ -24,6 +24,13 @@
to TS2; explicit `DEFAULT_DIAL_TS2` takes precedence. to TS2; explicit `DEFAULT_DIAL_TS2` takes precedence.
- Added validation/logging so invalid default dial values do not create bridge - Added validation/logging so invalid default dial values do not create bridge
state and normalize to no default for the active runtime session. state and normalize to no default for the active runtime session.
- Added compact HBP `OPTIONS` aliases for newer slot/default/timer settings:
`DIAL1`, `DIAL2`, `TGTO1`, `TGTO2`, `DIALTO1`, `DIALTO2` and `NDD_SLOT`.
- Bounded received HBP `OPTIONS` to 255 bytes. Overlength options are
acknowledged but ignored so clients do not reconnect-loop and oversized
strings do not enter session state.
- Added startup validation for `NETWORK_DIRECT_DIAL_SLOT`; invalid values fall
back to disabled (`0`) and are logged.
## Dial-a-TG ## Dial-a-TG
@ -40,6 +47,17 @@
- Preserved the current FreeDMR dial-a-TG policy cap of `999999`. - Preserved the current FreeDMR dial-a-TG policy cap of `999999`.
- Ensured FBP route targets created for dial-a-TG remain active across local - Ensured FBP route targets created for dial-a-TG remain active across local
retunes/disconnects, in line with the mesh "everything everywhere" model. retunes/disconnects, in line with the mesh "everything everywhere" model.
- Added opt-in network direct dial: a network-originated group call to a
connected repeater/hotspot's own ID can be presented locally as TG9 on the
configured slot. This is configured with `NETWORK_DIRECT_DIAL_SLOT` or
`NDD_SLOT` (`0` disabled, `1` TG9 TS1, `2` TG9 TS2).
- Network direct dial is a deliberate, narrow exception where network traffic
may activate the local RF TG9 presentation path. It does not override an
active or hangtime user-selected dial-a-TG route and does not apply to
RF-originated traffic.
- Existing HBP `SLOTS` data remains repeater-advertised metadata. FreeDMR1 does
not use it as a master-side routing policy for network direct dial; the new
explicit setting is `NETWORK_DIRECT_DIAL_SLOT`.
## Data Path ## Data Path

@ -61,6 +61,7 @@ SINGLE_MODE: True
VOICE_IDENT: True VOICE_IDENT: True
DIAL_A_TG: True DIAL_A_TG: True
DYNAMIC_TG_ROUTING: True DYNAMIC_TG_ROUTING: True
NETWORK_DIRECT_DIAL_SLOT: 0
TS1_STATIC: TS1_STATIC:
TS2_STATIC: TS2_STATIC:
DEFAULT_DIAL_TS1: 0 DEFAULT_DIAL_TS1: 0

@ -51,6 +51,8 @@ from utils import bytes_4, int_id, mk_id_dict, try_download,load_json,blake2bsum
import pickle import pickle
from reporting_const import * from reporting_const import *
MAX_OPTIONS_LENGTH = 255
# The module needs logging logging, but handlers, etc. are controlled by the parent # The module needs logging logging, but handlers, etc. are controlled by the parent
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1070,10 +1072,18 @@ class HBSYSTEM(DatagramProtocol):
_peer_id = _data[4:8] _peer_id = _data[4:8]
if _peer_id in self._peers and self._peers[_peer_id]['SOCKADDR'] == _sockaddr: if _peer_id in self._peers and self._peers[_peer_id]['SOCKADDR'] == _sockaddr:
_this_peer = self._peers[_peer_id] _this_peer = self._peers[_peer_id]
_this_peer['OPTIONS'] = _data[8:]
self.send_peer(_peer_id, b''.join([RPTACK, _peer_id])) self.send_peer(_peer_id, b''.join([RPTACK, _peer_id]))
if len(_data[8:]) > MAX_OPTIONS_LENGTH:
logger.warning(
'(%s) Peer %s sent overlength options (%s bytes), ignoring',
self._system,
_this_peer['CALLSIGN'],
len(_data[8:]),
)
return
_this_peer['OPTIONS'] = _data[8:].rstrip(b'\x00')
logger.info('(%s) Peer %s has sent options %s', self._system, _this_peer['CALLSIGN'], _this_peer['OPTIONS']) logger.info('(%s) Peer %s has sent options %s', self._system, _this_peer['CALLSIGN'], _this_peer['OPTIONS'])
self._CONFIG['SYSTEMS'][self._system]['OPTIONS'] = _this_peer['OPTIONS'].decode() self._CONFIG['SYSTEMS'][self._system]['OPTIONS'] = _this_peer['OPTIONS'].decode('ascii', errors='ignore')
else: else:
self.transport.write(b''.join([MSTNAK, _peer_id]), _sockaddr) self.transport.write(b''.join([MSTNAK, _peer_id]), _sockaddr)
logger.info('(%s) Options from Radio ID that is not logged: %s', self._system, int_id(_peer_id)) logger.info('(%s) Options from Radio ID that is not logged: %s', self._system, int_id(_peer_id))

@ -315,6 +315,7 @@ def minimal_config(system_names: tuple[str, ...] = ("MASTER-A", "MASTER-B")) ->
"VOICE_IDENT": False, "VOICE_IDENT": False,
"DIAL_A_TG": True, "DIAL_A_TG": True,
"DYNAMIC_TG_ROUTING": True, "DYNAMIC_TG_ROUTING": True,
"NETWORK_DIRECT_DIAL_SLOT": 0,
"TS1_STATIC": "", "TS1_STATIC": "",
"TS2_STATIC": "", "TS2_STATIC": "",
"DEFAULT_DIAL_TS1": 0, "DEFAULT_DIAL_TS1": 0,

@ -469,6 +469,7 @@ def write_bridge_master_config(
ts2_static: str = "91", ts2_static: str = "91",
dial_a_tg: bool = True, dial_a_tg: bool = True,
dynamic_tg_routing: bool = True, dynamic_tg_routing: bool = True,
network_direct_dial_slot: int = 0,
master_extra_config: str = "", master_extra_config: str = "",
) -> None: ) -> None:
global_use_acl_text = "True" if global_use_acl else "False" global_use_acl_text = "True" if global_use_acl else "False"
@ -497,6 +498,7 @@ SINGLE_MODE: True
VOICE_IDENT: False VOICE_IDENT: False
DIAL_A_TG: {dial_a_tg_text} DIAL_A_TG: {dial_a_tg_text}
DYNAMIC_TG_ROUTING: {dynamic_tg_routing_text} DYNAMIC_TG_ROUTING: {dynamic_tg_routing_text}
NETWORK_DIRECT_DIAL_SLOT: {network_direct_dial_slot}
TS1_STATIC: {ts1_static} TS1_STATIC: {ts1_static}
TS2_STATIC: {ts2_static} TS2_STATIC: {ts2_static}
DEFAULT_DIAL_TS1: 0 DEFAULT_DIAL_TS1: 0
@ -974,6 +976,7 @@ class UdpBlackBoxScenario:
ts2_static: str = "91", ts2_static: str = "91",
dial_a_tg: bool = True, dial_a_tg: bool = True,
dynamic_tg_routing: bool = True, dynamic_tg_routing: bool = True,
network_direct_dial_slot: int = 0,
master_extra_config: str = "", master_extra_config: str = "",
fbp_systems: dict[str, int] | None = None, fbp_systems: dict[str, int] | None = None,
fbp_proto_versions: dict[str, int] | None = None, fbp_proto_versions: dict[str, int] | None = None,
@ -997,6 +1000,7 @@ class UdpBlackBoxScenario:
"ts2_static": ts2_static, "ts2_static": ts2_static,
"dial_a_tg": dial_a_tg, "dial_a_tg": dial_a_tg,
"dynamic_tg_routing": dynamic_tg_routing, "dynamic_tg_routing": dynamic_tg_routing,
"network_direct_dial_slot": network_direct_dial_slot,
"master_extra_config": master_extra_config, "master_extra_config": master_extra_config,
} }

@ -84,6 +84,7 @@ DEFAULT_REFLECTOR: 0
self.assertEqual(parsed["ALIASES"]["STALE_TIME"], 86400) self.assertEqual(parsed["ALIASES"]["STALE_TIME"], 86400)
self.assertTrue(parsed["SYSTEMS"]["MASTER-A"]["DIAL_A_TG"]) self.assertTrue(parsed["SYSTEMS"]["MASTER-A"]["DIAL_A_TG"])
self.assertTrue(parsed["SYSTEMS"]["MASTER-A"]["DYNAMIC_TG_ROUTING"]) self.assertTrue(parsed["SYSTEMS"]["MASTER-A"]["DYNAMIC_TG_ROUTING"])
self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"], 0)
self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS1"], 0) self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS1"], 0)
self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS2"], 0) self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS2"], 0)
@ -141,6 +142,35 @@ DEFAULT_DIAL_TS2: 92
self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS2"], 92) self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS2"], 92)
self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"], 92) self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"], 92)
def test_config_invalid_network_direct_dial_slot_defaults_to_disabled(self):
for value in ("3", "A"):
with self.subTest(value=value):
config_text = f"""
[GLOBAL]
USE_ACL: False
[ALIASES]
TRY_DOWNLOAD: False
STALE_DAYS: 1
[MASTER-A]
MODE: MASTER
ENABLED: True
NETWORK_DIRECT_DIAL_SLOT: {value}
"""
fd, path = tempfile.mkstemp(suffix=".cfg")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(config_text)
with self.assertLogs("config", level="WARNING") as logs:
parsed = freedmr_config.build_config(path)
finally:
os.unlink(path)
self.assertEqual(parsed["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"], 0)
self.assertIn("NETWORK_DIRECT_DIAL_SLOT", "\n".join(logs.output))
def test_set_alias_updates_bridge_master_globals_and_shared_hblink_config(self): def test_set_alias_updates_bridge_master_globals_and_shared_hblink_config(self):
with DeterministicScenario() as scenario: with DeterministicScenario() as scenario:
bm = scenario.bm bm = scenario.bm
@ -596,6 +626,47 @@ DEFAULT_DIAL_TS2: 92
self.assertEqual(ts1_dial["TIMEOUT"], 40 * 60) self.assertEqual(ts1_dial["TIMEOUT"], 40 * 60)
self.assertEqual(ts2_dial["TIMEOUT"], 6 * 60) self.assertEqual(ts2_dial["TIMEOUT"], 6 * 60)
def test_options_compact_aliases_update_dial_timers_and_network_direct_dial(self):
config = minimal_config(("MASTER-A", "MASTER-B"))
config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = (
"DIAL1=235;DIAL2=236;TGTO1=5;TGTO2=6;DIALTO1=7;DIALTO2=8;NDD_SLOT=2"
)
with DeterministicScenario(config=config) as scenario:
scenario.bm.options_config()
self.assertEqual(config["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS1"], 235)
self.assertEqual(config["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS2"], 236)
self.assertEqual(config["SYSTEMS"]["MASTER-A"]["TS1_TG_TIMER"], 5)
self.assertEqual(config["SYSTEMS"]["MASTER-A"]["TS2_TG_TIMER"], 6)
self.assertEqual(config["SYSTEMS"]["MASTER-A"]["TS1_DIAL_TIMER"], 7)
self.assertEqual(config["SYSTEMS"]["MASTER-A"]["TS2_DIAL_TIMER"], 8)
self.assertEqual(config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"], 2)
def test_options_invalid_network_direct_dial_slot_is_ignored(self):
config = minimal_config(("MASTER-A", "MASTER-B"))
config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"] = 1
config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "NDD_SLOT=3;DIAL=0;TIMER=1"
with DeterministicScenario(config=config) as scenario:
with self.assertLogs(scenario.bm.logger, level="DEBUG") as logs:
scenario.bm.options_config()
self.assertEqual(config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"], 1)
self.assertIn("NETWORK_DIRECT_DIAL_SLOT is not 0, 1, or 2", "\n".join(logs.output))
def test_options_overlength_string_is_ignored(self):
config = minimal_config(("MASTER-A", "MASTER-B"))
config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"] = 1
config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "A" * 256
with DeterministicScenario(config=config) as scenario:
with self.assertLogs(scenario.bm.logger, level="WARNING") as logs:
scenario.bm.options_config()
self.assertEqual(config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"], 1)
self.assertIn("overlength options", "\n".join(logs.output))
def test_options_invalid_specific_timer_falls_back_without_blocking_valid_fields(self): def test_options_invalid_specific_timer_falls_back_without_blocking_valid_fields(self):
config = minimal_config(("MASTER-A", "MASTER-B")) config = minimal_config(("MASTER-A", "MASTER-B"))
config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = ( config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = (
@ -1271,6 +1342,116 @@ DEFAULT_DIAL_TS2: 92
self.assertEqual(fbp_entry["TO_TYPE"], "NONE") self.assertEqual(fbp_entry["TO_TYPE"], "NONE")
self.assertEqual(scenario.capture.packets, []) self.assertEqual(scenario.capture.packets, [])
def test_network_direct_dial_is_disabled_by_default(self):
config = minimal_config(("MASTER-A",))
add_openbridge_system(config, "OBP-1", network_id=3001)
with DeterministicScenario(config=config) as scenario:
scenario.register_peer("MASTER-A", 1001)
scenario.clock.advance(6)
scenario.inject_obp("OBP-1", PacketSpec(dst_id=1001, slot=1))
self.assertNotIn("#1001", scenario.bridge_state)
self.assertEqual(scenario.capture.for_system("MASTER-A"), [])
def test_network_direct_dial_routes_repeater_id_to_tg9_on_configured_slot(self):
config = minimal_config(("MASTER-A",))
config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"] = 1
add_openbridge_system(config, "OBP-1", network_id=3001)
with DeterministicScenario(config=config) as scenario:
scenario.register_peer("MASTER-A", 1001)
scenario.clock.advance(6)
scenario.inject_obp("OBP-1", PacketSpec(dst_id=1001, slot=1))
captured = scenario.capture.for_system("MASTER-A")
self.assertEqual(len(captured), 1)
self.assertEqual(captured[0].fields["dst_id"], bytes_3(9))
self.assertEqual(captured[0].fields["slot"], 1)
entry = next(
entry for entry in scenario.bridge_state["#1001"]
if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 1
)
self.assertTrue(entry["ACTIVE"])
def test_network_direct_dial_can_route_to_slot2_for_dmo_clients(self):
config = minimal_config(("MASTER-A",))
config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"] = 2
add_openbridge_system(config, "OBP-1", network_id=3001)
with DeterministicScenario(config=config) as scenario:
scenario.register_peer("MASTER-A", 1001)
scenario.clock.advance(6)
scenario.inject_obp("OBP-1", PacketSpec(dst_id=1001, slot=1))
captured = scenario.capture.for_system("MASTER-A")
self.assertEqual(len(captured), 1)
self.assertEqual(captured[0].fields["dst_id"], bytes_3(9))
self.assertEqual(captured[0].fields["slot"], 2)
def test_network_direct_dial_does_not_override_user_selected_dial_tg(self):
config = minimal_config(("MASTER-A",))
config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"] = 1
add_openbridge_system(config, "OBP-1", network_id=3001)
with DeterministicScenario(config=config) as scenario:
scenario.register_peer("MASTER-A", 1001)
scenario.bm.make_single_reflector(bytes_3(235), 1, "MASTER-A", 1)
scenario.clock.advance(6)
scenario.inject_obp("OBP-1", PacketSpec(dst_id=1001, slot=1))
self.assertNotIn("#1001", scenario.bridge_state)
source_entry = next(
entry for entry in scenario.bridge_state["#235"]
if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 1
)
self.assertTrue(source_entry["ACTIVE"])
self.assertEqual(scenario.capture.for_system("MASTER-A"), [])
def test_network_direct_dial_can_override_idle_default_dial_tg(self):
config = minimal_config(("MASTER-A",))
config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"] = 1
config["SYSTEMS"]["MASTER-A"]["DEFAULT_DIAL_TS1"] = 235
add_openbridge_system(config, "OBP-1", network_id=3001)
with DeterministicScenario(config=config) as scenario:
scenario.register_peer("MASTER-A", 1001)
scenario.bm.make_default_reflector(235, 1, "MASTER-A", 1)
scenario.clock.advance(6)
scenario.inject_obp("OBP-1", PacketSpec(dst_id=1001, slot=1))
captured = scenario.capture.for_system("MASTER-A")
self.assertEqual(len(captured), 1)
self.assertEqual(captured[0].fields["dst_id"], bytes_3(9))
self.assertEqual(captured[0].fields["slot"], 1)
default_entry = next(
entry for entry in scenario.bridge_state["#235"]
if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 1
)
direct_entry = next(
entry for entry in scenario.bridge_state["#1001"]
if entry["SYSTEM"] == "MASTER-A" and entry["TS"] == 1
)
self.assertFalse(default_entry["ACTIVE"])
self.assertTrue(direct_entry["ACTIVE"])
def test_rf_traffic_to_own_repeater_id_does_not_activate_network_direct_dial(self):
config = minimal_config(("MASTER-A",))
config["SYSTEMS"]["MASTER-A"]["NETWORK_DIRECT_DIAL_SLOT"] = 1
with DeterministicScenario(config=config) as scenario:
scenario.register_peer("MASTER-A", 1001)
scenario.clock.advance(6)
scenario.inject_hbp("MASTER-A", PacketSpec(dst_id=1001, slot=1))
self.assertNotIn("#1001", scenario.bridge_state)
def test_dial_a_tg_bcsq_uses_reflector_tg_for_fbp_target(self): def test_dial_a_tg_bcsq_uses_reflector_tg_for_fbp_target(self):
config = minimal_config(("MASTER-A", "MASTER-B")) config = minimal_config(("MASTER-A", "MASTER-B"))
add_openbridge_system(config, "OBP-1", network_id=3001) add_openbridge_system(config, "OBP-1", network_id=3001)
@ -2104,6 +2285,23 @@ DEFAULT_DIAL_TS2: 92
self.assertIn("DMRD packet too short", "\n".join(logs.output)) self.assertIn("DMRD packet too short", "\n".join(logs.output))
self.assertEqual(scenario.capture.for_system("MASTER-A"), []) self.assertEqual(scenario.capture.for_system("MASTER-A"), [])
def test_hbp_master_parser_acks_but_ignores_overlength_options(self):
config = minimal_config(("MASTER-A",))
with DeterministicScenario(config=config) as scenario:
sockaddr = ("127.0.0.1", 50000)
scenario.register_peer("MASTER-A", peer_id=1001, sockaddr=sockaddr)
packet = b"RPTO" + bytes_4(1001) + (b"A" * 256)
with self.assertLogs("hblink", level="WARNING") as logs:
scenario.inject_datagram("MASTER-A", packet, sockaddr=sockaddr)
ack, ack_sockaddr = scenario.transports["MASTER-A"].writes[-1]
self.assertEqual(ack, b"RPTACK" + bytes_4(1001))
self.assertEqual(ack_sockaddr, sockaddr)
self.assertNotIn("OPTIONS", config["SYSTEMS"]["MASTER-A"])
self.assertIn("overlength options", "\n".join(logs.output))
def test_obp_bridge_control_bcst_sets_global_stun_flag(self): def test_obp_bridge_control_bcst_sets_global_stun_flag(self):
config = minimal_config(()) config = minimal_config(())
add_openbridge_system(config, "OBP-1", network_id=3001) add_openbridge_system(config, "OBP-1", network_id=3001)

@ -935,7 +935,7 @@ class UdpBlackBoxHarnessTest(unittest.TestCase):
self.assertEqual(captured.fields["fbp_version"], 5) self.assertEqual(captured.fields["fbp_version"], 5)
self.assertEqual(captured.fields["source_rptr"], bytes_4(1001)) self.assertEqual(captured.fields["source_rptr"], bytes_4(1001))
def test_fbp_v4_packet_downgrades_session_to_v4_layout_for_compatibility(self): def test_fbp_v4_packet_does_not_downgrade_v5_session_layout(self):
require_udp_integration_enabled() require_udp_integration_enabled()
with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario:
@ -977,8 +977,9 @@ class UdpBlackBoxHarnessTest(unittest.TestCase):
master_b.close() master_b.close()
self.assertEqual(captured.packet[:4], b"DMRE") self.assertEqual(captured.packet[:4], b"DMRE")
self.assertEqual(len(captured.packet), 85) self.assertEqual(len(captured.packet), 89)
self.assertEqual(captured.fields["source_rptr"], b"\x00\x00\x00\x00") self.assertEqual(captured.fields["fbp_version"], 5)
self.assertEqual(captured.fields["source_rptr"], bytes_4(1001))
def test_fbp_configured_proto_v4_outbound_packet_carries_v4_version_byte(self): def test_fbp_configured_proto_v4_outbound_packet_carries_v4_version_byte(self):
require_udp_integration_enabled() require_udp_integration_enabled()
@ -1218,12 +1219,51 @@ class UdpBlackBoxHarnessTest(unittest.TestCase):
self.assertEqual(captured.fields["slot"], 2) self.assertEqual(captured.fields["slot"], 2)
self.assertEqual(captured.fields["stream_id"], bytes_4(0x01020305)) self.assertEqual(captured.fields["stream_id"], bytes_4(0x01020305))
def test_fbp_sustained_over_rate_stream_is_dropped_without_proxy_error(self): def test_fbp_network_direct_dial_routes_repeater_id_to_local_tg9(self):
require_udp_integration_enabled()
with UdpBlackBoxScenario(
fbp_systems={"OBP-1": 3001},
network_direct_dial_slot=1,
) as scenario:
master_b = scenario.repeater("MASTER-B", 1002)
fbp_peer = scenario.fbp_peer("OBP-1")
try:
master_b.login()
fbp_peer.send_bcka()
fbp_peer.send_bcve()
fbp_peer.drain(seconds=0.2)
fbp_peer.send_fbp(
PacketSpec(
peer_id=3001,
rf_src=3120001,
dst_id=1002,
slot=1,
stream_id=0x01020325,
),
source_server=9991,
source_rptr=1001,
)
captured = master_b.recv(timeout=2.0)
finally:
master_b.close()
self.assertEqual(captured.packet[:4], b"DMRD")
self.assertEqual(captured.fields["rf_src"], bytes_3(3120001))
self.assertEqual(captured.fields["dst_id"], bytes_3(9))
self.assertEqual(captured.fields["slot"], 1)
self.assertEqual(captured.fields["stream_id"], bytes_4(0x01020325))
def test_fbp_sustained_high_rate_stream_does_not_trigger_proxy_error(self):
require_udp_integration_enabled() require_udp_integration_enabled()
stream_id = 0x01020316 stream_id = 0x01020316
with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario: with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario:
fbp_peer = scenario.fbp_peer("OBP-1") fbp_peer = scenario.fbp_peer("OBP-1")
fbp_peer.send_bcka()
fbp_peer.send_bcve()
fbp_peer.drain(seconds=0.2)
fbp_peer.send_fbp( fbp_peer.send_fbp(
PacketSpec( PacketSpec(
@ -1240,7 +1280,7 @@ class UdpBlackBoxHarnessTest(unittest.TestCase):
) )
time.sleep(2.0) time.sleep(2.0)
for seq in range(1, 102): for seq in range(1, 201):
fbp_peer.send_fbp( fbp_peer.send_fbp(
PacketSpec( PacketSpec(
peer_id=3001, peer_id=3001,
@ -1254,9 +1294,12 @@ class UdpBlackBoxHarnessTest(unittest.TestCase):
source_server=9991, source_server=9991,
source_rptr=1001, source_rptr=1001,
) )
time.sleep(0.001)
output = scenario.process.wait_for_log("*PacketControl* RATE DROP!", timeout=3.0) time.sleep(0.5)
output = scenario.process.output()
self.assertIn("*CALL START*", output)
self.assertIn("PEER: 3001", output) self.assertIn("PEER: 3001", output)
self.assertIn("SRC: 9991", output) self.assertIn("SRC: 9991", output)
self.assertNotIn("proxy_BadPeer", output) self.assertNotIn("proxy_BadPeer", output)

Loading…
Cancel
Save

Powered by TurnKey Linux.