From 6f7ed179d5fd451a02f59ba3a84ceafd00a7ac78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20P=C3=A9rez?= Date: Sat, 14 Mar 2026 23:29:04 -0300 Subject: [PATCH] feat: add parrot (playback) entrypoint and fix PEER auth offsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Port playback.py to clean architecture: PlaybackUseCases, parrot_main.py, adn-parrot.py - Fix critical PEER auth bug: RPTACK/MSTNAK data offsets were off by 1 (5→6) preventing PEER login - Extract config normalization to shared config_normalizer.py (used by both main and parrot) --- .gitignore | 3 +- adn-parrot.example.yaml | 75 +++++++++ adn-parrot.py | 31 ++++ .../application/playback_use_cases.py | 81 ++++++++++ .../infrastructure/config_normalizer.py | 144 +++++++++++++++++ .../twisted_adapters/udp_hbp.py | 12 +- src/adn_server/main.py | 142 +---------------- src/adn_server/parrot_main.py | 145 ++++++++++++++++++ 8 files changed, 490 insertions(+), 143 deletions(-) create mode 100644 adn-parrot.example.yaml create mode 100644 adn-parrot.py create mode 100644 src/adn_server/application/playback_use_cases.py create mode 100644 src/adn_server/infrastructure/config_normalizer.py create mode 100644 src/adn_server/parrot_main.py diff --git a/.gitignore b/.gitignore index f7a1d20..80956e1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,9 @@ # ADN DMR Peer Server – production config, secrets, data, build, IDE/OS # ----------------------------------------------------------------------------- -# Production config (use adn-server.example.yaml as template) +# Production config (use *.example.yaml as template) adn-server.yaml +adn-parrot.yaml *.local.yaml *.local.yml diff --git a/adn-parrot.example.yaml b/adn-parrot.example.yaml new file mode 100644 index 0000000..64feb59 --- /dev/null +++ b/adn-parrot.example.yaml @@ -0,0 +1,75 @@ +# ADN DMR Parrot (playback) - example configuration +# Copy to adn-parrot.yaml and set real secrets there (never commit adn-parrot.yaml). +# +# The parrot runs a MASTER. The ECHO PEER (defined in adn-server.yaml) connects to it. +# When a user transmits to the ECHO peer, the parrot records the audio and plays it back. + +GLOBAL: + PATH: ./ + PING_TIME: 10 + MAX_MISSED: 3 + USE_ACL: true + REG_ACL: DENY:1 + SUB_ACL: DENY:1 + TGID_TS1_ACL: PERMIT:ALL + TGID_TS2_ACL: PERMIT:ALL + ALLOW_NULL_PASSPHRASE: false + ANNOUNCEMENT_LANGUAGES: es_ES + SERVER_ID: 9990 + DATA_GATEWAY: false + VALIDATE_SERVER_IDS: false + +REPORTS: + REPORT: false + REPORT_INTERVAL: 60 + REPORT_PORT: 4821 + REPORT_CLIENTS: "127.0.0.1" + +LOGGER: + LOG_FILE: /var/log/adn-server/parrot.log + LOG_HANDLERS: file-timed + LOG_LEVEL: DEBUG + LOG_NAME: ADN + +ALIASES: + TRY_DOWNLOAD: false + PATH: ./json/ + PEER_FILE: peer_ids.json + SUBSCRIBER_FILE: subscriber_ids.json + TGID_FILE: talkgroup_ids.json + PEER_URL: https://adn.systems/files/peer_ids.json + SUBSCRIBER_URL: https://adn.systems/files/subscriber_ids.json + TGID_URL: https://adn.systems/files/talkgroup_ids.json + LOCAL_SUBSCRIBER_FILE: local_subscriber_ids.json + STALE_DAYS: 1 + SUB_MAP_FILE: "" + SERVER_ID_URL: https://adn.systems/files/server_ids.tsv + SERVER_ID_FILE: server_ids.tsv + +SYSTEMS: + PARROT: + MODE: MASTER + ENABLED: true + REPEAT: true + MAX_PEERS: 1 + EXPORT_AMBE: false + IP: 127.0.0.1 + PORT: 54915 + PASSPHRASE: "" + GROUP_HANGTIME: 5 + USE_ACL: true + REG_ACL: DENY:1 + SUB_ACL: DENY:1 + TGID_TS1_ACL: PERMIT:ALL + TGID_TS2_ACL: PERMIT:ALL + DEFAULT_UA_TIMER: 10 + SINGLE_MODE: true + VOICE_IDENT: false + TS1_STATIC: "" + TS2_STATIC: "" + DEFAULT_REFLECTOR: 0 + GENERATOR: 1 + ANNOUNCEMENT_LANGUAGE: es_ES + ALLOW_UNREG_ID: true + PROXY_CONTROL: false + OVERRIDE_IDENT_TG: "" diff --git a/adn-parrot.py b/adn-parrot.py new file mode 100644 index 0000000..ddcfa4d --- /dev/null +++ b/adn-parrot.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# ADN DMR Parrot (playback) - launcher script +# Copyright (C) 2016-2019 Cortney T. Buffington, N0MJS & Mike Zingman, N4IRR +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# GPLv3. Derived from playback.py / ADN DMR Server / HBlink. + +""" +Run the ADN DMR Parrot (playback) from the project root. + + python adn-parrot.py + python adn-parrot.py -c adn-parrot.yaml + python adn-parrot.py --logging DEBUG + +Config default: adn-parrot.yaml in this directory. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) +if str(_ROOT / "src") not in sys.path: + sys.path.insert(0, str(_ROOT / "src")) + +from adn_server.parrot_main import main + +if __name__ == "__main__": + main() diff --git a/src/adn_server/application/playback_use_cases.py b/src/adn_server/application/playback_use_cases.py new file mode 100644 index 0000000..5d1a7bc --- /dev/null +++ b/src/adn_server/application/playback_use_cases.py @@ -0,0 +1,81 @@ +# ADN DMR Peer Server - playback (parrot) use case +# Copyright (C) 2016-2019 Cortney T. Buffington, N0MJS & Mike Zingman, N4IRR +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from playback.py. GPLv3. + +"""Exact port of playback.py dmrd_received: record group voice, then play back with new stream ID.""" + +from __future__ import annotations + +import logging +from random import randint +from time import sleep, time +from typing import Any + +from ..domain import int_id, bytes_4 +from ..infrastructure.hbp_constants import HBPF_DATA_SYNC, HBPF_SLT_VTERM + +logger = logging.getLogger(__name__) + + +class PlaybackUseCases: + """Exact port of legacy playback class. One instance per system.""" + + def __init__(self, system_name: str, get_protocol: Any = None) -> None: + self._system = system_name + self._get_protocol = get_protocol + self.STATUS: dict[str, Any] = {} + self.CALL_DATA: list[bytes] = [] + + def dmrd_received( + self, + system_name: str, + peer_id: bytes, + rf_src: bytes, + dst_id: bytes, + seq: int, + slot: int, + call_type: str, + frame_type: int, + dtype_vseq: int, + stream_id: bytes, + data: bytes, + ) -> None: + """Exact port of playback.dmrd_received (playback.py lines 114-161).""" + pkt_time = time() + + if call_type == "group": + proto = self._get_protocol() if self._get_protocol else None + slot_status = getattr(proto, "STATUS", {}).get(slot, {}) if proto else {} + + if stream_id != slot_status.get("RX_STREAM_ID", b"\x00"): + self.STATUS["RX_START"] = pkt_time + logger.info( + "(%s) *START RECORDING* STREAM ID: %s SUB: %s REPEATER: %s TGID %s, TS %s", + self._system, int_id(stream_id), int_id(rf_src), int_id(peer_id), int_id(dst_id), slot, + ) + self.CALL_DATA.append(data) + return + + if (frame_type == HBPF_DATA_SYNC) and (dtype_vseq == HBPF_SLT_VTERM) and (slot_status.get("RX_TYPE") != HBPF_SLT_VTERM) and self.CALL_DATA: + call_duration = pkt_time - self.STATUS.get("RX_START", pkt_time) + self.CALL_DATA.append(data) + logger.info("(%s) *END RECORDING* STREAM ID: %s", self._system, int_id(stream_id)) + sleep(2) + _new_stream_id = bytes_4(randint(0x00, 0xFFFFFFFF)) + logger.info( + "(%s) *START PLAYBACK* STREAM ID: %s SUB: %s REPEATER: %s TGID %s, TS %s, Duration: %s", + self._system, int_id(_new_stream_id), int_id(rf_src), int_id(peer_id), int_id(dst_id), slot, call_duration, + ) + + if proto: + for i in self.CALL_DATA: + i = i[:16] + _new_stream_id + i[20:] + proto.send_system(i) + sleep(0.06) + self.CALL_DATA = [] + logger.info("(%s) *END PLAYBACK* STREAM ID: %s", self._system, int_id(_new_stream_id)) + + else: + if self.CALL_DATA: + self.CALL_DATA.append(data) diff --git a/src/adn_server/infrastructure/config_normalizer.py b/src/adn_server/infrastructure/config_normalizer.py new file mode 100644 index 0000000..ae4abd9 --- /dev/null +++ b/src/adn_server/infrastructure/config_normalizer.py @@ -0,0 +1,144 @@ +# ADN DMR Peer Server - config normalization helpers +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Shared config normalization: PEER, OBP, MASTER runtime state. Used by main.py and parrot_main.py.""" + +from __future__ import annotations + +import copy +import logging +import socket +import time +from typing import Any + + +def expand_generator(config: dict, logger: logging.Logger) -> None: + """Replace MASTER systems with GENERATOR > 1 by SYSTEM-0, SYSTEM-1, ... (legacy generator).""" + systems = config.get("SYSTEMS", {}) + to_remove: list[str] = [] + new_systems: dict = {} + for system_name, sys_cfg in list(systems.items()): + if not sys_cfg.get("ENABLED", True): + continue + if sys_cfg.get("MODE") != "MASTER": + continue + generator = int(sys_cfg.get("GENERATOR", 1)) + if generator <= 1: + continue + for count in range(generator): + new_name = f"{system_name}-{count}" + new_cfg = copy.deepcopy(sys_cfg) + base_port = int(new_cfg.get("PORT", 56400)) + new_cfg["PORT"] = base_port + count + new_cfg["_default_options"] = "SINGLE={};DEFAULT_UA_TIMER={};VOICE={};LANG={}".format( + int(new_cfg.get("SINGLE_MODE", False)), + new_cfg.get("DEFAULT_UA_TIMER", 60), + int(new_cfg.get("VOICE_IDENT", False)), + new_cfg.get("ANNOUNCEMENT_LANGUAGE", "en_GB"), + ) + new_systems[new_name] = new_cfg + logger.debug("(GLOBAL) Generator - generated system %s", new_name) + to_remove.append(system_name) + for name in to_remove: + systems.pop(name, None) + for name, cfg in new_systems.items(): + systems[name] = cfg + + +def ensure_system_runtime_config(config: dict) -> None: + """Ensure MASTER has PEERS and PEER has STATS (legacy config.py runtime state).""" + for name, sys_cfg in config.get("SYSTEMS", {}).items(): + if sys_cfg.get("MODE") == "MASTER": + sys_cfg.setdefault("PEERS", {}) + elif sys_cfg.get("MODE") == "PEER": + sys_cfg.setdefault("STATS", { + "CONNECTION": "NO", + "CONNECTED": None, + "PINGS_SENT": 0, + "PINGS_ACKD": 0, + "NUM_OUTSTANDING": 0, + "PING_OUTSTANDING": False, + "LAST_PING_TX_TIME": 0, + "LAST_PING_ACK_TIME": 0, + }) + + +def normalize_peer_config(config: dict) -> None: + """Convert PEER systems from YAML to legacy format: MASTER_SOCKADDR, RADIO_ID/CALLSIGN/OPTIONS as bytes (config.py).""" + for name, sys_cfg in config.get("SYSTEMS", {}).items(): + if sys_cfg.get("MODE") != "PEER": + continue + master_ip_str = str(sys_cfg.get("MASTER_IP", "127.0.0.1")) + master_port = int(sys_cfg.get("MASTER_PORT", 56400)) + try: + resolved_ip = socket.gethostbyname(master_ip_str) + except OSError: + resolved_ip = master_ip_str + sys_cfg["_MASTER_IP"] = master_ip_str + sys_cfg["MASTER_IP"] = resolved_ip + sys_cfg["MASTER_PORT"] = master_port + sys_cfg["MASTER_SOCKADDR"] = (resolved_ip, master_port) + radio_id = int(sys_cfg.get("RADIO_ID", 0)) + sys_cfg["RADIO_ID"] = (radio_id & 0xFFFFFFFF).to_bytes(4, "big") + for field, length in [ + ("CALLSIGN", 8), ("RX_FREQ", 9), ("TX_FREQ", 9), ("TX_POWER", 2), ("COLORCODE", 2), + ("LATITUDE", 8), ("LONGITUDE", 9), ("HEIGHT", 3), ("LOCATION", 20), ("DESCRIPTION", 19), + ("SLOTS", 1), ("URL", 124), ("SOFTWARE_ID", 40), ("PACKAGE_ID", 40), + ]: + val = sys_cfg.get(field, "") + if isinstance(val, (int, float)): + val = str(val) + b = val.encode("utf-8") if isinstance(val, str) else val + if field == "CALLSIGN": + sys_cfg[field] = b.ljust(length)[:length] + elif field in ("RX_FREQ", "TX_FREQ", "LATITUDE", "LONGITUDE", "LOCATION", "DESCRIPTION", "URL", "SOFTWARE_ID", "PACKAGE_ID"): + sys_cfg[field] = b.ljust(length)[:length] + else: + sys_cfg[field] = b.rjust(length, b"0")[:length] if length <= 3 else b.ljust(length)[:length] + opt = sys_cfg.get("OPTIONS", "") + sys_cfg["OPTIONS"] = opt.encode("utf-8") if isinstance(opt, str) else (opt or b"") + passphrase = sys_cfg.get("PASSPHRASE", "") + sys_cfg["PASSPHRASE"] = passphrase.encode("utf-8") if isinstance(passphrase, str) else (passphrase or b"") + sys_cfg.setdefault("LOOSE", False) + stats = sys_cfg.get("STATS", {}) + stats["DNS_TIME"] = time.time() + + +def normalize_obp_config(config: dict) -> None: + """Normalize OPENBRIDGE systems and GLOBAL SERVER_ID (legacy config.py).""" + g = config.setdefault("GLOBAL", {}) + sid = g.get("SERVER_ID", 0) + g["SERVER_ID"] = (int(sid) & 0xFFFFFFFF).to_bytes(4, "big") if not isinstance(sid, bytes) else sid + for name, sys_cfg in config.get("SYSTEMS", {}).items(): + if sys_cfg.get("MODE") != "OPENBRIDGE": + continue + net_id = int(sys_cfg.get("NETWORK_ID", 0)) + sys_cfg["NETWORK_ID"] = (net_id & 0xFFFFFFFF).to_bytes(4, "big") + target_ip = str(sys_cfg.get("TARGET_IP", "")) + target_port = int(sys_cfg.get("TARGET_PORT", 62044)) + if target_ip: + try: + resolved = socket.gethostbyname(target_ip) + sys_cfg["TARGET_IP"] = resolved + sys_cfg["TARGET_SOCK"] = (resolved, target_port) + except OSError: + sys_cfg["TARGET_IP"] = None + sys_cfg["TARGET_SOCK"] = (None, target_port) + else: + sys_cfg["TARGET_IP"] = None + sys_cfg["TARGET_SOCK"] = (None, target_port) + ver = int(sys_cfg.get("PROTO_VER", sys_cfg.get("VER", 5))) + if ver in (0, 2, 3) or ver > 5: + ver = 5 + sys_cfg["VER"] = ver + p = sys_cfg.get("PASSPHRASE") or b"" + if isinstance(p, str): + p = p.strip().encode("utf-8") + else: + p = p or b"" + sys_cfg["PASSPHRASE"] = (p + b"\x00" * 20)[:20] + sys_cfg.setdefault("RELAX_CHECKS", True) + sys_cfg.setdefault("ENHANCED_OBP", True) + if "TG1_ACL" not in sys_cfg and "TGID_ACL" in sys_cfg: + sys_cfg["TG1_ACL"] = sys_cfg["TGID_ACL"] diff --git a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py index 20dda32..c1386ce 100644 --- a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py +++ b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py @@ -785,7 +785,7 @@ class HBPProtocol(DatagramProtocol): self.STATUS[_slot]["RX_STREAM_ID"] = _stream_id elif _command == MSTN: - _peer_id = _data[5:9] if len(_data) >= 9 else _data[4:8] + _peer_id = _data[6:10] if self._config.get("LOOSE") or _peer_id == self._config.get("RADIO_ID"): logger.warning("(%s) MSTNAK Received. Resetting connection to the Master.", self._system) self._stats["CONNECTION"] = "NO" @@ -793,13 +793,13 @@ class HBPProtocol(DatagramProtocol): elif _command == RPTA: if self._stats.get("CONNECTION") == "RPTL_SENT": - _login_int32 = _data[5:9] if len(_data) >= 9 else _data[4:8] + _login_int32 = _data[6:10] logger.info("(%s) Repeater Login ACK Received with 32bit ID: %s", self._system, int_id(_login_int32)) - _pass_hash = bhex(sha256(_login_int32 + self._config.get("PASSPHRASE", b"")).hexdigest()) + _pass_hash = bhex(sha256(b"".join([_login_int32, self._config.get("PASSPHRASE", b"")])).hexdigest()) self.send_master(b"".join([RPTK, self._config["RADIO_ID"], _pass_hash])) self._stats["CONNECTION"] = "AUTHENTICATED" elif self._stats.get("CONNECTION") == "AUTHENTICATED": - _peer_id = _data[5:9] if len(_data) >= 9 else _data[4:8] + _peer_id = _data[6:10] if self._config.get("LOOSE") or _peer_id == self._config.get("RADIO_ID"): logger.info("(%s) Repeater Authentication Accepted", self._system) _config_packet = b"".join([ @@ -826,7 +826,7 @@ class HBPProtocol(DatagramProtocol): self._stats["CONNECTION"] = "NO" logger.error("(%s) Master ACK Contained wrong ID - Connection Reset", self._system) elif self._stats.get("CONNECTION") == "CONFIG-SENT": - _peer_id = _data[5:9] if len(_data) >= 9 else _data[4:8] + _peer_id = _data[6:10] if self._config.get("LOOSE") or _peer_id == self._config.get("RADIO_ID"): logger.info("(%s) Repeater Configuration Accepted", self._system) if self._config.get("OPTIONS"): @@ -841,7 +841,7 @@ class HBPProtocol(DatagramProtocol): self._stats["CONNECTION"] = "NO" logger.error("(%s) Master ACK Contained wrong ID - Connection Reset", self._system) elif self._stats.get("CONNECTION") == "OPTIONS-SENT": - _peer_id = _data[5:9] if len(_data) >= 9 else _data[4:8] + _peer_id = _data[6:10] if self._config.get("LOOSE") or _peer_id == self._config.get("RADIO_ID"): logger.info("(%s) Repeater Options Accepted", self._system) self._stats["CONNECTION"] = "YES" diff --git a/src/adn_server/main.py b/src/adn_server/main.py index a4c5aca..c9caf47 100644 --- a/src/adn_server/main.py +++ b/src/adn_server/main.py @@ -12,7 +12,6 @@ Config default: adn-server.yaml at project root. from __future__ import annotations import argparse -import copy import logging import os import signal @@ -30,6 +29,12 @@ from twisted.internet import reactor, task, threads from .domain import bytes_3 from .infrastructure import YamlConfigLoader, setup_logging +from .infrastructure.config_normalizer import ( + expand_generator as _expand_generator, + ensure_system_runtime_config as _ensure_system_runtime_config, + normalize_peer_config as _normalize_peer_config, + normalize_obp_config as _normalize_obp_config, +) from .infrastructure.persistence import PickleSubMapStore from .infrastructure.persistence.keys_store import JsonKeysStore from .infrastructure.persistence.alias_loader import DefaultAliasLoader @@ -96,141 +101,6 @@ def _looping_errback(logger: logging.Logger, failure): logger.error("(GLOBAL) Unhandled error in timed loop: %s", failure.getTraceback()) -def _expand_generator(config: dict, logger: logging.Logger) -> None: - """Replace MASTER systems with GENERATOR > 1 by SYSTEM-0, SYSTEM-1, ... (legacy generator).""" - systems = config.get("SYSTEMS", {}) - to_remove: list[str] = [] - new_systems: dict = {} - for system_name, sys_cfg in list(systems.items()): - if not sys_cfg.get("ENABLED", True): - continue - if sys_cfg.get("MODE") != "MASTER": - continue - generator = int(sys_cfg.get("GENERATOR", 1)) - if generator <= 1: - continue - for count in range(generator): - new_name = f"{system_name}-{count}" - new_cfg = copy.deepcopy(sys_cfg) - base_port = int(new_cfg.get("PORT", 56400)) - new_cfg["PORT"] = base_port + count - new_cfg["_default_options"] = "SINGLE={};DEFAULT_UA_TIMER={};VOICE={};LANG={}".format( - int(new_cfg.get("SINGLE_MODE", False)), - new_cfg.get("DEFAULT_UA_TIMER", 60), - int(new_cfg.get("VOICE_IDENT", False)), - new_cfg.get("ANNOUNCEMENT_LANGUAGE", "en_GB"), - ) - new_systems[new_name] = new_cfg - logger.debug("(GLOBAL) Generator - generated system %s", new_name) - to_remove.append(system_name) - for name in to_remove: - systems.pop(name, None) - for name, cfg in new_systems.items(): - systems[name] = cfg - - -def _ensure_system_runtime_config(config: dict) -> None: - """Ensure MASTER has PEERS and PEER has STATS (legacy config.py runtime state).""" - for name, sys_cfg in config.get("SYSTEMS", {}).items(): - if sys_cfg.get("MODE") == "MASTER": - sys_cfg.setdefault("PEERS", {}) - elif sys_cfg.get("MODE") == "PEER": - sys_cfg.setdefault("STATS", { - "CONNECTION": "NO", - "CONNECTED": None, - "PINGS_SENT": 0, - "PINGS_ACKD": 0, - "NUM_OUTSTANDING": 0, - "PING_OUTSTANDING": False, - "LAST_PING_TX_TIME": 0, - "LAST_PING_ACK_TIME": 0, - }) - - -def _normalize_peer_config(config: dict) -> None: - """Convert PEER systems from YAML to legacy format: MASTER_SOCKADDR, RADIO_ID/CALLSIGN/OPTIONS as bytes (config.py).""" - import socket - for name, sys_cfg in config.get("SYSTEMS", {}).items(): - if sys_cfg.get("MODE") != "PEER": - continue - master_ip_str = str(sys_cfg.get("MASTER_IP", "127.0.0.1")) - master_port = int(sys_cfg.get("MASTER_PORT", 56400)) - try: - resolved_ip = socket.gethostbyname(master_ip_str) - except OSError: - resolved_ip = master_ip_str - sys_cfg["_MASTER_IP"] = master_ip_str - sys_cfg["MASTER_IP"] = resolved_ip - sys_cfg["MASTER_PORT"] = master_port - sys_cfg["MASTER_SOCKADDR"] = (resolved_ip, master_port) - radio_id = int(sys_cfg.get("RADIO_ID", 0)) - sys_cfg["RADIO_ID"] = (radio_id & 0xFFFFFFFF).to_bytes(4, "big") - for field, length in [ - ("CALLSIGN", 8), ("RX_FREQ", 9), ("TX_FREQ", 9), ("TX_POWER", 2), ("COLORCODE", 2), - ("LATITUDE", 8), ("LONGITUDE", 9), ("HEIGHT", 3), ("LOCATION", 20), ("DESCRIPTION", 19), - ("SLOTS", 1), ("URL", 124), ("SOFTWARE_ID", 40), ("PACKAGE_ID", 40), - ]: - val = sys_cfg.get(field, "") - if isinstance(val, (int, float)): - val = str(val) - b = val.encode("utf-8") if isinstance(val, str) else val - if field == "CALLSIGN": - sys_cfg[field] = b.ljust(length)[:length] - elif field in ("RX_FREQ", "TX_FREQ", "LATITUDE", "LONGITUDE", "LOCATION", "DESCRIPTION", "URL", "SOFTWARE_ID", "PACKAGE_ID"): - sys_cfg[field] = b.ljust(length)[:length] - else: - sys_cfg[field] = b.rjust(length, b"0")[:length] if length <= 3 else b.ljust(length)[:length] - opt = sys_cfg.get("OPTIONS", "") - sys_cfg["OPTIONS"] = opt.encode("utf-8") if isinstance(opt, str) else (opt or b"") - passphrase = sys_cfg.get("PASSPHRASE", "") - sys_cfg["PASSPHRASE"] = passphrase.encode("utf-8") if isinstance(passphrase, str) else (passphrase or b"") - sys_cfg.setdefault("LOOSE", False) - stats = sys_cfg.get("STATS", {}) - stats["DNS_TIME"] = time.time() - - -def _normalize_obp_config(config: dict) -> None: - """Normalize OPENBRIDGE systems and GLOBAL SERVER_ID (legacy config.py).""" - import socket - g = config.setdefault("GLOBAL", {}) - sid = g.get("SERVER_ID", 0) - g["SERVER_ID"] = (int(sid) & 0xFFFFFFFF).to_bytes(4, "big") if not isinstance(sid, bytes) else sid - for name, sys_cfg in config.get("SYSTEMS", {}).items(): - if sys_cfg.get("MODE") != "OPENBRIDGE": - continue - net_id = int(sys_cfg.get("NETWORK_ID", 0)) - sys_cfg["NETWORK_ID"] = (net_id & 0xFFFFFFFF).to_bytes(4, "big") - target_ip = str(sys_cfg.get("TARGET_IP", "")) - target_port = int(sys_cfg.get("TARGET_PORT", 62044)) - if target_ip: - try: - resolved = socket.gethostbyname(target_ip) - sys_cfg["TARGET_IP"] = resolved - sys_cfg["TARGET_SOCK"] = (resolved, target_port) - except OSError: - sys_cfg["TARGET_IP"] = None - sys_cfg["TARGET_SOCK"] = (None, target_port) - else: - sys_cfg["TARGET_IP"] = None - sys_cfg["TARGET_SOCK"] = (None, target_port) - # Legacy config.py 359: VER from PROTO_VER (OPENBRIDGE uses VER for send_system and receive check) - ver = int(sys_cfg.get("PROTO_VER", sys_cfg.get("VER", 5))) - if ver in (0, 2, 3) or ver > 5: - ver = 5 - sys_cfg["VER"] = ver - # Legacy config.py OPENBRIDGE: PASSPHRASE padded to 20 bytes with nulls (BLAKE2b/HMAC key) - p = sys_cfg.get("PASSPHRASE") or b"" - if isinstance(p, str): - p = p.strip().encode("utf-8") - else: - p = p or b"" - sys_cfg["PASSPHRASE"] = (p + b"\x00" * 20)[:20] - sys_cfg.setdefault("RELAX_CHECKS", True) - sys_cfg.setdefault("ENHANCED_OBP", True) - if "TG1_ACL" not in sys_cfg and "TGID_ACL" in sys_cfg: - sys_cfg["TG1_ACL"] = sys_cfg["TGID_ACL"] - - def main() -> None: parser = argparse.ArgumentParser(description="ADN DMR Peer Server") parser.add_argument("-c", "--config", dest="CONFIG_FILE", default=None, help="Path to adn-server.yaml") diff --git a/src/adn_server/parrot_main.py b/src/adn_server/parrot_main.py new file mode 100644 index 0000000..bf5b037 --- /dev/null +++ b/src/adn_server/parrot_main.py @@ -0,0 +1,145 @@ +# ADN DMR Peer Server - parrot (playback) entrypoint +# Copyright (C) 2016-2019 Cortney T. Buffington, N0MJS & Mike Zingman, N4IRR +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from playback.py. GPLv3. + +""" +ADN DMR Parrot (playback) entrypoint. + +Exact port of legacy playback.py: record group voice, play back with new stream ID. +Uses the same YAML config as adn-server (only MASTER/PEER systems, no OPENBRIDGE). + + python adn-parrot.py + python adn-parrot.py -c adn-parrot.yaml + python adn-parrot.py --logging DEBUG +""" + +from __future__ import annotations + +import argparse +import logging +import os +import signal +import sys +from pathlib import Path +from typing import Any + +_ROOT = Path(__file__).resolve().parent.parent.parent +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + +from twisted.internet import reactor, task + +from .infrastructure import YamlConfigLoader, setup_logging +from .infrastructure.config_normalizer import ( + ensure_system_runtime_config, + normalize_peer_config, + normalize_obp_config, +) +from .infrastructure.bridge_router_impl import InMemoryBridgeRouter +from .infrastructure.twisted_adapters.report_server import ReportServerFactory +from .infrastructure.twisted_adapters.udp_hbp import HBPProtocolFactory +from .application.playback_use_cases import PlaybackUseCases + + +def _looping_errback(logger_obj: logging.Logger, failure): + logger_obj.error("(GLOBAL) Unhandled error in timed loop: %s", failure.getTraceback()) + + +def main() -> None: + parser = argparse.ArgumentParser(description="ADN DMR Parrot (playback)") + parser.add_argument("-c", "--config", dest="CONFIG_FILE", default=None, help="Path to YAML config") + parser.add_argument("--logging", dest="LOG_LEVEL", default=None, help="Override log level") + args = parser.parse_args() + + project_root = os.path.dirname(os.path.abspath(__file__)) + if project_root.endswith("/adn_server"): + project_root = str(Path(project_root).parent.parent) + config_path = args.CONFIG_FILE or os.path.join(project_root, "adn-parrot.yaml") + + loader = YamlConfigLoader(project_root) + config = loader.load(config_path) + + if args.LOG_LEVEL: + config.setdefault("LOGGER", {})["LOG_LEVEL"] = args.LOG_LEVEL + logger = setup_logging(config.get("LOGGER", {})) + logger.info("\n\nCopyright (c) 2026 Rodrigo Pérez, CE5RPY ce5rpy@qmd.cl") + logger.info("\n\nCopyright (c) 2026 Joaquin Madrid Belando, EA5GVK ea5gvk@gmail.com") + logger.info("\nCopyright (c) 2024-2026 Esteban Mackay, HP3ICC setcom40@gmail.com") + logger.info("\nCopyright (c) 2020-2023 Simon G7RZU simon@gb7fr.org.uk") + logger.info("\nCopyright (c) 2013, 2014, 2015, 2016, 2018, 2019\n\tThe Regents of the K0USY Group. All rights reserved.") + logger.debug("\n\n(GLOBAL) Logging system started, anything from here on gets logged") + + ensure_system_runtime_config(config) + normalize_peer_config(config) + normalize_obp_config(config) + + g = config.setdefault("GLOBAL", {}) + sid = g.get("SERVER_ID", 0) + g["SERVER_ID"] = (int(sid) & 0xFFFFFFFF).to_bytes(4, "big") if not isinstance(sid, bytes) else sid + + protocols: dict[str, Any] = {} + router = InMemoryBridgeRouter() + report_factory = ReportServerFactory(config) + systems_cfg = config.get("SYSTEMS", {}) + report_factory.set_systems(systems_cfg) + + if config.get("REPORTS", {}).get("REPORT", False): + port = config["REPORTS"].get("REPORT_PORT", 4321) + reactor.listenTCP(port, report_factory) + logger.info("(REPORT) Report server listening on TCP %s", port) + + def reporting_loop(): + report_factory.set_systems(config.get("SYSTEMS", {})) + report_factory.send_config() + + report_interval = config.get("REPORTS", {}).get("REPORT_INTERVAL", 60) + task.LoopingCall(reporting_loop).start(report_interval).addErrback(_looping_errback, logger) + + playback_instances: dict[str, PlaybackUseCases] = {} + + def sig_handler(sig, frame): + logger.info("SHUTDOWN: PARROT IS TERMINATING WITH SIGNAL %s", sig) + if reactor.running: + reactor.stop() + + signal.signal(signal.SIGTERM, sig_handler) + signal.signal(signal.SIGINT, sig_handler) + + logger.info("ADN Parrot -- SYSTEM STARTING...") + for system_name, sys_cfg in systems_cfg.items(): + if not sys_cfg.get("ENABLED", True): + continue + if sys_cfg.get("MODE") == "OPENBRIDGE": + logger.critical( + "%s FATAL: Instance is mode 'OPENBRIDGE', which would be tragic for playback. " + "playback only works with MMDVM-based systems", + system_name, + ) + sys.exit( + "playback cannot function with OPENBRIDGE systems. System {} is configured as OPENBRIDGE".format( + system_name + ) + ) + + pb = PlaybackUseCases(system_name, get_protocol=lambda sn=system_name: protocols.get(sn)) + playback_instances[system_name] = pb + + protocol = HBPProtocolFactory( + system_name, + config, + report_factory, + router=router, + dmrd_received=pb.dmrd_received, + ) + protocols[system_name] = protocol + ip = sys_cfg.get("IP", "") + udp_port = sys_cfg.get("PORT", 56400) + reactor.listenUDP(udp_port, protocol, interface=ip or "0.0.0.0") + logger.debug("%s instance created: %s, %s", sys_cfg.get("MODE", "?"), system_name, protocol) + + reactor.run() + + +if __name__ == "__main__": + main()