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/helpers.py

773 lines
26 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 helpers
#
# 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
###############################################################################
"""Shared bridge routing helpers (no Twisted)."""
from __future__ import annotations
import time
from typing import Any
from ...domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, bytes_3, bytes_4, int_id
def is_private_subscriber_dst(dst_id: bytes) -> bool:
"""True for 7-digit private/unit destinations (legacy routerHBP pvt_call branch)."""
return len(str(int_id(dst_id))) == 7
def unit_data_hbp_target_idle(
dst_slot: dict,
pkt_time: float,
hangtime: float,
) -> bool:
"""Legacy sendDataToHBP gate: both RX/TX idle and past group hangtime."""
from ...domain.hbp_protocol import HBPF_SLT_VTERM
return (
dst_slot.get("RX_TYPE") == HBPF_SLT_VTERM
and dst_slot.get("TX_TYPE") == HBPF_SLT_VTERM
and (pkt_time - dst_slot.get("TX_TIME", 0) > hangtime)
)
def is_unit_data_ingress(
call_type: str,
dtype_vseq: int,
stream_id: bytes,
slot_rx_stream_id: bytes | None,
) -> bool:
"""True when legacy routerHBP sets ``_data_call`` (bridge_master.py ~3130).
Unit data is routed but must not update per-slot RX STATUS (busy check for
downlink SUB_MAP / hotspot match stays open on the source MASTER).
"""
if call_type != "unit":
return False
if dtype_vseq in (6, 7, 8):
return True
if dtype_vseq == 3:
return stream_id != (slot_rx_stream_id or b"\x00")
return False
# Embedded LC codeword sits at bits 116:148 inside the 48-bit EMB field (108:156).
# Legacy bridge_master.py replaces dmrbits[116:148] on bursts B–E (dtype_vseq 1–4).
EMB_LC_SLICE = slice(116, 148)
def tg4000_reset_on_vhead(int_dst_id: int, frame_type: int, dtype_vseq: int) -> bool:
"""True when TG/ID 4000 voice header should trigger a one-shot dynamic reset."""
return (
int_dst_id == 4000
and frame_type == HBPF_DATA_SYNC
and dtype_vseq == HBPF_SLT_VHEAD
)
def is_ua_session_tgid(tgid: int) -> bool:
"""True when a keyed TG may be stored as a user-activated dynamic session.
Excludes TG 4000 (reset command) and service/echo 9990–9999 (no SINGLE lock).
"""
t = int(tgid)
if t <= 0 or t == 4000:
return False
return not is_special_tg(str(t))
def obp_target_bcsq_quenches_stream(
systems_cfg: dict[str, Any], target_name: str, dst_id_b: bytes, stream_id: bytes
) -> bool:
"""True if target OBP config has _bcsq[tgid]==stream_id (bytes key or same int TG)."""
m = systems_cfg.get(target_name, {}).get("_bcsq")
if not isinstance(m, dict) or not m:
return False
tid = dst_id_b[:3] if isinstance(dst_id_b, bytes) and len(dst_id_b) >= 3 else bytes_3(int_id(dst_id_b))
if m.get(tid) == stream_id:
return True
for k, v in m.items():
if v != stream_id:
continue
try:
if isinstance(k, bytes) and len(k) >= 3 and int_id(k) == int_id(tid):
return True
except Exception:
continue
return False
def _peer_key_from_int(peer_key: Any) -> bytes:
if isinstance(peer_key, bytes):
return peer_key
if isinstance(peer_key, int):
return bytes_4(peer_key)
if isinstance(peer_key, str) and peer_key.isdigit():
return bytes_4(int(peer_key))
return bytes_4(int_id(peer_key))
def _fuzzy_peer_matches(
val: int,
peers: dict[Any, Any],
) -> list[bytes]:
val_str = str(val)
matches: list[bytes] = []
for pk in peers:
try:
pk_b = _peer_key_from_int(pk)
except (TypeError, ValueError):
continue
pk_int = int_id(pk_b)
pk_str = str(pk_int)
if pk_int == val or pk_int // 100 == val:
matches.append(pk_b)
continue
if len(val_str) >= 5 and len(pk_str) >= 7 and pk_str.startswith(val_str):
matches.append(pk_b)
return matches
def resolve_voice_peer_id(
peer_id: bytes,
rf_src: bytes,
system_name: str,
systems_cfg: dict[str, Any],
) -> bytes:
"""Resolve BRDG_EVENT field 5 for RX legs from a MASTER (hotspot transmitting).
Legacy bridge uses ``_peer_id`` from DMRD for TX legs unchanged. Only RX source
events need the full hotspot radio id so monitor ``rts_update`` marks that peer RX.
"""
peers = systems_cfg.get(system_name, {}).get("PEERS", {})
if not isinstance(peers, dict) or not peers:
return peer_id
peer_b = peer_id if isinstance(peer_id, bytes) else bytes_4(int_id(peer_id))
if peer_b in peers:
return peer_b
rf_b = rf_src if isinstance(rf_src, bytes) else bytes_3(int_id(rf_src))
if rf_b in peers:
return rf_b
peer_matches = _fuzzy_peer_matches(int_id(peer_id), peers)
if len(peer_matches) == 1:
return peer_matches[0]
rf_matches = _fuzzy_peer_matches(int_id(rf_src), peers)
if len(rf_matches) == 1:
return rf_matches[0]
return peer_id
# Back-compat alias for tests and imports.
report_peer_id_for_hbp_target = resolve_voice_peer_id
def is_special_tg(relay_table_key: str) -> bool:
"""True if bridge is special TGID 9990-9999 (excluded from infinite timer)."""
if relay_table_key and relay_table_key[0:1] == "#":
return False
try:
return 9990 <= int(relay_table_key) <= 9999
except ValueError:
return False
def parse_dmrd_route_fields(packet: bytes) -> tuple[int, int, str] | None:
"""Parse HBP DMRD slot, destination TG, and call type for downlink OPTIONS filter."""
if len(packet) < 17 or packet[:4] != b"DMRD":
return None
bits = packet[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"
return slot, int_id(packet[8:11]), call_type
def _system_has_active_bridge_leg(
bridges: dict[str, Any] | None,
system: str,
slot: int,
tgid: int,
*,
subscription_store: Any | None = None,
) -> bool:
"""True when the store (or legacy BRIDGES export) has an ACTIVE leg for ``(system, slot, tgid)``."""
if subscription_store is not None and system:
from adn_server.application.subscription.subscription_queries import (
system_has_active_leg_in_store,
)
return system_has_active_leg_in_store(subscription_store, system, slot, tgid)
if not bridges or not system:
return False
legs = bridges.get(str(tgid))
if not isinstance(legs, list):
return False
for leg in legs:
if not isinstance(leg, dict) or not leg.get("ACTIVE"):
continue
if str(leg.get("SYSTEM", "")) != system:
continue
if int(leg.get("TS", 0)) != int(slot):
continue
return True
return False
def peer_options_fields(peer: dict[str, Any]) -> dict[str, Any]:
"""Parse hotspot OPTIONS into fields used by SINGLE/TIMER resolution."""
from adn_server.application.report.payloads import parse_peer_options_fields
return parse_peer_options_fields(peer.get("OPTIONS"))
def _peer_ua_session_entry(
sys_cfg: dict[str, Any],
peer_id: bytes | None,
slot: int,
) -> dict[str, Any] | None:
if peer_id is None:
return None
store = sys_cfg.get("_PEER_UA_SESSIONS")
if not isinstance(store, dict):
return None
pk = bytes_4(int_id(peer_id))
per_peer = store.get(pk)
if not isinstance(per_peer, dict):
return None
entry = per_peer.get(slot)
return entry if isinstance(entry, dict) else None
def _write_peer_ua_session(
peer: dict[str, Any],
peer_id: bytes,
slot: int,
tgid: int,
expires: float,
sys_cfg: dict[str, Any],
) -> None:
entry = {"tgid": int(tgid), "expires": float(expires)}
pk = bytes_4(int_id(peer_id))
sys_cfg.setdefault("_PEER_UA_SESSIONS", {}).setdefault(pk, {})[slot] = entry
peer.setdefault("_UA_SESSION", {})[slot] = entry
def peer_single_mode(peer: dict[str, Any], sys_cfg: dict[str, Any]) -> bool:
from adn_server.application.report.payloads import resolve_peer_single_and_timer
single, _ = resolve_peer_single_and_timer(peer_options_fields(peer), sys_cfg)
return single
def _peer_ua_multi_store(sys_cfg: dict[str, Any]) -> dict[bytes, dict[int, set[int]]]:
store = sys_cfg.setdefault("_PEER_UA_MULTI_TGS", {})
if not isinstance(store, dict):
store = {}
sys_cfg["_PEER_UA_MULTI_TGS"] = store
return store
def register_peer_ua_multi_tg(
peer: dict[str, Any],
peer_id: bytes,
slot: int,
tgid: int,
sys_cfg: dict[str, Any],
) -> None:
"""SINGLE=0: accumulate keyed dynamic TGs per peer/slot until TG 4000."""
if peer_single_mode(peer, sys_cfg):
return
tgid_i = int(tgid)
if not is_ua_session_tgid(tgid_i):
return
if peer_receives_group_tgid(peer, slot, tgid_i):
return
pk = bytes_4(int_id(peer_id))
per_peer = _peer_ua_multi_store(sys_cfg).setdefault(pk, {})
slot_set = per_peer.setdefault(int(slot), set())
slot_set.add(tgid_i)
def peer_owns_multi_dynamic_ua(
peer: dict[str, Any],
slot: int,
tgid: int,
sys_cfg: dict[str, Any] | None,
*,
peer_id: bytes | None = None,
) -> bool:
"""True when SINGLE=0 peer has keyed this non-static dynamic TG (either slot)."""
if not sys_cfg or peer_single_mode(peer, sys_cfg):
return False
if peer_id is None:
return False
if peer_receives_group_tgid(peer, slot, tgid):
return False
pk = bytes_4(int_id(peer_id))
store = sys_cfg.get("_PEER_UA_MULTI_TGS")
if not isinstance(store, dict):
return False
per_peer = store.get(pk)
if not isinstance(per_peer, dict):
return False
tgid_i = int(tgid)
for voice_slot in (1, 2):
slot_set = per_peer.get(voice_slot)
if isinstance(slot_set, set) and tgid_i in slot_set:
return True
return False
def register_peer_ua_session(
peer: dict[str, Any],
peer_id: bytes,
slot: int,
tgid: int,
sys_cfg: dict[str, Any],
*,
now: float | None = None,
) -> None:
"""Track UA TG for this hotspot (SINGLE=1 exclusive; SINGLE=0 multi-dynamic set)."""
if not is_ua_session_tgid(tgid):
return
if not peer_single_mode(peer, sys_cfg):
register_peer_ua_multi_tg(peer, peer_id, slot, tgid, sys_cfg)
return
from adn_server.application.report.payloads import resolve_peer_single_and_timer
_, timer_min = resolve_peer_single_and_timer(peer_options_fields(peer), sys_cfg)
pkt_time = time.time() if now is None else now
_write_peer_ua_session(
peer,
peer_id,
slot,
int(tgid),
pkt_time + float(timer_min) * 60.0,
sys_cfg,
)
def seed_peer_ua_session_from_status(
peer: dict[str, Any],
peer_id: bytes,
slot: int,
status_slot: dict[str, Any],
sys_cfg: dict[str, Any],
*,
now: float | None = None,
) -> None:
"""Seed SINGLE session after RPTO when TX happened before OPTIONS (inject-only)."""
if not peer_single_mode(peer, sys_cfg):
return
pkt_time = time.time() if now is None else now
if peer_single_exclusive_tgid(peer, slot, sys_cfg, peer_id=peer_id, now=pkt_time) is not None:
return
rx_peer = status_slot.get("RX_PEER", b"\x00\x00\x00\x00")
if bytes_4(int_id(peer_id)) != bytes_4(int_id(rx_peer)):
return
rx_tgid = int_id(status_slot.get("RX_TGID", b"\x00\x00\x00"))
if not is_ua_session_tgid(rx_tgid):
return
connected_at = float(peer.get("CONNECTED", 0) or 0)
rx_time = float(status_slot.get("RX_TIME", 0) or 0)
if connected_at > 0 and rx_time < connected_at - 0.5:
return
register_peer_ua_session(peer, peer_id, slot, rx_tgid, sys_cfg, now=pkt_time)
def clear_peer_rx_status_slots(
status: dict[Any, Any],
peer_id: bytes,
*,
slot: int | None = None,
) -> None:
"""Reset RX fields on slots last owned by this peer (avoids stale OPTIONS seed)."""
pk = bytes_4(int_id(peer_id))
slots = (int(slot),) if slot is not None else (1, 2)
for slot_id in slots:
slot_st = status.get(slot_id)
if not isinstance(slot_st, dict):
continue
if bytes_4(int_id(slot_st.get("RX_PEER", b"\x00"))) != pk:
continue
slot_st["RX_PEER"] = b"\x00"
slot_st["RX_TGID"] = b"\x00\x00\x00"
slot_st["RX_STREAM_ID"] = b"\x00"
slot_st["RX_TIME"] = 0.0
def export_peer_ua_sessions(
sys_cfg: dict[str, Any],
peer_id: bytes | int,
*,
now: float | None = None,
) -> dict[str, dict[str, float | int]]:
"""Active SINGLE sessions for monitor snapshot (server source of truth)."""
pkt_time = time.time() if now is None else now
pk = bytes_4(int_id(peer_id))
out: dict[str, dict[str, float | int]] = {}
store = sys_cfg.get("_PEER_UA_SESSIONS")
if not isinstance(store, dict):
return out
per_peer = store.get(pk)
if not isinstance(per_peer, dict):
return out
for slot in (1, 2):
entry = per_peer.get(slot)
if not isinstance(entry, dict):
continue
exp = float(entry.get("expires", 0) or 0)
tgid = int(entry.get("tgid", 0) or 0)
if is_ua_session_tgid(tgid) and exp > pkt_time:
out[str(slot)] = {"tgid": tgid, "expires_at": exp}
return out
def restore_peer_ua_entries_to_memory(
sys_cfg: dict[str, Any],
peer_id: bytes,
entries: list[Any],
*,
now: float | None = None,
) -> list[int]:
"""Apply persisted dynamic TG rows to ``_PEER_UA_SESSIONS`` / ``_PEER_UA_MULTI_TGS``."""
pkt_time = time.time() if now is None else now
pk = bytes_4(int_id(peer_id))
restored: list[int] = []
for entry in entries:
tgid = int(entry.tgid)
if not is_ua_session_tgid(tgid):
continue
slot = int(entry.slot)
if entry.single_mode:
expires = entry.expires_at
if expires is not None and float(expires) <= pkt_time:
continue
per_peer = sys_cfg.setdefault("_PEER_UA_SESSIONS", {}).setdefault(pk, {})
per_peer[slot] = {
"tgid": tgid,
"expires": float(expires) if expires is not None else 0.0,
}
restored.append(tgid)
else:
multi = sys_cfg.setdefault("_PEER_UA_MULTI_TGS", {}).setdefault(pk, {})
multi.setdefault(slot, set()).add(tgid)
restored.append(tgid)
return restored
def purge_expired_peer_ua_sessions(sys_cfg: dict[str, Any], *, now: float | None = None) -> None:
"""Drop expired SINGLE=1 sessions from in-memory store."""
pkt_time = time.time() if now is None else now
store = sys_cfg.get("_PEER_UA_SESSIONS")
if not isinstance(store, dict):
return
for pk in list(store.keys()):
per_peer = store.get(pk)
if not isinstance(per_peer, dict):
continue
for slot in list(per_peer.keys()):
entry = per_peer.get(slot)
if not isinstance(entry, dict):
continue
exp = float(entry.get("expires", 0) or 0)
if exp > 0 and pkt_time >= exp:
per_peer.pop(slot, None)
if not per_peer:
store.pop(pk, None)
def clear_peer_ua_sessions(
peer: dict[str, Any],
sys_cfg: dict[str, Any],
peer_id: bytes,
*,
slot: int | None = None,
) -> None:
"""Clear per-peer UA state (SINGLE session and/or SINGLE=0 multi-dynamic set)."""
pk = bytes_4(int_id(peer_id))
store = sys_cfg.get("_PEER_UA_SESSIONS")
if isinstance(store, dict) and pk in store:
if slot is None:
store.pop(pk, None)
else:
per_peer = store.get(pk)
if isinstance(per_peer, dict):
per_peer.pop(slot, None)
multi = sys_cfg.get("_PEER_UA_MULTI_TGS")
if isinstance(multi, dict) and pk in multi:
if slot is None:
multi.pop(pk, None)
else:
per_peer = multi.get(pk)
if isinstance(per_peer, dict):
per_peer.pop(int(slot), None)
sessions = peer.get("_UA_SESSION")
if isinstance(sessions, dict):
if slot is None:
sessions.clear()
else:
sessions.pop(slot, None)
def peer_single_exclusive_tgid(
peer: dict[str, Any],
slot: int,
sys_cfg: dict[str, Any],
*,
peer_id: bytes | None = None,
now: float | None = None,
) -> int | None:
"""Active SINGLE session TG on ``slot``, or ``None`` when no exclusive lock."""
if not peer_single_mode(peer, sys_cfg):
return None
pkt_time = time.time() if now is None else now
entry = _peer_ua_session_entry(sys_cfg, peer_id, slot)
if entry is None:
sessions = peer.get("_UA_SESSION")
if isinstance(sessions, dict):
entry = sessions.get(slot)
if not isinstance(entry, dict):
return None
if pkt_time >= float(entry.get("expires", 0)):
return None
locked = entry.get("tgid")
return int(locked) if locked is not None else None
def peer_single_blocks_group_voice(
peer: dict[str, Any],
slot: int,
tgid: int,
sys_cfg: dict[str, Any] | None,
*,
peer_id: bytes | None = None,
now: float | None = None,
) -> bool:
"""True when SINGLE=1 peer must not receive downlink for ``tgid``.
With an active session on TG X, every other TG (static or dynamic) is blocked
until the TIMER expires or a new local TX replaces the session.
"""
del slot
if not sys_cfg:
return False
for voice_slot in (1, 2):
locked = peer_single_exclusive_tgid(
peer, voice_slot, sys_cfg, peer_id=peer_id, now=now,
)
if locked is not None and int(tgid) != locked:
return True
return False
def peer_receives_group_tgid(peer: dict[str, Any], slot: int, tgid: int) -> bool:
"""True when peer RPTO OPTIONS list the group TG on TS1 or TS2 (legacy REPEAT parity).
Voice may arrive on either timeslot; hotspots in repeater mode often use one RF
slot while self-service lists the TG on the other.
"""
del slot
from adn_server.application.report.payloads import parse_peer_options_static
ts1, ts2 = parse_peer_options_static(peer.get("OPTIONS"))
tg = str(tgid)
return tg in ts1 or tg in ts2
def peer_options_static_tg_slot(peer: dict[str, Any], tgid: int) -> int | None:
"""Timeslot (1 or 2) where peer OPTIONS list ``tgid``, when unambiguous."""
from adn_server.application.report.payloads import parse_peer_options_static
ts1, ts2 = parse_peer_options_static(peer.get("OPTIONS"))
tg = str(tgid)
in_ts1 = tg in ts1
in_ts2 = tg in ts2
if in_ts1 and not in_ts2:
return 1
if in_ts2 and not in_ts1:
return 2
return None
def synthetic_group_dmrd_route_packet(slot: int, tgid: int) -> bytes:
"""Minimal DMRD for inject-only downlink fan-out lookup (slot + TG only)."""
bits = 0x80 if int(slot) == 2 else 0
return b"DMRD" + b"\x00" * 4 + bytes_3(tgid) + b"\x00" * 4 + bytes([bits]) + b"\x00" * 38
def repeat_downlink_report_slot(
wire_slot: int,
tgid: int,
peers: dict[Any, Any],
downlink_peer_ids: tuple[bytes, ...],
sys_cfg: dict[str, Any] | None,
) -> int:
"""Monitor timeslot for REPEAT downlink START/TX (OBP bridge uses target TS, not wire slot).
When every downlink peer maps the TG to the same OPTIONS or UA slot, use that slot so
CTABLE chips match RF (cross-slot static/dynamic). Otherwise fall back to wire slot.
"""
display_slots: set[int] = set()
tgid_i = int(tgid)
for peer_id in downlink_peer_ids:
peer = peers.get(peer_id)
if not isinstance(peer, dict):
continue
static_slot = peer_options_static_tg_slot(peer, tgid_i)
if static_slot is not None:
display_slots.add(static_slot)
continue
if not sys_cfg:
continue
pk = bytes_4(int_id(peer_id))
for voice_slot in (1, 2):
locked = peer_single_exclusive_tgid(
peer, voice_slot, sys_cfg, peer_id=peer_id,
)
if locked is not None and int(locked) == tgid_i:
display_slots.add(voice_slot)
store = sys_cfg.get("_PEER_UA_MULTI_TGS")
if isinstance(store, dict):
per_peer = store.get(pk)
if isinstance(per_peer, dict):
for voice_slot in (1, 2):
slot_set = per_peer.get(voice_slot)
if isinstance(slot_set, set) and tgid_i in slot_set:
display_slots.add(voice_slot)
if len(display_slots) == 1:
return display_slots.pop()
return int(wire_slot)
def peer_single_blocks_uplink(
peer: dict[str, Any],
peer_id: bytes,
slot: int,
tgid: int,
sys_cfg: dict[str, Any] | None,
*,
now: float | None = None,
) -> bool:
"""SINGLE=1 never blocks local TX; a new TG replaces the session (see ``register_peer_ua_session``).
Downlink exclusivity is enforced by :func:`peer_single_blocks_group_voice` only.
"""
del peer, peer_id, slot, tgid, sys_cfg, now
return False
def _peer_owns_dynamic_ua(
peer: dict[str, Any],
slot: int,
tgid: int,
sys_cfg: dict[str, Any] | None,
*,
peer_id: bytes | None = None,
now: float | None = None,
) -> bool:
"""True when ``tgid`` is a non-static UA this peer activated (SINGLE session owner)."""
if peer_receives_group_tgid(peer, slot, tgid):
return False
if not sys_cfg:
return False
tgid_i = int(tgid)
for voice_slot in (1, 2):
locked = peer_single_exclusive_tgid(
peer, voice_slot, sys_cfg, peer_id=peer_id, now=now,
)
if locked is not None and tgid_i == locked:
return True
return False
def peer_should_receive_group_voice(
peer: dict[str, Any],
slot: int,
tgid: int,
*,
peer_id: bytes | None = None,
system: str | None = None,
bridges: dict[str, Any] | None = None,
subscription_store: Any | None = None,
connected_count: int = 1,
sys_cfg: dict[str, Any] | None = None,
now: float | None = None,
) -> bool:
"""Whether a hotspot should get downlink / monitor voice for ``(slot, tgid)``.
Inject-only multi-hotspot rules (per peer):
1. ``SINGLE=1`` with an active session on another TG → deny all other TGs.
2. TG in this peer's OPTIONS static list (TS1 or TS2) → allow (when not blocked by SINGLE).
3. ``SINGLE=1``: dynamic UA owned by this peer's exclusive session → allow.
4. ``SINGLE=0``: dynamic UA this peer keyed (multi set) → allow.
5. Sole connected hotspot with an ACTIVE bridge leg for ``(slot, tgid)`` → allow.
6. Otherwise → deny (no fan-out).
A system-wide ACTIVE bridge leg must **not** fan out to every hotspot when
several peers are online; that was the regression when ``bridges`` alone
decided fan-out for all connected hotspots.
"""
if peer_single_blocks_group_voice(peer, slot, tgid, sys_cfg, peer_id=peer_id, now=now):
return False
if peer_receives_group_tgid(peer, slot, tgid):
return True
if _peer_owns_dynamic_ua(peer, slot, tgid, sys_cfg, peer_id=peer_id, now=now):
return True
if peer_owns_multi_dynamic_ua(peer, slot, tgid, sys_cfg, peer_id=peer_id):
return True
if connected_count == 1 and system and _system_has_active_bridge_leg(
bridges, system, slot, tgid, subscription_store=subscription_store
):
return True
return False
def peer_matches_rf_source(peer_id: bytes, rf_src: bytes, peers: dict[Any, Any]) -> bool:
"""True when a hotspot radio id matches the voice RF source (parrot / echo downlink)."""
peer_b = _peer_key_from_int(peer_id)
return peer_b in _fuzzy_peer_matches(int_id(rf_src), peers)

Powered by TurnKey Linux.