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:
Cort BuffingtonandClaude Sonnet 4.6 committed 2026-06-26 12:41:31 -05:00
commit ce191c2b34
12 files changed
+2633

No files matched your search

+483
View File
@@ -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())
+73
View File
@@ -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)
+217
View File
@@ -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))
+124
View 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
+34
View File
@@ -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
+245
View File
@@ -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()
+246
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>'
+ ' &nbsp;<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>
+163
View File
@@ -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
View File
@@ -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())
+82
View File
@@ -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'])
+23
View File
@@ -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*"]
+3
View File
@@ -0,0 +1,3 @@
dmr_utils3
fastapi
uvicorn[standard]