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

602 lines
25 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 LC / Talker Alias
#
# 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
###############################################################################
"""Embedded LC rewrite and Talker Alias bridge coordination (no Twisted)."""
from __future__ import annotations
import logging
from typing import Any
from bitarray import bitarray
from ...domain import int_id
from ...domain.dmr.const import LC_OPT
from ...domain.talker_alias import DMRA_BLOCK_COUNT
from ..talker_alias_use_cases import passthrough_complete, talker_alias_settings
from .helpers import EMB_LC_SLICE
logger = logging.getLogger(__name__)
class LcTaMixin:
"""Talker Alias DMRA relay and embedded LC overlay on forward legs."""
def _get_stream_dmra_blocks(self, source_system: str, stream_id: bytes) -> dict[int, bytes] | None:
if not self._get_dmra_blocks:
return None
return self._get_dmra_blocks(source_system, stream_id)
def _both_ta_key(self, source_system: str, stream_id: bytes) -> tuple[str, bytes]:
return (source_system, stream_id)
def _ta_capable_source(self, source_system: str) -> bool:
"""True when the source leg can buffer TA (MASTER DMRA/voice or OBP voice)."""
return self._config.get("SYSTEMS", {}).get(source_system, {}).get("MODE") in (
"MASTER",
"OPENBRIDGE",
)
def _master_ta_wait_source(self, source_system: str) -> bool:
"""``both`` mode waits for hotspot TA only on HBP MASTER sources (not OBP)."""
return self._config.get("SYSTEMS", {}).get(source_system, {}).get("MODE") == "MASTER"
def _cancel_both_ta_wait(self, source_system: str, stream_id: bytes) -> None:
wait = self._both_ta_wait.pop(self._both_ta_key(source_system, stream_id), None)
if wait and wait.get("timer") and getattr(wait["timer"], "cancel", None):
try:
wait["timer"].cancel()
except Exception:
pass
def _register_both_ta_wait(
self,
source_system: str,
target_system: str,
rf_src: bytes,
stream_id: bytes,
source_peer: bytes,
wire_slot: int,
tgid: int,
) -> None:
"""Defer DMRA UDP + embed inject until MMDVM fragments arrive (both mode)."""
if not self._call_later:
return
key = self._both_ta_key(source_system, stream_id)
wait = self._both_ta_wait.setdefault(
key,
{"rf_src": rf_src, "peer": source_peer, "targets": set()},
)
wait["rf_src"] = rf_src
wait["peer"] = source_peer
wait["wire_slot"] = int(wire_slot)
wait["tgid"] = int(tgid)
wait["targets"].add(target_system)
if wait.get("timer"):
return
# Wait long enough to detect a slow source TA (MMDVM emits TA ~1 s in) before
# falling back to inject; passthrough is applied earlier via on_dmra_fragment_stored.
wait["timer"] = self._call_later(2.0, self._finalize_both_ta, source_system, stream_id)
def _finalize_both_ta(self, source_system: str, stream_id: bytes) -> None:
key = self._both_ta_key(source_system, stream_id)
wait = self._both_ta_wait.pop(key, None)
if not wait:
return
rf_src = wait["rf_src"]
peer = wait["peer"]
blocks = self._get_stream_dmra_blocks(source_system, stream_id)
# No source TA within the window: fall back to inject (template).
fallback_inject = not (blocks and passthrough_complete(blocks))
wire_slot = int(wait.get("wire_slot", 2))
tgid = int(wait.get("tgid", 0))
for target_system in wait["targets"]:
if not self._talker_alias.should_send_on_vhead(target_system, stream_id):
continue
self._send_talker_alias_to_target(
source_system, target_system, rf_src, stream_id, peer,
wire_slot=wire_slot, tgid=tgid,
force=True, fallback_inject=fallback_inject,
)
self._apply_both_ta_embed(source_system, rf_src, stream_id, force_inject=fallback_inject)
def _apply_both_ta_embed(
self,
source_system: str,
rf_src: bytes,
stream_id: bytes,
*,
force_inject: bool = False,
) -> None:
"""Install embed LC state once the TA decision is known (passthrough or inject)."""
if not self._get_protocols:
return
for proto in self._get_protocols().values():
status = getattr(proto, "STATUS", None)
if not isinstance(status, dict):
continue
for st in status.values():
if not isinstance(st, dict):
continue
if st.get("TX_TA_ON"):
continue
if st.get("_ta_embed_kind") == "passthrough" and not force_inject:
continue
if st.get("TX_STREAM_ID") == stream_id and st.get("TX_RFS") == rf_src:
target = st.get("_ta_target_system", source_system)
self._init_talker_alias_embed(
st, source_system, target, rf_src, stream_id, force_inject=force_inject,
)
elif st.get("REP_STREAM_ID") == stream_id:
self._init_talker_alias_embed(
st, source_system, source_system, rf_src, stream_id, force_inject=force_inject,
)
def on_dmra_fragment_stored(
self,
source_system: str,
peer_id: bytes,
rf_src: bytes,
stream_id: bytes,
) -> None:
"""Source TA may complete after VHEAD (DMRA UDP or decoded from voice).
Once the buffer is complete, overlay the source TA on the outgoing embedded LC
(passthrough/both). For `both` with a pending wait, also relay DMRA now and cancel
the inject fallback.
"""
if talker_alias_settings(self._config, source_system)["mode"] not in ("both", "passthrough"):
return
blocks = self._get_stream_dmra_blocks(source_system, stream_id)
if not blocks or not passthrough_complete(blocks):
return
relay_key = self._both_ta_key(source_system, stream_id)
already_relayed = relay_key in self._passthrough_relayed
wait = self._both_ta_wait.get(relay_key)
if wait and not already_relayed:
wait["peer"] = peer_id
self._cancel_both_ta_wait(source_system, stream_id)
for target_system in wait["targets"]:
self._send_talker_alias_to_target(
source_system, target_system, rf_src, stream_id, peer_id,
wire_slot=int(wait.get("wire_slot", 2)),
tgid=int(wait.get("tgid", 0)),
force=True,
)
self._apply_both_ta_embed(source_system, rf_src, stream_id)
if not already_relayed:
self._relay_passthrough_dmra(source_system, peer_id, rf_src, stream_id)
self._passthrough_relayed.add(relay_key)
def _relay_passthrough_dmra(
self,
source_system: str,
peer_id: bytes,
rf_src: bytes,
stream_id: bytes,
) -> None:
"""After the source TA buffer is complete, relay DMRA to bridge/repeat targets."""
blocks = self._get_stream_dmra_blocks(source_system, stream_id)
if not blocks or not passthrough_complete(blocks):
return
if self._get_protocols:
for proto in self._get_protocols().values():
status = getattr(proto, "STATUS", None)
if not isinstance(status, dict):
continue
for slot_key, st in status.items():
if not isinstance(st, dict):
continue
if st.get("TX_STREAM_ID") != stream_id or st.get("TX_RFS") != rf_src:
continue
target = st.get("_ta_target_system")
if not isinstance(target, str) or not target:
continue
tx_peer = st.get("TX_PEER", peer_id)
try:
wire_slot = int(slot_key)
except (TypeError, ValueError):
wire_slot = 2
tgid = int_id(st.get("TX_TGID", b"\x00\x00\x00"))
if tgid <= 0:
continue
self._send_talker_alias_to_target(
source_system, target, rf_src, stream_id, tx_peer,
wire_slot=wire_slot, tgid=tgid, force=True,
)
src_cfg = self._config.get("SYSTEMS", {}).get(source_system, {})
if src_cfg.get("MODE") == "MASTER" and src_cfg.get("REPEAT", True):
wire_slot, tgid = self._local_repeat_route(source_system, stream_id, rf_src)
if tgid > 0:
self.send_talker_alias_local_repeat(
source_system, peer_id, rf_src, stream_id, wire_slot, tgid,
)
def _local_repeat_route(
self, system_name: str, stream_id: bytes, rf_src: bytes,
) -> tuple[int, int]:
if not self._get_protocols:
return 2, 0
proto = self._get_protocols().get(system_name)
status = getattr(proto, "STATUS", None) if proto else None
if not isinstance(status, dict):
return 2, 0
for slot_key, st in status.items():
if not isinstance(st, dict):
continue
if st.get("REP_STREAM_ID") == stream_id or (
st.get("RX_STREAM_ID") == stream_id and st.get("RX_RFS") == rf_src
):
try:
wire_slot = int(slot_key)
except (TypeError, ValueError):
wire_slot = 2
tgid = int_id(st.get("REP_TGID") or st.get("RX_TGID") or st.get("TX_TGID") or b"\x00\x00\x00")
if tgid > 0:
return wire_slot, tgid
return 2, 0
def _dispatch_talker_alias_on_bridge_open(
self,
target_st: dict[str, Any],
source_system: str,
target_system: str,
rf_src: bytes,
stream_id: bytes,
source_peer: bytes,
wire_slot: int,
tgid: int,
) -> None:
"""Prepare TA on a new bridged HBP leg (VHEAD or first burst after hangtime)."""
settings = talker_alias_settings(self._config, source_system)
if not settings["enabled"]:
return
blocks = self._get_stream_dmra_blocks(source_system, stream_id)
have_source_ta = bool(blocks and passthrough_complete(blocks))
if settings["mode"] == "both" and self._master_ta_wait_source(source_system) and not have_source_ta:
self._register_both_ta_wait(
source_system, target_system, rf_src, stream_id, source_peer,
wire_slot, tgid,
)
return
self._init_talker_alias_embed(
target_st,
source_system,
target_system,
rf_src,
stream_id,
)
self._send_talker_alias_to_target(
source_system, target_system, rf_src, stream_id, source_peer,
wire_slot=wire_slot, tgid=tgid,
)
def _send_talker_alias_to_target(
self,
source_system: str,
target_system: str,
rf_src: bytes,
stream_id: bytes,
source_peer: bytes,
*,
wire_slot: int | None = None,
tgid: int | None = None,
force: bool = False,
fallback_inject: bool = False,
repeat_vhead: bool = False,
) -> None:
"""Emit DMRA to an HBP target on VHEAD (once per target stream)."""
if not self._send_dmra_to_system:
return
tgt_mode = self._config.get("SYSTEMS", {}).get(target_system, {}).get("MODE")
if tgt_mode not in ("MASTER", "PEER"):
return
blocks = self._get_stream_dmra_blocks(source_system, stream_id)
have_passthrough = bool(blocks and passthrough_complete(blocks))
if have_passthrough:
if force:
if not self._talker_alias.should_resend_passthrough_dmra(target_system, stream_id):
return
elif not repeat_vhead and not self._talker_alias.should_send_on_vhead(target_system, stream_id):
return
elif not repeat_vhead and not self._talker_alias.should_send_on_vhead(target_system, stream_id):
return
if not have_passthrough:
# Legacy resolve_ta (both): inject at VHEAD when the buffer is still empty.
fallback_inject = True
packets = self._talker_alias.packets_for_stream(
source_system,
rf_src,
stream_id,
self._get_dmra_blocks,
target_system=target_system,
fallback_inject=fallback_inject,
)
if not packets:
return
if wire_slot is None or tgid is None:
logger.warning(
"(%s) *TALKER ALIAS* stream %s DMRA not sent: missing slot/tgid",
target_system,
int_id(stream_id),
)
return
exclude = source_peer if target_system == source_system else None
try:
peer_count = self._send_dmra_to_system(
target_system,
packets,
exclude_peer=exclude,
slot=wire_slot,
tgid=tgid,
)
except Exception as e:
logger.warning("(ROUTER) send_dmra_to_system %s failed: %s", target_system, e)
return
self._talker_alias.mark_dmra_sent(
target_system,
stream_id,
kind="passthrough" if have_passthrough else "inject",
)
sid = int_id(stream_id)
if peer_count:
logger.debug(
"(%s) *TALKER ALIAS* stream %s sent %d DMRA block(s) to %d peer(s) TG %s slot %s",
target_system, sid, len(packets), peer_count, tgid, wire_slot,
)
elif exclude:
logger.debug(
"(%s) *TALKER ALIAS* stream %s no DMRA sent (source peer %s excluded on repeat)",
target_system, sid, int_id(exclude),
)
def send_talker_alias_local_repeat(
self,
system_name: str,
source_peer: bytes,
rf_src: bytes,
stream_id: bytes,
wire_slot: int,
tgid: int,
) -> None:
"""Inject/pass-through TA to other peers on this MASTER (REPEAT path)."""
self._send_talker_alias_to_target(
system_name, system_name, rf_src, stream_id, source_peer,
wire_slot=wire_slot, tgid=tgid,
repeat_vhead=True,
)
def prepare_talker_alias_local_repeat(
self,
system_name: str,
source_peer: bytes,
rf_src: bytes,
dst_id: bytes,
slot: int,
stream_id: bytes,
) -> None:
"""REPEAT on VHEAD: standalone DMRA plus embedded TA state for downlink DMRD.
WPSD/MMDVMHost ignores standalone DMRA UDP; it only displays Talker Alias decoded
from embedded LC inside repeated voice bursts (B–E).
"""
settings = talker_alias_settings(self._config, system_name)
if settings["enabled"] and self._get_protocols:
proto = self._get_protocols().get(system_name)
status = getattr(proto, "STATUS", None) if proto else None
if isinstance(status, dict) and slot in status:
st = status[slot]
if not isinstance(st, dict):
st = {}
status[slot] = st
if st.get("REP_STREAM_ID") != stream_id:
dst_lc = LC_OPT + dst_id + rf_src
st["REP_STREAM_ID"] = stream_id
st["REP_EMB_LC"] = self._encode_emblc(dst_lc)
self._init_talker_alias_embed(
st, system_name, system_name, rf_src, stream_id,
)
self.send_talker_alias_local_repeat(
system_name, source_peer, rf_src, stream_id, slot, int_id(dst_id),
)
def rewrite_repeat_voice_burst(
self,
system_name: str,
slot: int,
stream_id: bytes,
dtype_vseq: int,
dmrpkt: bytes,
) -> bytes:
"""Overlay Talker Alias on embedded LC for REPEAT copies (voice bursts B–E)."""
if dtype_vseq not in (1, 2, 3, 4) or len(dmrpkt) < 33:
return dmrpkt
if not self._get_protocols:
return dmrpkt
proto = self._get_protocols().get(system_name)
status = getattr(proto, "STATUS", None) if proto else None
if not isinstance(status, dict):
return dmrpkt
st = status.get(slot)
if not isinstance(st, dict) or st.get("REP_STREAM_ID") != stream_id:
return dmrpkt
if "REP_EMB_LC" not in st or st.get("TX_TA_EMB") is None:
return dmrpkt
dmrbits = bitarray(endian="big")
dmrbits.frombytes(dmrpkt)
self._rewrite_embed_lc(dmrbits, st, dtype_vseq, "REP_EMB_LC")
return dmrbits.tobytes()
def clear_talker_alias_stream(self, system_name: str, stream_id: bytes) -> None:
"""Release per-stream TA dedupe state after VTERM."""
self._cancel_both_ta_wait(system_name, stream_id)
self._passthrough_relayed.discard(self._both_ta_key(system_name, stream_id))
self._talker_alias.clear_stream(system_name, stream_id)
if not self._get_protocols:
return
proto = self._get_protocols().get(system_name)
if proto is not None and hasattr(proto, "clear_ta_stream_buffer"):
proto.clear_ta_stream_buffer(stream_id)
if proto is None:
return
status = getattr(proto, "STATUS", None)
if not isinstance(status, dict):
return
for slot in (1, 2):
st = status.get(slot)
if not isinstance(st, dict):
continue
if st.get("TX_STREAM_ID") == stream_id:
self._clear_talker_alias_embed(st)
if st.get("REP_STREAM_ID") == stream_id:
st.pop("REP_STREAM_ID", None)
st.pop("REP_EMB_LC", None)
self._clear_talker_alias_embed(st)
def _obp_talker_alias_target(self, target_system: str) -> bool:
"""OpenBridge mesh legs carry group LC only — no DMRA or embedded Talker Alias."""
return self._config.get("SYSTEMS", {}).get(target_system, {}).get("MODE") == "OPENBRIDGE"
def _init_talker_alias_embed(
self,
st: dict[str, Any],
source_system: str,
target_system: str,
rf_src: bytes,
stream_id: bytes,
*,
force_inject: bool = False,
) -> None:
"""Prepare per-stream embedded TA state for DMRD voice injection.
The group LC is always rewritten for the destination TG by ``_rewrite_embed_lc``;
here we only set ``TX_TA_EMB`` when a Talker Alias should be overlaid. The source
TA (passthrough/both) is re-encoded from its decoded DMRA/voice blocks once they
arrive; the template is used for ``inject`` and the ``both`` fallback. If no TA is
available yet (e.g. at VHEAD, before the source TA has been decoded), TX_TA_EMB is
left unset and only the destination group LC is emitted until it becomes available.
OpenBridge targets never receive embedded TA (mesh peers do not decode it).
"""
if self._obp_talker_alias_target(target_system):
return
st["_ta_source_system"] = source_system
st["_ta_target_system"] = target_system
settings = talker_alias_settings(self._config, source_system)
if not settings["enabled"]:
return
if settings["mode"] == "both" and not self._ta_capable_source(source_system):
force_inject = True
st.pop("TX_TA_EMB", None)
st.pop("TX_TA_PHASE", None)
st.pop("TX_TA_ON", None)
st.pop("_ta_last_embed_burst", None)
st.pop("_ta_last_embed_frag", None)
emblcs = self._talker_alias.embedded_emblc_for_stream(
source_system,
rf_src,
stream_id,
self._get_dmra_blocks,
target_system=target_system,
fallback_inject=force_inject,
)
if emblcs:
st["TX_TA_EMB"], st["TX_TA_BLOCK_COUNT"] = emblcs
st["TX_TA_PHASE"] = 0
# First B1–B4 cycle carries group LC; TA on the next cycle.
st["TX_TA_ON"] = False
blocks = self._get_stream_dmra_blocks(source_system, stream_id)
if blocks and passthrough_complete(blocks) and not force_inject:
st["_ta_embed_kind"] = "passthrough"
else:
st["_ta_embed_kind"] = "inject"
def _rewrite_embed_lc(
self,
dmrbits: bitarray,
st: dict[str, Any],
dtype_vseq: int,
emb_key: str,
) -> None:
"""Replace embedded LC on voice bursts B–E (legacy bridge.py parity).
Group-call superframes always carry the **destination** group LC (``emb_key``),
re-encoded for the rewritten TGID — this is required for the voice to be accepted
by the receiving MMDVM (a mismatched embedded LC causes packet loss). When a
Talker Alias is available (``TX_TA_EMB``: injected template or the source TA
re-encoded from its DMRA/voice blocks) it is overlaid on alternate superframes.
"""
if dtype_vseq not in (1, 2, 3, 4):
return
dmrpkt = dmrbits.tobytes()
burst_key = (dtype_vseq, dmrpkt)
# Duplicated uplink bursts (same B–E payload) must not advance the TA phase
# machine; REPEAT runs before ingress duplicate drops (legacy lastData parity).
if burst_key == st.get("_ta_last_embed_burst"):
last_frag = st.get("_ta_last_embed_frag")
if last_frag is not None:
dmrbits[EMB_LC_SLICE] = last_frag
return
ta_emb = st.get("TX_TA_EMB")
if ta_emb is not None and st.get("TX_TA_ON"):
phase = st.get("TX_TA_PHASE", 0)
block_count = st.get("TX_TA_BLOCK_COUNT", DMRA_BLOCK_COUNT)
frag = ta_emb[phase][dtype_vseq]
if dtype_vseq == 4:
st["TX_TA_ON"] = False
st["TX_TA_PHASE"] = (phase + 1) % block_count
else:
frag = st[emb_key][dtype_vseq]
if dtype_vseq == 4 and ta_emb is not None:
st["TX_TA_ON"] = True
dmrbits[EMB_LC_SLICE] = frag
st["_ta_last_embed_burst"] = burst_key
st["_ta_last_embed_frag"] = frag
def _clear_talker_alias_embed(self, st: dict[str, Any]) -> None:
st.pop("TX_TA_EMB", None)
st.pop("TX_TA_PHASE", None)
st.pop("TX_TA_BLOCK_COUNT", None)
st.pop("TX_TA_ON", None)
st.pop("_ta_embed_kind", None)
st.pop("_ta_last_embed_burst", None)
st.pop("_ta_last_embed_frag", None)

Powered by TurnKey Linux.