From 30b9ef697ff00387bfcdc8cc0ea259e929d0adc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20P=C3=A9rez?= Date: Sat, 14 Mar 2026 20:09:18 -0300 Subject: [PATCH] rewrite of adn peer server using clean architecture --- .gitignore | 95 + README.md | 30 + adn-server.example.yaml | 166 ++ adn-server.py | 30 + data/.gitkeep | 0 json/.gitkeep | 0 pyproject.toml | 31 + requirements.txt | 9 + src/adn_server/__init__.py | 25 + src/adn_server/application/__init__.py | 33 + .../application/bridge_use_cases.py | 1525 +++++++++++++++++ src/adn_server/application/ident_use_cases.py | 139 ++ src/adn_server/application/ports.py | 143 ++ .../application/reporting_use_cases.py | 50 + src/adn_server/application/voice_use_cases.py | 613 +++++++ src/adn_server/domain/__init__.py | 33 + src/adn_server/domain/entities.py | 66 + src/adn_server/domain/errors.py | 35 + src/adn_server/domain/result.py | 45 + src/adn_server/domain/value_objects.py | 63 + src/adn_server/infrastructure/__init__.py | 8 + .../infrastructure/bridge_router_impl.py | 43 + .../infrastructure/config_loader.py | 117 ++ .../infrastructure/hbp_constants.py | 54 + .../infrastructure/logging_config.py | 45 + .../infrastructure/persistence/__init__.py | 8 + .../persistence/alias_loader.py | 201 +++ .../infrastructure/persistence/keys_store.py | 35 + .../persistence/sub_map_store.py | 35 + .../infrastructure/security/__init__.py | 7 + .../security/password_crypto.py | 70 + .../security/password_download.py | 228 +++ .../security/user_passwords_loader.py | 83 + .../twisted_adapters/__init__.py | 8 + .../twisted_adapters/report_server.py | 115 ++ .../twisted_adapters/udp_hbp.py | 1325 ++++++++++++++ .../infrastructure/voice/__init__.py | 8 + .../infrastructure/voice/ambe_reader.py | 184 ++ .../infrastructure/voice/pkt_gen.py | 96 ++ .../infrastructure/voice/recording.py | 112 ++ .../infrastructure/voice/tts_ambe.py | 17 + .../infrastructure/voice/tts_engine.py | 323 ++++ .../infrastructure/voice/voice_map.py | 81 + src/adn_server/main.py | 553 ++++++ 44 files changed, 6887 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 adn-server.example.yaml create mode 100644 adn-server.py create mode 100644 data/.gitkeep create mode 100644 json/.gitkeep create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/adn_server/__init__.py create mode 100644 src/adn_server/application/__init__.py create mode 100644 src/adn_server/application/bridge_use_cases.py create mode 100644 src/adn_server/application/ident_use_cases.py create mode 100644 src/adn_server/application/ports.py create mode 100644 src/adn_server/application/reporting_use_cases.py create mode 100644 src/adn_server/application/voice_use_cases.py create mode 100644 src/adn_server/domain/__init__.py create mode 100644 src/adn_server/domain/entities.py create mode 100644 src/adn_server/domain/errors.py create mode 100644 src/adn_server/domain/result.py create mode 100644 src/adn_server/domain/value_objects.py create mode 100644 src/adn_server/infrastructure/__init__.py create mode 100644 src/adn_server/infrastructure/bridge_router_impl.py create mode 100644 src/adn_server/infrastructure/config_loader.py create mode 100644 src/adn_server/infrastructure/hbp_constants.py create mode 100644 src/adn_server/infrastructure/logging_config.py create mode 100644 src/adn_server/infrastructure/persistence/__init__.py create mode 100644 src/adn_server/infrastructure/persistence/alias_loader.py create mode 100644 src/adn_server/infrastructure/persistence/keys_store.py create mode 100644 src/adn_server/infrastructure/persistence/sub_map_store.py create mode 100644 src/adn_server/infrastructure/security/__init__.py create mode 100644 src/adn_server/infrastructure/security/password_crypto.py create mode 100644 src/adn_server/infrastructure/security/password_download.py create mode 100644 src/adn_server/infrastructure/security/user_passwords_loader.py create mode 100644 src/adn_server/infrastructure/twisted_adapters/__init__.py create mode 100644 src/adn_server/infrastructure/twisted_adapters/report_server.py create mode 100644 src/adn_server/infrastructure/twisted_adapters/udp_hbp.py create mode 100644 src/adn_server/infrastructure/voice/__init__.py create mode 100644 src/adn_server/infrastructure/voice/ambe_reader.py create mode 100644 src/adn_server/infrastructure/voice/pkt_gen.py create mode 100644 src/adn_server/infrastructure/voice/recording.py create mode 100644 src/adn_server/infrastructure/voice/tts_ambe.py create mode 100644 src/adn_server/infrastructure/voice/tts_engine.py create mode 100644 src/adn_server/infrastructure/voice/voice_map.py create mode 100644 src/adn_server/main.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7a1d20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,95 @@ +# ----------------------------------------------------------------------------- +# ADN DMR Peer Server – production config, secrets, data, build, IDE/OS +# ----------------------------------------------------------------------------- + +# Production config (use adn-server.example.yaml as template) +adn-server.yaml +*.local.yaml +*.local.yml + +# Local/helper scripts (not for prod repo; users use their own run method) +scripts/run.sh + +# Env and secrets +.env +.env.local +.env.*.local +!.env.example +user_passwords.json +encryption_key.secret +*.secret +keys.json + +# ----------------------------------------------------------------------------- +# Data and runtime (keep data/ via .gitkeep, do not commit contents) +# ----------------------------------------------------------------------------- +data/*.json +data/*.pkl +data/*.tsv +data/*.bak +!data/.gitkeep +json/* +!json/.gitkeep +docs/ +Audio/ + +# ----------------------------------------------------------------------------- +# Python +# ----------------------------------------------------------------------------- +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.eggs/ + +# Virtual envs +.venv/ +venv/ +env/ +ENV/ + +# ----------------------------------------------------------------------------- +# Logs and runtime +# ----------------------------------------------------------------------------- +*.log +log/ +tmp/ +temp/ +*.tmp +*.temp +.cache/ + +# ----------------------------------------------------------------------------- +# IDE and OS +# ----------------------------------------------------------------------------- +.idea/ +.vscode/ +.cursor/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# ----------------------------------------------------------------------------- +# Misc +# ----------------------------------------------------------------------------- +*.bak +*.backup +*.orig +logo.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bd27a1 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# ADN DMR Peer Server + +Clean Architecture rewrite of the ADN DMR conference bridge. Same behaviour as the original server; configuration is YAML. + +## License + +GPL v3. Derived from ADN DMR Server / HBlink. + +## Requirements + +- Python 3.10+ +- Dependencies: `pip install -r requirements.txt` + +## Configuration + +Copy `adn-server.example.yaml` to `adn-server.yaml` and edit with your settings. Production config is not committed. + +## Run + +```bash +pip install -r requirements.txt +python adn-server.py +``` + +Options: + +```bash +python adn-server.py -c /path/to/adn-server.yaml +python adn-server.py --logging DEBUG +``` diff --git a/adn-server.example.yaml b/adn-server.example.yaml new file mode 100644 index 0000000..7b8146c --- /dev/null +++ b/adn-server.example.yaml @@ -0,0 +1,166 @@ +# ADN DMR Peer Server - example configuration (YAML at project root) +# Copy to adn-server.yaml and set real secrets there (never commit adn-server.yaml). + +GLOBAL: + PATH: ./ + PING_TIME: 10 + MAX_MISSED: 3 + USE_ACL: true + REG_ACL: PERMIT:ALL + SUB_ACL: DENY:1 + TGID_TS1_ACL: PERMIT:ALL + TGID_TS2_ACL: PERMIT:ALL + GEN_STAT_BRIDGES: true + ALLOW_NULL_PASSPHRASE: true + ANNOUNCEMENT_LANGUAGES: "" + SERVER_ID: 73010 + DATA_GATEWAY: false + VALIDATE_SERVER_IDS: true + URL_SECURITY: "" + PORT_SECURITY: "" + PASS_SECURITY: "" + USERS_PASS: user_passwords.json + HASH_ENCRYPT: encryption_key.secret + +REPORTS: + REPORT: true + REPORT_INTERVAL: 60 + REPORT_PORT: 4321 + REPORT_CLIENTS: "127.0.0.1" + +LOGGER: + LOG_FILE: /var/log/adn-server/adn-server.log + LOG_HANDLERS: file-timed + LOG_LEVEL: INFO + LOG_NAME: adn-server + +ALIASES: + TRY_DOWNLOAD: true + PATH: ./ + 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_subcriber_ids.json + STALE_DAYS: 1 + SUB_MAP_FILE: "" + SERVER_ID_URL: https://adn.systems/files/server_ids.tsv + SERVER_ID_FILE: server_ids.tsv + CHECKSUM_URL: https://adn.systems/files/file_checksums.json + CHECKSUM_FILE: file_checksums.json + KEYS_FILE: keys.json + +ALLSTAR: + ENABLED: false + USER: llcgi + PASS: "" + SERVER: my.asl.server + PORT: 5038 + NODE: "0000" + +# Systems: MASTER, PEER, OPENBRIDGE. Names match legacy [SYSTEM], [D-APRS], [ECHO], [OBP-*]. +SYSTEMS: + SYSTEM: + MODE: MASTER + ENABLED: true + REPEAT: true + MAX_PEERS: 2 + EXPORT_AMBE: false + IP: 127.0.0.1 + PORT: 56400 + 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: 60 + SINGLE_MODE: false + VOICE_IDENT: false + TS1_STATIC: "" + TS2_STATIC: "" + DEFAULT_REFLECTOR: 0 + ANNOUNCEMENT_LANGUAGE: es_ES + GENERATOR: 102 + ALLOW_UNREG_ID: false + PROXY_CONTROL: false + OVERRIDE_IDENT_TG: "" + + D-APRS: + MODE: MASTER + ENABLED: true + REPEAT: false + MAX_PEERS: 2 + EXPORT_AMBE: false + IP: "" + PORT: 52555 + PASSPHRASE: "" + GROUP_HANGTIME: 0 + 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: false + VOICE_IDENT: false + TS1_STATIC: "" + TS2_STATIC: "" + DEFAULT_REFLECTOR: 0 + ANNOUNCEMENT_LANGUAGE: es_ES + GENERATOR: 2 + ALLOW_UNREG_ID: true + PROXY_CONTROL: false + OVERRIDE_IDENT_TG: "" + + ECHO: + MODE: PEER + ENABLED: true + LOOSE: false + EXPORT_AMBE: false + IP: 127.0.0.1 + PORT: 54917 + MASTER_IP: 127.0.0.1 + MASTER_PORT: 54915 + PASSPHRASE: "" + CALLSIGN: ECHO + RADIO_ID: 9990 + RX_FREQ: 449000000 + TX_FREQ: 444000000 + TX_POWER: 25 + COLORCODE: 1 + SLOTS: 1 + LATITUDE: "00.0000" + LONGITUDE: "000.0000" + HEIGHT: 0 + LOCATION: "9990 Parrot" + DESCRIPTION: ECHO + URL: adn.systems + SOFTWARE_ID: "20170620" + PACKAGE_ID: MMDVM_ADN-Systems + GROUP_HANGTIME: 5 + OPTIONS: "" + USE_ACL: true + SUB_ACL: DENY:1 + TGID_TS1_ACL: PERMIT:ALL + TGID_TS2_ACL: PERMIT:ALL + ANNOUNCEMENT_LANGUAGE: en_GB + + OBP-TEST: + MODE: OPENBRIDGE + ENABLED: false + IP: "" + PORT: 62044 + NETWORK_ID: 1 + PASSPHRASE: "" + TARGET_IP: "" + TARGET_PORT: 62044 + USE_ACL: true + SUB_ACL: DENY:1 + TGID_ACL: DENY:0-82,92-199,800-899,9990-9999,730999 + RELAX_CHECKS: true + ENHANCED_OBP: true + PROTO_VER: 2 diff --git a/adn-server.py b/adn-server.py new file mode 100644 index 0000000..22e64a7 --- /dev/null +++ b/adn-server.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# ADN DMR Peer Server - launcher script (like monitor/monitor.py) +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# GPLv3. Derived from ADN DMR Server / HBlink. + +""" +Run the ADN DMR Peer Server from the project root. + + python adn-server.py + python adn-server.py -c adn-server.yaml + python adn-server.py --logging DEBUG + +Config default: adn-server.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.main import main + +if __name__ == "__main__": + main() diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/json/.gitkeep b/json/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aae7e7d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +# ADN DMR Peer Server +# GPL v3. Derived from ADN DMR Server / HBlink. + +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "adn-server" +version = "0.1.0" +description = "ADN DMR Peer Server" +readme = "README.md" +license = { text = "GPL-3.0-or-later" } +requires-python = ">=3.10" +dependencies = [ + "Twisted>=22.0", + "dmr_utils3>=0.1.19", + "pyyaml>=6.0", + "setproctitle>=1.3", + "bitarray>=2.0", + "cryptography>=41.0", +] + +[project.optional-dependencies] +dev = ["pytest>=7", "pytest-cov"] + +[tool.setuptools.packages.find] +where = ["src"] + +[project.scripts] +adn-server = "adn_server.main:main" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4836e44 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +# ADN DMR Peer Server - dependencies +# Python 3.10+ + +Twisted>=22.0.0 +dmr_utils3>=0.1.19 +pyyaml>=6.0 +setproctitle>=1.3.0 +bitarray>=2.0.0 +cryptography>=41.0.0 diff --git a/src/adn_server/__init__.py b/src/adn_server/__init__.py new file mode 100644 index 0000000..e74e34c --- /dev/null +++ b/src/adn_server/__init__.py @@ -0,0 +1,25 @@ +# ADN DMR Peer Server +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# +# 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, see . +# +# Derived from: ADN DMR Server / HBlink (bridge_master.py, hblink.py): +# Copyright (C) 2026 Joaquin Madrid Belando, EA5GVK +# Copyright (C) 2025 Esteban Mackay, HP3ICC +# Copyright (C) 2025 Bruno Farias, CS8ABG +# Copyright (C) 2020-2023 Simon Adlem, G7RZU +# Copyright (C) 2016-2019 Cortney T. Buffington, N0MJS +# Original works and this derivative are under GPLv3. + +"""ADN DMR Peer Server — conference bridge (rewrite of bridge_master).""" diff --git a/src/adn_server/application/__init__.py b/src/adn_server/application/__init__.py new file mode 100644 index 0000000..a0973d6 --- /dev/null +++ b/src/adn_server/application/__init__.py @@ -0,0 +1,33 @@ +# ADN DMR Peer Server - application layer +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +from .ports import ( + ConfigLoader, + AliasLoader, + SubMapStore, + KeysStore, + ReportSender, + BridgeRouter, + VoiceProvider, + SecurityDownloader, +) +from .bridge_use_cases import BridgeUseCases +from .ident_use_cases import IdentUseCases +from .voice_use_cases import VoiceUseCases +from .reporting_use_cases import ReportingUseCases + +__all__ = [ + "ConfigLoader", + "AliasLoader", + "SubMapStore", + "KeysStore", + "ReportSender", + "BridgeRouter", + "VoiceProvider", + "SecurityDownloader", + "BridgeUseCases", + "IdentUseCases", + "VoiceUseCases", + "ReportingUseCases", +] diff --git a/src/adn_server/application/bridge_use_cases.py b/src/adn_server/application/bridge_use_cases.py new file mode 100644 index 0000000..dbd97c0 --- /dev/null +++ b/src/adn_server/application/bridge_use_cases.py @@ -0,0 +1,1525 @@ +# ADN DMR Peer Server - bridge use cases +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Bridge management: rule_timer, make_single_bridge, make_static_tg, etc. Orchestrates BridgeRouter.""" + +from __future__ import annotations + +import logging +import re +import time +from collections import deque +from typing import Any + +from bitarray import bitarray +from dmr_utils3 import bptc + +from ..domain import int_id, bytes_3, bytes_4 +from ..infrastructure.hbp_constants import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, HBPF_SLT_VTERM, STREAM_TO +from .ports import BridgeRouter + +logger = logging.getLogger(__name__) + + +def _is_special_tg(bridge_key: str) -> bool: + """True if bridge is special TGID 9990-9999 (excluded from infinite timer).""" + if bridge_key and bridge_key[0:1] == "#": + return False + try: + return 9990 <= int(bridge_key) <= 9999 + except ValueError: + return False + + +class BridgeUseCases: + """Use cases for conference bridge state (BRIDGES).""" + + def __init__( + self, + bridge_router: BridgeRouter, + config: dict[str, Any], + send_to_system: Any = None, + get_protocols: Any = None, + report_factory: Any = None, + on_bridge_deactivated: Any = None, + send_bcsq: Any = None, + ) -> None: + self._router = bridge_router + self._config = config + self._send_to_system = send_to_system # (system_name, packet, **kwargs) -> None + self._get_protocols = get_protocols # () -> dict[str, protocol] + self._report_factory = report_factory + self._on_bridge_deactivated = on_bridge_deactivated # (system_name: str) -> None; legacy disconnectedVoice + self._send_bcsq = send_bcsq # (system_name, tgid, stream_id) -> None; legacy OBP send_bcsq from router + + def get_bridges(self) -> dict[str, list[dict[str, Any]]]: + """Return current BRIDGES.""" + return self._router.get_bridges() + + def rule_timer_loop(self) -> None: + """Run one iteration of rule_timer_loop (legacy 52s LoopingCall). Activate/deactivate by timeout.""" + bridges = self._router.get_bridges() + systems_cfg = self._config.get("SYSTEMS", {}) + now = time.time() + remove_bridges: deque = deque() + _debug_msgs: list[str] = [] + + for bridge_key, entries in list(bridges.items()): + bridge_used = False + is_special_tg = _is_special_tg(bridge_key) + + for sys_entry in entries: + system_name = sys_entry.get("SYSTEM", "") + sys_config = systems_cfg.get(system_name, {}) + is_single_mode = sys_config.get("SINGLE_MODE", False) + to_type = sys_entry.get("TO_TYPE", "") + active = sys_entry.get("ACTIVE", False) + timer = sys_entry.get("TIMER", 0.0) + is_dynamic = bridge_key[0:1] != "#" and to_type != "STAT" + is_obp = sys_config.get("MODE") == "OPENBRIDGE" + + if not is_single_mode and is_dynamic and not is_obp and not is_special_tg: + if to_type == "ON": + if active: + bridge_used = True + _debug_msgs.append('(ROUTER) Conference Bridge ACTIVE (INFINITE TIMER): System: %s Bridge: %s, TS: %s, TGID: %s' % (system_name, bridge_key, sys_entry.get("TS"), int_id(sys_entry.get("TGID", b"")))) + else: + _debug_msgs.append('(ROUTER) Conference Bridge INACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s' % (system_name, bridge_key, sys_entry.get("TS"), int_id(sys_entry.get("TGID", b"")))) + elif to_type == "OFF": + if not active: + sys_entry["ACTIVE"] = True + bridge_used = True + logger.info( + "(ROUTER) Conference Bridge ACTIVATED (NO TIMEOUT): System: %s, Bridge: %s, TS: %s, TGID: %s", + system_name, bridge_key, sys_entry.get("TS"), int_id(sys_entry.get("TGID", b"")) + ) + else: + bridge_used = True + _debug_msgs.append('(ROUTER) Conference Bridge ACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s' % (system_name, bridge_key, sys_entry.get("TS"), int_id(sys_entry.get("TGID", b"")))) + else: + if to_type == "ON": + if active: + bridge_used = True + if timer < now: + sys_entry["ACTIVE"] = False + if self._on_bridge_deactivated and bridge_key[:1] == "#": + self._on_bridge_deactivated(system_name) + logger.info( + "(ROUTER) Conference Bridge TIMEOUT: DEACTIVATE System: %s, Bridge: %s, TS: %s, TGID: %s", + system_name, bridge_key, sys_entry.get("TS"), int_id(sys_entry.get("TGID", b"")) + ) + else: + logger.info( + "(ROUTER) Conference Bridge ACTIVE (ON timer running): System: %s Bridge: %s, TS: %s, TGID: %s, Timeout in: %.2fs,", + system_name, bridge_key, sys_entry.get("TS"), int_id(sys_entry.get("TGID", b"")), timer - now + ) + elif not active: + _debug_msgs.append('(ROUTER) Conference Bridge INACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s' % (system_name, bridge_key, sys_entry.get("TS"), int_id(sys_entry.get("TGID", b"")))) + elif to_type == "OFF": + if not active: + if timer < now: + sys_entry["ACTIVE"] = True + bridge_used = True + logger.info( + "(ROUTER) Conference Bridge TIMEOUT: ACTIVATE System: %s, Bridge: %s, TS: %s, TGID: %s", + system_name, bridge_key, sys_entry.get("TS"), int_id(sys_entry.get("TGID", b"")) + ) + else: + bridge_used = True + logger.info( + "(ROUTER) Conference Bridge INACTIVE (OFF timer running): System: %s Bridge: %s, TS: %s, TGID: %s, Timeout in: %.2fs,", + system_name, bridge_key, sys_entry.get("TS"), int_id(sys_entry.get("TGID", b"")), timer - now + ) + elif active: + bridge_used = True + _debug_msgs.append('(ROUTER) Conference Bridge ACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s' % (system_name, bridge_key, sys_entry.get("TS"), int_id(sys_entry.get("TGID", b"")))) + else: + if not is_obp or (is_obp and to_type == "STAT"): + bridge_used = True + _debug_msgs.append('(ROUTER) Conference Bridge NO ACTION: System: %s, Bridge: %s, TS: %s, TGID: %s' % (system_name, bridge_key, sys_entry.get("TS"), int_id(sys_entry.get("TGID", b"")))) + + if not bridge_used: + remove_bridges.append(bridge_key) + + if _debug_msgs: + logger.debug('\n'.join(_debug_msgs)) + + for key in remove_bridges: + del bridges[key] + logger.debug("(ROUTER) Unused conference bridge %s removed", key) + + def bridge_debug_loop(self) -> None: + """Legacy bridgeDebug (bridge_master.py 487-543): remove invalid bridges, fix >1 active dial per MASTER.""" + logger.debug("(BRIDGEDEBUG) Running bridge debug") + bridges = self._router.get_bridges() + systems_cfg = self._config.get("SYSTEMS", {}) + now = time.time() + statroll = 0 + + # Kill off any bridges that should not exist, ever (legacy: 0-9, #0-#9) + for b in ("0", "1", "2", "3", "4", "5", "6", "7", "8", "9"): + bridges.pop(b, None) + bridges.pop("#" + b, None) + + for system in systems_cfg: + bridgeroll = 0 + dialroll = 0 + activeroll = 0 + for _bridge, entries in list(bridges.items()): + for enabled_system in entries: + if enabled_system.get("SYSTEM") == system: + bridgeroll += 1 + if enabled_system.get("ACTIVE"): + if _bridge and _bridge[:1] == "#": + dialroll += 1 + activeroll += 1 + else: + activeroll += 1 + if enabled_system.get("TO_TYPE") == "STAT": + statroll += 1 + if bridgeroll: + logger.debug( + "(BRIDGEDEBUG) system %s has %s bridges of which %s are in an ACTIVE state", + system, bridgeroll, activeroll, + ) + if dialroll > 1 and systems_cfg.get(system, {}).get("MODE") == "MASTER": + logger.warning( + "(BRIDGEDEBUG) system %s has more than one active dial bridge (%s) - fixing", + system, dialroll, + ) + _tmout = float(systems_cfg.get(system, {}).get("DEFAULT_UA_TIMER", 10)) + times: dict[float, str] = {} + for _bridge, entries in list(bridges.items()): + for enabled_system in entries: + if enabled_system.get("ACTIVE") and _bridge and _bridge[:1] == "#": + t = enabled_system.get("TIMER") + if isinstance(t, (int, float)): + times[t] = _bridge + for _bridge in set(times.values()): + logger.warning("(BRIDGEDEBUG) deactivating system: %s for bridge: %s", system, _bridge) + try: + _setbridge = int(_bridge[1:]) if _bridge[:1] == "#" else int(_bridge) + except ValueError: + _setbridge = 9 + bridgetemp: list[dict[str, Any]] = [] + for bridgesystem in bridges.get(_bridge, []): + if bridgesystem.get("SYSTEM") == system and bridgesystem.get("TS") == 2: + bridgetemp.append({ + "SYSTEM": system, + "TS": 2, + "TGID": bytes_3(9), + "ACTIVE": False, + "TIMEOUT": _tmout * 60.0, + "TO_TYPE": "ON", + "OFF": [], + "ON": [bytes_3(_setbridge)], + "RESET": [], + "TIMER": now + _tmout * 60.0, + }) + else: + bridgetemp.append(bridgesystem) + bridges[_bridge] = bridgetemp + + logger.info("(BRIDGEDEBUG) The server currently has %s STATic bridges", statroll) + + def apply_in_band_signalling( + self, system_name: str, slot: int, dst_id: bytes, pkt_time: float + ) -> None: + """Legacy in-band signalling on voice terminator (bridge.py 817-866): reset TIMER, ON/OFF activation.""" + bridges = self._router.get_bridges() + _dst_group = int_id(dst_id) + dst_id_b = dst_id if isinstance(dst_id, bytes) and len(dst_id) >= 3 else bytes_3(_dst_group) + + for _bridge, entries in list(bridges.items()): + for _system in entries: + if _system.get("SYSTEM") != system_name: + continue + _ts = _system.get("TS") + _tgid = _system.get("TGID") + tgid_match = _tgid == dst_id_b or (isinstance(_tgid, bytes) and isinstance(dst_id_b, bytes) and _tgid == dst_id_b) + if not tgid_match and _tgid is not None: + try: + tgid_match = int_id(_tgid) == _dst_group + except (TypeError, ValueError): + pass + # TGID matches a rule source, reset its timer + if slot == _ts and tgid_match: + to_type = _system.get("TO_TYPE", "") + active = _system.get("ACTIVE", False) + timeout = _system.get("TIMEOUT") + timeout_sec = timeout if isinstance(timeout, (int, float)) else 0.0 + if (to_type == "ON" and active) or (to_type == "OFF" and not active): + if timeout_sec: + _system["TIMER"] = pkt_time + timeout_sec + logger.info("(%s) Transmission match for Bridge: %s. Reset timeout to %s", system_name, _bridge, _system["TIMER"]) + + # TGID matches an ACTIVATION trigger (dst_id in ON or RESET) + on_list = _system.get("ON") or [] + reset_list = _system.get("RESET") or [] + if slot == _ts and (dst_id_b in on_list or dst_id_b in reset_list or any(int_id(x) == _dst_group for x in on_list) or any(int_id(x) == _dst_group for x in reset_list)): + if dst_id_b in on_list or any(int_id(x) == _dst_group for x in on_list): + if not _system.get("ACTIVE"): + _system["ACTIVE"] = True + _system["TIMER"] = pkt_time + (float(_system.get("TIMEOUT") or 0) or 0) + logger.info("(%s) Bridge: %s, connection changed to state: %s", system_name, _bridge, _system["ACTIVE"]) + if _system.get("TO_TYPE") == "OFF": + _system["TIMER"] = pkt_time + logger.info("(%s) Bridge: %s set to \"OFF\" with an on timer rule: timeout timer cancelled", system_name, _bridge) + if _system.get("ACTIVE") and _system.get("TO_TYPE") == "ON" and _system.get("TIMEOUT"): + _system["TIMER"] = pkt_time + float(_system["TIMEOUT"]) + logger.info("(%s) Bridge: %s, timeout timer reset to: %s", system_name, _bridge, _system["TIMER"] - pkt_time) + + # TGID matches a DE-ACTIVATION trigger (dst_id in OFF or RESET) + off_list = _system.get("OFF") or [] + if slot == _ts and (dst_id_b in off_list or dst_id_b in reset_list or any(int_id(x) == _dst_group for x in off_list) or any(int_id(x) == _dst_group for x in reset_list)): + if dst_id_b in off_list or any(int_id(x) == _dst_group for x in off_list): + if _system.get("ACTIVE"): + _system["ACTIVE"] = False + logger.info("(%s) Bridge: %s, connection changed to state: %s", system_name, _bridge, _system["ACTIVE"]) + if _system.get("TO_TYPE") == "ON": + _system["TIMER"] = pkt_time + logger.info("(%s) Bridge: %s set to ON with and \"OFF\" timer rule: timeout timer cancelled", system_name, _bridge) + if not _system.get("ACTIVE") and _system.get("TO_TYPE") == "OFF" and _system.get("TIMEOUT"): + _system["TIMER"] = pkt_time + float(_system["TIMEOUT"]) + logger.info("(%s) Bridge: %s, timeout timer reset to: %s", system_name, _bridge, _system["TIMER"] - pkt_time) + if _system.get("ACTIVE") and _system.get("TO_TYPE") == "ON" and _dst_group in [int_id(x) for x in (_system.get("OFF") or [])]: + _system["TIMER"] = pkt_time + logger.info("(%s) Bridge: %s set to ON with and \"OFF\" timer rule: timeout timer cancelled", system_name, _bridge) + + def stream_trimmer_loop(self) -> None: + """Trim old stream state (legacy stream_trimmer_loop, 5s). RX/TX timeout per system/slot; OBP streams (legacy bridge.py 181-240).""" + logger.debug("(ROUTER) Trimming inactive stream IDs from system lists") + protocols = self._get_protocols() if self._get_protocols else {} + systems_cfg = self._config.get("SYSTEMS", {}) + now = time.time() + report = self._report_factory + for system_name, protocol in protocols.items(): + if not getattr(protocol, "STATUS", None): + continue + # OBP: legacy bridge.py 181-202 — send GROUP VOICE,END,RX on timeout so report/monitor clears TG + if systems_cfg.get(system_name, {}).get("MODE") == "OPENBRIDGE": + obp_streams = getattr(protocol, "_obp_streams", None) + if obp_streams and report and hasattr(report, "send_bridge_event"): + to_remove = [] + for stream_id, st in list(obp_streams.items()): + last = st.get("LAST", 0) + if st.get("_fin") and last < now - 180: + to_remove.append(stream_id) + continue + if last < now - 5: + try: + rfs = st.get("RFS", b"\x00\x00\x00") + peer = st.get("RX_PEER", b"\x00\x00\x00\x00") + tgid = st.get("TGID", b"\x00\x00\x00") + start = st.get("START", now) + duration = max(0.0, last - start) + report.send_bridge_event( + "GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}".format( + system_name, int_id(stream_id), int_id(peer), int_id(rfs), 1, int_id(tgid), duration + ) + ) + except Exception: + pass + to_remove.append(stream_id) + for stream_id in to_remove: + obp_streams.pop(stream_id, None) + continue + for slot in (1, 2): + _slot = protocol.STATUS.get(slot) + if not _slot: + continue + if _slot.get("RX_TYPE") != HBPF_SLT_VTERM and _slot.get("RX_TIME", 0) < now - 5: + _slot["RX_TYPE"] = HBPF_SLT_VTERM + logger.info( + "(%s) *TIME OUT* RX STREAM ID: %s SUB: %s TGID %s, TS %s, Duration: %.2f", + system_name, int_id(_slot.get("RX_STREAM_ID", b"")), int_id(_slot.get("RX_RFS", b"")), + int_id(_slot.get("RX_TGID", b"")), slot, _slot.get("RX_TIME", 0) - _slot.get("RX_START", 0), + ) + if report and hasattr(report, "send_bridge_event"): + try: + report.send_bridge_event( + "GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}".format( + system_name, int_id(_slot.get("RX_STREAM_ID", b"")), int_id(_slot.get("RX_PEER", b"")), + int_id(_slot.get("RX_RFS", b"")), slot, int_id(_slot.get("RX_TGID", b"")), + _slot.get("RX_TIME", 0) - _slot.get("RX_START", 0), + ) + ) + except Exception: + pass + if _slot.get("RX_TIME", 0) < now - 60: + _slot["RX_STREAM_ID"] = b"\x00" + if _slot.get("TX_TYPE") != HBPF_SLT_VTERM and _slot.get("TX_TIME", 0) < now - 5: + _slot["TX_TYPE"] = HBPF_SLT_VTERM + logger.debug( + "(%s) *TIME OUT* TX STREAM ID: %s SUB: %s TGID %s, TS %s, Duration: %.2f", + system_name, int_id(_slot.get("TX_STREAM_ID", b"")), int_id(_slot.get("TX_RFS", b"")), + int_id(_slot.get("TX_TGID", b"")), slot, _slot.get("TX_TIME", 0) - _slot.get("TX_START", 0), + ) + if report and hasattr(report, "send_bridge_event"): + try: + report.send_bridge_event( + "GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}".format( + system_name, int_id(_slot.get("TX_STREAM_ID", b"")), int_id(_slot.get("TX_PEER", b"")), + int_id(_slot.get("TX_RFS", b"")), slot, int_id(_slot.get("TX_TGID", b"")), + _slot.get("TX_TIME", 0) - _slot.get("TX_START", 0), + ) + ) + except Exception: + pass + + def bridge_reset_loop(self) -> None: + """Bridge reset iteration (legacy bridge_reset, 6s). Clear _reset and remove_bridge_system.""" + bridges = self._router.get_bridges() + systems_cfg = self._config.get("SYSTEMS", {}) + for system_name in list(systems_cfg.keys()): + sys_cfg = systems_cfg.get(system_name, {}) + if sys_cfg.get("_reset"): + logger.info("(BRIDGERESET) Bridge reset for %s - no peers", system_name) + self._remove_bridge_system(system_name, bridges) + try: + del sys_cfg["_opt_key"] + except KeyError: + pass + sys_cfg["_reset"] = False + sys_cfg["_resetlog"] = False + + def _remove_bridge_system(self, system_name: str, bridges: dict[str, list[dict[str, Any]]]) -> None: + """Remove all bridge entries for system (legacy remove_bridge_system).""" + to_remove: list[str] = [] + for bridge_key, entries in list(bridges.items()): + new_entries = [e for e in entries if e.get("SYSTEM") != system_name] + if len(new_entries) < len(entries): + if new_entries: + bridges[bridge_key] = new_entries + else: + to_remove.append(bridge_key) + for key in to_remove: + del bridges[key] + + def stat_trimmer_loop(self) -> None: + """Trim STAT-only bridges with no ON/OFF in use (legacy statTrimmer, 303s).""" + logger.debug("(ROUTER) STAT trimmer loop started") + bridges = self._router.get_bridges() + remove_bridges: deque = deque() + for bridge_key, entries in list(bridges.items()): + has_stat = any(e.get("TO_TYPE") == "STAT" for e in entries) + in_use = any( + (e.get("TO_TYPE") == "ON" and e.get("ACTIVE")) or e.get("TO_TYPE") == "OFF" + for e in entries + ) + if has_stat and not in_use: + remove_bridges.append(bridge_key) + for key in remove_bridges: + del bridges[key] + logger.debug("(ROUTER) STAT bridge %s removed", key) + + def acl_check(self, id_val: bytes | int, acl: tuple[bool, list[tuple[int, int]]]) -> bool: + """Check ID against ACL. Legacy acl_check.""" + return self._router.acl_check(id_val, acl) + + def make_single_bridge( + self, + _tgid: bytes | int, + _sourcesystem: str, + _slot: int, + _tmout: float, + ) -> None: + """Legacy make_single_bridge: create bridge for TG with entries per MASTER (source ACTIVE on its slot) and OBP.""" + tgid_int = int_id(_tgid) if not isinstance(_tgid, int) else _tgid + _tgid_s = str(tgid_int) + _tgid_b = _tgid if isinstance(_tgid, bytes) and len(_tgid) >= 3 else bytes_3(tgid_int) + if _tgid_s in ("9990", "9991", "9992", "9993", "9994", "9995", "9996", "9997", "9998", "9999"): + _tmout = 1.0 / 6.0 + timeout_sec = _tmout * 60.0 + now = time.time() + bridges = self._router.get_bridges() + bridges[_tgid_s] = [] + systems_cfg = self._config.get("SYSTEMS", {}) + for _system in systems_cfg: + sys_cfg = systems_cfg.get(_system, {}) + if sys_cfg.get("MODE") == "OPENBRIDGE": + if 79 <= tgid_int < 9990 or tgid_int > 9999: + bridges[_tgid_s].append({"SYSTEM": _system, "TS": 1, "TGID": _tgid_b, "ACTIVE": True, "TIMEOUT": "", "TO_TYPE": "NONE", "OFF": [], "ON": [], "RESET": [], "TIMER": now}) + else: + if _system == _sourcesystem: + if _slot == 1: + bridges[_tgid_s].append({"SYSTEM": _system, "TS": 1, "TGID": _tgid_b, "ACTIVE": True, "TIMEOUT": timeout_sec, "TO_TYPE": "ON", "OFF": [], "ON": [_tgid_b], "RESET": [], "TIMER": now + timeout_sec}) + bridges[_tgid_s].append({"SYSTEM": _system, "TS": 2, "TGID": _tgid_b, "ACTIVE": False, "TIMEOUT": timeout_sec, "TO_TYPE": "ON", "OFF": [], "ON": [_tgid_b], "RESET": [], "TIMER": now}) + else: + bridges[_tgid_s].append({"SYSTEM": _system, "TS": 2, "TGID": _tgid_b, "ACTIVE": True, "TIMEOUT": timeout_sec, "TO_TYPE": "ON", "OFF": [], "ON": [_tgid_b], "RESET": [], "TIMER": now + timeout_sec}) + bridges[_tgid_s].append({"SYSTEM": _system, "TS": 1, "TGID": _tgid_b, "ACTIVE": False, "TIMEOUT": timeout_sec, "TO_TYPE": "ON", "OFF": [], "ON": [_tgid_b], "RESET": [], "TIMER": now}) + else: + # Other MASTER/PEER: ACTIVE False (legacy). ACTIVE True only via make_static_tg when a peer has this TG static. + bridges[_tgid_s].append({"SYSTEM": _system, "TS": 1, "TGID": _tgid_b, "ACTIVE": False, "TIMEOUT": timeout_sec, "TO_TYPE": "ON", "OFF": [], "ON": [_tgid_b], "RESET": [], "TIMER": now}) + bridges[_tgid_s].append({"SYSTEM": _system, "TS": 2, "TGID": _tgid_b, "ACTIVE": False, "TIMEOUT": timeout_sec, "TO_TYPE": "ON", "OFF": [], "ON": [_tgid_b], "RESET": [], "TIMER": now}) + + def make_single_reflector(self, _tgid: bytes | int, _tmout: float, _sourcesystem: str) -> None: + """Legacy make_single_reflector: create reflector bridge #tgid with MASTERs and OBP.""" + _tgid_s = str(int_id(_tgid) if not isinstance(_tgid, int) else _tgid) + _bridge = "#" + _tgid_s + _tgid_b = _tgid if isinstance(_tgid, bytes) and len(_tgid) >= 3 else bytes_3(int(_tgid_s)) + if _tgid_s in ("9990", "9991", "9992", "9993", "9994", "9995", "9996", "9997", "9998", "9999"): + _tmout = 1.0 / 6.0 + now = time.time() + bridges = self._router.get_bridges() + bridges[_bridge] = [] + systems_cfg = self._config.get("SYSTEMS", {}) + for _system in systems_cfg: + if systems_cfg.get(_system, {}).get("MODE") == "MASTER": + def_ua = systems_cfg[_system].get("DEFAULT_UA_TIMER", 10) * 60.0 + if _system == _sourcesystem: + bridges[_bridge].append({"SYSTEM": _system, "TS": 2, "TGID": bytes_3(9), "ACTIVE": True, "TIMEOUT": _tmout * 60.0, "TO_TYPE": "ON", "OFF": [], "ON": [_tgid_b], "RESET": [], "TIMER": now + _tmout * 60.0}) + else: + bridges[_bridge].append({"SYSTEM": _system, "TS": 2, "TGID": bytes_3(9), "ACTIVE": False, "TIMEOUT": def_ua, "TO_TYPE": "ON", "OFF": [], "ON": [_tgid_b], "RESET": [], "TIMER": now}) + if systems_cfg.get(_system, {}).get("MODE") == "OPENBRIDGE" and (79 <= int(_tgid_s) < 9990 or int(_tgid_s) > 9999): + bridges[_bridge].append({"SYSTEM": _system, "TS": 1, "TGID": _tgid_b, "ACTIVE": True, "TIMEOUT": "", "TO_TYPE": "NONE", "OFF": [], "ON": [], "RESET": [], "TIMER": now}) + + def make_default_reflector(self, reflector: int, _tmout: float, system: str) -> None: + """Legacy make_default_reflector: ensure #reflector bridge exists and set system TS2 to ACTIVE/OFF.""" + bridge = "#" + str(reflector) + bridges = self._router.get_bridges() + if bridge not in bridges: + bridges[bridge] = [] + self.make_single_reflector(bytes_3(reflector), _tmout, system) + bridgetemp = deque() + for bridgesystem in bridges.get(bridge, []): + if bridgesystem.get("SYSTEM") == system and bridgesystem.get("TS") == 2: + bridgetemp.append({"SYSTEM": system, "TS": 2, "TGID": bytes_3(9), "ACTIVE": True, "TIMEOUT": _tmout * 60.0, "TO_TYPE": "OFF", "OFF": [], "ON": [bytes_3(reflector)], "RESET": [], "TIMER": time.time() + _tmout * 60.0}) + else: + bridgetemp.append(bridgesystem) + bridges[bridge] = list(bridgetemp) + + def make_static_tg(self, tg: int, ts: int, _tmout: float, system: str) -> None: + """Legacy make_static_tg: ensure bridge for tg exists and set system/ts to ACTIVE/OFF.""" + if str(tg) not in self._router.get_bridges(): + self.make_single_bridge(bytes_3(tg), system, ts, _tmout) + bridges = self._router.get_bridges() + bridgetemp = deque() + for bridgesystem in bridges.get(str(tg), []): + if bridgesystem.get("SYSTEM") == system and bridgesystem.get("TS") == ts: + bridgetemp.append({"SYSTEM": system, "TS": ts, "TGID": bytes_3(tg), "ACTIVE": True, "TIMEOUT": _tmout * 60.0, "TO_TYPE": "OFF", "OFF": [], "ON": [bytes_3(tg)], "RESET": [], "TIMER": time.time() + _tmout * 60.0}) + else: + bridgetemp.append(bridgesystem) + bridges[str(tg)] = list(bridgetemp) + + def reset_static_tg(self, tg: int, ts: int, _tmout: float, system: str) -> None: + """Legacy reset_static_tg: set system/ts entry to ACTIVE False, TO_TYPE ON.""" + bridges = self._router.get_bridges() + key = str(tg) + if key not in bridges: + return + bridgetemp = deque() + for bridgesystem in bridges[key]: + if bridgesystem.get("SYSTEM") == system and bridgesystem.get("TS") == ts: + bridgetemp.append({"SYSTEM": system, "TS": ts, "TGID": bytes_3(tg), "ACTIVE": False, "TIMEOUT": _tmout * 60.0, "TO_TYPE": "ON", "OFF": [], "ON": [bytes_3(tg)], "RESET": [], "TIMER": time.time() + _tmout * 60.0}) + else: + bridgetemp.append(bridgesystem) + bridges[key] = list(bridgetemp) + + def reset_all_reflector_system(self, _tmout: float, system: str) -> None: + """Legacy reset_all_reflector_system: set system's TS2 entry to inactive in every # bridge.""" + bridges = self._router.get_bridges() + timeout_sec = _tmout * 60.0 + now = time.time() + for bridge in list(bridges.keys()): + if bridge[:1] != "#": + continue + bridgetemp = deque() + for bridgesystem in bridges.get(bridge, []): + if bridgesystem.get("SYSTEM") == system and bridgesystem.get("TS") == 2: + tgid = bridgesystem.get("TGID", bytes_3(9)) + on_tgid = bytes_3(int(bridge[1:])) if bridge[1:] else bytes_3(9) + bridgetemp.append({"SYSTEM": system, "TS": 2, "TGID": tgid, "ACTIVE": False, "TIMEOUT": timeout_sec, "TO_TYPE": "ON", "OFF": [], "ON": [on_tgid], "RESET": [], "TIMER": now + timeout_sec}) + else: + bridgetemp.append(bridgesystem) + bridges[bridge] = list(bridgetemp) + + def remove_bridge_system(self, system: str) -> None: + """Legacy remove_bridge_system: set all entries for system to ACTIVE False, TO_TYPE ON.""" + bridges = self._router.get_bridges() + for _bridge in list(bridges.keys()): + bridgetemp = deque() + for bridgesystem in bridges.get(_bridge, []): + if bridgesystem.get("SYSTEM") != system: + bridgetemp.append(bridgesystem) + else: + t = bridgesystem.get("TIMEOUT") or 600.0 + if isinstance(t, str): + t = 600.0 + bridgetemp.append({"SYSTEM": system, "TS": bridgesystem.get("TS", 1), "TGID": bridgesystem.get("TGID", bytes_3(0)), "ACTIVE": False, "TIMEOUT": t, "TO_TYPE": "ON", "OFF": [], "ON": [bridgesystem.get("TGID", bytes_3(0))], "RESET": [], "TIMER": time.time() + t}) + bridges[_bridge] = list(bridgetemp) + + def make_stat_bridge(self, _tgid: bytes) -> None: + """Legacy make_stat_bridge: on-the-fly relay bridges for OBP traffic when GEN_STAT_BRIDGES is True.""" + _tgid_s = str(int_id(_tgid)) + bridges = self._router.get_bridges() + bridges[_tgid_s] = [] + systems_cfg = self._config.get("SYSTEMS", {}) + now = time.time() + for _system in systems_cfg: + sys_cfg = systems_cfg.get(_system, {}) + if sys_cfg.get("MODE") != "OPENBRIDGE": + if sys_cfg.get("MODE") == "MASTER": + _tmout = float(sys_cfg.get("DEFAULT_UA_TIMER", 10)) + timeout_sec = _tmout * 60.0 + bridges[_tgid_s].append({"SYSTEM": _system, "TS": 1, "TGID": _tgid, "ACTIVE": False, "TIMEOUT": timeout_sec, "TO_TYPE": "ON", "OFF": [], "ON": [_tgid], "RESET": [], "TIMER": now}) + bridges[_tgid_s].append({"SYSTEM": _system, "TS": 2, "TGID": _tgid, "ACTIVE": False, "TIMEOUT": timeout_sec, "TO_TYPE": "ON", "OFF": [], "ON": [_tgid], "RESET": [], "TIMER": now}) + else: + bridges[_tgid_s].append({"SYSTEM": _system, "TS": 1, "TGID": _tgid, "ACTIVE": True, "TIMEOUT": "", "TO_TYPE": "STAT", "OFF": [], "ON": [], "RESET": [], "TIMER": now}) + + def deactivate_all_dynamic_bridges(self, system_name: str) -> None: + """Legacy deactivate_all_dynamic_bridges: deactivate all non-STAT, non-reflector bridges for a system (TG 4000).""" + bridges = self._router.get_bridges() + for _bridge in list(bridges): + if _bridge[:1] == "#": + continue + for _sys_entry in bridges[_bridge]: + if _sys_entry.get("SYSTEM") == system_name and _sys_entry.get("TO_TYPE") != "STAT": + if _sys_entry.get("ACTIVE"): + _sys_entry["ACTIVE"] = False + logger.info( + "(ROUTER) Deactivated dynamic bridge due to TG/ID 4000: System: %s, Bridge: %s, TS: %s, TGID: %s", + system_name, _bridge, _sys_entry.get("TS"), int_id(_sys_entry.get("TGID", b"\x00\x00\x00")), + ) + + def _readd_system_after_ua_timer_change(self, system: str, _tmout: float) -> None: + """After remove_bridge_system, re-add system to bridges that no longer have ts1/ts2 (legacy 1624-1639).""" + bridges = self._router.get_bridges() + timeout_sec = _tmout * 60.0 + now = time.time() + for _bridge in list(bridges.keys()): + has_ts1 = any(e.get("SYSTEM") == system and e.get("TS") == 1 for e in bridges.get(_bridge, [])) + has_ts2 = any(e.get("SYSTEM") == system and e.get("TS") == 2 for e in bridges.get(_bridge, [])) + if _bridge[:1] != "#": + if not has_ts1: + try: + bridges[_bridge].append({"SYSTEM": system, "TS": 1, "TGID": bytes_3(int(_bridge)), "ACTIVE": False, "TIMEOUT": timeout_sec, "TO_TYPE": "ON", "OFF": [], "ON": [bytes_3(int(_bridge))], "RESET": [], "TIMER": now + timeout_sec}) + except ValueError: + pass + if not has_ts2: + try: + bridges[_bridge].append({"SYSTEM": system, "TS": 2, "TGID": bytes_3(int(_bridge)), "ACTIVE": False, "TIMEOUT": timeout_sec, "TO_TYPE": "ON", "OFF": [], "ON": [bytes_3(int(_bridge))], "RESET": [], "TIMER": now + timeout_sec}) + except ValueError: + pass + else: + if not has_ts2: + try: + bridges[_bridge].append({"SYSTEM": system, "TS": 2, "TGID": bytes_3(9), "ACTIVE": False, "TIMEOUT": timeout_sec, "TO_TYPE": "ON", "OFF": [bytes_3(4000)], "ON": [], "RESET": [], "TIMER": now + timeout_sec}) + except ValueError: + pass + + def apply_startup_bridges(self) -> None: + """Legacy startup: set default reflectors and static TGs for each MASTER system.""" + prohibited_tgs = (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999) + logger.debug("(ROUTER) Setting default reflectors") + for system, sys_cfg in self._config.get("SYSTEMS", {}).items(): + if sys_cfg.get("MODE") != "MASTER": + continue + default_ref = int(sys_cfg.get("DEFAULT_REFLECTOR", 0)) + if default_ref not in prohibited_tgs: + self.make_default_reflector(default_ref, float(sys_cfg.get("DEFAULT_UA_TIMER", 10)), system) + logger.debug("(ROUTER) setting static TGs") + for system, sys_cfg in self._config.get("SYSTEMS", {}).items(): + if sys_cfg.get("MODE") != "MASTER": + continue + tmout = float(sys_cfg.get("DEFAULT_UA_TIMER", 10)) + ts1_raw = sys_cfg.get("TS1_STATIC") or "" + ts2_raw = sys_cfg.get("TS2_STATIC") or "" + ts1 = [s.strip() for s in ts1_raw.split(",") if s.strip()] + ts2 = [s.strip() for s in ts2_raw.split(",") if s.strip()] + for tg_s in ts1: + try: + tg = int(tg_s) + except ValueError: + continue + if tg in prohibited_tgs: + continue + self.make_static_tg(tg, 1, tmout, system) + for tg_s in ts2: + try: + tg = int(tg_s) + except ValueError: + continue + if tg in prohibited_tgs: + continue + self.make_static_tg(tg, 2, tmout, system) + + def options_config_for_system(self, system_name: str) -> None: + """Update static TG bridges for one system immediately (e.g. when RPTO received). So incoming OBP traffic reaches hotspots without waiting for the 26s options_config_loop.""" + prohibited_tgs = (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999) + systems_cfg = self._config.get("SYSTEMS", {}) + sys_cfg = systems_cfg.get(system_name, {}) + if sys_cfg.get("MODE") != "MASTER" or "OPTIONS" not in sys_cfg: + return + try: + opt_str = sys_cfg["OPTIONS"] + if isinstance(opt_str, bytes): + opt_str = opt_str.decode("utf8", errors="replace") + opt_str = opt_str.rstrip("\x00").encode("ascii", "ignore").decode() + opt_str = re.sub(r"['\"]", "", opt_str) + _options: dict[str, Any] = {} + for x in opt_str.split(";"): + try: + k, v = x.split("=", 1) + _options[k.strip()] = v.strip() + except ValueError: + continue + for old_k, new_k in [("DIAL", "DEFAULT_REFLECTOR"), ("TIMER", "DEFAULT_UA_TIMER"), ("TS1", "TS1_STATIC"), ("TS2", "TS2_STATIC")]: + if old_k in _options: + _options[new_k] = _options.pop(old_k) + for old_k, new_k in [("StartRef", "DEFAULT_REFLECTOR"), ("RelinkTime", "DEFAULT_UA_TIMER")]: + if old_k in _options: + _options[new_k] = _options.pop(old_k) + if "TS1_1" in _options: + parts = [_options.pop("TS1_1", "")] + for i in range(2, 10): + p = _options.pop(f"TS1_{i}", None) + if p is not None: + parts.append(p) + _options["TS1_STATIC"] = ",".join(parts) + if "TS2_1" in _options: + parts = [_options.pop("TS2_1", "")] + for i in range(2, 10): + p = _options.pop(f"TS2_{i}", None) + if p is not None: + parts.append(p) + _options["TS2_STATIC"] = ",".join(parts) + _options.setdefault("DEFAULT_UA_TIMER", sys_cfg.get("DEFAULT_UA_TIMER", 10)) + _tmout = float(int(_options.get("DEFAULT_UA_TIMER", 10))) + if _tmout <= 0: + _tmout = 35791394 + new_ts1 = str(_options.get("TS1_STATIC") or "").strip() + new_ts2 = str(_options.get("TS2_STATIC") or "").strip() + if re.search(r"[^\d,]", new_ts1) or re.search(r"[^\d,]", new_ts2): + return + for tg_s in new_ts1.split(","): + if not tg_s.strip(): + continue + try: + tg = int(tg_s) + if tg in prohibited_tgs: + continue + self.make_static_tg(tg, 1, _tmout, system_name) + except ValueError: + pass + for tg_s in new_ts2.split(","): + if not tg_s.strip(): + continue + try: + tg = int(tg_s) + if tg == 0 or tg >= 16777215 or tg in prohibited_tgs: + continue + self.make_static_tg(tg, 2, _tmout, system_name) + except ValueError: + pass + systems_cfg[system_name]["TS1_STATIC"] = new_ts1 + systems_cfg[system_name]["TS2_STATIC"] = new_ts2 + systems_cfg[system_name]["DEFAULT_UA_TIMER"] = int(_tmout) + if new_ts1 or new_ts2: + logger.info("(OPTIONS) %s static TGs applied: TS1=%s TS2=%s", system_name, new_ts1 or "-", new_ts2 or "-") + except Exception as e: + logger.debug("(OPTIONS) options_config_for_system %s: %s", system_name, e) + + def _parse_options_static_tgs(self, opt_str: str, sys_cfg: dict) -> tuple[float, list[int], list[int]] | None: + """Parse OPTIONS string; return (tmout, ts1_tg_list, ts2_tg_list) or None. Used to apply static TGs to an existing bridge.""" + try: + if isinstance(opt_str, bytes): + opt_str = opt_str.decode("utf8", errors="replace") + opt_str = opt_str.rstrip("\x00").encode("ascii", "ignore").decode() + opt_str = re.sub(r"['\"]", "", opt_str) + _options: dict[str, Any] = {} + for x in opt_str.split(";"): + try: + k, v = x.split("=", 1) + _options[k.strip()] = v.strip() + except ValueError: + continue + for old_k, new_k in [("DIAL", "DEFAULT_REFLECTOR"), ("TIMER", "DEFAULT_UA_TIMER"), ("TS1", "TS1_STATIC"), ("TS2", "TS2_STATIC")]: + if old_k in _options: + _options[new_k] = _options.pop(old_k) + if "TS1_1" in _options: + parts = [_options.pop("TS1_1", "")] + for i in range(2, 10): + p = _options.pop(f"TS1_{i}", None) + if p is not None: + parts.append(p) + _options["TS1_STATIC"] = ",".join(parts) + if "TS2_1" in _options: + parts = [_options.pop("TS2_1", "")] + for i in range(2, 10): + p = _options.pop(f"TS2_{i}", None) + if p is not None: + parts.append(p) + _options["TS2_STATIC"] = ",".join(parts) + _tmout = float(int(_options.get("DEFAULT_UA_TIMER", sys_cfg.get("DEFAULT_UA_TIMER", 10)))) + if _tmout <= 0: + _tmout = 35791394 + ts1_list: list[int] = [] + ts2_list: list[int] = [] + for tg_s in str(_options.get("TS1_STATIC") or "").split(","): + try: + tg1 = int(tg_s.strip()) + if tg1 not in (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999): + ts1_list.append(tg1) + except ValueError: + pass + for tg_s in str(_options.get("TS2_STATIC") or "").split(","): + try: + tg2 = int(tg_s.strip()) + if 0 < tg2 < 16777215 and tg2 not in (9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999): + ts2_list.append(tg2) + except ValueError: + pass + return (_tmout, ts1_list, ts2_list) + except Exception: + return None + + def apply_static_tg_to_bridge(self, tg_int: int) -> None: + """When a bridge was just created from OBP, mark MASTER systems that have this TG as static (OPTIONS) as ACTIVE so the first OBP traffic reaches them.""" + systems_cfg = self._config.get("SYSTEMS", {}) + for _system in systems_cfg: + if systems_cfg.get(_system, {}).get("MODE") != "MASTER": + continue + if not systems_cfg.get(_system, {}).get("ENABLED", True): + continue + if "OPTIONS" not in systems_cfg.get(_system, {}): + continue + parsed = self._parse_options_static_tgs(systems_cfg[_system]["OPTIONS"], systems_cfg[_system]) + if not parsed: + continue + _tmout, ts1_list, ts2_list = parsed + if tg_int in ts1_list: + self.make_static_tg(tg_int, 1, _tmout, _system) + if tg_int in ts2_list: + self.make_static_tg(tg_int, 2, _tmout, _system) + + def log_connected_systems_and_tgs(self) -> None: + """Periodic debug: log each system, connection state, and static TGs (TS1/TS2). Only emits at DEBUG level.""" + if not logger.isEnabledFor(logging.DEBUG): + return + systems_cfg = self._config.get("SYSTEMS", {}) + protocols = self._get_protocols() if self._get_protocols else {} + lines: list[str] = ["(DEBUG) Systems and TGs:"] + for name in sorted(systems_cfg.keys()): + cfg = systems_cfg.get(name, {}) + mode = cfg.get("MODE", "?") + enabled = cfg.get("ENABLED", True) + en = "enabled" if enabled else "disabled" + parts = [f" {name}: {mode} ({en})"] + if mode == "MASTER": + proto = protocols.get(name) + peers = getattr(proto, "_peers", {}) if proto else {} + connected = [p for p in peers.values() if p.get("CONNECTION") == "YES"] + parts.append(f"peers_connected={len(connected)}") + if connected: + def _cs(c): + v = c.get("CALLSIGN") or b"" + if isinstance(v, bytes): + return v.decode("utf8", errors="replace").strip() or "?" + return str(v).strip() or "?" + parts.append( + "peers=[%s]" + % ", ".join("%s/%s" % (p.get("RADIO_ID", "?"), _cs(p)) for p in connected[:10]) + ) + if len(connected) > 10: + parts[-1] = parts[-1].rstrip("]") + f", +{len(connected) - 10} more]" + ts1 = (cfg.get("TS1_STATIC") or "").strip() + ts2 = (cfg.get("TS2_STATIC") or "").strip() + if ts1 or ts2: + parts.append("TS1=%s TS2=%s" % (ts1 or "-", ts2 or "-")) + elif mode == "PEER": + proto = protocols.get(name) + conn = getattr(proto, "_stats", {}).get("CONNECTION", "?") if proto else "?" + parts.append("connection=%s" % conn) + lines.append("".join(parts)) + if len(lines) > 1: + logger.debug("\n".join(lines)) + + def options_config_loop(self) -> None: + """Legacy options_config: parse OPTIONS from MASTER systems and update bridges (default reflector, static TGs).""" + prohibited_tgs = (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999) + logger.debug("(OPTIONS) Running options parser") + systems_cfg = self._config.get("SYSTEMS", {}) + for _system in list(systems_cfg.keys()): + try: + if systems_cfg.get(_system, {}).get("MODE") != "MASTER": + continue + if not systems_cfg.get(_system, {}).get("ENABLED", True): + continue + if "OPTIONS" not in systems_cfg.get(_system, {}): + continue + opt_str = systems_cfg[_system]["OPTIONS"] + if isinstance(opt_str, bytes): + opt_str = opt_str.decode("utf8", errors="replace") + opt_str = opt_str.rstrip("\x00").encode("ascii", "ignore").decode() + opt_str = re.sub(r"['\"]", "", opt_str) + _options: dict[str, Any] = {} + for x in opt_str.split(";"): + try: + k, v = x.split("=", 1) + _options[k.strip()] = v.strip() + except ValueError: + continue + logger.debug("(OPTIONS) Options found for %s", _system) + if "_opt_key" in systems_cfg[_system] and systems_cfg[_system].get("_opt_key"): + if "KEY" not in _options: + logger.debug("(OPTIONS) %s, options key set but no key in options string, skipping", _system) + continue + if systems_cfg[_system]["_opt_key"] != _options.get("KEY"): + logger.debug("(OPTIONS) %s, options key set but key sent does not match, skipping", _system) + continue + elif _options.get("KEY"): + systems_cfg[_system]["_opt_key"] = _options["KEY"] + logger.debug("(OPTIONS) %s, _opt_key not set but key sent. Setting to sent key", _system) + else: + systems_cfg[_system]["_opt_key"] = False + logger.debug("(OPTIONS) %s, _opt_key not set and no key sent. Set to false", _system) + for old_k, new_k in [("DIAL", "DEFAULT_REFLECTOR"), ("TIMER", "DEFAULT_UA_TIMER"), ("TS1", "TS1_STATIC"), ("TS2", "TS2_STATIC"), ("IDENTTG", "OVERRIDE_IDENT_TG"), ("VOICETG", "OVERRIDE_IDENT_TG"), ("IDENT", "VOICE")]: + if old_k in _options: + _options[new_k] = _options.pop(old_k) + for old_k, new_k in [("StartRef", "DEFAULT_REFLECTOR"), ("RelinkTime", "DEFAULT_UA_TIMER")]: + if old_k in _options: + _options[new_k] = _options.pop(old_k) + if "TS1_1" in _options: + parts = [_options.pop("TS1_1", "")] + for i in range(2, 10): + p = _options.pop(f"TS1_{i}", None) + if p is not None: + parts.append(p) + _options["TS1_STATIC"] = ",".join(parts) + if "TS2_1" in _options: + parts = [_options.pop("TS2_1", "")] + for i in range(2, 10): + p = _options.pop(f"TS2_{i}", None) + if p is not None: + parts.append(p) + _options["TS2_STATIC"] = ",".join(parts) + # VOICE_IDENT, SINGLE_MODE, LANG (legacy options_config lines 1576-1587) + if "VOICE" in _options and bool(_options["VOICE"]) and (systems_cfg[_system].get("VOICE_IDENT") != bool(int(_options["VOICE"]))): + systems_cfg[_system]["VOICE_IDENT"] = bool(int(_options["VOICE"])) + logger.debug("(OPTIONS) %s - Setting voice ident to %s", _system, systems_cfg[_system]["VOICE_IDENT"]) + if "OVERRIDE_IDENT_TG" in _options and _options["OVERRIDE_IDENT_TG"] and (systems_cfg[_system].get("OVERRIDE_IDENT_TG") != int(_options["OVERRIDE_IDENT_TG"])): + systems_cfg[_system]["OVERRIDE_IDENT_TG"] = int(_options["OVERRIDE_IDENT_TG"]) + logger.debug("(OPTIONS) %s - Setting OVERRIDE_IDENT_TG to %s", _system, systems_cfg[_system]["OVERRIDE_IDENT_TG"]) + if "LANG" in _options and _options["LANG"] != systems_cfg[_system].get("ANNOUNCEMENT_LANGUAGE"): + systems_cfg[_system]["ANNOUNCEMENT_LANGUAGE"] = _options["LANG"] + logger.debug("(OPTIONS) %s - Setting voice language to %s", _system, systems_cfg[_system]["ANNOUNCEMENT_LANGUAGE"]) + if "SINGLE" in _options and (systems_cfg[_system].get("SINGLE_MODE") != bool(int(_options["SINGLE"]))): + systems_cfg[_system]["SINGLE_MODE"] = bool(int(_options["SINGLE"])) + logger.debug("(OPTIONS) %s - Setting SINGLE_MODE to %s", _system, systems_cfg[_system]["SINGLE_MODE"]) + _options.setdefault("TS1_STATIC", False) + _options.setdefault("TS2_STATIC", False) + _options.setdefault("DEFAULT_REFLECTOR", 0) + _options.setdefault("OVERRIDE_IDENT_TG", False) + _options.setdefault("DEFAULT_UA_TIMER", systems_cfg[_system].get("DEFAULT_UA_TIMER", 10)) + if "TS1_STATIC" not in _options or "TS2_STATIC" not in _options or "DEFAULT_REFLECTOR" not in _options or "DEFAULT_UA_TIMER" not in _options: + logger.debug("(OPTIONS) %s - Required field missing, ignoring", _system) + continue + if _options["TS1_STATIC"] == "": + _options["TS1_STATIC"] = False + if _options["TS2_STATIC"] == "": + _options["TS2_STATIC"] = False + if _options.get("TS1_STATIC") and re.search(r"[^\d,]", str(_options["TS1_STATIC"])): + logger.debug("(OPTIONS) %s - TS1_STATIC contains characters other than numbers and comma, ignoring", _system) + continue + if _options.get("TS2_STATIC") and re.search(r"[^\d,]", str(_options["TS2_STATIC"])): + logger.debug("(OPTIONS) %s - TS2_STATIC contains characters other than numbers and comma, ignoring", _system) + continue + for key in ("DEFAULT_REFLECTOR", "OVERRIDE_IDENT_TG", "DEFAULT_UA_TIMER"): + if isinstance(_options.get(key), str) and not str(_options[key]).isdigit(): + logger.debug("(OPTIONS) %s - %s is not an integer, ignoring", _system, key) + continue + if int(_options.get("DEFAULT_UA_TIMER", 0)) == 0: + _options["DEFAULT_UA_TIMER"] = 35791394 + _tmout = float(int(_options["DEFAULT_UA_TIMER"])) + ua_timer_changed = int(_options["DEFAULT_UA_TIMER"]) != systems_cfg[_system].get("DEFAULT_UA_TIMER") + if ua_timer_changed: + logger.debug("(OPTIONS) %s Updating DEFAULT_UA_TIMER for existing bridges.", _system) + self.remove_bridge_system(_system) + self._readd_system_after_ua_timer_change(_system, _tmout) + new_ref = int(_options.get("DEFAULT_REFLECTOR", 0)) + cur_ref = int(systems_cfg[_system].get("DEFAULT_REFLECTOR", 0)) + if new_ref != cur_ref: + if new_ref > 0: + logger.debug("(OPTIONS) %s default reflector changed, updating", _system) + self.reset_all_reflector_system(_tmout, _system) + self.make_default_reflector(new_ref, _tmout, _system) + elif new_ref in prohibited_tgs and not bool(new_ref): + logger.debug("(OPTIONS) %s default reflector is prohibited, ignoring change", _system) + else: + logger.debug("(OPTIONS) %s default reflector disabled, updating", _system) + self.reset_all_reflector_system(_tmout, _system) + cur_ts1 = systems_cfg[_system].get("TS1_STATIC") or "" + cur_ts2 = systems_cfg[_system].get("TS2_STATIC") or "" + new_ts1 = _options.get("TS1_STATIC") or "" + new_ts2 = _options.get("TS2_STATIC") or "" + if str(new_ts1) != str(cur_ts1) or ua_timer_changed: + logger.debug("(OPTIONS) %s TS1 static TGs changed, updating", _system) + if cur_ts1: + for tg_s in str(cur_ts1).split(","): + if not tg_s.strip(): + continue + try: + self.reset_static_tg(int(tg_s), 1, _tmout, _system) + except ValueError: + pass + if new_ts1: + for tg_s in str(new_ts1).split(","): + if not tg_s.strip(): + continue + try: + tg = int(tg_s) + if tg in prohibited_tgs: + logger.debug("(OPTIONS) %s TS1 TG %s is prohibited, ignoring change", _system, tg) + continue + self.make_static_tg(tg, 1, _tmout, _system) + except ValueError: + pass + if str(new_ts2) != str(cur_ts2) or ua_timer_changed: + logger.debug("(OPTIONS) %s TS2 static TGs changed, updating", _system) + if cur_ts2: + for tg_s in str(cur_ts2).split(","): + if not tg_s.strip(): + continue + try: + t = int(tg_s) + if t == 0 or t >= 16777215: + continue + self.reset_static_tg(t, 2, _tmout, _system) + except ValueError: + pass + if new_ts2: + for tg_s in str(new_ts2).split(","): + if not tg_s.strip(): + continue + try: + tg = int(tg_s) + if tg == 0 or tg >= 16777215: + continue + if tg in prohibited_tgs: + logger.debug("(OPTIONS) %s TS2 TG %s is prohibited, ignoring change", _system, tg) + continue + self.make_static_tg(tg, 2, _tmout, _system) + except ValueError: + pass + systems_cfg[_system]["TS1_STATIC"] = _options.get("TS1_STATIC") or "" + systems_cfg[_system]["TS2_STATIC"] = _options.get("TS2_STATIC") or "" + systems_cfg[_system]["DEFAULT_REFLECTOR"] = int(_options.get("DEFAULT_REFLECTOR", 0)) + systems_cfg[_system]["DEFAULT_UA_TIMER"] = int(_options.get("DEFAULT_UA_TIMER", 10)) + except Exception as e: + logger.exception("(OPTIONS) caught exception: %s", e) + + 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: + """Called by UDP MASTER when DMRD is received. Forward to other systems in same bridge (to_target).""" + if not self._send_to_system: + return + if call_type == "unit": + self._pvt_call_received(system_name, peer_id, rf_src, dst_id, seq, slot, frame_type, dtype_vseq, stream_id, data) + return + bridge_key = str(int_id(dst_id)) + bridges = self._router.get_bridges() + dst_int = int_id(dst_id) + systems_cfg = self._config.get("SYSTEMS", {}) + source_is_obp = systems_cfg.get(system_name, {}).get("MODE") == "OPENBRIDGE" + if bridge_key not in bridges: + if dst_int < 5 or dst_int == 9 or dst_int == 4000 or dst_int == 5000: + logger.debug( + "(ROUTER) No bridge for TG %s (excluded TGID), not creating", + dst_int, + ) + elif source_is_obp and self._config.get("GLOBAL", {}).get("GEN_STAT_BRIDGES"): + logger.debug("(%s) Bridge for STAT TG %s does not exist. Creating", system_name, dst_int) + self.make_stat_bridge(dst_id) + self.apply_static_tg_to_bridge(dst_int) + bridges = self._router.get_bridges() + else: + tmout = self._config.get("SYSTEMS", {}).get(system_name, {}).get("DEFAULT_UA_TIMER", 10) + logger.info( + "(%s) Bridge for TG %s does not exist. Creating as User Activated. Timeout %s", + system_name, dst_int, tmout, + ) + self.make_single_bridge(dst_id, system_name, slot, float(tmout)) + self.apply_static_tg_to_bridge(dst_int) + bridges = self._router.get_bridges() + # Legacy bridge_master 3198–3218: forward to current bridge (tgid) and reflector bridge (#tgid) + entries = list(bridges.get(bridge_key, [])) + list(bridges.get("#" + bridge_key, [])) + # Legacy: only forward if source rule matches (system, TGID, slot, ACTIVE True) — bridge.py 349, 664 + dst_id_b = dst_id if isinstance(dst_id, bytes) and len(dst_id) >= 3 else bytes_3(dst_int) + has_source = any( + entry.get("SYSTEM") == system_name + and entry.get("TS") == slot + and entry.get("ACTIVE") + and (entry.get("TGID") == dst_id_b or int_id(entry.get("TGID") or b"\x00\x00\x00") == dst_int) + for entry in entries + ) + if not has_source and systems_cfg.get(system_name, {}).get("MODE") == "MASTER": + self.options_config_for_system(system_name) + bridges = self._router.get_bridges() + entries = list(bridges.get(bridge_key, [])) + list(bridges.get("#" + bridge_key, [])) + has_source = any( + entry.get("SYSTEM") == system_name + and entry.get("TS") == slot + and entry.get("ACTIVE") + and (entry.get("TGID") == dst_id_b or int_id(entry.get("TGID") or b"\x00\x00\x00") == dst_int) + for entry in entries + ) + if not has_source: + logger.debug( + "(ROUTER) No matching source rule for TG %s from %s slot %s (ACTIVE), not forwarding", + bridge_key, system_name, slot, + ) + return + pkt_time = time.time() + # Legacy bridge_master/bridge.py: Loop Control when source is OBP — ignore if we are echo (first HBP or first OBP wins) + if source_is_obp: + protocols = self._get_protocols() if self._get_protocols else {} + src_proto = protocols.get(system_name) if protocols else None + obp_streams = getattr(src_proto, "_obp_streams", None) if src_proto else None + if obp_streams is not None and stream_id in obp_streams: + if obp_streams[stream_id].get("_fin"): + if not obp_streams[stream_id].get("_finlog"): + logger.debug( + "(%s) OBP *LoopControl* STREAM ID: %s ALREADY FINISHED FROM THIS SOURCE, IGNORING", + system_name, int_id(stream_id), + ) + obp_streams[stream_id]["_finlog"] = True + return + # First HBP: if any MASTER/PEER already has this stream_id on RX, we are the echo — drop (log once per stream, legacy LOOPLOG) + for other_name, proto in (protocols or {}).items(): + if other_name == system_name: + continue + if systems_cfg.get(other_name, {}).get("MODE") == "OPENBRIDGE": + continue + status = getattr(proto, "STATUS", None) + if not status: + continue + for _slot in (1, 2): + if _slot in status and (status[_slot].get("RX_STREAM_ID") or b"") == stream_id: + if obp_streams is not None and stream_id in obp_streams and not obp_streams[stream_id].get("LOOPLOG"): + logger.debug( + "(%s) OBP *LoopControl* FIRST HBP: %s, STREAM ID: %s, TG: %s, TS: %s, IGNORE THIS SOURCE", + system_name, other_name, int_id(stream_id), int_id(dst_id), _slot, + ) + obp_streams[stream_id]["LOOPLOG"] = True + return + # First OBP: only the OBP that received the stream first (min 1ST) forwards; others drop + hr_times = {} + if obp_streams is not None and stream_id in obp_streams and "1ST" in obp_streams[stream_id]: + hr_times[system_name] = obp_streams[stream_id]["1ST"] + dst_id_b = dst_id if isinstance(dst_id, bytes) and len(dst_id) >= 3 else bytes_3(int_id(dst_id)) + for other_name, proto in (protocols or {}).items(): + if other_name == system_name: + continue + if systems_cfg.get(other_name, {}).get("MODE") != "OPENBRIDGE": + continue + other_streams = getattr(proto, "_obp_streams", None) + if other_streams is None or stream_id not in other_streams: + continue + st = other_streams[stream_id] + if "1ST" not in st or (st.get("TGID") or b"") != dst_id_b: + continue + hr_times[other_name] = st["1ST"] + if hr_times: + first_system = min(hr_times, key=hr_times.get) + if first_system != system_name: + if obp_streams is not None and stream_id in obp_streams and not obp_streams[stream_id].get("LOOPLOG"): + logger.debug( + "(%s) OBP *LoopControl* FIRST OBP %s, STREAM ID: %s, TG %s, IGNORE THIS SOURCE", + system_name, first_system, int_id(stream_id), int_id(dst_id), + ) + obp_streams[stream_id]["LOOPLOG"] = True + if self._send_bcsq and systems_cfg.get(system_name, {}).get("ENHANCED_OBP"): + if obp_streams is not None and stream_id in obp_streams and "_bcsq" not in obp_streams[stream_id]: + self._send_bcsq(system_name, dst_id, stream_id) + obp_streams[stream_id]["_bcsq"] = True + return + # Legacy bridge.py: BRDG_EVENT for report/monitor (GROUP VOICE START/END RX/TX) + if self._report_factory and hasattr(self._report_factory, "send_bridge_event"): + try: + if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VHEAD: + self._report_factory.send_bridge_event( + "GROUP VOICE,START,RX,{},{},{},{},{},{}".format( + system_name, int_id(stream_id), int_id(peer_id), int_id(rf_src), slot, int_id(dst_id) + ) + ) + elif frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VTERM: + duration = 0.0 + protocols = self._get_protocols() if self._get_protocols else {} + src_proto = protocols.get(system_name) if protocols else None + if source_is_obp and src_proto and getattr(src_proto, "_obp_streams", None) and stream_id in src_proto._obp_streams: + start = src_proto._obp_streams[stream_id].get("START") + if start is not None: + duration = pkt_time - start + elif src_proto and getattr(src_proto, "STATUS", None): + st = src_proto.STATUS + start = st.get(stream_id, {}).get("START") or st.get(slot, {}).get("RX_START") + if start is not None: + duration = pkt_time - start + self._report_factory.send_bridge_event( + "GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}".format( + system_name, int_id(stream_id), int_id(peer_id), int_id(rf_src), slot, int_id(dst_id), duration + ) + ) + except Exception: + pass + # ── Exact port of legacy bridge.py routerOBP/routerHBP forwarding to targets ── + pkt_time = time.time() + dmrpkt = data[20:53] if len(data) >= 53 else b"" + _bits = data[15] if len(data) > 15 else 0 + protocols = self._get_protocols() if self._get_protocols else {} + src_proto = protocols.get(system_name) if protocols else None + # Legacy routerHBP: _ber = _data[53:54]; _rssi = _data[54:55] + # Legacy routerOBP: _ber = b'\x00'; _rssi = b'\x00' (from dmrd_received defaults) + if source_is_obp: + _ber = b"\x00" + _rssi = b"\x00" + _hops = b"" + _source_server = b"\x00\x00\x00\x00" + _source_rptr = b"\x00\x00\x00\x00" + else: + _ber = data[53:54] if len(data) > 53 else b"\x00" + _rssi = data[54:55] if len(data) > 54 else b"\x00" + _hops = b"" + _sid = self._config.get("GLOBAL", {}).get("SERVER_ID") + if isinstance(_sid, bytes) and len(_sid) >= 4: + _source_server = _sid + elif _sid is not None: + _source_server = bytes_4(int(_sid) & 0xFFFFFFFF) + else: + _source_server = b"\x00\x00\x00\x00" + _source_rptr = peer_id + # Legacy: source LC — routerOBP uses self.STATUS[_stream_id]['LC'], routerHBP uses self.STATUS[_slot]['RX_LC'] + source_lc = None + if src_proto and getattr(src_proto, "STATUS", None): + st = src_proto.STATUS + if source_is_obp: + source_lc = st.get(stream_id, {}).get("LC") + else: + source_lc = st.get(slot, {}).get("RX_LC") + if not source_lc or len(source_lc) < 9: + source_lc = b"\x00\x00\x20" + dst_id_b + rf_src + forwarded = [] + for entry in entries: + if entry.get("SYSTEM") == system_name: + continue + if not entry.get("ACTIVE", False): + continue + if not systems_cfg.get(entry["SYSTEM"], {}).get("ENABLED", True): + continue + _target_system = systems_cfg.get(entry["SYSTEM"], {}) + target_mode = _target_system.get("MODE") + tgt_proto = protocols.get(entry["SYSTEM"]) if protocols else None + _target_status = getattr(tgt_proto, "STATUS", None) if tgt_proto else None + + if target_mode == "OPENBRIDGE": + # ── Exact port of legacy bridge.py OBP target (lines 355-401 / 672-718) ── + target_tgid = entry.get("TGID") + if isinstance(target_tgid, int): + target_tgid = bytes_3(target_tgid) + if _target_status is not None: + if stream_id not in _target_status: + _target_status[stream_id] = { + "START": pkt_time, + "CONTENTION": False, + "RFS": rf_src, + "TGID": dst_id_b, + } + dst_lc = source_lc[0:3] + target_tgid + rf_src + _target_status[stream_id]["H_LC"] = bptc.encode_header_lc(dst_lc) + _target_status[stream_id]["T_LC"] = bptc.encode_terminator_lc(dst_lc) + _target_status[stream_id]["EMB_LC"] = bptc.encode_emblc(dst_lc) + logger.info( + "(%s) Conference Bridge: %s, Call Bridged to OBP System: %s TS: %s, TGID: %s", + system_name, bridge_key, entry["SYSTEM"], entry.get("TS", 1), int_id(target_tgid), + ) + if self._report_factory and hasattr(self._report_factory, "send_bridge_event"): + try: + self._report_factory.send_bridge_event( + "GROUP VOICE,START,TX,{},{},{},{},{},{}".format( + entry["SYSTEM"], int_id(stream_id), int_id(peer_id), int_id(rf_src), entry.get("TS", 1), int_id(target_tgid) + ) + ) + except Exception: + pass + _target_status[stream_id]["LAST"] = pkt_time + _tmp_bits = _bits & ~(1 << 7) + _tmp_data = b"".join([data[:8], target_tgid, data[11:15], bytes([_tmp_bits]), data[16:20]]) + dmrbits = bitarray(endian="big") + dmrbits.frombytes(dmrpkt) + if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VHEAD: + dmrbits = _target_status[stream_id]["H_LC"][0:98] + dmrbits[98:166] + _target_status[stream_id]["H_LC"][98:197] + elif frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VTERM: + dmrbits = _target_status[stream_id]["T_LC"][0:98] + dmrbits[98:166] + _target_status[stream_id]["T_LC"][98:197] + if self._report_factory and hasattr(self._report_factory, "send_bridge_event"): + try: + call_duration = pkt_time - _target_status[stream_id].get("START", pkt_time) + self._report_factory.send_bridge_event( + "GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}".format( + entry["SYSTEM"], int_id(stream_id), int_id(peer_id), int_id(rf_src), entry.get("TS", 1), int_id(target_tgid), call_duration + ) + ) + except Exception: + pass + elif dtype_vseq in (1, 2, 3, 4): + dmrbits = dmrbits[0:116] + _target_status[stream_id]["EMB_LC"][dtype_vseq] + dmrbits[148:264] + dmrpkt_out = dmrbits.tobytes() + if source_is_obp: + _tmp_data = b"".join([_tmp_data, dmrpkt_out]) + else: + _tmp_data = b"".join([_tmp_data, dmrpkt_out]) + else: + _tmp_bits = _bits & ~(1 << 7) + _tmp_data = b"".join([data[:8], target_tgid, data[11:15], bytes([_tmp_bits]), data[16:20]]) + _tmp_data = b"".join([_tmp_data, dmrpkt]) + try: + self._send_to_system( + entry["SYSTEM"], _tmp_data, + _hops=_hops, _ber=_ber, _rssi=_rssi, + _source_server=_source_server, _source_rptr=_source_rptr, + ) + forwarded.append(entry["SYSTEM"]) + except Exception as e: + logger.warning("(ROUTER) send_to_system %s failed: %s", entry.get("SYSTEM"), e) + + else: + # ── Exact port of legacy bridge.py HBP target (lines 403-486 / 720-803) ── + entry_ts = entry.get("TS") + if entry_ts is None: + continue + entry_tgid_b = entry.get("TGID") + if isinstance(entry_tgid_b, int): + entry_tgid_b = bytes_3(entry_tgid_b) + if _target_status is None or entry_ts not in _target_status: + continue + _ts_st = _target_status[entry_ts] + # Contention handling (exact port of legacy bridge.py 413-432 / 730-745) + if (entry_tgid_b != _ts_st.get("RX_TGID", b"\x00\x00\x00")) and ((pkt_time - _ts_st.get("RX_TIME", 0)) < float(_target_system.get("GROUP_HANGTIME", 0))): + continue + if (entry_tgid_b != _ts_st.get("TX_TGID", b"\x00\x00\x00")) and ((pkt_time - _ts_st.get("TX_TIME", 0)) < float(_target_system.get("GROUP_HANGTIME", 0))): + continue + if (entry_tgid_b == _ts_st.get("RX_TGID", b"\x00\x00\x00")) and ((pkt_time - _ts_st.get("RX_TIME", 0)) < STREAM_TO): + continue + if (entry_tgid_b == _ts_st.get("TX_TGID", b"\x00\x00\x00")) and (rf_src != _ts_st.get("TX_RFS", b"")) and ((pkt_time - _ts_st.get("TX_TIME", 0)) < STREAM_TO): + continue + # New stream detection — legacy OBP uses _target_status[TS]['TX_STREAM_ID'], legacy HBP uses self.STATUS[_slot]['RX_STREAM_ID'] + if source_is_obp: + _is_new_stream = (_ts_st.get("TX_STREAM_ID") != stream_id) + else: + src_status = getattr(src_proto, "STATUS", None) if src_proto else None + _is_new_stream = (src_status.get(slot, {}).get("RX_STREAM_ID") if src_status else b"") != stream_id + if _is_new_stream: + _ts_st["TX_START"] = pkt_time + _ts_st["TX_TGID"] = entry_tgid_b + _ts_st["TX_STREAM_ID"] = stream_id + _ts_st["TX_RFS"] = rf_src + _ts_st["TX_PEER"] = peer_id + dst_lc = source_lc[0:3] + entry_tgid_b + rf_src + _ts_st["TX_H_LC"] = bptc.encode_header_lc(dst_lc) + _ts_st["TX_T_LC"] = bptc.encode_terminator_lc(dst_lc) + _ts_st["TX_EMB_LC"] = bptc.encode_emblc(dst_lc) + logger.info( + "(%s) Conference Bridge: %s, Call Bridged to HBP System: %s TS: %s, TGID: %s", + system_name, bridge_key, entry["SYSTEM"], entry_ts, int_id(entry_tgid_b), + ) + if self._report_factory and hasattr(self._report_factory, "send_bridge_event"): + try: + self._report_factory.send_bridge_event( + "GROUP VOICE,START,TX,{},{},{},{},{},{}".format( + entry["SYSTEM"], int_id(stream_id), int_id(peer_id), int_id(rf_src), entry_ts, int_id(entry_tgid_b) + ) + ) + except Exception: + pass + _ts_st["TX_TIME"] = pkt_time + _ts_st["TX_TYPE"] = dtype_vseq + # Slot bit rewrite (legacy bridge.py 457-460 / 770-773) + _src_entry_ts = slot + if _src_entry_ts != entry_ts: + _tmp_bits = _bits ^ (1 << 7) + else: + _tmp_bits = _bits + _tmp_data = b"".join([data[:8], entry_tgid_b, data[11:15], bytes([_tmp_bits]), data[16:20]]) + # LC rewrite (exact port of legacy bridge.py 468-482 / 781-799) + dmrbits = bitarray(endian="big") + dmrbits.frombytes(dmrpkt) + if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VHEAD: + dmrbits = _ts_st["TX_H_LC"][0:98] + dmrbits[98:166] + _ts_st["TX_H_LC"][98:197] + elif frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VTERM: + dmrbits = _ts_st["TX_T_LC"][0:98] + dmrbits[98:166] + _ts_st["TX_T_LC"][98:197] + if self._report_factory and hasattr(self._report_factory, "send_bridge_event"): + try: + call_duration = pkt_time - _ts_st.get("TX_START", pkt_time) + self._report_factory.send_bridge_event( + "GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}".format( + entry["SYSTEM"], int_id(stream_id), int_id(peer_id), int_id(rf_src), entry_ts, int_id(entry_tgid_b), call_duration + ) + ) + except Exception: + pass + elif dtype_vseq in (1, 2, 3, 4): + try: + dmrbits = dmrbits[0:116] + _ts_st["TX_EMB_LC"][dtype_vseq] + dmrbits[148:264] + except Exception: + pass + dmrpkt_out = dmrbits.tobytes() + # Legacy OBP source: _tmp_data + dmrpkt + b'\x00\x00' (bridge.py 483) + # Legacy HBP source: _tmp_data + dmrpkt + _data[53:55] (bridge.py 800) + if source_is_obp: + _tmp_data = b"".join([_tmp_data, dmrpkt_out, b"\x00\x00"]) + else: + _tmp_data = b"".join([_tmp_data, dmrpkt_out, data[53:55]]) + try: + self._send_to_system( + entry["SYSTEM"], _tmp_data, + _hops=_hops, _ber=_ber, _rssi=_rssi, + _source_server=_source_server, _source_rptr=_source_rptr, + ) + forwarded.append(entry["SYSTEM"]) + except Exception as e: + logger.warning("(ROUTER) send_to_system %s failed: %s", entry.get("SYSTEM"), e) + if forwarded: + if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VHEAD: + logger.info( + "(ROUTER) Bridged TG %s from %s -> %s", + bridge_key, system_name, ", ".join(forwarded), + ) + else: + logger.debug( + "(ROUTER) No ACTIVE targets for TG %s from %s (bridge has %s entries)", + bridge_key, system_name, len(bridges.get(bridge_key, [])), + ) + + def _pvt_call_received( + self, + system_name: str, + peer_id: bytes, + rf_src: bytes, + dst_id: bytes, + seq: int, + slot: int, + frame_type: int, + dtype_vseq: int, + stream_id: bytes, + data: bytes, + ) -> None: + """Legacy pvt_call_received: route private (unit) calls via SUB_MAP lookup.""" + pkt_time = time.time() + dmrpkt = data[20:53] if len(data) >= 53 else b"" + _bits = data[15] if len(data) > 15 else 0 + sub_map = self._config.get("_SUB_MAP", {}) + systems_cfg = self._config.get("SYSTEMS", {}) + protocols = self._get_protocols() if self._get_protocols else {} + source_proto = protocols.get(system_name) + source_status = getattr(source_proto, "STATUS", {}) if source_proto else {} + slot_st = source_status.get(slot, {}) + if stream_id != slot_st.get("RX_STREAM_ID"): + if (slot_st.get("RX_TYPE") != HBPF_SLT_VTERM) and (pkt_time < (slot_st.get("RX_TIME", 0) + STREAM_TO)) and (rf_src != slot_st.get("RX_RFS")): + logger.warning( + "(%s) PRIVATE CALL Packet received with STREAM ID: %s SUB: %s PEER: %s UNIT %s, SLOT %s collided with existing call", + system_name, int_id(stream_id), int_id(rf_src), int_id(peer_id), int_id(dst_id), slot, + ) + return + if dst_id in sub_map: + if sub_map[dst_id][0] != system_name: + self._pvt_targets = [sub_map[dst_id][0]] + else: + self._pvt_targets = [] + logger.error("PRIVATE call to a subscriber on the same system, send nothing") + else: + self._pvt_targets = [] + logger.info( + "(%s) *PRIVATE CALL START* STREAM ID: %s SUB: %s PEER: %s DST: %s, TS: %s, FORWARD: %s", + system_name, int_id(stream_id), int_id(rf_src), int_id(peer_id), int_id(dst_id), slot, self._pvt_targets, + ) + report = self._report_factory + if report and hasattr(report, "send_bridge_event"): + report.send_bridge_event( + "PRIVATE VOICE,START,RX,{},{},{},{},{},{}".format(system_name, int_id(stream_id), int_id(peer_id), int_id(rf_src), slot, int_id(dst_id)) + ) + for _target in getattr(self, "_pvt_targets", []): + target_proto = protocols.get(_target) + if not target_proto: + continue + _target_status = getattr(target_proto, "STATUS", {}) + _target_system = systems_cfg.get(_target, {}) + if _target_system.get("MODE") == "OPENBRIDGE": + if stream_id not in _target_status: + _target_status[stream_id] = { + "START": pkt_time, + "CONTENTION": False, + "RFS": rf_src, + "TYPE": "UNIT", + "DST": dst_id, + "ACTIVE": True, + } + logger.info( + "(%s) PRIVATE call bridged to OBP System: %s TS: %s, UNIT: %s", + system_name, _target, slot if _target_system.get("BOTH_SLOTS") else 1, int_id(dst_id), + ) + _target_status[stream_id]["LAST"] = pkt_time + if _target_system.get("BOTH_SLOTS"): + _tmp_bits = _bits + else: + _tmp_bits = _bits & ~(1 << 7) + _tmp_data = b"".join([data[:15], _tmp_bits.to_bytes(1, "big"), data[16:20]]) + send_data = b"".join([_tmp_data, dmrpkt]) + if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VTERM: + _target_status[stream_id]["ACTIVE"] = False + else: + ts_st = _target_status.get(slot, {}) + if (dst_id == ts_st.get("RX_TGID")) and ((pkt_time - ts_st.get("RX_TIME", 0)) < STREAM_TO): + if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VHEAD and slot_st.get("RX_STREAM_ID") != stream_id: + logger.info( + "(%s) PRIVATE Call not routed to destination %s, matching call already active on target: HBSystem: %s, TS: %s, DEST: %s", + system_name, int_id(dst_id), _target, slot, int_id(ts_st.get("RX_TGID", b"")), + ) + continue + if (dst_id == ts_st.get("TX_TGID")) and (rf_src != ts_st.get("TX_RFS")) and ((pkt_time - ts_st.get("TX_TIME", 0)) < STREAM_TO): + if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VHEAD and slot_st.get("RX_STREAM_ID") != stream_id: + logger.info( + "(%s) PRIVATE Call not routed for subscriber %s, call route in progress on target: HBSystem: %s, TS: %s, DEST: %s, SUB: %s", + system_name, int_id(rf_src), _target, slot, int_id(ts_st.get("TX_TGID", b"")), int_id(ts_st.get("TX_RFS", b"")), + ) + continue + if stream_id != slot_st.get("RX_STREAM_ID"): + ts_st["TX_START"] = pkt_time + ts_st["TX_TGID"] = dst_id + ts_st["TX_STREAM_ID"] = stream_id + ts_st["TX_RFS"] = rf_src + ts_st["TX_PEER"] = peer_id + logger.info("(%s) PRIVATE call bridged to HBP System: %s TS: %s, DST: %s", system_name, _target, slot, int_id(dst_id)) + ts_st["TX_TIME"] = pkt_time + ts_st["TX_TYPE"] = dtype_vseq + send_data = data + try: + self._send_to_system(_target, send_data) + except Exception as e: + logger.warning("(ROUTER) send_to_system %s failed: %s", _target, e) + if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VTERM and slot_st.get("RX_TYPE") != HBPF_SLT_VTERM: + self._pvt_targets = [] + call_duration = pkt_time - slot_st.get("RX_START", pkt_time) + logger.info( + "(%s) *PRIVATE CALL END* STREAM ID: %s SUB: %s PEER: %s DST: %s, TS %s, Duration: %.2f", + system_name, int_id(stream_id), int_id(rf_src), int_id(peer_id), int_id(dst_id), slot, call_duration, + ) + slot_st["RX_PEER"] = peer_id + slot_st["RX_SEQ"] = seq + slot_st["RX_RFS"] = rf_src + slot_st["RX_TYPE"] = dtype_vseq + slot_st["RX_TGID"] = dst_id + slot_st["RX_TIME"] = pkt_time + slot_st["RX_STREAM_ID"] = stream_id diff --git a/src/adn_server/application/ident_use_cases.py b/src/adn_server/application/ident_use_cases.py new file mode 100644 index 0000000..cfc020f --- /dev/null +++ b/src/adn_server/application/ident_use_cases.py @@ -0,0 +1,139 @@ +# ADN DMR Peer Server - voice ident use cases +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink (threadIdent, ident). GPLv3. + +"""Voice ident: periodic ident on MASTER systems (VOICE_IDENT, slot 2 idle 30s).""" + +from __future__ import annotations + +import logging +import re +import time +from typing import Any, Callable + +from ..domain import bytes_3, int_id +from ..infrastructure.hbp_constants import HBPF_SLT_VTERM + +logger = logging.getLogger(__name__) + + +def _alias_tg(dst_id: bytes, config: dict[str, Any]) -> str: + """Resolve TGID to alias for logging; fallback to numeric.""" + tg_ids = config.get("_TG_IDS", {}) + idx = int_id(dst_id) + return tg_ids.get(idx, str(idx)) + + +class IdentUseCases: + """Run voice ident for MASTER systems with VOICE_IDENT (legacy threadIdent/ident).""" + + def __init__( + self, + config: dict[str, Any], + voice_use_cases: Any, + audio_path: str, + get_protocols: Callable[[], dict[str, Any]], + call_from_reactor: Callable[..., None], + ) -> None: + self._config = config + self._voice = voice_use_cases + self._audio_path = audio_path + self._get_protocols = get_protocols + self._call_from_reactor = call_from_reactor + + def run_ident(self) -> None: + """Run ident once (legacy ident()). Call from thread; uses call_from_reactor to send packets.""" + systems_cfg = self._config.get("SYSTEMS", {}) + protocols = self._get_protocols() + ann_lang = (self._config.get("VOICE", {}).get("ANNOUNCEMENT_LANGUAGES") or "").strip() + if not ann_lang: + return + words_by_lang = self._voice.get_ambe_words(ann_lang, self._audio_path) + if not words_by_lang: + return + + for system in list(systems_cfg.keys()): + sys_cfg = systems_cfg.get(system, {}) + if sys_cfg.get("MODE") != "MASTER": + continue + if not sys_cfg.get("VOICE_IDENT"): + continue + _lang = sys_cfg.get("ANNOUNCEMENT_LANGUAGE", "en_GB") + if _lang not in words_by_lang: + continue + words = words_by_lang[_lang] + max_peers = int(sys_cfg.get("MAX_PEERS", 1)) + if max_peers > 1: + logger.debug("(IDENT) %s System has MAX_PEERS > 1, skipping", system) + continue + _callsign = None + peers = sys_cfg.get("PEERS", {}) + for _peerid in peers: + peer_cfg = peers.get(_peerid, {}) + if isinstance(peer_cfg, dict) and peer_cfg.get("CALLSIGN"): + cs = peer_cfg["CALLSIGN"] + _callsign = cs.decode("utf-8", errors="replace") if isinstance(cs, bytes) else cs + break + if not _callsign: + logger.debug("(IDENT) %s System has no peers or no recorded callsign, skipping", system) + continue + + protocol = protocols.get(system) + if not protocol or not getattr(protocol, "STATUS", None): + continue + _slot = protocol.STATUS.get(2) + if not _slot: + continue + rx_type = _slot.get("RX_TYPE") + tx_type = _slot.get("TX_TYPE") + tx_time = _slot.get("TX_TIME", 0) + rx_time = _slot.get("RX_TIME", 0) + now = time.time() + if (rx_type != HBPF_SLT_VTERM or tx_type != HBPF_SLT_VTERM or + now - tx_time <= 30 or now - rx_time <= 30): + continue + + _all_call = bytes_3(16777215) + _source_id = bytes_3(5000) + _dst_id = b"" + override_tg = sys_cfg.get("OVERRIDE_IDENT_TG") + if override_tg is not None and int(override_tg) > 0 and int(override_tg) < 16777215: + _dst_id = bytes_3(int(override_tg)) + else: + _dst_id = _all_call + + logger.info( + "(%s) %s System idle. Sending voice ident to TG %s", + system, _callsign, _alias_tg(_dst_id, self._config), + ) + + silence = words.get("silence") + if not silence: + continue + _say = [silence, silence, silence, words.get("this-is") or silence] + for _ in range(6): + _say.append(silence) + _systemcs = re.sub(r"\W+", "", _callsign).upper() + for character in _systemcs: + _say.append(words.get(character) or silence) + _say.append(silence) + for _ in range(5): + _say.append(silence) + _say.append(words.get("adn") or silence) + + server_id = self._config.get("GLOBAL", {}).get("SERVER_ID", b"\x00\x00\x00\x00") + if not isinstance(server_id, bytes): + server_id = bytes_3(int(server_id)) + speech = self._voice.pkt_gen(_source_id, _dst_id, server_id, 1, _say) + + time.sleep(1) + _slot = protocol.STATUS.get(2) + if not _slot: + continue + _next_time = time.time() + for pkt in speech: + _next_time += 0.058 + _delay = _next_time - time.time() + if _delay > 0.001: + time.sleep(_delay) + self._call_from_reactor(protocol.send_voice_packet, pkt, _source_id, _dst_id, _slot) diff --git a/src/adn_server/application/ports.py b/src/adn_server/application/ports.py new file mode 100644 index 0000000..70c1dc5 --- /dev/null +++ b/src/adn_server/application/ports.py @@ -0,0 +1,143 @@ +# ADN DMR Peer Server - application ports +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Abstract ports (interfaces) for infrastructure adapters.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class ConfigLoader(ABC): + """Load and validate config (YAML at project root). Returns same semantic structure as legacy INI.""" + + @abstractmethod + def load(self, path: str | None = None) -> dict[str, Any]: + """Load config; path None = default (project root adn-server.yaml).""" + ... + + @abstractmethod + def reload_voice_config(self, config: dict[str, Any]) -> None: + """Reload voice/announcement config from main config file (optional second arg: path).""" + ... + + +class AliasLoader(ABC): + """Load and refresh alias dicts (peer_ids, subscriber_ids, talkgroup_ids, server_ids, checksums).""" + + @abstractmethod + def load_aliases(self, config: dict[str, Any]) -> tuple[ + dict[int, str], # peer_ids + dict[int, str], # subscriber_ids + dict[int, str], # talkgroup_ids + dict[int, str], # local_subscriber_ids + dict[str, str], # server_ids + dict[str, str], # checksums + ]: + """Build alias dicts from config (downloads + files). Same as legacy mk_aliases.""" + ... + + +class SubMapStore(ABC): + """Persist and load SUB_MAP (bytes_3(peer) -> (callsign, slot, time)).""" + + @abstractmethod + def load(self, path: str) -> dict[bytes, tuple[str, int, float]]: + """Load SUB_MAP from pickle file.""" + ... + + @abstractmethod + def save(self, path: str, sub_map: dict[bytes, tuple[str, int, float]]) -> None: + """Save SUB_MAP to pickle file.""" + ... + + +class KeysStore(ABC): + """Load/save keys JSON (e.g. system API key).""" + + @abstractmethod + def load(self, path: str) -> dict[str, Any]: + """Load keys from JSON.""" + ... + + @abstractmethod + def save(self, path: str, keys: dict[str, Any]) -> None: + """Save keys to JSON.""" + ... + + +class ReportSender(ABC): + """Send config and bridge state to report TCP clients (CONFIG_SND, BRIDGE_SND, BRDG_EVENT).""" + + @abstractmethod + def send_config(self, systems: dict[str, Any]) -> None: + """Send CONFIG_SND (pickle systems).""" + ... + + @abstractmethod + def send_bridge(self, bridges: dict[str, Any]) -> None: + """Send BRIDGE_SND (pickle bridges).""" + ... + + @abstractmethod + def send_bridge_event(self, event: str) -> None: + """Send BRDG_EVENT (opcode + event string).""" + ... + + +class BridgeRouter(ABC): + """Query and update BRIDGES (conference bridge state). Used by rule_timer, make_single_bridge, etc.""" + + @abstractmethod + def get_bridges(self) -> dict[str, list[dict[str, Any]]]: + """Return current BRIDGES dict (key = TGID or #reflector).""" + ... + + @abstractmethod + def set_bridges(self, bridges: dict[str, list[dict[str, Any]]]) -> None: + """Replace BRIDGES (e.g. after rule_timer or make_single_bridge).""" + ... + + @abstractmethod + def acl_check(self, id_bytes_or_int: bytes | int, acl: tuple[bool, list[tuple[int, int]]]) -> bool: + """Check ID against ACL; return True if permitted.""" + ... + + +class VoiceProvider(ABC): + """AMBE voice packets, file announcements, TTS. Used for announcements and playback.""" + + @abstractmethod + def get_ambe_words(self, languages: str, audio_path: str) -> dict[str, dict[str, Any]]: + """Load AMBE words by language (readAMBE.readfiles).""" + ... + + @abstractmethod + def pkt_gen(self, rf_src: bytes, dst_id: bytes, peer: bytes, slot: int, phrase: list[Any]) -> Any: + """Generate HBP voice packets for a phrase (generator). Legacy mk_voice.pkt_gen.""" + ... + + @abstractmethod + def ensure_tts_ambe(self, text: str, lang: str, out_path: str, config: dict[str, Any]) -> str | None: + """TTS to AMBE file; return path or None. Legacy tts_engine.ensure_tts_ambe.""" + ... + + def read_single_file(self, audio_path: str, lang: str, file_number: str) -> list: + """Read one AMBE file (e.g. ondemand/{file_number}.ambe). Legacy readSingleFile for playFileOnRequest.""" + return [] + + +class SecurityDownloader(ABC): + """Periodic security downloads (passwords, encryption). Legacy security_downloader.""" + + @abstractmethod + def init_downloads(self, config: dict[str, Any]) -> None: + """One-time init (e.g. create dirs).""" + ... + + @abstractmethod + def periodic_download(self, config: dict[str, Any]) -> None: + """Periodic password/encryption download.""" + ... diff --git a/src/adn_server/application/reporting_use_cases.py b/src/adn_server/application/reporting_use_cases.py new file mode 100644 index 0000000..c440dcf --- /dev/null +++ b/src/adn_server/application/reporting_use_cases.py @@ -0,0 +1,50 @@ +# ADN DMR Peer Server - reporting use cases +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Reporting: send config/bridge to TCP clients, KA reporting. Orchestrates ReportSender.""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from .ports import ReportSender + +logger = logging.getLogger(__name__) + + +class ReportingUseCases: + """Use cases for TCP report server and keepalive reporting.""" + + def __init__(self, report_sender: ReportSender, config: dict[str, Any]) -> None: + self._sender = report_sender + self._config = config + + def send_config(self, systems: dict[str, Any]) -> None: + """Send CONFIG_SND to all report clients.""" + self._sender.send_config(systems) + + def send_bridge(self, bridges: dict[str, Any]) -> None: + """Send BRIDGE_SND to all report clients.""" + self._sender.send_bridge(bridges) + + def send_bridge_event(self, event: str) -> None: + """Send BRDG_EVENT to all report clients.""" + self._sender.send_bridge_event(event) + + def ka_reporting_loop(self) -> None: + """Legacy kaReporting (60s): check OBP keepalive status and log warnings for stale connections.""" + logger.debug("(ROUTER) KeepAlive reporting loop started") + systems_cfg = self._config.get("SYSTEMS", {}) + now = time.time() + for system_name, sys_cfg in systems_cfg.items(): + if sys_cfg.get("MODE") == "OPENBRIDGE" and sys_cfg.get("ENHANCED_OBP"): + if "_bcka" not in sys_cfg: + logger.warning("(ROUTER) not sending to system %s as KeepAlive never seen", system_name) + elif sys_cfg["_bcka"] < now - 60: + logger.warning( + "(ROUTER) not sending to system %s as last KeepAlive was %s seconds ago", + system_name, int(now - sys_cfg["_bcka"]), + ) diff --git a/src/adn_server/application/voice_use_cases.py b/src/adn_server/application/voice_use_cases.py new file mode 100644 index 0000000..601b8f0 --- /dev/null +++ b/src/adn_server/application/voice_use_cases.py @@ -0,0 +1,613 @@ +# ADN DMR Peer Server - voice use cases +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Voice/AMBE/TTS: scheduled announcements, TTS announcements, playback. Orchestrates VoiceProvider.""" + +from __future__ import annotations + +import logging +import os +import time +from datetime import datetime +from typing import Any, Callable + +from ..domain import bytes_3 +from ..infrastructure.hbp_constants import HBPF_SLT_VTERM +from ..infrastructure.voice.tts_engine import ensure_tts_ambe as tts_ensure_tts_ambe +from .ports import VoiceProvider + +logger = logging.getLogger(__name__) + +_FRAME_INTERVAL = 0.058 +_ANNOUNCEMENT_EXCLUDED = ("ECHO", "D-APRS") +_BROADCAST_GAP = 1.5 + + +class VoiceUseCases: + """Use cases for voice announcements and TTS.""" + + def __init__( + self, + voice_provider: VoiceProvider, + config: dict[str, Any], + get_protocols: Callable[[], dict[str, Any]] | None = None, + call_from_reactor: Callable[..., None] | None = None, + audio_path: str | None = None, + get_bridges: Callable[[], dict[str, list[dict[str, Any]]]] | None = None, + call_later: Callable[..., Any] | None = None, + start_looping_call: Callable[[Callable[[], None], float, bool], Any] | None = None, + defer_to_thread: Callable[..., Any] | None = None, + ) -> None: + self._voice = voice_provider + self._config = config + self._get_protocols = get_protocols + self._call_from_reactor = call_from_reactor + self._audio_path = audio_path or "" + self._get_bridges = get_bridges + self._call_later = call_later + self._start_looping_call = start_looping_call + self._defer_to_thread = defer_to_thread + self._ann_tasks: dict[int, Any] = {} + self._tts_tasks: dict[int, Any] = {} + self._announcement_running: dict[int, bool] = {1: False, 2: False, 3: False, 4: False} + self._tts_running: dict[int, bool] = {1: False, 2: False, 3: False, 4: False} + self._announcement_last_hour: dict[int, int] = {1: -1, 2: -1, 3: -1, 4: -1} + self._tts_last_hour: dict[int, int] = {1: -1, 2: -1, 3: -1, 4: -1} + self._config_file_mtime: float = 0.0 + self._broadcast_queue: list[dict[str, Any]] = [] + self._broadcast_active: bool = False + + def get_ambe_words(self, languages: str, audio_path: str) -> dict[str, dict[str, Any]]: + """Load AMBE words for given languages (legacy readAMBE.readfiles).""" + return self._voice.get_ambe_words(languages, audio_path) + + def pkt_gen(self, rf_src: bytes, dst_id: bytes, peer: bytes, slot: int, phrase: list[Any]) -> Any: + """Generate HBP voice packets for phrase (legacy mk_voice.pkt_gen).""" + return self._voice.pkt_gen(rf_src, dst_id, peer, slot, phrase) + + def _build_announcement_targets( + self, tg_int: int, tg_str: str, label: str + ) -> list[dict[str, Any]]: + """MASTER systems with active bridge for tg_int and idle slot (legacy target list).""" + targets: list[dict[str, Any]] = [] + protocols = self._get_protocols() if self._get_protocols else {} + systems_cfg = self._config.get("SYSTEMS", {}) + bridges = self._get_bridges() if self._get_bridges else {} + bridge_entries = bridges.get(tg_str, []) + for sys_name in list(protocols.keys()): + if sys_name in _ANNOUNCEMENT_EXCLUDED or any( + sys_name.startswith(ex + "-") for ex in _ANNOUNCEMENT_EXCLUDED + ): + continue + if sys_name not in systems_cfg or systems_cfg[sys_name].get("MODE") != "MASTER": + continue + if not systems_cfg[sys_name].get("PEERS"): + continue + has_peers = any( + systems_cfg[sys_name]["PEERS"].get(pid, {}).get("CALLSIGN") + for pid in systems_cfg[sys_name]["PEERS"] + ) + if not has_peers or sys_name not in protocols: + continue + sys_obj = protocols[sys_name] + if not getattr(sys_obj, "STATUS", None): + continue + active_slots = [ + be["TS"] for be in bridge_entries + if be.get("SYSTEM") == sys_name and be.get("ACTIVE") and be.get("TS") is not None + ] + active_slots = list(dict.fromkeys(active_slots)) + if not active_slots: + continue + for ts in active_slots: + slot_index = 2 if ts == 2 else 1 + slot = sys_obj.STATUS.get(slot_index) + if not slot: + continue + rx_type = slot.get("RX_TYPE") + tx_type = slot.get("TX_TYPE") + if (rx_type != HBPF_SLT_VTERM) or (tx_type != HBPF_SLT_VTERM): + logger.debug("(%s) System %s TS%s busy, skipping", label, sys_name, ts) + continue + targets.append({"sys_obj": sys_obj, "name": sys_name, "slot": slot, "ts": ts}) + return targets + + def _send_filtered_by_tg( + self, sys_obj: Any, pkt: bytes, tg: int, ts: int, bridges: dict[str, list[dict[str, Any]]] + ) -> int: + """Return -1 if sent, 0 if TG/TS not active (legacy _sendFilteredByTG).""" + tg_str = str(tg) + for be in bridges.get(tg_str, []): + if be.get("SYSTEM") == getattr(sys_obj, "_system", None) and be.get("TS") == ts and be.get("ACTIVE"): + sys_obj.send_system(pkt) + return -1 + return 0 + + def _enqueue_broadcast( + self, _type: str, targets: list[dict[str, Any]], pkts_by_ts: dict[int, list[bytes]], + source_id: bytes, dst_id: bytes, tg: int, num: int, label: str, + ) -> None: + self._broadcast_queue.append({ + 'type': _type, 'targets': targets, 'pkts_by_ts': pkts_by_ts, + 'source_id': source_id, 'dst_id': dst_id, 'tg': tg, 'num': num, 'label': label, + }) + _pos = len(self._broadcast_queue) + if self._broadcast_active: + logger.info('(%s) Enqueued broadcast (position %s in queue)', label, _pos) + else: + self._start_next_broadcast() + + def _start_next_broadcast(self) -> None: + if not self._broadcast_queue: + self._broadcast_active = False + return + self._broadcast_active = True + _item = self._broadcast_queue.pop(0) + _type = _item['type'] + _label = _item['label'] + logger.info('(%s) Starting broadcast from queue (%s remaining)', _label, len(self._broadcast_queue)) + if self._call_later: + if _type == 'ann': + self._call_later(0.5, self._announcement_send_broadcast, _item['targets'], _item['pkts_by_ts'], 0, _item['source_id'], _item['dst_id'], _item['tg'], _item['num'], _label, None) + elif _type == 'tts': + self._call_later(0.5, self._tts_send_broadcast, _item['targets'], _item['pkts_by_ts'], 0, _item['source_id'], _item['dst_id'], _item['tg'], _item['num'], _label, None) + + def _broadcast_finished(self) -> None: + if self._broadcast_queue: + logger.info('(QUEUE) Broadcast finished, next in %.1fs (%s queued)', _BROADCAST_GAP, len(self._broadcast_queue)) + if self._call_later: + self._call_later(_BROADCAST_GAP, self._start_next_broadcast) + else: + self._broadcast_active = False + logger.info('(QUEUE) Broadcast finished, queue empty') + + def _announcement_send_broadcast( + self, + targets: list[dict[str, Any]], + pkts_by_ts: dict[int, list[bytes]], + pkt_idx: int, + source_id: bytes, + dst_id: bytes, + tg: int, + ann_num: int, + label: str, + next_time: float | None = None, + ) -> None: + """Send one batch of packets; schedule next via call_later (legacy _announcementSendBroadcast).""" + total = len(pkts_by_ts.get(1, [])) + if pkt_idx >= total: + for t in targets: + try: + obj = t.get("sys_obj") + if getattr(obj, "STATUS", None): + for sid in list(obj.STATUS.keys()): + if sid not in (1, 2): + del obj.STATUS[sid] + except Exception: + pass + self._announcement_running[ann_num] = False + logger.info("(%s) Broadcast complete: %s packets sent to %s targets", label, total, len(targets)) + self._broadcast_finished() + return + bridges = self._get_bridges() if self._get_bridges else {} + now = time.time() + for t in targets: + try: + sys_obj = t["sys_obj"] + slot = t["slot"] + t_ts = t["ts"] + pkt = pkts_by_ts[t_ts][pkt_idx] + stream_id = pkt[16:20] + if stream_id not in sys_obj.STATUS: + sys_obj.STATUS[stream_id] = { + "START": now, + "CONTENTION": False, + "RFS": source_id, + "TGID": dst_id, + "LAST": now, + } + slot["TX_TGID"] = dst_id + else: + sys_obj.STATUS[stream_id]["LAST"] = now + slot["TX_TIME"] = now + self._send_filtered_by_tg(sys_obj, pkt, tg, t_ts, bridges) + except Exception as e: + logger.error("(%s) Error sending packet %s to %s/TS%s: %s", label, pkt_idx, t.get("name"), t.get("ts"), e) + if next_time is None: + next_time = now + _FRAME_INTERVAL + else: + next_time = next_time + _FRAME_INTERVAL + delay = max(0.001, next_time - time.time()) + if self._call_later: + self._call_later( + delay, + self._announcement_send_broadcast, + targets, + pkts_by_ts, + pkt_idx + 1, + source_id, + dst_id, + tg, + ann_num, + label, + next_time, + ) + + def scheduled_announcement(self, ann_num: int = 1, _retry: int = 0) -> None: + """Run one scheduled file announcement (legacy scheduledAnnouncement 1–4).""" + g = self._config.get("VOICE", {}) + prefix = "ANNOUNCEMENT" if ann_num == 1 else "ANNOUNCEMENT{}".format(ann_num) + label = "ANNOUNCEMENT" if ann_num == 1 else "ANNOUNCEMENT-{}".format(ann_num) + if not g.get("{}_ENABLED".format(prefix)): + return + if self._announcement_running.get(ann_num): + if _retry == 0: + logger.debug("(%s) Previous announcement still running, skipping", label) + return + mode = g.get("{}_MODE".format(prefix), "interval") + if mode == "hourly" and _retry == 0: + now = datetime.now() + if now.minute != 0: + return + if self._announcement_last_hour.get(ann_num) == now.hour: + return + self._announcement_last_hour[ann_num] = now.hour + if self._broadcast_active and _retry < 60: + if _retry == 0: + logger.debug("(%s) Broadcast queue busy, deferring prep", label) + if self._call_later: + self._call_later(3.0 + ann_num * 0.5, self.scheduled_announcement, ann_num, _retry + 1) + return + _file = g.get("{}_FILE".format(prefix), "") + _tg = int(g.get("{}_TG".format(prefix), 0)) + _lang = g.get("{}_LANGUAGE".format(prefix), "en_GB") + if not _file or not _tg: + return + _dst_id = bytes_3(_tg) + _source_id = bytes_3(5000) + server_id = g.get("SERVER_ID", b"\x00\x00\x00\x00") + if not isinstance(server_id, bytes): + server_id = bytes_3(int(server_id)) + logger.info("(%s) Playing file: %s to TG %s (both TS, mode: %s, lang: %s)", label, _file, _tg, mode, _lang) + try: + _say = self.read_single_file(self._audio_path, _lang, str(_file)) + except Exception as e: + logger.warning("(%s) Cannot read AMBE file: Audio/%s/ondemand/%s.ambe: %s", label, _lang, _file, e) + return + if not _say: + logger.warning("(%s) AMBE file empty or not found: %s/ondemand/%s.ambe", label, _lang, _file) + return + tg_str = str(_tg) + targets = self._build_announcement_targets(_tg, tg_str, label) + if not targets: + logger.info("(%s) No systems with active bridge for TG %s to send to", label, _tg) + return + _say_list = [_say] + pkts_by_ts = { + 1: list(self.pkt_gen(_source_id, _dst_id, server_id, 0, _say_list)), + 2: list(self.pkt_gen(_source_id, _dst_id, server_id, 1, _say_list)), + } + ts1_count = sum(1 for t in targets if t["ts"] == 1) + ts2_count = sum(1 for t in targets if t["ts"] == 2) + sys_names = ", ".join("{}/TS{}".format(t["name"], t["ts"]) for t in targets[:8]) + if len(targets) > 8: + sys_names += ", ... +{}".format(len(targets) - 8) + logger.info( + "(%s) Broadcasting %s packets to %s targets (TS1:%s TS2:%s): %s", + label, len(pkts_by_ts[1]), len(targets), ts1_count, ts2_count, sys_names, + ) + self._announcement_running[ann_num] = True + self._enqueue_broadcast('ann', targets, pkts_by_ts, _source_id, _dst_id, _tg, ann_num, label) + + def scheduled_tts_announcement(self, tts_num: int = 1, _retry: int = 0) -> None: + """Run one scheduled TTS announcement (legacy scheduledTTSAnnouncement 1–4).""" + g = self._config.get("VOICE", {}) + prefix = "TTS_ANNOUNCEMENT{}".format(tts_num) + label = "TTS-{}".format(tts_num) + if not g.get("{}_ENABLED".format(prefix), False): + return + if self._tts_running.get(tts_num): + if _retry == 0: + logger.debug("(%s) Previous TTS announcement still running, skipping", label) + return + mode = g.get("{}_MODE".format(prefix), "interval") + if mode == "hourly" and _retry == 0: + now = datetime.now() + if now.minute != 0: + return + if self._tts_last_hour.get(tts_num) == now.hour: + return + self._tts_last_hour[tts_num] = now.hour + if self._broadcast_active and _retry < 60: + if _retry == 0: + logger.debug("(%s) Broadcast queue busy, deferring TTS prep", label) + if self._call_later: + self._call_later(3.0 + tts_num * 0.5, self.scheduled_tts_announcement, tts_num, _retry + 1) + return + _file = g.get("{}_FILE".format(prefix), "") + _tg = int(g.get("{}_TG".format(prefix), 0)) + _lang = g.get("{}_LANGUAGE".format(prefix), "en_GB") + self._tts_running[tts_num] = True + logger.info("(%s) Starting TTS conversion in background thread for %s", label, _file) + if self._defer_to_thread: + d = self._defer_to_thread(tts_ensure_tts_ambe, self._config, tts_num, self._audio_path) + d.addCallback(self._tts_conversion_done, tts_num, _file, _tg, _lang, mode, label) + d.addErrback(self._tts_conversion_error, tts_num, label) + else: + try: + ambe_path = tts_ensure_tts_ambe(self._config, tts_num, self._audio_path) + self._tts_conversion_done(ambe_path, tts_num, _file, _tg, _lang, mode, label) + except Exception as e: + self._tts_conversion_error(e, tts_num, label) + + def _tts_conversion_done( + self, ambe_path: str | None, tts_num: int, _file: str, _tg: int, _lang: str, mode: str, label: str, _retry: int = 0 + ) -> None: + """After TTS conversion: broadcast like scheduled_announcement (legacy _ttsConversionDone).""" + if not ambe_path: + self._tts_running[tts_num] = False + logger.warning("(%s) No AMBE file available for TTS announcement %s", label, _file) + return + if self._broadcast_active and _retry < 60: + if _retry == 0: + logger.debug('(%s) Broadcast queue busy, deferring TTS packet prep', 'TTS-{}'.format(tts_num)) + if self._call_later: + self._call_later(3.0 + tts_num * 0.5, self._tts_conversion_done, ambe_path, tts_num, _file, _tg, _lang, mode, label, _retry + 1) + return + logger.info("(%s) Playing TTS file: %s to TG %s (both TS, mode: %s, lang: %s)", label, _file, _tg, mode, _lang) + _dst_id = bytes_3(_tg) + _source_id = bytes_3(5000) + server_id = self._config.get("GLOBAL", {}).get("SERVER_ID", b"\x00\x00\x00\x00") + if not isinstance(server_id, bytes): + server_id = bytes_3(int(server_id)) + _file_base = _file.replace(".ambe", "") + _say = self.read_single_file(self._audio_path, _lang, _file_base) + if not _say: + logger.warning("(%s) Cannot read AMBE file: %s", label, ambe_path) + self._tts_running[tts_num] = False + return + tg_str = str(_tg) + targets = self._build_announcement_targets(_tg, tg_str, label) + if not targets: + self._tts_running[tts_num] = False + logger.info("(%s) No systems with active bridge for TG %s to send to", label, _tg) + return + _say_list = [_say] + pkts_by_ts = { + 1: list(self.pkt_gen(_source_id, _dst_id, server_id, 0, _say_list)), + 2: list(self.pkt_gen(_source_id, _dst_id, server_id, 1, _say_list)), + } + logger.info("(%s) Broadcasting %s packets to %s targets", label, len(pkts_by_ts[1]), len(targets)) + self._enqueue_broadcast('tts', targets, pkts_by_ts, _source_id, _dst_id, _tg, tts_num, label) + + def _tts_conversion_error(self, failure: Any, tts_num: int, label: str) -> None: + self._tts_running[tts_num] = False + try: + msg = failure.getErrorMessage() + except Exception: + msg = str(failure) + logger.error("(%s) TTS conversion error: %s", label, msg) + + def _tts_send_broadcast( + self, + targets: list[dict[str, Any]], + pkts_by_ts: dict[int, list[bytes]], + pkt_idx: int, + source_id: bytes, + dst_id: bytes, + tg: int, + tts_num: int, + label: str, + next_time: float | None = None, + ) -> None: + """Same as _announcement_send_broadcast but clears _tts_running (legacy _ttsSendBroadcast).""" + total = len(pkts_by_ts.get(1, [])) + if pkt_idx >= total: + for t in targets: + try: + obj = t.get("sys_obj") + if getattr(obj, "STATUS", None): + for sid in list(obj.STATUS.keys()): + if sid not in (1, 2): + del obj.STATUS[sid] + except Exception: + pass + self._tts_running[tts_num] = False + logger.info("(%s) Broadcast complete: %s packets sent to %s targets", label, total, len(targets)) + self._broadcast_finished() + return + bridges = self._get_bridges() if self._get_bridges else {} + now = time.time() + for t in targets: + try: + sys_obj = t["sys_obj"] + slot = t["slot"] + t_ts = t["ts"] + pkt = pkts_by_ts[t_ts][pkt_idx] + stream_id = pkt[16:20] + if stream_id not in sys_obj.STATUS: + sys_obj.STATUS[stream_id] = { + "START": now, + "CONTENTION": False, + "RFS": source_id, + "TGID": dst_id, + "LAST": now, + } + slot["TX_TGID"] = dst_id + else: + sys_obj.STATUS[stream_id]["LAST"] = now + slot["TX_TIME"] = now + self._send_filtered_by_tg(sys_obj, pkt, tg, t_ts, bridges) + except Exception as e: + logger.error("(%s) Error sending packet %s to %s: %s", label, pkt_idx, t.get("name"), e) + if next_time is None: + next_time = now + _FRAME_INTERVAL + else: + next_time = next_time + _FRAME_INTERVAL + delay = max(0.001, next_time - time.time()) + if self._call_later: + self._call_later( + delay, + self._tts_send_broadcast, + targets, + pkts_by_ts, + pkt_idx + 1, + source_id, + dst_id, + tg, + tts_num, + label, + next_time, + ) + + def read_single_file(self, audio_path: str, lang: str, file_number: str) -> list: + """Read one AMBE file (e.g. ondemand/{file_number}.ambe). Legacy readSingleFile.""" + return self._voice.read_single_file(audio_path, lang, file_number) + + def play_file_on_request(self, file_number: str, system: str) -> None: + """Play AMBE file on request (legacy playFileOnRequest). TG 9991-9999 triggers this.""" + if not self._get_protocols or not self._call_from_reactor or not self._audio_path: + return + protocol = self._get_protocols().get(system) + if not protocol or not getattr(protocol, "STATUS", None): + return + sys_cfg = self._config.get("SYSTEMS", {}).get(system, {}) + lang = sys_cfg.get("ANNOUNCEMENT_LANGUAGE", "en_GB") + pairs = self.read_single_file(self._audio_path, lang, file_number) + if not pairs: + logger.warning("(%s) AMBE file not found or empty: %s/ondemand/%s.ambe", system, lang, file_number) + return + logger.info("(%s) Playing on-demand AMBE file: %s (ID: %s)", system, file_number, file_number) + time.sleep(1) + _say = [pairs] + server_id = self._config.get("GLOBAL", {}).get("SERVER_ID", b"\x00\x00\x00\x00") + if not isinstance(server_id, bytes): + server_id = bytes_3(int(server_id)) + speech = self.pkt_gen(bytes_3(5000), bytes_3(9), server_id, 1, _say) + _slot = protocol.STATUS.get(2) + if not _slot: + return + _source_id = bytes_3(5000) + _dst_id = bytes_3(9) + _next_time = time.time() + _pkt_count = 0 + for pkt in speech: + _next_time += 0.058 + delay = _next_time - time.time() + if delay > 0.001: + time.sleep(delay) + self._call_from_reactor(protocol.send_voice_packet, pkt, _source_id, _dst_id, _slot) + _pkt_count += 1 + logger.info("(%s) On-demand playback complete: %s (%d packets)", system, file_number, _pkt_count) + + def disconnected_voice(self, system: str) -> None: + """Send 'disconnected' / 'linked to reflector' voice (legacy disconnectedVoice). Run from thread.""" + if not self._get_protocols or not self._call_from_reactor or not self._audio_path: + return + protocol = self._get_protocols().get(system) + if not protocol or not getattr(protocol, "STATUS", None): + return + ann_lang = (self._config.get("VOICE", {}).get("ANNOUNCEMENT_LANGUAGES") or "").strip() + if not ann_lang: + return + words_by_lang = self.get_ambe_words(ann_lang, self._audio_path) + sys_cfg = self._config.get("SYSTEMS", {}).get(system, {}) + _lang = sys_cfg.get("ANNOUNCEMENT_LANGUAGE", "en_GB") + if _lang not in words_by_lang: + return + words = words_by_lang[_lang] + silence = words.get("silence") + if not silence: + return + _say = [silence, silence] + default_refl = int(sys_cfg.get("DEFAULT_REFLECTOR", 0)) + if default_refl > 0: + _say.append(silence) + _say.append(words.get("linkedto") or silence) + _say.append(silence) + _say.append(words.get("to") or silence) + _say.append(silence) + _say.append(silence) + for digit in str(default_refl): + _say.append(words.get(digit) or silence) + _say.append(silence) + else: + _say.append(words.get("notlinked") or silence) + _say.append(silence) + server_id = self._config.get("GLOBAL", {}).get("SERVER_ID", b"\x00\x00\x00\x00") + if not isinstance(server_id, bytes): + server_id = bytes_3(int(server_id)) + speech = self.pkt_gen(bytes_3(5000), bytes_3(9), server_id, 1, _say) + time.sleep(1) + _slot = protocol.STATUS.get(2) + if not _slot: + return + logger.debug("(%s) Sending disconnected voice", system) + _next_time = time.time() + for pkt in speech: + _next_time += 0.058 + _delay = _next_time - time.time() + if _delay > 0.001: + time.sleep(_delay) + self._call_from_reactor(protocol.send_voice_packet, pkt, bytes_3(5000), bytes_3(9), _slot) + logger.debug("(%s) disconnected voice thread end", system) + + def check_voice_config_reload(self, config_file_path: str | None = None) -> None: + """Check main config file mtime and reload if changed (15s loop). Start/stop announcement LoopingCalls.""" + if config_file_path and os.path.isfile(config_file_path): + try: + mtime = os.path.getmtime(config_file_path) + except OSError: + return + if mtime == self._config_file_mtime: + return + self._config_file_mtime = mtime + logger.info("(VOICE-RELOAD) config file change detected, reloading configuration...") + g = self._config.get("VOICE", {}) + if not self._start_looping_call: + return + for ann_num in range(1, 5): + prefix = "ANNOUNCEMENT" if ann_num == 1 else "ANNOUNCEMENT{}".format(ann_num) + label = "ANNOUNCEMENT" if ann_num == 1 else "ANNOUNCEMENT-{}".format(ann_num) + enabled = g.get("{}_ENABLED".format(prefix), False) + if ann_num in self._ann_tasks: + try: + if getattr(self._ann_tasks[ann_num], "running", False): + self._ann_tasks[ann_num].stop() + except Exception: + pass + del self._ann_tasks[ann_num] + logger.info("(VOICE-RELOAD) %s stopped", label) + if enabled: + mode = g.get("{}_MODE".format(prefix), "interval") + interval = 30.0 if mode == "hourly" else float(g.get("{}_INTERVAL".format(prefix), 60)) + lc = self._start_looping_call(lambda an=ann_num: self.scheduled_announcement(an), interval, False) + self._ann_tasks[ann_num] = lc + logger.info( + "(VOICE-RELOAD) %s enabled - mode: %s, file: %s, TG: %s", + label, mode, g.get("{}_FILE".format(prefix)), g.get("{}_TG".format(prefix)), + ) + for tts_num in range(1, 5): + prefix = "TTS_ANNOUNCEMENT{}".format(tts_num) + label = "TTS-{}".format(tts_num) + enabled = g.get("{}_ENABLED".format(prefix), False) + if tts_num in self._tts_tasks: + try: + if getattr(self._tts_tasks[tts_num], "running", False): + self._tts_tasks[tts_num].stop() + except Exception: + pass + del self._tts_tasks[tts_num] + logger.info("(VOICE-RELOAD) %s stopped", label) + if enabled: + mode = g.get("{}_MODE".format(prefix), "interval") + interval = 30.0 if mode == "hourly" else float(g.get("{}_INTERVAL".format(prefix), 60)) + lc = self._start_looping_call(lambda tn=tts_num: self.scheduled_tts_announcement(tn), interval, False) + self._tts_tasks[tts_num] = lc + logger.info( + "(VOICE-RELOAD) %s enabled - mode: %s, file: %s, TG: %s", + label, mode, g.get("{}_FILE".format(prefix)), g.get("{}_TG".format(prefix)), + ) + if config_file_path and self._config_file_mtime: + logger.info("(VOICE-RELOAD) config reload completed") diff --git a/src/adn_server/domain/__init__.py b/src/adn_server/domain/__init__.py new file mode 100644 index 0000000..e97dd96 --- /dev/null +++ b/src/adn_server/domain/__init__.py @@ -0,0 +1,33 @@ +# ADN DMR Peer Server - domain layer +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +from .entities import BridgeEntry, StreamState, SystemConfig +from .value_objects import DmrId, TgId, Slot, CallType, bytes_3, bytes_4, int_id, ID_MIN, ID_MAX, PEER_MAX +from .errors import DomainError, ConfigError, ACLError +from .result import Result, Success, Fail, is_fail, is_ok, unwrap_or + +__all__ = [ + "BridgeEntry", + "StreamState", + "SystemConfig", + "DmrId", + "TgId", + "Slot", + "CallType", + "bytes_3", + "bytes_4", + "int_id", + "ID_MIN", + "ID_MAX", + "PEER_MAX", + "DomainError", + "ConfigError", + "ACLError", + "Result", + "Success", + "Fail", + "is_fail", + "is_ok", + "unwrap_or", +] diff --git a/src/adn_server/domain/entities.py b/src/adn_server/domain/entities.py new file mode 100644 index 0000000..c7a5849 --- /dev/null +++ b/src/adn_server/domain/entities.py @@ -0,0 +1,66 @@ +# ADN DMR Peer Server - domain entities +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Domain entities: bridge entry, stream state, system config (mirror legacy BRIDGES/STATUS/CONFIG).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .value_objects import Slot + + +@dataclass +class BridgeEntry: + """One system/slot entry in a bridge (legacy BRIDGES[tgid][i]).""" + + SYSTEM: str + TS: Slot + TGID: bytes # 3 bytes + ACTIVE: bool + TIMEOUT: float | str # seconds or '' for STAT + TO_TYPE: str # 'ON', 'OFF', 'STAT', 'NONE' + OFF: list[bytes] + ON: list[bytes] + RESET: list[Any] + TIMER: float + + +@dataclass +class StreamState: + """Per-slot stream state (legacy STATUS[slot] and per-stream state).""" + + RX_START: float = 0.0 + TX_START: float = 0.0 + RX_SEQ: int = 0 + RX_RFS: bytes = b"\x00" + TX_RFS: bytes = b"\x00" + RX_PEER: bytes = b"\x00" + TX_PEER: bytes = b"\x00" + RX_STREAM_ID: bytes = b"\x00" + TX_STREAM_ID: bytes = b"\x00" + RX_TGID: bytes = b"\x00\x00\x00" + TX_TGID: bytes = b"\x00\x00\x00" + RX_TIME: float = 0.0 + TX_TIME: float = 0.0 + RX_TYPE: int = 0 + TX_TYPE: int = 0 + RX_LC: bytes = b"\x00" + TX_H_LC: bytes = b"\x00" + TX_T_LC: bytes = b"\x00" + TX_EMB_LC: dict[int, bytes] = field(default_factory=dict) + lastSeq: bool = False + lastData: bool = False + packets: int = 0 + crcs: set[Any] = field(default_factory=set) + + +@dataclass +class SystemConfig: + """Minimal system config for domain (MODE, name, etc.). Full config lives in infrastructure.""" + + name: str + MODE: str # MASTER, PEER, OPENBRIDGE + ENABLED: bool = True diff --git a/src/adn_server/domain/errors.py b/src/adn_server/domain/errors.py new file mode 100644 index 0000000..c8700fc --- /dev/null +++ b/src/adn_server/domain/errors.py @@ -0,0 +1,35 @@ +# ADN DMR Peer Server - domain errors +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Domain errors for the ADN DMR Peer Server.""" + + +class DomainError(Exception): + """Base exception for domain/application.""" + + pass + + +class ConfigError(DomainError): + """Configuration loading or validation error.""" + + pass + + +class ACLError(DomainError): + """ACL parse or evaluation error.""" + + pass + + +class AliasError(DomainError): + """Alias load or resolve error.""" + + pass + + +class ReportProtocolError(DomainError): + """Report protocol decode or opcode error.""" + + pass diff --git a/src/adn_server/domain/result.py b/src/adn_server/domain/result.py new file mode 100644 index 0000000..9266c57 --- /dev/null +++ b/src/adn_server/domain/result.py @@ -0,0 +1,45 @@ +# ADN DMR Peer Server - result type +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Result type for functional error handling (Success/Fail).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Generic, TypeVar + +T = TypeVar("T") +E = TypeVar("E", bound=Exception) + + +@dataclass(frozen=True, slots=True) +class Success(Generic[T]): + """Successful result wrapping a value.""" + + value: T + + +@dataclass(frozen=True, slots=True) +class Fail(Generic[E]): + """Failed result wrapping an error.""" + + error: E + + +Result = Success[T] | Fail[E] + + +def is_fail(r: Result[T, E]) -> bool: + """Return True if result is Fail.""" + return isinstance(r, Fail) + + +def is_ok(r: Result[T, E]) -> bool: + """Return True if result is Success.""" + return isinstance(r, Success) + + +def unwrap_or(r: Result[T, E], default: T) -> T: + """Return value if Success, else default.""" + return r.value if isinstance(r, Success) else default diff --git a/src/adn_server/domain/value_objects.py b/src/adn_server/domain/value_objects.py new file mode 100644 index 0000000..95cdbd9 --- /dev/null +++ b/src/adn_server/domain/value_objects.py @@ -0,0 +1,63 @@ +# ADN DMR Peer Server - value objects +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Value objects: DMR IDs, slot, call type, etc. (mirror legacy const and types).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +# Legacy ID_MIN=1, ID_MAX=16776415, PEER_MAX=4294967295 +ID_MIN = 1 +ID_MAX = 16776415 +PEER_MAX = 4294967295 + + +@dataclass(frozen=True, slots=True) +class DmrId: + """DMR subscriber/peer/talkgroup numeric ID.""" + + value: int + + def __int__(self) -> int: + return self.value + + +@dataclass(frozen=True, slots=True) +class TgId: + """Talkgroup ID (same numeric space as DmrId for group calls).""" + + value: int + + def __int__(self) -> int: + return self.value + + +Slot = Literal[1, 2] +"""Timeslot 1 or 2.""" + +CallType = Literal["unit", "group"] +"""Unit (private) or group call.""" + + +def bytes_3(i: int) -> bytes: + """Encode int as 3-byte big-endian (legacy bytes_3).""" + return (int(i) & 0xFFFFFF).to_bytes(3, "big") + + +def bytes_4(i: int) -> bytes: + """Encode int as 4-byte big-endian (legacy bytes_4, salt, peer_id in packets).""" + return (int(i) & 0xFFFFFFFF).to_bytes(4, "big") + + +def int_id(val: bytes | int) -> int: + """Decode 3- or 4-byte big-endian to int (legacy int_id).""" + if isinstance(val, int): + return val + if len(val) >= 4: + return int.from_bytes(val[:4], "big") + if len(val) == 3: + return int.from_bytes(val, "big") + return 0 diff --git a/src/adn_server/infrastructure/__init__.py b/src/adn_server/infrastructure/__init__.py new file mode 100644 index 0000000..3cbd95e --- /dev/null +++ b/src/adn_server/infrastructure/__init__.py @@ -0,0 +1,8 @@ +# ADN DMR Peer Server - infrastructure layer +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +from .config_loader import YamlConfigLoader +from .logging_config import setup_logging + +__all__ = ["YamlConfigLoader", "setup_logging"] diff --git a/src/adn_server/infrastructure/bridge_router_impl.py b/src/adn_server/infrastructure/bridge_router_impl.py new file mode 100644 index 0000000..89c5557 --- /dev/null +++ b/src/adn_server/infrastructure/bridge_router_impl.py @@ -0,0 +1,43 @@ +# ADN DMR Peer Server - bridge router implementation +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""In-memory BRIDGES and ACL check (legacy acl_check).""" + +from __future__ import annotations + +from typing import Any + +from ..application.ports import BridgeRouter + + +def _int_id(val: bytes | int) -> int: + if isinstance(val, int): + return val + if len(val) >= 4: + return int.from_bytes(val[:4], "big") + if len(val) == 3: + return int.from_bytes(val, "big") + return 0 + + +class InMemoryBridgeRouter(BridgeRouter): + """Holds BRIDGES dict; implements acl_check like legacy.""" + + def __init__(self) -> None: + self._bridges: dict[str, list[dict[str, Any]]] = {} + + def get_bridges(self) -> dict[str, list[dict[str, Any]]]: + return self._bridges + + def set_bridges(self, bridges: dict[str, list[dict[str, Any]]]) -> None: + self._bridges = bridges + + def acl_check(self, id_bytes_or_int: bytes | int, acl: tuple[bool, list[tuple[int, int]]]) -> bool: + """Legacy acl_check: (action, ranges). If id in any range return action else not action.""" + action, ranges = acl + i = _int_id(id_bytes_or_int) + for lo, hi in ranges: + if lo <= i <= hi: + return action + return not action diff --git a/src/adn_server/infrastructure/config_loader.py b/src/adn_server/infrastructure/config_loader.py new file mode 100644 index 0000000..c2b68fc --- /dev/null +++ b/src/adn_server/infrastructure/config_loader.py @@ -0,0 +1,117 @@ +# ADN DMR Peer Server - YAML config loader +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Load config from YAML at project root; same semantic structure as legacy INI.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Any + +import yaml + +from ..domain import ID_MAX, ID_MIN, PEER_MAX +from ..domain.errors import ConfigError +from . import logging_config + + +def acl_build(acl_str: str | None, max_id: int) -> tuple[bool, list[tuple[int, int]]]: + """Build ACL from string e.g. 'DENY:1-5,3120101'. Returns (action, [(lo, hi), ...]).""" + if not acl_str: + return (True, [(ID_MIN, max_id)]) + parts = acl_str.split(":", 1) + if len(parts) != 2: + return (True, [(ID_MIN, max_id)]) + action = parts[0].strip().upper() == "PERMIT" + acl: list[tuple[int, int]] = [] + for entry in parts[1].strip().split(","): + entry = entry.strip() + if entry == "ALL": + acl.append((ID_MIN, max_id)) + break + if "-" in entry: + start_s, end_s = entry.split("-", 1) + start, end = int(start_s.strip()), int(end_s.strip()) + if not (ID_MIN <= start <= max_id) and not (ID_MIN <= end <= max_id): + raise ConfigError(f"ACL range out of bounds: {entry}") + acl.append((start, end)) + else: + i = int(entry) + if not (ID_MIN <= i <= max_id): + raise ConfigError(f"ACL id out of bounds: {entry}") + acl.append((i, i)) + return (action, acl) + + +def process_acls(config: dict[str, Any]) -> None: + """Inject processed ACLs into CONFIG (GLOBAL and per SYSTEM). Mutates config.""" + from ..domain import PEER_MAX + + g = config.get("GLOBAL", {}) + g["REG_ACL"] = acl_build(g.get("REG_ACL", "PERMIT:ALL"), PEER_MAX) + for key in ("SUB_ACL", "TG1_ACL", "TG2_ACL"): + acl_key = "TGID_TS1_ACL" if key == "TG1_ACL" else ("TGID_TS2_ACL" if key == "TG2_ACL" else key) + g[key] = acl_build(g.get(acl_key, g.get(key, "PERMIT:ALL")), ID_MAX) + for system_name, sys_cfg in config.get("SYSTEMS", {}).items(): + if sys_cfg.get("MODE") == "MASTER": + sys_cfg["REG_ACL"] = acl_build(sys_cfg.get("REG_ACL", "PERMIT:ALL"), PEER_MAX) + for key in ("SUB_ACL", "TG1_ACL", "TG2_ACL"): + acl_key = "TGID_TS1_ACL" if key == "TG1_ACL" else ("TGID_TS2_ACL" if key == "TG2_ACL" else key) + sys_cfg[key] = acl_build(sys_cfg.get(acl_key, sys_cfg.get(key, "PERMIT:ALL")), ID_MAX) + + +class YamlConfigLoader: + """Load config from YAML file; same semantics as legacy build_config.""" + + def __init__(self, project_root: str | Path = ".") -> None: + self._root = Path(project_root).resolve() + + def load(self, path: str | None = None) -> dict[str, Any]: + """Load config. path=None uses project root adn-server.yaml.""" + if path is None: + path = str(self._root / "adn-server.yaml") + if not os.path.isfile(path): + raise ConfigError(f"Config file not found: {path}") + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + if not data or not isinstance(data, dict): + raise ConfigError("Invalid YAML or empty config") + # Normalize to same top-level keys as legacy + config: dict[str, Any] = { + "GLOBAL": data.get("GLOBAL", {}), + "VOICE": data.get("VOICE", {}), + "REPORTS": data.get("REPORTS", {}), + "LOGGER": data.get("LOGGER", {}), + "ALIASES": data.get("ALIASES", {}), + "ALLSTAR": data.get("ALLSTAR", {}), + "SYSTEMS": data.get("SYSTEMS", {}), + } + # Ensure REPORT_CLIENTS is list + if "REPORT_CLIENTS" in config["REPORTS"] and isinstance(config["REPORTS"]["REPORT_CLIENTS"], str): + config["REPORTS"]["REPORT_CLIENTS"] = [ + x.strip() for x in config["REPORTS"]["REPORT_CLIENTS"].split(",") + ] + process_acls(config) + return config + + def reload_voice_config(self, config: dict[str, Any], config_path: str | None = None) -> None: + """Reload GLOBAL from main config file (voice, announcements, TTS live there). Updates config in place.""" + if not config_path or not os.path.isfile(config_path): + return + try: + with open(config_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + except (OSError, yaml.YAMLError): + return + if not data or not isinstance(data, dict): + return + new_global = data.get("GLOBAL", {}) + if new_global: + config["GLOBAL"].update(new_global) + process_acls(config) + new_voice = data.get("VOICE", {}) + if new_voice: + config.setdefault("VOICE", {}).update(new_voice) diff --git a/src/adn_server/infrastructure/hbp_constants.py b/src/adn_server/infrastructure/hbp_constants.py new file mode 100644 index 0000000..e30db62 --- /dev/null +++ b/src/adn_server/infrastructure/hbp_constants.py @@ -0,0 +1,54 @@ +# ADN DMR Peer Server - HBP protocol constants +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink (const.py). GPLv3. + +"""Homebrew protocol opcodes and frame types (legacy const.py).""" + +# DMR +DMR = b"DMR" +DMRD = b"DMRD" +DMRE = b"DMRE" +DMRF = b"DMRF" +DMRA = b"DMRA" + +# Master/peer +RPTL = b"RPTL" +RPTPING = b"RPTPING" +RPTACK = b"RPTACK" +RPTCL = b"RPTCL" +RPTK = b"RPTK" +RPTC = b"RPTC" +RPTP = b"RPTP" +RPTA = b"RPTA" +RPTO = b"RPTO" +MSTCL = b"MSTCL" +MSTNAK = b"MSTNAK" +MSTPONG = b"MSTPONG" +MSTN = b"MSTN" # peer receives MSTNAK as MSTN (4-char command) +MSTP = b"MSTP" # peer receives MSTPONG as MSTP +MSTC = b"MSTC" # peer receives MSTCL as MSTC + +# OpenBridge +EOBP = b"EOBP" +BC = b"BC" +BCKA = b"BCKA" +BCSQ = b"BCSQ" +BCST = b"BCST" +BCVE = b"BCVE" + +# Proxy +PRIN = b"PRIN" +PRBL = b"PRBL" + +# Frame types (bits) +HBPF_VOICE = 0x0 +HBPF_VOICE_SYNC = 0x1 +HBPF_DATA_SYNC = 0x2 +HBPF_SLT_VHEAD = 0x1 +HBPF_SLT_VTERM = 0x2 + +VER = 5 +PROTO_VER = 5 + +# Legacy const.py: stream timeout (seconds) for contention +STREAM_TO = 0.36 diff --git a/src/adn_server/infrastructure/logging_config.py b/src/adn_server/infrastructure/logging_config.py new file mode 100644 index 0000000..95d4fde --- /dev/null +++ b/src/adn_server/infrastructure/logging_config.py @@ -0,0 +1,45 @@ +# ADN DMR Peer Server - logging setup +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Configure logging (same format/handlers as legacy log.config_logging).""" + +from __future__ import annotations + +import logging +import sys +from functools import partial, partialmethod +from typing import Any + + +def setup_logging(log_config: dict[str, Any]) -> logging.Logger: + """Configure logging from CONFIG['LOGGER']. Returns root logger.""" + level = getattr(logging, (log_config.get("LOG_LEVEL", "INFO")).upper(), logging.INFO) + log_file = log_config.get("LOG_FILE", "/dev/null") + handlers_cfg = log_config.get("LOG_HANDLERS", "console-timed").strip().split(",") + log_name = log_config.get("LOG_NAME", "ADN") + + logging.TRACE = 5 + logging.addLevelName(logging.TRACE, "TRACE") + logging.Logger.trace = partialmethod(logging.Logger.log, logging.TRACE) + logging.trace = partial(logging.log, logging.TRACE) + + handlers: list[logging.Handler] = [] + if "console-timed" in handlers_cfg or "console" in handlers_cfg: + h = logging.StreamHandler() + h.setFormatter(logging.Formatter("%(levelname)s %(asctime)s %(message)s")) + handlers.append(h) + if ("file-timed" in handlers_cfg or "file" in handlers_cfg) and log_file and log_file != "/dev/null": + try: + h = logging.FileHandler(log_file, encoding="utf-8") + h.setFormatter(logging.Formatter("%(levelname)s %(asctime)s %(message)s")) + handlers.append(h) + except OSError as e: + # Fallback: warn to stderr if file cannot be opened (e.g. permission, missing dir) + sys.stderr.write("(LOGGER) Could not open log file %s: %s\n" % (log_file, e)) + + # force=True (Python 3.8+) so our handlers replace any already set by other libs (e.g. Twisted) + logging.basicConfig(level=level, handlers=handlers or [logging.NullHandler()], force=True) + logger = logging.getLogger(log_name) + logger.setLevel(level) + return logger diff --git a/src/adn_server/infrastructure/persistence/__init__.py b/src/adn_server/infrastructure/persistence/__init__.py new file mode 100644 index 0000000..195cfa4 --- /dev/null +++ b/src/adn_server/infrastructure/persistence/__init__.py @@ -0,0 +1,8 @@ +# ADN DMR Peer Server - persistence +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +from .sub_map_store import PickleSubMapStore +from .keys_store import JsonKeysStore + +__all__ = ["PickleSubMapStore", "JsonKeysStore"] diff --git a/src/adn_server/infrastructure/persistence/alias_loader.py b/src/adn_server/infrastructure/persistence/alias_loader.py new file mode 100644 index 0000000..258c05f --- /dev/null +++ b/src/adn_server/infrastructure/persistence/alias_loader.py @@ -0,0 +1,201 @@ +# ADN DMR Peer Server - alias loader +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Load alias dicts (peer_ids, subscriber_ids, talkgroup_ids, etc.). Legacy mk_aliases.""" + +from __future__ import annotations + +import csv +import hashlib +import json +import logging +import ssl +import time +from pathlib import Path +from typing import Any +from urllib.request import urlopen + +from ...application.ports import AliasLoader + +logger = logging.getLogger(__name__) + + +def try_download(path: Path, file_name: str, url: str, stale_sec: float) -> str: + """Legacy try_download: download file from url if missing or older than stale_sec. Returns result message.""" + if not url: + return f"ID ALIAS MAPPER: '{file_name}' URL empty, not downloaded" + full = path / file_name + now = time.time() + file_exists = full.is_file() + if file_exists: + file_old = (full.stat().st_mtime + stale_sec) < now + else: + file_old = True + if not file_old and file_exists: + return f"ID ALIAS MAPPER: '{file_name}' is current, not downloaded" + try: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with urlopen(url, context=ctx, timeout=30) as response: + data = response.read() + except OSError as e: + return f"ID ALIAS MAPPER: '{file_name}' could not be downloaded due to an IOError: {e}" + if not data or data == b"{}": + return f"ID ALIAS MAPPER: '{file_name}' file not written because downloaded data is empty" + try: + full.parent.mkdir(parents=True, exist_ok=True) + full.write_bytes(data) + except OSError as e: + return f"ID ALIAS mapper '{file_name}' file could not be written: {e}" + return f"ID ALIAS MAPPER: '{file_name}' successfully downloaded" + + +def _blake2bsum(file_path: Path) -> str: + """Blake2b hex digest of file (legacy blake2bsum).""" + h = hashlib.blake2b() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + h.update(chunk) + return h.hexdigest() + + +class DefaultAliasLoader(AliasLoader): + """Load aliases from JSON files and optional downloads. Legacy mk_aliases.""" + + def load_aliases( + self, + config: dict[str, Any], + ) -> tuple[ + dict[int, str], + dict[int, str], + dict[int, str], + dict[int, str], + dict[str, str], + dict[str, str], + ]: + """Build alias dicts. Same order as legacy mk_aliases.""" + aliases = config.get("ALIASES", {}) + path = Path(aliases.get("PATH", "./data/")).resolve() + stale_sec = float(aliases.get("STALE_TIME", aliases.get("STALE_DAYS", 1) * 86400)) + if aliases.get("TRY_DOWNLOAD"): + if aliases.get("CHECKSUM_FILE") and aliases.get("CHECKSUM_URL"): + result = try_download(path, aliases["CHECKSUM_FILE"], aliases.get("CHECKSUM_URL", ""), stale_sec) + logger.info("(ALIAS) %s", result) + for key, url_key in [ + ("PEER_FILE", "PEER_URL"), + ("SUBSCRIBER_FILE", "SUBSCRIBER_URL"), + ("TGID_FILE", "TGID_URL"), + ("SERVER_ID_FILE", "SERVER_ID_URL"), + ]: + url = aliases.get(url_key) + if url and aliases.get(key): + result = try_download(path, aliases[key], url, stale_sec) + logger.info("(ALIAS) %s", result) + checksums = self._load_checksums(path, aliases.get("CHECKSUM_FILE")) + peer_file = aliases.get("PEER_FILE", "peer_ids.json") + sub_file = aliases.get("SUBSCRIBER_FILE", "subscriber_ids.json") + tgid_file = aliases.get("TGID_FILE", "talkgroup_ids.json") + server_file = aliases.get("SERVER_ID_FILE", "server_ids.tsv") + peer_ids = self._load_id_json_verified(path / peer_file, checksums.get("peer_ids"), "peer_ids") + subscriber_ids = self._load_id_json_verified(path / sub_file, checksums.get("subscriber_ids"), "subscriber_ids") + talkgroup_ids = self._load_id_json_verified(path / tgid_file, checksums.get("talkgroup_ids"), "talkgroup_ids") + local_subscriber_ids = self._load_id_json( + path / aliases.get("LOCAL_SUBSCRIBER_FILE", "subscriber_ids.json") + ) + server_ids = self._load_server_tsv_verified(path, server_file, checksums.get("server_ids")) + if server_ids: + logger.info("(ALIAS) ID ALIAS MAPPER: server_ids dictionary is available") + return (peer_ids, subscriber_ids, talkgroup_ids, local_subscriber_ids, server_ids, checksums) + + def _load_checksums(self, path: Path, file_name: str | None) -> dict[str, str]: + """Load checksum JSON (legacy load_json of CHECKSUM_FILE). Keys e.g. peer_ids, subscriber_ids, talkgroup_ids, server_ids.""" + if not file_name: + return {} + full = path / file_name + if not full.is_file(): + return {} + try: + with open(full, "r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + logger.error("(ALIAS) ID ALIAS MAPPER: Cannot load checksums: %s", e) + return {} + return dict(data) if isinstance(data, dict) else {} + + def _load_server_tsv(self, path: Path, file_name: str) -> dict[str, str]: + """Legacy mk_server_dict: TSV with 'OPB Net ID' -> 'Country'.""" + full = path / file_name + if not full.is_file(): + return {} + try: + with open(full, "r", newline="", encoding="utf-8") as f: + reader = csv.DictReader(f, dialect="excel-tab") + out: dict[str, str] = {} + for row in reader: + net_id = row.get("OPB Net ID", "").strip() + country = row.get("Country", "").strip() + if net_id: + out[net_id] = country + return out + except Exception as err: + logger.warning("(ALIAS) ID ALIAS MAPPER: %s could not be read: %s", file_name, err) + return {} + + def _load_id_json_verified( + self, file_path: Path, expected_checksum: str | None, name: str + ) -> dict[int, str]: + """Load ID JSON; if expected_checksum given, verify blake2b first (legacy).""" + if not file_path.is_file(): + return {} + if expected_checksum: + try: + if _blake2bsum(file_path) != expected_checksum: + logger.error("(ALIAS) ID ALIAS MAPPER: problem with blake2bsum of %s file. not updating.", name) + return {} + except Exception as e: + logger.error("(ALIAS) ID ALIAS MAPPER: problem with blake2bsum of %s file: %s", name, e) + return {} + return self._load_id_json(file_path) + + def _load_id_json(self, file_path: Path) -> dict[int, str]: + """Load JSON with 'id' -> 'callsign' structure; return {int(id): callsign}.""" + if not file_path.is_file(): + return {} + try: + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return {} + if not isinstance(data, dict) or "count" in data: + if isinstance(data, dict) and "count" in data: + del data["count"] + out: dict[int, str] = {} + if isinstance(data, dict): + for key, val in data.items(): + if isinstance(val, list): + for record in val: + if isinstance(record, dict) and "id" in record and "callsign" in record: + try: + out[int(record["id"])] = str(record["callsign"]) + except (ValueError, TypeError): + pass + return out + + def _load_server_tsv_verified( + self, path: Path, file_name: str, expected_checksum: str | None + ) -> dict[str, str]: + """Load server_ids TSV; if expected_checksum given, verify blake2b first (legacy).""" + full = path / file_name + if not full.is_file(): + return {} + if expected_checksum: + try: + if _blake2bsum(full) != expected_checksum: + logger.error("(ALIAS) ID ALIAS MAPPER: problem with blake2bsum of server_ids file: not updating.") + return {} + except Exception as e: + logger.error("(ALIAS) ID ALIAS MAPPER: problem with blake2bsum of server_ids file: %s", e) + return {} + return self._load_server_tsv(path, file_name) diff --git a/src/adn_server/infrastructure/persistence/keys_store.py b/src/adn_server/infrastructure/persistence/keys_store.py new file mode 100644 index 0000000..dca4463 --- /dev/null +++ b/src/adn_server/infrastructure/persistence/keys_store.py @@ -0,0 +1,35 @@ +# ADN DMR Peer Server - keys JSON store +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Load/save keys from JSON (legacy utils.load_json/save_json).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from ...application.ports import KeysStore + + +class JsonKeysStore(KeysStore): + """Persist keys as JSON.""" + + def load(self, path: str) -> dict[str, Any]: + """Load keys from JSON file.""" + p = Path(path) + if not p.is_file(): + return {} + try: + with open(p, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {} + + def save(self, path: str, keys: dict[str, Any]) -> None: + """Save keys to JSON file.""" + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + with open(p, "w", encoding="utf-8") as f: + json.dump(keys, f, indent=2) diff --git a/src/adn_server/infrastructure/persistence/sub_map_store.py b/src/adn_server/infrastructure/persistence/sub_map_store.py new file mode 100644 index 0000000..32a70c2 --- /dev/null +++ b/src/adn_server/infrastructure/persistence/sub_map_store.py @@ -0,0 +1,35 @@ +# ADN DMR Peer Server - SUB_MAP persistence +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Load/save SUB_MAP (pickle): bytes_3(peer) -> (callsign, slot, time).""" + +from __future__ import annotations + +import pickle +from pathlib import Path +from typing import Any + +from ...application.ports import SubMapStore + + +class PickleSubMapStore(SubMapStore): + """Persist SUB_MAP as pickle (legacy compatible).""" + + def load(self, path: str) -> dict[bytes, tuple[str, int, float]]: + """Load SUB_MAP from pickle file.""" + p = Path(path) + if not p.is_file(): + return {} + try: + with open(p, "rb") as f: + return pickle.load(f) + except (pickle.PickleError, OSError): + return {} + + def save(self, path: str, sub_map: dict[bytes, tuple[str, int, float]]) -> None: + """Save SUB_MAP to pickle file.""" + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + with open(p, "wb") as f: + pickle.dump(sub_map, f) diff --git a/src/adn_server/infrastructure/security/__init__.py b/src/adn_server/infrastructure/security/__init__.py new file mode 100644 index 0000000..0e8888c --- /dev/null +++ b/src/adn_server/infrastructure/security/__init__.py @@ -0,0 +1,7 @@ +# ADN DMR Peer Server - security +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +from .password_download import DefaultSecurityDownloader, StubSecurityDownloader + +__all__ = ["DefaultSecurityDownloader", "StubSecurityDownloader"] diff --git a/src/adn_server/infrastructure/security/password_crypto.py b/src/adn_server/infrastructure/security/password_crypto.py new file mode 100644 index 0000000..dfc5f8c --- /dev/null +++ b/src/adn_server/infrastructure/security/password_crypto.py @@ -0,0 +1,70 @@ +# ADN DMR Peer Server - password encryption (legacy password_crypto) +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server. GPLv3. + +"""Fernet-based encrypt/decrypt for stored passwords. Legacy password_crypto.""" + +from __future__ import annotations + +import os +from typing import Optional + +try: + from cryptography.fernet import Fernet +except ImportError: + Fernet = None # type: ignore[misc, assignment] + + +def get_or_create_key(key_path: str) -> bytes: + """Load encryption key from file or create and save one.""" + if Fernet is None: + raise RuntimeError("cryptography package required for password_crypto") + if os.path.exists(key_path): + with open(key_path, "rb") as f: + return f.read() + key = Fernet.generate_key() + os.makedirs(os.path.dirname(key_path), exist_ok=True) + with open(key_path, "wb") as f: + f.write(key) + return key + + +def get_fernet(key_path: str = "config/encryption_key.secret") -> "Fernet": + """Return Fernet instance using key at key_path.""" + if Fernet is None: + raise RuntimeError("cryptography package required for password_crypto") + key = get_or_create_key(key_path) + return Fernet(key) + + +def decrypt_password(encrypted_password: Optional[str], key_path: str = "config/encryption_key.secret") -> Optional[str]: + """Decrypt a stored password; return as-is if empty or on failure.""" + if not encrypted_password: + return encrypted_password + try: + fernet = get_fernet(key_path) + decrypted = fernet.decrypt(encrypted_password.encode("utf-8")) + return decrypted.decode("utf-8") + except Exception: + return encrypted_password + + +def encrypt_password(password: Optional[str], key_path: str = "config/encryption_key.secret") -> Optional[str]: + """Encrypt a password for storage.""" + if not password: + return password + fernet = get_fernet(key_path) + encrypted = fernet.encrypt(password.encode("utf-8")) + return encrypted.decode("utf-8") + + +def is_encrypted(value: Optional[str], key_path: str = "config/encryption_key.secret") -> bool: + """Return True if value looks like Fernet-encrypted data.""" + if not value: + return False + try: + fernet = get_fernet(key_path) + fernet.decrypt(value.encode("utf-8")) + return True + except Exception: + return False diff --git a/src/adn_server/infrastructure/security/password_download.py b/src/adn_server/infrastructure/security/password_download.py new file mode 100644 index 0000000..787a7f3 --- /dev/null +++ b/src/adn_server/infrastructure/security/password_download.py @@ -0,0 +1,228 @@ +# ADN DMR Peer Server - security downloads (legacy security_downloader) +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server. GPLv3. + +"""Central security server: download encryption key and user passwords. Legacy init_security_downloads, periodic_password_download.""" + +from __future__ import annotations + +import logging +import os +import shutil +import socket +import tempfile +import time +from typing import Any +from urllib.parse import quote +from urllib.request import Request, urlopen +from urllib.error import HTTPError, URLError + +from ...application.ports import SecurityDownloader + +logger = logging.getLogger(__name__) + +DOWNLOAD_INTERVAL_PASSWORDS = 300 + +_last_passwords_download = 0.0 +_last_passwords_size = 0 +_last_passwords_content: bytes | None = None + + +def _resolve_hostname(hostname: str, timeout: int = 10) -> str | None: + try: + old_timeout = socket.getdefaulttimeout() + socket.setdefaulttimeout(timeout) + ip = socket.gethostbyname(hostname) + socket.setdefaulttimeout(old_timeout) + logger.debug("(SECURITY) Resolved %s to %s", hostname, ip) + return ip + except socket.gaierror as e: + logger.error("(SECURITY) DNS resolution failed for %s: %s", hostname, e) + return None + except Exception as e: + logger.error("(SECURITY) Unexpected error resolving %s: %s", hostname, e) + return None + + +def _build_download_url(config: dict[str, Any], filename: str) -> tuple[str | None, str | None]: + g = config.get("GLOBAL", {}) + url_security = (g.get("URL_SECURITY") or "").strip() + port_security = (g.get("PORT_SECURITY") or "").strip() + pass_security = (g.get("PASS_SECURITY") or "").strip() + if not url_security or not port_security or not pass_security: + return None, None + try: + socket.inet_aton(url_security) + host = url_security + except OSError: + host = _resolve_hostname(url_security) + if not host: + logger.error("(SECURITY) Could not resolve hostname: %s", url_security) + return None, None + url = f"http://{host}:{port_security}/descargar?pass={quote(pass_security, safe='')}&file={filename}" + return url, url_security + + +def _download_file_safely( + url: str, dest_path: str, timeout: int = 60 +) -> bool: + try: + fd, temp_path = tempfile.mkstemp() + os.close(fd) + try: + logger.debug("(SECURITY) Attempting download from: %s", url) + req = Request(url) + req.add_header("User-Agent", "ADN-Systems-DMR/1.0") + with urlopen(req, timeout=timeout) as response: + content = response.read() + if len(content) == 0: + logger.warning("(SECURITY) Downloaded file is empty, keeping existing: %s", dest_path) + os.unlink(temp_path) + return False + with open(temp_path, "wb") as f: + f.write(content) + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + shutil.move(temp_path, dest_path) + logger.info("(SECURITY) Successfully downloaded: %s (%d bytes)", dest_path, len(content)) + return True + except HTTPError as e: + logger.error("(SECURITY) HTTP error downloading %s: %s (Code: %d)", dest_path, e, e.code) + if os.path.exists(temp_path): + os.unlink(temp_path) + return False + except URLError as e: + logger.error("(SECURITY) URL error downloading %s: %s", dest_path, e.reason) + if os.path.exists(temp_path): + os.unlink(temp_path) + return False + except socket.timeout: + logger.error("(SECURITY) Timeout downloading %s", dest_path) + if os.path.exists(temp_path): + os.unlink(temp_path) + return False + except Exception as e: + logger.error("(SECURITY) Unexpected error downloading %s: %s", dest_path, e) + return False + + +def _download_encryption_key(config: dict[str, Any], config_dir: str) -> bool: + g = config.get("GLOBAL", {}) + hash_encrypt = (g.get("HASH_ENCRYPT") or "encryption_key.secret").strip() + dest_path = os.path.join(config_dir, hash_encrypt) + url, _ = _build_download_url(config, hash_encrypt) + if not url: + logger.debug("(SECURITY) Security server not configured, skipping encryption key download") + return False + logger.info("(SECURITY) Downloading encryption key from central server...") + return _download_file_safely(url, dest_path) + + +def _download_user_passwords( + config: dict[str, Any], data_dir: str, force: bool = False +) -> bool: + global _last_passwords_download, _last_passwords_size, _last_passwords_content + now = time.time() + if not force and (now - _last_passwords_download) < DOWNLOAD_INTERVAL_PASSWORDS: + return False + g = config.get("GLOBAL", {}) + users_pass = (g.get("USERS_PASS") or "user_passwords.json").strip() + dest_path = os.path.join(data_dir, users_pass) + url, _ = _build_download_url(config, users_pass) + if not url: + logger.debug("(SECURITY) Security server not configured, skipping passwords download") + return False + try: + logger.debug("(SECURITY) Downloading passwords from: %s", url) + req = Request(url) + req.add_header("User-Agent", "ADN-Systems-DMR/1.0") + with urlopen(req, timeout=60) as response: + new_content = response.read() + new_size = len(new_content) + if new_size == 0: + logger.warning("(SECURITY) Downloaded passwords file is empty, keeping existing") + _last_passwords_download = now + return False + if _last_passwords_content is not None and new_content == _last_passwords_content: + logger.debug("(SECURITY) Passwords file unchanged, no update needed") + _last_passwords_download = now + return False + fd, temp_path = tempfile.mkstemp() + os.close(fd) + with open(temp_path, "wb") as f: + f.write(new_content) + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + shutil.move(temp_path, dest_path) + _last_passwords_content = new_content + _last_passwords_size = new_size + _last_passwords_download = now + logger.info("(SECURITY) Successfully updated passwords file: %s (%d bytes)", dest_path, new_size) + return True + except HTTPError as e: + logger.error("(SECURITY) HTTP error downloading passwords: %s (Code: %d)", e, e.code) + _last_passwords_download = now + return False + except URLError as e: + logger.error("(SECURITY) URL error downloading passwords: %s", e.reason) + _last_passwords_download = now + return False + except socket.timeout: + logger.error("(SECURITY) Timeout downloading passwords") + _last_passwords_download = now + return False + except Exception as e: + logger.error("(SECURITY) Unexpected error downloading passwords: %s", e) + _last_passwords_download = now + return False + + +class DefaultSecurityDownloader(SecurityDownloader): + """Legacy security_downloader: init_security_downloads + periodic_password_download.""" + + def __init__(self, project_root: str) -> None: + self._project_root = project_root + + def _config_dir(self, config: dict[str, Any]) -> str: + return os.path.join(self._project_root, (config.get("GLOBAL", {}).get("CONFIG_PATH") or "config")) + + def _data_dir(self, config: dict[str, Any]) -> str: + path = (config.get("ALIASES", {}).get("PATH") or "data").rstrip("/") + return os.path.join(self._project_root, path) + + def init_downloads(self, config: dict[str, Any]) -> None: + """One-time init: resolve hostname, download encryption key and passwords (force).""" + url_security = (config.get("GLOBAL", {}).get("URL_SECURITY") or "").strip() + if not url_security: + logger.info("(SECURITY) Central security server not configured") + return + port_security = (config.get("GLOBAL", {}).get("PORT_SECURITY") or "").strip() + logger.info("(SECURITY) Initializing centralized security downloads...") + logger.info("(SECURITY) Security server: %s:%s", url_security, port_security) + try: + socket.inet_aton(url_security) + logger.info("(SECURITY) Using IP address: %s", url_security) + except OSError: + resolved = _resolve_hostname(url_security) + if resolved: + logger.info("(SECURITY) Resolved hostname %s to IP: %s", url_security, resolved) + else: + logger.error("(SECURITY) Failed to resolve hostname: %s", url_security) + return + config_dir = self._config_dir(config) + data_dir = self._data_dir(config) + _download_encryption_key(config, config_dir) + _download_user_passwords(config, data_dir, force=True) + + def periodic_download(self, config: dict[str, Any]) -> None: + """Periodic password file download (every 5 min).""" + data_dir = self._data_dir(config) + _download_user_passwords(config, data_dir) + + +class StubSecurityDownloader(SecurityDownloader): + """Stub: init and periodic_download do nothing.""" + + def init_downloads(self, config: dict[str, Any]) -> None: + pass + + def periodic_download(self, config: dict[str, Any]) -> None: + pass diff --git a/src/adn_server/infrastructure/security/user_passwords_loader.py b/src/adn_server/infrastructure/security/user_passwords_loader.py new file mode 100644 index 0000000..1430b41 --- /dev/null +++ b/src/adn_server/infrastructure/security/user_passwords_loader.py @@ -0,0 +1,83 @@ +# ADN DMR Peer Server - load and decrypt user passwords (legacy hblink load_user_passwords, get_user_password) +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Load user_passwords.json, decrypt with password_crypto; expose get_user_password(radio_id) for auth.""" + +from __future__ import annotations + +import json +import logging +import os +import time +from typing import Any + +logger = logging.getLogger(__name__) + +USER_PASSWORDS_RELOAD_INTERVAL = 10.0 +_last_load = 0.0 + + +class UserPasswordsLoader: + """Load and cache decrypted user passwords from GLOBAL.USERS_PASS (JSON with 'passwords' dict).""" + + def __init__(self, project_root: str) -> None: + self._project_root = project_root + self._passwords: dict[str, str] = {} + self._config: dict[str, Any] = {} + self._config_dir = os.path.join(project_root, "config") + self._key_path = os.path.join(self._config_dir, "encryption_key.secret") + + def load(self, config: dict[str, Any]) -> dict[str, str]: + """Load user_passwords.json from data dir, decrypt each; return passwords dict. Legacy load_user_passwords.""" + global _last_load + self._config = config + now = time.time() + if now - _last_load < USER_PASSWORDS_RELOAD_INTERVAL and self._passwords: + return self._passwords + data_dir = os.path.join( + self._project_root, + (config.get("ALIASES", {}).get("PATH") or "data").rstrip("/"), + ) + users_pass = (config.get("GLOBAL", {}).get("USERS_PASS") or "user_passwords.json").strip() + path = os.path.join(data_dir, users_pass) + key_path = os.path.join( + self._project_root, + (config.get("GLOBAL", {}).get("CONFIG_PATH") or "config").rstrip("/"), + ) + hash_encrypt = (config.get("GLOBAL", {}).get("HASH_ENCRYPT") or "encryption_key.secret").strip() + self._key_path = os.path.join(key_path, hash_encrypt) + self._passwords = {} + if not os.path.exists(path): + _last_load = now + return self._passwords + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + encrypted = data.get("passwords", {}) + from .password_crypto import decrypt_password + for radio_id, pwd in encrypted.items(): + self._passwords[str(radio_id)] = decrypt_password(pwd, self._key_path) or "" + logger.debug("(AUTH) Loaded %d individual passwords from %s", len(self._passwords), path) + except (FileNotFoundError, json.JSONDecodeError, Exception) as e: + logger.warning("(AUTH) Could not load user passwords: %s", e) + _last_load = now + return self._passwords + + def get_user_password(self, radio_id: int) -> bytes | None: + """Return password for radio_id (for login auth); 7-char prefix match like legacy. Legacy get_user_password.""" + if time.time() - _last_load >= USER_PASSWORDS_RELOAD_INTERVAL and self._config: + self.load(self._config) + radio_id_str = str(radio_id) + if not radio_id_str.isdigit(): + return None + if radio_id_str in self._passwords: + pwd = self._passwords[radio_id_str] + return pwd.encode("utf-8") if isinstance(pwd, str) else pwd + if len(radio_id_str) == 9: + base_id = radio_id_str[:7] + if base_id in self._passwords: + logger.debug("(AUTH) Radio ID %s using base ID %s password", radio_id_str, base_id) + pwd = self._passwords[base_id] + return pwd.encode("utf-8") if isinstance(pwd, str) else pwd + return None diff --git a/src/adn_server/infrastructure/twisted_adapters/__init__.py b/src/adn_server/infrastructure/twisted_adapters/__init__.py new file mode 100644 index 0000000..ef761ca --- /dev/null +++ b/src/adn_server/infrastructure/twisted_adapters/__init__.py @@ -0,0 +1,8 @@ +# ADN DMR Peer Server - Twisted adapters +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +from .report_server import ReportServerFactory, REPORT_OPCODES +from .udp_hbp import HBPProtocolFactory + +__all__ = ["ReportServerFactory", "REPORT_OPCODES", "HBPProtocolFactory"] diff --git a/src/adn_server/infrastructure/twisted_adapters/report_server.py b/src/adn_server/infrastructure/twisted_adapters/report_server.py new file mode 100644 index 0000000..9900754 --- /dev/null +++ b/src/adn_server/infrastructure/twisted_adapters/report_server.py @@ -0,0 +1,115 @@ +# ADN DMR Peer Server - TCP report server +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Report server: CONFIG_SND, BRIDGE_SND, BRDG_EVENT (legacy reportFactory, bridgeReportFactory).""" + +from __future__ import annotations + +import logging +import pickle +from typing import Any + +from twisted.internet.protocol import Factory +from twisted.protocols.basic import NetstringReceiver + +logger = logging.getLogger(__name__) + +# Same opcodes as legacy reporting_const +REPORT_OPCODES = { + "CONFIG_REQ": b"\x00", + "CONFIG_SND": b"\x01", + "BRIDGE_REQ": b"\x02", + "BRIDGE_SND": b"\x03", + "CONFIG_UPD": b"\x04", + "BRIDGE_UPD": b"\x05", + "LINK_EVENT": b"\x06", + "BRDG_EVENT": b"\x07", +} + + +class ReportProtocol(NetstringReceiver): + """Single report client connection.""" + + def __init__(self, factory: "ReportServerFactory") -> None: + self._factory = factory + + def connectionMade(self) -> None: + self._factory.clients.append(self) + peer = self.transport.getPeer() if self.transport else None + addr = f"{peer.host}:{peer.port}" if peer else "?" + logger.info("(REPORT) Client connected from %s (%s client(s))", addr, len(self._factory.clients)) + # Send CONFIG_SND and BRIDGE_SND immediately so monitor gets systems/bridges without waiting for loop + self._factory._send_config_to(self) + self._factory._send_bridge_to(self) + + def connectionLost(self, reason: Any = None) -> None: + if self in self._factory.clients: + self._factory.clients.remove(self) + logger.info("(REPORT) Client disconnected (%s client(s))", len(self._factory.clients)) + + def stringReceived(self, data: bytes) -> None: + if data[:1] == REPORT_OPCODES["CONFIG_REQ"]: + self._factory._send_config_to(self) + elif data[:1] == REPORT_OPCODES["BRIDGE_REQ"]: + self._factory._send_bridge_to(self) + else: + pass # unknown opcode + + +class ReportServerFactory(Factory): + """Factory for report protocol; holds config and bridges and sends to clients.""" + + def __init__(self, config: dict[str, Any]) -> None: + self._config = config + self.clients: list[ReportProtocol] = [] + self._systems: dict[str, Any] = {} + self._bridges: dict[str, Any] = {} + + def buildProtocol(self, addr: Any) -> ReportProtocol | None: + allowed = self._config.get("REPORTS", {}).get("REPORT_CLIENTS", ["127.0.0.1"]) + if isinstance(allowed, str): + allowed = [x.strip() for x in allowed.split(",")] + if "*" in allowed or (hasattr(addr, "host") and addr.host in allowed): + return ReportProtocol(self) + return None + + def set_systems(self, systems: dict[str, Any]) -> None: + self._systems = systems + + def set_bridges(self, bridges: dict[str, Any]) -> None: + self._bridges = bridges + + def _send_config_to(self, client: ReportProtocol) -> None: + """Send CONFIG_SND to a single client (e.g. on connect or CONFIG_REQ).""" + payload = pickle.dumps(self._systems, protocol=2) + msg = REPORT_OPCODES["CONFIG_SND"] + payload + client.sendString(msg) + logger.debug("(REPORT) Sent CONFIG_SND to client (%d systems)", len(self._systems)) + + def _send_bridge_to(self, client: ReportProtocol) -> None: + """Send BRIDGE_SND to a single client (e.g. on connect or BRIDGE_REQ).""" + payload = pickle.dumps(self._bridges, protocol=2) + msg = REPORT_OPCODES["BRIDGE_SND"] + payload + client.sendString(msg) + logger.debug("(REPORT) Sent BRIDGE_SND to client (%d bridges)", len(self._bridges)) + + def send_config(self) -> None: + """Send CONFIG_SND (pickle SYSTEMS) to all clients.""" + n = len(self.clients) + logger.debug("(REPORT) Sending CONFIG_SND to %s client(s)", n) + for c in self.clients: + self._send_config_to(c) + + def send_bridge(self) -> None: + """Send BRIDGE_SND (pickle BRIDGES) to all clients.""" + n = len(self.clients) + logger.debug("(REPORT) Sending BRIDGE_SND to %s client(s)", n) + for c in self.clients: + self._send_bridge_to(c) + + def send_bridge_event(self, event: str) -> None: + """Send BRDG_EVENT.""" + msg = REPORT_OPCODES["BRDG_EVENT"] + event.encode("utf-8", errors="ignore") + for c in self.clients: + c.sendString(msg) diff --git a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py new file mode 100644 index 0000000..20dda32 --- /dev/null +++ b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py @@ -0,0 +1,1325 @@ +# ADN DMR Peer Server - UDP HBP protocol +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink (hblink.py). GPLv3. +# Source of truth: adn-dmr-server hblink.py master_datagramReceived / HBSYSTEM. + +"""UDP HBP: direct port from hblink.py. MASTER uses same logic, log strings and packet handling as legacy.""" + +from __future__ import annotations + +import logging +import time +from binascii import a2b_hex as bhex +from collections import deque +from hashlib import blake2b, sha1, sha256 +from hmac import compare_digest, new as hmac_new +from random import randint +from typing import Any, Callable + +from bitarray import bitarray +from twisted.internet import reactor, task +from twisted.internet.protocol import DatagramProtocol + +from ...domain import int_id, bytes_4 +from dmr_utils3 import decode +from dmr_utils3.const import LC_OPT + +from ..hbp_constants import ( + BC, + BCKA, + BCSQ, + BCST, + BCVE, + DMR, + DMRA, + DMRD, + DMRE, + EOBP, + HBPF_DATA_SYNC, + HBPF_SLT_VHEAD, + HBPF_SLT_VTERM, + MSTNAK, + MSTCL, + MSTPONG, + MSTN, + MSTP, + MSTC, + PRIN, + RPTACK, + RPTA, + RPTCL, + RPTC, + RPTK, + RPTL, + RPTO, + RPTP, + RPTPING, + PROTO_VER, + VER, +) + +logger = logging.getLogger(__name__) + + +def get_user_password(radio_id: int): + """Legacy get_user_password (hblink.py). Stub: returns None (no individual passwords).""" + return None + + +def _get_passphrase_bytes(sys_cfg: dict) -> bytes: + """PASSPHRASE as bytes (legacy config has bytes).""" + p = sys_cfg.get("PASSPHRASE") or b"" + return p if isinstance(p, bytes) else p.encode("utf-8") + + +def _calc_hash(salt_str: bytes, password: bytes) -> bytes: + """Same as legacy: bhex(sha256(salt_str + password).hexdigest()).""" + return bhex(sha256(salt_str + password).hexdigest()) + + +def _make_slot_status() -> dict[str, Any]: + """One slot STATUS dict (legacy routerHBP.STATUS[1] or [2]).""" + now = time.time() + return { + "RX_START": now, + "TX_START": now, + "RX_SEQ": 0, + "RX_RFS": b"\x00", + "TX_RFS": b"\x00", + "RX_PEER": b"\x00", + "TX_PEER": b"\x00", + "RX_STREAM_ID": b"\x00", + "TX_STREAM_ID": b"\x00", + "RX_TGID": b"\x00\x00\x00", + "TX_TGID": b"\x00\x00\x00", + "RX_TIME": now, + "TX_TIME": now, + "RX_TYPE": HBPF_SLT_VTERM, + "TX_TYPE": HBPF_SLT_VTERM, + "RX_LC": b"\x00", + "TX_H_LC": b"\x00", + "TX_T_LC": b"\x00", + "TX_EMB_LC": {1: b"\x00", 2: b"\x00", 3: b"\x00", 4: b"\x00"}, + "lastSeq": False, + "lastData": False, + "packets": 0, + "crcs": set(), + } + + +class HBPProtocol(DatagramProtocol): + """Port of HBSYSTEM (hblink.py). MASTER: same master_datagramReceived logic and logs.""" + + def __init__( + self, + system_name: str, + config: dict[str, Any], + report_factory: Any = None, + router: Any = None, + dmrd_received: Callable[..., None] | None = None, + get_user_password_callback: Callable[[int], bytes | None] | None = None, + on_play_file_request: Callable[[str, str], None] | None = None, + on_handle_recording: Callable[..., None] | None = None, + on_in_band_signalling: Callable[[str, int, bytes, float], None] | None = None, + on_options_received: Callable[[str], None] | None = None, + on_deactivate_dynamic_bridges: Callable[[str], None] | None = None, + ) -> None: + self._CONFIG = config + self._system = system_name + self._report = report_factory + self._router = router + self._dmrd_received = dmrd_received + self._get_user_password = get_user_password_callback if get_user_password_callback is not None else get_user_password + self._on_play_file_request = on_play_file_request + self._on_handle_recording = on_handle_recording + self._on_in_band_signalling = on_in_band_signalling + self._on_options_received = on_options_received + self._on_deactivate_dynamic_bridges = on_deactivate_dynamic_bridges + self._config = config.get("SYSTEMS", {}).get(system_name, {}) + if self._config.get("MODE") == "OPENBRIDGE": + self._laststrid = deque([], 20) + self.STATUS = {1: _make_slot_status(), 2: _make_slot_status()} + # Legacy bridge_master: OBP stream state by stream_id for loop control (1ST) and duplicate handling + self._obp_streams = {} + else: + self._laststrid = {1: b"", 2: b""} + self.STATUS = {1: _make_slot_status(), 2: _make_slot_status()} + if self._config.get("MODE") == "MASTER": + self._peers = self._config.get("PEERS", {}) + else: + self._peers = {} + if self._config.get("MODE") == "PEER": + self._stats = self._config.get("STATS", {}) + else: + self._stats = {} + + def startProtocol(self) -> None: + if self._config.get("MODE") == "OPENBRIDGE": + logger.info( + "(%s) Starting OBP. TARGET_IP: %s, TARGET_PORT: %s", + self._system, + self._config.get("TARGET_IP", ""), + self._config.get("TARGET_PORT", ""), + ) + if self._config.get("ENHANCED_OBP"): + self._bcka_loop = task.LoopingCall(self._obp_send_bcka) + self._bcka_loop.start(10) + self._bcve_loop = task.LoopingCall(self._obp_send_bcve) + self._bcve_loop.start(60) + elif self._config.get("MODE") == "MASTER": + ping_time = self._CONFIG.get("GLOBAL", {}).get("PING_TIME", 10) + self._maintenance_loop = task.LoopingCall(self._master_maintenance_loop) + self._maintenance_loop.start(ping_time) + elif self._config.get("MODE") == "PEER": + ping_time = self._CONFIG.get("GLOBAL", {}).get("PING_TIME", 10) + self._maintenance_loop = task.LoopingCall(self._peer_maintenance_loop) + self._maintenance_loop.start(ping_time) + + # ── Exact port of hblink.py send_peers / send_peer / send_master / send_system ── + + def send_peers(self, _packet: bytes, _hops: bytes = b"", _ber: bytes = b"\x00", _rssi: bytes = b"\x00", _source_server: bytes = b"\x00\x00\x00\x00", _source_rptr: bytes = b"\x00\x00\x00\x00") -> None: + for _peer in self._peers: + if len(_packet) < 54: + _packet = b"".join([_packet, _ber, _rssi]) + self.send_peer(_peer, _packet) + + def send_peer(self, _peer: bytes, _packet: bytes) -> None: + if _packet[:4] == DMRD: + _packet = b"".join([_packet[:11], _peer, _packet[15:]]) + self.transport.write(_packet, self._peers[_peer]["SOCKADDR"]) + + def send_master(self, _packet: bytes, _hops: bytes = b"", _ber: bytes = b"\x00", _rssi: bytes = b"\x00", _source_server: bytes = b"\x00\x00\x00\x00", _source_rptr: bytes = b"\x00\x00\x00\x00") -> None: + if _packet[:4] == DMRD: + if len(_packet) < 54: + _packet = b"".join([_packet[:11], self._config["RADIO_ID"], _packet[15:], _ber, _rssi]) + else: + _packet = b"".join([_packet[:11], self._config["RADIO_ID"], _packet[15:]]) + self.transport.write(_packet, self._config["MASTER_SOCKADDR"]) + + def send_system( + self, + _packet: bytes, + _hops: bytes = b"", + _ber: bytes = b"\x00", + _rssi: bytes = b"\x00", + _source_server: bytes = b"\x00\x00\x00\x00", + _source_rptr: bytes = b"\x00\x00\x00\x00", + ) -> None: + if self._config.get("MODE") == "MASTER": + self.send_peers(_packet, _hops, _ber, _rssi, _source_server, _source_rptr) + elif self._config.get("MODE") == "OPENBRIDGE": + if "STUN" in self._CONFIG: + logger.info("(%s) Bridge STUNned, discarding", self._system) + return + if not _hops: + _hops = (1).to_bytes(1, "big") + if _packet[:3] == DMR and self._config.get("TARGET_IP"): + _sid = self._CONFIG.get("GLOBAL", {}).get("SERVER_ID", b"\x00\x00\x00\x00") + _server_id = _sid if isinstance(_sid, bytes) and len(_sid) >= 4 else bytes_4(int(_sid) & 0xFFFFFFFF if isinstance(_sid, int) else 0) + _passphrase = self._config.get("PASSPHRASE", b"") + _target_addr = (self._config["TARGET_IP"], self._config["TARGET_PORT"]) + if "VER" in self._config and self._config["VER"] > 4: + _ver = VER.to_bytes(1, "big") + _packet = b"".join([DMRE, _packet[4:11], _server_id, _packet[15:], _ber, _rssi, _ver, time.time_ns().to_bytes(8, "big"), _source_server, _source_rptr, _hops]) + _h = blake2b(key=_passphrase, digest_size=16) + _h.update(_packet) + _hash = _h.digest() + _packet = b"".join([_packet, _hash]) + self.transport.write(_packet, _target_addr) + elif "VER" in self._config and self._config["VER"] == 4: + _ver = VER.to_bytes(1, "big") + _packet = b"".join([DMRE, _packet[4:11], _server_id, _packet[15:], _ber, _rssi, _ver, time.time_ns().to_bytes(8, "big"), _source_server, _hops]) + _h = blake2b(key=_passphrase, digest_size=16) + _h.update(_packet) + _hash = _h.digest() + _packet = b"".join([_packet, _hash]) + self.transport.write(_packet, _target_addr) + elif "VER" in self._config and self._config["VER"] == 3: + logger.error("(%s) protocol version 3 no longer supported", self._system) + elif "VER" in self._config and self._config["VER"] == 2: + logger.error("(%s) protocol version 2 no longer supported", self._system) + else: + _packet = b"".join([DMRD, _packet[4:11], _server_id, _packet[15:]]) + _packet = b"".join([_packet, hmac_new(_passphrase, _packet, sha1).digest()]) + self.transport.write(_packet, _target_addr) + else: + if not self._config.get("TARGET_IP"): + logger.debug("(%s) Not sent packet as TARGET_IP not currently known", self._system) + else: + logger.error("(%s) OpenBridge system was asked to send non DMR packet with send_system(): %s", self._system, _packet) + elif self._config.get("MODE") == "PEER": + self.send_master(_packet, _hops, _ber, _rssi, _source_server, _source_rptr) + + def send_voice_packet( + self, pkt: bytes, _source_id: bytes, _dest_id: bytes, _slot: dict[str, Any] + ) -> None: + """Legacy sendVoicePacket: update STATUS for stream/slot then send_system(pkt).""" + _stream_id = pkt[16:20] + _pkt_time = time.time() + if _stream_id not in self.STATUS: + self.STATUS[_stream_id] = { + "START": _pkt_time, + "CONTENTION": False, + "RFS": _source_id, + "TGID": _dest_id, + "LAST": _pkt_time, + } + _slot["TX_TGID"] = _dest_id + else: + self.STATUS[_stream_id]["LAST"] = _pkt_time + _slot["TX_TIME"] = _pkt_time + self.send_system(pkt) + + def validate_id(self, _peer_id: bytes): + """Legacy validate_id (hblink.py lines 862-883). Returns True or callsign string or False.""" + if "ALLOW_UNREG_ID" not in self._config: + return True + if self._config.get("ALLOW_UNREG_ID"): + return True + _int_peer_id = int_id(_peer_id) + _int_peer_id = int(str(_int_peer_id)[:7]) + _subscriber_ids = self._CONFIG.get("_SUB_IDS", {}) + _peer_ids = self._CONFIG.get("_PEER_IDS", {}) + _local_subscriber_ids = self._CONFIG.get("_LOCAL_SUBSCRIBER_IDS", {}) + if _int_peer_id in _local_subscriber_ids: + return _local_subscriber_ids[_int_peer_id] + if _int_peer_id in _subscriber_ids: + return _subscriber_ids[_int_peer_id] + if _int_peer_id in _peer_ids: + return _peer_ids[_int_peer_id] + return False + + def datagramReceived(self, data: bytes, addr: tuple[str, int]) -> None: + if len(data) < 4: + return + mode = self._config.get("MODE") + if mode == "MASTER": + self._master_datagram_received(data, addr) + elif mode == "PEER": + self._peer_datagram_received(data, addr) + elif mode == "OPENBRIDGE": + self._obp_datagram_received(data, addr) + else: + logger.debug("(%s) UDP received %d bytes from %s", self._system, len(data), addr) + + def _master_maintenance_loop(self) -> None: + """Legacy master_maintenance_loop (hblink.py lines 731-755). Removes timed-out peers.""" + _global = self._CONFIG.get("GLOBAL", {}) + ping_time = _global.get("PING_TIME", 10) + max_missed = _global.get("MAX_MISSED", 3) + remove_list = deque() + for peer in self._peers: + _this_peer = self._peers[peer] + if _this_peer.get("LAST_PING", 0) + (ping_time * max_missed) < time.time(): + remove_list.append(peer) + for peer in remove_list: + logger.info( + "(%s) Peer %s (%s) has timed out and is being removed", + self._system, self._peers[peer].get("CALLSIGN", b""), self._peers[peer].get("RADIO_ID", b""), + ) + self.transport.write(b"".join([MSTCL, peer]), self._peers[peer]["SOCKADDR"]) + del self._CONFIG["SYSTEMS"][self._system]["PEERS"][peer] + if not self._CONFIG["SYSTEMS"][self._system]["PEERS"]: + sys_cfg = self._CONFIG["SYSTEMS"][self._system] + if "OPTIONS" in sys_cfg: + if "_default_options" in sys_cfg: + logger.info("(%s) Setting default Options: %s", self._system, sys_cfg["_default_options"]) + sys_cfg["OPTIONS"] = sys_cfg["_default_options"] + else: + del sys_cfg["OPTIONS"] + logger.info("(%s) Deleting HBP Options", self._system) + sys_cfg["_reset"] = True + + def _master_datagram_received(self, _data: bytes, _sockaddr: tuple[str, int]) -> None: + """Direct port of hblink.py master_datagramReceived (lines 888-1146).""" + _command = _data[:4] + _global = self._CONFIG.get("GLOBAL", {}) + + if _command == DMRD: + _peer_id = _data[11:15] + if _peer_id not in self._peers: + logger.info( + "(%s) DMRD ignored: peer %s not in peers (peers: %s)", + self._system, int_id(_peer_id), [int_id(p) for p in self._peers], + ) + elif self._peers[_peer_id]["CONNECTION"] != "YES": + logger.info( + "(%s) DMRD ignored: peer %s not CONNECTION=YES (state=%s)", + self._system, int_id(_peer_id), self._peers[_peer_id].get("CONNECTION", "?"), + ) + elif self._peers[_peer_id]["SOCKADDR"] != _sockaddr: + logger.info( + "(%s) DMRD ignored: peer %s SOCKADDR mismatch", + self._system, int_id(_peer_id), + ) + if _peer_id in self._peers and self._peers[_peer_id]["CONNECTION"] == "YES" and self._peers[_peer_id]["SOCKADDR"] == _sockaddr: + _seq = _data[4] + _rf_src = _data[5:8] + _dst_id = _data[8:11] + _bits = _data[15] + _slot = 2 if (_bits & 0x80) else 1 + if _bits & 0x40: + _call_type = "unit" + elif (_bits & 0x23) == 0x23: + _call_type = "vcsbk" + else: + _call_type = "group" + _frame_type = (_bits & 0x30) >> 4 + _dtype_vseq = _bits & 0xF + _stream_id = _data[16:20] + if not int_id(_stream_id): + logger.warning("(%s) CALL DROPPED AS STREAM ID IS NULL FROM SUBSCRIBER %s", self._system, int_id(_rf_src)) + return + pkt_time = time.time() + # ACL (legacy order and _laststrid) + if self._router and _global.get("USE_ACL"): + if not self._router.acl_check(_rf_src, _global.get("SUB_ACL", (True, []))): + if self._laststrid[_slot] != _stream_id: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY GLOBAL ACL", self._system, int_id(_stream_id), int_id(_rf_src)) + self._laststrid[_slot] = _stream_id + return + if _slot == 1 and not self._router.acl_check(_dst_id, _global.get("TG1_ACL", (True, []))): + if self._laststrid[_slot] != _stream_id: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS1 ACL", self._system, int_id(_stream_id), int_id(_dst_id)) + self._laststrid[_slot] = _stream_id + return + if _slot == 2 and not self._router.acl_check(_dst_id, _global.get("TG2_ACL", (True, []))): + if self._laststrid[_slot] != _stream_id: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS2 ACL", self._system, int_id(_stream_id), int_id(_dst_id)) + self._laststrid[_slot] = _stream_id + return + if self._router and self._config.get("USE_ACL"): + if not self._router.acl_check(_rf_src, self._config.get("SUB_ACL", (True, []))): + if self._laststrid[_slot] != _stream_id: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY SYSTEM ACL", self._system, int_id(_stream_id), int_id(_rf_src)) + self._laststrid[_slot] = _stream_id + return + if _slot == 1 and not self._router.acl_check(_dst_id, self._config.get("TG1_ACL", (True, []))): + if self._laststrid[_slot] != _stream_id: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY SYSTEM TS1 ACL", self._system, int_id(_stream_id), int_id(_dst_id)) + self._laststrid[_slot] = _stream_id + return + if _slot == 2 and not self._router.acl_check(_dst_id, self._config.get("TG2_ACL", (True, []))): + if self._laststrid[_slot] != _stream_id: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY SYSTEM TS2 ACL", self._system, int_id(_stream_id), int_id(_dst_id)) + self._laststrid[_slot] = _stream_id + return + # SUB_MAP update (legacy routerHBP.dmrd_received) + sub_map = self._CONFIG.get("_SUB_MAP") + if sub_map is not None: + sub_map[_rf_src] = (self._system, _slot, pkt_time) + # TG 4000: deactivate all dynamic bridges (legacy routerHBP) + _int_dst_id = int_id(_dst_id) + if _int_dst_id == 4000 and self._on_deactivate_dynamic_bridges: + self._on_deactivate_dynamic_bridges(self._system) + # TG 9991-9999: information services (legacy peer_datagramReceived, also needed in MASTER) + if 9991 <= _int_dst_id <= 9999 and self._on_play_file_request: + if _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VHEAD: + reactor.callInThread(self._on_play_file_request, self._system, str(_int_dst_id)) + if self._config.get("REPEAT", True): + pkt = [_data[:11], b"", _data[15:]] + for _peer in self._peers: + if _peer != _peer_id: + pkt[1] = _peer + self.transport.write(b"".join(pkt), self._peers[_peer]["SOCKADDR"]) + _voice = self._CONFIG.get("VOICE", {}) + if self._on_handle_recording and _voice.get("RECORDING_ENABLED") and int_id(_dst_id) == _voice.get("RECORDING_TG") and _slot == _voice.get("RECORDING_TIMESLOT", 2): + dmrpkt = _data[20:] if len(_data) > 20 else _data + self._on_handle_recording(dmrpkt, _frame_type, _dtype_vseq, _stream_id, pkt_time, _rf_src, _int_dst_id, _slot) + if _call_type == "group" and _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VHEAD: + logger.info( + "(%s) CALL RX peer %s src %s -> TG %s slot %s", + self._system, int_id(_peer_id), int_id(_rf_src), int_id(_dst_id), _slot, + ) + # Legacy bridge.py: stream state and LC for OBP LC rewrite (H_LC/T_LC/EMB_LC) + if _stream_id not in self.STATUS: + self.STATUS[_stream_id] = { + "START": pkt_time, + "CONTENTION": False, + "RFS": _rf_src, + "TGID": _dst_id, + "LAST": pkt_time, + "lastSeq": False, + "lastData": False, + } + dmrpkt = _data[20:53] if len(_data) >= 53 else b"" + if _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VHEAD and len(dmrpkt) >= 33: + try: + decoded = decode.voice_head_term(dmrpkt) + self.STATUS[_stream_id]["LC"] = decoded["LC"] + except Exception: + self.STATUS[_stream_id]["LC"] = LC_OPT + _dst_id + _rf_src + else: + self.STATUS[_stream_id]["LC"] = LC_OPT + _dst_id + _rf_src + # Legacy routerHBP: update slot STATUS RX_LC before calling dmrd_received + if _slot in self.STATUS: + if _stream_id != self.STATUS[_slot].get("RX_STREAM_ID"): + self.STATUS[_slot]["RX_START"] = pkt_time + if _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VHEAD and len(dmrpkt) >= 33: + try: + decoded_slot = decode.voice_head_term(dmrpkt) + self.STATUS[_slot]["RX_LC"] = decoded_slot["LC"] + except Exception: + self.STATUS[_slot]["RX_LC"] = LC_OPT + _dst_id + _rf_src + else: + self.STATUS[_slot]["RX_LC"] = LC_OPT + _dst_id + _rf_src + if self._dmrd_received: + self._dmrd_received( + self._system, _peer_id, _rf_src, _dst_id, _seq, _slot, + _call_type, _frame_type, _dtype_vseq, _stream_id, _data, + ) + if _slot in self.STATUS: + self.STATUS[_slot]["RX_PEER"] = _peer_id + self.STATUS[_slot]["RX_SEQ"] = _seq + self.STATUS[_slot]["RX_RFS"] = _rf_src + self.STATUS[_slot]["RX_TYPE"] = _dtype_vseq + self.STATUS[_slot]["RX_TGID"] = _dst_id + self.STATUS[_slot]["RX_TIME"] = pkt_time + self.STATUS[_slot]["RX_STREAM_ID"] = _stream_id + + elif _command == RPTL: + _peer_id = _data[4:8] + if len(self._peers) < self._config.get("MAX_PEERS", 1) or _peer_id in self._peers: + if _peer_id == b"\xff\xff\xff\xff" or ( + self._router + and self._router.acl_check(_peer_id, _global.get("REG_ACL", (True, []))) + and self._router.acl_check(_peer_id, self._config.get("REG_ACL", (True, []))) + and self.validate_id(_peer_id) + ): + self._peers[_peer_id] = { + "CONNECTION": "RPTL-RECEIVED", + "CONNECTED": time.time(), + "PINGS_RECEIVED": 0, + "LAST_PING": time.time(), + "SOCKADDR": _sockaddr, + "IP": _sockaddr[0], + "PORT": _sockaddr[1], + "SALT": randint(0, 0xFFFFFFFF), + "RADIO_ID": str(int_id(_peer_id)), + "CALLSIGN": "", + "RX_FREQ": "", + "TX_FREQ": "", + "TX_POWER": "", + "COLORCODE": "", + "LATITUDE": "", + "LONGITUDE": "", + "HEIGHT": "", + "LOCATION": "", + "DESCRIPTION": "", + "SLOTS": "", + "URL": "", + "SOFTWARE_ID": "", + "PACKAGE_ID": "", + } + if _peer_id == b"\xff\xff\xff\xff": + logger.info("(%s) Server Status Probe Logging in with Radio ID: %s, %s:%s", self._system, int_id(_peer_id), _sockaddr[0], _sockaddr[1]) + else: + logger.info("(%s) Repeater Logging in with Radio ID: %s, %s:%s", self._system, int_id(_peer_id), _sockaddr[0], _sockaddr[1]) + _salt_str = bytes_4(self._peers[_peer_id]["SALT"]) + self.send_peer(_peer_id, b"".join([RPTACK, _salt_str])) + self._peers[_peer_id]["CONNECTION"] = "CHALLENGE_SENT" + logger.info("(%s) Sent Challenge Response to %s for login: %s", self._system, int_id(_peer_id), self._peers[_peer_id]["SALT"]) + else: + self.transport.write(b"".join([MSTNAK, _peer_id]), _sockaddr) + logger.warning("(%s) Invalid Login from %s Radio ID: %s Denied by Registation ACL or not registered ID", self._system, _sockaddr[0], int_id(_peer_id)) + if self._CONFIG.get("SYSTEMS", {}).get(self._system): + self._CONFIG["SYSTEMS"][self._system]["_reset"] = True + else: + self.transport.write(b"".join([MSTNAK, _peer_id]), _sockaddr) + logger.warning("(%s) Registration denied from Radio ID: %s Maximum number of peers exceeded", self._system, int_id(_peer_id)) + + elif _command == RPTK: + _peer_id = _data[4:8] + if _peer_id in self._peers and self._peers[_peer_id]["CONNECTION"] == "CHALLENGE_SENT" and self._peers[_peer_id]["SOCKADDR"] == _sockaddr: + _this_peer = self._peers[_peer_id] + _this_peer["LAST_PING"] = time.time() + _sent_hash = _data[8:] + _salt_str = bytes_4(_this_peer["SALT"]) + _radio_id_int = int_id(_peer_id) + _individual_password = self._get_user_password(_radio_id_int) + _passphrase = _get_passphrase_bytes(self._config) + if _individual_password is not None: + _calc_hash_val = _calc_hash(_salt_str, _individual_password) + if _sent_hash == _calc_hash_val: + _this_peer["CONNECTION"] = "WAITING_CONFIG" + self.send_peer(_peer_id, b"".join([RPTACK, _peer_id])) + logger.info("(%s) Peer %s has completed the login exchange successfully (individual password)", self._system, _this_peer["RADIO_ID"]) + else: + logger.warning("(%s) Peer %s has FAILED the login exchange (wrong individual password)", self._system, _this_peer["RADIO_ID"]) + self.transport.write(b"".join([MSTNAK, _peer_id]), _sockaddr) + del self._peers[_peer_id] + elif len(_passphrase) > 0: + _calc_hash_val = _calc_hash(_salt_str, _passphrase) + if _sent_hash == _calc_hash_val: + _this_peer["CONNECTION"] = "WAITING_CONFIG" + self.send_peer(_peer_id, b"".join([RPTACK, _peer_id])) + logger.info("(%s) Peer %s has completed the login exchange successfully (global passphrase)", self._system, _this_peer["RADIO_ID"]) + else: + logger.warning("(%s) Peer %s has FAILED the login exchange (wrong global passphrase)", self._system, _this_peer["RADIO_ID"]) + self.transport.write(b"".join([MSTNAK, _peer_id]), _sockaddr) + del self._peers[_peer_id] + else: + logger.warning("(%s) Peer %s has FAILED - no individual password configured and no global passphrase", self._system, _this_peer["RADIO_ID"]) + self.transport.write(b"".join([MSTNAK, _peer_id]), _sockaddr) + del self._peers[_peer_id] + else: + self.transport.write(b"".join([MSTNAK, _peer_id]), _sockaddr) + logger.info("(%s) Login challenge from Radio ID that has not logged in: %s", self._system, int_id(_peer_id)) + + elif _command == RPTC: + if _data[:5] == RPTCL: + _peer_id = _data[5:9] + if _peer_id in self._peers and self._peers[_peer_id]["CONNECTION"] == "YES" and self._peers[_peer_id]["SOCKADDR"] == _sockaddr: + logger.info("(%s) Peer is closing down: %s (%s)", self._system, self._peers[_peer_id]["CALLSIGN"], int_id(_peer_id)) + self.transport.write(b"".join([MSTNAK, _peer_id]), _sockaddr) + del self._peers[_peer_id] + sys_cfg = self._CONFIG.get("SYSTEMS", {}).get(self._system, {}) + if "OPTIONS" in sys_cfg: + if "_default_options" in sys_cfg: + sys_cfg["OPTIONS"] = sys_cfg["_default_options"] + logger.info("(%s) Setting default Options: %s", self._system, sys_cfg["_default_options"]) + else: + logger.info("(%s) Deleting HBP Options", self._system) + del sys_cfg["OPTIONS"] + sys_cfg["_reset"] = True + else: + _peer_id = _data[4:8] + if _peer_id in self._peers and self._peers[_peer_id]["CONNECTION"] == "WAITING_CONFIG" and self._peers[_peer_id]["SOCKADDR"] == _sockaddr: + _this_peer = self._peers[_peer_id] + _this_peer["CONNECTION"] = "YES" + _this_peer["CONNECTED"] = time.time() + _this_peer["LAST_PING"] = time.time() + _this_peer["CALLSIGN"] = _data[8:16] + _this_peer["RX_FREQ"] = _data[16:25] + _this_peer["TX_FREQ"] = _data[25:34] + _this_peer["TX_POWER"] = _data[34:36] + _this_peer["COLORCODE"] = _data[36:38] + _this_peer["LATITUDE"] = _data[38:46] + _this_peer["LONGITUDE"] = _data[46:55] + _this_peer["HEIGHT"] = _data[55:58] + _this_peer["LOCATION"] = _data[58:78] + _this_peer["DESCRIPTION"] = _data[78:97] + _this_peer["SLOTS"] = _data[97:98] + _this_peer["URL"] = _data[98:222] + _this_peer["SOFTWARE_ID"] = _data[222:262] + _this_peer["PACKAGE_ID"] = _data[262:302] + if ("ALLOW_UNREG_ID" in self._config and not self._config["ALLOW_UNREG_ID"]) and _this_peer["CALLSIGN"].decode("utf8", errors="replace").rstrip() != self.validate_id(_peer_id): + del self._peers[_peer_id] + self.transport.write(b"".join([MSTNAK, _peer_id]), _sockaddr) + self._CONFIG.setdefault("SYSTEMS", {}).setdefault(self._system, {})["_reset"] = True + logger.info("(%s) Callsign does not match subscriber database: ID: %s, Sent Call: %s, DB call %s", self._system, int_id(_peer_id), _this_peer["CALLSIGN"].decode("utf8", errors="replace").rstrip(), self.validate_id(_peer_id)) + else: + self.send_peer(_peer_id, b"".join([RPTACK, _peer_id])) + logger.info("(%s) Peer %s (%s) has sent repeater configuration, Package ID: %s, Software ID: %s, Desc: %s", self._system, _this_peer["CALLSIGN"], _this_peer["RADIO_ID"], self._peers[_peer_id]["PACKAGE_ID"].decode("utf8", errors="replace").rstrip(), self._peers[_peer_id]["SOFTWARE_ID"].decode("utf8", errors="replace").rstrip(), self._peers[_peer_id]["DESCRIPTION"].decode("utf8", errors="replace").rstrip()) + else: + self.transport.write(b"".join([MSTNAK, _peer_id]), _sockaddr) + logger.info("(%s) Peer info from Radio ID that has not logged in: %s", self._system, int_id(_peer_id)) + + elif _command == RPTO: + _peer_id = _data[4:8] + if _peer_id in self._peers and self._peers[_peer_id]["SOCKADDR"] == _sockaddr: + _this_peer = self._peers[_peer_id] + _this_peer["OPTIONS"] = _data[8:] + self.send_peer(_peer_id, b"".join([RPTACK, _peer_id])) + logger.info("(%s) Peer %s has sent options %s", self._system, _this_peer["CALLSIGN"], _this_peer["OPTIONS"]) + self._CONFIG.setdefault("SYSTEMS", {}).setdefault(self._system, {})["OPTIONS"] = _this_peer["OPTIONS"].decode("utf8", errors="replace") + if self._on_options_received: + try: + self._on_options_received(self._system) + except Exception: + pass + else: + self.transport.write(b"".join([MSTNAK, _peer_id]), _sockaddr) + logger.info("(%s) Options from Radio ID that is not logged: %s", self._system, int_id(_peer_id)) + + elif _command == RPTP: + _peer_id = _data[7:11] + if _peer_id in self._peers and self._peers[_peer_id]["CONNECTION"] == "YES" and self._peers[_peer_id]["SOCKADDR"] == _sockaddr: + self._peers[_peer_id]["PINGS_RECEIVED"] += 1 + self._peers[_peer_id]["LAST_PING"] = time.time() + self.send_peer(_peer_id, b"".join([MSTPONG, _peer_id])) + logger.log(logging.TRACE if hasattr(logging, "TRACE") else logging.DEBUG, "(%s) Received and answered RPTPING from peer %s (%s)", self._system, self._peers[_peer_id]["CALLSIGN"], int_id(_peer_id)) + else: + self.transport.write(b"".join([MSTNAK, _peer_id]), _sockaddr) + logger.info("(%s) Ping from Radio ID that is not logged in: %s", self._system, int_id(_peer_id)) + + elif _command == DMRA: + logger.debug("(%s) Peer has sent Talker Alias packet %s", self._system, _data) + + elif _command == PRIN: + logger.info("(%s) *ProxyInfo* Connection from IP:Port: %s", self._system, _data.decode("utf8", errors="replace")[4:]) + + else: + logger.error("(%s) Unrecognized command. Raw HBP PDU: %s", self._system, _data) + + def _peer_update_sockaddr(self, ip: str) -> None: + """Legacy updateSockaddr: update MASTER_IP and MASTER_SOCKADDR after DNS resolve.""" + self._config["MASTER_IP"] = ip + self._config["MASTER_SOCKADDR"] = (ip, self._config["MASTER_PORT"]) + logger.info("(%s) hostname resolution performed: %s", self._system, ip) + + def _peer_update_sockaddr_errback(self, failure) -> None: + logger.info("(%s) hostname resolution error: %s", self._system, failure) + + def _peer_maintenance_loop(self) -> None: + """Legacy peer_maintenance_loop (hblink.py lines 757-780).""" + _global = self._CONFIG.get("GLOBAL", {}) + max_missed = _global.get("MAX_MISSED", 3) + if self._stats.get("PING_OUTSTANDING"): + self._stats["NUM_OUTSTANDING"] = self._stats.get("NUM_OUTSTANDING", 0) + 1 + if self._stats.get("CONNECTION") != "YES" or self._stats.get("NUM_OUTSTANDING", 0) >= max_missed: + self._stats["PINGS_SENT"] = 0 + self._stats["PINGS_ACKD"] = 0 + self._stats["NUM_OUTSTANDING"] = 0 + self._stats["PING_OUTSTANDING"] = False + self._stats["CONNECTION"] = "RPTL_SENT" + if self._stats.get("DNS_TIME", 0) < (time.time() - 600): + self._stats["DNS_TIME"] = time.time() + _master_ip = self._config.get("_MASTER_IP", "") + if _master_ip: + d = reactor.resolve(_master_ip) + d.addCallback(self._peer_update_sockaddr) + d.addErrback(self._peer_update_sockaddr_errback) + self.send_master(b"".join([RPTL, self._config["RADIO_ID"]])) + logger.info("(%s) Sending login request to master %s:%s", self._system, self._config.get("MASTER_IP"), self._config.get("MASTER_PORT")) + if self._stats.get("CONNECTION") == "YES": + self.send_master(b"".join([RPTPING, self._config["RADIO_ID"]])) + self._stats["PINGS_SENT"] = self._stats.get("PINGS_SENT", 0) + 1 + self._stats["PING_OUTSTANDING"] = True + + def _peer_datagram_received(self, _data: bytes, _sockaddr: tuple[str, int]) -> None: + """Direct port of hblink.py peer_datagramReceived (lines 1149-1313).""" + if self._config.get("MASTER_SOCKADDR") != _sockaddr: + return + _command = _data[:4] + if _command == DMRD: + _peer_id = _data[11:15] + if self._config.get("LOOSE") or _peer_id == self._config.get("RADIO_ID"): + _seq = _data[4] + _rf_src = _data[5:8] + _dst_id = _data[8:11] + _bits = _data[15] + _slot = 2 if (_bits & 0x80) else 1 + if _bits & 0x40: + _call_type = "unit" + elif (_bits & 0x23) == 0x23: + _call_type = "vcsbk" + else: + _call_type = "group" + _frame_type = (_bits & 0x30) >> 4 + _dtype_vseq = _bits & 0xF + _stream_id = _data[16:20] + if not int_id(_stream_id): + logger.warning("(%s) CALL DROPPED AS STREAM ID IS NULL FROM SUBSCRIBER %s", self._system, int_id(_rf_src)) + return + pkt_time = time.time() + _global = self._CONFIG.get("GLOBAL", {}) + if self._router and _global.get("USE_ACL"): + if not self._router.acl_check(_rf_src, _global.get("SUB_ACL", (True, []))): + if self._laststrid[_slot] != _stream_id: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY GLOBAL ACL", self._system, int_id(_stream_id), int_id(_rf_src)) + self._laststrid[_slot] = _stream_id + return + if _slot == 1 and not self._router.acl_check(_dst_id, _global.get("TG1_ACL", (True, []))): + if self._laststrid[_slot] != _stream_id: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS1 ACL", self._system, int_id(_stream_id), int_id(_dst_id)) + self._laststrid[_slot] = _stream_id + return + if _slot == 2 and not self._router.acl_check(_dst_id, _global.get("TG2_ACL", (True, []))): + if self._laststrid[_slot] != _stream_id: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS2 ACL", self._system, int_id(_stream_id), int_id(_dst_id)) + self._laststrid[_slot] = _stream_id + return + if self._router and self._config.get("USE_ACL"): + if not self._router.acl_check(_rf_src, self._config.get("SUB_ACL", (True, []))): + if self._laststrid[_slot] != _stream_id: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY SYSTEM ACL", self._system, int_id(_stream_id), int_id(_rf_src)) + self._laststrid[_slot] = _stream_id + return + if _slot == 1 and not self._router.acl_check(_dst_id, self._config.get("TG1_ACL", (True, []))): + if self._laststrid[_slot] != _stream_id: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY SYSTEM TS1 ACL", self._system, int_id(_stream_id), int_id(_dst_id)) + self._laststrid[_slot] = _stream_id + return + if _slot == 2 and not self._router.acl_check(_dst_id, self._config.get("TG2_ACL", (True, []))): + if self._laststrid[_slot] != _stream_id: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY SYSTEM TS2 ACL", self._system, int_id(_stream_id), int_id(_dst_id)) + self._laststrid[_slot] = _stream_id + return + # SUB_MAP update (legacy routerHBP.dmrd_received) + sub_map = self._CONFIG.get("_SUB_MAP") + if sub_map is not None: + sub_map[_rf_src] = (self._system, _slot, pkt_time) + _int_dst_id = int_id(_dst_id) + # TG 4000: deactivate all dynamic bridges (legacy routerHBP) + if _int_dst_id == 4000 and self._on_deactivate_dynamic_bridges: + self._on_deactivate_dynamic_bridges(self._system) + if self._on_play_file_request and _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VHEAD: + if 9991 <= _int_dst_id <= 9999: + reactor.callInThread(self._on_play_file_request, str(_int_dst_id), self._system) + if _call_type == "group" and _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VHEAD: + logger.info( + "(%s) CALL RX (from master) src %s -> TG %s slot %s", + self._system, int_id(_rf_src), int_id(_dst_id), _slot, + ) + if self._dmrd_received: + self._dmrd_received(self._system, _peer_id, _rf_src, _dst_id, _seq, _slot, _call_type, _frame_type, _dtype_vseq, _stream_id, _data) + # Legacy in-band signalling on voice terminator (bridge.py 807-866): run before updating STATUS + if ( + _frame_type == HBPF_DATA_SYNC + and _dtype_vseq == HBPF_SLT_VTERM + and self.STATUS + and self.STATUS.get(_slot, {}).get("RX_TYPE") != HBPF_SLT_VTERM + and self._on_in_band_signalling + ): + self._on_in_band_signalling(self._system, _slot, _dst_id, pkt_time) + if self.STATUS: + if _stream_id != self.STATUS[_slot]["RX_STREAM_ID"]: + self.STATUS[_slot]["RX_START"] = pkt_time + self.STATUS[_slot]["RX_PEER"] = _peer_id + self.STATUS[_slot]["RX_SEQ"] = _seq + self.STATUS[_slot]["RX_RFS"] = _rf_src + self.STATUS[_slot]["RX_TYPE"] = _dtype_vseq + self.STATUS[_slot]["RX_TGID"] = _dst_id + self.STATUS[_slot]["RX_TIME"] = pkt_time + self.STATUS[_slot]["RX_STREAM_ID"] = _stream_id + + elif _command == MSTN: + _peer_id = _data[5:9] if len(_data) >= 9 else _data[4:8] + 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" + self._stats["CONNECTED"] = time.time() + + elif _command == RPTA: + if self._stats.get("CONNECTION") == "RPTL_SENT": + _login_int32 = _data[5:9] if len(_data) >= 9 else _data[4:8] + 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()) + 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] + 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([ + self._config["RADIO_ID"], + self._config.get("CALLSIGN", b""), + self._config.get("RX_FREQ", b""), + self._config.get("TX_FREQ", b""), + self._config.get("TX_POWER", b""), + self._config.get("COLORCODE", b""), + self._config.get("LATITUDE", b""), + self._config.get("LONGITUDE", b""), + self._config.get("HEIGHT", b""), + self._config.get("LOCATION", b""), + self._config.get("DESCRIPTION", b""), + self._config.get("SLOTS", b""), + self._config.get("URL", b""), + self._config.get("SOFTWARE_ID", b""), + self._config.get("PACKAGE_ID", b""), + ]) + self.send_master(b"".join([RPTC, _config_packet])) + self._stats["CONNECTION"] = "CONFIG-SENT" + logger.info("(%s) Repeater Configuration Sent", self._system) + else: + 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] + 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"): + self.send_master(b"".join([RPTO, self._config["RADIO_ID"], self._config["OPTIONS"]])) + self._stats["CONNECTION"] = "OPTIONS-SENT" + logger.info("(%s) Sent options: (%s)", self._system, self._config["OPTIONS"]) + else: + self._stats["CONNECTION"] = "YES" + self._stats["CONNECTED"] = time.time() + logger.info("(%s) Connection to Master Completed", self._system) + else: + 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] + 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" + self._stats["CONNECTED"] = time.time() + logger.info("(%s) Connection to Master Completed with options", self._system) + else: + self._stats["CONNECTION"] = "NO" + logger.error("(%s) Master ACK Contained wrong ID - Connection Reset", self._system) + + elif _command == MSTP: + _peer_id = _data[7:11] + if self._config.get("LOOSE") or _peer_id == self._config.get("RADIO_ID"): + self._stats["PING_OUTSTANDING"] = False + self._stats["NUM_OUTSTANDING"] = 0 + self._stats["PINGS_ACKD"] = self._stats.get("PINGS_ACKD", 0) + 1 + + elif _command == MSTC: + _peer_id = _data[5:9] + if self._config.get("LOOSE") or _peer_id == self._config.get("RADIO_ID"): + self._stats["CONNECTION"] = "NO" + logger.info("(%s) MSTCL Recieved", self._system) + + else: + logger.error("(%s) Received an invalid command in packet: %s", self._system, _data.hex() if hasattr(_data, "hex") else _data) + + def _obp_send_bcka(self) -> None: + """Legacy send_bcka: BCKA + HMAC-SHA1 to TARGET. Uses TARGET_SOCK (IP only; hostnames resolved at startup or on first peer packet).""" + _addr = self._config.get("TARGET_SOCK") + if _addr and _addr[0]: + _packet = BCKA + hmac_new(self._config["PASSPHRASE"], BCKA, sha1).digest() + self.transport.write(_packet, _addr) + else: + logger.debug("(%s) *BridgeControl* not sending KeepAlive, TARGET not currently known", self._system) + + def _obp_send_bcve(self) -> None: + """Legacy send_bcve: BCVE + VER byte + HMAC-SHA1. Uses TARGET_SOCK (IP only).""" + _addr = self._config.get("TARGET_SOCK") + if self._config.get("ENHANCED_OBP") and _addr and _addr[0]: + _packet = BCVE + VER.to_bytes(1, "big") + _packet = _packet + hmac_new(self._config["PASSPHRASE"], _packet[4:5], sha1).digest() + self.transport.write(_packet, _addr) + else: + logger.debug("(%s) *BridgeControl* not sending BCVE, TARGET not currently known", self._system) + + def _obp_send_bcsq(self, _tgid: bytes, _stream_id: bytes) -> None: + """Legacy send_bcsq: BCSQ + tgid + stream_id + HMAC-SHA1. Uses TARGET_SOCK (IP only).""" + _addr = self._config.get("TARGET_SOCK") + if _addr and _addr[0]: + _packet = BCSQ + _tgid + _stream_id + _packet = _packet + hmac_new(self._config["PASSPHRASE"], _packet, sha1).digest() + self.transport.write(_packet, _addr) + + def _obp_stream_accept( + self, + _stream_id: bytes, + _dst_id: bytes, + _seq: int, + _data: bytes, + _frame_type: int, + _dtype_vseq: int, + _dmrpkt: bytes, + _rf_src: bytes = b"\x00\x00\x00", + _peer_id: bytes = b"\x00\x00\x00\x00", + ) -> bool: + """Legacy bridge_master OBP: stream state, 1ST, _fin, duplicate/seq handling. Returns True if packet should be forwarded to bridge.""" + now = time.time() + # Trim old streams (legacy: 180s) + if _stream_id not in self._obp_streams: + to_remove = [sid for sid, st in self._obp_streams.items() if st.get("LAST", 0) < now - 180] + for sid in to_remove: + self._obp_streams.pop(sid, None) + if _stream_id not in self._obp_streams: + self._obp_streams[_stream_id] = { + "1ST": time.perf_counter(), + "TGID": _dst_id, + "START": now, + "LAST": now, + "lastSeq": False, + "lastData": False, + "packets": 0, + "_fin": False, + "RFS": _rf_src, + "RX_PEER": _peer_id, + } + return True + st = self._obp_streams[_stream_id] + if st.get("_fin"): + return False + st["LAST"] = now + st["packets"] = st.get("packets", 0) + 1 + # Duplicate / out-of-order (legacy bridge_master 3256-3184) + if st.get("lastData") and st["lastData"] == _data and _seq > 1: + logger.debug( + "(%s) *PacketControl* last packet is a complete duplicate, discarding. Stream ID: %s TGID: %s", + self._system, int_id(_stream_id), int_id(_dst_id), + ) + return False + if _seq is not None and _seq == st.get("lastSeq"): + logger.debug( + "(%s) *PacketControl* Duplicate sequence number %s, discarding. Stream ID: %s TGID: %s", + self._system, _seq, int_id(_stream_id), int_id(_dst_id), + ) + return False + if _seq is not None and st.get("lastSeq") is not None and _seq != 1 and _seq < st["lastSeq"]: + logger.debug( + "(%s) *PacketControl* Out of order packet - last SEQ: %s, this SEQ: %s, discarding. Stream ID: %s TGID: %s", + self._system, st["lastSeq"], _seq, int_id(_stream_id), int_id(_dst_id), + ) + return False + if _seq is not None and st.get("lastSeq") is not None and _seq > st["lastSeq"] + 1: + logger.debug( + "(%s) *PacketControl* Missed packet(s) - last SEQ: %s, this SEQ: %s. Stream ID: %s TGID: %s", + self._system, st["lastSeq"], _seq, int_id(_stream_id), int_id(_dst_id), + ) + st["lastSeq"] = _seq + st["lastData"] = _data + if _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VTERM: + st["_fin"] = True + return True + + def _obp_datagram_received(self, _packet: bytes, _sockaddr: tuple[str, int]) -> None: + """Port of hblink.py OPENBRIDGE.datagramReceived: DMRD v1 (53+HMAC), BCKA, BCVE.""" + if _packet[:3] == DMR and _packet[:4] == DMRD and len(_packet) >= 73: + _data = _packet[:53] + _stream_id = _data[16:20] + if self._config.get("VER", 5) > 1: + if _stream_id not in self._laststrid: + logger.warning("(%s) *ProtoControl* Version 1 protocol prohibited by PROTO_VER, Ver: %s", self._system, self._config.get("VER")) + self._laststrid.append(_stream_id) + self._obp_send_bcve() + return + _hash = _packet[53:73] + _ckhs = hmac_new(self._config["PASSPHRASE"], _data, sha1).digest() + if compare_digest(_hash, _ckhs) and (_sockaddr == self._config.get("TARGET_SOCK") or self._config.get("RELAX_CHECKS")): + _peer_id = _data[11:15] + if self._config.get("NETWORK_ID") != _peer_id: + if _stream_id not in self._laststrid: + logger.error("(%s) OpenBridge packet discarded because NETWORK_ID: %s Does not match sent Peer ID: %s", self._system, int_id(self._config.get("NETWORK_ID", b"")), int_id(_peer_id)) + self._laststrid.append(_stream_id) + return + _seq = _data[4] + _rf_src = _data[5:8] + _dst_id = _data[8:11] + _bits = _data[15] + _slot = 2 if (_bits & 0x80) else 1 + if _bits & 0x40: + _call_type = "unit" + elif (_bits & 0x23) == 0x23: + _call_type = "vcsbk" + else: + _call_type = "group" + _frame_type = (_bits & 0x30) >> 4 + _dtype_vseq = _bits & 0xF + if _slot != 1: + logger.error("(%s) OpenBridge packet discarded because it was not received on slot 1. SID: %s, TGID %s", self._system, int_id(_rf_src), int_id(_dst_id)) + return + if "STUN" in self._CONFIG: + if _stream_id not in self._laststrid: + logger.warning("(%s) Bridge STUNned, discarding", self._system) + self._laststrid.append(_stream_id) + return + _int_dst_id = int_id(_dst_id) + if _call_type != "unit": + if _int_dst_id <= 79 or (_int_dst_id >= 9990 and _int_dst_id <= 9999) or (_int_dst_id >= 92 and _int_dst_id <= 199) or _int_dst_id == 900999: + if _stream_id not in self._laststrid: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY GLOBAL TG FILTER", self._system, int_id(_stream_id), _int_dst_id) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + _global = self._CONFIG.get("GLOBAL", {}) + if self._router and _global.get("USE_ACL"): + if not self._router.acl_check(_rf_src, _global.get("SUB_ACL", (True, []))): + if _stream_id not in self._laststrid: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS1 ACL", self._system, int_id(_stream_id), int_id(_rf_src)) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + if _slot == 1 and not self._router.acl_check(_dst_id, _global.get("TG1_ACL", (True, []))): + if _stream_id not in self._laststrid: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS1 ACL", self._system, int_id(_stream_id), int_id(_dst_id)) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + if self._router and self._config.get("USE_ACL"): + if not self._router.acl_check(_rf_src, self._config.get("SUB_ACL", (True, []))): + if _stream_id not in self._laststrid: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY SYSTEM ACL", self._system, int_id(_stream_id), int_id(_rf_src)) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + if not self._router.acl_check(_dst_id, self._config.get("TG1_ACL", (True, []))): + if _stream_id not in self._laststrid: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY SYSTEM ACL", self._system, int_id(_stream_id), int_id(_dst_id)) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + if _call_type == "group" and _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VHEAD: + logger.info( + "(%s) CALL RX (OBP) src %s -> TG %s slot %s", + self._system, int_id(_rf_src), int_id(_dst_id), _slot, + ) + # Legacy bridge.py routerOBP: stream state with LC (STATUS[stream_id]) + dmrpkt_obp = _data[20:53] if len(_data) >= 53 else b"" + if _call_type == "group" or _call_type == "vcsbk": + if not self._obp_stream_accept(_stream_id, _dst_id, _seq, _data, _frame_type, _dtype_vseq, dmrpkt_obp, _rf_src, _peer_id): + self._config["_bcka"] = time.time() + return + # Legacy routerOBP: STATUS[stream_id] with LC (bridge.py 259-278) + if _stream_id not in self.STATUS: + self.STATUS[_stream_id] = { + "START": time.time(), + "CONTENTION": False, + "RFS": _rf_src, + "TGID": _dst_id, + "LAST": time.time(), + "lastSeq": False, + "lastData": False, + } + if _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VHEAD and len(dmrpkt_obp) >= 33: + try: + decoded_obp = decode.voice_head_term(dmrpkt_obp) + self.STATUS[_stream_id]["LC"] = decoded_obp["LC"] + except Exception: + self.STATUS[_stream_id]["LC"] = LC_OPT + _dst_id + _rf_src + else: + self.STATUS[_stream_id]["LC"] = LC_OPT + _dst_id + _rf_src + self.STATUS[_stream_id]["LAST"] = time.time() + # Also update slot STATUS for contention/RX tracking + if _slot in self.STATUS: + self.STATUS[_slot]["RX_STREAM_ID"] = _stream_id + self.STATUS[_slot]["RX_TGID"] = _dst_id + self.STATUS[_slot]["RX_RFS"] = _rf_src + self.STATUS[_slot]["RX_TIME"] = time.time() + if self._dmrd_received: + self._dmrd_received(self._system, _peer_id, _rf_src, _dst_id, _seq, _slot, _call_type, _frame_type, _dtype_vseq, _stream_id, _data) + self._config["_bcka"] = time.time() + else: + logger.warning("(%s) OpenBridge HMAC failed, packet discarded - OPCODE: %s SRC: %s", self._system, _packet[:4], _sockaddr) + elif _packet[:4] == DMRE: + # Legacy hblink.py OPENBRIDGE: DMRE (v5) incoming – 89-byte or 85-byte format, BLAKE2b + if len(_packet) < 69: + return + _data = _packet[:53] + _embedded_version = _packet[55] + if _embedded_version > 4: + if len(_packet) < 89: + return + _timestamp = _packet[56:64] + _source_server = _packet[64:68] + _source_rptr = _packet[68:72] + _hops = _packet[72] + _hash = _packet[73:89] + _hash_len = 73 + else: + if len(_packet) < 85: + return + _timestamp = _packet[56:64] + _source_server = _packet[64:68] + _source_rptr = b"\x00\x00\x00\x00" + _hops = _packet[68] + _hash = _packet[69:85] + _hash_len = 69 + self._config["VER"] = _embedded_version + _passphrase = _get_passphrase_bytes(self._config) + _h = blake2b(key=_passphrase, digest_size=16) + _h.update(_packet[:_hash_len]) + _ckhs = _h.digest() + _stream_id = _data[16:20] + if not (compare_digest(_hash, _ckhs) and (_sockaddr == self._config.get("TARGET_SOCK") or self._config.get("RELAX_CHECKS"))): + logger.warning("(%s) OpenBridge DMRE BLAKE2b failed, packet discarded - SRC: %s", self._system, _sockaddr) + return + _peer_id = _data[11:15] + if self._config.get("NETWORK_ID") != _peer_id: + if _stream_id not in self._laststrid: + logger.error("(%s) OpenBridge DMRE discarded because NETWORK_ID: %s Does not match sent Peer ID: %s", self._system, int_id(self._config.get("NETWORK_ID", b"")), int_id(_peer_id)) + self._laststrid.append(_stream_id) + return + _seq = _data[4] + _rf_src = _data[5:8] + _dst_id = _data[8:11] + _int_dst_id = int_id(_dst_id) + _bits = _data[15] + _slot = 2 if (_bits & 0x80) else 1 + if _bits & 0x40: + _call_type = "unit" + elif (_bits & 0x23) == 0x23: + _call_type = "vcsbk" + else: + _call_type = "group" + _frame_type = (_bits & 0x30) >> 4 + _dtype_vseq = _bits & 0xF + if "STUN" in self._CONFIG: + if _stream_id not in self._laststrid: + logger.warning("(%s) Bridge STUNned, discarding", self._system) + self._laststrid.append(_stream_id) + return + _ts_sec = int.from_bytes(_timestamp, "big") / 1_000_000_000 + if _ts_sec < (time.time() - 5): + if _stream_id not in self._laststrid: + logger.warning("(%s) Packet from server %s more than 5s old!, discarding", self._system, int.from_bytes(_source_server, "big")) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + _src_srv_int = int.from_bytes(_source_server, "big") + _src_srv_str = str(_src_srv_int) + _src_srv_len = len(_src_srv_str) + if _src_srv_len < 4 or _src_srv_len > 7: + if _stream_id not in self._laststrid: + logger.warning("(%s) Source Server should be between 4 and 7 digits, discarding Src: %s", self._system, _src_srv_int) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + _global = self._CONFIG.get("GLOBAL", {}) + _server_ids = self._CONFIG.get("_SERVER_IDS", set()) + if _global.get("VALIDATE_SERVER_IDS") and _src_srv_len in (4, 5) and (_src_srv_str[:4] not in _server_ids): + if _stream_id not in self._laststrid: + logger.warning("(%s) Source Server ID is 4 or 5 digits but not in list: %s", self._system, _src_srv_int) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + if _src_srv_len > 5 and not self.validate_id(_source_server): + if _stream_id not in self._laststrid: + logger.warning("(%s) Source Server 6 or 7 digits but not a valid DMR ID, discarding Src: %s", self._system, _src_srv_int) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + _inthops = (_hops if isinstance(_hops, int) else int.from_bytes(_hops, "big")) + 1 + if _inthops > 10: + logger.warning("(%s) MAX HOPS exceed, dropping. Hops: %s, DST: %s, SRC: %s", self._system, _inthops, _int_dst_id, _src_srv_int) + self._obp_send_bcsq(_dst_id, _stream_id) + return + if _call_type != "unit": + if _int_dst_id <= 79: + if _stream_id not in self._laststrid: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s BY GLOBAL TG FILTER (local to repeater)", self._system, int_id(_stream_id), _int_dst_id) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + if (_int_dst_id >= 9990 and _int_dst_id <= 9999) or _int_dst_id == 900999: + if _stream_id not in self._laststrid: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s BY GLOBAL TG FILTER (local to server)", self._system, int_id(_stream_id), _int_dst_id) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + _sid = _global.get("SERVER_ID", 0) + _our_srv = int(str(_sid)[:4]) if isinstance(_sid, int) else int(str(int.from_bytes(_sid, "big"))[:4]) + if (_int_dst_id >= 92 and _int_dst_id <= 199) and int(_src_srv_str[:4]) != _our_srv: + if _stream_id not in self._laststrid: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s BY GLOBAL TG FILTER (local to server main ID)", self._system, int_id(_stream_id), _int_dst_id) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + if ((_int_dst_id >= 80 and _int_dst_id <= 89) or (_int_dst_id >= 800 and _int_dst_id <= 899)) and int(_src_srv_str[:3]) != int(str(_our_srv)[:3]): + if _stream_id not in self._laststrid: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s BY GLOBAL TG FILTER (local to MCC)", self._system, int_id(_stream_id), _int_dst_id) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + if _global.get("USE_ACL") and self._router: + if not self._router.acl_check(_rf_src, _global.get("SUB_ACL", (True, []))): + if _stream_id not in self._laststrid: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS1 ACL", self._system, int_id(_stream_id), int_id(_rf_src)) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + if _slot == 1 and not self._router.acl_check(_dst_id, _global.get("TG1_ACL", (True, []))): + if _stream_id not in self._laststrid: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS1 ACL", self._system, int_id(_stream_id), int_id(_dst_id)) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + if self._config.get("USE_ACL") and self._router: + if not self._router.acl_check(_rf_src, self._config.get("SUB_ACL", (True, []))): + if _stream_id not in self._laststrid: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY SYSTEM ACL", self._system, int_id(_stream_id), int_id(_rf_src)) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + if not self._router.acl_check(_dst_id, self._config.get("TG1_ACL", (True, []))): + if _stream_id not in self._laststrid: + logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY SYSTEM ACL", self._system, int_id(_stream_id), int_id(_dst_id)) + self._obp_send_bcsq(_dst_id, _stream_id) + self._laststrid.append(_stream_id) + return + if _call_type == "group" and _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VHEAD: + logger.info("(%s) CALL RX (OBP DMRE) src %s -> TG %s slot %s", self._system, int_id(_rf_src), int_id(_dst_id), _slot) + dmrpkt_obp = _data[20:53] if len(_data) >= 53 else b"" + if _call_type == "group" or _call_type == "vcsbk": + if not self._obp_stream_accept(_stream_id, _dst_id, _seq, _data, _frame_type, _dtype_vseq, dmrpkt_obp, _rf_src, _peer_id): + self._config["_bcka"] = time.time() + return + # Legacy routerOBP: STATUS[stream_id] with LC (same as DMRD v1 path) + if _stream_id not in self.STATUS: + self.STATUS[_stream_id] = { + "START": time.time(), + "CONTENTION": False, + "RFS": _rf_src, + "TGID": _dst_id, + "LAST": time.time(), + "lastSeq": False, + "lastData": False, + } + if _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VHEAD and len(dmrpkt_obp) >= 33: + try: + decoded_obp = decode.voice_head_term(dmrpkt_obp) + self.STATUS[_stream_id]["LC"] = decoded_obp["LC"] + except Exception: + self.STATUS[_stream_id]["LC"] = LC_OPT + _dst_id + _rf_src + else: + self.STATUS[_stream_id]["LC"] = LC_OPT + _dst_id + _rf_src + self.STATUS[_stream_id]["LAST"] = time.time() + if _slot in self.STATUS: + self.STATUS[_slot]["RX_STREAM_ID"] = _stream_id + self.STATUS[_slot]["RX_TGID"] = _dst_id + self.STATUS[_slot]["RX_RFS"] = _rf_src + self.STATUS[_slot]["RX_TIME"] = time.time() + _data_dmrd = DMRD + _data[4:] + if self._dmrd_received: + self._dmrd_received(self._system, _peer_id, _rf_src, _dst_id, _seq, _slot, _call_type, _frame_type, _dtype_vseq, _stream_id, _data_dmrd) + self._config["_bcka"] = time.time() + elif _packet[:4] == EOBP: + logger.warning("(%s) *ProtoControl* KF7EEL EOBP protocol not supported", self._system) + elif self._config.get("ENHANCED_OBP") and _packet[:2] == BC: + if _packet[:4] == BCKA and len(_packet) >= 24: + _hash = _packet[4:24] + _ckhs = hmac_new(self._config["PASSPHRASE"], _packet[:4], sha1).digest() + if compare_digest(_hash, _ckhs): + self._config["_bcka"] = time.time() + if _sockaddr != self._config.get("TARGET_SOCK"): + logger.info("(%s) *BridgeControl* Source IP and Port has changed for OBP from %s:%s to %s:%s, updating", self._system, self._config.get("TARGET_IP"), self._config.get("TARGET_PORT"), _sockaddr[0], _sockaddr[1]) + self._config["TARGET_IP"] = _sockaddr[0] + self._config["TARGET_PORT"] = _sockaddr[1] + self._config["TARGET_SOCK"] = _sockaddr + self._config.pop("_no_target_log_time", None) # reset so next "no target" logs once + else: + logger.info("(%s) *BridgeControl* BCKA invalid KeepAlive, packet discarded", self._system) + if _packet[:4] == BCVE and len(_packet) >= 25: + _ver = int.from_bytes(_packet[4:5], "big") + _hash = _packet[5:25] + _ckhs = hmac_new(self._config["PASSPHRASE"], _packet[4:5], sha1).digest() + if compare_digest(_hash, _ckhs): + if _ver in (2, 3) or _ver > 5: + logger.info("(%s) *ProtoControl* BCVE Version not supported, Ver: %s", self._system, _ver) + elif _ver > self._config.get("VER", 5): + logger.info("(%s) *ProtoControl* BCVE Version upgrade, Ver: %s", self._system, _ver) + self._config["VER"] = _ver + elif _ver == self._config.get("VER", 5): + pass + else: + logger.warning("(%s) *ProtoControl* BCVE Version downgrade not allowed, Ver: %s", self._system, _ver) + else: + logger.warning("(%s) *ProtoControl* BCVE invalid, packet discarded", self._system) + + +def HBPProtocolFactory( + system_name: str, + config: dict[str, Any], + report_factory: Any = None, + router: Any = None, + dmrd_received: Callable[..., None] | None = None, + get_user_password_callback: Callable[[int], bytes | None] | None = None, + on_play_file_request: Callable[[str, str], None] | None = None, + on_handle_recording: Callable[..., None] | None = None, + on_in_band_signalling: Callable[[str, int, bytes, float], None] | None = None, + on_options_received: Callable[[str], None] | None = None, + on_deactivate_dynamic_bridges: Callable[[str], None] | None = None, +) -> HBPProtocol: + """Create HBP protocol instance (legacy: one HBSYSTEM per system).""" + return HBPProtocol( + system_name, + config, + report_factory, + router, + dmrd_received, + get_user_password_callback=get_user_password_callback, + on_play_file_request=on_play_file_request, + on_handle_recording=on_handle_recording, + on_in_band_signalling=on_in_band_signalling, + on_options_received=on_options_received, + on_deactivate_dynamic_bridges=on_deactivate_dynamic_bridges, + ) diff --git a/src/adn_server/infrastructure/voice/__init__.py b/src/adn_server/infrastructure/voice/__init__.py new file mode 100644 index 0000000..6bff2df --- /dev/null +++ b/src/adn_server/infrastructure/voice/__init__.py @@ -0,0 +1,8 @@ +# ADN DMR Peer Server - voice infrastructure +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +from .ambe_reader import DefaultVoiceProvider, ReadAMBE, StubVoiceProvider +from .pkt_gen import pkt_gen + +__all__ = ["DefaultVoiceProvider", "ReadAMBE", "StubVoiceProvider", "pkt_gen"] diff --git a/src/adn_server/infrastructure/voice/ambe_reader.py b/src/adn_server/infrastructure/voice/ambe_reader.py new file mode 100644 index 0000000..7349d99 --- /dev/null +++ b/src/adn_server/infrastructure/voice/ambe_reader.py @@ -0,0 +1,184 @@ +# ADN DMR Peer Server - AMBE reader and default voice provider +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink (read_ambe.py, mk_voice). GPLv3. + +"""AMBE word loading (readAMBE) and VoiceProvider that uses it + pkt_gen.""" + +from __future__ import annotations + +import glob +import logging +import os +from itertools import islice +from pathlib import Path +from typing import Any, Iterator + +from bitarray import bitarray + +from ...application.ports import VoiceProvider +from .pkt_gen import pkt_gen as _pkt_gen +from .voice_map import VOICE_MAP + +logger = logging.getLogger(__name__) + +_AMBE_LENGTH = 9 + +# Silence burst pair (legacy default when no .ambe available) +SILENCE_PAIR = [ + bitarray("101011000000101010100000010000000000001000000000000000000000010001000000010000000000100000000000100000000000"), + bitarray("001010110000001010101000000100000000000010000000000000000000000100010000000100000000001000000000001000000000"), +] + + +def _make_bursts(data: bitarray): + """Yield 108-bit bursts from bitarray (legacy _make_bursts).""" + it = iter(data) + n = len(data) + for i in range(0, n, 108): + chunk = bitarray([k for k in islice(it, 108)]) + if len(chunk) < 108: + chunk.extend([False] * (108 - len(chunk))) + yield chunk + + +class ReadAMBE: + """Legacy readAMBE: load AMBE words by language from path (dir of .ambe or .indx+.ambe).""" + + def __init__(self, lang: str, path: str | Path) -> None: + self.langcsv = lang + self.langs = [s.strip() for s in lang.split(",") if s.strip()] + self.path = Path(path) if not isinstance(path, Path) else path + + def readfiles(self) -> dict[str, dict[str, list[list[bitarray]]]]: + """Load words per language. Returns {lang: {voice_name: [[b0,b1], ...]}}.""" + result: dict[str, dict[str, list[list[bitarray]]]] = {} + for _lang in self.langs: + _prefix = self.path / _lang + _wordBADict: dict[str, list[list[bitarray]]] = {} + if _prefix.is_dir(): + for ambe_path in glob.glob(str(_prefix / "*.ambe")): + basename = os.path.basename(ambe_path) + voice_name, _ = basename.split(".", 1) + try: + with open(ambe_path, "rb") as f: + _wordBitarray = bitarray(endian="big") + _wordBitarray.frombytes(f.read()) + except OSError: + continue + _wordBADict[voice_name] = self._pairs_from_bitarray(_wordBitarray) + _wordBADict.setdefault("silence", [SILENCE_PAIR]) + result[_lang] = _wordBADict + else: + index_path = Path(str(_prefix) + ".indx") + ambe_path = Path(str(_prefix) + ".ambe") + if not index_path.is_file() or not ambe_path.is_file(): + result[_lang] = {"silence": [SILENCE_PAIR]} + continue + indexDict: dict[str, list[int]] = {} + with open(index_path, "r", encoding="utf-8") as index: + for line in index: + parts = line.split() + if len(parts) >= 3: + voice_name, start, length = parts[0], int(parts[1]), int(parts[2]) + indexDict[voice_name] = [start * _AMBE_LENGTH, length * _AMBE_LENGTH] + try: + with open(ambe_path, "rb") as ambe: + for voice_name, (start, length) in indexDict.items(): + ambe.seek(start) + _wordBitarray = bitarray(endian="big") + _wordBitarray.frombytes(ambe.read(length)) + _wordBADict[voice_name] = self._pairs_from_bitarray(_wordBitarray) + except OSError: + result[_lang] = {"silence": [SILENCE_PAIR]} + continue + _wordBADict.setdefault("silence", [SILENCE_PAIR]) + result[_lang] = _wordBADict + return result + + def _pairs_from_bitarray(self, _wordBitarray: bitarray) -> list[list[bitarray]]: + """Convert bitarray to list of [burst0, burst1] pairs (108 bits each).""" + _wordBA: list[list[bitarray]] = [] + pairs = 1 + _lastburst: bitarray | None = None + for _burst in _make_bursts(_wordBitarray): + if pairs == 2 and _lastburst is not None: + _wordBA.append([_lastburst, _burst]) + _lastburst = None + pairs = 1 + else: + pairs = 2 + _lastburst = _burst + return _wordBA + + def readSingleFile(self, filename: str) -> list[list[bitarray]]: + """Read one .ambe file; return list of [b0, b1] pairs (legacy readSingleFile).""" + full = self.path / filename if not filename.startswith("/") else Path(filename) + if not full.is_file(): + full = self.path / filename + if not full.is_file(): + return [] + try: + with open(full, "rb") as ambe: + _wordBitarray = bitarray(endian="big") + _wordBitarray.frombytes(ambe.read()) + except OSError: + return [] + return self._pairs_from_bitarray(_wordBitarray) + + +class DefaultVoiceProvider(VoiceProvider): + """Voice provider using ReadAMBE and pkt_gen (legacy readAMBE + mk_voice.pkt_gen).""" + + def __init__(self) -> None: + self._words: dict[str, dict[str, Any]] = {} + + def get_ambe_words(self, languages: str, audio_path: str) -> dict[str, dict[str, Any]]: + """Load AMBE words (readAMBE.readfiles). Apply i18n voiceMap per lang. Cached per (languages, audio_path).""" + key = f"{languages}:{audio_path}" + if key not in self._words: + reader = ReadAMBE(languages, audio_path) + result = reader.readfiles() + for lang, words in result.items(): + _map = VOICE_MAP.get(lang, {}) + for mapword, mapped in _map.items(): + if mapped in words: + words[mapword] = words[mapped] + logger.info("(AMBE) for language %s, read %s words into voice dict", lang, len(words) - 1) + self._words[key] = result + return self._words[key] + + def read_single_file(self, audio_path: str, lang: str, file_number: str) -> list: + """Read one .ambe file (e.g. {lang}/ondemand/{file_number}.ambe). Legacy readSingleFile.""" + rel = os.path.join(lang, "ondemand", f"{file_number}.ambe") + reader = ReadAMBE(lang, audio_path) + return reader.readSingleFile(rel) + + def pkt_gen( + self, rf_src: bytes, dst_id: bytes, peer: bytes, slot: int, phrase: list[Any] + ) -> Iterator[bytes]: + """Generate HBP voice packets for phrase. Legacy mk_voice.pkt_gen.""" + return _pkt_gen(rf_src, dst_id, peer, slot, phrase) + + def ensure_tts_ambe(self, text: str, lang: str, out_path: str, config: dict[str, Any]) -> str | None: + """Return out_path if .ambe file exists (cached). Full TTS conversion is in tts_engine.ensure_tts_ambe.""" + if out_path and os.path.isfile(out_path): + return out_path + return None + + +class StubVoiceProvider(VoiceProvider): + """Stub: get_ambe_words returns empty; pkt_gen returns empty iterator; read_single_file returns [].""" + + def get_ambe_words(self, languages: str, audio_path: str) -> dict[str, dict[str, Any]]: + return {} + + def pkt_gen( + self, rf_src: bytes, dst_id: bytes, peer: bytes, slot: int, phrase: list[Any] + ) -> Iterator[bytes]: + return iter([]) + + def ensure_tts_ambe(self, text: str, lang: str, out_path: str, config: dict[str, Any]) -> str | None: + return None + + def read_single_file(self, audio_path: str, lang: str, file_number: str) -> list: + return [] diff --git a/src/adn_server/infrastructure/voice/pkt_gen.py b/src/adn_server/infrastructure/voice/pkt_gen.py new file mode 100644 index 0000000..74c15d8 --- /dev/null +++ b/src/adn_server/infrastructure/voice/pkt_gen.py @@ -0,0 +1,96 @@ +# ADN DMR Peer Server - voice packet generator (legacy mk_voice.pkt_gen) +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink (mk_voice.py). GPLv3. + +"""Generate HBP DMRD voice packets for a phrase. Legacy mk_voice.pkt_gen.""" + +from __future__ import annotations + +from random import randint +from typing import Any, Iterator + +from bitarray import bitarray +from dmr_utils3 import bptc +from dmr_utils3.const import EMB, BS_DATA_SYNC, BS_VOICE_SYNC, LC_OPT, SLOT_TYPE +from dmr_utils3.utils import bytes_4 + +# Precalculated DMRD byte 15 (slot << 7 | this) +HEADBITS = 0b00100001 +BURSTBITS = [0b00010000, 0b00000001, 0b00000010, 0b00000011, 0b00000100, 0b00000101] +TERMBITS = 0b00100010 + +NULL_EMB_LC = bitarray(endian="big") +NULL_EMB_LC.frombytes(b"\x00\x00\x00\x00") + +TAIL = b"\x00\x00" + + +def pkt_gen( + rf_src: bytes, + dst_id: bytes, + peer: bytes, + slot: int, + phrase: list[Any], +) -> Iterator[bytes]: + """Generate DMRD voice packets for phrase. Each word in phrase is list of [b0, b1] burst pairs (bitarray).""" + stream_id = bytes_4(randint(0x00, 0xFFFFFFFF)) + sdp = rf_src + dst_id + peer + lc = LC_OPT + dst_id + rf_src + + head_lc = bptc.encode_header_lc(lc) + head_lc = [head_lc[:98], head_lc[-98:]] + + term_lc = bptc.encode_terminator_lc(lc) + term_lc = [term_lc[:98], term_lc[-98:]] + + emb_lc = bptc.encode_emblc(lc) + embed = [ + BS_VOICE_SYNC, + EMB["BURST_B"][:8] + emb_lc[1] + EMB["BURST_B"][-8:], + EMB["BURST_C"][:8] + emb_lc[2] + EMB["BURST_C"][-8:], + EMB["BURST_D"][:8] + emb_lc[3] + EMB["BURST_D"][-8:], + EMB["BURST_E"][:8] + emb_lc[4] + EMB["BURST_E"][-8:], + EMB["BURST_F"][:8] + NULL_EMB_LC + EMB["BURST_F"][-8:], + ] + + seq = 0 + slot_byte = (slot << 7) & 0xFF + + for _ in range(3): + pkt = ( + b"DMRD" + + bytes([seq]) + + sdp + + bytes([slot_byte | HEADBITS]) + + stream_id + + (head_lc[0] + SLOT_TYPE["VOICE_LC_HEAD"][:10] + BS_DATA_SYNC + SLOT_TYPE["VOICE_LC_HEAD"][-10:] + head_lc[1]).tobytes() + + TAIL + ) + seq = (seq + 1) % 0x100 + yield pkt + + for word in phrase: + for burst in range(len(word)): + b0, b1 = word[burst][0], word[burst][1] + pkt = ( + b"DMRD" + + bytes([seq]) + + sdp + + bytes([slot_byte | BURSTBITS[burst % 6]]) + + stream_id + + (b0 + embed[burst % 6] + b1).tobytes() + + TAIL + ) + seq = (seq + 1) % 0x100 + yield pkt + + pkt = ( + b"DMRD" + + bytes([seq]) + + sdp + + bytes([slot_byte | TERMBITS]) + + stream_id + + (term_lc[0] + SLOT_TYPE["VOICE_LC_TERM"][:10] + BS_DATA_SYNC + SLOT_TYPE["VOICE_LC_TERM"][-10:] + term_lc[1]).tobytes() + + TAIL + ) + yield pkt diff --git a/src/adn_server/infrastructure/voice/recording.py b/src/adn_server/infrastructure/voice/recording.py new file mode 100644 index 0000000..a723fc9 --- /dev/null +++ b/src/adn_server/infrastructure/voice/recording.py @@ -0,0 +1,112 @@ +# ADN DMR Peer Server - voice recording (legacy _handleRecording, _saveRecording) +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""Record DMR voice to AMBE file when RECORDING_ENABLED and packet matches RECORDING_TG / RECORDING_TIMESLOT.""" + +from __future__ import annotations + +import logging +import os +import time +from typing import Any + +from bitarray import bitarray + +logger = logging.getLogger(__name__) + +RECORDING_MAX_FRAMES = 2750 + + +class RecordingHandler: + """Legacy _handleRecording + _saveRecording: accumulate voice bursts, save to Audio/{lang}/ondemand/{file}.ambe.""" + + def __init__(self, config: dict[str, Any], project_root: str) -> None: + self._config = config + self._project_root = project_root + self._active = False + self._stream_id: bytes | None = None + self._bursts = bitarray(endian="big") + self._start_time = 0.0 + self._frames = 0 + self._rf_src: bytes | None = None + + def handle_recording( + self, + dmrpkt: bytes, + frame_type: int, + dtype_vseq: int, + stream_id: bytes, + pkt_time: float, + rf_src: bytes, + int_dst_id: int, + slot: int, + ) -> None: + """Legacy _handleRecording: accumulate voice or start/end stream.""" + g = self._config.get("VOICE", {}) + if not g.get("RECORDING_ENABLED"): + return + if int_dst_id != g.get("RECORDING_TG", 0) or slot != g.get("RECORDING_TIMESLOT", 2): + return + if frame_type == 2 and dtype_vseq == 1: # HBPF_DATA_SYNC, HBPF_SLT_VHEAD + if self._active and self._stream_id != stream_id: + logger.info("(RECORDING) New transmission detected, saving previous recording (%d frames)", self._frames) + self._save_recording() + self._active = True + self._stream_id = stream_id + self._bursts = bitarray(endian="big") + self._start_time = pkt_time + self._frames = 0 + self._rf_src = rf_src + logger.info("(RECORDING) Recording started - SUB: %s, TG: %s, TS: %s", int.from_bytes(rf_src, "big") if rf_src else 0, int_dst_id, slot) + return + if not self._active or self._stream_id != stream_id: + return + if frame_type in (0, 1): # HBPF_VOICE, HBPF_VOICE_SYNC + _bits_data = bitarray(endian="big") + _bits_data.frombytes(dmrpkt) + if len(_bits_data) >= 264: + self._bursts.extend(_bits_data[:108]) + self._bursts.extend(_bits_data[156:264]) + self._frames += 1 + if self._frames >= RECORDING_MAX_FRAMES: + logger.info("(RECORDING) Max duration reached (%d frames), saving", self._frames) + self._save_recording() + return + if frame_type == 2 and dtype_vseq == 2: # HBPF_SLT_VTERM + self._save_recording() + return + + def _save_recording(self) -> None: + """Legacy _saveRecording: write bursts to Audio/{lang}/ondemand/{file}.ambe.""" + if not self._active or self._frames == 0: + self._active = False + self._stream_id = None + logger.warning("(RECORDING) No frames recorded, discarding") + return + g = self._config.get("VOICE", {}) + lang = g.get("RECORDING_LANGUAGE", "en_GB") + file_name = g.get("RECORDING_FILE", "recording") + audio_path = os.path.join(self._project_root, g.get("AUDIO_PATH", "Audio")) + out_dir = os.path.join(audio_path, lang, "ondemand") + os.makedirs(out_dir, exist_ok=True) + path = os.path.join(out_dir, file_name + ".ambe") + try: + with open(path, "wb") as f: + f.write(self._bursts.tobytes()) + except OSError as e: + logger.warning("(RECORDING) Could not save: %s", e) + self._active = False + self._stream_id = None + self._bursts = bitarray(endian="big") + self._frames = 0 + self._rf_src = None + return + duration = time.time() - self._start_time + _id = int.from_bytes(self._rf_src, "big") if self._rf_src else 0 + logger.info("(RECORDING) Recording saved: %s (%d frames, %.1f seconds, SUB: %s)", path, self._frames, duration, _id) + self._active = False + self._stream_id = None + self._bursts = bitarray(endian="big") + self._frames = 0 + self._rf_src = None diff --git a/src/adn_server/infrastructure/voice/tts_ambe.py b/src/adn_server/infrastructure/voice/tts_ambe.py new file mode 100644 index 0000000..8bdd7c3 --- /dev/null +++ b/src/adn_server/infrastructure/voice/tts_ambe.py @@ -0,0 +1,17 @@ +# ADN DMR Peer Server - TTS to AMBE stub +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +"""TTS to AMBE file (legacy tts_engine.ensure_tts_ambe). Stub until ported.""" + +from __future__ import annotations + +from typing import Any + + +def ensure_tts_ambe(text: str, lang: str, out_path: str, config: dict[str, Any]) -> str | None: + """ + Generate TTS audio and convert to AMBE file; return output path or None. + Legacy: tts_engine.ensure_tts_ambe. Full implementation to be ported when needed. + """ + return None diff --git a/src/adn_server/infrastructure/voice/tts_engine.py b/src/adn_server/infrastructure/voice/tts_engine.py new file mode 100644 index 0000000..8350c53 --- /dev/null +++ b/src/adn_server/infrastructure/voice/tts_engine.py @@ -0,0 +1,323 @@ +# ADN DMR Peer Server - TTS engine (legacy tts_engine.py) +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server tts_engine. GPLv3. + +""" +TTS Engine: convert .txt to .ambe for DMR. +Pipeline: .txt -> gTTS -> .mp3 -> ffmpeg -> .wav (8kHz mono 16-bit) -> vocoder/AMBEServer -> .ambe +""" + +from __future__ import annotations + +import logging +import os +import socket +import struct +import subprocess +import wave +from typing import Any + +logger = logging.getLogger(__name__) + +_LANG_MAP = { + "es_ES": "es", "en_GB": "en", "en_US": "en", "fr_FR": "fr", + "de_DE": "de", "it_IT": "it", "pt_PT": "pt", "pt_BR": "pt", + "pl_PL": "pl", "nl_NL": "nl", "da_DK": "da", "sv_SE": "sv", + "no_NO": "no", "el_GR": "el", "th_TH": "th", "cy_GB": "cy", + "ca_ES": "ca", "gl_ES": "gl", "eu_ES": "eu", +} + +DV3K_START_BYTE = 0x61 +DV3K_TYPE_CONTROL = 0x00 +DV3K_TYPE_AMBE = 0x01 +DV3K_TYPE_AUDIO = 0x02 +DV3K_AMBE_FIELD_ID = 0x01 +DV3K_AUDIO_FIELD_ID = 0x00 +DV3K_SAMPLES_PER_FRAME = 160 +DV3K_RATET_DMR = bytes([0x61, 0x00, 0x02, 0x00, 0x09, 0x21]) +DV3K_PRODID_REQ = bytes([0x61, 0x00, 0x01, 0x00, 0x30]) + + +def _get_tts_lang(announcement_language: str) -> str: + if announcement_language in _LANG_MAP: + return _LANG_MAP[announcement_language] + return announcement_language[:2] if len(announcement_language) >= 2 else announcement_language + + +def _generate_tts_audio(text: str, lang: str, mp3_path: str) -> bool: + try: + from gtts import gTTS + except ImportError: + logger.error("(TTS) gTTS not installed. Run: pip install gTTS") + return False + try: + tts = gTTS(text=text, lang=lang, slow=False) + tts.save(mp3_path) + logger.info("(TTS) TTS audio generated: %s", mp3_path) + return True + except Exception as e: + logger.error("(TTS) Error generating TTS audio: %s", e) + return False + + +def _convert_to_wav(mp3_path: str, wav_path: str, volume_db: int = 0, speed: float = 1.0) -> bool: + speed = max(0.5, min(2.0, speed)) + _filters: list[str] = [] + if speed != 1.0: + _filters.append('atempo={:.2f}'.format(speed)) + logger.info('(TTS) Aplicando velocidad: x%.2f', speed) + if volume_db != 0: + _filters.append('volume={}dB'.format(volume_db)) + logger.info("(TTS) Applying volume adjustment: %ddB", volume_db) + cmd = ["ffmpeg", "-y", "-i", mp3_path, "-ar", "8000", "-ac", "1", "-sample_fmt", "s16"] + if _filters: + cmd += ["-af", ",".join(_filters)] + cmd += ["-f", "wav", wav_path] + try: + result = subprocess.run(cmd, capture_output=True, timeout=60) + if result.returncode != 0: + logger.error("(TTS) ffmpeg error: %s", (result.stderr or b"").decode("utf-8", errors="ignore")[:500]) + return False + logger.info("(TTS) Audio converted to 8kHz mono WAV: %s", wav_path) + return True + except FileNotFoundError: + logger.error("(TTS) ffmpeg not found. Install ffmpeg on the system") + return False + except subprocess.TimeoutExpired: + logger.error("(TTS) ffmpeg conversion timeout") + return False + except Exception as e: + logger.error("(TTS) Audio conversion error: %s", e) + return False + + +def _encode_ambe_vocoder(wav_path: str, ambe_path: str, vocoder_cmd: str) -> bool: + if not vocoder_cmd: + return False + cmd = vocoder_cmd.replace("{wav}", wav_path).replace("{ambe}", ambe_path) + try: + result = subprocess.run(cmd, shell=True, capture_output=True, timeout=120) + if result.returncode != 0: + logger.error("(TTS) Vocoder error: %s", (result.stderr or b"").decode("utf-8", errors="ignore")[:500]) + return False + if not os.path.isfile(ambe_path): + logger.error("(TTS) Vocoder did not produce AMBE file: %s", ambe_path) + return False + logger.info("(TTS) Audio encoded to AMBE via external vocoder: %s", ambe_path) + return True + except subprocess.TimeoutExpired: + logger.error("(TTS) AMBE encoding timeout") + return False + except Exception as e: + logger.error("(TTS) Error running vocoder: %s", e) + return False + + +def _build_audio_packet(pcm_samples: list[int]) -> bytes: + payload = struct.pack("BB", DV3K_AUDIO_FIELD_ID, len(pcm_samples)) + for sample in pcm_samples: + payload += struct.pack(">h", sample) + header = bytes([DV3K_START_BYTE]) + struct.pack(">HB", len(payload), DV3K_TYPE_AUDIO) + return header + payload + + +def _parse_ambe_response(data: bytes) -> bytes | None: + if len(data) < 4 or data[0] != DV3K_START_BYTE: + return None + _payload_len = struct.unpack(">H", data[1:3])[0] + _pkt_type = data[3] + if _pkt_type == DV3K_TYPE_AMBE and len(data) > 5: + _field_id = data[4] + if _field_id == DV3K_AMBE_FIELD_ID: + _num_bits = data[5] + _num_bytes = (_num_bits + 7) // 8 + return data[6 : 6 + _num_bytes] + return None + + +def _encode_ambe_ambeserver(wav_path: str, ambe_path: str, host: str, port: int) -> bool: + host = host.strip().strip('"').strip("'") + logger.info("(TTS-AMBESERVER) Connecting to AMBEServer %s:%d", host, port) + try: + resolved = socket.gethostbyname(host) + if resolved != host: + logger.info("(TTS-AMBESERVER) Host %s resolved to %s", host, resolved) + host = resolved + except socket.gaierror as e: + logger.error('(TTS-AMBESERVER) Cannot resolve host "%s": %s', host, e) + return False + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(5.0) + except Exception as e: + logger.error("(TTS-AMBESERVER) Error creating UDP socket: %s", e) + return False + try: + sock.sendto(DV3K_PRODID_REQ, (host, port)) + data, _ = sock.recvfrom(1024) + if data[0] != DV3K_START_BYTE: + logger.error("(TTS-AMBESERVER) Invalid response from AMBEServer") + sock.close() + return False + logger.info("(TTS-AMBESERVER) AMBEServer connected") + except socket.timeout: + logger.error("(TTS-AMBESERVER) Timeout connecting to AMBEServer %s:%d", host, port) + sock.close() + return False + except Exception as e: + logger.error("(TTS-AMBESERVER) Connection error: %s", e) + sock.close() + return False + try: + sock.sendto(DV3K_RATET_DMR, (host, port)) + data, _ = sock.recvfrom(1024) + if data[0] != DV3K_START_BYTE: + logger.error("(TTS-AMBESERVER) Error setting RATET DMR") + sock.close() + return False + logger.info("(TTS-AMBESERVER) RATET DMR configured") + except (socket.timeout, Exception): + sock.close() + return False + try: + wf = wave.open(wav_path, "rb") + except Exception as e: + logger.error("(TTS-AMBESERVER) Error opening WAV: %s", e) + sock.close() + return False + if wf.getsampwidth() != 2 or wf.getnchannels() != 1: + logger.error("(TTS-AMBESERVER) WAV must be mono 16-bit PCM") + wf.close() + sock.close() + return False + _total_frames = wf.getnframes() + _sample_rate = wf.getframerate() + logger.info("(TTS-AMBESERVER) WAV: %d samples, %d Hz", _total_frames, _sample_rate) + _raw_frames = wf.readframes(_total_frames) + wf.close() + _samples = list(struct.unpack("<" + "h" * _total_frames, _raw_frames)) + _ambe_frames: list[bytes] = [] + for i in range(0, len(_samples), DV3K_SAMPLES_PER_FRAME): + _chunk = _samples[i : i + DV3K_SAMPLES_PER_FRAME] + if len(_chunk) < DV3K_SAMPLES_PER_FRAME: + _chunk = _chunk + [0] * (DV3K_SAMPLES_PER_FRAME - len(_chunk)) + _audio_pkt = _build_audio_packet(_chunk) + try: + sock.sendto(_audio_pkt, (host, port)) + data, _ = sock.recvfrom(1024) + _ambe_data = _parse_ambe_response(data) + if _ambe_data is not None: + _ambe_frames.append(_ambe_data) + except (socket.timeout, Exception): + pass + sock.close() + if not _ambe_frames: + logger.error("(TTS-AMBESERVER) No AMBE frames received") + return False + try: + with open(ambe_path, "wb") as f: + for frame in _ambe_frames: + f.write(frame) + except Exception as e: + logger.error("(TTS-AMBESERVER) Error writing AMBE: %s", e) + return False + logger.info("(TTS-AMBESERVER) Encoding completed: %s", ambe_path) + return True + + +def _cleanup(files: list[str]) -> None: + for f in files: + try: + if os.path.isfile(f): + os.remove(f) + except Exception: + pass + + +def text_to_ambe( + txt_path: str, + ambe_path: str, + language: str, + vocoder_cmd: str, + ambeserver_host: str = "", + ambeserver_port: int = 2460, + volume_db: int = 0, + speed: float = 1.0, +) -> bool: + """Convert .txt to .ambe (gTTS -> mp3 -> ffmpeg -> wav -> vocoder/AMBEServer).""" + if not os.path.isfile(txt_path): + logger.warning("(TTS) Text file not found: %s", txt_path) + return False + if os.path.isfile(ambe_path): + if os.path.getmtime(ambe_path) > os.path.getmtime(txt_path): + logger.info("(TTS) Using cached AMBE (newer than .txt): %s", ambe_path) + return True + with open(txt_path, "r", encoding="utf-8") as f: + text = f.read().strip() + if not text: + logger.warning("(TTS) Text file is empty: %s", txt_path) + return False + logger.info("(TTS) Converting text to AMBE: %s (%d chars, language: %s)", txt_path, len(text), language) + _dir = os.path.dirname(ambe_path) + if _dir: + os.makedirs(_dir, exist_ok=True) + _base = os.path.splitext(ambe_path)[0] + _mp3_path = _base + ".mp3" + _wav_path = _base + ".wav" + _tts_lang = _get_tts_lang(language) + if not _generate_tts_audio(text, _tts_lang, _mp3_path): + return False + if not _convert_to_wav(_mp3_path, _wav_path, volume_db, speed): + _cleanup([_mp3_path]) + return False + _encoded = False + if ambeserver_host: + logger.info("(TTS) Using AMBEServer %s:%d", ambeserver_host, ambeserver_port) + _encoded = _encode_ambe_ambeserver(_wav_path, ambe_path, ambeserver_host, ambeserver_port) + if not _encoded: + logger.warning("(TTS) AMBEServer failed, trying external vocoder...") + if not _encoded and vocoder_cmd: + logger.info("(TTS) Using external vocoder") + _encoded = _encode_ambe_vocoder(_wav_path, ambe_path, vocoder_cmd) + if not _encoded: + logger.warning("(TTS) Could not encode to AMBE. Configure TTS_AMBESERVER_HOST or TTS_VOCODER_CMD.") + _cleanup([_mp3_path, _wav_path]) + return False + _cleanup([_mp3_path, _wav_path]) + logger.info("(TTS) Conversion completed: %s -> %s", txt_path, ambe_path) + return True + + +def ensure_tts_ambe(config: dict[str, Any], tts_num: int, audio_path: str) -> str | None: + """Ensure .ambe exists for TTS_ANNOUNCEMENT{tts_num}; create from .txt if needed. Returns path or None.""" + g = config.get("VOICE", {}) + _prefix = "TTS_ANNOUNCEMENT{}".format(tts_num) + if not g.get("{}_ENABLED".format(_prefix), False): + return None + _file = g.get("{}_FILE".format(_prefix), "") + _lang = g.get("{}_LANGUAGE".format(_prefix), "en_GB") + if not _file: + return None + _txt_path = os.path.join(audio_path, _lang, "ondemand", _file + ".txt") + _ambe_path = os.path.join(audio_path, _lang, "ondemand", _file + ".ambe") + if os.path.isfile(_ambe_path): + if not os.path.isfile(_txt_path): + logger.info("(TTS-%d) Using existing AMBE file (no .txt): %s", tts_num, _ambe_path) + return _ambe_path + if os.path.getmtime(_ambe_path) > os.path.getmtime(_txt_path): + logger.debug("(TTS-%d) Using cached AMBE: %s", tts_num, _ambe_path) + return _ambe_path + if not os.path.isfile(_txt_path): + logger.warning("(TTS-%d) Text file not found: %s", tts_num, _txt_path) + return None + _vocoder_cmd = g.get("TTS_VOCODER_CMD", "") + _ambeserver_host = g.get("TTS_AMBESERVER_HOST", "").strip() + _ambeserver_port = int(g.get("TTS_AMBESERVER_PORT", 2460)) + _volume_db = int(g.get("TTS_VOLUME", -3)) + _speed = float(g.get("TTS_SPEED", 1.0)) + if text_to_ambe(_txt_path, _ambe_path, _lang, _vocoder_cmd, _ambeserver_host, _ambeserver_port, _volume_db, _speed): + return _ambe_path + if os.path.isfile(_ambe_path): + logger.warning("(TTS-%d) Using previous AMBE (conversion failed): %s", tts_num, _ambe_path) + return _ambe_path + return None diff --git a/src/adn_server/infrastructure/voice/voice_map.py b/src/adn_server/infrastructure/voice/voice_map.py new file mode 100644 index 0000000..0dbe6cd --- /dev/null +++ b/src/adn_server/infrastructure/voice/voice_map.py @@ -0,0 +1,81 @@ +# ADN DMR Peer Server - i18n voice key mapping +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server i8n_voice_map.py (Simon Adlem, G7RZU). GPLv3. + +"""Map logical keys (e.g. 'A') to AMBE file names (e.g. 'alpha') per language.""" + +VOICE_MAP: dict[str, dict[str, str]] = { + "en_GB": { + "A": "alpha", "B": "bravo", "C": "charlie", "D": "delta", "E": "echo", + "F": "foxtrot", "G": "golf", "H": "hotel", "I": "india", "J": "juliet", + "K": "kilo", "L": "lima", "M": "mike", "N": "november", "O": "oscar", + "P": "papa", "Q": "quebec", "R": "romeo", "S": "sierra", "T": "tango", + "U": "uniform", "V": "victor", "W": "whiskey", "X": "x-ray", "Y": "yankee", + "Z": "zulu", "to": "silence", "notlinked": "not-linked", "linkedto": "linked-to", + }, + "en_GB_2": { + "A": "alpha", "B": "bravo", "C": "charlie", "D": "delta", "E": "echo", + "F": "foxtrot", "G": "golf", "H": "hotel", "I": "india", "J": "juliet", + "K": "kilo", "L": "lima", "M": "mike", "N": "november", "O": "oscar", + "P": "papa", "Q": "quebec", "R": "romeo", "S": "sierra", "T": "tango", + "U": "uniform", "V": "victor", "W": "whiskey", "X": "x-ray", "Y": "yankee", + "Z": "zulu", "to": "silence", "notlinked": "not-linked", "linkedto": "linked-to", + }, + "cy_GB": { + "A": "alpha", "B": "bravo", "C": "charlie", "D": "delta", "E": "echo", + "F": "foxtrot", "G": "golf", "H": "hotel", "I": "india", "J": "juliet", + "K": "kilo", "L": "lima", "M": "mike", "N": "november", "O": "oscar", + "P": "papa", "Q": "quebec", "R": "romeo", "S": "sierra", "T": "tango", + "U": "uniform", "V": "victor", "W": "whiskey", "X": "x-ray", "Y": "yankee", + "Z": "zulu", "to": "silence", "notlinked": "not-linked", "linkedto": "linked-to", + "allstar-link-mode": "alpha", + }, + "en_US": { + "to": "2", "adn": "silence", "this-is": "silence", "allstar-link-mode": "alpha", + }, + "es_ES": { + "0": "zero", "1": "one", "2": "two", "3": "three", "4": "four", + "5": "five", "6": "six", "7": "seven", "8": "eight", "9": "nine", + "A": "alfa", "B": "bravo", "C": "charlie", "D": "delta", "E": "echo", + "F": "foxtrot", "G": "golf", "H": "hotel", "I": "india", "J": "juliet", + "K": "kilo", "L": "lima", "M": "mike", "N": "november", "O": "oscar", + "P": "papa", "Q": "quebec", "R": "romeo", "S": "sierra", "T": "tango", + "U": "uniform", "V": "victor", "W": "whiskey", "X": "x-ray", "Y": "yankee", + "Z": "zulu", "to": "silence", "notlinked": "not-linked", "linkedto": "linked-to", + "allstar-link-mode": "alfa", + }, + "fr_FR": { + "A": "alpha", "B": "bravo", "C": "charlie", "D": "delta", "E": "echo", + "F": "foxtrot", "G": "golf", "H": "hotel", "I": "india", "J": "juliet", + "K": "kilo", "L": "lima", "M": "mike", "N": "november", "O": "oscar", + "P": "papa", "Q": "quebec", "R": "romeo", "S": "sierra", "T": "tango", + "U": "uniform", "V": "victor", "W": "whiskey", "X": "x-ray", "Y": "yankee", + "Z": "zulu", "to": "silence", "notlinked": "not-linked", "linkedto": "linked-to", + "allstar-link-mode": "alpha", + }, + "pt_PT": { + "A": "alpha", "B": "bravo", "C": "charlie", "D": "delta", "E": "echo", + "F": "foxtrot", "G": "golf", "H": "hotel", "I": "india", "J": "juliet", + "K": "kilo", "L": "lima", "M": "mike", "N": "november", "O": "oscar", + "P": "papa", "Q": "quebec", "R": "romeo", "S": "sierra", "T": "tango", + "U": "uniform", "V": "victor", "W": "whiskey", "X": "x-ray", "Y": "yankee", + "Z": "zulu", "to": "silence", "notlinked": "not-linked", "linkedto": "linked-to", + "allstar-link-mode": "alpha", + }, + "el_GR": { + "A": "alpha", "B": "bravo", "C": "charlie", "D": "delta", "E": "echo", + "F": "foxtrot", "G": "golf", "H": "hotel", "I": "india", "J": "juliet", + "K": "kilo", "L": "lima", "M": "mike", "N": "november", "O": "oscar", + "P": "papa", "Q": "quebec", "R": "romeo", "S": "sierra", "T": "tango", + "U": "uniform", "V": "victor", "W": "whiskey", "X": "x-ray", "Y": "yankee", + "Z": "zulu", "to": "silence", "notlinked": "not-linked", "allstar-link-mode": "alpha", + }, + "de_DE": {"to": "silence", "allstar-link-mode": "A"}, + "dk_DK": {"to": "silence", "adn": "silence", "this-is": "silence", "allstar-link-mode": "A"}, + "it_IT": {"to": "silence", "adn": "silence", "this-is": "silence", "allstar-link-mode": "A"}, + "no_NO": {"to": "silence", "adn": "silence", "this-is": "silence", "allstar-link-mode": "A"}, + "pl_PL": {"to": "silence", "adn": "silence", "this-is": "silence", "allstar-link-mode": "A"}, + "se_SE": {"to": "silence", "adn": "silence", "this-is": "silence", "allstar-link-mode": "A"}, + "CW": {"to": "silence", "adn": "silence", "this-is": "silence", "linkedto": "silence", "allstar-link-mode": "T"}, + "th_TH": {"to": "silence", "allstar-link-mode": "A"}, +} diff --git a/src/adn_server/main.py b/src/adn_server/main.py new file mode 100644 index 0000000..a4c5aca --- /dev/null +++ b/src/adn_server/main.py @@ -0,0 +1,553 @@ +# ADN DMR Peer Server - entrypoint +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY +# Derived from ADN DMR Server / HBlink. GPLv3. + +""" +ADN DMR Peer Server entrypoint. + +Run: python -m adn_server.main [-c adn-server.yaml] [--logging LEVEL] +Config default: adn-server.yaml at project root. +""" + +from __future__ import annotations + +import argparse +import copy +import logging +import os +import signal +import sys +import time +from pathlib import Path +from typing import Any + +# Ensure package is on path when run as __main__ +_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, threads + +from .domain import bytes_3 +from .infrastructure import YamlConfigLoader, setup_logging +from .infrastructure.persistence import PickleSubMapStore +from .infrastructure.persistence.keys_store import JsonKeysStore +from .infrastructure.persistence.alias_loader import DefaultAliasLoader +from .infrastructure.twisted_adapters.report_server import ReportServerFactory +from .infrastructure.twisted_adapters.udp_hbp import HBPProtocolFactory +from .infrastructure.bridge_router_impl import InMemoryBridgeRouter +from .infrastructure.voice import DefaultVoiceProvider, StubVoiceProvider +from .infrastructure.security.password_download import DefaultSecurityDownloader, StubSecurityDownloader +from .infrastructure.security.user_passwords_loader import UserPasswordsLoader +from .infrastructure.voice.recording import RecordingHandler +from .application import ( + BridgeUseCases, + IdentUseCases, + VoiceUseCases, + ReportingUseCases, + ReportSender, + VoiceProvider, + SecurityDownloader, +) + + +class ReportSenderAdapter(ReportSender): + """Adapt ReportServerFactory to ReportSender port.""" + + def __init__(self, factory: ReportServerFactory) -> None: + self._factory = factory + + def send_config(self, systems) -> None: + self._factory.set_systems(systems) + self._factory.send_config() + + def send_bridge(self, bridges) -> None: + self._factory.set_bridges(bridges) + self._factory.send_bridge() + + def send_bridge_event(self, event: str) -> None: + self._factory.send_bridge_event(event) + + +def _make_echo_bridges() -> dict: + """Initial BRIDGES for ECHO peer (legacy make_bridges 9990).""" + now = time.time() + timeout_sec = 2 * 60 + return { + "9990": [ + { + "SYSTEM": "ECHO", + "TS": 2, + "TGID": bytes_3(9990), + "ACTIVE": True, + "TIMEOUT": timeout_sec, + "TO_TYPE": "NONE", + "ON": [], + "OFF": [], + "RESET": [], + "TIMER": now + timeout_sec, + } + ] + } + + +def _looping_errback(logger: logging.Logger, failure): + """Errback for LoopingCalls (legacy loopingErrHandle).""" + 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") + 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-server.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") + + # Aliases + alias_loader = DefaultAliasLoader() + peer_ids, subscriber_ids, talkgroup_ids, local_subscriber_ids, server_ids, checksums = ( + alias_loader.load_aliases(config) + ) + config["_SUB_IDS"] = subscriber_ids + config["_PEER_IDS"] = peer_ids + config["_TG_IDS"] = talkgroup_ids + config["_LOCAL_SUBSCRIBER_IDS"] = local_subscriber_ids + config["_SERVER_IDS"] = server_ids + config["CHECKSUMS"] = checksums + + # SUB_MAP (shared mutable; used by SubMapTrimmer and shutdown) + aliases_cfg = config.get("ALIASES", {}) + data_path = (aliases_cfg.get("PATH") or ".").rstrip("/") + sub_map_file = aliases_cfg.get("SUB_MAP_FILE") or "sub_map.pkl" + sub_map_path = os.path.join(project_root, data_path, sub_map_file) + sub_map_store = PickleSubMapStore() + sub_map = sub_map_store.load(sub_map_path) + config["_SUB_MAP"] = sub_map + + # Generator: expand MASTER systems with GENERATOR > 1 into SYSTEM-0, SYSTEM-1, ... (legacy) + _expand_generator(config, logger) + _ensure_system_runtime_config(config) + _normalize_peer_config(config) + _normalize_obp_config(config) + + # BRIDGES + bridge_router = InMemoryBridgeRouter() + systems_cfg = config.get("SYSTEMS", {}) + if "ECHO" in systems_cfg and systems_cfg.get("ECHO", {}).get("MODE") == "PEER": + bridge_router.set_bridges(_make_echo_bridges()) + else: + bridge_router.set_bridges({}) + + # Protocol registry for send_to_system (legacy: systems[name].send_system(packet)) + protocols: dict[str, Any] = {} + report_factory = ReportServerFactory(config) + + def send_to_system(system_name: str, packet: bytes, **kwargs: Any) -> None: + p = protocols.get(system_name) + if p is not None and hasattr(p, "send_system"): + p.send_system(packet, **kwargs) + + def send_bcsq(system_name: str, tgid: bytes, stream_id: bytes) -> None: + """Legacy: bridge calls send_bcsq (e.g. loop control first OBP). OBP protocol only.""" + p = protocols.get(system_name) + if p is not None and hasattr(p, "_obp_send_bcsq"): + p._obp_send_bcsq(tgid, stream_id) + + report_sender = ReportSenderAdapter(report_factory) + report_factory.set_systems(systems_cfg) + report_factory.set_bridges(bridge_router.get_bridges()) + reporting_use_cases = ReportingUseCases(report_sender, config) + if config.get("GLOBAL", {}).get("URL_SECURITY", "").strip(): + security = DefaultSecurityDownloader(project_root) + else: + security = StubSecurityDownloader() + security.init_downloads(config) + + user_passwords_loader = UserPasswordsLoader(project_root) + user_passwords_loader.load(config) + + recording_handler = RecordingHandler(config, project_root) + + # Voice: AMBE words + pkt_gen (legacy readAMBE + mk_voice). Use Default when languages + audio path set. + audio_path = os.path.join(project_root, config.get("VOICE", {}).get("AUDIO_PATH", "Audio")) + ann_lang = (config.get("VOICE", {}).get("ANNOUNCEMENT_LANGUAGES") or "").strip() + if ann_lang and os.path.isdir(audio_path): + voice_provider = DefaultVoiceProvider() + else: + voice_provider = StubVoiceProvider() + def _start_voice_loop(fn, interval: float, now: bool): + lc = task.LoopingCall(fn) + d = lc.start(interval, now=now) + d.addErrback(_looping_errback, logger) + return lc + + voice_use_cases = VoiceUseCases( + voice_provider, + config, + get_protocols=lambda: protocols, + call_from_reactor=reactor.callFromThread, + audio_path=audio_path, + get_bridges=bridge_router.get_bridges, + call_later=reactor.callLater, + start_looping_call=_start_voice_loop, + defer_to_thread=threads.deferToThread, + ) + voice_use_cases.check_voice_config_reload(config_path) + ident_use_cases = IdentUseCases( + config, + voice_use_cases, + audio_path, + get_protocols=lambda: protocols, + call_from_reactor=reactor.callFromThread, + ) + + bridge_use_cases = BridgeUseCases( + bridge_router, + config, + send_to_system=send_to_system, + get_protocols=lambda: protocols, + report_factory=report_factory, + on_bridge_deactivated=lambda sys: reactor.callInThread(voice_use_cases.disconnected_voice, sys), + send_bcsq=send_bcsq, + ) + bridge_use_cases.apply_startup_bridges() + report_factory.set_bridges(bridge_router.get_bridges()) + report_factory.set_systems(config.get("SYSTEMS", {})) + + # Report server (same order as legacy: log then listen) + if config.get("REPORTS", {}).get("REPORT", True): + logger.info("(REPORT) HBlink TCP reporting server configured") + port = config["REPORTS"].get("REPORT_PORT", 4321) + reactor.listenTCP(port, report_factory) + logger.info("(REPORT) Report server listening on TCP %s", port) + + # Reporting loop (REPORT_INTERVAL) — same logs as legacy after send_config/send_bridge + def reporting_loop(): + logger.debug("(REPORT) Periodic reporting loop started") + report_factory.set_systems(config.get("SYSTEMS", {})) + report_factory.set_bridges(bridge_router.get_bridges()) + report_factory.send_config() + report_factory.send_bridge() + # Legacy: peer count and SUB_MAP count + systems_with_peers = sum(1 for s in config.get("SYSTEMS", {}) if config.get("SYSTEMS", {}).get(s, {}).get("PEERS")) + logger.info("(REPORT) %s systems have at least one peer", systems_with_peers) + logger.info("(REPORT) Subscriber Map has %s entries", len(config.get("_SUB_MAP", {}))) + + report_interval = config.get("REPORTS", {}).get("REPORT_INTERVAL", 60) + task.LoopingCall(reporting_loop).start(report_interval).addErrback(_looping_errback, logger) + + # LoopingCalls (legacy intervals) + def _rule_timer_in_thread(): + bridge_use_cases.rule_timer_loop() + reactor.callFromThread(report_factory.set_bridges, bridge_router.get_bridges()) + reactor.callFromThread(report_factory.send_bridge) + task.LoopingCall(lambda: threads.deferToThread(_rule_timer_in_thread)).start(52).addErrback(_looping_errback, logger) + task.LoopingCall(bridge_use_cases.stream_trimmer_loop).start(5).addErrback(_looping_errback, logger) + task.LoopingCall(bridge_use_cases.bridge_reset_loop).start(6).addErrback(_looping_errback, logger) + if config.get("GLOBAL", {}).get("GEN_STAT_BRIDGES", False): + def _stat_trimmer_in_thread(): + bridge_use_cases.stat_trimmer_loop() + reactor.callFromThread(report_factory.set_bridges, bridge_router.get_bridges()) + reactor.callFromThread(report_factory.send_bridge) + task.LoopingCall(lambda: threads.deferToThread(_stat_trimmer_in_thread)).start(303).addErrback(_looping_errback, logger) + + # KA Reporting (legacy kaReporting, 60s) + task.LoopingCall(lambda: threads.deferToThread(reporting_use_cases.ka_reporting_loop)).start(60).addErrback(_looping_errback, logger) + + # bridgeDebug (legacy 66s) — remove invalid bridges, fix >1 active dial per MASTER + task.LoopingCall( + lambda: (bridge_use_cases.bridge_debug_loop(), report_factory.set_bridges(bridge_router.get_bridges()), report_factory.send_bridge()) + ).start(66).addErrback(_looping_errback, logger) + + # Alias reload (STALE_DAYS -> seconds) + alias_interval = (aliases_cfg.get("STALE_DAYS") or 1) * 86400 + + def alias_reload_loop(): + logger.debug("(ALIAS) starting alias thread") + try: + p, s, t, l, sv, ch = alias_loader.load_aliases(config) + config["_SUB_IDS"] = s + config["_PEER_IDS"] = p + config["_TG_IDS"] = t + config["_LOCAL_SUBSCRIBER_IDS"] = l + config["_SERVER_IDS"] = sv + config["CHECKSUMS"] = ch + except Exception as e: + logger.warning("(ALIAS) alias reload failed: %s", e) + + task.LoopingCall(alias_reload_loop).start(alias_interval).addErrback(_looping_errback, logger) + + # SubMapTrimmer (3600s) + save + def sub_map_trimmer_loop(): + logger.debug("(SUBSCRIBER) Subscriber Map trimmer loop started") + now = time.time() + to_remove = [k for k, v in sub_map.items() if v[2] < (now - 86400)] + for k in to_remove: + sub_map.pop(k, None) + if aliases_cfg.get("SUB_MAP_FILE"): + try: + sub_map_store.save(sub_map_path, sub_map) + logger.info("(SUBSCRIBER) Writing SUB_MAP to disk") + except Exception as e: + logger.warning("(SUBSCRIBER) Cannot write SUB_MAP to file: %s", e) + + task.LoopingCall(sub_map_trimmer_loop).start(3600).addErrback(_looping_errback, logger) + + # Kill switch + shutdown (legacy kill_server every 5s; SIGTERM/SIGINT trigger _KILL_SERVER) + config.setdefault("GLOBAL", {})["_KILL_SERVER"] = False + keys_store = JsonKeysStore() + keys_path = os.path.join(project_root, data_path, aliases_cfg.get("KEYS_FILE") or "keys.json") + keys = keys_store.load(keys_path) if keys_path else {} + + def kill_server_loop(): + try: + if config.get("GLOBAL", {}).get("_KILL_SERVER"): + logger.info("(GLOBAL) SHUTDOWN: CONFBRIDGE IS TERMINATING - killserver called") + if reactor.running: + reactor.stop() + if aliases_cfg.get("SUB_MAP_FILE"): + sub_map_store.save(sub_map_path, sub_map) + try: + keys_store.save(keys_path, keys) + logger.info("(KEYS) saved system keys to keystore") + except Exception as e: + logger.error("(GLOBAL) Cannot save key file: %s", e) + except KeyError: + pass + + task.LoopingCall(kill_server_loop).start(5).addErrback(_looping_errback, logger) + + def shutdown_handler(): + """On reactor shutdown: save SUB_MAP and keys.""" + if aliases_cfg.get("SUB_MAP_FILE"): + try: + sub_map_store.save(sub_map_path, config["_SUB_MAP"]) + logger.info("(SUBSCRIBER) Writing SUB_MAP to disk (shutdown)") + except Exception as e: + logger.warning("(SUBSCRIBER) Cannot write SUB_MAP on shutdown: %s", e) + try: + keys_store.save(keys_path, keys) + logger.info("(KEYS) saved system keys to keystore (shutdown)") + except Exception as e: + logger.error("(GLOBAL) Cannot save key file on shutdown: %s", e) + + def sig_handler(sig, frame): + logger.info("(GLOBAL) SHUTDOWN: CONFBRIDGE IS TERMINATING WITH SIGNAL %s", sig) + config["GLOBAL"]["_KILL_SERVER"] = True + if reactor.running: + reactor.stop() + + signal.signal(signal.SIGTERM, sig_handler) + signal.signal(signal.SIGINT, sig_handler) + reactor.addSystemEventTrigger("before", "shutdown", shutdown_handler) + + # Voice/announcement config reload (15s): re-read main YAML, start/stop announcement LoopingCalls on change + def voice_reload_loop(): + try: + loader.reload_voice_config(config, config_path) + voice_use_cases.check_voice_config_reload(config_path) + except Exception as e: + logger.debug("(VOICE-RELOAD) %s", e) + + task.LoopingCall(voice_reload_loop).start(15).addErrback(_looping_errback, logger) + logger.info("(VOICE-RELOAD) config file watch active (every 15 seconds)") + + def security_loop(): + security.periodic_download(config) + + task.LoopingCall(security_loop).start(300).addErrback(_looping_errback, logger) + logger.info("(SECURITY) Periodic password download task started (every 5 minutes)") + + # Ident (3600s): run ident in thread for MASTERs with VOICE_IDENT + def ident_loop(): + logger.debug("(IDENT) starting ident thread") + reactor.callInThread(ident_use_cases.run_ident) + + task.LoopingCall(ident_loop).start(3600).addErrback(_looping_errback, logger) + task.LoopingCall(bridge_use_cases.options_config_loop).start(26).addErrback(_looping_errback, logger) + task.LoopingCall(bridge_use_cases.log_connected_systems_and_tgs).start(60).addErrback(_looping_errback, logger) + task.LoopingCall(lambda: logger.debug("(ROUTER) KeepAlive reporting loop started")).start(60).addErrback(_looping_errback, logger) + + # UDP listeners per system (same order as legacy: SYSTEM STARTING then instance created per system) + logger.info("(GLOBAL) ADN DMR Peer Server -- SYSTEM STARTING...") + for system_name, sys_cfg in systems_cfg.items(): + if not sys_cfg.get("ENABLED", True): + continue + ip = sys_cfg.get("IP", "") + udp_port = sys_cfg.get("PORT", 56400) + protocol = HBPProtocolFactory( + system_name, + config, + report_factory, + router=bridge_router, + dmrd_received=bridge_use_cases.dmrd_received, + get_user_password_callback=user_passwords_loader.get_user_password, + on_play_file_request=voice_use_cases.play_file_on_request, + on_handle_recording=recording_handler.handle_recording, + on_in_band_signalling=bridge_use_cases.apply_in_band_signalling, + on_options_received=bridge_use_cases.options_config_for_system, + on_deactivate_dynamic_bridges=bridge_use_cases.deactivate_all_dynamic_bridges, + ) + protocols[system_name] = protocol + reactor.listenUDP(udp_port, protocol, interface=ip or "0.0.0.0") + logger.debug("(GLOBAL) %s instance created: %s, %s", sys_cfg.get("MODE", "?"), system_name, protocol) + logger.info("(GLOBAL) UDP %s listening on %s:%s", system_name, ip or "*", udp_port) + + logger.info("(GLOBAL) ADN DMR Peer Server started. Use adn-dmr-server as reference.") + reactor.run() + + +if __name__ == "__main__": + main()