mirror of
https://github.com/n0mjs710/dmrlink3
synced 2026-11-08 12:26:30 +00:00
- ON/OFF/RESET converted from list to frozenset in make_bridge_config(): membership tests on every voice packet are now O(1) instead of O(n). These fields are pure config and never mutated at runtime, so frozenset is safe and correct. - Added index_bridges() and BRIDGE_SRC_INDEX / BRIDGE_BY_SYSTEM indexes, mirroring the same optimisation already present in hblink3. The per-packet forwarding loop now does a single O(1) dict lookup on (system, ts, tgid) instead of iterating all bridges and systems. The VOICE_HEAD trigger loop uses BRIDGE_BY_SYSTEM for the same benefit. Index is built once in async_main after make_bridge_config() and holds live references to the BRIDGES dicts, so ACTIVE/TIMER mutations are visible through it. RAM delta: negligible (~9 KB total for both changes at typical config sizes). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
426 lines
20 KiB
Python
426 lines
20 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
###############################################################################
|
|
# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS <n0mjs@me.com>
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software Foundation,
|
|
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
###############################################################################
|
|
|
|
# This application bridges traffic between IPSC systems using rule files.
|
|
#
|
|
# bridge_rules.py defines IPSC network, timeslot, and TGID matching rules that
|
|
# determine which voice calls are bridged between systems.
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import signal
|
|
import sys
|
|
|
|
from binascii import b2a_hex as ahex
|
|
from importlib.util import spec_from_file_location, module_from_spec
|
|
from time import time
|
|
|
|
from dmr_utils3.utils import bytes_3, bytes_4, int_id
|
|
|
|
from config import acl_check, process_acls
|
|
from dmrlink import (IPSC, ReportServer, TS_CLEAR_TIME, build_aliases, config_reports,
|
|
mk_ipsc_systems, run_periodic, systems)
|
|
from const import (GV_BURST_TYPE_OFF, VOICE_HEAD, VOICE_TERM, SLOT1_VOICE, SLOT2_VOICE)
|
|
|
|
|
|
__author__ = 'Cortney T. Buffington, N0MJS'
|
|
__copyright__ = 'Copyright (c) 2016-2026 Cortney T. Buffington, N0MJS and the K0USY Group'
|
|
__credits__ = 'Adam Fast, KC0YLK; Dave Kierzkowski, KD8EYF; Steve Zingman, N4IRS; Mike Zingman, N4IRR'
|
|
__license__ = 'GNU GPLv3'
|
|
__maintainer__ = 'Cort Buffington, N0MJS'
|
|
__email__ = 'n0mjs@me.com'
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Bridge state; populated in async_main after rules file is loaded
|
|
BRIDGES = {}
|
|
TRUNKS = []
|
|
BRIDGE_CONF = {}
|
|
BRIDGE_SRC_INDEX = {} # (system, ts, tgid) -> [(bridge, member, all_members)]
|
|
BRIDGE_BY_SYSTEM = {} # system -> [(bridge, member)]
|
|
|
|
# Alias dicts; populated on startup in async_main (dashboard owns downloads/refresh)
|
|
peer_ids = {}
|
|
subscriber_ids = {}
|
|
talkgroup_ids = {}
|
|
local_ids = {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bridge rules loader
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_file_module(path, label):
|
|
"""Load a Python file by path and return it as a module object."""
|
|
spec = spec_from_file_location(label, path)
|
|
if spec is None:
|
|
return None
|
|
mod = module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
def make_bridge_config(_bridge_rules):
|
|
try:
|
|
bridge_file = _load_file_module(_bridge_rules, 'bridge_rules')
|
|
if bridge_file is None:
|
|
raise FileNotFoundError(_bridge_rules)
|
|
logger.info('Bridge configuration file found and imported')
|
|
except FileNotFoundError:
|
|
logger.critical('Bridge rules file "%s" not found', _bridge_rules)
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
logger.critical('Error loading bridge rules file "%s": %s', _bridge_rules, e, exc_info=True)
|
|
sys.exit(1)
|
|
|
|
for _bridge in bridge_file.BRIDGES:
|
|
for _system in bridge_file.BRIDGES[_bridge]:
|
|
if _system['SYSTEM'] not in CONFIG['SYSTEMS']:
|
|
sys.exit('ERROR: Conference bridge configured for system not found in main config')
|
|
|
|
_system['TGID'] = bytes_3(_system['TGID'])
|
|
_system['ON'] = frozenset(bytes_3(x) for x in _system['ON'])
|
|
_system['OFF'] = frozenset(bytes_3(x) for x in _system['OFF'])
|
|
_system['RESET'] = frozenset(bytes_3(x) for x in _system['RESET'])
|
|
_system['TIMEOUT'] = _system['TIMEOUT'] * 60
|
|
_system['TIMER'] = time() + _system['TIMEOUT']
|
|
|
|
return {
|
|
'BRIDGE_CONF': bridge_file.BRIDGE_CONF,
|
|
'BRIDGES': bridge_file.BRIDGES,
|
|
'TRUNKS': bridge_file.TRUNKS,
|
|
}
|
|
|
|
|
|
def index_bridges(_bridges):
|
|
src_index = {}
|
|
by_system = {}
|
|
for _bridge in _bridges:
|
|
_members = _bridges[_bridge]
|
|
for _member in _members:
|
|
src_index.setdefault((_member['SYSTEM'], _member['TS'], _member['TGID']), []).append(
|
|
(_bridge, _member, _members))
|
|
by_system.setdefault(_member['SYSTEM'], []).append((_bridge, _member))
|
|
return src_index, by_system
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rule timer loop (runs every 60 seconds)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def rule_timer_loop(_report=None):
|
|
logger.debug('(ALL IPSC SYSTEMS) Rule timer loop')
|
|
_now = time()
|
|
_changed = False
|
|
|
|
for _bridge in BRIDGES:
|
|
for _system in BRIDGES[_bridge]:
|
|
if _system['TO_TYPE'] == 'ON':
|
|
if _system['ACTIVE']:
|
|
if _system['TIMER'] < _now:
|
|
_system['ACTIVE'] = False
|
|
_changed = True
|
|
logger.info('Bridge TIMEOUT: DEACTIVATE System: %s, Bridge: %s, TS: %s, TGID: %s',
|
|
_system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID']))
|
|
else:
|
|
logger.info('Bridge ACTIVE (ON timer): System: %s Bridge: %s TS: %s TGID: %s Timeout in: %.0fs',
|
|
_system['SYSTEM'], _bridge, _system['TS'],
|
|
int_id(_system['TGID']), _system['TIMER'] - _now)
|
|
else:
|
|
logger.debug('Bridge INACTIVE (no change): System: %s Bridge: %s TS: %s TGID: %s',
|
|
_system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID']))
|
|
elif _system['TO_TYPE'] == 'OFF':
|
|
if not _system['ACTIVE']:
|
|
if _system['TIMER'] < _now:
|
|
_system['ACTIVE'] = True
|
|
_changed = True
|
|
logger.info('Bridge TIMEOUT: ACTIVATE System: %s, Bridge: %s, TS: %s, TGID: %s',
|
|
_system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID']))
|
|
else:
|
|
logger.info('Bridge INACTIVE (OFF timer): System: %s Bridge: %s TS: %s TGID: %s Timeout in: %.0fs',
|
|
_system['SYSTEM'], _bridge, _system['TS'],
|
|
int_id(_system['TGID']), _system['TIMER'] - _now)
|
|
else:
|
|
logger.debug('Bridge ACTIVE (no change): System: %s Bridge: %s TS: %s TGID: %s',
|
|
_system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID']))
|
|
else:
|
|
logger.debug('Bridge NO ACTION: System: %s, Bridge: %s, TS: %s, TGID: %s',
|
|
_system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID']))
|
|
|
|
if _changed and _report:
|
|
_report.send_bridge()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Report server with bridge support
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class BridgeReportServer(ReportServer):
|
|
|
|
def send_bridge(self):
|
|
bridge_data = {}
|
|
for name, bridge in BRIDGES.items():
|
|
bridge_data[name] = []
|
|
for entry in bridge:
|
|
bridge_data[name].append({
|
|
'SYSTEM': entry['SYSTEM'],
|
|
'TS': entry['TS'],
|
|
'TGID': int_id(entry['TGID']),
|
|
'ACTIVE': entry['ACTIVE'],
|
|
'TO_TYPE': entry['TO_TYPE'],
|
|
'TIMEOUT': entry['TIMEOUT'],
|
|
'TIMER': entry['TIMER'],
|
|
'ON': [int_id(t) for t in entry['ON']],
|
|
'OFF': [int_id(t) for t in entry['OFF']],
|
|
'RESET': [int_id(t) for t in entry['RESET']],
|
|
})
|
|
self._send_json({'type': 'bridge', 'bridges': bridge_data})
|
|
|
|
def send_bridge_event(self, _event):
|
|
self._send_json({'type': 'bridge_event', 'data': _event})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bridge IPSC subclass
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class bridgeIPSC(IPSC):
|
|
def __init__(self, _name, _config, _report):
|
|
IPSC.__init__(self, _name, _config, _report)
|
|
|
|
self.STATUS = {
|
|
1: {'RX_TGID': b'\x00\x00\x00', 'TX_TGID': b'\x00\x00\x00',
|
|
'RX_TIME': 0, 'TX_TIME': 0,
|
|
'RX_SRC_SUB': b'\x00\x00\x00', 'TX_SRC_SUB': b'\x00\x00\x00'},
|
|
2: {'RX_TGID': b'\x00\x00\x00', 'TX_TGID': b'\x00\x00\x00',
|
|
'RX_TIME': 0, 'TX_TIME': 0,
|
|
'RX_SRC_SUB': b'\x00\x00\x00', 'TX_SRC_SUB': b'\x00\x00\x00'},
|
|
}
|
|
|
|
|
|
def group_voice(self, _src_sub, _dst_group, _ts, _end, _peerid, _data):
|
|
if not acl_check(_src_sub, self._CONFIG['GLOBAL']['SUB_ACL']):
|
|
logger.warning('(%s) Group Voice ***REJECTED BY ACL*** From: %s, Peer %s, Dst %s',
|
|
self._system, int_id(_src_sub), int_id(_peerid), int_id(_dst_group))
|
|
return
|
|
super().group_voice(_src_sub, _dst_group, _ts, _end, _peerid, _data)
|
|
|
|
_burst_data_type = _data[GV_BURST_TYPE_OFF] # int; use VOICE_HEAD / SLOT1_VOICE etc.
|
|
now = time()
|
|
|
|
# Both ON and OFF triggers fire on key-down (VOICE_HEAD) so the bridge state is
|
|
# current before the VOICE_HEAD itself is forwarded and before any unkey delay.
|
|
if _burst_data_type == VOICE_HEAD:
|
|
_bridge_changed = False
|
|
for _bridge, _system in BRIDGE_BY_SYSTEM.get(self._system, ()):
|
|
if _ts != _system['TS']:
|
|
continue
|
|
if _dst_group in _system['ON'] or _dst_group in _system['RESET']:
|
|
if _dst_group in _system['ON'] and not _system['ACTIVE']:
|
|
_system['ACTIVE'] = True
|
|
_bridge_changed = True
|
|
logger.info('(%s) Bridge: %s activated', self._system, _bridge)
|
|
if _system['TO_TYPE'] == 'OFF':
|
|
_system['TIMER'] = now
|
|
logger.info('(%s) Bridge: %s OFF-timer cancelled (activated by ON trigger)', self._system, _bridge)
|
|
if _system['ACTIVE'] and _system['TO_TYPE'] == 'ON':
|
|
_system['TIMER'] = now + _system['TIMEOUT']
|
|
logger.info('(%s) Bridge: %s ON-timer reset to %.0fs', self._system, _bridge, _system['TIMEOUT'])
|
|
if _dst_group in _system['OFF'] or _dst_group in _system['RESET']:
|
|
if _dst_group in _system['OFF'] and _system['ACTIVE']:
|
|
_system['ACTIVE'] = False
|
|
_bridge_changed = True
|
|
logger.info('(%s) Bridge: %s deactivated', self._system, _bridge)
|
|
if _system['TO_TYPE'] == 'ON':
|
|
_system['TIMER'] = now
|
|
logger.info('(%s) Bridge: %s ON-timer cancelled (deactivated by OFF trigger)', self._system, _bridge)
|
|
if not _system['ACTIVE'] and _system['TO_TYPE'] == 'OFF':
|
|
_system['TIMER'] = now + _system['TIMEOUT']
|
|
logger.info('(%s) Bridge: %s OFF-timer reset to %.0fs', self._system, _bridge, _system['TIMEOUT'])
|
|
if _bridge_changed and self._report:
|
|
self._report.send_bridge()
|
|
|
|
for _bridge, _system, _members in BRIDGE_SRC_INDEX.get((self._system, _ts, _dst_group), ()):
|
|
if not _system['ACTIVE']:
|
|
continue
|
|
|
|
for _target in _members:
|
|
if _target['SYSTEM'] == self._system:
|
|
continue
|
|
if not _target['ACTIVE']:
|
|
continue
|
|
|
|
_target_status = systems[_target['SYSTEM']].STATUS
|
|
_target_system = self._CONFIG['SYSTEMS'][_target['SYSTEM']]
|
|
|
|
# BEGIN CONTENTION HANDLING
|
|
if _target['SYSTEM'] not in TRUNKS:
|
|
if ((_target['TGID'] != _target_status[_target['TS']]['RX_TGID']) and
|
|
((now - _target_status[_target['TS']]['RX_TIME']) < _target_system['LOCAL']['GROUP_HANGTIME'])):
|
|
if _burst_data_type == VOICE_HEAD:
|
|
logger.info('(%s) Call not bridged to TGID %s, target in RX group hangtime: %s TS: %s TGID: %s',
|
|
self._system, int_id(_target['TGID']),
|
|
_target['SYSTEM'], _target['TS'],
|
|
int_id(_target_status[_target['TS']]['RX_TGID']))
|
|
continue
|
|
if ((_target['TGID'] != _target_status[_target['TS']]['TX_TGID']) and
|
|
((now - _target_status[_target['TS']]['TX_TIME']) < _target_system['LOCAL']['GROUP_HANGTIME'])):
|
|
if _burst_data_type == VOICE_HEAD:
|
|
logger.info('(%s) Call not bridged to TGID %s, target in TX group hangtime: %s TS: %s TGID: %s',
|
|
self._system, int_id(_target['TGID']),
|
|
_target['SYSTEM'], _target['TS'],
|
|
int_id(_target_status[_target['TS']]['TX_TGID']))
|
|
continue
|
|
if ((_target['TGID'] == _target_status[_target['TS']]['RX_TGID']) and
|
|
((now - _target_status[_target['TS']]['RX_TIME']) < TS_CLEAR_TIME)):
|
|
if _burst_data_type == VOICE_HEAD:
|
|
logger.info('(%s) Call not bridged to TGID %s, matching call active on target: %s TS: %s TGID: %s',
|
|
self._system, int_id(_target['TGID']),
|
|
_target['SYSTEM'], _target['TS'],
|
|
int_id(_target_status[_target['TS']]['RX_TGID']))
|
|
continue
|
|
if ((_target['TGID'] == _target_status[_target['TS']]['TX_TGID']) and
|
|
(_src_sub != _target_status[_target['TS']]['TX_SRC_SUB']) and
|
|
((now - _target_status[_target['TS']]['TX_TIME']) < TS_CLEAR_TIME)):
|
|
if _burst_data_type == VOICE_HEAD:
|
|
logger.info('(%s) Call not bridged for sub %s, bridge in progress on target: %s TS: %s TGID: %s SUB: %s',
|
|
self._system, int_id(_src_sub),
|
|
_target['SYSTEM'], _target['TS'],
|
|
int_id(_target_status[_target['TS']]['TX_TGID']),
|
|
int_id(_target_status[_target['TS']]['TX_SRC_SUB']))
|
|
continue
|
|
# END CONTENTION HANDLING
|
|
|
|
# BEGIN FRAME FORWARDING
|
|
_tmp_data = _data
|
|
|
|
# Rewrite Peer ID
|
|
_tmp_data = _tmp_data.replace(_peerid, _target_system['LOCAL']['RADIO_ID'], 1)
|
|
|
|
# Rewrite IPSC SRC + DST GROUP
|
|
_tmp_data = _tmp_data.replace(_src_sub + _dst_group, _src_sub + _target['TGID'], 1)
|
|
|
|
# Rewrite DST GROUP + IPSC SRC in DMR LC
|
|
_tmp_data = _tmp_data.replace(_dst_group + _src_sub, _target['TGID'] + _src_sub, 1)
|
|
|
|
# Rewrite IPSC timeslot byte
|
|
_call_info = int_id(_data[17:18])
|
|
if _target['TS'] == 1:
|
|
_call_info &= ~(1 << 5)
|
|
elif _target['TS'] == 2:
|
|
_call_info |= 1 << 5
|
|
_tmp_data = _tmp_data[:17] + bytes([_call_info]) + _tmp_data[18:]
|
|
|
|
# Rewrite DMR timeslot in burst data
|
|
if _burst_data_type in (SLOT1_VOICE, SLOT2_VOICE):
|
|
_new_burst = SLOT1_VOICE if _target['TS'] == 1 else SLOT2_VOICE
|
|
_tmp_data = _tmp_data[:30] + bytes([_new_burst]) + _tmp_data[31:]
|
|
|
|
systems[_target['SYSTEM']].transmit_group_voice(
|
|
_src_sub, _target['TGID'], _target['TS'], _burst_data_type, _tmp_data, _peerid)
|
|
# END FRAME FORWARDING
|
|
|
|
_target_status[_target['TS']]['TX_TGID'] = _target['TGID']
|
|
_target_status[_target['TS']]['TX_TIME'] = now
|
|
_target_status[_target['TS']]['TX_SRC_SUB'] = _src_sub
|
|
|
|
# Record RX state for contention handler
|
|
self.STATUS[_ts]['RX_TGID'] = _dst_group
|
|
self.STATUS[_ts]['RX_TIME'] = now
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# __main__
|
|
# ---------------------------------------------------------------------------
|
|
|
|
if __name__ == '__main__':
|
|
import argparse
|
|
import os
|
|
|
|
from config import build_config
|
|
from log import config_logging
|
|
|
|
os.chdir(os.path.dirname(os.path.realpath(sys.argv[0])))
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('-c', '--config', action='store', dest='CFG_FILE',
|
|
help='/full/path/to/dmrlink.cfg')
|
|
parser.add_argument('-b', '--bridge-rules', action='store', dest='BRIDGE_RULES',
|
|
default='bridge_rules.py',
|
|
help='path to bridge rules file (default: bridge_rules.py)')
|
|
parser.add_argument('-ll', '--log_level', action='store', dest='LOG_LEVEL',
|
|
help='Override config file log level')
|
|
parser.add_argument('-lh', '--log_handle', action='store', dest='LOG_HANDLERS',
|
|
help='Override config file log handlers')
|
|
cli_args = parser.parse_args()
|
|
|
|
if not cli_args.CFG_FILE:
|
|
cli_args.CFG_FILE = os.path.dirname(os.path.abspath(__file__)) + '/dmrlink.cfg'
|
|
|
|
CONFIG = build_config(cli_args.CFG_FILE)
|
|
|
|
if cli_args.LOG_LEVEL:
|
|
CONFIG['LOGGER']['LOG_LEVEL'] = cli_args.LOG_LEVEL
|
|
if cli_args.LOG_HANDLERS:
|
|
CONFIG['LOGGER']['LOG_HANDLERS'] = cli_args.LOG_HANDLERS
|
|
|
|
config_logging(CONFIG['LOGGER'])
|
|
logger.info("DMRlink3 'bridge.py' (c) 2016-2026 N0MJS & the K0USY Group — SYSTEM STARTING...")
|
|
|
|
async def async_main():
|
|
global BRIDGES, TRUNKS, BRIDGE_CONF
|
|
|
|
loop = asyncio.get_running_loop()
|
|
stop_event = asyncio.Event()
|
|
|
|
def sig_handler(sig):
|
|
logger.info('*** DMRlink3 bridge.py TERMINATING WITH SIGNAL %s ***', signal.Signals(sig).name)
|
|
for system in systems:
|
|
systems[system].de_register_self()
|
|
stop_event.set()
|
|
|
|
for sig in [signal.SIGTERM, signal.SIGINT, signal.SIGQUIT]:
|
|
loop.add_signal_handler(sig, sig_handler, sig)
|
|
|
|
report_server = config_reports(CONFIG, BridgeReportServer)
|
|
|
|
global peer_ids, subscriber_ids, talkgroup_ids, local_ids
|
|
peer_ids, subscriber_ids, talkgroup_ids, local_ids = build_aliases(CONFIG)
|
|
|
|
await mk_ipsc_systems(CONFIG, systems, bridgeIPSC, report_server)
|
|
|
|
process_acls(CONFIG)
|
|
|
|
CONFIG_DICT = make_bridge_config(cli_args.BRIDGE_RULES)
|
|
BRIDGE_CONF = CONFIG_DICT['BRIDGE_CONF']
|
|
TRUNKS = CONFIG_DICT['TRUNKS']
|
|
BRIDGES = CONFIG_DICT['BRIDGES']
|
|
|
|
global BRIDGE_SRC_INDEX, BRIDGE_BY_SYSTEM
|
|
BRIDGE_SRC_INDEX, BRIDGE_BY_SYSTEM = index_bridges(BRIDGES)
|
|
|
|
loop.create_task(run_periodic(60, rule_timer_loop, 'rule_timer', report_server))
|
|
|
|
await stop_event.wait()
|
|
|
|
asyncio.run(async_main())
|