feat: add parrot (playback) entrypoint and fix PEER auth offsets

- 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)
pull/4/head
Rodrigo Pérez 7 months ago
parent 30b9ef697f
commit 6f7ed179d5

3
.gitignore vendored

@ -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

@ -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: "<set-in-adn-parrot.yaml>"
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: ""

@ -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 <ce5rpy@qmd.cl>
# 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()

@ -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 <ce5rpy@qmd.cl>
# 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)

@ -0,0 +1,144 @@
# ADN DMR Peer Server - config normalization helpers
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
# 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"]

@ -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"

@ -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")

@ -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 <ce5rpy@qmd.cl>
# 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()
Loading…
Cancel
Save

Powered by TurnKey Linux.