mirror of
https://github.com/n0mjs710/dmrlink3
synced 2026-11-08 04:19:24 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
commit
ce191c2b34
12 files changed
+2633
No files matched your search
@@ -0,0 +1,483 @@
|
||||
#!/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.
|
||||
#
|
||||
# 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())
|
||||
@@ -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)
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/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
|
||||
###############################################################################
|
||||
|
||||
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))
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
###############################################################################
|
||||
# Copyright (C) 2013-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
|
||||
###############################################################################
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,34 @@
|
||||
###############################################################################
|
||||
# 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.
|
||||
###############################################################################
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/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.
|
||||
###############################################################################
|
||||
|
||||
'''
|
||||
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()
|
||||
@@ -0,0 +1,246 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{REPORT_NAME}} — DMRlink3</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f172a; --panel:#1e293b; --panel2:#172033; --border:#334155;
|
||||
--text:#e2e8f0; --muted:#94a3b8; --accent:#38bdf8;
|
||||
--green:#16a34a; --green-bg:#16351f; --red:#dc2626; --red-bg:#3a1620;
|
||||
--orange:#ea580c; --orange-bg:#3a2412; --warn:#fbbf24;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--text);padding:14px;font-size:13px}
|
||||
.wrap{max-width:1400px;margin:0 auto}
|
||||
header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:10px}
|
||||
h1{color:var(--accent);font-size:22px;font-weight:600}
|
||||
h1 small{color:var(--muted);font-size:13px;font-weight:400;margin-left:8px}
|
||||
.badge{padding:6px 14px;border-radius:6px;font-weight:600;font-size:13px;border:1px solid var(--border)}
|
||||
.badge.up{background:var(--green-bg);color:#4ade80;border-color:var(--green)}
|
||||
.badge.down{background:var(--red-bg);color:#f87171;border-color:var(--red)}
|
||||
section{margin-bottom:22px}
|
||||
h2{font-size:15px;color:var(--accent);margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px}
|
||||
h3{font-size:13px;color:var(--muted);margin:12px 0 6px}
|
||||
table{width:100%;border-collapse:collapse;background:var(--panel);border-radius:8px;overflow:hidden;margin-bottom:12px}
|
||||
th{background:var(--panel2);color:var(--muted);text-align:left;padding:8px 10px;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--border)}
|
||||
td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:none}
|
||||
.sub{color:var(--muted);font-size:11px}
|
||||
.mono{font-variant-numeric:tabular-nums}
|
||||
.sysname{font-weight:600;color:var(--accent)}
|
||||
.bridge-head td{background:var(--panel2);color:#fff;font-weight:600;font-size:12px;letter-spacing:.02em;padding-top:9px;padding-bottom:9px}
|
||||
.pill{display:inline-block;padding:2px 7px;border-radius:10px;font-size:10px;border:1px solid var(--border);color:var(--muted)}
|
||||
.pill.master{border-color:var(--accent);color:var(--accent)}
|
||||
.pill.peer{border-color:#818cf8;color:#818cf8}
|
||||
.status-ok{color:#4ade80;font-weight:600}
|
||||
.status-bad{color:#f87171;font-weight:600}
|
||||
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:middle}
|
||||
.dot.ok{background:#22c55e}.dot.bad{background:#ef4444}
|
||||
.empty{color:var(--muted);padding:10px;background:var(--panel);border-radius:8px;text-align:center}
|
||||
#log{background:#0b1220;border:1px solid var(--border);border-radius:8px;height:230px;overflow-y:auto;padding:8px 10px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:1.5}
|
||||
#log .start{color:#7dd3fc}#log .end{color:#86efac}#log .ts{color:#64748b}
|
||||
.ka-ok{color:#4ade80}.ka-warn{color:var(--warn)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>{{REPORT_NAME}}<small>DMRlink3 Monitor</small></h1>
|
||||
<div id="conn" class="badge down">DMRlink3: connecting…</div>
|
||||
</header>
|
||||
|
||||
<section><h2>IPSC Systems</h2><div id="systems"></div></section>
|
||||
<section><h2>Conference Bridges</h2><div id="bridges"></div></section>
|
||||
<section><h2>Call Log</h2><div id="log"></div></section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ST = { systems:{}, bridges:{}, active:{}, log:[], dmrlink:false };
|
||||
const STALE = 60000;
|
||||
|
||||
const el = id => document.getElementById(id);
|
||||
const esc = s => (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
|
||||
function dur(secs){
|
||||
secs = Math.max(0, Math.floor(secs));
|
||||
const d=Math.floor(secs/86400), h=Math.floor(secs%86400/3600), m=Math.floor(secs%3600/60), s=secs%60;
|
||||
if(d) return d+'d '+h+'h'; if(h) return h+'h '+m+'m'; if(m) return m+'m '+s+'s'; return s+'s';
|
||||
}
|
||||
const idName = (name, id) => name ? esc(name)+' <span class="sub mono">('+id+')</span>' : '<span class="mono">'+esc(String(id||''))+'</span>';
|
||||
const ckey = e => (e.system||'')+'|'+(e.ts||0)+'|'+(e.call_id||0);
|
||||
|
||||
// ---- renderers ----
|
||||
|
||||
function renderSystems(){
|
||||
const names = Object.keys(ST.systems);
|
||||
if(!names.length){ el('systems').innerHTML='<div class="empty">No systems (dmrlink3 not connected or no IPSC networks enabled)</div>'; return; }
|
||||
let h='';
|
||||
for(const n of names){
|
||||
const s = ST.systems[n];
|
||||
const mode = s.MODE||'PEER';
|
||||
const modeClass = mode==='MASTER'?'master':'peer';
|
||||
h += '<h3><span class="sysname">'+esc(n)+'</span> <span class="pill '+modeClass+'">'+esc(mode)+'</span>'
|
||||
+ ' <span class="sub">Radio ID: '+esc(s.RADIO_ID)+' | '+esc(s.IP||'0.0.0.0')+':'+esc(s.PORT)+'</span></h3>';
|
||||
|
||||
// Master status row (when we are a peer)
|
||||
if(mode === 'PEER'){
|
||||
const ms = s.MASTER||{};
|
||||
const connected = ms.CONNECTED;
|
||||
const rid = ms.RADIO_ID != null ? ms.RADIO_ID : '—';
|
||||
h += '<table><tr><th>Role</th><th>Radio ID</th><th>Address</th><th>Status</th><th>KA Sent</th><th>KA Rcvd</th><th>KA Outstanding</th><th>KA Missed</th></tr>';
|
||||
h += '<tr><td class="sub">Master</td><td class="mono">'+esc(rid)+'</td>'
|
||||
+ '<td class="mono">'+esc(ms.IP||'—')+':'+esc(ms.PORT||'—')+'</td>'
|
||||
+ '<td><span class="dot '+(connected?'ok':'bad')+'"></span><span class="'+(connected?'status-ok':'status-bad')+'">'+(connected?'Connected':'Disconnected')+'</span></td>'
|
||||
+ '<td class="mono">'+esc(ms.KEEP_ALIVES_SENT||0)+'</td>'
|
||||
+ '<td class="mono">'+esc(ms.KEEP_ALIVES_RECEIVED||0)+'</td>'
|
||||
+ '<td class="mono">'+esc(ms.KEEP_ALIVES_OUTSTANDING||0)+'</td>'
|
||||
+ '<td class="mono">'+esc(ms.KEEP_ALIVES_MISSED||0)+'</td>'
|
||||
+ '</tr></table>';
|
||||
}
|
||||
|
||||
// Peer list
|
||||
const peers = s.PEERS||{};
|
||||
const pids = Object.keys(peers);
|
||||
h += '<h3 style="margin-top:6px">'+
|
||||
(mode==='MASTER' ? 'Connected Peers ('+pids.length+')' : 'Peers in IPSC ('+pids.length+')')+'</h3>';
|
||||
if(!pids.length){
|
||||
h += '<div class="empty" style="margin-bottom:10px">No peers</div>';
|
||||
} else {
|
||||
h += '<table><tr><th>Radio ID</th><th>Address</th><th>Status</th><th>KA Sent</th><th>KA Rcvd</th><th>KA Outstanding</th><th>KA Missed</th><th>Last KA</th></tr>';
|
||||
for(const pid of pids){
|
||||
const p = peers[pid];
|
||||
const ps = p.STATUS||{};
|
||||
const connected = ps.CONNECTED;
|
||||
const lastKA = ps.KEEP_ALIVE_RX_TIME ? new Date(ps.KEEP_ALIVE_RX_TIME*1000).toLocaleTimeString() : '—';
|
||||
h += '<tr>'
|
||||
+ '<td class="mono">'+esc(p.RADIO_ID||pid)+'</td>'
|
||||
+ '<td class="mono">'+esc(p.IP)+':'+esc(p.PORT)+'</td>'
|
||||
+ '<td><span class="dot '+(connected?'ok':'bad')+'"></span><span class="'+(connected?'status-ok':'status-bad')+'">'+(connected?'Connected':'Disconnected')+'</span></td>'
|
||||
+ '<td class="mono">'+esc(ps.KEEP_ALIVES_SENT||0)+'</td>'
|
||||
+ '<td class="mono">'+esc(ps.KEEP_ALIVES_RECEIVED||0)+'</td>'
|
||||
+ '<td class="mono">'+esc(ps.KEEP_ALIVES_OUTSTANDING||0)+'</td>'
|
||||
+ '<td class="mono">'+esc(ps.KEEP_ALIVES_MISSED||0)+'</td>'
|
||||
+ '<td class="mono sub">'+lastKA+'</td>'
|
||||
+ '</tr>';
|
||||
}
|
||||
h += '</table>';
|
||||
}
|
||||
}
|
||||
el('systems').innerHTML = h;
|
||||
}
|
||||
|
||||
function renderBridges(){
|
||||
const names = Object.keys(ST.bridges);
|
||||
if(!names.length){ el('bridges').innerHTML='<div class="empty">No bridge data (is bridge.py running?)</div>'; return; }
|
||||
let h='<table><tr><th>System</th><th>TS</th><th>TGID</th><th>Status</th><th>Timer</th><th>Timeout Action</th><th>Activate TGIDs</th><th>Deactivate TGIDs</th></tr>';
|
||||
for(const b of names){
|
||||
h += '<tr class="bridge-head"><td colspan="8">'+esc(b)+'</td></tr>';
|
||||
for(const m of ST.bridges[b]){
|
||||
const on = m.ACTIVE;
|
||||
const to = m.TO_TYPE==='ON'||m.TO_TYPE==='OFF';
|
||||
const rem = to ? '<span class="js-countdown mono" data-rem="'+(m.remaining==null?0:m.remaining)+'" data-arr="'+Date.now()+'">'+(m.remaining<=0?'expired':dur(m.remaining))+'</span>'
|
||||
: '<span class="sub">N/A</span>';
|
||||
const act = m.TO_TYPE==='ON'?'Disconnect after timer':m.TO_TYPE==='OFF'?'Reconnect after timer':'<span class="sub">None</span>';
|
||||
h += '<tr>'
|
||||
+ '<td class="sysname">'+esc(m.SYSTEM)+'</td>'
|
||||
+ '<td class="mono">'+esc(m.TS)+'</td>'
|
||||
+ '<td class="mono">'+idName(m.TGID_NAME, m.TGID)+'</td>'
|
||||
+ '<td><span class="'+(on?'status-ok':'status-bad')+'">'+(on?'Active':'Inactive')+'</span></td>'
|
||||
+ '<td>'+rem+'</td>'
|
||||
+ '<td class="sub">'+act+'</td>'
|
||||
+ '<td class="mono sub">'+(m.ON||[]).join(', ')+'</td>'
|
||||
+ '<td class="mono sub">'+(m.OFF||[]).join(', ')+'</td>'
|
||||
+ '</tr>';
|
||||
}
|
||||
}
|
||||
el('bridges').innerHTML = h+'</table>';
|
||||
}
|
||||
|
||||
function logRow(e){
|
||||
const t = new Date((e._arrived||0)*1000).toLocaleTimeString();
|
||||
const ev = e.event||'';
|
||||
const isStart = ev.includes('START');
|
||||
const cls = isStart ? 'start' : 'end';
|
||||
const d = e.duration != null ? ' · '+e.duration.toFixed(1)+'s' : '';
|
||||
const src = e.src_alias ? esc(e.src_alias)+' ('+e.src+')' : esc(String(e.src||''));
|
||||
const tg = e.tgid_alias ? esc(e.tgid_alias)+' ('+e.tgid+')' : esc(String(e.tgid||''));
|
||||
return '<div><span class="ts">'+t+'</span> <span class="'+cls+'">'+esc(ev)+'</span>'
|
||||
+ ' · '+esc(e.system||'')
|
||||
+ ' TS'+esc(e.ts||'?')
|
||||
+ ' · '+src+' → '+tg+d+'</div>';
|
||||
}
|
||||
function renderLog(){ el('log').innerHTML = ST.log.map(logRow).join(''); }
|
||||
|
||||
// ---- live countdown tick ----
|
||||
setInterval(()=>{
|
||||
const now = Date.now();
|
||||
let reaped = false;
|
||||
for(const k in ST.active){ if(now - (ST.active[k]._arrived||0)*1000 > STALE){ delete ST.active[k]; reaped=true; } }
|
||||
if(reaped) renderLog();
|
||||
document.querySelectorAll('.js-countdown').forEach(s=>{
|
||||
const r = (+s.dataset.rem) - (now - (+s.dataset.arr))/1000;
|
||||
s.textContent = r<=0?'expired':dur(r);
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// ---- websocket ----
|
||||
function setConn(up){
|
||||
ST.dmrlink = up;
|
||||
const c = el('conn');
|
||||
c.className = 'badge '+(up?'up':'down');
|
||||
c.textContent = 'DMRlink3: '+(up?'connected':'disconnected');
|
||||
}
|
||||
function renderAll(){ renderSystems(); renderBridges(); renderLog(); }
|
||||
|
||||
function applyBridgeEvent(data){
|
||||
data._arrived = data._arrived || (Date.now()/1000);
|
||||
const ev = data.event||'';
|
||||
if(ev.includes('START')) ST.active[ckey(data)] = data;
|
||||
else delete ST.active[ckey(data)];
|
||||
ST.log.unshift(data);
|
||||
if(ST.log.length > 400) ST.log.pop();
|
||||
renderLog();
|
||||
}
|
||||
|
||||
let sock = null;
|
||||
function connect(){
|
||||
sock = new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||||
sock.onmessage = ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if(m.type==='initial'){
|
||||
setConn(m.dmrlink);
|
||||
ST.systems = m.systems||{};
|
||||
ST.bridges = m.bridges||{};
|
||||
ST.active = {};
|
||||
(m.active||[]).forEach(e => { ST.active[ckey(e)] = e; });
|
||||
ST.log = (m.log||[]);
|
||||
renderAll();
|
||||
}
|
||||
else if(m.type==='dmrlink'){
|
||||
setConn(m.connected);
|
||||
if(!m.connected){ ST.systems={}; ST.bridges={}; ST.active={}; renderAll(); }
|
||||
}
|
||||
else if(m.type==='config'){
|
||||
ST.systems = m.systems||{};
|
||||
renderSystems();
|
||||
}
|
||||
else if(m.type==='bridge'){
|
||||
ST.bridges = m.bridges||{};
|
||||
renderBridges();
|
||||
}
|
||||
else if(m.type==='bridge_event'){
|
||||
applyBridgeEvent(m.data||{});
|
||||
}
|
||||
};
|
||||
sock.onclose = () => {
|
||||
setConn(false);
|
||||
el('conn').textContent = 'DMRlink3: dashboard offline';
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
}
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
+940
@@ -0,0 +1,940 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
###############################################################################
|
||||
# Copyright (C) 2013-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
|
||||
###############################################################################
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/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
|
||||
###############################################################################
|
||||
|
||||
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'])
|
||||
@@ -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*"]
|
||||
@@ -0,0 +1,3 @@
|
||||
dmr_utils3
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
Reference in new issue
Block a user