commit ce191c2b346f6093624baf4c3bdf0d10151dd0ed Author: Cort Buffington Date: Fri Jun 26 12:41:31 2026 -0500 Initial port: Python 3 / asyncio reimplementation of dmrlink Full port of dmrlink (Python 2 / Twisted) to Python 3 with asyncio, aligned with the HBlink3 2026_cleanup branch style. - const.py: merged ipsc_const + ipsc_mask, all bytes literals corrected for Py3 - log.py: root-logger config matching HBlink3 log.py - config.py: configparser Py3 port; bytes built with bytes([]) / bytes.fromhex() - dmrlink.py: full asyncio IPSC protocol engine; ReportServer (NDJSON/TCP) replaces Twisted DatagramProtocol, LoopingCall, NetstringReceiver, and pickle - bridge.py: asyncio port of confbridge.py, renamed to match HBlink3 convention; BridgeReportServer adds NDJSON bridge/call-event streaming - dashboard/: FastAPI + WebSocket dashboard adapted for IPSC data shapes (MASTER/PEER mode, PEERS dict, bridge_event call log) - requirements.txt, pyproject.toml: package metadata Co-Authored-By: Claude Sonnet 4.6 diff --git a/bridge.py b/bridge.py new file mode 100644 index 0000000..1778b25 --- /dev/null +++ b/bridge.py @@ -0,0 +1,483 @@ +#!/usr/bin/env python +# +############################################################################### +# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS +# +# 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. +# +# The optional sub_acl.py defines subscriber ACL rules (PERMIT/DENY lists). + +import asyncio +import json +import logging +import signal +import sys + +from binascii import b2a_hex as ahex +from importlib import import_module +from time import time + +from dmr_utils3.utils import bytes_3, bytes_4, int_id + +from dmrlink import (IPSC, ReportServer, build_aliases, config_reports, + mk_ipsc_systems, run_periodic, systems) +from const import BURST_DATA_TYPE + + +__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__) + +# Minimum time between different subscribers transmitting on the same TGID +TS_CLEAR_TIME = .2 + +# Bridge state; populated in async_main after rules file is loaded +BRIDGES = {} +TRUNKS = [] +BRIDGE_CONF = {} + + +# --------------------------------------------------------------------------- +# Bridge rules loader +# --------------------------------------------------------------------------- + +def make_bridge_config(_bridge_rules): + try: + bridge_file = import_module(_bridge_rules) + logger.info('Bridge configuration file found and imported') + except ImportError: + sys.exit('Bridge configuration file not found or invalid') + + 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']) + for i, e in enumerate(_system['ON']): + _system['ON'][i] = bytes_3(_system['ON'][i]) + for i, e in enumerate(_system['OFF']): + _system['OFF'][i] = bytes_3(_system['OFF'][i]) + for i, e in enumerate(_system['RESET']): + _system['RESET'][i] = bytes_3(_system['RESET'][i]) + _system['TIMEOUT'] = _system['TIMEOUT'] * 60 + _system['TIMER'] = time() + + return { + 'BRIDGE_CONF': bridge_file.BRIDGE_CONF, + 'BRIDGES': bridge_file.BRIDGES, + 'TRUNKS': bridge_file.TRUNKS, + } + + +# --------------------------------------------------------------------------- +# Subscriber ACL +# --------------------------------------------------------------------------- + +def build_acl(_sub_acl): + ACL = set() + try: + logger.info('ACL file found, importing entries. This will take about 1.5 seconds per 1 million IDs') + acl_file = import_module(_sub_acl) + sections = acl_file.ACL.split(':') + ACL_ACTION = sections[0] + for entry in sections[1].split(','): + if '-' in entry: + start, end = entry.split('-') + for rid in range(int(start), int(end) + 1): + ACL.add(bytes_3(rid)) + else: + ACL.add(bytes_3(int(entry))) + logger.info('ACL loaded: action "%s" for %s radio IDs', ACL_ACTION, len(ACL)) + except ImportError: + logger.info('ACL file not found or invalid - all subscriber IDs are valid') + ACL_ACTION = 'NONE' + + global allow_sub + if ACL_ACTION == 'PERMIT': + def allow_sub(_sub): + return _sub in ACL + elif ACL_ACTION == 'DENY': + def allow_sub(_sub): + return _sub not in ACL + else: + def allow_sub(_sub): + return True + + return ACL + + +# --------------------------------------------------------------------------- +# Rule timer loop (runs every 60 seconds) +# --------------------------------------------------------------------------- + +def rule_timer_loop(): + logger.info('(ALL IPSC SYSTEMS) Rule timer loop') + _now = time() + + for _bridge in BRIDGES: + for _system in BRIDGES[_bridge]: + if _system['TO_TYPE'] == 'ON': + if _system['ACTIVE']: + if _system['TIMER'] < _now: + _system['ACTIVE'] = False + 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 + 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'])) + + +# --------------------------------------------------------------------------- +# 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'], + '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'}, + } + + self.last_seq_id = b'\x00' + self.call_start = 0 + + def group_voice(self, _src_sub, _dst_group, _ts, _end, _peerid, _data): + if not allow_sub(_src_sub): + 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 + + _burst_data_type = _data[30:31] + _seq_id = _data[5:6] + now = time() + + for _bridge in BRIDGES: + for _system in BRIDGES[_bridge]: + if (_system['SYSTEM'] == self._system and + _system['TGID'] == _dst_group and + _system['TS'] == _ts and + _system['ACTIVE']): + + for _target in BRIDGES[_bridge]: + 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 == 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 == 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 == 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 == 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 == BURST_DATA_TYPE['SLOT1_VOICE'] or + _burst_data_type == BURST_DATA_TYPE['SLOT2_VOICE']): + if _target['TS'] == 1: + _burst_data_type = BURST_DATA_TYPE['SLOT1_VOICE'] + elif _target['TS'] == 2: + _burst_data_type = BURST_DATA_TYPE['SLOT2_VOICE'] + _tmp_data = _tmp_data[:30] + _burst_data_type + _tmp_data[31:] + + systems[_target['SYSTEM']].send_to_ipsc(_tmp_data) + # 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 + + # BEGIN IN-BAND SIGNALING + if _burst_data_type == BURST_DATA_TYPE['VOICE_HEAD']: + if self.last_seq_id != _seq_id or (self.call_start + TS_CLEAR_TIME) < now: + self.last_seq_id = _seq_id + self.call_start = now + logger.info('(%s) GROUP VOICE START: CallID: %s Peer: %s, Sub: %s, TS: %s, TGID: %s', + self._system, int_id(_seq_id), int_id(_peerid), + int_id(_src_sub), _ts, int_id(_dst_group)) + if self._CONFIG['REPORTS']['REPORT_NETWORKS'] == 'NETWORK' and self._report: + self._report.send_bridge_event({ + 'event': 'GROUP VOICE START', + 'system': self._system, + 'call_id': int_id(_seq_id), + 'peer': int_id(_peerid), + 'src': int_id(_src_sub), + 'ts': _ts, + 'tgid': int_id(_dst_group), + }) + + if _burst_data_type == BURST_DATA_TYPE['VOICE_TERM']: + if self.last_seq_id == _seq_id: + call_duration = now - self.call_start + logger.info('(%s) GROUP VOICE END: CallID: %s Peer: %s, Sub: %s, TS: %s, TGID: %s Duration: %.2fs', + self._system, int_id(_seq_id), int_id(_peerid), + int_id(_src_sub), _ts, int_id(_dst_group), call_duration) + if self._CONFIG['REPORTS']['REPORT_NETWORKS'] == 'NETWORK' and self._report: + self._report.send_bridge_event({ + 'event': 'GROUP VOICE END', + 'system': self._system, + 'call_id': int_id(_seq_id), + 'peer': int_id(_peerid), + 'src': int_id(_src_sub), + 'ts': _ts, + 'tgid': int_id(_dst_group), + 'duration': round(call_duration, 2), + }) + else: + logger.warning('(%s) GROUP VOICE END WITHOUT MATCHING START: CallID: %s Peer: %s, Sub: %s, TS: %s, TGID: %s', + self._system, int_id(_seq_id), int_id(_peerid), + int_id(_src_sub), _ts, int_id(_dst_group)) + if self._CONFIG['REPORTS']['REPORT_NETWORKS'] == 'NETWORK' and self._report: + self._report.send_bridge_event({ + 'event': 'GROUP VOICE UNMATCHED END', + 'system': self._system, + 'call_id': int_id(_seq_id), + 'peer': int_id(_peerid), + 'src': int_id(_src_sub), + 'ts': _ts, + 'tgid': int_id(_dst_group), + }) + + # Iterate rules for in-band activation/deactivation + for _bridge in BRIDGES: + for _system in BRIDGES[_bridge]: + if _system['SYSTEM'] != self._system: + continue + + if (_dst_group in _system['ON'] or _dst_group in _system['RESET']) and _ts == _system['TS']: + if _dst_group in _system['ON']: + if not _system['ACTIVE']: + _system['ACTIVE'] = 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']) and _ts == _system['TS']: + if _dst_group in _system['OFF']: + if _system['ACTIVE']: + _system['ACTIVE'] = False + 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 _system['ACTIVE'] and _system['TO_TYPE'] == 'ON' and _dst_group in _system['OFF']: + _system['TIMER'] = now + logger.info('(%s) Bridge: %s ON-timer cancelled (OFF trigger while active)', self._system, _bridge) + # END IN-BAND SIGNALING + + +# --------------------------------------------------------------------------- +# __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', + help='bridge rules module name (default: bridge_rules)') + parser.add_argument('-s', '--sub-acl', action='store', dest='SUB_ACL', + default='sub_acl', + help='subscriber ACL module name (default: sub_acl)') + 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) + build_aliases(CONFIG) + await mk_ipsc_systems(CONFIG, systems, bridgeIPSC, report_server) + + CONFIG_DICT = make_bridge_config(cli_args.BRIDGE_RULES) + BRIDGE_CONF = CONFIG_DICT['BRIDGE_CONF'] + TRUNKS = CONFIG_DICT['TRUNKS'] + BRIDGES = CONFIG_DICT['BRIDGES'] + + build_acl(cli_args.SUB_ACL) + + loop.create_task(run_periodic(60, rule_timer_loop, 'rule_timer')) + + await stop_event.wait() + + asyncio.run(async_main()) diff --git a/bridge_rules_SAMPLE.py b/bridge_rules_SAMPLE.py new file mode 100644 index 0000000..faaa663 --- /dev/null +++ b/bridge_rules_SAMPLE.py @@ -0,0 +1,73 @@ +''' +DMRlink3 bridge rules — SAMPLE FILE +==================================== +THIS FILE WILL NOT WORK AS-IS. You must substitute your own system names, +talkgroup IDs, and timeslot assignments. + +Rename this file to bridge_rules.py (or whatever you pass to -b/--bridge-rules +on the bridge.py command line). + +BRIDGE STRUCTURE +---------------- +Bridges are organized by name (e.g. 'WORLDWIDE', 'STATEWIDE'). Think of each +bridge as a conference room: any system that is ACTIVE on a bridge will receive +all voice traffic arriving on that bridge from any other active system. + +Each system entry under a bridge has the following keys: + + SYSTEM - Name matching a section in dmrlink.cfg (case-sensitive) + TS - Timeslot to match (1 or 2) + TGID - Integer talkgroup ID to match on that timeslot + ACTIVE - Initial state: True = bridging active, False = bridging off + TIMEOUT - Timer duration in MINUTES (used with TO_TYPE ON or OFF) + TO_TYPE - Timer behavior: + 'ON' - When activated, deactivate after TIMEOUT minutes + 'OFF' - When deactivated, reactivate after TIMEOUT minutes + 'NONE' - No timer; state only changes via ON/OFF triggers + ON - List of TGIDs that activate this entry (PTT on one of these + talkgroups keys the bridge on) + OFF - List of TGIDs that deactivate this entry + RESET - List of TGIDs that reset a running ON-type timer without + changing active state. Useful when voice traffic arrives on a + different TGID than the trigger. + +TRUNKS +------ +TRUNKS is a list of system names that bypass contention handling. All traffic +is always forwarded to a trunk system without checking group-hangtime or +timeslot-clear-time rules. Leave as [] if not needed. + +BRIDGE_CONF +----------- +Global options for bridge.py. Currently unused beyond the REPORT key (which +is vestigial from the dmrlink pickle-reporting era and can be left as-is). +''' + +# Global bridge.py options +BRIDGE_CONF = { + 'REPORT': True, +} + +# Trunk systems — contention handling bypassed for these +TRUNKS = [] + +# Bridge definitions +BRIDGES = { + 'WORLDWIDE': [ + {'SYSTEM': 'MASTER-1', 'TS': 1, 'TGID': 91, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'ON', 'ON': [91], 'OFF': [9, 10], 'RESET': []}, + {'SYSTEM': 'CLIENT-1', 'TS': 1, 'TGID': 91, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'ON', 'ON': [91], 'OFF': [9, 10], 'RESET': []}, + ], + 'NATIONWIDE': [ + {'SYSTEM': 'MASTER-1', 'TS': 1, 'TGID': 3100, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [3,], 'OFF': [8, 10], 'RESET': []}, + {'SYSTEM': 'CLIENT-2', 'TS': 1, 'TGID': 3100, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [3,], 'OFF': [8, 10], 'RESET': []}, + ], + 'STATEWIDE': [ + {'SYSTEM': 'MASTER-1', 'TS': 2, 'TGID': 3129, 'ACTIVE': False, 'TIMEOUT': 5, 'TO_TYPE': 'NONE', 'ON': [4,], 'OFF': [7, 10], 'RESET': []}, + {'SYSTEM': 'CLIENT-2', 'TS': 2, 'TGID': 3129, 'ACTIVE': False, 'TIMEOUT': 5, 'TO_TYPE': 'NONE', 'ON': [4,], 'OFF': [7, 10], 'RESET': []}, + ], +} + + +if __name__ == '__main__': + from pprint import pprint + pprint(BRIDGES) diff --git a/config.py b/config.py new file mode 100644 index 0000000..f944eac --- /dev/null +++ b/config.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python +# +############################################################################### +# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS +# +# 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 +############################################################################### + +import configparser +import sys +from socket import getaddrinfo, IPPROTO_UDP + +__author__ = 'Cortney T. Buffington, N0MJS' +__copyright__ = 'Copyright (c) 2016-2026 Cortney T. Buffington, N0MJS and the K0USY Group' +__license__ = 'GNU GPLv3' +__maintainer__ = 'Cort Buffington, N0MJS' +__email__ = 'n0mjs@me.com' + + +def get_address(_host): + ipv4 = ipv6 = '' + for item in getaddrinfo(_host, None, 0, 0, IPPROTO_UDP): + if item[0] == 2: + ipv4 = item[4][0] + elif item[0] == 30: + ipv6 = item[4][0] + if ipv4: + return ipv4 + if ipv6: + return ipv6 + return 'invalid address' + + +def build_config(_config_file): + config = configparser.ConfigParser() + if not config.read(_config_file): + sys.exit('Configuration file \'' + _config_file + '\' is not a valid configuration file! Exiting...') + + CONFIG = { + 'GLOBAL': {}, + 'REPORTS': {}, + 'LOGGER': {}, + 'ALIASES': {}, + 'SYSTEMS': {}, + } + + try: + for section in config.sections(): + if section == 'GLOBAL': + CONFIG['GLOBAL'].update({ + 'PATH': config.get(section, 'PATH'), + }) + + elif section == 'REPORTS': + CONFIG['REPORTS'].update({ + 'REPORT_NETWORKS': config.get(section, 'REPORT_NETWORKS').strip(), + 'REPORT_RCM': config.get(section, 'REPORT_RCM').strip().lower() in ('true', 'yes', '1', 'on'), + 'REPORT_INTERVAL': config.getint(section, 'REPORT_INTERVAL'), + 'REPORT_PORT': config.getint(section, 'REPORT_PORT'), + 'REPORT_CLIENTS': [c.strip() for c in config.get(section, 'REPORT_CLIENTS').split(',')], + 'PRINT_PEERS_INC_MODE': config.getboolean(section, 'PRINT_PEERS_INC_MODE'), + 'PRINT_PEERS_INC_FLAGS': config.getboolean(section, 'PRINT_PEERS_INC_FLAGS'), + }) + + elif section == 'LOGGER': + CONFIG['LOGGER'].update({ + 'LOG_FILE': config.get(section, 'LOG_FILE'), + 'LOG_HANDLERS': config.get(section, 'LOG_HANDLERS'), + 'LOG_LEVEL': config.get(section, 'LOG_LEVEL'), + 'LOG_NAME': config.get(section, 'LOG_NAME'), + }) + + elif section == 'ALIASES': + CONFIG['ALIASES'].update({ + 'TRY_DOWNLOAD': config.getboolean(section, 'TRY_DOWNLOAD'), + 'PATH': config.get(section, 'PATH'), + 'PEER_FILE': config.get(section, 'PEER_FILE'), + 'SUBSCRIBER_FILE': config.get(section, 'SUBSCRIBER_FILE'), + 'TGID_FILE': config.get(section, 'TGID_FILE'), + 'LOCAL_FILE': config.get(section, 'LOCAL_FILE'), + 'PEER_URL': config.get(section, 'PEER_URL'), + 'SUBSCRIBER_URL': config.get(section, 'SUBSCRIBER_URL'), + 'STALE_TIME': config.getint(section, 'STALE_DAYS') * 86400, + }) + + elif config.getboolean(section, 'ENABLED'): + CONFIG['SYSTEMS'][section] = {'LOCAL': {}, 'MASTER': {}, 'PEERS': {}} + + CONFIG['SYSTEMS'][section]['LOCAL'].update({ + 'ENABLED': True, + 'PEER_OPER': config.getboolean(section, 'PEER_OPER'), + 'IPSC_MODE': config.get(section, 'IPSC_MODE'), + 'TS1_LINK': config.getboolean(section, 'TS1_LINK'), + 'TS2_LINK': config.getboolean(section, 'TS2_LINK'), + 'MODE': b'', + 'AUTH_ENABLED': config.getboolean(section, 'AUTH_ENABLED'), + 'CSBK_CALL': config.getboolean(section, 'CSBK_CALL'), + 'RCM': config.getboolean(section, 'RCM'), + 'CON_APP': config.getboolean(section, 'CON_APP'), + 'XNL_CALL': config.getboolean(section, 'XNL_CALL'), + 'XNL_MASTER': config.getboolean(section, 'XNL_MASTER'), + 'DATA_CALL': config.getboolean(section, 'DATA_CALL'), + 'VOICE_CALL': config.getboolean(section, 'VOICE_CALL'), + 'MASTER_PEER': config.getboolean(section, 'MASTER_PEER'), + 'FLAGS': b'', + 'RADIO_ID': bytes.fromhex(format(int(config.get(section, 'RADIO_ID')), '08x')), + 'IP': config.get(section, 'IP').strip(), + 'PORT': config.getint(section, 'PORT'), + 'ALIVE_TIMER': config.getint(section, 'ALIVE_TIMER'), + 'MAX_MISSED': config.getint(section, 'MAX_MISSED'), + 'AUTH_KEY': bytes.fromhex(config.get(section, 'AUTH_KEY').rjust(40, '0')), + 'GROUP_HANGTIME': config.getint(section, 'GROUP_HANGTIME'), + 'NUM_PEERS': 0, + }) + + CONFIG['SYSTEMS'][section]['MASTER'].update({ + 'RADIO_ID': b'\x00\x00\x00\x00', + 'MODE': b'\x00', + 'MODE_DECODE': '', + 'FLAGS': b'\x00\x00\x00\x00', + 'FLAGS_DECODE': '', + 'STATUS': { + 'CONNECTED': False, + 'PEER_LIST': False, + 'KEEP_ALIVES_SENT': 0, + 'KEEP_ALIVES_MISSED': 0, + 'KEEP_ALIVES_OUTSTANDING': 0, + 'KEEP_ALIVES_RECEIVED': 0, + 'KEEP_ALIVE_RX_TIME': 0, + }, + 'IP': '', + 'PORT': '', + }) + + if not CONFIG['SYSTEMS'][section]['LOCAL']['MASTER_PEER']: + CONFIG['SYSTEMS'][section]['MASTER'].update({ + 'IP': get_address(config.get(section, 'MASTER_IP')), + 'PORT': config.getint(section, 'MASTER_PORT'), + }) + + # Build the MODE byte + MODE_BYTE = 0 + if CONFIG['SYSTEMS'][section]['LOCAL']['PEER_OPER']: + MODE_BYTE |= 1 << 6 + if CONFIG['SYSTEMS'][section]['LOCAL']['IPSC_MODE'] == 'ANALOG': + MODE_BYTE |= 1 << 4 + elif CONFIG['SYSTEMS'][section]['LOCAL']['IPSC_MODE'] == 'DIGITAL': + MODE_BYTE |= 1 << 5 + if CONFIG['SYSTEMS'][section]['LOCAL']['TS1_LINK']: + MODE_BYTE |= 1 << 3 + else: + MODE_BYTE |= 1 << 2 + if CONFIG['SYSTEMS'][section]['LOCAL']['TS2_LINK']: + MODE_BYTE |= 1 << 1 + else: + MODE_BYTE |= 1 << 0 + CONFIG['SYSTEMS'][section]['LOCAL']['MODE'] = bytes([MODE_BYTE]) + + # Build the FLAGS field (4 bytes: 0x00, 0x00, FLAG_1, FLAG_2) + FLAG_1 = FLAG_2 = 0 + if CONFIG['SYSTEMS'][section]['LOCAL']['CSBK_CALL']: + FLAG_1 |= 1 << 7 + if CONFIG['SYSTEMS'][section]['LOCAL']['RCM']: + FLAG_1 |= 1 << 6 + if CONFIG['SYSTEMS'][section]['LOCAL']['CON_APP']: + FLAG_1 |= 1 << 5 + if CONFIG['SYSTEMS'][section]['LOCAL']['XNL_CALL']: + FLAG_2 |= 1 << 7 + if CONFIG['SYSTEMS'][section]['LOCAL']['XNL_CALL'] and CONFIG['SYSTEMS'][section]['LOCAL']['XNL_MASTER']: + FLAG_2 |= 1 << 6 + elif CONFIG['SYSTEMS'][section]['LOCAL']['XNL_CALL'] and not CONFIG['SYSTEMS'][section]['LOCAL']['XNL_MASTER']: + FLAG_2 |= 1 << 5 + if CONFIG['SYSTEMS'][section]['LOCAL']['AUTH_ENABLED']: + FLAG_2 |= 1 << 4 + if CONFIG['SYSTEMS'][section]['LOCAL']['DATA_CALL']: + FLAG_2 |= 1 << 3 + if CONFIG['SYSTEMS'][section]['LOCAL']['VOICE_CALL']: + FLAG_2 |= 1 << 2 + if CONFIG['SYSTEMS'][section]['LOCAL']['MASTER_PEER']: + FLAG_2 |= 1 << 0 + CONFIG['SYSTEMS'][section]['LOCAL']['FLAGS'] = b'\x00\x00' + bytes([FLAG_1, FLAG_2]) + + except configparser.Error as err: + print(err) + sys.exit('Could not parse configuration file, exiting...') + + return CONFIG + + +if __name__ == '__main__': + import os + import argparse + from pprint import pprint + + os.chdir(os.path.dirname(os.path.realpath(sys.argv[0]))) + + parser = argparse.ArgumentParser() + parser.add_argument('-c', '--config', action='store', dest='CONFIG_FILE', + help='/full/path/to/dmrlink.cfg') + cli_args = parser.parse_args() + + if not cli_args.CONFIG_FILE: + cli_args.CONFIG_FILE = os.path.dirname(os.path.abspath(__file__)) + '/../dmrlink.cfg' + + pprint(build_config(cli_args.CONFIG_FILE)) diff --git a/const.py b/const.py new file mode 100644 index 0000000..fe9d263 --- /dev/null +++ b/const.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python +# +############################################################################### +# Copyright (C) 2013-2026 Cortney T. Buffington, N0MJS +# +# 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 +############################################################################### + +# IPSC Protocol Constants and Bitmasks -- merged from ipsc_const.py and ipsc_mask.py + +# Known IPSC Message Types +CALL_CONFIRMATION = b'\x05' +TXT_MESSAGE_ACK = b'\x54' +CALL_MON_STATUS = b'\x61' +CALL_MON_RPT = b'\x62' +CALL_MON_NACK = b'\x63' +XCMP_XNL = b'\x70' +GROUP_VOICE = b'\x80' +PVT_VOICE = b'\x81' +GROUP_DATA = b'\x83' +PVT_DATA = b'\x84' +RPT_WAKE_UP = b'\x85' +UNKNOWN_COLLISION = b'\x86' +MASTER_REG_REQ = b'\x90' +MASTER_REG_REPLY = b'\x91' +PEER_LIST_REQ = b'\x92' +PEER_LIST_REPLY = b'\x93' +PEER_REG_REQ = b'\x94' +PEER_REG_REPLY = b'\x95' +MASTER_ALIVE_REQ = b'\x96' +MASTER_ALIVE_REPLY = b'\x97' +PEER_ALIVE_REQ = b'\x98' +PEER_ALIVE_REPLY = b'\x99' +DE_REG_REQ = b'\x9A' +DE_REG_REPLY = b'\x9B' + +# IPSC Version Information +IPSC_VER_14 = b'\x00' +IPSC_VER_15 = b'\x00' +IPSC_VER_15A = b'\x00' +IPSC_VER_16 = b'\x01' +IPSC_VER_17 = b'\x02' +IPSC_VER_18 = b'\x02' +IPSC_VER_19 = b'\x03' +IPSC_VER_22 = b'\x04' + +LINK_TYPE_IPSC = b'\x04' + +BURST_DATA_TYPE = { + 'VOICE_HEAD': b'\x01', + 'VOICE_TERM': b'\x02', + 'SLOT1_VOICE': b'\x0A', + 'SLOT2_VOICE': b'\x8A', +} + +# IPSC Version field used in registration packets +IPSC_VER = LINK_TYPE_IPSC + IPSC_VER_17 + LINK_TYPE_IPSC + IPSC_VER_16 + +# Packet type membership lists +ANY_PEER_REQUIRED = [GROUP_VOICE, PVT_VOICE, GROUP_DATA, PVT_DATA, CALL_MON_STATUS, + CALL_MON_RPT, CALL_MON_NACK, XCMP_XNL, RPT_WAKE_UP, DE_REG_REQ] +PEER_REQUIRED = [PEER_ALIVE_REQ, PEER_ALIVE_REPLY, PEER_REG_REQ, PEER_REG_REPLY] +MASTER_REQUIRED = [PEER_LIST_REPLY, MASTER_ALIVE_REPLY] +USER_PACKETS = [GROUP_VOICE, PVT_VOICE, GROUP_DATA, PVT_DATA] + +# RCM Timeslot constants +TS = {b'\x00': '1', b'\x01': '2'} + + +# --------------------------------------------------------------------------- +# IPSC Bitmasks (from ipsc_mask.py) +# --------------------------------------------------------------------------- + +# LINKING STATUS byte flags +# xx.. .... Peer Operational (01 = operational) +# ..xx .... Peer MODE: 00=No Radio, 01=Analog, 10=Digital +# .... xx.. IPSC Slot 1: 10=on, 01=off +# .... ..xx IPSC Slot 2: 10=on, 01=off +PEER_OP_MSK = 0b01000000 +PEER_MODE_MSK = 0b00110000 +PEER_MODE_ANALOG = 0b00010000 +PEER_MODE_DIGITAL = 0b00100000 +IPSC_TS1_MSK = 0b00001100 +IPSC_TS2_MSK = 0b00000011 + +# SERVICE FLAGS byte 3 +CSBK_MSK = 0b10000000 +RPT_MON_MSK = 0b01000000 +CON_APP_MSK = 0b00100000 + +# SERVICE FLAGS byte 4 +XNL_STAT_MSK = 0b10000000 +XNL_MSTR_MSK = 0b01000000 +XNL_SLAVE_MSK = 0b00100000 +PKT_AUTH_MSK = 0b00010000 +DATA_CALL_MSK = 0b00001000 +VOICE_CALL_MSK = 0b00000100 +MSTR_PEER_MSK = 0b00000001 + +# TIMESLOT CALL & STATUS byte (byte 17 of voice/data packets) +# .x.. .... End flag (0=in-progress, 1=end) +# ..x. .... Timeslot (0=TS1, 1=TS2) +END_MSK = 0b01000000 +TS_CALL_MSK = 0b00100000 + +# RTP header bitmasks +RTP_VER_MSK = 0b11000000 +RTP_PAD_MSK = 0b00100000 +RTP_EXT_MSK = 0b00010000 +RTP_CSIC_MSK = 0b00001111 +RTP_MRKR_MSK = 0b10000000 +RTP_PAY_TYPE_MSK = 0b01111111 diff --git a/dashboard/config_sample.py b/dashboard/config_sample.py new file mode 100644 index 0000000..1db0cc3 --- /dev/null +++ b/dashboard/config_sample.py @@ -0,0 +1,34 @@ +############################################################################### +# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS +# +# 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. +############################################################################### + +# Copy this file to config.py and edit for your install. + +# Display / branding +REPORT_NAME = 'My DMRlink3 System' # Shown in the dashboard header + +# Connection to dmrlink3's reporting feed ([REPORTS] in dmrlink.cfg) +DMRLINK_IP = '127.0.0.1' # dmrlink3 reporting host +DMRLINK_PORT = 4321 # dmrlink3 REPORT_PORT + +# Web server +WEB_HOST = '0.0.0.0' # Interface to bind the dashboard +WEB_PORT = 8080 # Port (must be > 1024 if not root) + +# Call log +LOG_LINES = 300 # Number of recent call-log entries to retain + +# Alias files (map DMR IDs to callsigns / talkgroup names). +# Point PATH at dmrlink3's directory to share the downloaded files, or +# keep your own copies here. +PATH = '../' +PEER_FILE = 'peer_ids.json' +SUBSCRIBER_FILE = 'subscriber_ids.json' +TGID_FILE = 'talkgroup_ids.json' # optional {id: name} dict; ok if missing +LOCAL_SUB_FILE = '' # optional local override, '' to disable +LOCAL_PEER_FILE = '' # optional local override, '' to disable diff --git a/dashboard/server.py b/dashboard/server.py new file mode 100644 index 0000000..7122372 --- /dev/null +++ b/dashboard/server.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python +# +############################################################################### +# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS +# +# 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. +############################################################################### + +''' +DMRlink3 dashboard backend. + +Connects to dmrlink3's NDJSON reporting feed (one JSON object per line), keeps +authoritative display state, and serves a single-page UI that receives live +JSON events over a WebSocket. + +Run: python server.py (from the dashboard/ directory, or set PYTHONPATH) +''' + +import asyncio +import json +import logging +import os +import sys +import time +from collections import deque +from contextlib import asynccontextmanager + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse +import uvicorn + +from dmr_utils3.utils import mk_id_dict, get_alias + +HERE = os.path.dirname(os.path.abspath(__file__)) +STATIC = os.path.join(HERE, 'static') +sys.path.insert(0, HERE) + +try: + from config import (REPORT_NAME, DMRLINK_IP, DMRLINK_PORT, + WEB_HOST, WEB_PORT, LOG_LINES, + PATH, PEER_FILE, SUBSCRIBER_FILE, TGID_FILE, + LOCAL_SUB_FILE, LOCAL_PEER_FILE) +except ImportError: + sys.exit('No config.py found — copy config_sample.py to config.py and edit it.') + +logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') +logger = logging.getLogger('dmrdash') + +# Drop stale "active" calls this many seconds after START if no END arrives. +CALL_STALE = 60 + + +# ---- alias resolution -------------------------------------------------------- + +def _abs(p): + return p if os.path.isabs(p) else os.path.join(HERE, p) + +def load_aliases(): + base = _abs(PATH) + peer_ids = mk_id_dict(base, PEER_FILE) + subscriber_ids = mk_id_dict(base, SUBSCRIBER_FILE) + talkgroup_ids = mk_id_dict(base, TGID_FILE) + if LOCAL_PEER_FILE: + peer_ids.update(mk_id_dict(base, LOCAL_PEER_FILE)) + if LOCAL_SUB_FILE: + subscriber_ids.update(mk_id_dict(base, LOCAL_SUB_FILE)) + logger.info('aliases loaded: %d peers, %d subscribers, %d talkgroups', + len(peer_ids), len(subscriber_ids), len(talkgroup_ids)) + return peer_ids, subscriber_ids, talkgroup_ids + +PEER_IDS, SUBSCRIBER_IDS, TALKGROUP_IDS = load_aliases() + +def alias(_id, _dict): + a = get_alias(_id, _dict) + return None if a == _id else a + + +# ---- shared state ------------------------------------------------------------ + +class State: + def __init__(self): + self.systems = {} # last 'config' payload + self.bridges = {} # last 'bridge' payload (enriched) + self.active = {} # call_key -> START bridge_event + self.log = deque(maxlen=LOG_LINES) + self.dmrlink = False + self.clients = set() # connected WebSockets + +STATE = State() + + +def call_key(evt): + return '{}|{}|{}'.format(evt.get('system', ''), evt.get('ts', 0), evt.get('call_id', 0)) + +def enrich_call(evt): + evt['src_alias'] = alias(evt.get('src'), SUBSCRIBER_IDS) + evt['peer_alias'] = alias(evt.get('peer'), PEER_IDS) + evt['tgid_alias'] = alias(evt.get('tgid'), TALKGROUP_IDS) + return evt + +def enrich_bridges(bridges): + now = time.time() + for members in bridges.values(): + for m in members: + m['TGID_NAME'] = alias(m.get('TGID'), TALKGROUP_IDS) + if m.get('TO_TYPE') in ('ON', 'OFF'): + m['remaining'] = int(m.get('TIMER', now) - now) + else: + m['remaining'] = None + return bridges + + +async def broadcast(obj): + dead = set() + for ws in STATE.clients: + try: + await ws.send_json(obj) + except Exception: + dead.add(ws) + STATE.clients -= dead + + +async def handle_event(evt): + t = evt.get('type') + if t == 'config': + STATE.systems = evt.get('systems', {}) + await broadcast({'type': 'config', 'systems': STATE.systems}) + + elif t == 'bridge': + STATE.bridges = enrich_bridges(evt.get('bridges', {})) + await broadcast({'type': 'bridge', 'bridges': STATE.bridges}) + + elif t == 'bridge_event': + data = evt.get('data', {}) + enrich_call(data) + data['_arrived'] = time.time() + key = call_key(data) + event_name = data.get('event', '') + if 'START' in event_name: + STATE.active[key] = data + else: + STATE.active.pop(key, None) + STATE.log.appendleft(data) + await broadcast({'type': 'bridge_event', 'data': data}) + + else: + logger.debug('ignoring unknown event type: %s', t) + + +# ---- dmrlink3 feed client (with reconnect) ----------------------------------- + +async def dmrlink_feed(): + while True: + try: + reader, writer = await asyncio.open_connection(DMRLINK_IP, DMRLINK_PORT) + logger.info('connected to dmrlink3 feed at %s:%s', DMRLINK_IP, DMRLINK_PORT) + STATE.dmrlink = True + await broadcast({'type': 'dmrlink', 'connected': True}) + while True: + line = await reader.readline() + if not line: + break + try: + await handle_event(json.loads(line)) + except json.JSONDecodeError: + logger.warning('bad JSON line from dmrlink3: %r', line[:120]) + except (ConnectionRefusedError, OSError) as e: + logger.warning('dmrlink3 feed unavailable (%s); retrying in 3s', e) + finally: + if STATE.dmrlink: + STATE.dmrlink = False + STATE.systems = {} + STATE.bridges = {} + STATE.active = {} + await broadcast({'type': 'dmrlink', 'connected': False}) + await asyncio.sleep(3) + + +async def reap_calls(): + while True: + await asyncio.sleep(10) + now = time.time() + stale = [k for k, e in STATE.active.items() + if now - e.get('_arrived', now) > CALL_STALE] + for k in stale: + STATE.active.pop(k, None) + if stale: + logger.debug('reaped %d stale active call(s)', len(stale)) + + +@asynccontextmanager +async def lifespan(app): + feed = asyncio.create_task(dmrlink_feed()) + reaper = asyncio.create_task(reap_calls()) + yield + feed.cancel() + reaper.cancel() + +app = FastAPI(lifespan=lifespan) + + +# ---- HTTP + WebSocket -------------------------------------------------------- + +@app.get('/', response_class=HTMLResponse) +async def index(): + with open(os.path.join(STATIC, 'dashboard.html'), encoding='utf-8') as f: + html = f.read() + return html.replace('{{REPORT_NAME}}', REPORT_NAME) + +@app.get('/api/state') +async def api_state(): + return JSONResponse(current_state()) + +def current_state(): + return { + 'report_name': REPORT_NAME, + 'dmrlink': STATE.dmrlink, + 'systems': STATE.systems, + 'bridges': STATE.bridges, + 'active': list(STATE.active.values()), + 'log': list(STATE.log), + } + +@app.websocket('/ws') +async def ws_endpoint(ws: WebSocket): + await ws.accept() + STATE.clients.add(ws) + try: + await ws.send_json({'type': 'initial', **current_state()}) + while True: + await ws.receive_text() + except WebSocketDisconnect: + pass + finally: + STATE.clients.discard(ws) + + +def main(): + uvicorn.run(app, host=WEB_HOST, port=WEB_PORT, log_level='warning') + +if __name__ == '__main__': + main() diff --git a/dashboard/static/dashboard.html b/dashboard/static/dashboard.html new file mode 100644 index 0000000..085907c --- /dev/null +++ b/dashboard/static/dashboard.html @@ -0,0 +1,246 @@ + + + + + +{{REPORT_NAME}} — DMRlink3 + + + +
+
+

{{REPORT_NAME}}DMRlink3 Monitor

+
DMRlink3: connecting…
+
+ +

IPSC Systems

+

Conference Bridges

+

Call Log

+
+ + + + diff --git a/dmrlink-SAMPLE.cfg b/dmrlink-SAMPLE.cfg new file mode 100644 index 0000000..9a5b250 --- /dev/null +++ b/dmrlink-SAMPLE.cfg @@ -0,0 +1,163 @@ +# DMRlink3 SAMPLE CONFIGURATION FILE +# +# Rename to dmrlink.cfg and fill in your values. +# +# DMRlink3 is a Python 3 / asyncio port of dmrlink for IPSC2 (Motorola DMR) +# network bridging and monitoring. +# + + +# GLOBAL CONFIGURATION ITEMS +# +[GLOBAL] +PATH: /opt/dmrlink3/ + + +# NETWORK REPORTING CONFIGURATION +# REPORT_NETWORKS controls what happens each reporting interval: +# +# (empty) - No reporting +# PRINT - Pretty-print peer/master state to STDOUT (debugging only) +# NETWORK - Open a TCP listener and push NDJSON events to clients. +# The companion dashboard connects here. +# +# REPORT_RCM - Forward Repeater Call Monitor packets to reporting clients. +# Requires RCM: True and CON_APP: True on each IPSC system. +# +# REPORT_INTERVAL - Seconds between periodic config/bridge snapshots +# REPORT_PORT - TCP port when REPORT_NETWORKS = NETWORK +# REPORT_CLIENTS - Comma-separated list of client IPs allowed to connect, +# or * to allow any host +# +# PRINT_PEERS_INC_MODE - Include mode byte decode in PRINT output +# PRINT_PEERS_INC_FLAGS - Include flags byte decode in PRINT output +# +[REPORTS] +REPORT_NETWORKS: NETWORK +REPORT_RCM: False +REPORT_INTERVAL: 60 +REPORT_PORT: 4321 +REPORT_CLIENTS: 127.0.0.1 +PRINT_PEERS_INC_MODE: False +PRINT_PEERS_INC_FLAGS: False + + +# SYSTEM LOGGER CONFIGURATION +# LOG_FILE - Full path for file-based handlers; use /dev/null otherwise. +# LOG_HANDLERS - Comma-separated list (no spaces) of one or more: +# null console console-timed file file-timed syslog +# LOG_LEVEL - DEBUG, INFO, WARNING, ERROR, or CRITICAL +# LOG_NAME - Logger name shown in log output +# +[LOGGER] +LOG_FILE: /var/log/dmrlink3/dmrlink3.log +LOG_HANDLERS: console-timed +LOG_LEVEL: INFO +LOG_NAME: DMRlink3 + + +# SUBSCRIBER / PEER / TGID ALIAS DOWNLOADS +# DMRlink3 can download ID alias files from radioid.net and build lookup +# dictionaries for use by bridge applications. +# +# TRY_DOWNLOAD - Attempt to download fresh alias files on startup +# PATH - Local directory to store downloaded files +# PEER_FILE - Filename for peer (repeater) ID list +# SUBSCRIBER_FILE - Filename for subscriber (radio) ID list +# TGID_FILE - Filename for talkgroup ID list (no download URL; provide locally) +# LOCAL_FILE - Additional local ID file (set False if not used) +# PEER_URL - URL to download peer IDs from +# SUBSCRIBER_URL - URL to download subscriber IDs from +# STALE_DAYS - Re-download after this many days +# +[ALIASES] +TRY_DOWNLOAD: True +LOCAL_FILE: False +PATH: ./ +PEER_FILE: peer_ids.json +SUBSCRIBER_FILE: subscriber_ids.json +TGID_FILE: talkgroup_ids.json +PEER_URL: https://www.radioid.net/static/rptrs.json +SUBSCRIBER_URL: https://www.radioid.net/static/users.json +STALE_DAYS: 7 + + +# IPSC NETWORK DEFINITIONS +# +# [SECTION_NAME] - Arbitrary name; used to identify the system throughout +# dmrlink3 and in bridge rule files. Must be unique. +# +# ENABLED - True to connect; False to skip without removing config +# RADIO_ID - DMR Radio ID dmrlink3 presents to this IPSC network +# IP - Local IP to bind (leave blank to bind all interfaces) +# PORT - Local UDP port (must be unique per system) +# ALIVE_TIMER - Seconds between keep-alive transmissions +# MAX_MISSED - Keep-alives missed before dropping master/peer connection +# PEER_OPER - Signal operational status to peers (True is correct) +# IPSC_MODE - DIGITAL, ANALOG, or NONE +# TS1_LINK - Is timeslot 1 linked? +# TS2_LINK - Is timeslot 2 linked? +# CSBK_CALL - Process CSBK packets (False; dmrlink3 cannot use them) +# RCM - Repeater Call Monitoring (requires CON_APP: True) +# CON_APP - Third-party console application flag (set True) +# XNL_CALL - XCMP/XNL processing (False; not supported) +# XNL_MASTER - XNL master flag (False) +# DATA_CALL - Accept data calls +# VOICE_CALL - Accept voice calls +# MASTER_PEER - True if dmrlink3 is the IPSC master for this network +# AUTH_ENABLED - Use HMAC-SHA1 packet authentication +# AUTH_KEY - Authentication key (up to 40 hex characters) +# MASTER_IP - IP of the IPSC master (ignored when MASTER_PEER: True) +# MASTER_PORT - UDP port of the IPSC master (ignored when MASTER_PEER: True) +# GROUP_HANGTIME - Group call hangtime in seconds + + +[SAMPLE-PEER] +ENABLED: True +RADIO_ID: 12345 +IP: +PORT: 50000 +ALIVE_TIMER: 5 +MAX_MISSED: 20 +PEER_OPER: True +IPSC_MODE: DIGITAL +TS1_LINK: True +TS2_LINK: True +CSBK_CALL: False +RCM: True +CON_APP: True +XNL_CALL: False +XNL_MASTER: False +DATA_CALL: True +VOICE_CALL: True +MASTER_PEER: False +AUTH_ENABLED: True +AUTH_KEY: 1A2B3C +MASTER_IP: 1.2.3.4 +MASTER_PORT: 50000 +GROUP_HANGTIME: 5 + + +[SAMPLE-MASTER] +ENABLED: False +RADIO_ID: 54321 +IP: 192.168.1.1 +PORT: 50001 +ALIVE_TIMER: 5 +MAX_MISSED: 20 +PEER_OPER: True +IPSC_MODE: DIGITAL +TS1_LINK: True +TS2_LINK: True +CSBK_CALL: False +RCM: True +CON_APP: True +XNL_CALL: False +XNL_MASTER: False +DATA_CALL: True +VOICE_CALL: True +MASTER_PEER: True +AUTH_ENABLED: True +AUTH_KEY: 1A2B3C +# MASTER_IP and MASTER_PORT are ignored when MASTER_PEER is True +GROUP_HANGTIME: 5 diff --git a/dmrlink.py b/dmrlink.py new file mode 100644 index 0000000..bb7a2ea --- /dev/null +++ b/dmrlink.py @@ -0,0 +1,940 @@ +#!/usr/bin/env python +# +############################################################################### +# Copyright (C) 2013-2026 Cortney T. Buffington, N0MJS +# +# 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 +############################################################################### + +import asyncio +import json +import logging +import signal + +from binascii import b2a_hex as ahex +from binascii import a2b_hex as bhex +from hashlib import sha1 +from hmac import new as hmac_new, compare_digest +from socket import inet_ntoa as IPAddr +from socket import inet_aton as IPHexStr +from time import time + +from const import * +from dmr_utils3.utils import bytes_2, bytes_3, bytes_4, int_id, try_download, mk_id_dict + +__author__ = 'Cortney T. Buffington, N0MJS' +__copyright__ = 'Copyright (c) 2013-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__) + +# Global systems dict (populated by mk_ipsc_systems) +systems = {} + + +# --------------------------------------------------------------------------- +# Async utilities +# --------------------------------------------------------------------------- + +async def run_periodic(_interval, _func, _name, *_args): + """Replace twisted's task.LoopingCall. Logs exceptions and keeps running.""" + try: + while True: + await asyncio.sleep(_interval) + try: + _func(*_args) + except Exception: + logger.error('(GLOBAL) Error in periodic task %s', _name, exc_info=True) + except asyncio.CancelledError: + raise + + +# --------------------------------------------------------------------------- +# Reporting server (NDJSON over TCP, replaces pickle/Netstring) +# --------------------------------------------------------------------------- + +def _systems_snapshot(_systems): + """Produce a JSON-serializable view of the live SYSTEMS dict.""" + result = {} + for name, sys in _systems.items(): + local = sys['LOCAL'] + master = sys['MASTER'] + peers = sys['PEERS'] + result[name] = { + 'MODE': 'MASTER' if local['MASTER_PEER'] else 'PEER', + 'RADIO_ID': int_id(local['RADIO_ID']), + 'IP': local['IP'] or '0.0.0.0', + 'PORT': local['PORT'], + 'ALIVE_TIMER': local['ALIVE_TIMER'], + 'GROUP_HANGTIME': local['GROUP_HANGTIME'], + 'MASTER': { + 'RADIO_ID': int_id(master['RADIO_ID']) if master['RADIO_ID'] != b'\x00\x00\x00\x00' else None, + 'IP': master.get('IP', ''), + 'PORT': master.get('PORT', ''), + 'CONNECTED': master['STATUS']['CONNECTED'], + 'PEER_LIST': master['STATUS']['PEER_LIST'], + 'KEEP_ALIVES_SENT': master['STATUS']['KEEP_ALIVES_SENT'], + 'KEEP_ALIVES_RECEIVED': master['STATUS']['KEEP_ALIVES_RECEIVED'], + 'KEEP_ALIVES_OUTSTANDING':master['STATUS']['KEEP_ALIVES_OUTSTANDING'], + 'KEEP_ALIVES_MISSED': master['STATUS']['KEEP_ALIVES_MISSED'], + }, + 'PEERS': { + str(int_id(pid)): { + 'RADIO_ID': int_id(pid), + 'IP': p['IP'], + 'PORT': p['PORT'], + 'CONNECTED': p['STATUS']['CONNECTED'], + 'KEEP_ALIVES_SENT': p['STATUS']['KEEP_ALIVES_SENT'], + 'KEEP_ALIVES_RECEIVED': p['STATUS']['KEEP_ALIVES_RECEIVED'], + 'KEEP_ALIVES_OUTSTANDING': p['STATUS']['KEEP_ALIVES_OUTSTANDING'], + 'KEEP_ALIVES_MISSED': p['STATUS']['KEEP_ALIVES_MISSED'], + 'KEEP_ALIVE_RX_TIME': p['STATUS']['KEEP_ALIVE_RX_TIME'], + } + for pid, p in peers.items() + }, + } + return result + + +class ReportServer: + """Async TCP server that pushes NDJSON events to connected dashboard clients.""" + + def __init__(self, _config): + self._config = _config + self.clients = [] # list of asyncio.StreamWriter + + async def start(self, port): + allowed = self._config['REPORTS']['REPORT_CLIENTS'] + server = await asyncio.start_server( + lambda r, w: self._client_connected(r, w, allowed), + host='0.0.0.0', + port=port, + ) + logger.info('(GLOBAL) DMRlink3 reporting server listening on port %s', port) + async with server: + await server.serve_forever() + + async def _client_connected(self, reader, writer, allowed): + addr = writer.get_extra_info('peername') + if '*' not in allowed and addr[0] not in allowed: + logger.warning('(GLOBAL) Reporting connection rejected from %s', addr[0]) + writer.close() + return + logger.info('(GLOBAL) Reporting client connected: %s:%s', addr[0], addr[1]) + self.clients.append(writer) + try: + while True: + data = await reader.read(256) + if not data: + break + except asyncio.IncompleteReadError: + pass + except Exception as e: + logger.warning('(GLOBAL) Reporting client error: %s', e) + finally: + if writer in self.clients: + self.clients.remove(writer) + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + logger.info('(GLOBAL) Reporting client disconnected: %s:%s', addr[0], addr[1]) + + def _send_json(self, obj): + data = (json.dumps(obj) + '\n').encode() + dead = [] + for writer in self.clients: + try: + writer.write(data) + except Exception as e: + logger.warning('(GLOBAL) Reporting write error: %s', e) + dead.append(writer) + for w in dead: + if w in self.clients: + self.clients.remove(w) + + def send_config(self): + self._send_json({'type': 'config', 'systems': _systems_snapshot(self._config['SYSTEMS'])}) + + def send_bridge(self): + pass # Overridden by BridgeReportServer in bridge.py + + def send_rcm(self, _data): + pass # RCM monitoring not forwarded in dmrlink3 + + +# --------------------------------------------------------------------------- +# Reporting setup (called from async context) +# --------------------------------------------------------------------------- + +def config_reports(_config, _factory): + loop = asyncio.get_running_loop() + + if _config['REPORTS']['REPORT_NETWORKS'] == 'PRINT': + def reporting_loop(): + logger.debug('(GLOBAL) Periodic reporting loop (PRINT)') + for system in _config['SYSTEMS']: + print_master(_config, system) + print_peer_list(_config, system) + loop.create_task(run_periodic(_config['REPORTS']['REPORT_INTERVAL'], reporting_loop, 'reporting')) + return None + + elif _config['REPORTS']['REPORT_NETWORKS'] == 'NETWORK': + def reporting_loop(_server): + logger.debug('(GLOBAL) Periodic reporting loop (NETWORK)') + _server.send_config() + _server.send_bridge() + report_server = _factory(_config) + loop.create_task(report_server.start(_config['REPORTS']['REPORT_PORT'])) + loop.create_task(run_periodic(_config['REPORTS']['REPORT_INTERVAL'], + reporting_loop, 'reporting', report_server)) + logger.info('(GLOBAL) DMRlink3 TCP reporting server configured on port %s', + _config['REPORTS']['REPORT_PORT']) + return report_server + + else: + logger.info('(GLOBAL) TCP reporting disabled') + return None + + +# --------------------------------------------------------------------------- +# ID alias helpers +# --------------------------------------------------------------------------- + +def build_aliases(_config): + if _config['ALIASES']['TRY_DOWNLOAD']: + result = try_download(_config['ALIASES']['PATH'], _config['ALIASES']['PEER_FILE'], + _config['ALIASES']['PEER_URL'], _config['ALIASES']['STALE_TIME']) + logger.info(result) + result = try_download(_config['ALIASES']['PATH'], _config['ALIASES']['SUBSCRIBER_FILE'], + _config['ALIASES']['SUBSCRIBER_URL'], _config['ALIASES']['STALE_TIME']) + logger.info(result) + + peer_ids = mk_id_dict(_config['ALIASES']['PATH'], _config['ALIASES']['PEER_FILE']) + if peer_ids: + logger.info('ID ALIAS MAPPER: peer_ids dictionary is available') + + subscriber_ids = mk_id_dict(_config['ALIASES']['PATH'], _config['ALIASES']['SUBSCRIBER_FILE']) + if subscriber_ids: + logger.info('ID ALIAS MAPPER: subscriber_ids dictionary is available') + + talkgroup_ids = mk_id_dict(_config['ALIASES']['PATH'], _config['ALIASES']['TGID_FILE']) + if talkgroup_ids: + logger.info('ID ALIAS MAPPER: talkgroup_ids dictionary is available') + + local_ids = mk_id_dict(_config['ALIASES']['PATH'], _config['ALIASES']['LOCAL_FILE']) + if local_ids: + logger.info('ID ALIAS MAPPER: local_ids dictionary is available') + + return peer_ids, subscriber_ids, talkgroup_ids, local_ids + + +# --------------------------------------------------------------------------- +# Protocol helpers +# --------------------------------------------------------------------------- + +def process_mode_byte(_hex_mode): + _mode = _hex_mode[0] + _peer_op = bool(_mode & PEER_OP_MSK) + _ts1 = bool(_mode & IPSC_TS1_MSK) + _ts2 = bool(_mode & IPSC_TS2_MSK) + + if _mode & PEER_MODE_MSK == PEER_MODE_MSK: + _peer_mode = 'UNKNOWN' + elif not _mode & PEER_MODE_MSK: + _peer_mode = 'NO_RADIO' + elif _mode & PEER_MODE_ANALOG: + _peer_mode = 'ANALOG' + elif _mode & PEER_MODE_DIGITAL: + _peer_mode = 'DIGITAL' + else: + _peer_mode = 'UNKNOWN' + + return {'PEER_OP': _peer_op, 'PEER_MODE': _peer_mode, 'TS_1': _ts1, 'TS_2': _ts2} + + +def process_flags_bytes(_hex_flags): + _byte3 = _hex_flags[2] + _byte4 = _hex_flags[3] + + return { + 'CSBK': bool(_byte3 & CSBK_MSK), + 'RCM': bool(_byte3 & RPT_MON_MSK), + 'CON_APP': bool(_byte3 & CON_APP_MSK), + 'XNL_CON': bool(_byte4 & XNL_STAT_MSK), + 'XNL_MASTER': bool(_byte4 & XNL_MSTR_MSK), + 'XNL_SLAVE': bool(_byte4 & XNL_SLAVE_MSK), + 'AUTH': bool(_byte4 & PKT_AUTH_MSK), + 'DATA': bool(_byte4 & DATA_CALL_MSK), + 'VOICE': bool(_byte4 & VOICE_CALL_MSK), + 'MASTER': bool(_byte4 & MSTR_PEER_MSK), + } + + +def build_peer_list(_peers): + concatenated_peers = b'' + for peer in _peers: + hex_ip = IPHexStr(_peers[peer]['IP']) + hex_port = bytes_2(_peers[peer]['PORT']) + mode = _peers[peer]['MODE'] + concatenated_peers += peer + hex_ip + hex_port + mode + return bytes_2(len(concatenated_peers)) + concatenated_peers + + +def print_peer_list(_config, _network): + _peers = _config['SYSTEMS'][_network]['PEERS'] + _status = _config['SYSTEMS'][_network]['MASTER']['STATUS']['PEER_LIST'] + + if _status and not _peers: + print('We are the only peer for: %s' % _network) + print('') + return + + print('Peer List for: %s' % _network) + for peer in _peers.keys(): + _this_peer = _peers[peer] + _this_peer_stat = _this_peer['STATUS'] + me = '(self)' if peer == _config['SYSTEMS'][_network]['LOCAL']['RADIO_ID'] else '' + print('\tRADIO ID: {} {}'.format(int_id(peer), me)) + print('\t\tIP Address: {}:{}'.format(_this_peer['IP'], _this_peer['PORT'])) + if _this_peer['MODE_DECODE'] and _config['REPORTS']['PRINT_PEERS_INC_MODE']: + print('\t\tMode Values:') + for name, value in _this_peer['MODE_DECODE'].items(): + print('\t\t\t{}: {}'.format(name, value)) + if _this_peer['FLAGS_DECODE'] and _config['REPORTS']['PRINT_PEERS_INC_FLAGS']: + print('\t\tService Flags:') + for name, value in _this_peer['FLAGS_DECODE'].items(): + print('\t\t\t{}: {}'.format(name, value)) + print('\t\tStatus: {}, KA Sent: {}, KA Outstanding: {}, KA Missed: {}'.format( + _this_peer_stat['CONNECTED'], _this_peer_stat['KEEP_ALIVES_SENT'], + _this_peer_stat['KEEP_ALIVES_OUTSTANDING'], _this_peer_stat['KEEP_ALIVES_MISSED'])) + print('\t\t KA Received: {}, Last KA at: {}'.format( + _this_peer_stat['KEEP_ALIVES_RECEIVED'], _this_peer_stat['KEEP_ALIVE_RX_TIME'])) + print('') + + +def print_master(_config, _network): + if _config['SYSTEMS'][_network]['LOCAL']['MASTER_PEER']: + print('DMRlink3 is the Master for %s' % _network) + else: + _master = _config['SYSTEMS'][_network]['MASTER'] + print('Master for %s' % _network) + print('\tRADIO ID: {}'.format(int_id(_master['RADIO_ID']))) + if _master['MODE_DECODE'] and _config['REPORTS']['PRINT_PEERS_INC_MODE']: + print('\t\tMode Values:') + for name, value in _master['MODE_DECODE'].items(): + print('\t\t\t{}: {}'.format(name, value)) + if _master['FLAGS_DECODE'] and _config['REPORTS']['PRINT_PEERS_INC_FLAGS']: + print('\t\tService Flags:') + for name, value in _master['FLAGS_DECODE'].items(): + print('\t\t\t{}: {}'.format(name, value)) + print('\t\tStatus: {}, KA Sent: {}, KA Outstanding: {}, KA Missed: {}'.format( + _master['STATUS']['CONNECTED'], _master['STATUS']['KEEP_ALIVES_SENT'], + _master['STATUS']['KEEP_ALIVES_OUTSTANDING'], _master['STATUS']['KEEP_ALIVES_MISSED'])) + print('\t\t KA Received: {}, Last KA at: {}'.format( + _master['STATUS']['KEEP_ALIVES_RECEIVED'], _master['STATUS']['KEEP_ALIVE_RX_TIME'])) + + +# --------------------------------------------------------------------------- +# System factory +# --------------------------------------------------------------------------- + +async def mk_ipsc_systems(_config, _systems, _ipsc, _report_server): + loop = asyncio.get_running_loop() + for system in _config['SYSTEMS']: + if _config['SYSTEMS'][system]['LOCAL']['ENABLED']: + _systems[system] = _ipsc(system, _config, _report_server) + proto = _systems[system] + ip = _config['SYSTEMS'][system]['LOCAL']['IP'] or '0.0.0.0' + port = _config['SYSTEMS'][system]['LOCAL']['PORT'] + await loop.create_datagram_endpoint( + lambda p=proto: p, + local_addr=(ip, port), + ) + logger.info('(%s) UDP endpoint bound on %s:%s', system, ip, port) + return _systems + + +# --------------------------------------------------------------------------- +# IPSC Protocol class +# --------------------------------------------------------------------------- + +class IPSC(asyncio.DatagramProtocol): + def __init__(self, _name, _config, _report): + self._system = _name + self._CONFIG = _config + self._report = _report + self._config = self._CONFIG['SYSTEMS'][self._system] + self._rcm = self._CONFIG['REPORTS']['REPORT_RCM'] and self._report + self._local = self._config['LOCAL'] + self._local_id = self._local['RADIO_ID'] + self._master = self._config['MASTER'] + self._master_stat = self._master['STATUS'] + self._master_sock = (self._master['IP'], self._master['PORT']) + self._peers = self._config['PEERS'] + + self.TS_FLAGS = self._local['MODE'] + self._local['FLAGS'] + + self.MASTER_REG_REQ_PKT = MASTER_REG_REQ + self._local_id + self.TS_FLAGS + IPSC_VER + self.MASTER_ALIVE_PKT = MASTER_ALIVE_REQ + self._local_id + self.TS_FLAGS + IPSC_VER + self.PEER_LIST_REQ_PKT = PEER_LIST_REQ + self._local_id + self.PEER_REG_REQ_PKT = PEER_REG_REQ + self._local_id + IPSC_VER + self.PEER_REG_REPLY_PKT = PEER_REG_REPLY + self._local_id + IPSC_VER + self.PEER_ALIVE_REQ_PKT = PEER_ALIVE_REQ + self._local_id + self.TS_FLAGS + self.PEER_ALIVE_REPLY_PKT = PEER_ALIVE_REPLY + self._local_id + self.TS_FLAGS + self.MASTER_ALIVE_REPLY_PKT = MASTER_ALIVE_REPLY + self._local_id + self.TS_FLAGS + IPSC_VER + self.PEER_LIST_REPLY_PKT = PEER_LIST_REPLY + self._local_id + self.DE_REG_REQ_PKT = DE_REG_REQ + self._local_id + self.DE_REG_REPLY_PKT = DE_REG_REPLY + self._local_id + + logger.info('(%s) IPSC Instance Created: %s, %s:%s', + self._system, int_id(self._local['RADIO_ID']), + self._local['IP'], self._local['PORT']) + + # ----------------------------------------------------------------------- + # asyncio.DatagramProtocol interface + # ----------------------------------------------------------------------- + + def connection_made(self, transport): + self.transport = transport + loop = asyncio.get_event_loop() + if not self._local['MASTER_PEER']: + loop.create_task(run_periodic( + self._local['ALIVE_TIMER'], self.peer_maintenance_loop, self._system)) + else: + loop.create_task(run_periodic( + self._local['ALIVE_TIMER'], self.master_maintenance_loop, self._system)) + + def datagram_received(self, data, addr): + host, port = addr + _packettype = data[0:1] + _peerid = data[1:5] + + if self._local['AUTH_ENABLED']: + if not self.validate_auth(self._local['AUTH_KEY'], data): + logger.warning('(%s) AuthError: packet failed authentication. Type %s: Peer: %s, %s:%s', + self._system, ahex(_packettype), int_id(_peerid), host, port) + return + data = self.strip_hash(data) + + if _packettype in ANY_PEER_REQUIRED: + if not (self.valid_master(_peerid) or self.valid_peer(_peerid)): + logger.warning('(%s) PeerError: Peer not in peer-list: %s, %s:%s', + self._system, int_id(_peerid), host, port) + return + + if _packettype in USER_PACKETS: + _src_sub = data[6:9] + _dst_sub = data[9:12] + _call_info = data[17] + _ts = (_call_info & TS_CALL_MSK) + 1 + _end = bool(_call_info & END_MSK) + + if _packettype == GROUP_VOICE: + self.reset_keep_alive(_peerid) + self.group_voice(_src_sub, _dst_sub, _ts, _end, _peerid, data) + elif _packettype == PVT_VOICE: + self.reset_keep_alive(_peerid) + self.private_voice(_src_sub, _dst_sub, _ts, _end, _peerid, data) + elif _packettype == GROUP_DATA: + self.reset_keep_alive(_peerid) + self.group_data(_src_sub, _dst_sub, _ts, _end, _peerid, data) + elif _packettype == PVT_DATA: + self.reset_keep_alive(_peerid) + self.private_data(_src_sub, _dst_sub, _ts, _end, _peerid, data) + return + + elif _packettype == XCMP_XNL: + self.xcmp_xnl(data) + elif _packettype == CALL_MON_STATUS: + self.call_mon_status(data) + elif _packettype == CALL_MON_RPT: + self.call_mon_rpt(data) + elif _packettype == CALL_MON_NACK: + self.call_mon_nack(data) + elif _packettype == DE_REG_REQ: + self.de_register_peer(_peerid) + logger.warning('(%s) Peer De-Registration Request From: %s, %s:%s', + self._system, int_id(_peerid), host, port) + elif _packettype == DE_REG_REPLY: + logger.warning('(%s) Peer De-Registration Reply From: %s, %s:%s', + self._system, int_id(_peerid), host, port) + elif _packettype == RPT_WAKE_UP: + self.repeater_wake_up(data) + return + + if _packettype in PEER_REQUIRED: + if not self.valid_peer(_peerid): + logger.warning('(%s) PeerError: Peer not in peer-list: %s, %s:%s', + self._system, int_id(_peerid), host, port) + return + if _packettype == PEER_ALIVE_REQ: + self.peer_alive_req(data, _peerid, host, port) + elif _packettype == PEER_REG_REQ: + self.peer_reg_req(_peerid, host, port) + elif _packettype == PEER_ALIVE_REPLY: + self.peer_alive_reply(_peerid) + elif _packettype == PEER_REG_REPLY: + self.peer_reg_reply(_peerid) + return + + if _packettype in MASTER_REQUIRED: + if not self.valid_master(_peerid): + logger.warning('(%s) MasterError: %s, %s:%s is not the master peer', + self._system, int_id(_peerid), host, port) + return + if _packettype == MASTER_ALIVE_REPLY: + self.master_alive_reply(_peerid) + elif _packettype == PEER_LIST_REPLY: + self.peer_list_reply(data, _peerid) + return + + if _packettype == MASTER_REG_REPLY: + self.master_reg_reply(data, _peerid) + elif _packettype == MASTER_REG_REQ: + self.master_reg_req(data, _peerid, host, port) + elif _packettype == MASTER_ALIVE_REQ: + self.master_alive_req(_peerid, host, port) + elif _packettype == PEER_LIST_REQ: + self.peer_list_req(_peerid) + else: + self.unknown_message(_packettype, _peerid, data) + + def error_received(self, exc): + logger.error('(%s) UDP error received: %s', self._system, exc) + + def connection_lost(self, exc): + logger.warning('(%s) UDP connection lost: %s', self._system, exc) + + # ----------------------------------------------------------------------- + # Callbacks (overridden by subclasses) + # ----------------------------------------------------------------------- + + def call_mon_status(self, _data): + logger.debug('(%s) Repeater Call Monitor Origin Packet Received', self._system) + if self._rcm: + self._report.send_rcm(self._system + ',' + _data.decode('latin-1')) + + def call_mon_rpt(self, _data): + logger.debug('(%s) Repeater Call Monitor Repeating Packet Received', self._system) + if self._rcm: + self._report.send_rcm(self._system + ',' + _data.decode('latin-1')) + + def call_mon_nack(self, _data): + logger.debug('(%s) Repeater Call Monitor NACK Packet Received', self._system) + if self._rcm: + self._report.send_rcm(self._system + ',' + _data.decode('latin-1')) + + def xcmp_xnl(self, _data): + logger.debug('(%s) XCMP/XNL Packet Received', self._system) + + def repeater_wake_up(self, _data): + logger.debug('(%s) Repeater Wake-Up Packet Received', self._system) + + def group_voice(self, _src_sub, _dst_sub, _ts, _end, _peerid, _data): + logger.debug('(%s) Group Voice Packet: From %s, Peer %s, Dst %s', + self._system, int_id(_src_sub), int_id(_peerid), int_id(_dst_sub)) + + def private_voice(self, _src_sub, _dst_sub, _ts, _end, _peerid, _data): + logger.debug('(%s) Private Voice Packet: From %s, Peer %s, Dst %s', + self._system, int_id(_src_sub), int_id(_peerid), int_id(_dst_sub)) + + def group_data(self, _src_sub, _dst_sub, _ts, _end, _peerid, _data): + logger.debug('(%s) Group Data Packet: From %s, Peer %s, Dst %s', + self._system, int_id(_src_sub), int_id(_peerid), int_id(_dst_sub)) + + def private_data(self, _src_sub, _dst_sub, _ts, _end, _peerid, _data): + logger.debug('(%s) Private Data Packet: From %s, Peer %s, Dst %s', + self._system, int_id(_src_sub), int_id(_peerid), int_id(_dst_sub)) + + def unknown_message(self, _packettype, _peerid, _data): + logger.error('(%s) Unknown Message Type: %s From: %s', + self._system, ahex(_packettype), int_id(_peerid)) + + # ----------------------------------------------------------------------- + # IPSC maintenance + # ----------------------------------------------------------------------- + + def valid_peer(self, _peerid): + return _peerid in self._peers + + def valid_master(self, _peerid): + return self._master['RADIO_ID'] == _peerid + + def de_register_peer(self, _peerid): + if _peerid in self._peers: + del self._peers[_peerid] + logger.info('(%s) Peer De-Registered: %s', self._system, int_id(_peerid)) + else: + logger.warning('(%s) De-Registration requested for unknown peer: %s', + self._system, int_id(_peerid)) + + def de_register_self(self): + logger.info('(%s) De-Registering self from IPSC', self._system) + pkt = self.hashed_packet(self._local['AUTH_KEY'], self.DE_REG_REQ_PKT) + self.send_to_ipsc(pkt) + + def process_peer_list(self, _data): + _temp_peers = [] + _peer_list_length = int.from_bytes(_data[5:7], 'big') + self._local['NUM_PEERS'] = _peer_list_length // 11 + logger.info('(%s) Peer List Received: %s peers in this IPSC', + self._system, self._local['NUM_PEERS']) + + for i in range(7, _peer_list_length + 7, 11): + _hex_radio_id = _data[i:i+4] + _ip_address = IPAddr(_data[i+4:i+8]) + _port = int.from_bytes(_data[i+8:i+10], 'big') + _hex_mode = _data[i+10:i+11] + + _temp_peers.append(_hex_radio_id) + _decoded_mode = process_mode_byte(_hex_mode) + + if _hex_radio_id in self._peers: + self._peers[_hex_radio_id]['IP'] = _ip_address + self._peers[_hex_radio_id]['PORT'] = _port + self._peers[_hex_radio_id]['MODE'] = _hex_mode + self._peers[_hex_radio_id]['MODE_DECODE'] = _decoded_mode + self._peers[_hex_radio_id]['FLAGS'] = b'' + self._peers[_hex_radio_id]['FLAGS_DECODE']= '' + logger.debug('(%s) Peer Updated: %s', self._system, int_id(_hex_radio_id)) + else: + self._peers[_hex_radio_id] = { + 'IP': _ip_address, + 'PORT': _port, + 'MODE': _hex_mode, + 'MODE_DECODE': _decoded_mode, + 'FLAGS': b'', + 'FLAGS_DECODE':'', + 'STATUS': { + 'CONNECTED': False, + 'KEEP_ALIVES_SENT': 0, + 'KEEP_ALIVES_MISSED': 0, + 'KEEP_ALIVES_OUTSTANDING': 0, + 'KEEP_ALIVES_RECEIVED': 0, + 'KEEP_ALIVE_RX_TIME': 0, + }, + } + logger.debug('(%s) Peer Added: %s', self._system, int_id(_hex_radio_id)) + + for peer in list(self._peers.keys()): + if peer not in _temp_peers: + self.de_register_peer(peer) + logger.warning('(%s) Peer Deleted (not in new peer list): %s', + self._system, int_id(peer)) + + def send_packet(self, _packet, addr): + _host, _port = addr + if self._local['AUTH_ENABLED']: + _hash = bhex(hmac_new(self._local['AUTH_KEY'], _packet, sha1).hexdigest()[:20]) + _packet = _packet + _hash + self.transport.sendto(_packet, (_host, _port)) + + def send_to_ipsc(self, _packet): + if self._local['AUTH_ENABLED']: + _hash = bhex(hmac_new(self._local['AUTH_KEY'], _packet, sha1).hexdigest()[:20]) + _packet = _packet + _hash + if self._master['STATUS']['CONNECTED']: + self.transport.sendto(_packet, (self._master['IP'], self._master['PORT'])) + for peer in list(self._peers): + if self._peers[peer]['STATUS']['CONNECTED']: + self.transport.sendto(_packet, (self._peers[peer]['IP'], self._peers[peer]['PORT'])) + + def hashed_packet(self, _key, _data): + _hash = bhex(hmac_new(_key, _data, sha1).hexdigest()[:20]) + return _data + _hash + + def strip_hash(self, _data): + return _data[:-10] + + def validate_auth(self, _key, _data): + _payload = self.strip_hash(_data) + _hash = _data[-10:] + _chk_hash = bhex(hmac_new(_key, _payload, sha1).hexdigest()[:20]) + return compare_digest(_chk_hash, _hash) + + def reset_keep_alive(self, _peerid): + if _peerid in self._peers: + self._peers[_peerid]['STATUS']['KEEP_ALIVES_OUTSTANDING'] = 0 + self._peers[_peerid]['STATUS']['KEEP_ALIVE_RX_TIME'] = int(time()) + if _peerid == self._master['RADIO_ID']: + self._master_stat['KEEP_ALIVES_OUTSTANDING'] = 0 + + # ----------------------------------------------------------------------- + # Maintenance loop handlers + # ----------------------------------------------------------------------- + + def peer_alive_req(self, _data, _peerid, _host, _port): + _hex_mode = _data[5:6] + _hex_flags = _data[6:10] + self._peers[_peerid]['MODE'] = _hex_mode + self._peers[_peerid]['MODE_DECODE'] = process_mode_byte(_hex_mode) + self._peers[_peerid]['FLAGS'] = _hex_flags + self._peers[_peerid]['FLAGS_DECODE'] = process_flags_bytes(_hex_flags) + self.send_packet(self.PEER_ALIVE_REPLY_PKT, (_host, _port)) + self.reset_keep_alive(_peerid) + logger.debug('(%s) Keep-Alive reply sent to Peer %s, %s:%s', + self._system, int_id(_peerid), _host, _port) + + def peer_reg_req(self, _peerid, _host, _port): + self.send_packet(self.PEER_REG_REPLY_PKT, (_host, _port)) + logger.info('(%s) Peer Registration Request From: %s, %s:%s', + self._system, int_id(_peerid), _host, _port) + + def peer_alive_reply(self, _peerid): + self.reset_keep_alive(_peerid) + self._peers[_peerid]['STATUS']['KEEP_ALIVES_RECEIVED'] += 1 + self._peers[_peerid]['STATUS']['KEEP_ALIVE_RX_TIME'] = int(time()) + logger.debug('(%s) Keep-Alive Reply received from Peer %s', + self._system, int_id(_peerid)) + + def peer_reg_reply(self, _peerid): + if _peerid in self._peers: + self._peers[_peerid]['STATUS']['CONNECTED'] = True + logger.info('(%s) Registration Reply From: %s, %s:%s', + self._system, int_id(_peerid), + self._peers[_peerid]['IP'], self._peers[_peerid]['PORT']) + + def master_alive_reply(self, _peerid): + self.reset_keep_alive(_peerid) + self._master['STATUS']['KEEP_ALIVES_RECEIVED'] += 1 + self._master['STATUS']['KEEP_ALIVE_RX_TIME'] = int(time()) + logger.debug('(%s) Keep-Alive Reply received from Master %s', + self._system, int_id(_peerid)) + + def peer_list_reply(self, _data, _peerid): + self._master['STATUS']['PEER_LIST'] = True + if len(_data) > 18: + self.process_peer_list(_data) + logger.debug('(%s) Peer List Reply Received From Master %s', + self._system, int_id(_peerid)) + + def master_reg_reply(self, _data, _peerid): + _hex_mode = _data[5:6] + _hex_flags = _data[6:10] + _num_peers = _data[10:12] + self._local['NUM_PEERS'] = int.from_bytes(_num_peers, 'big') + self._master['RADIO_ID'] = _peerid + self._master['MODE'] = _hex_mode + self._master['MODE_DECODE'] = process_mode_byte(_hex_mode) + self._master['FLAGS'] = _hex_flags + self._master['FLAGS_DECODE'] = process_flags_bytes(_hex_flags) + self._master_stat['CONNECTED'] = True + self._master_stat['KEEP_ALIVES_OUTSTANDING'] = 0 + logger.warning('(%s) Registration response from Master: %s, %s:%s (%s peers)', + self._system, int_id(_peerid), + self._master['IP'], self._master['PORT'], self._local['NUM_PEERS']) + + def master_reg_req(self, _data, _peerid, _host, _port): + _hex_mode = _data[5:6] + _hex_flags = _data[6:10] + self.MASTER_REG_REPLY_PKT = (MASTER_REG_REPLY + self._local_id + self.TS_FLAGS + + bytes_2(self._local['NUM_PEERS']) + IPSC_VER) + self.send_packet(self.MASTER_REG_REPLY_PKT, (_host, _port)) + logger.info('(%s) Master Registration Packet Received from peer %s, %s:%s', + self._system, int_id(_peerid), _host, _port) + + if _peerid not in self._peers: + self._peers[_peerid] = { + 'IP': _host, + 'PORT': _port, + 'MODE': _hex_mode, + 'MODE_DECODE': process_mode_byte(_hex_mode), + 'FLAGS': _hex_flags, + 'FLAGS_DECODE':process_flags_bytes(_hex_flags), + 'STATUS': { + 'CONNECTED': True, + 'KEEP_ALIVES_SENT': 0, + 'KEEP_ALIVES_MISSED': 0, + 'KEEP_ALIVES_OUTSTANDING': 0, + 'KEEP_ALIVES_RECEIVED': 0, + 'KEEP_ALIVE_RX_TIME': int(time()), + }, + } + self._local['NUM_PEERS'] = len(self._peers) + logger.debug('(%s) Peer Added: %s, %s:%s (IPSC now has %s Peers)', + self._system, int_id(_peerid), _host, _port, self._local['NUM_PEERS']) + + def master_alive_req(self, _peerid, _host, _port): + if _peerid in self._peers: + self._peers[_peerid]['STATUS']['KEEP_ALIVES_RECEIVED'] += 1 + self._peers[_peerid]['STATUS']['KEEP_ALIVE_RX_TIME'] = int(time()) + self.send_packet(self.MASTER_ALIVE_REPLY_PKT, (_host, _port)) + logger.debug('(%s) Master Keep-Alive Request from peer %s, %s:%s', + self._system, int_id(_peerid), _host, _port) + else: + logger.warning('(%s) Master Keep-Alive from *UNREGISTERED* peer %s, %s:%s', + self._system, int_id(_peerid), _host, _port) + + def peer_list_req(self, _peerid): + if _peerid in self._peers: + logger.debug('(%s) Peer List Request from peer %s', self._system, int_id(_peerid)) + self.send_to_ipsc(self.PEER_LIST_REPLY_PKT + build_peer_list(self._peers)) + else: + logger.warning('(%s) Peer List Request from *UNREGISTERED* peer %s', + self._system, int_id(_peerid)) + + # ----------------------------------------------------------------------- + # Connection maintenance loops (run_periodic callbacks) + # ----------------------------------------------------------------------- + + def master_maintenance_loop(self): + logger.debug('(%s) MASTER Maintenance Loop', self._system) + update_time = int(time()) + for peer in list(self._peers.keys()): + delta = update_time - self._peers[peer]['STATUS']['KEEP_ALIVE_RX_TIME'] + logger.debug('(%s) Time since last KA from Peer %s: %ss', + self._system, int_id(peer), delta) + if delta > 120: + self.de_register_peer(peer) + self.send_to_ipsc(self.PEER_LIST_REPLY_PKT + build_peer_list(self._peers)) + logger.warning('(%s) Timeout exceeded for Peer %s — de-registering', + self._system, int_id(peer)) + + def peer_maintenance_loop(self): + logger.debug('(%s) PEER Maintenance Loop', self._system) + + if not self._master_stat['CONNECTED']: + self.send_packet(self.MASTER_REG_REQ_PKT, self._master_sock) + logger.info('(%s) Registering with Master: %s:%s', + self._system, self._master['IP'], self._master['PORT']) + + elif self._master_stat['CONNECTED']: + self.send_packet(self.MASTER_ALIVE_PKT, self._master_sock) + logger.debug('(%s) Keep-Alive sent to Master: %s, %s:%s', + self._system, int_id(self._master['RADIO_ID']), + self._master['IP'], self._master['PORT']) + + if self._master_stat['KEEP_ALIVES_OUTSTANDING'] > 0: + self._master_stat['KEEP_ALIVES_MISSED'] += 1 + logger.info('(%s) Master Keep-Alive Missed: %s:%s', + self._system, self._master['IP'], self._master['PORT']) + + if self._master_stat['KEEP_ALIVES_OUTSTANDING'] >= self._local['MAX_MISSED']: + self._master_stat['CONNECTED'] = False + self._master_stat['KEEP_ALIVES_OUTSTANDING'] = 0 + logger.error('(%s) Max Master Keep-Alives Missed — de-registering Master: %s:%s', + self._system, self._master['IP'], self._master['PORT']) + + self._master_stat['KEEP_ALIVES_SENT'] += 1 + self._master_stat['KEEP_ALIVES_OUTSTANDING'] += 1 + + else: + logger.error('(%s) Master in UNKNOWN STATE: %s:%s', + self._system, self._master['IP'], self._master['PORT']) + self._master_stat['CONNECTED'] = False + + if self._master_stat['CONNECTED'] and not self._master_stat['PEER_LIST']: + if self._local['NUM_PEERS']: + self.send_packet(self.PEER_LIST_REQ_PKT, self._master_sock) + logger.info('(%s) No Peer List — Requesting One From the Master', self._system) + else: + self._master_stat['PEER_LIST'] = True + logger.debug('(%s) Skip peer list request — we are the only Peer', self._system) + + if self._master_stat['PEER_LIST']: + for peer in list(self._peers.keys()): + if peer == self._local_id: + continue + + if not self._peers[peer]['STATUS']['CONNECTED']: + self.send_packet(self.PEER_REG_REQ_PKT, + (self._peers[peer]['IP'], self._peers[peer]['PORT'])) + logger.info('(%s) Registering with Peer %s, %s:%s', + self._system, int_id(peer), + self._peers[peer]['IP'], self._peers[peer]['PORT']) + + elif self._peers[peer]['STATUS']['CONNECTED']: + self.send_packet(self.PEER_ALIVE_REQ_PKT, + (self._peers[peer]['IP'], self._peers[peer]['PORT'])) + logger.debug('(%s) Keep-Alive sent to Peer %s, %s:%s', + self._system, int_id(peer), + self._peers[peer]['IP'], self._peers[peer]['PORT']) + + if self._peers[peer]['STATUS']['KEEP_ALIVES_OUTSTANDING'] > 0: + self._peers[peer]['STATUS']['KEEP_ALIVES_MISSED'] += 1 + logger.info('(%s) Peer Keep-Alive Missed: %s, %s:%s', + self._system, int_id(peer), + self._peers[peer]['IP'], self._peers[peer]['PORT']) + + if self._peers[peer]['STATUS']['KEEP_ALIVES_OUTSTANDING'] >= self._local['MAX_MISSED']: + self._peers[peer]['STATUS']['CONNECTED'] = False + logger.warning('(%s) Max Peer Keep-Alives Missed — de-registering Peer: %s, %s:%s', + self._system, int_id(peer), + self._peers[peer]['IP'], self._peers[peer]['PORT']) + + self._peers[peer]['STATUS']['KEEP_ALIVES_SENT'] += 1 + self._peers[peer]['STATUS']['KEEP_ALIVES_OUTSTANDING'] += 1 + + +# --------------------------------------------------------------------------- +# __main__ +# --------------------------------------------------------------------------- + +if __name__ == '__main__': + import argparse + import os + import sys + + 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('-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 'dmrlink.py' (c) 2013-2026 N0MJS & the K0USY Group — SYSTEM STARTING...") + + async def async_main(): + loop = asyncio.get_running_loop() + stop_event = asyncio.Event() + + def sig_handler(sig): + logger.info('*** DMRlink3 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, ReportServer) + build_aliases(CONFIG) + await mk_ipsc_systems(CONFIG, systems, IPSC, report_server) + await stop_event.wait() + + asyncio.run(async_main()) diff --git a/log.py b/log.py new file mode 100644 index 0000000..ab6e622 --- /dev/null +++ b/log.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python +# +############################################################################### +# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS +# +# 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 +############################################################################### + +import logging +from logging.config import dictConfig + +__author__ = 'Cortney T. Buffington, N0MJS' +__copyright__ = 'Copyright (c) 2016-2026 Cortney T. Buffington, N0MJS and the K0USY Group' +__license__ = 'GNU GPLv3' +__maintainer__ = 'Cort Buffington, N0MJS' +__email__ = 'n0mjs@me.com' + + +def config_logging(_logger): + dictConfig({ + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s' + }, + 'timed': { + 'format': '%(levelname)s %(asctime)s %(message)s' + }, + 'simple': { + 'format': '%(levelname)s %(message)s' + }, + 'syslog': { + 'format': '%(name)s (%(process)d): %(levelname)s %(message)s' + } + }, + 'handlers': { + 'null': { + 'class': 'logging.NullHandler' + }, + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'simple' + }, + 'console-timed': { + 'class': 'logging.StreamHandler', + 'formatter': 'timed' + }, + 'file': { + 'class': 'logging.FileHandler', + 'formatter': 'simple', + 'filename': _logger['LOG_FILE'], + }, + 'file-timed': { + 'class': 'logging.FileHandler', + 'formatter': 'timed', + 'filename': _logger['LOG_FILE'], + }, + 'syslog': { + 'class': 'logging.handlers.SysLogHandler', + 'formatter': 'syslog', + } + }, + 'root': { + 'handlers': _logger['LOG_HANDLERS'].split(','), + 'level': _logger['LOG_LEVEL'], + 'propagate': True, + }, + }) + return logging.getLogger(_logger['LOG_NAME']) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fce9718 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "dmrlink3" +version = "3.0.0" +description = "Python 3 / asyncio IPSC2 (Motorola DMR) network bridge and monitor" +authors = [ + {name = "Cortney T. Buffington, N0MJS", email = "n0mjs@me.com"}, +] +license = {text = "GPLv3"} +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "dmr_utils3", + "fastapi", + "uvicorn[standard]", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["dmrlink3*", "dashboard*"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bca139f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +dmr_utils3 +fastapi +uvicorn[standard]