mirror of
https://github.com/n0mjs710/dmrlink3
synced 2026-11-08 04:19:24 +00:00
Mirrors the hblink3 Step 5 work for the IPSC side:
* peer -> peer_connected / peer_disconnected (the message type now carries
the lifecycle; the dashboard consumes the new names). dmrlink3 has no
'stream' event -- its bridge_event/ping/bridge are IPSC-specific and are
deliberately left as-is (see the hblink/dmrlink dashboard-parity note:
IPSC ping is a firewall timer, not an app-level keepalive).
* Unix-socket transport for a same-box dashboard: REPORT_TRANSPORT=unix binds
start_unix_server on REPORT_SOCKET (stale socket file cleared first); the
dashboard connects via open_unix_connection(DMRLINK_SOCKET) when
DMRLINK_TRANSPORT=unix. TCP stays the default for remote dashboards. A local
socket can't NIC-flap or be conntrack-evicted, retiring the silent-severance
class for local dashboards (filesystem perms replace the IP ACL).
Both REPORT_TRANSPORT/REPORT_SOCKET and DMRLINK_TRANSPORT/DMRLINK_SOCKET are
optional (fallback to tcp), so existing configs keep working unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1363 lines
64 KiB
Python
1363 lines
64 KiB
Python
#!/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 socket
|
|
import json
|
|
import logging
|
|
import signal
|
|
import os
|
|
|
|
from binascii import b2a_hex as ahex
|
|
from binascii import a2b_hex as bhex
|
|
from collections import deque
|
|
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 (
|
|
ANY_PEER_REQUIRED, PEER_REQUIRED, MASTER_REQUIRED, USER_PACKETS,
|
|
GROUP_VOICE, PVT_VOICE, GROUP_DATA, PVT_DATA,
|
|
CALL_MON_STATUS, CALL_MON_RPT, REPEATER_BLOCKED, CALL_INTERRUPT_REQ,
|
|
XCMP_XNL, RPT_WAKE_UP,
|
|
DE_REG_REQ, DE_REG_REPLY,
|
|
MASTER_REG_REQ, MASTER_REG_REPLY,
|
|
PEER_LIST_REQ, PEER_LIST_REPLY,
|
|
PEER_REG_REQ, PEER_REG_REPLY,
|
|
MASTER_ALIVE_REQ, MASTER_ALIVE_REPLY,
|
|
PEER_ALIVE_REQ, PEER_ALIVE_REPLY,
|
|
SYSTEM_MAP_REQ, SYSTEM_MAP_REPLY, UNKNOWN_9E, WIRELINE,
|
|
REMOTE_PROG_REQ, REMOTE_PROG_REPLY, OPCODE_0xF0,
|
|
VOICE_HEAD, VOICE_TERM,
|
|
GV_BURST_TYPE_OFF, GV_CALL_INFO_OFF,
|
|
IPSC_VER,
|
|
PEER_OP_MSK, PEER_MODE_MSK, PEER_MODE_ANALOG, PEER_MODE_DIGITAL,
|
|
IPSC_TS1_MSK, IPSC_TS2_MSK,
|
|
CSBK_MSK, RPT_MON_MSK, CON_APP_MSK,
|
|
XNL_STAT_MSK, XNL_MSTR_MSK, XNL_SLAVE_MSK, PKT_AUTH_MSK,
|
|
DATA_CALL_MSK, VOICE_CALL_MSK, MSTR_PEER_MSK,
|
|
END_MSK, TS_CALL_MSK,
|
|
)
|
|
from dmr_utils3.utils import bytes_2, bytes_3, bytes_4, int_id, try_download, mk_id_dict
|
|
|
|
from config import acl_check
|
|
|
|
__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__)
|
|
|
|
# Minimum gap between calls on the same TS — handles Talker Alias VOICE_HEAD churn
|
|
TS_CLEAR_TIME = 0.2
|
|
|
|
# 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 _peer_snapshot(_pid, _p):
|
|
"""JSON-serializable view of one IPSC peer. Shared by the full-state snapshot
|
|
and the granular send_peer delta (so both carry an identical shape, incl. the
|
|
PING_LOSS quality figure)."""
|
|
st = _p['STATUS']
|
|
return {
|
|
'RADIO_ID': int_id(_pid),
|
|
'IP': _p['IP'],
|
|
'PORT': _p['PORT'],
|
|
'CONNECTED': st['CONNECTED'],
|
|
'CONNECT_TIME': st['CONNECT_TIME'],
|
|
'KEEP_ALIVES_SENT': st['KEEP_ALIVES_SENT'],
|
|
'KEEP_ALIVES_RECEIVED': st['KEEP_ALIVES_RECEIVED'],
|
|
'KEEP_ALIVES_OUTSTANDING': st['KEEP_ALIVES_OUTSTANDING'],
|
|
'KEEP_ALIVES_MISSED': st['KEEP_ALIVES_MISSED'],
|
|
'KEEP_ALIVE_RX_TIME': st['KEEP_ALIVE_RX_TIME'],
|
|
'PING_LOSS': st.get('PING_LOSS', 0),
|
|
}
|
|
|
|
|
|
def _systems_snapshot(_systems):
|
|
"""Produce a JSON-serializable view of the live SYSTEMS dict."""
|
|
result = {}
|
|
for name, sys in _systems.items():
|
|
# TRUNK systems have a different config structure; report them separately.
|
|
if sys.get('SYSTEM_TYPE') == 'TRUNK':
|
|
local = sys['LOCAL']
|
|
trunk = sys['TRUNK']
|
|
result[name] = {
|
|
'MODE': 'TRUNK',
|
|
'RADIO_ID': int_id(local['RADIO_ID']),
|
|
'IP': local['IP'] or '0.0.0.0',
|
|
'PORT': local['PORT'],
|
|
'GROUP_HANGTIME': local['GROUP_HANGTIME'],
|
|
'PEER_IP': trunk['PEER_IP'],
|
|
'PEER_PORT': trunk['PEER_PORT'],
|
|
'MASTER': {},
|
|
'PEERS': {},
|
|
}
|
|
continue
|
|
|
|
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'],
|
|
'CONNECT_TIME': master['STATUS']['CONNECT_TIME'],
|
|
'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'],
|
|
'PING_LOSS': master['STATUS'].get('PING_LOSS', 0),
|
|
},
|
|
'PEERS': {
|
|
str(int_id(pid)): _peer_snapshot(pid, p)
|
|
for pid, p in peers.items()
|
|
},
|
|
}
|
|
return result
|
|
|
|
|
|
# Turn on TCP keepalive with aggressive timers so a silently-severed connection
|
|
# (NIC/interface flap, DHCP renew, firewall/conntrack eviction, router reboot --
|
|
# anything that drops the flow without a clean FIN/RST) is detected by the OS
|
|
# within ~2 minutes instead of the default ~2 hours. The transport then errors,
|
|
# which unblocks the read loop and sheds the client. Only meaningful for
|
|
# non-loopback connections; harmless on loopback.
|
|
def _enable_tcp_keepalive(_writer, _idle=60, _intvl=15, _cnt=4):
|
|
sock = _writer.get_extra_info('socket')
|
|
if sock is None:
|
|
return
|
|
try:
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
if hasattr(socket, 'TCP_KEEPIDLE'): # Linux
|
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, _idle)
|
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, _intvl)
|
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, _cnt)
|
|
except OSError as e:
|
|
logger.warning('(GLOBAL) Could not set TCP keepalive: %s', e)
|
|
|
|
|
|
# Drop a client whose outbound buffer has grown past this, i.e. it is not
|
|
# reading -- a stuck/dead peer we haven't detected yet. Bounds memory so one
|
|
# wedged dashboard can't grow dmrlink3 without limit.
|
|
_REPORT_WRITE_BUFFER_LIMIT = 1 << 20 # 1 MiB
|
|
|
|
# Real peer/bridge state changes now travel as granular delta events (send_peer,
|
|
# send_bridge), so the full config+bridge snapshot no longer needs to go out every
|
|
# REPORT_INTERVAL. periodic_push re-pushes the full state only this often (a
|
|
# reconciliation safety net); the other ticks send a tiny heartbeat so the
|
|
# dashboard's link-liveness read timeout stays fed. Mirrors hblink3.
|
|
REPORT_RESYNC_SECONDS = 60
|
|
|
|
|
|
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):
|
|
# Transport is TCP by default (dashboards commonly run on a separate host).
|
|
# REPORT_TRANSPORT=unix binds a local Unix-domain socket instead -- a
|
|
# same-box dashboard that can't NIC-flap or be conntrack-evicted, retiring
|
|
# the whole silent-severance class. Mirrors hblink3/hblink4's selector.
|
|
reports = self._config['REPORTS']
|
|
transport = reports.get('REPORT_TRANSPORT', 'tcp')
|
|
allowed = reports['REPORT_CLIENTS']
|
|
try:
|
|
if transport == 'unix':
|
|
path = reports.get('REPORT_SOCKET', '')
|
|
# Remove a stale socket file left by an unclean exit before binding.
|
|
if path and os.path.exists(path):
|
|
try:
|
|
os.unlink(path)
|
|
except OSError:
|
|
pass
|
|
server = await asyncio.start_unix_server(
|
|
lambda r, w: self._client_connected(r, w, allowed, unix=True), path=path)
|
|
logger.info('(GLOBAL) DMRlink3 reporting server listening on unix socket %s', path)
|
|
else:
|
|
server = await asyncio.start_server(
|
|
lambda r, w: self._client_connected(r, w, allowed, unix=False),
|
|
host='0.0.0.0', port=reports['REPORT_PORT'])
|
|
logger.info('(GLOBAL) DMRlink3 reporting server listening on port %s', reports['REPORT_PORT'])
|
|
except OSError as e:
|
|
logger.error('(GLOBAL) Reporting server could not bind (%s): %s', transport, e)
|
|
return
|
|
async with server:
|
|
await server.serve_forever()
|
|
|
|
async def _client_connected(self, reader, writer, allowed, unix=False):
|
|
if unix:
|
|
# Filesystem permissions govern a Unix socket -- no IP ACL, and a local
|
|
# socket can't silently sever so TCP keepalive is moot.
|
|
peer = 'unix'
|
|
else:
|
|
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
|
|
peer = '%s:%s' % (addr[0], addr[1])
|
|
_enable_tcp_keepalive(writer)
|
|
logger.info('(GLOBAL) Reporting client connected: %s', peer)
|
|
self.clients.append(writer)
|
|
self.send_config() # push current state immediately; don't wait for the next periodic tick
|
|
self.send_bridge() # no-op in base class; BridgeReportServer overrides this
|
|
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', peer)
|
|
|
|
def _send_json(self, obj):
|
|
data = (json.dumps(obj) + '\n').encode()
|
|
dead = []
|
|
for writer in self.clients:
|
|
# StreamWriter.write() buffers and almost never raises for a broken
|
|
# peer, so a dead connection is caught two other ways: is_closing()
|
|
# (set once the OS/keepalive has errored the transport) and an
|
|
# over-limit write buffer (the client has stopped reading).
|
|
if writer.is_closing():
|
|
dead.append(writer)
|
|
continue
|
|
try:
|
|
transport = writer.transport
|
|
if transport is not None and \
|
|
transport.get_write_buffer_size() > _REPORT_WRITE_BUFFER_LIMIT:
|
|
logger.warning('(GLOBAL) Dropping unresponsive client %s (write buffer over limit)',
|
|
writer.get_extra_info('peername'))
|
|
dead.append(writer)
|
|
continue
|
|
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)
|
|
try:
|
|
w.close()
|
|
except Exception:
|
|
pass
|
|
|
|
def send_config(self):
|
|
# Advertise the push interval so the dashboard can size its "link dead"
|
|
# read timeout relative to how often we actually send (this config push
|
|
# is the de-facto heartbeat / slow resync).
|
|
self._send_json({'type': 'config',
|
|
'systems': _systems_snapshot(self._config['SYSTEMS']),
|
|
# Dashboard flags a peer/master gold at/above this ping-loss %.
|
|
'ping_loss_warn': self._config['GLOBAL'].get('PING_LOSS_WARN', 5),
|
|
'report_interval': self._config['REPORTS']['REPORT_INTERVAL']})
|
|
|
|
def send_bridge(self):
|
|
pass # Overridden by BridgeReportServer in bridge.py
|
|
|
|
def send_bridge_event(self, _event):
|
|
pass # Overridden by BridgeReportServer in bridge.py
|
|
|
|
# Granular peer lifecycle delta (an IPSC peer registering or dropping) so the
|
|
# dashboard reflects membership changes immediately instead of waiting for the
|
|
# next slow resync. _info is a per-peer snapshot view on 'connected', omitted
|
|
# on 'disconnected'. Mirrors hblink3's send_peer.
|
|
def send_peer(self, _system, _radio_id, _action, _info=None):
|
|
# Canonical vocabulary: 'peer_connected' / 'peer_disconnected' (the type
|
|
# carries the lifecycle; _action is 'connected'|'disconnected').
|
|
evt = {'type': 'peer_' + _action, 'system': _system, 'radio_id': _radio_id}
|
|
if _info is not None:
|
|
evt['info'] = _info
|
|
self._send_json(evt)
|
|
|
|
def send_rcm(self, _data):
|
|
pass # RCM monitoring not forwarded in dmrlink3
|
|
|
|
# True on the periodic ticks where a full state resync is due (a reconciliation
|
|
# safety net, ~REPORT_RESYNC_SECONDS apart); on the other ticks send only a
|
|
# tiny heartbeat so the dashboard's link-liveness timeout stays fed without
|
|
# re-pushing full state. Real state changes travel as granular events.
|
|
def _resync_due(self):
|
|
self._resync_tick = getattr(self, '_resync_tick', -1) + 1
|
|
interval = max(1, self._config['REPORTS'].get('REPORT_INTERVAL', 10))
|
|
every = max(1, round(REPORT_RESYNC_SECONDS / interval))
|
|
if self._resync_tick % every == 0:
|
|
return True
|
|
self._send_json({'type': 'ping'})
|
|
return False
|
|
|
|
def periodic_push(self):
|
|
# Full config+bridge only on the resync tick; heartbeat otherwise.
|
|
# send_bridge is a no-op in the base class (overridden by BridgeReportServer).
|
|
if self._resync_due():
|
|
self.send_config()
|
|
self.send_bridge()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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.periodic_push()
|
|
report_server = _factory(_config)
|
|
loop.create_task(report_server.start())
|
|
loop.create_task(run_periodic(_config['REPORTS']['REPORT_INTERVAL'],
|
|
reporting_loop, 'reporting', report_server))
|
|
logger.info('(GLOBAL) DMRlink3 reporting server configured (%s)',
|
|
_config['REPORTS'].get('REPORT_TRANSPORT', 'tcp'))
|
|
return report_server
|
|
|
|
else:
|
|
logger.info('(GLOBAL) TCP reporting disabled')
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ID alias helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_aliases(_config):
|
|
if not _config['ALIASES'].get('USE_ALIASES', True):
|
|
logger.info('ID ALIAS MAPPER: disabled in configuration')
|
|
return {}, {}, {}, {}
|
|
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')
|
|
|
|
local_peer_path = _config['ALIASES']['PATH'] + 'local_peer_ids.json'
|
|
try:
|
|
with open(local_peer_path, 'r') as _f:
|
|
local_peer_ids = json.load(_f)
|
|
count = 0
|
|
for _id, _call in local_peer_ids.items():
|
|
peer_ids[int(_id)] = _call
|
|
count += 1
|
|
logger.info('ID ALIAS MAPPER: local_peer_ids.json merged %d entries into peer_ids', count)
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
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].get('SYSTEM_TYPE') == 'TRUNK':
|
|
continue # TRUNK systems are instantiated separately by bridge.py
|
|
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']
|
|
|
|
# Dashboard-reporting state (mirrors hblink3):
|
|
# _reported_peers -- radio_ids last reported 'connected' via send_peer,
|
|
# so the maintenance-loop reconciliation sweep emits deltas only on change.
|
|
# _ping_q -- radio_id -> {'ev': deque[(ts, missed)], 'base': float, 'warm': list}
|
|
# sliding window of keepalive observations feeding the PING_LOSS metric.
|
|
self._reported_peers = set()
|
|
self._ping_q = {}
|
|
|
|
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
|
|
|
|
self.rx_start = {1: 0, 2: 0} # per-TS RX call start timestamp (VOICE_HEAD → VOICE_TERM)
|
|
self.tx_start = {1: 0, 2: 0} # per-TS TX call start timestamp (forwarded HEAD → TERM)
|
|
|
|
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_running_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)
|
|
|
|
# Master watchdog: any packet from a registered peer refreshes their keepalive timestamp,
|
|
# preventing false timeouts when keepalive packets are lost but voice/data is flowing.
|
|
if self._local['MASTER_PEER'] and len(data) >= 5 and _peerid in self._peers:
|
|
self._peers[_peerid]['STATUS']['KEEP_ALIVE_RX_TIME'] = int(time())
|
|
|
|
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]
|
|
_end = bool(_call_info & END_MSK)
|
|
|
|
if _packettype == GROUP_VOICE:
|
|
# Timeslot detection: VOICE_HEAD/VOICE_TERM encode TS in call_info byte;
|
|
# SLOT1/SLOT2_VOICE encode it in bit 7 of the burst_type byte instead.
|
|
_burst_type = data[GV_BURST_TYPE_OFF]
|
|
if _burst_type in (VOICE_HEAD, VOICE_TERM):
|
|
_ts = 2 if (_call_info & TS_CALL_MSK) else 1
|
|
else:
|
|
_ts = 2 if (_burst_type & 0x80) else 1
|
|
self.reset_keep_alive(_peerid)
|
|
self.group_voice(_src_sub, _dst_sub, _ts, _end, _peerid, data)
|
|
else:
|
|
_ts = 2 if (_call_info & TS_CALL_MSK) else 1
|
|
if _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:
|
|
logger.debug('(%s) XCMP/XNL from %s:%s — ignored', self._system, host, port)
|
|
elif _packettype == CALL_MON_STATUS:
|
|
self.call_mon_status(data)
|
|
elif _packettype == CALL_MON_RPT:
|
|
self.call_mon_rpt(data)
|
|
elif _packettype == REPEATER_BLOCKED:
|
|
self.repeater_blocked(data)
|
|
elif _packettype == CALL_INTERRUPT_REQ:
|
|
logger.debug('(%s) CALL_INTERRUPT_REQ from %s:%s', self._system, host, port)
|
|
elif _packettype == DE_REG_REQ:
|
|
self.send_packet(self.DE_REG_REPLY_PKT, (host, port))
|
|
self.de_register_peer(_peerid)
|
|
if self._local['MASTER_PEER'] and self._peers:
|
|
self.send_to_ipsc(self.PEER_LIST_REPLY_PKT + build_peer_list(self._peers))
|
|
logger.info('(%s) Peer De-Registration From: %s, %s:%s',
|
|
self._system, int_id(_peerid), host, port)
|
|
elif _packettype == DE_REG_REPLY:
|
|
logger.info('(%s) 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, host, port)
|
|
elif _packettype == OPCODE_0xF0:
|
|
logger.debug('(%s) 0xF0 from %s:%s — benign, no response', self._system, host, port)
|
|
elif _packettype in (SYSTEM_MAP_REQ, SYSTEM_MAP_REPLY, UNKNOWN_9E,
|
|
WIRELINE, REMOTE_PROG_REQ, REMOTE_PROG_REPLY):
|
|
logger.debug('(%s) Opcode %s from %s:%s — no handler',
|
|
self._system, ahex(_packettype), host, port)
|
|
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 repeater_blocked(self, _data):
|
|
logger.debug('(%s) Repeater Blocked 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))
|
|
_burst_type = _data[GV_BURST_TYPE_OFF]
|
|
_now = time()
|
|
if _burst_type == VOICE_HEAD:
|
|
if self.rx_start[_ts] == 0 or (_now - self.rx_start[_ts]) > TS_CLEAR_TIME:
|
|
self.rx_start[_ts] = _now
|
|
logger.info('(%s) GROUP VOICE START: Peer: %s, Src: %s, TS: %s, TGID: %s',
|
|
self._system, int_id(_peerid), int_id(_src_sub), _ts, int_id(_dst_sub))
|
|
if self._report:
|
|
self._report.send_bridge_event({
|
|
'event': 'GROUP VOICE START',
|
|
'system': self._system,
|
|
'peer': int_id(_peerid),
|
|
'src': int_id(_src_sub),
|
|
'ts': _ts,
|
|
'tgid': int_id(_dst_sub),
|
|
})
|
|
elif _burst_type == VOICE_TERM:
|
|
if self.rx_start[_ts] > 0:
|
|
_duration = _now - self.rx_start[_ts]
|
|
self.rx_start[_ts] = 0
|
|
logger.info('(%s) GROUP VOICE END: Peer: %s, Src: %s, TS: %s, TGID: %s Duration: %.2fs',
|
|
self._system, int_id(_peerid), int_id(_src_sub), _ts, int_id(_dst_sub), _duration)
|
|
if self._report:
|
|
self._report.send_bridge_event({
|
|
'event': 'GROUP VOICE END',
|
|
'system': self._system,
|
|
'peer': int_id(_peerid),
|
|
'src': int_id(_src_sub),
|
|
'ts': _ts,
|
|
'tgid': int_id(_dst_sub),
|
|
'duration': round(_duration, 2),
|
|
})
|
|
|
|
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))
|
|
elif self.valid_master(_peerid):
|
|
self._master_stat['CONNECTED'] = False
|
|
self._master_stat['CONNECT_TIME'] = 0
|
|
self._master_stat['PEER_LIST'] = False
|
|
logger.info('(%s) Master De-Registered: %s', self._system, int_id(_peerid))
|
|
else:
|
|
logger.warning('(%s) De-Registration from unknown source: %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,
|
|
'CONNECT_TIME': 0,
|
|
'KEEP_ALIVES_SENT': 0,
|
|
'KEEP_ALIVES_MISSED': 0,
|
|
'KEEP_ALIVES_OUTSTANDING': 0,
|
|
'KEEP_ALIVES_RECEIVED': 0,
|
|
'KEEP_ALIVE_RX_TIME': 0,
|
|
'PING_LOSS': 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 transmit_group_voice(self, _src_sub, _dst_group, _ts, _burst_type, _data, _src_peer=None):
|
|
"""Broadcast a GROUP_VOICE packet to all IPSC peers and track TX call state."""
|
|
_now = time()
|
|
if _burst_type == VOICE_HEAD:
|
|
if self.tx_start[_ts] == 0 or (_now - self.tx_start[_ts]) > TS_CLEAR_TIME:
|
|
self.tx_start[_ts] = _now
|
|
logger.info('(%s) GROUP VOICE TX START: TS: %s, TGID: %s, Src: %s, SrcPeer: %s',
|
|
self._system, _ts, int_id(_dst_group),
|
|
int_id(_src_sub), int_id(_src_peer) if _src_peer else '—')
|
|
if self._report:
|
|
self._report.send_bridge_event({
|
|
'event': 'GROUP VOICE TX START',
|
|
'system': self._system,
|
|
'ts': _ts,
|
|
'tgid': int_id(_dst_group),
|
|
'src': int_id(_src_sub),
|
|
'src_peer': int_id(_src_peer) if _src_peer else None,
|
|
})
|
|
elif _burst_type == VOICE_TERM:
|
|
if self.tx_start[_ts] > 0:
|
|
_duration = _now - self.tx_start[_ts]
|
|
self.tx_start[_ts] = 0
|
|
logger.info('(%s) GROUP VOICE TX END: TS: %s, TGID: %s Duration: %.2fs',
|
|
self._system, _ts, int_id(_dst_group), _duration)
|
|
if self._report:
|
|
self._report.send_bridge_event({
|
|
'event': 'GROUP VOICE TX END',
|
|
'system': self._system,
|
|
'ts': _ts,
|
|
'tgid': int_id(_dst_group),
|
|
'duration': round(_duration, 2),
|
|
})
|
|
self.send_to_ipsc(_data)
|
|
|
|
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())
|
|
self._note_keepalive(_peerid) # PEER mode: this peer's reply to our keepalive
|
|
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
|
|
self._peers[_peerid]['STATUS']['CONNECT_TIME'] = int(time())
|
|
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())
|
|
self._note_keepalive(_peerid) # PEER mode: master's reply to our keepalive
|
|
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['CONNECT_TIME'] = int(time())
|
|
self._master_stat['KEEP_ALIVES_OUTSTANDING'] = 0
|
|
logger.info('(%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'])
|
|
# Request peer list immediately instead of waiting for the next maintenance loop tick
|
|
if self._local['NUM_PEERS']:
|
|
self.send_packet(self.PEER_LIST_REQ_PKT, self._master_sock)
|
|
logger.info('(%s) Requesting peer list from master', self._system)
|
|
|
|
def master_reg_req(self, _data, _peerid, _host, _port):
|
|
if not acl_check(_peerid, self._local['REG_ACL']):
|
|
logger.warning('(%s) Peer Registration ***REJECTED BY ACL***: %s %s:%s',
|
|
self._system, int_id(_peerid), _host, _port)
|
|
return
|
|
|
|
_hex_mode = _data[5:6]
|
|
_hex_flags = _data[6:10]
|
|
|
|
is_new = _peerid not in self._peers
|
|
if is_new:
|
|
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,
|
|
'CONNECT_TIME': int(time()),
|
|
'KEEP_ALIVES_SENT': 0,
|
|
'KEEP_ALIVES_MISSED': 0,
|
|
'KEEP_ALIVES_OUTSTANDING': 0,
|
|
'KEEP_ALIVES_RECEIVED': 0,
|
|
'KEEP_ALIVE_RX_TIME': int(time()),
|
|
'PING_LOSS': 0,
|
|
},
|
|
}
|
|
else:
|
|
self._peers[_peerid].update({
|
|
'IP': _host, 'PORT': _port,
|
|
'MODE': _hex_mode, 'MODE_DECODE': process_mode_byte(_hex_mode),
|
|
'FLAGS': _hex_flags, 'FLAGS_DECODE': process_flags_bytes(_hex_flags),
|
|
})
|
|
self._peers[_peerid]['STATUS']['KEEP_ALIVE_RX_TIME'] = int(time())
|
|
|
|
self._local['NUM_PEERS'] = len(self._peers)
|
|
|
|
reg_reply = MASTER_REG_REPLY + self._local_id + self.TS_FLAGS + bytes_2(self._local['NUM_PEERS']) + IPSC_VER
|
|
self.send_packet(reg_reply, (_host, _port))
|
|
# Push current peer list immediately; no need for peer to request it first
|
|
self.send_packet(self.PEER_LIST_REPLY_PKT + build_peer_list(self._peers), (_host, _port))
|
|
|
|
if is_new:
|
|
logger.info('(%s) Peer Registered: %s, %s:%s (IPSC now has %s peers)',
|
|
self._system, int_id(_peerid), _host, _port, self._local['NUM_PEERS'])
|
|
# Push updated peer list to all existing registered peers
|
|
for existing_id, ep in list(self._peers.items()):
|
|
if existing_id != _peerid:
|
|
self.send_packet(self.PEER_LIST_REPLY_PKT + build_peer_list(self._peers),
|
|
(ep['IP'], ep['PORT']))
|
|
else:
|
|
logger.info('(%s) Peer Re-Registered: %s, %s:%s',
|
|
self._system, int_id(_peerid), _host, _port)
|
|
|
|
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._note_keepalive(_peerid) # MASTER mode: this peer's keepalive to us
|
|
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, _host, _port):
|
|
if _peerid in self._peers:
|
|
logger.debug('(%s) Peer List Request from peer %s', self._system, int_id(_peerid))
|
|
self.send_packet(self.PEER_LIST_REPLY_PKT + build_peer_list(self._peers), (_host, _port))
|
|
else:
|
|
logger.warning('(%s) Peer List Request from *UNREGISTERED* peer %s',
|
|
self._system, int_id(_peerid))
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Ping-loss quality metric (mirrors hblink3's per-repeater PING_LOSS).
|
|
# Detects a peer/master that STAYS CONNECTED but loses keepalives (lossy link
|
|
# -> choppy audio), which a simple up/down status never shows. hblink3 has to
|
|
# self-calibrate an unknown repeater ping cadence; IPSC's keepalive cadence is
|
|
# known -- ALIVE_TIMER -- so we use it directly as the expected interval. One
|
|
# uniform path serves BOTH roles: we note every keepalive-class packet OBSERVED
|
|
# from a radio and infer how many were lost in the gap since the previous one.
|
|
# MASTER mode observes peers' MASTER_ALIVE_REQs; PEER mode observes the master's
|
|
# / peers' *_ALIVE_REPLYs to our requests -- either way a lost keepalive widens
|
|
# the gap by (about) one interval. Window is a deque pruned to PING_LOSS_WINDOW
|
|
# minutes; loss% = missed / (received + missed).
|
|
# -----------------------------------------------------------------------
|
|
def _ping_loss_window_secs(self):
|
|
return min(60, max(1, self._CONFIG['GLOBAL'].get('PING_LOSS_WINDOW', 5))) * 60
|
|
|
|
def _prune_pings(self, _ev, _now):
|
|
cutoff = _now - self._ping_loss_window_secs()
|
|
while _ev and _ev[0][0] < cutoff:
|
|
_ev.popleft()
|
|
|
|
def _note_keepalive(self, _radio_id, _now=None):
|
|
now = time() if _now is None else _now
|
|
base = self._local['ALIVE_TIMER'] or 0
|
|
st = self._ping_q.get(_radio_id)
|
|
if st is None:
|
|
st = self._ping_q[_radio_id] = {'ev': deque(), 'last': 0.0}
|
|
gap = (now - st['last']) if st['last'] else 0.0
|
|
# Between base*1.5 (jitter guard) and the keepalive-timeout horizon, infer
|
|
# how many keepalives were lost in the gap. A gap beyond the horizon means
|
|
# the session lapsed (peer was down / re-registered) -- that is a disconnect,
|
|
# not a lossy link, so don't attribute the downtime as loss; start fresh.
|
|
horizon = base * (self._local.get('MAX_MISSED', 5) + 1)
|
|
if base > 0 and base * 1.5 < gap <= horizon:
|
|
missed = max(0, round(gap / base) - 1)
|
|
else:
|
|
missed = 0
|
|
st['ev'].append((now, missed))
|
|
st['last'] = now
|
|
self._prune_pings(st['ev'], now)
|
|
|
|
def _ping_loss_pct(self, _radio_id, _now):
|
|
st = self._ping_q.get(_radio_id)
|
|
if not st:
|
|
return 0
|
|
self._prune_pings(st['ev'], _now)
|
|
received = len(st['ev'])
|
|
missed = sum(m for _, m in st['ev'])
|
|
total = received + missed
|
|
return round(100.0 * missed / total) if total > 0 else 0
|
|
|
|
# Run once per maintenance sweep from either loop: refresh the PING_LOSS figure
|
|
# for each connected peer (and, in PEER mode, the master) so it rides the next
|
|
# snapshot / delta, prune dead radios' history, then emit granular peer
|
|
# connect/disconnect deltas for any change since the last sweep. The peer delta
|
|
# is a centralized reconciliation over self._peers -- it can't miss a scattered
|
|
# add/remove path and only emits on actual change (never a full-state flood).
|
|
# Mirrors hblink3.HBSYSTEM.report_peer_deltas. (The master link-state, a single
|
|
# well-known entity, rides the periodic snapshot rather than a delta.)
|
|
def report_maintenance(self):
|
|
now = time()
|
|
for pid, p in self._peers.items():
|
|
if p['STATUS']['CONNECTED']:
|
|
p['STATUS']['PING_LOSS'] = self._ping_loss_pct(pid, now)
|
|
if not self._local['MASTER_PEER']:
|
|
self._master_stat['PING_LOSS'] = (
|
|
self._ping_loss_pct(self._master['RADIO_ID'], now)
|
|
if self._master_stat['CONNECTED'] else 0)
|
|
for rid in list(self._ping_q):
|
|
self._prune_pings(self._ping_q[rid]['ev'], now)
|
|
if not self._ping_q[rid]['ev']:
|
|
del self._ping_q[rid]
|
|
if not self._report:
|
|
return
|
|
current = {pid for pid, p in self._peers.items() if p['STATUS']['CONNECTED']}
|
|
for pid in current - self._reported_peers:
|
|
self._report.send_peer(self._system, int_id(pid), 'connected',
|
|
_peer_snapshot(pid, self._peers[pid]))
|
|
for pid in self._reported_peers - current:
|
|
self._report.send_peer(self._system, int_id(pid), 'disconnected')
|
|
self._reported_peers = current
|
|
|
|
# -----------------------------------------------------------------------
|
|
# 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))
|
|
self.report_maintenance()
|
|
|
|
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['CONNECT_TIME'] = 0
|
|
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
|
|
self._master_stat['CONNECT_TIME'] = 0
|
|
|
|
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
|
|
|
|
self.report_maintenance()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# __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())
|