You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ADN-DMR-Peer-Server/src/adn_server/application/report/payloads.py

609 lines
20 KiB

# ADN DMR Peer Server - application report payloads
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# 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
###############################################################################
"""Map runtime SYSTEMS / BRIDGES / BRDG_EVENT CSV to report JSON payloads (application layer)."""
from __future__ import annotations
import re
import time
from typing import Any
from adn_server.application.routing.helpers import (
export_peer_ua_multi_tgs,
export_peer_ua_sessions,
peer_rf_mode,
)
from adn_server.domain import int_id
from adn_server.domain.hbp_protocol import normalize_fixed_width_ascii
from adn_server.domain.ua_timer import normalize_ua_timer_minutes, ua_timer_is_infinite
REPORT_PROTOCOL = 2
REPORT_FEATURES = (
"INGRESS",
"END_TX_FORWARD",
"PUSH_ON_CONNECT",
"REPORT_V2",
"ROUTING_TABLE_JSON",
"VOICE_EVENT_JSON",
"DELTA_UPDATES",
)
_SYSTEM_MODES = frozenset({"MASTER", "PEER", "XLXPEER", "OPENBRIDGE"})
_TO_TYPES = frozenset({"ON", "OFF", "STAT", "NONE"})
_CSV_FAMILIES = {
"GROUP VOICE": "GROUP",
"PRIVATE VOICE": "PRIVATE",
"UNIT DATA": "UNIT",
# routing_use_cases emits these labels for dtype_vseq in (6, 7, 8). CSBK (3) is
# still logged at INFO but no longer emitted to monitor (prelude storm filter).
"UNIT CSBK": "UNIT",
"UNIT DATA HEADER": "UNIT",
"UNIT VCSBK 1/2 DATA BLOCK": "UNIT",
"UNIT VCSBK 3/4 DATA BLOCK": "UNIT",
}
def _dmr_id(value: Any) -> int:
if isinstance(value, int):
return value
return int_id(value)
def _peer_connected(peer: dict[str, Any]) -> bool:
return peer.get("CONNECTION") == "YES"
def _parse_options_kv(options: Any) -> dict[str, str]:
"""Parse RPTO OPTIONS string into upper-case keys (legacy normalisation)."""
if options is None:
return {}
text = normalize_fixed_width_ascii(options)
text = text.encode("ascii", "ignore").decode()
text = re.sub(r"['\"]", "", text).strip()
if not text:
return {}
parsed: dict[str, str] = {}
for part in text.split(";"):
part = part.strip()
if "=" not in part:
continue
key, value = part.split("=", 1)
parsed[key.strip().upper()] = value.strip()
for old, new in (("TS1", "TS1_STATIC"), ("TS2", "TS2_STATIC"), ("TIMER", "DEFAULT_UA_TIMER")):
if old in parsed and new not in parsed:
parsed[new] = parsed[old]
return parsed
_STATIC_TG_TOKEN_RE = re.compile(r"^\d+$")
def static_tg_list(value: Any) -> list[str]:
"""Normalize legacy TS1_STATIC / TS2_STATIC (comma string or list) to string TG ids."""
if value is None:
return []
if isinstance(value, str):
return [x.strip() for x in value.split(",") if x.strip()]
if isinstance(value, list):
return [str(x).strip() for x in value if str(x).strip()]
text = str(value).strip()
return [text] if text else []
def dedupe_static_tg_list(parts: list[str]) -> list[str]:
"""Collapse duplicate TG ids within one TS list (order preserved)."""
out: list[str] = []
seen: set[str] = set()
for tg in parts:
t = str(tg).strip()
if not t or t in seen:
continue
seen.add(t)
out.append(t)
return out
def normalize_static_tg_slot_lists(
ts1: list[str],
ts2: list[str],
) -> tuple[list[str], list[str]]:
"""Dedupe within each slot list; same TG may appear on TS1 and TS2 (duplex)."""
return dedupe_static_tg_list(ts1), dedupe_static_tg_list(ts2)
def _static_slot_csv_valid(raw: str) -> bool:
"""True when every comma-separated token in TS1/TS2 is a numeric talkgroup id."""
val = raw.strip()
if not val:
return True
if re.search(r"[^\d,]", val):
return False
for token in val.split(","):
token = token.strip()
if not token:
continue
if not _STATIC_TG_TOKEN_RE.match(token):
return False
return True
def peer_options_pass_only(options: Any) -> bool:
"""True when OPTIONS is exclusively ``PASS=password`` (self-service RPTO)."""
parsed = _parse_options_kv(options)
return list(parsed.keys()) == ["PASS"] and bool(parsed["PASS"].strip())
def peer_options_static_valid(options: Any) -> bool:
"""False when TS1/TS2 static lists contain non-numeric tokens (legacy bridge_master parity).
``PASS=`` must be sent alone (self-service); combined PASS+OPTIONS is rejected here.
"""
parsed = _parse_options_kv(options)
if not parsed:
return True
if "PASS" in parsed:
return False
for slot in (1, 2):
for i in range(1, 10):
key = f"TS{slot}_{i}"
if key in parsed and not _STATIC_TG_TOKEN_RE.match(parsed[key].strip()):
return False
for key in ("TS1_STATIC", "TS2_STATIC"):
if key in parsed and not _static_slot_csv_valid(parsed[key]):
return False
return True
def parse_peer_options_fields(options: Any) -> dict[str, Any]:
"""Parse OPTIONS into static lists plus optional ``SINGLE`` / ``TIMER`` when present."""
if not peer_options_static_valid(options):
return {}
parsed = _parse_options_kv(options)
if not parsed:
return {}
out: dict[str, Any] = {}
ts1, ts2 = parse_peer_options_static(options)
if ts1:
out["TS1_STATIC"] = ts1
if ts2:
out["TS2_STATIC"] = ts2
if "SINGLE" in parsed:
out["SINGLE"] = parsed["SINGLE"]
timer_raw = parsed.get("DEFAULT_UA_TIMER")
if timer_raw is not None:
try:
out["TIMER"] = float(timer_raw)
except (TypeError, ValueError):
pass
return out
def resolve_peer_single_and_timer(
fields: dict[str, Any],
sys_cfg: dict[str, Any],
) -> tuple[bool, float]:
"""Use OPTIONS ``SINGLE``/``TIMER`` when present; else YAML ``SINGLE_MODE``/``DEFAULT_UA_TIMER``."""
from adn_server.domain.config_coerce import coerce_bool, parse_options_single
if "SINGLE" in fields:
parsed_single = parse_options_single(fields["SINGLE"])
single = (
parsed_single
if parsed_single is not None
else coerce_bool(sys_cfg.get("SINGLE_MODE", False))
)
else:
single = coerce_bool(sys_cfg.get("SINGLE_MODE", False))
if "TIMER" in fields:
try:
timer = normalize_ua_timer_minutes(
float(fields["TIMER"]),
default_minutes=float(sys_cfg.get("DEFAULT_UA_TIMER", 10)),
)
except (TypeError, ValueError):
timer = normalize_ua_timer_minutes(
float(sys_cfg.get("DEFAULT_UA_TIMER", 10)),
default_minutes=10.0,
)
else:
timer = normalize_ua_timer_minutes(
float(sys_cfg.get("DEFAULT_UA_TIMER", 10)),
default_minutes=10.0,
)
return single, timer
def parse_peer_options_static(options: Any) -> tuple[list[str], list[str]]:
"""Parse hotspot RPTO OPTIONS (``TS1=…;TS2=…;``) into static TG id lists."""
if not peer_options_static_valid(options):
return [], []
parsed = _parse_options_kv(options)
if not parsed:
return [], []
for old, new in (("TS1", "TS1_STATIC"), ("TS2", "TS2_STATIC")):
if old in parsed and new not in parsed:
parsed[new] = parsed[old]
ts1_parts: list[str] = []
if "TS1_1" in parsed:
ts1_parts.append(parsed["TS1_1"])
for i in range(2, 10):
p = parsed.get(f"TS1_{i}")
if p:
ts1_parts.append(p)
elif parsed.get("TS1_STATIC"):
ts1_parts = [x.strip() for x in parsed["TS1_STATIC"].split(",") if x.strip()]
ts2_parts: list[str] = []
if "TS2_1" in parsed:
ts2_parts.append(parsed["TS2_1"])
for i in range(2, 10):
p = parsed.get(f"TS2_{i}")
if p:
ts2_parts.append(p)
elif parsed.get("TS2_STATIC"):
ts2_parts = [x.strip() for x in parsed["TS2_STATIC"].split(",") if x.strip()]
return normalize_static_tg_slot_lists(ts1_parts, ts2_parts)
def _peer_field_json(value: Any) -> str | None:
"""Sanitize a legacy peer field for JSON (no secrets)."""
if value is None:
return None
text = normalize_fixed_width_ascii(value)
return text or None
# Legacy CONFIG peer keys → topology JSON (display / lnksys classification).
_TOPOLOGY_PEER_FIELDS: tuple[tuple[str, str], ...] = (
("CALLSIGN", "callsign"),
("RX_FREQ", "rx_freq"),
("TX_FREQ", "tx_freq"),
("LOCATION", "location"),
("DESCRIPTION", "description"),
("URL", "url"),
("SLOTS", "slots"),
("PACKAGE_ID", "package_id"),
("SOFTWARE_ID", "software_id"),
("COLORCODE", "colorcode"),
("TX_POWER", "tx_power"),
)
def _peer_connected_at(peer: dict[str, Any]) -> int | None:
"""Unix time when peer logged in (legacy CONFIG ``CONNECTED``), or None."""
if not _peer_connected(peer):
return None
raw = peer.get("CONNECTED", 0)
try:
ts = int(float(raw))
except (TypeError, ValueError):
return None
return ts if ts > 0 else None
def _sanitized_peer_options_text(options: Any) -> str | None:
"""RPTO OPTIONS for monitor display (omit ``PASS=`` secrets)."""
if options is None:
return None
text = normalize_fixed_width_ascii(options)
if not text:
return None
parts: list[str] = []
for part in text.split(";"):
piece = part.strip()
if not piece:
continue
if piece.upper().startswith("PASS="):
continue
parts.append(piece)
if not parts:
return None
return ";".join(parts) + ";"
def _topology_peer_row(
peer_key: Any,
peer: dict[str, Any],
*,
sys_cfg: dict[str, Any] | None = None,
) -> dict[str, Any]:
row: dict[str, Any] = {
"id": _dmr_id(peer_key),
"connected": _peer_connected(peer),
}
connected_at = _peer_connected_at(peer)
if connected_at is not None:
row["connected_at"] = connected_at
if peer.get("IP"):
row["ip"] = str(peer["IP"])
if peer.get("PORT") is not None:
row["port"] = int(peer["PORT"])
for legacy_key, json_key in _TOPOLOGY_PEER_FIELDS:
if legacy_key not in peer:
continue
text = _peer_field_json(peer[legacy_key])
if text is not None:
row[json_key] = text
yaml_cfg = sys_cfg if isinstance(sys_cfg, dict) else {}
if "OPTIONS" in peer and peer_options_static_valid(peer.get("OPTIONS")):
opt_text = _sanitized_peer_options_text(peer.get("OPTIONS"))
if opt_text:
row["options"] = opt_text
fields = parse_peer_options_fields(peer.get("OPTIONS"))
ts1 = fields.get("TS1_STATIC") or []
ts2 = fields.get("TS2_STATIC") or []
if ts1:
row["ts1_static"] = ts1
if ts2:
row["ts2_static"] = ts2
single, timer = resolve_peer_single_and_timer(fields, yaml_cfg)
else:
single, timer = resolve_peer_single_and_timer({}, yaml_cfg)
ts1 = static_tg_list(yaml_cfg.get("TS1_STATIC"))
ts2 = static_tg_list(yaml_cfg.get("TS2_STATIC"))
if ts1:
row["ts1_static"] = ts1
if ts2:
row["ts2_static"] = ts2
row["single_mode"] = single
if not ua_timer_is_infinite(timer):
row["ua_timer_min"] = timer
row["ua_sessions"] = export_peer_ua_sessions(yaml_cfg, peer_key)
row["ua_multi_tgs"] = export_peer_ua_multi_tgs(yaml_cfg, peer_key)
row["rf_mode"] = peer_rf_mode(peer)
return row
def _system_has_connected_users(cfg: dict[str, Any]) -> bool:
"""True when an enabled system has at least one live peer or upstream link."""
if not cfg.get("ENABLED", True):
return False
mode = cfg.get("MODE", "MASTER")
if mode == "MASTER":
return any(
_peer_connected(peer)
for peer in cfg.get("PEERS", {}).values()
if isinstance(peer, dict)
)
if mode == "OPENBRIDGE":
return any(
_peer_connected(peer)
for peer in cfg.get("PEERS", {}).values()
if isinstance(peer, dict)
)
if mode in ("PEER", "XLXPEER"):
for key in ("XLXSTATS", "STATS"):
block = cfg.get(key)
if isinstance(block, dict) and block.get("CONNECTION") == "YES":
return True
return False
return False
def hello_connected_system_names(systems: dict[str, Any]) -> list[str]:
"""Enabled system names with connected users — optional HELLO hint (topology is authoritative)."""
return sorted(
name
for name, cfg in systems.items()
if isinstance(cfg, dict) and _system_has_connected_users(cfg)
)
def build_topology(systems: dict[str, Any], *, seq: int, ts: float | None = None) -> dict[str, Any]:
"""Sanitized topology snapshot (no passphrases or runtime-only keys)."""
epoch = time.time() if ts is None else ts
out_systems: list[dict[str, Any]] = []
for name, cfg in systems.items():
if not isinstance(cfg, dict):
continue
mode = cfg.get("MODE", "MASTER")
if mode not in _SYSTEM_MODES:
mode = str(mode)
entry: dict[str, Any] = {
"name": name,
"mode": mode,
"enabled": bool(cfg.get("ENABLED", True)),
}
if cfg.get("IP"):
entry["ip"] = str(cfg["IP"])
port = cfg.get("PORT")
if port is not None:
entry["port"] = int(port)
if "REPEAT" in cfg:
entry["repeat"] = bool(cfg["REPEAT"])
if cfg.get("ENHANCED_OBP"):
entry["enhanced_obp"] = True
if mode == "OPENBRIDGE" and cfg.get("NETWORK_ID") is not None:
entry["network_id"] = _dmr_id(cfg["NETWORK_ID"])
if mode == "MASTER":
ts1 = static_tg_list(cfg.get("TS1_STATIC"))
ts2 = static_tg_list(cfg.get("TS2_STATIC"))
if ts1:
entry["ts1_static"] = ts1
if ts2:
entry["ts2_static"] = ts2
peers_out: list[dict[str, Any]] = []
for peer_key, peer in cfg.get("PEERS", {}).items():
if not isinstance(peer, dict):
continue
peers_out.append(_topology_peer_row(peer_key, peer, sys_cfg=cfg))
entry["peers"] = peers_out
out_systems.append(entry)
return {"type": "topology", "seq": int(seq), "ts": float(epoch), "systems": out_systems}
def build_routing_table(bridges: dict[str, Any], *, seq: int, ts: float | None = None) -> dict[str, Any]:
"""Routing table snapshot from BRIDGES."""
epoch = time.time() if ts is None else ts
routes: list[dict[str, Any]] = []
for relay_table_key, legs in bridges.items():
if not isinstance(legs, list):
continue
leg_rows: list[dict[str, Any]] = []
for leg in legs:
if not isinstance(leg, dict):
continue
to_type = leg.get("TO_TYPE", "NONE")
if to_type not in _TO_TYPES:
to_type = str(to_type)
row: dict[str, Any] = {
"system": str(leg.get("SYSTEM", "")),
"ts": int(leg.get("TS", 1)),
"tgid": _dmr_id(leg.get("TGID", 0)),
"active": bool(leg.get("ACTIVE", False)),
"to_type": to_type,
}
timer = leg.get("TIMER")
if timer is not None:
row["timer_expires_at"] = float(timer)
leg_rows.append(row)
routes.append({"relay_table_key": str(relay_table_key), "legs": leg_rows})
return {"type": "routing_table", "seq": int(seq), "ts": float(epoch), "routes": routes}
def parse_bridge_event_csv(event: str, *, ts: float | None = None) -> dict[str, Any] | None:
"""Parse legacy BRDG_EVENT CSV into a ``voice_event`` dict."""
parts = [p.strip() for p in event.split(",")]
if len(parts) < 9:
return None
call_family = _CSV_FAMILIES.get(parts[0])
if call_family is None:
return None
phase = parts[1]
direction = parts[2]
try:
stream_id = int(parts[4])
peer_id = int(parts[5])
src_id = int(parts[6])
slot = int(parts[7])
dst_id = int(parts[8])
except (ValueError, IndexError):
return None
if slot not in (1, 2):
return None
if direction not in ("RX", "TX"):
return None
epoch = time.time() if ts is None else ts
voice: dict[str, Any] = {
"type": "voice_event",
"ts": epoch,
"call_family": call_family,
"phase": phase,
"direction": direction,
"system": parts[3],
"stream_id": stream_id,
"peer_id": peer_id,
"src_id": src_id,
"slot": slot,
"dst_id": dst_id,
}
if phase == "END" and len(parts) > 9:
try:
voice["duration_s"] = float(parts[9])
except ValueError:
voice["duration_s"] = None
elif phase != "END":
voice["duration_s"] = None
if call_family == "PRIVATE" and direction == "TX":
# routing_use_cases._pvt_call_received appends the destination hotspot's own
# peer id (from SUB_MAP) after the legacy fields -- there's no static TG a
# private call's subscriber-id destination could ever match, so this is the
# only way the monitor can know which single hotspot is receiving.
voice["is_announcement"] = False
base_len = 10 if phase == "END" else 9
if len(parts) > base_len:
try:
voice["dest_peer_id"] = int(parts[-1])
except ValueError:
pass
else:
voice["is_announcement"] = len(parts) > 9 and parts[-1] == "1"
return voice
def routing_table_delta(
previous: dict[str, Any] | None,
current: dict[str, Any],
*,
seq: int,
ts: float | None = None,
) -> dict[str, Any] | None:
"""Build a delta message when only some routes changed; ``None`` if unchanged."""
if previous is None:
return None
prev_routes = {r["relay_table_key"]: r for r in previous.get("routes", [])}
changed: list[dict[str, Any]] = []
for route in current.get("routes", []):
key = route["relay_table_key"]
if prev_routes.get(key) != route:
changed.append(route)
if not changed:
return None
since_seq = int(previous.get("seq", 0))
epoch = time.time() if ts is None else ts
patch = {
"type": "routing_table",
"seq": int(seq),
"ts": float(epoch),
"routes": changed,
}
return {
"type": "delta",
"seq": int(seq),
"ts": float(epoch),
"since_seq": since_seq,
"patch": patch,
}
def topology_delta(
previous: dict[str, Any] | None,
current: dict[str, Any],
*,
seq: int,
ts: float | None = None,
) -> dict[str, Any] | None:
"""Build a delta when only some systems changed; ``None`` if unchanged."""
if previous is None:
return None
prev_systems = {s["name"]: s for s in previous.get("systems", [])}
changed: list[dict[str, Any]] = []
for system in current.get("systems", []):
name = system["name"]
if prev_systems.get(name) != system:
changed.append(system)
if not changed:
return None
since_seq = int(previous.get("seq", 0))
epoch = time.time() if ts is None else ts
patch = {
"type": "topology",
"seq": int(seq),
"ts": float(epoch),
"systems": changed,
}
return {
"type": "delta",
"seq": int(seq),
"ts": float(epoch),
"since_seq": since_seq,
"patch": patch,
}

Powered by TurnKey Linux.