Add network direct dial support

master
Simon 3 weeks ago
parent 3027bb885e
commit 2fa05ae0a3

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

@ -104,6 +104,7 @@ DIAL_A_TG_PROHIBITED_DEFAULTS = [
]
DIAL_A_TG_MAX = 999999
UA_TIMER_UNLIMITED = 35791394
MAX_OPTIONS_LENGTH = 255
TIMER_OPTION_KEYS = (
'DEFAULT_UA_TIMER',
'TG_TIMER',
@ -117,6 +118,7 @@ TIMER_OPTION_KEYS = (
)
TIMER_EXPLICIT_KEY = '_TIMER_OPTIONS_SET'
DMR_ID_MAX = 16777215
NETWORK_DIRECT_DIAL_SLOTS = (0, 1, 2)
OBP_RATE_DROP_MIN_DURATION = 2.0
OBP_RATE_DROP_MIN_PACKETS = 50
OBP_RATE_DROP_MAX_PPS = 50
@ -198,6 +200,12 @@ def parse_default_reflector_option(value, default=None):
return default
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):
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):
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
# or packet mutation.
@ -1274,6 +1357,13 @@ def options_config():
_options = {}
CONFIG['SYSTEMS'][_system]['OPTIONS'] = CONFIG['SYSTEMS'][_system]['OPTIONS'].rstrip('\x00')
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'])
for x in CONFIG['SYSTEMS'][_system]['OPTIONS'].split(";"):
@ -1316,6 +1406,20 @@ def options_config():
_options['DIAL_A_TG'] = _options.pop('DIALTG')
if 'DYNAMIC' in _options:
_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:
_options['DEFAULT_DIAL_TS1'] = _options.pop('default_dial_ts1')
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))
if 'DYNAMIC_TG_ROUTING' not in _options:
_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)
if _default_dial_ts1 is None:
@ -1481,6 +1587,11 @@ def options_config():
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)
_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:
logger.debug('(OPTIONS) %s - Required field missing, ignoring',_system)
continue
@ -1542,6 +1653,9 @@ def options_config():
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)
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 = []
if ('_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']) or (_options['TS1_STATIC'] != CONFIG['SYSTEMS'][_system]['TS1_STATIC']):
_tmout = _default_ua_timer
@ -1569,6 +1683,7 @@ def options_config():
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
CONFIG['SYSTEMS'][_system]['NETWORK_DIRECT_DIAL_SLOT'] = _network_direct_dial_slot
if '_reloadoptions' in CONFIG['SYSTEMS'][_system] and CONFIG['SYSTEMS'][_system]['_reloadoptions']:
CONFIG['SYSTEMS'][_system]['_reloadoptions'] = False
@ -2177,6 +2292,9 @@ class routerOBP(OPENBRIDGE):
logger.debug('(%s) Bridge for STAT TG %s does not exist. Creating',self._system, int_id(_dst_id))
make_stat_bridge(_dst_id)
if not _data_control:
activate_network_direct_dial(_dst_id, pkt_time)
_sysIgnore = deque()
for _bridge in BRIDGES:
for _system in BRIDGES[_bridge]:

@ -29,6 +29,7 @@ change.
import configparser
import sys
import const
import logging
import socket
import ipaddress
@ -44,6 +45,20 @@ __license__ = 'GNU GPLv3'
__maintainer__ = 'Simon Adlem, G7RZU'
__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):
default_timer = config.getint(section, 'DEFAULT_UA_TIMER', fallback=10)
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),
'DIAL_A_TG': config.getboolean(section, 'DIAL_A_TG', 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=''),
'TS2_STATIC': config.get(section,'TS2_STATIC', fallback=''),
'DEFAULT_DIAL_TS1': config.getint(section, 'DEFAULT_DIAL_TS1', fallback=0),

@ -61,6 +61,7 @@ SINGLE_MODE: True
VOICE_IDENT: True
DIAL_A_TG: True
DYNAMIC_TG_ROUTING: True
NETWORK_DIRECT_DIAL_SLOT: 0
TS1_STATIC:
TS2_STATIC:
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
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
Static TGs on both slots:
@ -66,6 +70,18 @@ Use a user API 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
| Field | Meaning |
@ -84,6 +100,7 @@ KEY=my-secret-key
| `TS2_TG_TIMER` | Slot 2 conventional 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. |
| `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`. |
| `DYNAMIC` | Enable/disable automatic dynamic conventional TG routing. 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`. |
| `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 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 2 controls slot 2.
- 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.
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
the config file. System-wide defaults are intended for sparing use; client
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
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

@ -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
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.
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
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,
@ -117,6 +123,16 @@ to another FBP peer is covered for source server, source repeater, hops, BER and
RSSI metadata preservation.
The OpenBridge parser seam is covered for truncated `DMRE` packets so malformed
UDP input is logged and discarded before fixed-offset parsing can raise.
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`
STUN packet sets the global `STUN` traffic gate.
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.
- Added validation/logging so invalid default dial values do not create bridge
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
@ -40,6 +47,17 @@
- Preserved the current FreeDMR dial-a-TG policy cap of `999999`.
- Ensured FBP route targets created for dial-a-TG remain active across local
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

@ -61,6 +61,7 @@ SINGLE_MODE: True
VOICE_IDENT: True
DIAL_A_TG: True
DYNAMIC_TG_ROUTING: True
NETWORK_DIRECT_DIAL_SLOT: 0
TS1_STATIC:
TS2_STATIC:
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
from reporting_const import *
MAX_OPTIONS_LENGTH = 255
# The module needs logging logging, but handlers, etc. are controlled by the parent
import logging
logger = logging.getLogger(__name__)
@ -1070,10 +1072,18 @@ class HBSYSTEM(DatagramProtocol):
_peer_id = _data[4:8]
if _peer_id in self._peers and self._peers[_peer_id]['SOCKADDR'] == _sockaddr:
_this_peer = self._peers[_peer_id]
_this_peer['OPTIONS'] = _data[8:]
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'])
self._CONFIG['SYSTEMS'][self._system]['OPTIONS'] = _this_peer['OPTIONS'].decode()
self._CONFIG['SYSTEMS'][self._system]['OPTIONS'] = _this_peer['OPTIONS'].decode('ascii', errors='ignore')
else:
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))

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

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

@ -84,6 +84,7 @@ DEFAULT_REFLECTOR: 0
self.assertEqual(parsed["ALIASES"]["STALE_TIME"], 86400)
self.assertTrue(parsed["SYSTEMS"]["MASTER-A"]["DIAL_A_TG"])
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_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_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):
with DeterministicScenario() as scenario:
bm = scenario.bm
@ -596,6 +626,47 @@ DEFAULT_DIAL_TS2: 92
self.assertEqual(ts1_dial["TIMEOUT"], 40 * 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):
config = minimal_config(("MASTER-A", "MASTER-B"))
config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = (
@ -1271,6 +1342,116 @@ DEFAULT_DIAL_TS2: 92
self.assertEqual(fbp_entry["TO_TYPE"], "NONE")
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):
config = minimal_config(("MASTER-A", "MASTER-B"))
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.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):
config = minimal_config(())
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["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()
with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario:
@ -977,8 +977,9 @@ class UdpBlackBoxHarnessTest(unittest.TestCase):
master_b.close()
self.assertEqual(captured.packet[:4], b"DMRE")
self.assertEqual(len(captured.packet), 85)
self.assertEqual(captured.fields["source_rptr"], b"\x00\x00\x00\x00")
self.assertEqual(len(captured.packet), 89)
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):
require_udp_integration_enabled()
@ -1218,12 +1219,51 @@ class UdpBlackBoxHarnessTest(unittest.TestCase):
self.assertEqual(captured.fields["slot"], 2)
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()
stream_id = 0x01020316
with UdpBlackBoxScenario(fbp_systems={"OBP-1": 3001}) as scenario:
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(
PacketSpec(
@ -1240,7 +1280,7 @@ class UdpBlackBoxHarnessTest(unittest.TestCase):
)
time.sleep(2.0)
for seq in range(1, 102):
for seq in range(1, 201):
fbp_peer.send_fbp(
PacketSpec(
peer_id=3001,
@ -1254,9 +1294,12 @@ class UdpBlackBoxHarnessTest(unittest.TestCase):
source_server=9991,
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("SRC: 9991", output)
self.assertNotIn("proxy_BadPeer", output)

Loading…
Cancel
Save

Powered by TurnKey Linux.