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

2113 lines
74 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
from ...domain.hbp_protocol import HBPF_SLT_VTERM, STREAM_TO
from ...domain.mesh_session import obp_session
from ..server_voice import DEFAULT_SERVER_VOICE_ID
PeerVoiceSlotRow = dict[str, Any]
PeerVoiceSlotMap = dict[int, PeerVoiceSlotRow]
# A per-peer downlink voice session with no frames for this long is considered
# dead (VTERM lost or stream abandoned). Matches the legacy bridge_master idle
# loop threshold (bridge_master.py ~607: RX_TIME < now - 5).
_STALE_PEER_SESSION_TIMEOUT = 5.0
RF_MODE_SIMPLEX = "simplex"
RF_MODE_DUPLEX = "duplex"
# MMDVMHost DMO: downlink DMRD with TS1 bit set is dropped; only TS2 passes (DMRNetwork.cpp).
SIMPLEX_VOICE_SLOT = 2
def _peer_freq_text(value: Any) -> str:
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace").strip("\x00").strip()
return str(value or "").strip()
def parse_peer_slots_code(slots: Any) -> int | None:
"""MMDVM RPTC ``SLOTS`` byte: 4=simplex, 1–3=duplex (per MMDVMHost / Wireshark dissector)."""
if isinstance(slots, bytes):
raw = slots[:1]
elif slots is None:
return None
else:
text = str(slots).strip()
raw = text[:1].encode("ascii", errors="ignore") if text else b""
if not raw:
return None
try:
return int(raw.decode("ascii", errors="ignore"))
except ValueError:
return None
def derive_peer_rf_mode(peer: dict[str, Any]) -> str:
"""Classify hotspot RF from RPTC ``SLOTS`` and matching RX/TX frequencies."""
slots_i = parse_peer_slots_code(peer.get("SLOTS"))
if slots_i == 4:
return RF_MODE_SIMPLEX
rx = _peer_freq_text(peer.get("RX_FREQ"))
tx = _peer_freq_text(peer.get("TX_FREQ"))
if rx and tx and rx == tx:
return RF_MODE_SIMPLEX
return RF_MODE_DUPLEX
def peer_rf_mode(peer: dict[str, Any]) -> str:
"""Cached or derived simplex/duplex mode for downlink and monitor."""
cached = peer.get("RF_MODE")
if cached in (RF_MODE_SIMPLEX, RF_MODE_DUPLEX):
return str(cached)
return derive_peer_rf_mode(peer)
def peer_is_simplex(peer: dict[str, Any]) -> bool:
return peer_rf_mode(peer) == RF_MODE_SIMPLEX
def _slot_leg_active(slot_st: dict[str, Any], leg: str, pkt_time: float) -> bool:
"""True when the slot's ``RX`` or ``TX`` leg carries voice (within STREAM_TO)."""
leg_type = slot_st.get(f"{leg}_TYPE")
if leg_type is None or leg_type == HBPF_SLT_VTERM:
return False
return (pkt_time - float(slot_st.get(f"{leg}_TIME", 0) or 0)) < STREAM_TO
def slot_has_active_voice(slot_st: dict[str, Any], pkt_time: float) -> bool:
"""True when the slot has an active group-voice RX or TX leg (within STREAM_TO)."""
return _slot_leg_active(slot_st, "RX", pkt_time) or _slot_leg_active(slot_st, "TX", pkt_time)
def slot_voice_held_by_other_stream(slot_st: dict[str, Any], stream_id: bytes, pkt_time: float) -> bool:
"""Like ``slot_has_active_voice``, but ``stream_id``'s own TX leg does not count."""
if _slot_leg_active(slot_st, "RX", pkt_time):
return True
return slot_st.get("TX_STREAM_ID") != stream_id and _slot_leg_active(slot_st, "TX", pkt_time)
def _slot_last_voice_activity(slot_st: dict[str, Any]) -> tuple[bytes, float]:
"""Most recent RX/TX TG and timestamp on this slot."""
rx_tg = slot_st.get("RX_TGID", b"\x00\x00\x00")
rx_t = float(slot_st.get("RX_TIME", 0))
tx_tg = slot_st.get("TX_TGID", b"\x00\x00\x00")
tx_t = float(slot_st.get("TX_TIME", 0))
if tx_t >= rx_t:
return tx_tg, tx_t
return rx_tg, rx_t
def slot_in_group_hangtime(
slot_st: dict[str, Any],
incoming_tgid_b: bytes,
pkt_time: float,
group_hangtime: float,
) -> bool:
"""True when the slot is idle but other TGs are blocked for GROUP_HANGTIME seconds."""
hang = float(group_hangtime or 0)
if hang <= 0:
return False
if slot_has_active_voice(slot_st, pkt_time):
return False
last_tg, last_t = _slot_last_voice_activity(slot_st)
if last_t <= 0:
return False
if bytes_4(int_id(incoming_tgid_b)) == bytes_4(int_id(last_tg)):
return False
return (pkt_time - last_t) < hang
def hbp_slot_blocks_group_voice(
slot_st: dict[str, Any],
incoming_tgid_b: bytes,
stream_id: bytes,
pkt_time: float,
group_hangtime: float,
*,
allow_same_stream: bool = True,
is_vterm: bool = False,
) -> bool:
"""True when group voice must not be routed or repeated to this slot.
Active QSO: any other stream is blocked (independent of GROUP_HANGTIME).
Post-VTERM: other TGs are blocked for ``group_hangtime`` seconds from config.
"""
if allow_same_stream and stream_id:
if stream_id == slot_st.get("RX_STREAM_ID"):
return False
if stream_id == slot_st.get("TX_STREAM_ID"):
rx_stream = slot_st.get("RX_STREAM_ID")
rx_stream_active = bool(rx_stream) and int_id(rx_stream) != 0
# RX leg is only "genuinely active" if it received voice within
# STREAM_TO — a stale RX_STREAM_ID from an earlier, ended stream
# must not block same-TX-stream packets (legacy contention compares
# TGID + time, never stream IDs).
rx_type = slot_st.get("RX_TYPE")
rx_genuinely_active = (
rx_stream_active
and stream_id != rx_stream
and rx_type is not None
and rx_type != HBPF_SLT_VTERM
and (pkt_time - float(slot_st.get("RX_TIME", 0) or 0)) < STREAM_TO
)
if rx_genuinely_active:
pass
else:
return False
if slot_has_active_voice(slot_st, pkt_time):
return True
return slot_in_group_hangtime(slot_st, incoming_tgid_b, pkt_time, group_hangtime)
def slot_status_peer_owner(slot_st: dict[str, Any]) -> bytes | None:
"""Hotspot radio id that last owned this slot STATUS row (RX preferred, then TX)."""
rx = slot_st.get("RX_PEER")
if rx is not None and int_id(rx) != 0:
return bytes_4(int_id(rx))
tx = slot_st.get("TX_PEER")
if tx is not None and int_id(tx) != 0:
return bytes_4(int_id(tx))
return None
def peer_key_in_peers(peer_id: bytes, peers: dict[Any, Any] | None) -> bool:
"""True when ``peer_id`` is a connected hotspot key in ``PEERS``."""
if not peers:
return False
pk = bytes_4(int_id(peer_id))
if pk in peers:
return True
for key in peers:
try:
if bytes_4(int_id(key)) == pk:
return True
except (TypeError, ValueError):
continue
return False
def slot_status_hotspot_owner(
slot_st: dict[str, Any],
peers: dict[Any, Any] | None = None,
) -> bytes | None:
"""Connected hotspot owning this slot row; ignores bridge ``TX_PEER`` (e.g. OBP 73010)."""
for field in ("RX_PEER", "TX_PEER"):
raw = slot_st.get(field)
if raw is None or int_id(raw) == 0:
continue
pk = bytes_4(int_id(raw))
if peers is not None and not peer_key_in_peers(pk, peers):
continue
return pk
return None
def _peer_transmit_hangtime_blocks(
hang_row: tuple[int, float] | None,
incoming_tgid_b: bytes,
pkt_time: float,
group_hangtime: float,
) -> bool:
"""True when hotspot RF transmit hangtime blocks a different TG."""
hang = float(group_hangtime or 0)
if hang <= 0 or hang_row is None:
return False
last_tg, last_t = hang_row
return int_id(incoming_tgid_b) != int(last_tg) and (pkt_time - float(last_t)) < hang
def _peer_status_rx_hangtime_blocks(
peer_id: bytes,
slot_st: dict[str, Any],
incoming_tgid_b: bytes,
pkt_time: float,
group_hangtime: float,
) -> bool:
"""Ingress RX hangtime for this hotspot (ignores bridge TX_TGID on shared STATUS)."""
pk = bytes_4(int_id(peer_id))
if bytes_4(int_id(slot_st.get("RX_PEER", b""))) != pk:
return False
if slot_has_active_voice(slot_st, pkt_time):
return False
rx_tg = slot_st.get("RX_TGID", b"\x00\x00\x00")
rx_t = float(slot_st.get("RX_TIME", 0))
hang = float(group_hangtime or 0)
if hang <= 0 or rx_t <= 0:
return False
if int_id(incoming_tgid_b) == int_id(rx_tg):
return False
return (pkt_time - rx_t) < hang
def _peer_single_locked_tgid(
peer: dict[str, Any],
sys_cfg: dict[str, Any],
*,
peer_id: bytes | None = None,
now: float | None = None,
prefer_slot: int | None = None,
) -> int | None:
"""First active SINGLE=1 session TG (``prefer_slot`` checked before the other TS)."""
slots: list[int] = []
if prefer_slot is not None:
slots.append(int(prefer_slot))
for voice_slot in (1, 2):
if prefer_slot is not None and voice_slot == int(prefer_slot):
continue
slots.append(voice_slot)
for voice_slot in slots:
locked = peer_single_exclusive_tgid(
peer, voice_slot, sys_cfg, peer_id=peer_id, now=now,
)
if locked is not None:
return locked
return None
def peer_single_same_tg_foreign_tx_blocks(
peer: dict[str, Any],
peer_id: bytes,
incoming_tgid_b: bytes,
stream_id: bytes,
slot_st: dict[str, Any],
sys_cfg: dict[str, Any] | None,
*,
pkt_time: float,
) -> bool:
"""SINGLE=1 UA on TG T: block another peer's stream on the same TG (slot busy)."""
if not sys_cfg or not peer_single_mode(peer, sys_cfg):
return False
incoming = int_id(incoming_tgid_b)
locked = _peer_single_locked_tgid(
peer, sys_cfg, peer_id=peer_id, now=pkt_time,
)
if locked is None or int(locked) != incoming:
return False
if not slot_has_active_voice(slot_st, pkt_time):
return False
slot_tg = int_id(slot_st.get("RX_TGID", b"\x00\x00\x00"))
if slot_tg != incoming:
return False
owner = slot_st.get("RX_PEER") or slot_st.get("TX_PEER")
pk = bytes_4(int_id(peer_id))
if owner is None or int_id(owner) == 0 or bytes_4(int_id(owner)) == pk:
return False
leg_stream = slot_st.get("RX_STREAM_ID") or slot_st.get("TX_STREAM_ID")
if stream_id and leg_stream == stream_id:
return False
return True
def peer_hotspot_voice_slot_busy_reason(
peer_id: bytes,
voice_slot: int,
stream_id: bytes,
incoming_tgid_b: bytes,
slot_st: dict[str, Any],
peer_slots: PeerVoiceSlotMap | None,
hang_row: tuple[int, float] | None,
pkt_time: float,
group_hangtime: float,
*,
peers: dict[Any, Any] | None = None,
peer: dict[str, Any] | None = None,
sys_cfg: dict[str, Any] | None = None,
) -> str | None:
"""Reason this hotspot must not receive another group stream on ``voice_slot``, or None.
Same logic as ``peer_hotspot_voice_slot_busy`` (which just checks ``is not
None``) -- kept as one function so the diagnostic reason can never drift
from the actual accept/reject decision.
Hard rules (SINGLE=0 and SINGLE=1):
- **Transmitting** (``ingress``): drop every downlink byte until VTERM clears the slot.
- **Listening** on TG *T*: drop every byte for TG *U* ≠ *T* on this RF slot (no hangtime
exception for another OPTIONS/UA TG).
- **Bridge hold**: while an ACTIVE bridge leg keeps *T* on the slot, foreign legs on
*U* ≠ *T* stay dropped (OBP or HBP).
- Same ``stream_id`` on the same TG continues one call leg.
"""
pk = bytes_4(int_id(peer_id))
if peer is None and peers is not None:
peer = peers.get(peer_id) or peers.get(pk)
if _peer_transmit_hangtime_blocks(hang_row, incoming_tgid_b, pkt_time, group_hangtime):
return f"GROUP_HANGTIME: recent local transmit hanging on slot {voice_slot}"
incoming_tgid = int_id(incoming_tgid_b)
active = (peer_slots or {}).get(int(voice_slot))
if isinstance(active, dict):
active_tgid = int(active.get("tgid", 0) or 0)
active_stream = active.get("stream_id")
active_time = float(active.get("time", 0) or 0)
age = pkt_time - active_time
if isinstance(active, dict) and active_time > 0 and age >= _STALE_PEER_SESSION_TIMEOUT:
peer_slots.pop(int(voice_slot), None)
active = None
if isinstance(active, dict) and active.get("ingress"):
return f"peer is transmitting (ingress) on slot {voice_slot}"
if isinstance(active, dict) and active.get("bridge_hold") and active_tgid and incoming_tgid != active_tgid:
if age <= group_hangtime:
return f"bridge hold: TG {active_tgid} active on slot {voice_slot}"
peer_slots.pop(int(voice_slot), None)
active = None
if isinstance(active, dict) and stream_id and active_stream:
if active_stream == stream_id:
pass
elif active_tgid and active_tgid == incoming_tgid:
if age >= STREAM_TO:
peer_slots.pop(int(voice_slot), None)
else:
return f"TG {incoming_tgid} already active on slot {voice_slot} with a different stream"
elif active_tgid and incoming_tgid != active_tgid:
return f"slot {voice_slot} busy with different TG {active_tgid}"
else:
return f"slot {voice_slot} busy with an unidentified active stream"
elif isinstance(active, dict):
if active_tgid and active_tgid == incoming_tgid:
pass
else:
return (
f"slot {voice_slot} already occupied by TG {active_tgid}"
if active_tgid
else f"slot {voice_slot} already occupied by another stream"
)
if isinstance(peer, dict) and peer_single_blocks_foreign_same_tg_downlink(
peer, pk, voice_slot, incoming_tgid_b, peer_slots, sys_cfg, now=pkt_time,
):
return f"SINGLE mode: local UA lock on TG {incoming_tgid} blocks foreign downlink on slot {voice_slot}"
if isinstance(peer, dict) and peer_single_same_tg_foreign_tx_blocks(
peer, pk, incoming_tgid_b, stream_id, slot_st, sys_cfg, pkt_time=pkt_time,
):
return f"SINGLE mode: peer is transmitting a different call on TG {incoming_tgid}"
if bytes_4(int_id(slot_st.get("RX_PEER", b""))) == pk:
rx_active = (
slot_st.get("RX_TYPE") is not None
and slot_st.get("RX_TYPE") != HBPF_SLT_VTERM
and (pkt_time - float(slot_st.get("RX_TIME", 0))) < STREAM_TO
)
if rx_active and stream_id != slot_st.get("RX_STREAM_ID"):
return f"slot {voice_slot} STATUS RX owner with a different active stream"
if _peer_status_rx_hangtime_blocks(
peer_id, slot_st, incoming_tgid_b, pkt_time, group_hangtime,
):
return f"GROUP_HANGTIME: recent STATUS RX hangtime on slot {voice_slot}"
return None
def peer_hotspot_voice_slot_busy(
peer_id: bytes,
voice_slot: int,
stream_id: bytes,
incoming_tgid_b: bytes,
slot_st: dict[str, Any],
peer_slots: PeerVoiceSlotMap | None,
hang_row: tuple[int, float] | None,
pkt_time: float,
group_hangtime: float,
*,
peers: dict[Any, Any] | None = None,
peer: dict[str, Any] | None = None,
sys_cfg: dict[str, Any] | None = None,
) -> bool:
"""True when this hotspot must not receive another group stream on ``voice_slot``."""
return (
peer_hotspot_voice_slot_busy_reason(
peer_id,
voice_slot,
stream_id,
incoming_tgid_b,
slot_st,
peer_slots,
hang_row,
pkt_time,
group_hangtime,
peers=peers,
peer=peer,
sys_cfg=sys_cfg,
)
is not None
)
def master_per_peer_slot_contention(
config: dict[str, Any],
system_name: str,
system_cfg: dict[str, Any],
*,
connected_count: int = 0,
) -> bool:
"""True when slot busy/hangtime applies per hotspot, not globally on the MASTER row."""
del config, system_name, connected_count
return system_cfg.get("MODE") == "MASTER"
def inject_only_defer_obp_hbp_slot_contention(
config: dict[str, Any],
target_system: str,
target_system_cfg: dict[str, Any],
*,
source_is_obp: bool,
source_is_hbp: bool = False,
connected_count: int = 0,
) -> bool:
"""Whether ``to_target`` should skip global MASTER slot STATUS contention.
Defer to ``send_peer`` (same as REPEAT): per-peer ``hbp_slot_blocks_group_voice_for_peer``
+ OPTIONS/UA slot remap. Global STATUS on the bridge wire TS would block another
hotspot's TG while a different peer is active on that TS.
"""
if target_system_cfg.get("MODE") != "MASTER":
return False
if not master_per_peer_slot_contention(
config, target_system, target_system_cfg, connected_count=connected_count,
):
return False
if source_is_obp:
return True
return source_is_hbp and connected_count > 1
_SERVER_VOICE_RF_SRC_LEGACY = 5000
def master_slot_holds_server_broadcast(
slot_st: dict[str, Any],
pkt_time: float,
*,
server_voice_rf_src: int | None = None,
server_voice_rf_srcs: frozenset[int] | None = None,
) -> bool:
"""True when server scheduled/TTS voice holds the flat MASTER slot TX row.
Announcements stamp TX_TYPE=VHEAD, TX_RFS=server voice ID, and refresh TX_TIME each frame.
Re-apply global slot contention for OBP→MASTER when held so inject-only defer
does not interleave mesh voice (legacy ``bridge_master`` TX_TGID/TX_TIME rules).
"""
tx_rfs = int_id(slot_st.get("TX_RFS", b"\x00\x00\x00"))
if server_voice_rf_srcs is not None:
if tx_rfs not in server_voice_rf_srcs:
return False
elif server_voice_rf_src is not None:
if tx_rfs != server_voice_rf_src:
return False
elif tx_rfs != DEFAULT_SERVER_VOICE_ID and tx_rfs != _SERVER_VOICE_RF_SRC_LEGACY:
return False
tx_type = slot_st.get("TX_TYPE")
if tx_type is None or tx_type == HBPF_SLT_VTERM:
return False
tx_time = float(slot_st.get("TX_TIME", 0) or 0)
if tx_time <= 0:
return True
return (pkt_time - tx_time) < STREAM_TO
_OBP_FLAT_TX_KEYS = (
"TX_START",
"TX_TGID",
"TX_STREAM_ID",
"TX_RFS",
"TX_PEER",
"TX_H_LC",
"TX_T_LC",
"TX_EMB_LC",
"TX_TIME",
"TX_TYPE",
)
def obp_deferred_bridge_tx_leg(slot_st: dict[str, Any], stream_id: bytes) -> dict[str, Any]:
"""Per-stream bridge TX stamp for inject-only OBP→MASTER legs on a shared slot row."""
legs = slot_st.setdefault("TX_STREAMS", {})
if not isinstance(legs, dict):
legs = {}
slot_st["TX_STREAMS"] = legs
return legs.setdefault(stream_id, {})
def obp_flat_bridge_tx_idle(slot_st: dict[str, Any], pkt_time: float) -> bool:
"""True when flat ``TX_*`` on the slot is not carrying an active bridge leg."""
tx_type = slot_st.get("TX_TYPE")
if tx_type is None or tx_type == HBPF_SLT_VTERM:
return True
tx_time = float(slot_st.get("TX_TIME", 0) or 0)
return pkt_time >= tx_time + STREAM_TO
def obp_bridge_tx_leg_active(leg: dict[str, Any], pkt_time: float) -> bool:
tx_type = leg.get("TX_TYPE")
if tx_type is None or tx_type == HBPF_SLT_VTERM:
return False
tx_time = float(leg.get("TX_TIME", 0) or 0)
return pkt_time < tx_time + STREAM_TO
def obp_publish_flat_bridge_tx(slot_st: dict[str, Any], leg: dict[str, Any]) -> None:
"""Mirror one per-stream bridge leg onto the legacy flat ``TX_*`` slot row."""
for key in _OBP_FLAT_TX_KEYS:
if key in leg:
slot_st[key] = leg[key]
def obp_sync_flat_bridge_tx_times(
slot_st: dict[str, Any],
leg: dict[str, Any],
stream_id: bytes,
pkt_time: float,
dtype_vseq: int,
) -> None:
"""Refresh per-stream leg activity; mirror times to flat row only for the owner stream."""
leg["TX_TIME"] = pkt_time
leg["TX_TYPE"] = dtype_vseq
if slot_st.get("TX_STREAM_ID") == stream_id:
slot_st["TX_TIME"] = pkt_time
slot_st["TX_TYPE"] = dtype_vseq
def obp_pick_active_bridge_tx_leg(
slot_st: dict[str, Any],
pkt_time: float,
) -> tuple[bytes | None, dict[str, Any] | None]:
legs = slot_st.get("TX_STREAMS")
if not isinstance(legs, dict):
return None, None
best_sid: bytes | None = None
best_leg: dict[str, Any] | None = None
best_time = -1.0
for sid, leg in legs.items():
if not isinstance(leg, dict) or not obp_bridge_tx_leg_active(leg, pkt_time):
continue
t = float(leg.get("TX_TIME", 0) or 0)
if t > best_time:
best_time = t
best_sid = sid
best_leg = leg
return best_sid, best_leg
def obp_clear_flat_bridge_tx(slot_st: dict[str, Any]) -> None:
slot_st["TX_TYPE"] = HBPF_SLT_VTERM
slot_st["TX_STREAM_ID"] = b"\x00"
slot_st["TX_TIME"] = 0.0
def obp_clear_deferred_bridge_tx_leg(
slot_st: dict[str, Any],
stream_id: bytes,
pkt_time: float,
) -> None:
"""End one per-stream OBP bridge leg; republish flat ``TX_*`` for another active leg if any."""
legs = slot_st.get("TX_STREAMS")
if isinstance(legs, dict):
legs.pop(stream_id, None)
if slot_st.get("TX_STREAM_ID") == stream_id:
repl_sid, repl_leg = obp_pick_active_bridge_tx_leg(slot_st, pkt_time)
if repl_leg is not None and repl_sid is not None:
obp_publish_flat_bridge_tx(slot_st, repl_leg)
else:
obp_clear_flat_bridge_tx(slot_st)
def hbp_slot_blocks_group_voice_for_peer_reason(
slot_st: dict[str, Any],
peer_id: bytes,
incoming_tgid_b: bytes,
stream_id: bytes,
pkt_time: float,
group_hangtime: float,
*,
per_peer: bool,
peers: dict[Any, Any] | None = None,
peer_slots: PeerVoiceSlotMap | None = None,
peer_hang_row: tuple[int, float] | None = None,
voice_slot: int | None = None,
sys_cfg: dict[str, Any] | None = None,
) -> str | None:
"""Reason for ``hbp_slot_blocks_group_voice_for_peer``'s block, or None.
Same logic as ``hbp_slot_blocks_group_voice_for_peer`` (which just checks
``is not None``) -- kept as one function so the diagnostic reason can
never drift from the actual accept/reject decision.
"""
if per_peer:
if voice_slot is None:
return None
peer = None
if peers is not None:
pk = bytes_4(int_id(peer_id))
peer = peers.get(peer_id) or peers.get(pk)
return peer_hotspot_voice_slot_busy_reason(
peer_id,
int(voice_slot),
stream_id,
incoming_tgid_b,
slot_st,
peer_slots,
peer_hang_row,
pkt_time,
group_hangtime,
peers=peers,
peer=peer if isinstance(peer, dict) else None,
sys_cfg=sys_cfg,
)
if hbp_slot_blocks_group_voice(
slot_st, incoming_tgid_b, stream_id, pkt_time, group_hangtime,
):
return "global STATUS slot contention"
return None
def hbp_slot_blocks_group_voice_for_peer(
slot_st: dict[str, Any],
peer_id: bytes,
incoming_tgid_b: bytes,
stream_id: bytes,
pkt_time: float,
group_hangtime: float,
*,
per_peer: bool,
peers: dict[Any, Any] | None = None,
peer_slots: PeerVoiceSlotMap | None = None,
peer_hang_row: tuple[int, float] | None = None,
voice_slot: int | None = None,
sys_cfg: dict[str, Any] | None = None,
) -> bool:
"""Slot contention scoped to one hotspot when ``per_peer`` (inject-only multi-HS).
``STATUS[slot]`` is shared at the MASTER, but each connected hotspot has an
independent RF timeslot. Another peer's active QSO must not block this peer.
Inject-only OBP→HBP defers global contention and stamps bridge ``TX_*`` on the
shared slot row before ``send_peer``. Same-stream exemption must not treat that
bridge TX stamp as the hotspot's own leg while the peer is still on the air (RX).
"""
return (
hbp_slot_blocks_group_voice_for_peer_reason(
slot_st,
peer_id,
incoming_tgid_b,
stream_id,
pkt_time,
group_hangtime,
per_peer=per_peer,
peers=peers,
peer_slots=peer_slots,
peer_hang_row=peer_hang_row,
voice_slot=voice_slot,
sys_cfg=sys_cfg,
)
is not None
)
def hbp_ingress_new_stream_collision(
slot_st: dict[str, Any],
peer_id: bytes,
rf_src: bytes,
stream_id: bytes,
pkt_time: float,
*,
per_peer: bool,
) -> bool:
"""True when a new group-voice stream must drop on ingress (legacy routerHBP).
When ``per_peer`` is False the MASTER ``STATUS[slot]`` is treated as a shared
RF slot: only one live stream per wire timeslot (legacy bridge_master).
When ``per_peer`` is True the MASTER fronts multiple hotspots, each on its
own frequency, so concurrent streams to different TGs on the same slot are
legitimate. Per-hotspot contention (one listen TG per peer/slot) is enforced
at downlink time by ``peer_hotspot_voice_slot_busy``; it must not be applied
here on ingress, otherwise a second hotspot is silenced by the first.
Same-subscriber rekey with a new stream id is always allowed.
"""
from ...domain.hbp_protocol import HBPF_SLT_VTERM, STREAM_TO
if stream_id and stream_id == slot_st.get("RX_STREAM_ID"):
return False
if stream_id and stream_id == slot_st.get("TX_STREAM_ID"):
return False
if per_peer:
return False
del peer_id
for leg in ("RX", "TX"):
type_key = f"{leg}_TYPE"
time_key = f"{leg}_TIME"
rfs_key = f"{leg}_RFS"
stream_key = f"{leg}_STREAM_ID"
dtype = slot_st.get(type_key)
if dtype is None or dtype == HBPF_SLT_VTERM:
continue
leg_time = float(slot_st.get(time_key, 0) or 0)
if pkt_time >= leg_time + STREAM_TO:
continue
if stream_id and stream_id == slot_st.get(stream_key):
continue
prev_rfs = slot_st.get(rfs_key, b"\x00\x00\x00")
if int_id(rf_src) != 0 and bytes_4(int_id(rf_src)) == bytes_4(int_id(prev_rfs)):
continue
return True
return False
def _same_rf_source(a: bytes, b: bytes) -> bool:
return int_id(a) != 0 and bytes_4(int_id(a)) == bytes_4(int_id(b))
def _hbp_slot_active_tgid(slot_st: dict[str, Any], pkt_time: float) -> bytes | None:
if not slot_has_active_voice(slot_st, pkt_time):
return None
rx_type = slot_st.get("RX_TYPE")
if rx_type is not None and rx_type != HBPF_SLT_VTERM:
return slot_st.get("RX_TGID")
tx_type = slot_st.get("TX_TYPE")
if tx_type is not None and tx_type != HBPF_SLT_VTERM:
return slot_st.get("TX_TGID") or slot_st.get("RX_TGID")
return slot_st.get("RX_TGID")
def _obp_stream_active(st: dict[str, Any], pkt_time: float) -> bool:
if st.get("_fin"):
return False
start = float(st.get("START", 0) or 0)
if start <= 0 or start + 180 < pkt_time:
return False
last = float(st.get("LAST", start) or start)
if (pkt_time - last) >= STREAM_TO:
return False
return True
def obp_ingress_stream_on_system(
obp_status: dict[Any, Any],
stream_id: bytes,
dst_id: bytes,
rf_src: bytes,
) -> int:
best_sid: bytes | None = None
best_first: float | None = None
for sid, st in obp_status.items():
if not isinstance(sid, (bytes, bytearray)) or not isinstance(st, dict):
continue
if "H_LC" in st:
continue
if st.get("TGID") != dst_id:
continue
if not _same_rf_source(st.get("RFS", b"\x00\x00\x00"), rf_src):
continue
if st.get("_fin"):
continue
first = st.get("1ST")
if first is None:
continue
if best_first is None or float(first) < best_first:
best_first = float(first)
best_sid = sid
return int_id(best_sid if best_sid is not None else stream_id)
def obp_cross_system_winner(
protocols: dict[str, Any],
systems_cfg: dict[str, Any],
system_name: str,
stream_id: bytes,
dst_id: bytes,
) -> str:
hr_times: dict[str, float] = {}
for other_name, proto in protocols.items():
if systems_cfg.get(other_name, {}).get("MODE") != "OPENBRIDGE":
continue
obp_status = getattr(proto, "STATUS", None)
if not isinstance(obp_status, dict):
continue
ent = obp_status.get(stream_id)
if not isinstance(ent, dict) or "1ST" not in ent or ent.get("TGID") != dst_id:
continue
hr_times[other_name] = float(ent["1ST"])
if not hr_times:
return system_name
return min(hr_times, key=hr_times.get)
def obp_is_canonical_ingress(
protocols: dict[str, Any],
systems_cfg: dict[str, Any],
system_name: str,
stream_id: bytes,
dst_id: bytes,
rf_src: bytes,
) -> bool:
src_proto = protocols.get(system_name)
obp_status = getattr(src_proto, "STATUS", None) if src_proto else None
if not isinstance(obp_status, dict):
return False
sid = int_id(stream_id)
if sid != obp_ingress_stream_on_system(obp_status, stream_id, dst_id, rf_src):
return False
return system_name == obp_cross_system_winner(
protocols, systems_cfg, system_name, stream_id, dst_id
)
def obp_status_plugin_voice(
protocols: dict[str, Any],
system_name: str,
stream_id: bytes,
) -> bool:
proto = protocols.get(system_name)
status = getattr(proto, "STATUS", None) if proto else None
if not isinstance(status, dict):
return False
ent = status.get(stream_id)
return isinstance(ent, dict) and bool(ent.get("_plugin_voice"))
def tg_has_active_conversation(
protocols: dict[str, Any],
systems_cfg: dict[str, Any],
tgid_b: bytes,
stream_id: bytes,
rf_src: bytes,
pkt_time: float,
) -> bool:
"""True when TG *tgid_b* has an active (in-progress) voice conversation.
Distinct from ``group_voice_tg_ingress_collision``: that helper also matches a TG
that is merely in ``GROUP_HANGTIME`` (idle but recent). This one only matches a TG
with a live stream (within ``STREAM_TO`` of the last frame), so the ingress gate
can activate the TG silently (no uplink, deliver downlink of the active QSO)
instead of rejecting the stream.
"""
tgid = int_id(tgid_b)
if tgid < 5 or tgid in (9, 4000, 5000):
return False
for sys_name, proto in protocols.items():
mode = systems_cfg.get(sys_name, {}).get("MODE")
status = getattr(proto, "STATUS", None)
if not isinstance(status, dict):
continue
if mode in ("MASTER", "PEER"):
for slot_key in (1, 2):
slot_st = status.get(slot_key)
if not isinstance(slot_st, dict):
continue
if not slot_has_active_voice(slot_st, pkt_time):
continue
active_tg = _hbp_slot_active_tgid(slot_st, pkt_time)
if active_tg is None or int_id(active_tg) != tgid:
continue
leg_stream = slot_st.get("RX_STREAM_ID") or slot_st.get("TX_STREAM_ID")
leg_rfs = slot_st.get("RX_RFS") or slot_st.get("TX_RFS") or b"\x00\x00\x00"
if stream_id and leg_stream == stream_id:
continue
if _same_rf_source(rf_src, leg_rfs):
continue
return True
elif mode == "OPENBRIDGE":
for key, st in status.items():
if isinstance(key, int) or not isinstance(st, dict):
continue
if "TGID" not in st or int_id(st.get("TGID", b"")) != tgid:
continue
if not _obp_stream_active(st, pkt_time):
continue
leg_stream = key if isinstance(key, (bytes, bytearray)) else b""
leg_rfs = st.get("RFS", b"\x00\x00\x00")
if stream_id and leg_stream == stream_id:
continue
if _same_rf_source(rf_src, leg_rfs):
continue
return True
return False
def group_voice_tg_ingress_collision(
protocols: dict[str, Any],
systems_cfg: dict[str, Any],
tgid_b: bytes,
stream_id: bytes,
rf_src: bytes,
pkt_time: float,
) -> bool:
"""True when another active group-voice leg already owns this TG (HBP or OBP)."""
tgid = int_id(tgid_b)
if tgid < 5 or tgid in (9, 4000, 5000):
return False
for sys_name, proto in protocols.items():
mode = systems_cfg.get(sys_name, {}).get("MODE")
status = getattr(proto, "STATUS", None)
if not isinstance(status, dict):
continue
if mode in ("MASTER", "PEER"):
for slot_key in (1, 2):
slot_st = status.get(slot_key)
if not isinstance(slot_st, dict):
continue
active_tg = _hbp_slot_active_tgid(slot_st, pkt_time)
if active_tg is None or int_id(active_tg) != tgid:
continue
leg_stream = slot_st.get("RX_STREAM_ID") or slot_st.get("TX_STREAM_ID")
leg_rfs = slot_st.get("RX_RFS") or slot_st.get("TX_RFS") or b"\x00\x00\x00"
if stream_id and leg_stream == stream_id:
continue
if _same_rf_source(rf_src, leg_rfs):
continue
return True
elif mode == "OPENBRIDGE":
for key, st in status.items():
if isinstance(key, int) or not isinstance(st, dict):
continue
if "TGID" not in st or int_id(st.get("TGID", b"")) != tgid:
continue
if not _obp_stream_active(st, pkt_time):
continue
leg_stream = key if isinstance(key, (bytes, bytearray)) else b""
leg_rfs = st.get("RFS", b"\x00\x00\x00")
if stream_id and leg_stream == stream_id:
continue
if _same_rf_source(rf_src, leg_rfs):
continue
return True
return False
def hbp_ingress_downlink_session_blocks_tx(
voice_slot: int,
incoming_tgid_b: bytes,
peer_slots: PeerVoiceSlotMap | None,
) -> bool:
"""True when a hotspot mid downlink QSO on this TG/slot must not ingress TX."""
active = (peer_slots or {}).get(int(voice_slot))
if not isinstance(active, dict) or active.get("ingress"):
return False
active_tg = int(active.get("tgid", 0) or 0)
incoming_tg = int_id(incoming_tgid_b)
return bool(active_tg and incoming_tg and active_tg == incoming_tg)
def hbp_master_ingress_repeat_allowed(
slot_st: dict[str, Any],
peer_id: bytes,
rf_src: bytes,
dst_id: bytes,
stream_id: bytes,
pkt_time: float,
*,
protocols: dict[str, Any] | None = None,
systems_cfg: dict[str, Any] | None = None,
is_vterm: bool = False,
system_cfg: dict[str, Any] | None = None,
) -> bool:
"""True when MASTER REPEAT may fan this ingress packet to other peers.
In a multi-hotspot MASTER, concurrent streams from different peers to
different TGs on the same wire timeslot are legitimate: each hotspot is
on its own RF frequency. Contention is enforced per-peer at downlink
time (``peer_slot_blocks_downlink``). Applying a global shared-slot gate
here would drop voice packets of an active stream whenever a second
stream updates ``RX_STREAM_ID``, causing audible gaps on the first call.
"""
if stream_id and stream_id == slot_st.get("RX_STREAM_ID"):
owner = slot_st.get("RX_PEER")
return (
owner is not None
and int_id(owner) != 0
and bytes_4(int_id(owner)) == bytes_4(int_id(peer_id))
)
# A VTERM closes an existing stream; it must reach peers that were hearing
# that stream even when another stream is now active on the shared slot
# (multi-hotspot MASTER). Blocking it leaves per-peer sessions open forever.
if is_vterm:
return True
per_peer = bool(system_cfg and master_per_peer_slot_contention(
systems_cfg or {}, "", system_cfg, connected_count=0,
))
if hbp_ingress_new_stream_collision(
slot_st, peer_id, rf_src, stream_id, pkt_time, per_peer=per_peer,
):
return False
if protocols and systems_cfg and group_voice_tg_ingress_collision(
protocols, systems_cfg, dst_id, stream_id, rf_src, pkt_time,
):
return False
return True
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 unit_data_reportable(dtype_vseq: int) -> bool:
"""True when a unit-data frame should emit monitor/report events.
CSBK prelude (dtype 3) is routed but omitted from BRDG_EVENT — same policy as
data-log ``log_dtypes`` — to avoid SMS/GPS setup storms in logs and monitors.
"""
return dtype_vseq in (6, 7, 8)
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(
config: dict[str, Any], target_name: str, dst_id_b: bytes, stream_id: bytes
) -> bool:
"""True when the target OBP has quenched this stream for this talkgroup."""
return obp_session(config, target_name).quenches(dst_id_b, stream_id)
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 is_on_demand_service_dst(dst_id: int) -> bool:
"""True for private-call destinations 9991-9999 (on-demand audio trigger)."""
return 9991 <= int(dst_id) <= 9999
def is_server_originated_voice(
packet: bytes,
*,
server_voice_rf_src: int | None = None,
server_voice_rf_srcs: frozenset[int] | None = None,
) -> bool:
"""True for server group playback (server voice ID -> TG 9 TS2), e.g. on-demand / disconnected."""
if len(packet) < 11:
return False
burst = parse_dmrd_burst_fields(packet)
if burst is None:
return False
slot, _, _, _, dst_id, call_type = burst
if call_type not in ("group", "vcsbk"):
return False
src = int_id(packet[5:8])
if server_voice_rf_srcs is not None:
if src not in server_voice_rf_srcs:
return False
elif server_voice_rf_src is not None:
if src != server_voice_rf_src and src != _SERVER_VOICE_RF_SRC_LEGACY:
return False
elif src != DEFAULT_SERVER_VOICE_ID and src != _SERVER_VOICE_RF_SRC_LEGACY:
return False
return int_id(dst_id) == 9 and slot == 2
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."""
burst = parse_dmrd_burst_fields(packet)
if burst is None:
return None
slot, _, _, _, dst_id, call_type = burst
return slot, int_id(dst_id), call_type
def parse_dmrd_burst_fields(
packet: bytes,
) -> tuple[int, int, int, bytes, bytes, str] | None:
"""Parse wire slot, frame type, dtype, stream id, dst, call type from group DMRD."""
if len(packet) < 20 or packet[:4] != b"DMRD":
return None
bits = packet[15]
slot = 2 if (bits & 0x80) else 1
if bits & 0x40:
return None
if (bits & 0x23) == 0x23:
call_type = "vcsbk"
else:
call_type = "group"
frame_type = (bits & 0x30) >> 4
dtype_vseq = bits & 0xF
return slot, frame_type, dtype_vseq, packet[16:20], 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.
Memoized against the OPTIONS blob, the way ``cached_peer_static_tgs`` already
memoizes the static lists: OPTIONS only changes on RPTO, which drops the cache
(``invalidate_peer_options_cache``), while ingress asks for these fields
several times per voice frame through ``peer_single_mode``.
"""
opts = peer.get("OPTIONS")
key = opts if isinstance(opts, bytes) else b""
cached = peer.get("_CACHED_OPTIONS_FIELDS")
if cached is not None and cached[0] == key:
return cached[1]
from adn_server.application.report.payloads import parse_peer_options_fields
fields = parse_peer_options_fields(opts)
peer["_CACHED_OPTIONS_FIELDS"] = (key, fields)
return fields
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],
*,
source: str = "local",
) -> None:
entry = {"tgid": int(tgid), "expires": float(expires), "source": str(source)}
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 _peer_static_tg_blocks_slot(peer: dict[str, Any], slot: int, tgid: int) -> bool:
"""Does this peer's static OPTIONS already cover ``tgid`` for this exact
slot? Simplex peers have one real RF path regardless of nominal TS1/TS2,
so any static match blocks (matches peer_receives_group_tgid's
either-slot check); duplex peers are checked per-slot, since a static
match on one slot must not block genuinely independent dynamic activity
on the *other* slot (e.g. TG static on TS2, this same peer separately
keying up the same TG on TS1)."""
from adn_server.application.routing.peer_downlink_index import cached_peer_static_tgs
ts1, ts2 = cached_peer_static_tgs(peer)
tg = str(tgid)
if peer_is_simplex(peer):
return tg in ts1 or tg in ts2
if int(slot) == 1:
return tg in ts1
if int(slot) == 2:
return tg in ts2
return False
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_static_tg_blocks_slot(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 peer_dynamic_tg_active_on_slot(
peer: dict[str, Any],
tgid: int,
slot: int,
sys_cfg: dict[str, Any] | None,
*,
peer_id: bytes | None = None,
) -> bool:
"""True when a *dynamic* (non-static OPTIONS) TG is active on this
specific slot for this peer right now. Covers SINGLE=1 (independent
exclusive session per slot) and SINGLE=0 (independently keyed multi-TG
set per slot)."""
if not sys_cfg or peer_id is None:
return False
tgid_i = int(tgid)
if peer_single_mode(peer, sys_cfg):
locked = peer_single_exclusive_tgid(peer, slot, sys_cfg, peer_id=peer_id)
return locked is not None and locked == tgid_i
store = sys_cfg.get("_PEER_UA_MULTI_TGS")
if not isinstance(store, dict):
return False
per_peer = store.get(bytes_4(int_id(peer_id)))
if not isinstance(per_peer, dict):
return False
slot_set = per_peer.get(slot)
return isinstance(slot_set, set) and tgid_i in slot_set
def peer_dynamic_tg_active_on_both_slots(
peer: dict[str, Any],
tgid: int,
sys_cfg: dict[str, Any] | None,
*,
peer_id: bytes | None = None,
) -> bool:
"""True when a *dynamic* (non-static OPTIONS) TG is active on both slot 1
and slot 2 for this peer right now -- static-or-dynamic makes no
difference to whether a duplex peer should get the call on both slots."""
return (
peer_dynamic_tg_active_on_slot(peer, tgid, 1, sys_cfg, peer_id=peer_id)
and peer_dynamic_tg_active_on_slot(peer, tgid, 2, sys_cfg, peer_id=peer_id)
)
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,
source: str = "local",
) -> 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
from adn_server.domain.ua_timer import UA_SESSION_NEVER_EXPIRES_AT, ua_timer_is_infinite
_, timer_min = resolve_peer_single_and_timer(peer_options_fields(peer), sys_cfg)
pkt_time = time.time() if now is None else now
if ua_timer_is_infinite(timer_min):
expires_at = UA_SESSION_NEVER_EXPIRES_AT
else:
expires_at = pkt_time + float(timer_min) * 60.0
# One exclusive dynamic TG per hotspot (either RF slot); new local TX replaces all others.
other_slot = 2 if int(slot) == 1 else 1
clear_peer_ua_sessions(peer, sys_cfg, peer_id, slot=other_slot)
_write_peer_ua_session(
peer,
peer_id,
slot,
int(tgid),
expires_at,
sys_cfg,
source=source,
)
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 == 0.0 or exp > pkt_time):
row: dict[str, float | int] = {"tgid": tgid}
if exp > pkt_time:
row["expires_at"] = exp
out[str(slot)] = row
return out
def export_peer_ua_multi_tgs(
sys_cfg: dict[str, Any],
peer_id: bytes | int,
) -> dict[str, list[int]]:
"""Active SINGLE=0 multi-dynamic TG sets for monitor snapshot."""
pk = bytes_4(int_id(peer_id))
store = sys_cfg.get("_PEER_UA_MULTI_TGS")
if not isinstance(store, dict):
return {}
per_peer = store.get(pk)
if not isinstance(per_peer, dict):
return {}
out: dict[str, list[int]] = {}
for slot in (1, 2):
tg_set = per_peer.get(slot)
if isinstance(tg_set, set) and tg_set:
tgids = sorted(
int(t) for t in tg_set if is_ua_session_tgid(int(t))
)
if tgids:
out[str(slot)] = tgids
return out
def master_dynamic_tg_slots(
sys_cfg: dict[str, Any],
tg_int: int,
*,
now: float | None = None,
) -> set[int]:
"""Slots where ``tg_int`` is an active dynamic UA session for any peer.
SINGLE=1 sessions come from ``_PEER_UA_SESSIONS``; SINGLE=0 multi-dynamic
sets from ``_PEER_UA_MULTI_TGS``. Used to activate SYSTEM bridge legs for
dynamic TGs (OBP/HBP inbound) the same way OPTIONS static TGs are activated.
"""
if not is_ua_session_tgid(tg_int):
return set()
pkt_time = time.time() if now is None else now
slots: set[int] = set()
single_store = sys_cfg.get("_PEER_UA_SESSIONS")
if isinstance(single_store, dict):
for per_peer in single_store.values():
if not isinstance(per_peer, dict):
continue
for slot, entry in per_peer.items():
if not isinstance(entry, dict):
continue
if int(entry.get("tgid", 0) or 0) != tg_int:
continue
exp = float(entry.get("expires", 0) or 0)
if exp == 0.0 or exp > pkt_time:
slots.add(int(slot))
multi_store = sys_cfg.get("_PEER_UA_MULTI_TGS")
if isinstance(multi_store, dict):
for per_peer in multi_store.values():
if not isinstance(per_peer, dict):
continue
for slot, tg_set in per_peer.items():
if isinstance(tg_set, set) and tg_int in {int(t) for t in tg_set}:
slots.add(int(slot))
return slots
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:
from adn_server.domain.ua_timer import UA_SESSION_NEVER_EXPIRES_AT, ua_session_never_expires
expires = entry.expires_at
if (
expires is not None
and not ua_session_never_expires(float(expires))
and float(expires) <= pkt_time
):
continue
per_peer = sys_cfg.setdefault("_PEER_UA_SESSIONS", {}).setdefault(pk, {})
if expires is None or ua_session_never_expires(float(expires)):
exp_mem = UA_SESSION_NEVER_EXPIRES_AT
else:
exp_mem = float(expires)
per_peer[slot] = {
"tgid": tgid,
"expires": exp_mem,
}
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
exp = float(entry.get("expires", 0) or 0)
if exp > 0 and pkt_time >= exp:
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 SINGLE session on TG *X* in the peer's RF listen slot for
*tgid*, every other TG on **that same RF slot** is blocked until the TIMER
expires or a new local TX replaces the session.
Duplex hotspots have independent RF timeslots: a listen lock on TS1 must not
block a static TG on TS2 (and vice versa). Simplex hotspots always collapse
to ``SIMPLEX_VOICE_SLOT``, so both TGs share the same RF slot and blocking
still applies.
"""
if not sys_cfg:
return False
voice_slot = peer_downlink_voice_slot(peer, int(slot), int(tgid), sys_cfg, peer_id=peer_id)
locked = peer_single_exclusive_tgid(
peer, voice_slot, sys_cfg, peer_id=peer_id, now=now,
)
return locked is not None and int(tgid) != locked
def peer_single_blocks_foreign_same_tg_downlink(
peer: dict[str, Any],
peer_id: bytes,
voice_slot: int,
incoming_tgid_b: bytes,
peer_slots: PeerVoiceSlotMap | None,
sys_cfg: dict[str, Any] | None,
*,
now: float | None = None,
) -> bool:
"""SINGLE=1 local UA on TG T: block network downlink on T unless hotspot is TX on T.
Downlink listen locks (``source=listen``) only exclude other TGs via
``peer_single_blocks_group_voice``; same-TG stream overlap uses
``peer_hotspot_voice_slot_busy``.
A SINGLE=1 UA session for TG T is itself proof that the peer activated T
dynamically — the peer must receive downlink for T even when T is not in
the static OPTIONS list. Blocking it here would make dynamic TGs deaf to
their own activated TG once the local TX ends.
"""
if not sys_cfg or not peer_single_mode(peer, sys_cfg):
return False
incoming = int_id(incoming_tgid_b)
locked = _peer_single_locked_tgid(
peer, sys_cfg, peer_id=peer_id, now=now, prefer_slot=voice_slot,
)
if locked is None or int(locked) != incoming:
return False
entry = _peer_ua_session_entry(sys_cfg, peer_id, voice_slot)
if isinstance(entry, dict) and entry.get("source") == "listen":
return False
active = (peer_slots or {}).get(int(voice_slot))
if isinstance(active, dict) and active.get("ingress"):
return True
# UA session locked to this TG (dynamic activation) must hear its downlink.
if isinstance(entry, dict) and int(entry.get("tgid", 0) or 0) == incoming:
return False
if isinstance(peer, dict) and peer_receives_group_tgid(peer, voice_slot, incoming):
return False
return True
def peer_static_options_tg_count(peer: dict[str, Any]) -> int:
"""Count distinct static group TGs listed in peer OPTIONS (TS1 ∪ TS2)."""
from adn_server.application.routing.peer_downlink_index import cached_peer_static_tgs
ts1, ts2 = cached_peer_static_tgs(peer)
return len(set(ts1) | set(ts2))
def peer_wants_downlink_single_listen_lock(peer: dict[str, Any], sys_cfg: dict[str, Any]) -> bool:
"""SINGLE=1 downlink listen lock for overlap — not full-table lab witnesses."""
if not sys_cfg:
return False
if not peer_single_mode(peer, sys_cfg):
return False
return peer_static_options_tg_count(peer) <= 6
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.routing.peer_downlink_index import cached_peer_static_tgs
ts1, ts2 = cached_peer_static_tgs(peer)
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.routing.peer_downlink_index import cached_peer_static_tgs
ts1, ts2 = cached_peer_static_tgs(peer)
tg = str(tgid)
in_ts1 = tg in ts1
in_ts2 = tg in ts2
if peer_is_simplex(peer) and (in_ts1 or in_ts2):
return SIMPLEX_VOICE_SLOT
if in_ts1 and not in_ts2:
return 1
if in_ts2 and not in_ts1:
return 2
return None
def synthetic_group_dmrd_burst_packet(
slot: int,
tgid: int,
stream_id: bytes,
*,
frame_type: int = 0,
dtype_vseq: int = 0,
call_type: str = "group",
) -> bytes:
"""Minimal DMRD with burst header fields for slot-tracking helpers."""
bits = 0x80 if int(slot) == 2 else 0
if call_type == "vcsbk":
bits |= 0x23
else:
bits |= (int(frame_type) & 0x3) << 4
bits |= int(dtype_vseq) & 0xF
sid = bytes_4(int_id(stream_id)) if stream_id else b"\x00" * 4
return b"DMRD" + b"\x00" * 4 + bytes_3(tgid) + b"\x00" * 4 + bytes([bits]) + sid + b"\x00" * 34
def synthetic_group_dmrd_route_packet(
slot: int,
tgid: int,
stream_id: bytes | None = None,
) -> bytes:
"""Minimal DMRD for downlink/monitor gate lookup (slot, TG, optional stream)."""
sid = stream_id if stream_id else b"\x00" * 4
return synthetic_group_dmrd_burst_packet(slot, tgid, sid)
def peer_downlink_voice_slot(
peer: dict[str, Any],
wire_slot: int,
tgid: int,
sys_cfg: dict[str, Any] | None = None,
*,
peer_id: bytes | None = None,
) -> int:
"""Monitor/BRDG field 7: TS where this peer listens for ``tgid`` (OPTIONS or UA)."""
if peer_is_simplex(peer):
return SIMPLEX_VOICE_SLOT
static = peer_options_static_tg_slot(peer, tgid)
if static is not None:
return static
from adn_server.application.routing.peer_downlink_index import cached_peer_static_tgs
ts1, ts2 = cached_peer_static_tgs(peer)
tg = str(tgid)
if tg in ts1 and tg in ts2:
# Static on both slots (peer_options_static_tg_slot returns None
# because that's ambiguous *as a single answer*, not because the TG
# is unresolved) -- an in-progress SINGLE=1 exclusive lock or SINGLE=0
# UA_MULTI entry from this peer's own current transmission must not
# override a config that already, unambiguously, permits wire_slot.
return int(wire_slot)
if sys_cfg is not None and peer_id is not None:
tgid_i = int(tgid)
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:
return voice_slot
store = sys_cfg.get("_PEER_UA_MULTI_TGS")
if isinstance(store, dict):
per_peer = store.get(pk)
if isinstance(per_peer, dict):
wire_slot_i = int(wire_slot)
wire_slot_set = per_peer.get(wire_slot_i)
if isinstance(wire_slot_set, set) and tgid_i in wire_slot_set:
return wire_slot_i
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 voice_slot
return int(wire_slot)
def remap_dmrd_to_peer_static_slot(
packet: bytes,
peer: dict[str, Any],
sys_cfg: dict[str, Any] | None = None,
*,
peer_id: bytes | None = None,
) -> bytes:
"""Flip DMRD slot bit so the hotspot RF TS matches OPTIONS/UA for this TG."""
parsed = parse_dmrd_route_fields(packet)
if parsed is None:
return packet
voice_slot, tgid, call_type = parsed
if call_type not in ("group", "vcsbk"):
return packet
if is_special_tg(str(tgid)):
return packet
cfg_slot = peer_downlink_voice_slot(
peer, voice_slot, tgid, sys_cfg, peer_id=peer_id,
)
if cfg_slot == voice_slot:
return packet
bits = packet[15]
new_bits = bits ^ (1 << 7)
return packet[:15] + bytes([new_bits]) + packet[16:]
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.