mirror of
https://github.com/n0mjs710/dmrlink3
synced 2026-11-08 04:19:24 +00:00
Ports hblink3's dashboard-reporting overhaul to dmrlink3's IPSC path so the
two converge (plan: ~/development/dashboard-reporting-normalization.md):
- Granular peer events: a centralized reconciliation sweep in both the master
and peer maintenance loops (report_maintenance) diffs currently-connected
peers vs the last-reported set and emits send_peer connected/disconnected
deltas only on change -- the dashboard reflects membership immediately
instead of waiting for the periodic full push. (The single master link-state,
in PEER mode, continues to ride the snapshot.)
- Demote the full push: reporting_loop now calls periodic_push, which re-pushes
the full config+bridge snapshot only every REPORT_RESYNC_SECONDS (60s) as a
reconciliation safety net and sends a tiny {'type':'ping'} heartbeat on the
other ticks. Idle feed stops re-serializing full state every REPORT_INTERVAL.
- Ping-loss quality metric (PING_LOSS): detects a peer/master that STAYS
CONNECTED but loses keepalives (lossy link -> choppy audio). One uniform
gap-inference path serves both roles -- MASTER mode observes peers'
MASTER_ALIVE_REQs, PEER mode observes the master's/peers' *_ALIVE_REPLYs to
our requests. IPSC's cadence is known (ALIVE_TIMER) so no self-calibration is
needed (unlike hblink3). A gap beyond the keepalive-timeout horizon is treated
as a session lapse (reconnect), not loss. Sliding window pruned to
PING_LOSS_WINDOW minutes; dashboard flags the row gold at >= PING_LOSS_WARN%.
Config: PING_LOSS_WINDOW / PING_LOSS_WARN added to GLOBAL (optional, defaults).
Dashboard: backend applies peer deltas + ignores the ping heartbeat; frontend
renders "N% ping loss" and golds the radio id. Tests: TestPeerEvents +
TestPingLoss (73 pass). Verified live over real sockets (peer delta fired,
heartbeat/resync split confirmed, PING_LOSS climbed to 30% under a lossy peer)
and deployed to the live dmrlink3 services clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
284 lines
10 KiB
Python
284 lines
10 KiB
Python
"""Shared test utilities for dmrlink3 unit tests."""
|
|
|
|
import struct
|
|
from time import time as _time
|
|
|
|
from const import (
|
|
GROUP_VOICE, VOICE_HEAD, VOICE_TERM, SLOT1_VOICE, SLOT2_VOICE,
|
|
GV_CALL_INFO_OFF, GV_BURST_TYPE_OFF,
|
|
IPSC_VER, TS_CALL_MSK,
|
|
MASTER_REG_REQ, MASTER_ALIVE_REQ, DE_REG_REQ, PEER_LIST_REQ,
|
|
)
|
|
from dmr_utils3.utils import bytes_2, bytes_3, bytes_4
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mode / flags constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Operational, digital, TS1+TS2 linked — matches config.py with PEER_OPER=True,
|
|
# IPSC_MODE=DIGITAL, TS1_LINK=True, TS2_LINK=True.
|
|
# Bit layout: 01 (oper) | 10 (digital) | 10 (TS1 on) | 10 (TS2 on) = 0x6A
|
|
MODE_DIGITAL_TS12 = bytes([0b01101010])
|
|
|
|
# FLAGS bytes 3 and 4 for CON_APP=True, DATA_CALL=True, VOICE_CALL=True
|
|
# byte3 = 0x20 (CON_APP bit), byte4 = 0x0D (DATA|VOICE|MASTER) or 0x0C (DATA|VOICE)
|
|
FLAGS_MASTER = b'\x00\x00\x20\x0D' # …MASTER_PEER=True
|
|
FLAGS_PEER = b'\x00\x00\x20\x0C' # …MASTER_PEER=False
|
|
|
|
TS_FLAGS_MASTER = MODE_DIGITAL_TS12 + FLAGS_MASTER # 5 bytes
|
|
TS_FLAGS_PEER = MODE_DIGITAL_TS12 + FLAGS_PEER # 5 bytes
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config factories
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def make_system(radio_id_int, ip, port, master_peer=True,
|
|
master_ip='127.0.0.1', master_port=50100):
|
|
"""Return a SYSTEMS entry identical in structure to what config.py produces."""
|
|
radio_id = bytes_4(radio_id_int)
|
|
flags = FLAGS_MASTER if master_peer else FLAGS_PEER
|
|
return {
|
|
'LOCAL': {
|
|
'ENABLED': True,
|
|
'PEER_OPER': True,
|
|
'IPSC_MODE': 'DIGITAL',
|
|
'TS1_LINK': True,
|
|
'TS2_LINK': True,
|
|
'MODE': MODE_DIGITAL_TS12,
|
|
'FLAGS': flags,
|
|
'AUTH_ENABLED': False,
|
|
'CSBK_CALL': False,
|
|
'RCM': False,
|
|
'CON_APP': True,
|
|
'XNL_CALL': False,
|
|
'XNL_MASTER': False,
|
|
'DATA_CALL': True,
|
|
'VOICE_CALL': True,
|
|
'MASTER_PEER': master_peer,
|
|
'RADIO_ID': radio_id,
|
|
'IP': ip,
|
|
'PORT': port,
|
|
'ALIVE_TIMER': 5,
|
|
'MAX_MISSED': 5,
|
|
'AUTH_KEY': b'\x00' * 20,
|
|
'GROUP_HANGTIME': 5,
|
|
'NUM_PEERS': 0,
|
|
# PERMIT:ALL — mirrors what process_acls() produces at runtime.
|
|
# acl_build('PERMIT:ALL', PEER_MAX) → (True, frozenset(), (1,), (4294967295,))
|
|
'REG_ACL': (True, frozenset(), (1,), (4294967295,)),
|
|
},
|
|
'MASTER': {
|
|
'RADIO_ID': b'\x00\x00\x00\x00',
|
|
'MODE': b'\x00',
|
|
'MODE_DECODE': '',
|
|
'FLAGS': b'\x00\x00\x00\x00',
|
|
'FLAGS_DECODE': '',
|
|
'IP': master_ip if not master_peer else '',
|
|
'PORT': master_port if not master_peer else '',
|
|
'STATUS': {
|
|
'CONNECTED': False,
|
|
'CONNECT_TIME': 0,
|
|
'PEER_LIST': False,
|
|
'KEEP_ALIVES_SENT': 0,
|
|
'KEEP_ALIVES_MISSED': 0,
|
|
'KEEP_ALIVES_OUTSTANDING': 0,
|
|
'KEEP_ALIVES_RECEIVED': 0,
|
|
'KEEP_ALIVE_RX_TIME': 0,
|
|
},
|
|
},
|
|
'PEERS': {},
|
|
}
|
|
|
|
|
|
def make_config(systems_dict=None):
|
|
"""Return a minimal CONFIG dict suitable for IPSC / bridgeIPSC construction."""
|
|
return {
|
|
'GLOBAL': {
|
|
'PATH': '/tmp',
|
|
# PERMIT:ALL — mirrors what process_acls() produces at runtime.
|
|
# acl_build('PERMIT:ALL', ID_MAX) → (True, frozenset(), (1,), (16776415,))
|
|
'SUB_ACL': (True, frozenset(), (1,), (16776415,)),
|
|
},
|
|
'REPORTS': {
|
|
'REPORT_NETWORKS': 'NONE',
|
|
'REPORT_RCM': False,
|
|
'REPORT_INTERVAL': 60,
|
|
'REPORT_PORT': 19999,
|
|
'REPORT_CLIENTS': ['127.0.0.1'],
|
|
'PRINT_PEERS_INC_MODE': False,
|
|
'PRINT_PEERS_INC_FLAGS': False,
|
|
},
|
|
'LOGGER': {
|
|
'LOG_FILE': '/dev/null',
|
|
'LOG_HANDLERS': 'null',
|
|
'LOG_LEVEL': 'ERROR',
|
|
'LOG_NAME': 'test',
|
|
},
|
|
'ALIASES': {
|
|
'TRY_DOWNLOAD': False,
|
|
'LOCAL_FILE': False,
|
|
'PATH': '/tmp',
|
|
'PEER_FILE': 'peer_ids.json',
|
|
'SUBSCRIBER_FILE': 'subscriber_ids.json',
|
|
'TGID_FILE': 'talkgroup_ids.json',
|
|
'PEER_URL': '',
|
|
'SUBSCRIBER_URL': '',
|
|
'STALE_TIME': 0,
|
|
},
|
|
'SYSTEMS': systems_dict or {},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mock transport
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class MockDatagramTransport:
|
|
"""Captures sendto() calls for assertion in tests."""
|
|
def __init__(self):
|
|
self.sent = []
|
|
|
|
def sendto(self, data, addr=None):
|
|
self.sent.append((bytes(data), addr))
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Peer state helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def make_peer_entry(host='127.0.0.1', port=50200):
|
|
"""Return a pre-built peer STATUS dict matching what master_reg_req creates."""
|
|
return {
|
|
'IP': host,
|
|
'PORT': port,
|
|
'MODE': MODE_DIGITAL_TS12,
|
|
'MODE_DECODE': {},
|
|
'FLAGS': FLAGS_PEER,
|
|
'FLAGS_DECODE': {},
|
|
'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,
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Packet builders
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def make_reg_req(peer_id_bytes, ts_flags=None):
|
|
"""MASTER_REG_REQ packet sent by a joining peer."""
|
|
return MASTER_REG_REQ + peer_id_bytes + (ts_flags or TS_FLAGS_PEER) + IPSC_VER
|
|
|
|
|
|
def make_alive_req(peer_id_bytes, ts_flags=None):
|
|
"""MASTER_ALIVE_REQ (keepalive) sent by a peer."""
|
|
return MASTER_ALIVE_REQ + peer_id_bytes + (ts_flags or TS_FLAGS_PEER) + IPSC_VER
|
|
|
|
|
|
def make_dereg_req(peer_id_bytes):
|
|
"""DE_REG_REQ sent by a leaving peer."""
|
|
return DE_REG_REQ + peer_id_bytes
|
|
|
|
|
|
def make_peer_list_req(peer_id_bytes):
|
|
"""PEER_LIST_REQ sent by a peer."""
|
|
return PEER_LIST_REQ + peer_id_bytes
|
|
|
|
|
|
def make_gv_packet(peer_id, src_sub, dst_group, burst_type, timeslot, seq=0):
|
|
"""
|
|
Build a GROUP_VOICE packet suitable for datagram_received().
|
|
|
|
Packet layout (65 bytes total):
|
|
[0] GROUP_VOICE opcode
|
|
[1:5] peer_id
|
|
[5] seq_id (informational only — Talker Alias firmware churns this)
|
|
[6:9] src_sub
|
|
[9:12] dst_group
|
|
[12] call_type
|
|
[13:17] call_ctrl
|
|
[17] call_info — bit 5 = TS (for VOICE_HEAD/VOICE_TERM)
|
|
[18:30] RTP header (12 bytes)
|
|
[30] burst_type — bit 7 = TS (for SLOT1/SLOT2_VOICE)
|
|
[31:34] dst_group — start of DMR payload (LC dst field for TGID rewrite)
|
|
[34:37] src_sub — DMR LC src field
|
|
[37:65] zeros
|
|
"""
|
|
call_info = TS_CALL_MSK if timeslot == 2 else 0
|
|
|
|
return (
|
|
GROUP_VOICE
|
|
+ peer_id # bytes 1-4
|
|
+ bytes([seq]) # byte 5 (seq_id)
|
|
+ src_sub # bytes 6-8
|
|
+ dst_group # bytes 9-11
|
|
+ b'\x00' # byte 12 (call_type)
|
|
+ b'\x00\x00\x00\x00' # bytes 13-16 (call_ctrl)
|
|
+ bytes([call_info]) # byte 17 (call_info)
|
|
+ b'\x00' * 12 # bytes 18-29 (RTP header)
|
|
+ bytes([burst_type]) # byte 30 (burst_type)
|
|
+ dst_group # bytes 31-33 (DMR LC dst — for TGID rewrite)
|
|
+ src_sub # bytes 34-36 (DMR LC src)
|
|
+ b'\x00' * 28 # bytes 37-64 (rest of DMR payload)
|
|
)
|
|
|
|
|
|
def make_trunk_system(radio_id_int=3129100, ip='127.0.0.1', port=50200,
|
|
peer_ip='10.0.0.1', peer_port=50200):
|
|
"""Return a SYSTEMS entry for a TRUNK system (parallel to make_system).
|
|
|
|
Matches the structure produced by config.py when SYSTEM_TYPE = TRUNK.
|
|
"""
|
|
return {
|
|
'SYSTEM_TYPE': 'TRUNK',
|
|
'LOCAL': {
|
|
'ENABLED': True,
|
|
'SYSTEM_TYPE': 'TRUNK',
|
|
'RADIO_ID': bytes_4(radio_id_int),
|
|
'IP': ip,
|
|
'PORT': port,
|
|
'GROUP_HANGTIME': 5,
|
|
},
|
|
'TRUNK': {
|
|
'PEER_IP': peer_ip,
|
|
'PEER_PORT': peer_port,
|
|
'PEER_SOCK': (peer_ip, peer_port),
|
|
},
|
|
'MASTER': {},
|
|
'PEERS': {},
|
|
}
|
|
|
|
|
|
def make_bridge_rules(src_system, tgt_system, src_tgid, tgt_tgid=None, ts=1, active=True):
|
|
"""Return a BRIDGES dict with one bridge connecting src_system to tgt_system."""
|
|
if tgt_tgid is None:
|
|
tgt_tgid = src_tgid
|
|
src_tgid_b = bytes_3(src_tgid) if isinstance(src_tgid, int) else src_tgid
|
|
tgt_tgid_b = bytes_3(tgt_tgid) if isinstance(tgt_tgid, int) else tgt_tgid
|
|
return {
|
|
'TESTBRIDGE': [
|
|
{
|
|
'SYSTEM': src_system, 'TS': ts, 'TGID': src_tgid_b, 'ACTIVE': active,
|
|
'TO_TYPE': 'NONE', 'TIMEOUT': 0, 'TIMER': 0,
|
|
'ON': [], 'OFF': [], 'RESET': [],
|
|
},
|
|
{
|
|
'SYSTEM': tgt_system, 'TS': ts, 'TGID': tgt_tgid_b, 'ACTIVE': active,
|
|
'TO_TYPE': 'NONE', 'TIMEOUT': 0, 'TIMER': 0,
|
|
'ON': [], 'OFF': [], 'RESET': [],
|
|
},
|
|
]
|
|
}
|