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

859 lines
40 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# ADN DMR Peer Server - bridge table management
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
###############################################################################
# Copyright (C) 2026 Joaquin Madrid Belando, EA5GVK <ea5gvk@gmail.com>
# Copyright (C) 2020 Simon Adlem, G7RZU <g7rzu@gb7fr.org.uk>
# Copyright (C) 2016-2019 Cortney T. Buffington, N0MJS <n0mjs@me.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""BRIDGES table lifecycle: create, static TG, OPTIONS refresh (no Twisted)."""
from __future__ import annotations
import logging
import re
import time
from typing import Any
from ...domain import bytes_3, bytes_4, int_id
from ...domain.config_coerce import coerce_bool, parse_options_single
from ...domain.dynamic_tg import DynamicTgEntry
from ..proxy.deployment import is_proxy_inject_only
logger = logging.getLogger(__name__)
class SubscriptionTableMixin:
"""ensure_dynamic_relay, stat/static TG, OPTIONS refresh (RPTO / startup / dmrd)."""
def sync_restored_dynamic_tgs(
self,
peer_id: bytes,
system_name: str,
sys_cfg: dict[str, Any],
entries: list[DynamicTgEntry],
*,
now: float,
) -> None:
"""After DB restore: bridge timers and missing relay tables (routing concern)."""
from .dynamic_tg_restore import sync_restored_dynamic_bridges
sync_restored_dynamic_bridges(
entries,
system_name=system_name,
peer_id=peer_id,
sys_cfg=sys_cfg,
sub_store=self._subscription_store,
ensure_dynamic_relay=self.ensure_dynamic_relay,
ua_timer_minutes_for_peer=self._ua_timer_minutes_for_peer,
now=now,
)
def ensure_dynamic_relay(
self,
_tgid: bytes | int,
_sourcesystem: str,
_slot: int,
_tmout: float,
) -> None:
"""Legacy ensure_dynamic_relay: 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
from ..subscription.subscription_table_ops import ensure_dynamic_relay_store
ensure_dynamic_relay_store(
self._subscription_store,
tgid_int,
_sourcesystem,
_slot,
float(_tmout),
self._config.get("SYSTEMS", {}),
time.time(),
)
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
from ..subscription.subscription_table_ops import make_single_reflector_store
make_single_reflector_store(
self._subscription_store,
int(_tgid_s),
float(_tmout),
_sourcesystem,
self._config.get("SYSTEMS", {}),
time.time(),
)
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."""
from ..subscription.subscription_table_ops import make_default_reflector_store
make_default_reflector_store(
self._subscription_store,
reflector,
float(_tmout),
system,
self._config.get("SYSTEMS", {}),
time.time(),
)
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."""
from ..subscription.subscription_table_ops import make_static_tg_store
single_mode = coerce_bool(
self._config.get("SYSTEMS", {}).get(system, {}).get("SINGLE_MODE", False)
)
make_static_tg_store(
self._subscription_store,
tg,
ts,
float(_tmout),
system,
self._config.get("SYSTEMS", {}),
time.time(),
single_mode=single_mode,
)
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."""
from ..subscription.subscription_table_ops import reset_static_tg_store
reset_static_tg_store(
self._subscription_store,
tg,
ts,
float(_tmout),
system,
time.time(),
)
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."""
from ..subscription.subscription_table_ops import reset_all_reflector_system_store
reset_all_reflector_system_store(
self._subscription_store,
float(_tmout),
system,
time.time(),
)
def remove_bridge_system(self, system: str) -> None:
"""Deactivate all legs for one system (legacy remove_bridge_system)."""
from ..subscription.subscription_reset_ops import deactivate_system_legs_store
deactivate_system_legs_store(self._subscription_store, system, time.time())
def ensure_stat_relay(self, _tgid: bytes) -> None:
"""Legacy ensure_stat_relay: on-the-fly relay bridges for OBP traffic when GEN_STAT_BRIDGES is True."""
_tgid_s = str(int_id(_tgid))
from ..subscription.subscription_table_ops import ensure_stat_relay_store
ensure_stat_relay_store(
self._subscription_store,
_tgid,
self._config.get("SYSTEMS", {}),
time.time(),
)
def deactivate_all_dynamic_relays(self, system_name: str) -> None:
"""Legacy deactivate_all_dynamic_relays: deactivate all non-STAT, non-reflector bridges for a system (TG 4000)."""
from ..subscription.subscription_table_ops import deactivate_all_dynamic_relays_store
deactivate_all_dynamic_relays_store(self._subscription_store, system_name)
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)."""
from ..subscription.subscription_table_ops import readd_system_after_ua_timer_change_store
readd_system_after_ua_timer_change_store(
self._subscription_store,
system,
float(_tmout),
time.time(),
)
def apply_startup_subscriptions(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)
for system, sys_cfg in self._config.get("SYSTEMS", {}).items():
if sys_cfg.get("MODE") != "MASTER":
continue
if not sys_cfg.get("ENABLED", True):
continue
self.options_config_for_system(system)
self._sync_subscription_store()
def _first_connected_peer_options(self, system_name: str) -> bytes | str | None:
"""First connected peer OPTIONS (legacy options_config peer scan without 26s loop)."""
protocols = self._get_protocols() if self._get_protocols else {}
proto = protocols.get(system_name)
peers = getattr(proto, "_peers", {}) if proto is not None else {}
if not isinstance(peers, dict):
return None
for peer in peers.values():
if isinstance(peer, dict) and peer.get("CONNECTION") == "YES" and peer.get("OPTIONS"):
return peer["OPTIONS"]
return None
def _options_key_allows(self, system_name: str, parsed: dict[str, Any]) -> bool:
"""Legacy OPTIONS KEY gate (_opt_key on MASTER)."""
sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {})
if sys_cfg.get("_opt_key"):
if "KEY" not in parsed:
logger.debug(
"(OPTIONS) %s, options key set but no key in options string, skipping",
system_name,
)
return False
if sys_cfg["_opt_key"] != parsed.get("KEY"):
logger.debug(
"(OPTIONS) %s, options key set but key sent does not match, skipping",
system_name,
)
return False
elif parsed.get("KEY"):
sys_cfg["_opt_key"] = parsed["KEY"]
logger.debug(
"(OPTIONS) %s, _opt_key not set but key sent. Setting to sent key",
system_name,
)
else:
sys_cfg["_opt_key"] = False
return True
def _maybe_update_reflector_from_options(
self, system_name: str, parsed: dict[str, Any]
) -> None:
"""Apply DEFAULT_REFLECTOR / DIAL changes from parsed OPTIONS (legacy options_config)."""
prohibited_tgs = (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999)
sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {})
raw_timer = parsed.get("DEFAULT_UA_TIMER", sys_cfg.get("DEFAULT_UA_TIMER", 10))
try:
timer_int = int(raw_timer)
tmout = float(35791394 if timer_int == 0 else timer_int)
except (TypeError, ValueError):
tmout = float(sys_cfg.get("DEFAULT_UA_TIMER", 10))
new_ref = int(parsed.get("DEFAULT_REFLECTOR", 0) or 0)
cur_ref = int(sys_cfg.get("DEFAULT_REFLECTOR", 0) or 0)
if new_ref == cur_ref:
return
if new_ref > 0:
logger.debug("(OPTIONS) %s default reflector changed, updating", system_name)
self.reset_all_reflector_system(tmout, system_name)
self.make_default_reflector(new_ref, tmout, system_name)
elif new_ref in prohibited_tgs and not bool(new_ref):
logger.debug("(OPTIONS) %s default reflector is prohibited, ignoring change", system_name)
else:
logger.debug("(OPTIONS) %s default reflector disabled, updating", system_name)
self.reset_all_reflector_system(tmout, system_name)
def _parse_options_string(self, opt_str: bytes | str) -> dict[str, Any] | None:
"""Parse hotspot OPTIONS / RPTO payload into a normalized options dict."""
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"),
("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)
return _options
except Exception:
return None
def _yaml_default_ua_timer(self, sys_cfg: dict[str, Any]) -> float:
tmout = float(sys_cfg.get("DEFAULT_UA_TIMER", 10))
return 35791394.0 if tmout <= 0 else tmout
def _peer_ua_timer_minutes(self, parsed: dict[str, Any], sys_cfg: dict[str, Any]) -> float:
try:
value = int(parsed.get("DEFAULT_UA_TIMER", sys_cfg.get("DEFAULT_UA_TIMER", 10)))
except (TypeError, ValueError):
return self._yaml_default_ua_timer(sys_cfg)
if value == 0:
return 35791394.0
return float(value)
def _ua_timer_minutes_for_peer(self, system_name: str, peer_id: bytes) -> float:
"""UA bridge timeout (minutes): transmitting peer OPTIONS TIMER, else YAML default."""
sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {})
protocols = self._get_protocols() if self._get_protocols else {}
proto = protocols.get(system_name)
peers = getattr(proto, "_peers", {}) if proto is not None else {}
if isinstance(peers, dict):
peer_int = int_id(peer_id)
for pk, peer in peers.items():
if not isinstance(peer, dict) or peer.get("CONNECTION") != "YES":
continue
try:
pk_int = int_id(pk if isinstance(pk, bytes) else bytes_4(int(pk)))
except (TypeError, ValueError):
continue
if pk_int != peer_int:
continue
opt = peer.get("OPTIONS")
if opt is None:
break
parsed = self._parse_options_string(opt)
if parsed:
return self._peer_ua_timer_minutes(parsed, sys_cfg)
break
return self._yaml_default_ua_timer(sys_cfg)
def _connected_peer_options_strings(self, system_name: str) -> list[bytes | str]:
protocols = self._get_protocols() if self._get_protocols else {}
proto = protocols.get(system_name)
peers = getattr(proto, "_peers", {}) if proto is not None else {}
options: list[bytes | str] = []
if not isinstance(peers, dict):
return options
for peer in peers.values():
if not isinstance(peer, dict) or peer.get("CONNECTION") != "YES":
continue
opt = peer.get("OPTIONS")
if opt is not None:
options.append(opt)
return options
def _static_tg_timer_maps_for_master(
self,
system_name: str,
*,
peer_options: bytes | str | None = None,
) -> tuple[dict[int, float], dict[int, float]]:
"""Per-TG TIMER (minutes) from each peer OPTIONS; never merged with max() across peers."""
sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {})
ts1_timers: dict[int, float] = {}
ts2_timers: dict[int, float] = {}
if peer_options is not None:
parsed = self._parse_options_static_tgs(peer_options, sys_cfg)
if parsed is not None:
peer_tmout, ts1_list, ts2_list = parsed
for tg in ts1_list:
ts1_timers[tg] = peer_tmout
for tg in ts2_list:
ts2_timers[tg] = peer_tmout
return ts1_timers, ts2_timers
for opt in self._connected_peer_options_strings(system_name):
parsed = self._parse_options_static_tgs(opt, sys_cfg)
if parsed is None:
continue
peer_tmout, ts1_list, ts2_list = parsed
for tg in ts1_list:
if tg not in ts1_timers:
ts1_timers[tg] = peer_tmout
for tg in ts2_list:
if tg not in ts2_timers:
ts2_timers[tg] = peer_tmout
return ts1_timers, ts2_timers
def _options_static_apply_fingerprint(self, system_name: str) -> str:
"""Fingerprint for duplicate RPTO short-circuit (includes runtime SINGLE_MODE)."""
sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {})
merged = self._merged_static_tg_lists_for_master(system_name)
if merged is not None:
ts1_nums, ts2_nums = merged
new_ts1 = ",".join(str(x) for x in ts1_nums)
new_ts2 = ",".join(str(x) for x in ts2_nums)
else:
new_ts1 = str(sys_cfg.get("TS1_STATIC") or "").strip()
new_ts2 = str(sys_cfg.get("TS2_STATIC") or "").strip()
return (
f"{new_ts1}|{new_ts2}|"
f"{int(bool(sys_cfg.get('SINGLE_MODE', False)))}"
)
def _options_static_lists_valid(self, opt_str: bytes | str) -> bool:
"""Legacy: malformed TS1/TS2 in OPTIONS aborts static bridge refresh."""
from adn_server.application.report.payloads import peer_options_static_valid
return peer_options_static_valid(opt_str)
def _should_apply_system_single_from_options(self, system_name: str) -> bool:
"""Whether peer OPTIONS may overwrite system ``SINGLE_MODE`` (legacy single-hotspot only).
Inject-only proxy and multi-peer masters keep YAML ``SINGLE_MODE`` for bridge
timers / in-band signalling; per-peer ``SINGLE`` still applies to downlink via
``peer_single_mode()``.
"""
sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {})
if is_proxy_inject_only(self._config, system_name):
return False
try:
max_peers = int(sys_cfg.get("MAX_PEERS", 1))
except (TypeError, ValueError):
max_peers = 1
return max_peers <= 1
def _apply_master_runtime_options(self, system_name: str, _options: dict[str, Any]) -> None:
"""Apply SINGLE/TIMER/VOICE/LANG from peer OPTIONS over YAML defaults (legacy options_config).
Runtime YAML/OPTIONS flags only; bridge legs are updated via the subscription store.
"""
systems_cfg = self._config.get("SYSTEMS", {})
sys_cfg = systems_cfg.get(system_name, {})
if sys_cfg.get("MODE") != "MASTER":
return
if "VOICE" in _options and bool(_options["VOICE"]) and (
sys_cfg.get("VOICE_IDENT") != bool(int(_options["VOICE"]))
):
sys_cfg["VOICE_IDENT"] = bool(int(_options["VOICE"]))
logger.debug("(OPTIONS) %s - Setting voice ident to %s", system_name, sys_cfg["VOICE_IDENT"])
if "OVERRIDE_IDENT_TG" in _options and _options["OVERRIDE_IDENT_TG"] and (
sys_cfg.get("OVERRIDE_IDENT_TG") != int(_options["OVERRIDE_IDENT_TG"])
):
sys_cfg["OVERRIDE_IDENT_TG"] = int(_options["OVERRIDE_IDENT_TG"])
logger.debug(
"(OPTIONS) %s - Setting OVERRIDE_IDENT_TG to %s",
system_name,
sys_cfg["OVERRIDE_IDENT_TG"],
)
if "LANG" in _options and _options["LANG"] != sys_cfg.get("ANNOUNCEMENT_LANGUAGE"):
sys_cfg["ANNOUNCEMENT_LANGUAGE"] = _options["LANG"]
logger.debug("(OPTIONS) %s - Setting voice language to %s", system_name, sys_cfg["ANNOUNCEMENT_LANGUAGE"])
if "SINGLE" in _options and self._should_apply_system_single_from_options(system_name):
new_single = parse_options_single(_options["SINGLE"])
if new_single is not None and coerce_bool(sys_cfg.get("SINGLE_MODE", False)) != new_single:
sys_cfg["SINGLE_MODE"] = new_single
logger.info("(OPTIONS) %s - Setting SINGLE_MODE to %s", system_name, sys_cfg["SINGLE_MODE"])
# TIMER is per-peer: applied via make_static_tg for that peer's static TGs only.
def options_config_for_system(
self,
system_name: str,
peer_options: bytes | str | None = None,
) -> None:
"""Update runtime flags and static TG bridges (RPTO or voice path).
``peer_options`` from RPTO overrides YAML (inject-only proxy: OPTIONS live on each peer).
"""
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":
return
try:
runtime_source: bytes | str | None = peer_options
if runtime_source is None and "OPTIONS" in sys_cfg:
runtime_source = sys_cfg["OPTIONS"]
if runtime_source is None:
runtime_source = self._first_connected_peer_options(system_name)
if runtime_source is not None:
parsed_runtime = self._parse_options_string(runtime_source)
if parsed_runtime and self._options_key_allows(system_name, parsed_runtime):
self._apply_master_runtime_options(system_name, parsed_runtime)
self._maybe_update_reflector_from_options(system_name, parsed_runtime)
source_opt: bytes | str | None = peer_options
if source_opt is None and "OPTIONS" in sys_cfg:
source_opt = sys_cfg["OPTIONS"]
if source_opt is not None and not self._options_static_lists_valid(source_opt):
self._sync_subscription_store()
return
merged = self._merged_static_tg_lists_for_master(system_name)
_fp = self._options_static_apply_fingerprint(system_name)
if sys_cfg.get("_options_static_apply_fp") == _fp:
if merged is not None:
ts1_nums, ts2_nums = merged
yaml_tmout = self._yaml_default_ua_timer(sys_cfg)
ts1_timers, ts2_timers = self._static_tg_timer_maps_for_master(
system_name,
peer_options=peer_options if peer_options is not None else None,
)
if peer_options is not None:
refresh_ts1 = set(ts1_timers)
refresh_ts2 = set(ts2_timers)
else:
refresh_ts1 = set(ts1_nums)
refresh_ts2 = set(ts2_nums)
for tg in refresh_ts1:
if tg in prohibited_tgs:
continue
self.make_static_tg(tg, 1, ts1_timers.get(tg, yaml_tmout), system_name)
for tg in refresh_ts2:
if tg in prohibited_tgs:
continue
self.make_static_tg(tg, 2, ts2_timers.get(tg, yaml_tmout), system_name)
self._restore_prohibited_static_bridge_legs(system_name)
self._sync_subscription_store()
return
if merged is None:
# Echo TGs (9990–9999) are excluded from merged lists but still need restore.
self._restore_prohibited_static_bridge_legs(system_name)
self._sync_subscription_store()
return
ts1_nums, ts2_nums = merged
yaml_tmout = self._yaml_default_ua_timer(sys_cfg)
if peer_options is not None:
ts1_timers, ts2_timers = self._static_tg_timer_maps_for_master(
system_name, peer_options=peer_options
)
else:
ts1_timers, ts2_timers = self._static_tg_timer_maps_for_master(system_name)
new_ts1 = ",".join(str(x) for x in ts1_nums)
new_ts2 = ",".join(str(x) for x in ts2_nums)
if re.search(r"[^\d,]", new_ts1) or re.search(r"[^\d,]", new_ts2):
return
# Legacy: reset TGs that were removed (bridge_master.py 1736-1767)
old_ts1 = str(sys_cfg.get("TS1_STATIC") or "").strip()
old_ts2 = str(sys_cfg.get("TS2_STATIC") or "").strip()
new_ts1_set: set[int] = set()
new_ts2_set: set[int] = set()
for x in new_ts1.split(","):
if x.strip():
try:
new_ts1_set.add(int(x))
except ValueError:
pass
for x in new_ts2.split(","):
if x.strip():
try:
t = int(x)
if t != 0 and t < 16777215:
new_ts2_set.add(t)
except ValueError:
pass
for tg_s in old_ts1.split(","):
if not tg_s.strip():
continue
try:
tg = int(tg_s)
if tg not in new_ts1_set:
self.reset_static_tg(tg, 1, ts1_timers.get(tg, yaml_tmout), system_name)
except ValueError:
pass
for tg_s in old_ts2.split(","):
if not tg_s.strip():
continue
try:
tg = int(tg_s)
if tg not in new_ts2_set and tg != 0 and tg < 16777215:
self.reset_static_tg(tg, 2, ts2_timers.get(tg, yaml_tmout), system_name)
except ValueError:
pass
peer_ts1_set = set(ts1_timers) if peer_options is not None else None
peer_ts2_set = set(ts2_timers) if peer_options is not None else None
for tg in ts1_nums:
if tg in prohibited_tgs:
continue
if peer_ts1_set is not None and tg not in peer_ts1_set:
continue
self.make_static_tg(tg, 1, ts1_timers.get(tg, yaml_tmout), system_name)
for tg in ts2_nums:
if tg == 0 or tg >= 16777215 or tg in prohibited_tgs:
continue
if peer_ts2_set is not None and tg not in peer_ts2_set:
continue
self.make_static_tg(tg, 2, ts2_timers.get(tg, yaml_tmout), system_name)
systems_cfg[system_name]["TS1_STATIC"] = new_ts1
systems_cfg[system_name]["TS2_STATIC"] = new_ts2
systems_cfg[system_name]["_options_static_apply_fp"] = self._options_static_apply_fingerprint(
system_name
)
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 "-")
self._restore_prohibited_static_bridge_legs(system_name)
self._sync_subscription_store()
except Exception as e:
logger.debug("(OPTIONS) options_config_for_system %s: %s", system_name, e)
def _static_tg_lists_from_runtime_cfg(self, sys_cfg: dict[str, Any]) -> tuple[list[int], list[int]] | None:
"""Build static TG lists from TS1_STATIC / TS2_STATIC (updated when peers send RPTO)."""
from ..report.payloads import normalize_static_tg_slot_lists, static_tg_list
prohibited_tgs = (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999)
ts1_parts, ts2_parts = normalize_static_tg_slot_lists(
static_tg_list(sys_cfg.get("TS1_STATIC")),
static_tg_list(sys_cfg.get("TS2_STATIC")),
)
ts1_list: list[int] = []
ts2_list: list[int] = []
for tg_s in ts1_parts:
try:
tg = int(tg_s.strip())
except ValueError:
continue
if tg in prohibited_tgs:
continue
ts1_list.append(tg)
for tg_s in ts2_parts:
try:
tg = int(tg_s.strip())
except ValueError:
continue
if tg in prohibited_tgs:
continue
if tg == 0 or tg >= 16777215:
continue
ts2_list.append(tg)
if not ts1_list and not ts2_list:
return None
return (ts1_list, ts2_list)
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] = []
ts1_raw = str(_options.get("TS1_STATIC") or "").strip()
ts2_raw = str(_options.get("TS2_STATIC") or "").strip()
if ts1_raw and not re.search(r"[^\d,]", ts1_raw):
for tg_s in ts1_raw.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
if ts2_raw and not re.search(r"[^\d,]", ts2_raw):
for tg_s in ts2_raw.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 _merged_static_tg_lists_for_master(
self, system_name: str
) -> tuple[list[int], list[int]] | None:
"""Union static TG ids from runtime YAML and every connected peer RPTO (inject proxy)."""
sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {})
if sys_cfg.get("MODE") != "MASTER":
return None
ts1_set: set[int] = set()
ts2_set: set[int] = set()
runtime = self._static_tg_lists_from_runtime_cfg(sys_cfg)
if runtime is not None:
ts1_list, ts2_list = runtime
ts1_set.update(ts1_list)
ts2_set.update(ts2_list)
protocols = self._get_protocols() if self._get_protocols else {}
proto = protocols.get(system_name)
peers = getattr(proto, "_peers", {}) if proto is not None else {}
if isinstance(peers, dict):
for peer in peers.values():
if not isinstance(peer, dict) or peer.get("CONNECTION") != "YES":
continue
opt = peer.get("OPTIONS")
if opt is None:
continue
if isinstance(opt, bytes):
opt_str = opt.decode("utf8", errors="replace")
else:
opt_str = str(opt)
parsed = self._parse_options_static_tgs(opt_str, sys_cfg)
if parsed is None:
continue
_peer_tmout, ts1_list, ts2_list = parsed
ts1_set.update(ts1_list)
ts2_set.update(ts2_list)
if not ts1_set and not ts2_set and "OPTIONS" in sys_cfg:
parsed = self._parse_options_static_tgs(sys_cfg["OPTIONS"], sys_cfg)
if parsed is not None:
_peer_tmout, ts1_list, ts2_list = parsed
ts1_set.update(ts1_list)
ts2_set.update(ts2_list)
if not ts1_set and not ts2_set:
return None
return (sorted(ts1_set), sorted(ts2_set))
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 in static TS1/TS2 (runtime lists or OPTIONS) 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
parsed = self._merged_static_tg_lists_for_master(_system)
if not parsed:
continue
ts1_list, ts2_list = parsed
ts1_timers, ts2_timers = self._static_tg_timer_maps_for_master(_system)
yaml_tmout = self._yaml_default_ua_timer(systems_cfg.get(_system, {}))
if tg_int in ts1_list:
self.make_static_tg(tg_int, 1, ts1_timers.get(tg_int, yaml_tmout), _system)
if tg_int in ts2_list:
self.make_static_tg(tg_int, 2, ts2_timers.get(tg_int, yaml_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))

Powered by TurnKey Linux.