feat: add DMR Talker Alias (DMRA and embedded DMRD)

Inject Talker Alias on HBP bridge/repeat via standalone DMRA packets
and embedded LC in forwarded DMRD voice (FLCO 4–7) for MMDVMHost/
DMRGateway. UTF-8 encoding (format 2) and LC/TA superframe alternation
for the full stream; clear embed state on VTERM.
pull/4/head
Rodrigo Pérez 4 months ago
parent 7969117602
commit 24d835948e

@ -17,6 +17,10 @@ GLOBAL:
PASS_SECURITY: "<set-in-adn-server.yaml>"
USERS_PASS: user_passwords.json
HASH_ENCRYPT: encryption_key.secret
# Talker Alias (HBP DMRA): see docs/en/server/user-guide/talker-alias.md
TALKER_ALIAS: false
TALKER_ALIAS_MODE: both
TALKER_ALIAS_FORMAT: "{callsign} {fname}"
REPORTS:
REPORT: true

@ -61,6 +61,16 @@ Server-wide defaults. Many keys can be overridden per system if `USE_ACL` (or si
| **USERS_PASS** | Filename for per-radio password JSON (optional). |
| **HASH_ENCRYPT** | Path to encryption key for password file handling. |
### Talker Alias (`GLOBAL`)
Optional DMR Talker Alias on HBP (`DMRA` packets). Full guide: [Talker Alias](talker-alias.md).
| Key | Meaning |
|-----|---------|
| **TALKER_ALIAS** | Enable server TA inject/passthrough (`false` default). |
| **TALKER_ALIAS_MODE** | `both` (default), `passthrough`, or `inject`. |
| **TALKER_ALIAS_FORMAT** | Template, e.g. `{callsign} {fname}`. Max **29** chars (protocol limit, not YAML). |
---
## `SYSTEMS` — common fields

@ -0,0 +1,99 @@
# Talker Alias (DMR)
DMR **Talker Alias** (ETSI TS 102 361-2, 2016) carries a short alphanumeric label in the voice stream. On the Homebrew Protocol (HBP) it appears as separate **`DMRA`** UDP packets (15 bytes each, up to four per transmission).
This is **not** the same as subscriber ID aliases in `subscriber_ids.json` used for logs and the monitor UI. Talker Alias is embedded signaling intended for **radio displays** (OLED, Hytera/MD380tools, etc.).
See also: [Configuration](configuration.md#talker-alias-global).
---
## How ADN handles Talker Alias
When enabled on a **MASTER** system, the server can:
| Mode | Behaviour |
|------|-----------|
| **`both`** (default) | Pass through TA from the source hotspot/radio when all four `DMRA` blocks were received; otherwise inject from `subscriber_ids` + template. |
| **`passthrough`** | Relay buffered `DMRA` only. |
| **`inject`** | Always build TA from the configured template and alias data. |
On **bridge forward** at voice header (`VHEAD`), the server sends four `DMRA` packets to each HBP target (**MASTER** peers or **PEER** upstream) once per stream, then forwards `DMRD` as usual.
**MMDVMHost / DMRGateway (Pi-Star, WPSD):** stock MMDVMHost does **not** consume standalone downlink `DMRA` UDP; it decodes Talker Alias from **embedded LC inside `DMRD` voice** (FLCO 4–7). When TA is enabled, ADN injects TA into the embedded LC of voice bursts **B–E** (dtype 1–4) on **bridge forward** to HBP targets, in addition to optional standalone `DMRA` packets for clients that support them.
On the **same MASTER**, when **`REPEAT`** copies group voice to other logged-in hotspots, the server also sends those four `DMRA` packets on `VHEAD` (excluding the transmitting peer). Bridge forwarding to the same system shares the same once-per-stream dedupe, so TA is not sent twice.
**Not supported:** OpenBridge legs (no standard `DMRA` on OBP/DMRE wire). Legacy ADN never implemented TA beyond debug logging.
---
## Configuration
Under **`GLOBAL`** (optional per-system override with the same keys):
```yaml
GLOBAL:
TALKER_ALIAS: false
TALKER_ALIAS_MODE: both
TALKER_ALIAS_FORMAT: "{callsign} {fname}"
```
| Key | Meaning |
|-----|---------|
| **TALKER_ALIAS** | Master switch (`false` by default). |
| **TALKER_ALIAS_MODE** | `both`, `passthrough`, or `inject`. Default **`both`** if omitted. |
| **TALKER_ALIAS_FORMAT** | Python format string; fields: `{callsign}`, `{fname}`, `{surname}`, `{id}`. |
Maximum string length is **29 characters** (ETSI / MMDVMHost). This limit is fixed in code and is **not** configurable, to avoid incompatible payloads on radios and hotspots.
Subscriber JSON may include `fname`, `surname`, or a dedicated `talker_alias` field per record.
---
## HBP `DMRA` wire format
| Offset | Field |
|--------|--------|
| 0–3 | `DMRA` |
| 4–6 | Source DMR ID (3 bytes, big-endian) |
| 7 | Block index 0–3 |
| 8–14 | 7 payload bytes |
Encoding uses **UTF-8 format** (format 2), matching MMDVMHost `DMRTA.cpp`.
Embedded TA in `DMRD` voice alternates superframes: one cycle (bursts B–E) with the normal group embedded LC, the next with a TA block (FLCO 4–7), repeating until the stream ends.
---
## Pi-Star / MMDVM operator settings
For TA to reach the **local RF radio**:
| Setting | Recommendation |
|---------|----------------|
| **DMR DumpTAData** | `1` (on, Pi-Star default): write embedded Talker Alias to the MMDVM log — required for Pi-Star dashboard / log-based tools to show TA; with `0` nothing appears there. This does **not** block TA on RF. |
| **DMR EmbeddedLCOnly** | `off` (default). If `on`, Talker Alias from the network is disabled. |
Pi-Star’s web dashboard does not show TA; use the radio OLED, MD380tools, or tools such as [pistar-lastqso](https://github.com/kencormack/pistar-lastqso).
**Radio compatibility:** Hytera PD6/7/9 and MD380tools firmware generally work. Many Motorola radios do not support TA; some older firmware may show audio issues when TA is present.
The **adn-monitor proxy** already forwards `DMRA` to the master unchanged.
---
## Limitations and follow-up work
| Topic | Status |
|-------|--------|
| TA on **OpenBridge** targets | Not available (no `DMRA` on standard OBP wire). |
| **Monitor dashboard** live TA column | Separate work (monitor uses DB aliases, not live `DMRA`). |
| Unrelated **`RuntimeError`** in bridge iteration | Tracked as a separate fix. |
---
## References
- [ETSI TS 102 361-2](http://www.etsi.org/deliver/etsi_ts/102300_102399/10236102/02.03.01_60/ts_10236102v020301p.pdf) (Talker Alias format)
- [MMDVMHost `DMRTA.cpp`](https://github.com/g4klx/MMDVMHost/blob/master/DMRTA.cpp) / [`DMRNetwork.cpp`](https://github.com/g4klx/MMDVMHost/blob/master/DMRNetwork.cpp) (`writeTalkerAlias`)

@ -61,6 +61,16 @@ Valores por defecto de todo el servidor. Muchas claves pueden sobrescribirse por
| **USERS_PASS** | Nombre de fichero JSON de contraseñas por radio (opcional). |
| **HASH_ENCRYPT** | Ruta a la clave de cifrado para el manejo del fichero de contraseñas. |
### Talker Alias (`GLOBAL`)
Talker Alias DMR opcional en HBP (paquetes `DMRA`). Guía completa: [Talker Alias](talker-alias.md).
| Clave | Significado |
|-------|-------------|
| **TALKER_ALIAS** | Activa inyección/passthrough de TA (`false` por defecto). |
| **TALKER_ALIAS_MODE** | `both` (por defecto), `passthrough` o `inject`. |
| **TALKER_ALIAS_FORMAT** | Plantilla, p. ej. `{callsign} {fname}`. Máx. **29** caracteres (límite de protocolo, no YAML). |
---
## `SYSTEMS` — campos comunes

@ -0,0 +1,99 @@
# Talker Alias (DMR)
El **Talker Alias** DMR (ETSI TS 102 361-2, 2016) transporta una etiqueta alfanumérica corta en el flujo de voz. En el protocolo Homebrew (HBP) aparece como paquetes UDP **`DMRA`** (15 bytes cada uno, hasta cuatro por transmisión).
**No** es lo mismo que los alias de IDs en `subscriber_ids.json` usados en logs y en el monitor. Talker Alias es señalización embebida para **pantallas de radio** (OLED, Hytera/MD380tools, etc.).
Ver también: [Configuración](configuration.md#talker-alias-global).
---
## Cómo lo gestiona ADN
Con la función activada en un sistema **MASTER**, el servidor puede:
| Modo | Comportamiento |
|------|----------------|
| **`both`** (por defecto) | Passthrough del TA del hotspot/radio origen si se recibieron los cuatro bloques `DMRA`; si no, inyección desde `subscriber_ids` + plantilla. |
| **`passthrough`** | Solo reenvía `DMRA` bufferizado. |
| **`inject`** | Siempre genera TA desde la plantilla y datos de alias. |
En el **reenvío por bridge** en cabecera de voz (`VHEAD`), el servidor envía cuatro paquetes `DMRA` a cada destino HBP (**MASTER** peers o **PEER** upstream) una vez por stream, y después reenvía `DMRD` como siempre.
**MMDVMHost / DMRGateway (Pi-Star, WPSD):** el MMDVMHost estándar **no** procesa `DMRA` UDP independiente en downlink; decodifica Talker Alias desde la **LC embebida en voz `DMRD`** (FLCO 4–7). Con TA activado, ADN inyecta el TA en la LC embebida de los bursts **B–E** (dtype 1–4) en el **reenvío por bridge** hacia destinos HBP, alternando LC de grupo y bloques TA durante todo el stream, además de los paquetes `DMRA` opcionales para clientes que los soporten.
En el **mismo MASTER**, cuando **`REPEAT`** copia voz de grupo a otros hotspots registrados, el servidor también envía esos cuatro `DMRA` en `VHEAD` (excluyendo el peer que transmite). Si el bridge apunta al mismo system, comparten la misma deduplicación por stream y el TA no se envía dos veces.
**No soportado:** tramos OpenBridge (no hay `DMRA` estándar en OBP/DMRE). El ADN legacy nunca implementó TA más allá de logs de depuración.
---
## Configuración
Bajo **`GLOBAL`** (opcional override por sistema con las mismas claves):
```yaml
GLOBAL:
TALKER_ALIAS: false
TALKER_ALIAS_MODE: both
TALKER_ALIAS_FORMAT: "{callsign} {fname}"
```
| Clave | Significado |
|-------|-------------|
| **TALKER_ALIAS** | Interruptor maestro (`false` por defecto). |
| **TALKER_ALIAS_MODE** | `both`, `passthrough` o `inject`. Por defecto **`both`** si se omite. |
| **TALKER_ALIAS_FORMAT** | Plantilla tipo Python; campos: `{callsign}`, `{fname}`, `{surname}`, `{id}`. |
La longitud máxima es **29 caracteres** (ETSI / MMDVMHost). Este límite está fijado en código y **no** es configurable, para evitar payloads incompatibles en radios y hotspots.
El JSON de suscriptores puede incluir `fname`, `surname` o un campo `talker_alias` por registro.
---
## Formato HBP `DMRA`
| Offset | Campo |
|--------|--------|
| 0–3 | `DMRA` |
| 4–6 | ID DMR origen (3 bytes, big-endian) |
| 7 | Índice de bloque 0–3 |
| 8–14 | 7 bytes de payload |
La codificación usa **formato UTF-8** (formato 2), igual que MMDVMHost `DMRTA.cpp`.
El TA embebido en voz `DMRD` alterna supertramas: un ciclo (bursts B–E) con la LC de grupo normal y el siguiente con un bloque TA (FLCO 4–7), repitiendo hasta el fin del stream.
---
## Ajustes Pi-Star / MMDVM
Para que el TA llegue a la **radio RF local**:
| Ajuste | Recomendación |
|--------|----------------|
| **DMR DumpTAData** | `1` (activado, valor por defecto en Pi-Star): escribe el Talker Alias embebido en el log MMDVM — necesario para que el dashboard de Pi-Star y herramientas basadas en log muestren TA; con `0` no aparece nada ahí. **No** bloquea el TA hacia RF. |
| **DMR EmbeddedLCOnly** | `off` (por defecto). Si está `on`, se desactiva el Talker Alias recibido de la red. |
El panel web de Pi-Star no muestra TA; use OLED de la radio, MD380tools o herramientas como [pistar-lastqso](https://github.com/kencormack/pistar-lastqso).
**Compatibilidad de radios:** Hytera PD6/7/9 y firmware MD380tools suelen funcionar. Muchas Motorola no soportan TA; firmware antiguo puede dar problemas de audio con TA presente.
El **proxy adn-monitor** ya reenvía `DMRA` al master sin modificar.
---
## Limitaciones y trabajo pendiente
| Tema | Estado |
|------|--------|
| TA en destinos **OpenBridge** | No disponible (sin `DMRA` estándar en OBP). |
| Columna TA en vivo en el **monitor** | Trabajo aparte (el monitor usa alias de BD, no `DMRA` en vivo). |
| **`RuntimeError`** no relacionado en iteración de bridges | Fix en rama separada. |
---
## Referencias
- [ETSI TS 102 361-2](http://www.etsi.org/deliver/etsi_ts/102300_102399/10236102/02.03.01_60/ts_10236102v020301p.pdf)
- [MMDVMHost `DMRTA.cpp`](https://github.com/g4klx/MMDVMHost/blob/master/DMRTA.cpp) / [`DMRNetwork.cpp`](https://github.com/g4klx/MMDVMHost/blob/master/DMRNetwork.cpp)

@ -50,6 +50,7 @@ nav:
- Guía de usuario:
- Introducción: server/user-guide/introduction.md
- Configuración: server/user-guide/configuration.md
- Talker Alias: server/user-guide/talker-alias.md
- Bridges y talkgroups: server/user-guide/bridges-and-talkgroups.md
- Números especiales (4000, 999x, eco): server/user-guide/special-numbers.md
- Llamadas privadas: server/user-guide/private-calls.md

@ -50,6 +50,7 @@ nav:
- User guide:
- Introduction: server/user-guide/introduction.md
- Configuration: server/user-guide/configuration.md
- Talker Alias: server/user-guide/talker-alias.md
- Bridges and talkgroups: server/user-guide/bridges-and-talkgroups.md
- Special numbers (4000, 999x, echo): server/user-guide/special-numbers.md
- Private calls: server/user-guide/private-calls.md

@ -40,10 +40,16 @@ from dmr_utils3 import decode
from dmr_utils3.const import LC_OPT
from ..domain import int_id, bytes_3, bytes_4, HBPF_DATA_SYNC, HBPF_SLT_VHEAD, HBPF_SLT_VTERM, STREAM_TO
from ..domain.talker_alias import DMRA_BLOCK_COUNT
from .ports import BridgeRouter
from .talker_alias_use_cases import TalkerAliasUseCases
logger = logging.getLogger(__name__)
# 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)
# While loop-control loser, re-send BCSQ periodically so peers stop forwarding if first UDP was lost (legacy sends once).
_BCSQ_LOSER_RESEND_SEC = 2.0
@ -91,6 +97,8 @@ class BridgeUseCases:
report_factory: Any = None,
on_bridge_deactivated: Any = None,
send_bcsq: Any = None,
send_dmra_to_system: Any = None,
get_dmra_blocks: Any = None,
) -> None:
self._router = bridge_router
self._config = config
@ -99,6 +107,129 @@ class BridgeUseCases:
self._report_factory = report_factory
self._on_bridge_deactivated = on_bridge_deactivated # (system_name: str) -> None; legacy disconnectedVoice
self._send_bcsq = send_bcsq # (system_name, tgid, stream_id) -> None; legacy OBP send_bcsq from router
self._send_dmra_to_system = send_dmra_to_system
self._get_dmra_blocks = get_dmra_blocks
self._talker_alias = TalkerAliasUseCases(config)
def _send_talker_alias_to_target(
self,
source_system: str,
target_system: str,
rf_src: bytes,
stream_id: bytes,
source_peer: bytes,
) -> 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
if not self._talker_alias.should_send_on_vhead(target_system, stream_id):
return
packets = self._talker_alias.packets_for_stream(
source_system,
rf_src,
stream_id,
self._get_dmra_blocks,
target_system=target_system,
)
if not packets:
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)
except Exception as e:
logger.warning("(ROUTER) send_dmra_to_system %s failed: %s", target_system, e)
return
sid = int_id(stream_id)
if peer_count:
logger.debug(
"(%s) *TALKER ALIAS* stream %s sent %d DMRA block(s) to %d peer(s)",
target_system, sid, len(packets), peer_count,
)
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,
) -> 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,
)
def clear_talker_alias_stream(self, system_name: str, stream_id: bytes) -> None:
"""Release per-stream TA dedupe state after VTERM."""
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 None:
return
status = getattr(proto, "STATUS", None)
if not isinstance(status, dict):
return
for slot in (1, 2):
st = status.get(slot)
if isinstance(st, dict) and st.get("TX_STREAM_ID") == stream_id:
self._clear_talker_alias_embed(st)
def _init_talker_alias_embed(
self,
st: dict[str, Any],
source_system: str,
target_system: str,
rf_src: bytes,
stream_id: bytes,
) -> None:
"""Prepare per-stream embedded TA state for DMRD voice injection."""
st.pop("TX_TA_EMB", None)
st.pop("TX_TA_PHASE", None)
st.pop("TX_TA_ON", None)
emblcs = self._talker_alias.embedded_emblc_for_stream(
source_system,
rf_src,
stream_id,
self._get_dmra_blocks,
target_system=target_system,
)
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
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; alternate group LC and TA blocks."""
if dtype_vseq not in (1, 2, 3, 4):
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
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)
def get_bridges(self) -> dict[str, list[dict[str, Any]]]:
"""Return current BRIDGES."""
@ -575,6 +706,8 @@ class BridgeUseCases:
_v = hbp_status.get(_k)
if isinstance(_v, dict) and _v.get("LAST", 0) < now - 180:
hbp_status.pop(_k, None)
if hasattr(protocol, "trim_dmra_streams"):
protocol.trim_dmra_streams()
def bridge_reset_loop(self) -> None:
"""Bridge reset iteration (legacy bridge_reset, 6s). Clear _reset and remove_bridge_system."""
@ -2177,6 +2310,13 @@ class BridgeUseCases:
_target_status[stream_id]["H_LC"] = bptc.encode_header_lc(dst_lc)
_target_status[stream_id]["T_LC"] = bptc.encode_terminator_lc(dst_lc)
_target_status[stream_id]["EMB_LC"] = bptc.encode_emblc(dst_lc)
self._init_talker_alias_embed(
_target_status[stream_id],
system_name,
entry["SYSTEM"],
rf_src,
stream_id,
)
logger.debug(
"(%s) Conference Bridge: %s, Call Bridged to OBP System: %s TS: %s, TGID: %s",
system_name, _bridge_table_name, entry["SYSTEM"], entry.get("TS", 1), int_id(target_tgid),
@ -2220,6 +2360,7 @@ class BridgeUseCases:
dmrbits = _target_status[stream_id]["H_LC"][0:98] + dmrbits[98:166] + _target_status[stream_id]["H_LC"][98:197]
elif frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VTERM:
dmrbits = _target_status[stream_id]["T_LC"][0:98] + dmrbits[98:166] + _target_status[stream_id]["T_LC"][98:197]
self._clear_talker_alias_embed(_target_status[stream_id])
if self._report_factory and hasattr(self._report_factory, "send_bridge_event"):
try:
call_duration = pkt_time - _target_status[stream_id].get("START", pkt_time)
@ -2231,7 +2372,9 @@ class BridgeUseCases:
except Exception:
pass
elif dtype_vseq in (1, 2, 3, 4):
dmrbits = dmrbits[0:116] + _target_status[stream_id]["EMB_LC"][dtype_vseq] + dmrbits[148:264]
self._rewrite_embed_lc(
dmrbits, _target_status[stream_id], dtype_vseq, "EMB_LC",
)
dmrpkt_out = dmrbits.tobytes()
_tmp_data = b"".join([_tmp_data, dmrpkt_out])
else:
@ -2300,6 +2443,13 @@ class BridgeUseCases:
_ts_st["TX_H_LC"] = bptc.encode_header_lc(dst_lc)
_ts_st["TX_T_LC"] = bptc.encode_terminator_lc(dst_lc)
_ts_st["TX_EMB_LC"] = bptc.encode_emblc(dst_lc)
self._init_talker_alias_embed(
_ts_st,
system_name,
entry["SYSTEM"],
rf_src,
stream_id,
)
logger.info(
"(%s) Conference Bridge: %s, Call Bridged to HBP System: %s TS: %s, TGID: %s",
system_name, _bridge_table_name, entry["SYSTEM"], entry_ts, int_id(entry_tgid_b),
@ -2313,6 +2463,10 @@ class BridgeUseCases:
)
except Exception:
pass
# First successful forward to HBP (may not be VHEAD if earlier frames were hangtime-blocked).
self._send_talker_alias_to_target(
system_name, entry["SYSTEM"], rf_src, stream_id, peer_id,
)
_ts_st["TX_TIME"] = pkt_time
_ts_st["TX_TYPE"] = dtype_vseq
# Slot bit rewrite (legacy bridge.py 457-460 / 770-773)
@ -2340,10 +2494,9 @@ class BridgeUseCases:
except Exception:
pass
elif dtype_vseq in (1, 2, 3, 4):
try:
dmrbits = dmrbits[0:116] + _ts_st["TX_EMB_LC"][dtype_vseq] + dmrbits[148:264]
except Exception:
pass
self._rewrite_embed_lc(
dmrbits, _ts_st, dtype_vseq, "TX_EMB_LC",
)
dmrpkt_out = dmrbits.tobytes()
# bridge_master.routerOBP.to_target HBP branch: _tmp_data + dmrpkt only (~2041-2042);
# HBP source adds BER/RSSI from payload (bridge.py routerHBP ~800).
@ -2360,6 +2513,9 @@ class BridgeUseCases:
forwarded.append(entry["SYSTEM"])
except Exception as e:
logger.warning("(ROUTER) send_to_system %s failed: %s", entry.get("SYSTEM"), e)
if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VTERM:
self._clear_talker_alias_embed(_ts_st)
self._talker_alias.clear_stream(entry["SYSTEM"], stream_id)
# Legacy bridge_master routerOBP ~2420-2434: after to_target, VTERM — CALL END log, END RX report, _fin, lastSeq
if (
source_is_obp

@ -0,0 +1,209 @@
# ADN DMR Peer Server - Talker Alias use cases
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
"""Talker Alias policy: template formatting, inject vs passthrough."""
from __future__ import annotations
import logging
from typing import Any
from ..domain import int_id
from ..domain.talker_alias import (
DMRA_BLOCK_COUNT,
DMRA_PAYLOAD_LEN,
build_dmra_packet,
build_dmra_packets,
buffer_from_blocks,
decode_7bit,
encode_talker_alias_emblc,
encode_talker_alias_emblc_from_blocks,
truncate_talker_alias,
)
logger = logging.getLogger(__name__)
VALID_MODES = frozenset({"inject", "passthrough", "both"})
def talker_alias_settings(config: dict[str, Any], system_name: str | None = None) -> dict[str, Any]:
"""Effective Talker Alias settings (GLOBAL with optional per-system override)."""
global_cfg = config.get("GLOBAL", {})
sys_cfg = config.get("SYSTEMS", {}).get(system_name or "", {}) if system_name else {}
enabled = sys_cfg.get("TALKER_ALIAS")
if enabled is None:
enabled = global_cfg.get("TALKER_ALIAS", False)
mode = sys_cfg.get("TALKER_ALIAS_MODE")
if mode is None:
mode = global_cfg.get("TALKER_ALIAS_MODE", "both")
if mode not in VALID_MODES:
mode = "both"
fmt = sys_cfg.get("TALKER_ALIAS_FORMAT")
if fmt is None:
fmt = global_cfg.get("TALKER_ALIAS_FORMAT", "{callsign} {fname}")
return {
"enabled": bool(enabled),
"mode": mode,
"format": str(fmt),
}
def format_talker_alias_text(config: dict[str, Any], rf_src: bytes) -> str:
"""Build display string from subscriber profile + template."""
settings = talker_alias_settings(config)
template = settings["format"]
rid = int_id(rf_src)
profiles = config.get("_SUB_PROFILES", {})
profile = profiles.get(rid, {})
sub_ids = config.get("_SUB_IDS", {})
callsign = profile.get("callsign") or sub_ids.get(rid) or ""
fname = profile.get("fname") or ""
surname = profile.get("surname") or ""
if profile.get("talker_alias"):
text = str(profile["talker_alias"])
else:
try:
text = template.format(
callsign=callsign,
fname=fname,
surname=surname,
id=rid,
)
except (KeyError, ValueError):
text = callsign or f"DMR ID:{rid}"
text = " ".join(text.split())
if not text.strip():
text = callsign or f"DMR ID:{rid}"
return truncate_talker_alias(text)
def passthrough_complete(blocks: dict[int, bytes]) -> bool:
"""True when all four TA blocks were received."""
return all(i in blocks and len(blocks[i]) >= DMRA_PAYLOAD_LEN for i in range(DMRA_BLOCK_COUNT))
def passthrough_packets_from_blocks(rf_src: bytes, blocks: dict[int, bytes]) -> list[bytes]:
"""Rebuild four DMRA packets from buffered block payloads."""
packets: list[bytes] = []
for block_id in range(DMRA_BLOCK_COUNT):
payload = blocks.get(block_id, b"\x00" * DMRA_PAYLOAD_LEN)
packets.append(build_dmra_packet(rf_src, block_id, payload))
return packets
class TalkerAliasUseCases:
"""Orchestrate inject / passthrough for bridge forwarding."""
def __init__(self, config: dict[str, Any]) -> None:
self._config = config
self._sent_streams: set[tuple[str, bytes]] = set()
self._embed_logged: set[tuple[str, bytes]] = set()
def clear_stream(self, system_name: str, stream_id: bytes) -> None:
self._sent_streams.discard((system_name, stream_id))
self._embed_logged.discard((system_name, stream_id))
def should_send_on_vhead(self, target_system: str, stream_id: bytes) -> bool:
key = (target_system, stream_id)
if key in self._sent_streams:
return False
self._sent_streams.add(key)
return True
def packets_for_stream(
self,
source_system: str,
rf_src: bytes,
stream_id: bytes,
get_passthrough_blocks: Any,
*,
target_system: str | None = None,
) -> list[bytes] | None:
"""Return DMRA packets to send on VHEAD, or None if TA disabled / nothing to send."""
settings = talker_alias_settings(self._config, source_system)
if not settings["enabled"]:
return None
target = target_system or source_system
via = "repeat" if source_system == target else "bridge"
mode = settings["mode"]
blocks = get_passthrough_blocks(source_system, stream_id) if get_passthrough_blocks else None
have_passthrough = bool(blocks and passthrough_complete(blocks))
if mode == "passthrough":
if not have_passthrough:
return None
text = decode_7bit(buffer_from_blocks(blocks))
logger.debug(
"(%s) *TALKER ALIAS* passthrough '%s' via %s -> %s stream %s",
source_system, text, via, target, int_id(stream_id),
)
return passthrough_packets_from_blocks(rf_src, blocks)
if mode == "inject":
text = format_talker_alias_text(self._config, rf_src)
logger.debug(
"(%s) *TALKER ALIAS* inject '%s' via %s -> %s stream %s",
source_system, text, via, target, int_id(stream_id),
)
return build_dmra_packets(rf_src, text)
# both
if have_passthrough:
text = decode_7bit(buffer_from_blocks(blocks))
logger.debug(
"(%s) *TALKER ALIAS* passthrough '%s' via %s -> %s stream %s",
source_system, text, via, target, int_id(stream_id),
)
return passthrough_packets_from_blocks(rf_src, blocks)
text = format_talker_alias_text(self._config, rf_src)
logger.debug(
"(%s) *TALKER ALIAS* inject '%s' via %s -> %s stream %s",
source_system, text, via, target, int_id(stream_id),
)
return build_dmra_packets(rf_src, text)
def embedded_emblc_for_stream(
self,
source_system: str,
rf_src: bytes,
stream_id: bytes,
get_passthrough_blocks: Any,
*,
target_system: str | None = None,
) -> tuple[list[dict[int, Any]], int] | None:
"""Return (encode_emblc dicts, block count 1–4) for embedded TA in DMRD, or None."""
settings = talker_alias_settings(self._config, source_system)
if not settings["enabled"]:
return None
target = target_system or source_system
via = "repeat" if source_system == target else "bridge"
mode = settings["mode"]
log_key = (source_system, stream_id)
log_embed = log_key not in self._embed_logged
def _log_embed(text: str, passthrough: bool) -> None:
if not log_embed:
return
self._embed_logged.add(log_key)
kind = "passthrough" if passthrough else "inject"
logger.debug(
"(%s) *TALKER ALIAS* embed %s '%s' via %s -> %s stream %s",
source_system, kind, text, via, target, int_id(stream_id),
)
blocks = get_passthrough_blocks(source_system, stream_id) if get_passthrough_blocks else None
have_passthrough = bool(blocks and passthrough_complete(blocks))
if mode == "passthrough":
if not have_passthrough:
return None
text = decode_7bit(buffer_from_blocks(blocks))
_log_embed(text, True)
return encode_talker_alias_emblc_from_blocks(blocks)
if mode == "inject":
text = format_talker_alias_text(self._config, rf_src)
_log_embed(text, False)
return encode_talker_alias_emblc(text)
if have_passthrough:
text = decode_7bit(buffer_from_blocks(blocks))
_log_embed(text, True)
return encode_talker_alias_emblc_from_blocks(blocks)
text = format_talker_alias_text(self._config, rf_src)
_log_embed(text, False)
return encode_talker_alias_emblc(text)

@ -0,0 +1,184 @@
# ADN DMR Peer Server - DMR Talker Alias (ETSI / MMDVMHost)
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
"""Talker Alias encode/decode and HBP DMRA packet builders (UTF-8 format 2)."""
from __future__ import annotations
TALKER_ALIAS_MAX_LEN = 29
TA_FORMAT_7BIT = 0
TA_FORMAT_ISO8 = 1
TA_FORMAT_UTF8 = 2
DMRA_OPCODE = b"DMRA"
DMRA_PACKET_LEN = 15
DMRA_BLOCK_COUNT = 4
DMRA_PAYLOAD_LEN = 7
DMRA_BUF_LEN = DMRA_BLOCK_COUNT * DMRA_PAYLOAD_LEN
FLCO_TALKER_ALIAS_HEADER = 4
FLCO_TALKER_ALIAS_BLOCK3 = 7
TA_EMB_LC_LEN = 9
def truncate_talker_alias(text: str) -> str:
"""Hard-cap at protocol maximum (not user-configurable)."""
if len(text) <= TALKER_ALIAS_MAX_LEN:
return text
return text[:TALKER_ALIAS_MAX_LEN]
def decode_ta(buf: bytes) -> str:
"""Decode 28-byte TA buffer (formats 0–3). Mirrors MMDVMHost CDMRTA::decodeTA."""
if len(buf) < 1:
return ""
ta_format = (buf[0] >> 6) & 0x03
ta_size = (buf[0] >> 1) & 0x1F
if ta_format == 1 or ta_format == 2:
raw = buf[1 : 1 + ta_size]
return raw.decode("latin-1", errors="replace")
if ta_format != 0:
return ""
out = bytearray()
t1 = 0
t2 = 0
c = 0
for i in range(min(32, len(buf))):
if t2 >= ta_size:
break
for j in range(7, -1, -1):
c = ((c << 1) | ((buf[i] >> j) & 1)) & 0xFF
t1 += 1
if t1 == 7:
if i > 0:
out.append(c & 0x7F)
t2 += 1
if t2 >= ta_size:
break
t1 = 0
c = 0
return out[:ta_size].decode("ascii", errors="replace")
decode_7bit = decode_ta # backwards-compatible alias
def encode_utf8(text: str) -> bytes:
"""Encode text into 28-byte TA buffer (format 2 / UTF-8)."""
text = truncate_talker_alias(text)
raw = text.encode("utf-8")
if len(raw) > 27:
raw = truncate_talker_alias(text.encode("utf-8")[:27].decode("utf-8", errors="ignore")).encode("utf-8")
size = len(raw)
header = (TA_FORMAT_UTF8 << 6) | (size << 1) | 0
buf = bytearray(DMRA_BUF_LEN)
buf[0] = header
buf[1 : 1 + size] = raw
return bytes(buf)
def encode_7bit(text: str) -> bytes:
"""Encode text into 28-byte TA buffer (format 0 / 7-bit)."""
text = truncate_talker_alias(text)
size = len(text)
bits: list[int] = []
for b in (0, 0):
bits.append(b)
for b in range(4, -1, -1):
bits.append((size >> b) & 1)
for ch in text:
v = ord(ch) & 0x7F
for b in range(6, -1, -1):
bits.append((v >> b) & 1)
while len(bits) < DMRA_BUF_LEN * 8:
bits.append(0)
buf = bytearray(DMRA_BUF_LEN)
for i in range(DMRA_BUF_LEN):
byte = 0
for j in range(8):
byte = (byte << 1) | bits[i * 8 + j]
buf[i] = byte
return bytes(buf)
def blocks_from_buffer(buf: bytes) -> list[bytes]:
"""Split 28-byte encoded buffer into four 7-byte DMRA payloads."""
buf = buf.ljust(DMRA_BUF_LEN, b"\x00")[:DMRA_BUF_LEN]
return [buf[i * DMRA_PAYLOAD_LEN : (i + 1) * DMRA_PAYLOAD_LEN] for i in range(DMRA_BLOCK_COUNT)]
def required_ta_block_count(buf: bytes) -> int:
"""Blocks 1–4 actually needed (skip trailing zero payloads). ETSI allows 1–4."""
blocks = blocks_from_buffer(buf)
last = 0
for i in range(DMRA_BLOCK_COUNT):
if blocks[i] != b"\x00" * DMRA_PAYLOAD_LEN:
last = i
return max(1, last + 1)
def buffer_from_blocks(blocks: dict[int, bytes]) -> bytes:
"""Merge up to four block payloads into a 28-byte buffer."""
buf = bytearray(DMRA_BUF_LEN)
for block_id, payload in blocks.items():
if 0 <= block_id < DMRA_BLOCK_COUNT and payload:
start = block_id * DMRA_PAYLOAD_LEN
buf[start : start + DMRA_PAYLOAD_LEN] = payload[:DMRA_PAYLOAD_LEN]
return bytes(buf)
def build_dmra_packets(rf_src: bytes, text: str) -> list[bytes]:
"""Build HBP DMRA packets (1–4) for server injection."""
rf = rf_src[:3] if len(rf_src) >= 3 else rf_src.ljust(3, b"\x00")[:3]
encoded = encode_utf8(text)
blocks = blocks_from_buffer(encoded)
count = required_ta_block_count(encoded)
packets: list[bytes] = []
for block_id in range(count):
packets.append(DMRA_OPCODE + rf + bytes([block_id]) + blocks[block_id])
return packets
def build_dmra_packet(rf_src: bytes, block_id: int, payload: bytes) -> bytes:
"""Build one 15-byte DMRA packet (pass-through or re-encode block)."""
rf = rf_src[:3] if len(rf_src) >= 3 else rf_src.ljust(3, b"\x00")[:3]
block = max(0, min(3, int(block_id)))
pl = payload[:DMRA_PAYLOAD_LEN].ljust(DMRA_PAYLOAD_LEN, b"\x00")
return DMRA_OPCODE + rf + bytes([block]) + pl
def parse_dmra_packet(data: bytes) -> tuple[bytes, int, bytes] | None:
"""Parse DMRA packet; returns (rf_src, block_id, payload) or None."""
if len(data) < DMRA_PACKET_LEN or data[:4] != DMRA_OPCODE:
return None
rf_src = data[4:7]
block_id = data[7]
payload = data[8:15]
return rf_src, block_id, payload
def talker_alias_lc_bytes(block_id: int, payload7: bytes) -> bytes:
"""Build 9-byte embedded LC for one TA fragment (FLCO 4–7 + FID + 7 payload)."""
block = max(0, min(3, int(block_id)))
payload = payload7[:DMRA_PAYLOAD_LEN].ljust(DMRA_PAYLOAD_LEN, b"\x00")
return bytes([FLCO_TALKER_ALIAS_HEADER + block, 0x00]) + payload
def encode_talker_alias_emblc(text: str) -> tuple[list[dict[int, "bitarray.bitarray"]], int]:
"""Embedded-LC dicts for TA blocks 0..N-1 and block count N (1–4)."""
from dmr_utils3 import bptc
encoded = encode_utf8(text)
blocks = blocks_from_buffer(encoded)
count = required_ta_block_count(encoded)
emblcs = [bptc.encode_emblc(talker_alias_lc_bytes(i, blocks[i])) for i in range(count)]
return emblcs, count
def encode_talker_alias_emblc_from_blocks(blocks: dict[int, bytes]) -> tuple[list[dict[int, "bitarray.bitarray"]], int]:
"""Build embedded TA LC dicts from buffered DMRA payloads."""
from dmr_utils3 import bptc
buf = buffer_from_blocks(blocks)
buf_blocks = blocks_from_buffer(buf)
count = required_ta_block_count(buf)
emblcs = [bptc.encode_emblc(talker_alias_lc_bytes(i, buf_blocks[i])) for i in range(count)]
return emblcs, count

@ -84,6 +84,14 @@ def ensure_system_runtime_config(config: dict) -> None:
})
def apply_talker_alias_defaults(config: dict) -> None:
"""Default GLOBAL Talker Alias keys (mode both; max length is protocol-fixed in code)."""
g = config.setdefault("GLOBAL", {})
g.setdefault("TALKER_ALIAS", False)
g.setdefault("TALKER_ALIAS_MODE", "both")
g.setdefault("TALKER_ALIAS_FORMAT", "{callsign} {fname}")
def normalize_peer_config(config: dict) -> None:
"""Convert PEER systems from YAML to legacy format: MASTER_SOCKADDR, RADIO_ID/CALLSIGN/OPTIONS as bytes (config.py)."""
for name, sys_cfg in config.get("SYSTEMS", {}).items():

@ -218,3 +218,50 @@ class DefaultAliasLoader(AliasLoader):
logger.error("(ALIAS) ID ALIAS MAPPER: problem with blake2bsum of server_ids file: %s", e)
return {}
return self._load_server_tsv(path, file_name)
def load_subscriber_profiles(self, config: dict[str, Any]) -> dict[int, dict[str, str]]:
"""Load {id: {callsign, fname, surname, talker_alias?}} from subscriber JSON files."""
aliases = config.get("ALIASES", {})
path = Path(aliases.get("PATH", "./data/")).resolve()
sub_file = aliases.get("SUBSCRIBER_FILE", "subscriber_ids.json")
local_file = aliases.get("LOCAL_SUBSCRIBER_FILE", "subscriber_ids.json")
profiles: dict[int, dict[str, str]] = {}
for file_name in (sub_file, local_file):
self._merge_subscriber_profiles(path / file_name, profiles)
return profiles
def _merge_subscriber_profiles(self, file_path: Path, out: dict[int, dict[str, str]]) -> None:
if not file_path.is_file():
return
try:
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
return
if not isinstance(data, dict):
return
if "count" in data:
data = {k: v for k, v in data.items() if k != "count"}
for _key, val in data.items():
if not isinstance(val, list):
continue
for record in val:
if not isinstance(record, dict) or "id" not in record:
continue
try:
rid = int(record["id"])
except (ValueError, TypeError):
continue
entry: dict[str, str] = {}
if record.get("callsign"):
entry["callsign"] = str(record["callsign"])
if record.get("fname"):
entry["fname"] = str(record["fname"])
if record.get("surname"):
entry["surname"] = str(record["surname"])
if record.get("talker_alias"):
entry["talker_alias"] = str(record["talker_alias"])
if entry:
prev = out.get(rid, {})
prev.update(entry)
out[rid] = prev

@ -41,6 +41,7 @@ from twisted.internet import reactor, task
from twisted.internet.protocol import DatagramProtocol
from ...domain import int_id, bytes_4
from ...domain.talker_alias import DMRA_PACKET_LEN, parse_dmra_packet
from dmr_utils3 import decode
from dmr_utils3.const import LC_OPT
@ -145,6 +146,8 @@ class HBPProtocol(DatagramProtocol):
on_options_received: Callable[[str], None] | None = None,
on_deactivate_dynamic_bridges: Callable[[str], None] | None = None,
on_obp_bcsq_received: Callable[[str, bytes, bytes], None] | None = None,
on_talker_alias_local_repeat: Callable[[str, bytes, bytes, bytes], None] | None = None,
on_talker_alias_stream_end: Callable[[str, bytes], None] | None = None,
) -> None:
self._CONFIG = config
self._system = system_name
@ -158,6 +161,8 @@ class HBPProtocol(DatagramProtocol):
self._on_options_received = on_options_received
self._on_deactivate_dynamic_bridges = on_deactivate_dynamic_bridges
self._on_obp_bcsq_received = on_obp_bcsq_received
self._on_talker_alias_local_repeat = on_talker_alias_local_repeat
self._on_talker_alias_stream_end = on_talker_alias_stream_end
self._config = config.get("SYSTEMS", {}).get(system_name, {})
if self._config.get("MODE") == "OPENBRIDGE":
self._laststrid = deque([], 20)
@ -172,8 +177,14 @@ class HBPProtocol(DatagramProtocol):
self.STATUS = {1: _make_slot_status(), 2: _make_slot_status()}
if self._config.get("MODE") == "MASTER":
self._peers = self._config.get("PEERS", {})
self._dmra_by_stream: dict[bytes, dict[str, Any]] = {}
self._dmra_rf_stream: dict[tuple[bytes, bytes], bytes] = {}
else:
self._peers = {}
self._dmra_by_stream = {}
self._dmra_rf_stream = {}
if self._config.get("MODE") == "PEER":
self._dmra_downlink: dict[bytes, dict[str, Any]] = {}
if self._config.get("MODE") == "PEER":
self._stats = self._config.get("STATS", {})
else:
@ -221,6 +232,72 @@ class HBPProtocol(DatagramProtocol):
_packet = b"".join([_packet[:11], _peer, _packet[15:]])
self.transport.write(_packet, self._peers[_peer]["SOCKADDR"])
def note_dmrd_stream(self, peer_id: bytes, rf_src: bytes, stream_id: bytes) -> None:
"""Associate active stream with source for DMRA pass-through buffering."""
if self._config.get("MODE") != "MASTER" or not stream_id:
return
self._dmra_rf_stream[(peer_id, rf_src)] = stream_id
def store_dmra_packet(self, peer_id: bytes, data: bytes) -> None:
"""Buffer one DMRA block from a hotspot (MASTER receive path)."""
parsed = parse_dmra_packet(data)
if not parsed:
return
rf_src, block_id, payload = parsed
if block_id > 3:
return
stream_id = self._dmra_rf_stream.get((peer_id, rf_src))
if not stream_id:
return
now = time.time()
entry = self._dmra_by_stream.setdefault(
stream_id,
{"blocks": {}, "rf_src": rf_src, "peer": peer_id, "last": now},
)
entry["blocks"][block_id] = payload
entry["last"] = now
entry["rf_src"] = rf_src
def get_dmra_blocks(self, stream_id: bytes) -> dict[int, bytes] | None:
"""Return buffered DMRA block payloads for a stream, if any."""
entry = self._dmra_by_stream.get(stream_id)
if not entry:
return None
blocks = entry.get("blocks")
return dict(blocks) if isinstance(blocks, dict) else None
def trim_dmra_streams(self, max_age: float = 180.0) -> None:
"""Drop stale DMRA buffers (same order of magnitude as stream trimmer)."""
if self._config.get("MODE") != "MASTER":
return
now = time.time()
cutoff = now - max_age
for stream_id in list(self._dmra_by_stream):
if self._dmra_by_stream[stream_id].get("last", 0) < cutoff:
del self._dmra_by_stream[stream_id]
def send_dmra_to_peers(self, packets: list[bytes], exclude_peer: bytes | None = None) -> int:
"""Send DMRA packets to logged-in peers (MASTER downlink). Returns peer count."""
if self._config.get("MODE") != "MASTER":
return 0
sent = 0
for peer in self._peers:
if exclude_peer and peer == exclude_peer:
continue
for pkt in packets:
self.send_peer(peer, pkt)
sent += 1
return sent
def send_dmra_system(self, packets: list[bytes], exclude_peer: bytes | None = None) -> int:
"""Send DMRA on this system link (MASTER → peers, PEER → upstream master)."""
if self._config.get("MODE") == "MASTER":
return self.send_dmra_to_peers(packets, exclude_peer=exclude_peer)
if self._config.get("MODE") == "PEER":
for pkt in packets:
self.send_master(pkt)
return 0
def send_master(self, _packet: bytes, _hops: bytes = b"", _ber: bytes = b"\x00", _rssi: bytes = b"\x00", _source_server: bytes = b"\x00\x00\x00\x00", _source_rptr: bytes = b"\x00\x00\x00\x00") -> None:
if _packet[:4] == DMRD:
if len(_packet) < 54:
@ -467,12 +544,23 @@ class HBPProtocol(DatagramProtocol):
sub_map = self._CONFIG.get("_SUB_MAP")
if sub_map is not None:
sub_map[_rf_src] = (self._system, _slot, pkt_time)
self.note_dmrd_stream(_peer_id, _rf_src, _stream_id)
if self._config.get("REPEAT", True):
pkt = [_data[:11], b"", _data[15:]]
for _peer in self._peers:
if _peer != _peer_id:
pkt[1] = _peer
self.transport.write(b"".join(pkt), self._peers[_peer]["SOCKADDR"])
if (
self._config.get("REPEAT", True)
and _call_type in ("group", "vcsbk")
and _frame_type == HBPF_DATA_SYNC
and _dtype_vseq == HBPF_SLT_VHEAD
and self._on_talker_alias_local_repeat
):
self._on_talker_alias_local_repeat(
self._system, _peer_id, _rf_src, _stream_id,
)
# TG 4000: deactivate after REPEAT so peers see the packet (legacy order)
if _int_dst_id == 4000 and self._on_deactivate_dynamic_bridges:
_kind = "Private call to ID" if _call_type == "unit" else "Group call to TG"
@ -532,6 +620,13 @@ class HBPProtocol(DatagramProtocol):
and self._on_in_band_signalling
):
self._on_in_band_signalling(self._system, _slot, _dst_id, pkt_time)
if (
_call_type in ("group", "vcsbk")
and _frame_type == HBPF_DATA_SYNC
and _dtype_vseq == HBPF_SLT_VTERM
and self._on_talker_alias_stream_end
):
self._on_talker_alias_stream_end(self._system, _stream_id)
if _slot in self.STATUS:
self.STATUS[_slot]["RX_PEER"] = _peer_id
self.STATUS[_slot]["RX_SEQ"] = _seq
@ -712,7 +807,14 @@ class HBPProtocol(DatagramProtocol):
logger.info("(%s) Ping from Radio ID that is not logged in: %s", self._system, int_id(_peer_id))
elif _command == DMRA:
_peer_id = None
for pid, peer in self._peers.items():
if peer.get("SOCKADDR") == _sockaddr:
_peer_id = pid
break
if _peer_id is not None and len(_data) >= DMRA_PACKET_LEN:
logger.debug("(%s) Peer has sent Talker Alias packet %s", self._system, _data)
self.store_dmra_packet(_peer_id, _data)
elif _command == PRIN:
logger.info("(%s) *ProxyInfo* Connection from IP:Port: %s", self._system, _data.decode("utf8", errors="replace")[4:])
@ -886,6 +988,21 @@ class HBPProtocol(DatagramProtocol):
self.STATUS[_slot]["RX_TIME"] = pkt_time
self.STATUS[_slot]["RX_STREAM_ID"] = _stream_id
elif _command == DMRA:
if len(_data) >= DMRA_PACKET_LEN:
parsed = parse_dmra_packet(_data)
if parsed:
rf_src, block_id, payload = parsed
if block_id <= 3:
now = time.time()
entry = self._dmra_downlink.setdefault(
rf_src,
{"blocks": {}, "last": now},
)
entry["blocks"][block_id] = payload
entry["last"] = now
logger.debug("(%s) Talker Alias from master (downlink)", self._system)
elif _command == MSTN:
_peer_id = _data[6:10]
if self._config.get("LOOSE") or _peer_id == self._config.get("RADIO_ID"):
@ -1427,6 +1544,8 @@ def HBPProtocolFactory(
on_options_received: Callable[[str], None] | None = None,
on_deactivate_dynamic_bridges: Callable[[str], None] | None = None,
on_obp_bcsq_received: Callable[[str, bytes, bytes], None] | None = None,
on_talker_alias_local_repeat: Callable[[str, bytes, bytes, bytes], None] | None = None,
on_talker_alias_stream_end: Callable[[str, bytes], None] | None = None,
) -> HBPProtocol:
"""Create HBP protocol instance (legacy: one HBSYSTEM per system)."""
return HBPProtocol(
@ -1442,4 +1561,6 @@ def HBPProtocolFactory(
on_options_received=on_options_received,
on_deactivate_dynamic_bridges=on_deactivate_dynamic_bridges,
on_obp_bcsq_received=on_obp_bcsq_received,
on_talker_alias_local_repeat=on_talker_alias_local_repeat,
on_talker_alias_stream_end=on_talker_alias_stream_end,
)

@ -50,6 +50,7 @@ from twisted.internet import reactor, task, threads
from .domain import bytes_3
from .infrastructure import YamlConfigLoader, reopen_file_handlers, setup_logging
from .infrastructure.config_normalizer import (
apply_talker_alias_defaults as _apply_talker_alias_defaults,
expand_generator as _expand_generator,
ensure_system_runtime_config as _ensure_system_runtime_config,
normalize_peer_config as _normalize_peer_config,
@ -153,6 +154,7 @@ def main() -> None:
loader = YamlConfigLoader(project_root)
config = loader.load(config_path)
_apply_talker_alias_defaults(config)
# Voice config lives in a separate file for hot-reload without restart
voice_config_path = os.path.join(os.path.dirname(os.path.abspath(config_path)), "adn-voice.yaml")
@ -179,6 +181,7 @@ def main() -> None:
subscriber_ids[900999] = "D-APRS"
subscriber_ids[4294967295] = "SC"
config["_SUB_IDS"] = subscriber_ids
config["_SUB_PROFILES"] = alias_loader.load_subscriber_profiles(config)
config["_PEER_IDS"] = peer_ids
config["_TG_IDS"] = talkgroup_ids
config["_LOCAL_SUBSCRIBER_IDS"] = local_subscriber_ids
@ -217,6 +220,22 @@ def main() -> None:
if p is not None and hasattr(p, "send_system"):
p.send_system(packet, **kwargs)
def send_dmra_to_system(
system_name: str,
packets: list[bytes],
exclude_peer: bytes | None = None,
) -> int:
p = protocols.get(system_name)
if p is not None and hasattr(p, "send_dmra_system"):
return int(p.send_dmra_system(packets, exclude_peer=exclude_peer) or 0)
return 0
def get_dmra_blocks(system_name: str, stream_id: bytes) -> dict[int, bytes] | None:
p = protocols.get(system_name)
if p is not None and hasattr(p, "get_dmra_blocks"):
return p.get_dmra_blocks(stream_id)
return None
def send_bcsq(system_name: str, tgid: bytes, stream_id: bytes) -> None:
"""Legacy: bridge calls send_bcsq (e.g. loop control first OBP). OBP protocol only."""
p = protocols.get(system_name)
@ -281,6 +300,8 @@ def main() -> None:
report_factory=report_factory,
on_bridge_deactivated=lambda sys: reactor.callInThread(voice_use_cases.disconnected_voice, sys),
send_bcsq=send_bcsq,
send_dmra_to_system=send_dmra_to_system,
get_dmra_blocks=get_dmra_blocks,
)
bridge_use_cases.apply_startup_bridges()
report_factory.set_bridges(bridge_router.get_bridges())
@ -340,6 +361,7 @@ def main() -> None:
try:
p, s, t, l, sv, ch = alias_loader.load_aliases(config)
config["_SUB_IDS"] = s
config["_SUB_PROFILES"] = alias_loader.load_subscriber_profiles(config)
config["_PEER_IDS"] = p
config["_TG_IDS"] = t
config["_LOCAL_SUBSCRIBER_IDS"] = l
@ -473,6 +495,8 @@ def main() -> None:
on_options_received=bridge_use_cases.options_config_for_system,
on_deactivate_dynamic_bridges=bridge_use_cases.deactivate_all_dynamic_bridges,
on_obp_bcsq_received=bridge_use_cases.on_obp_bcsq_received,
on_talker_alias_local_repeat=bridge_use_cases.send_talker_alias_local_repeat,
on_talker_alias_stream_end=bridge_use_cases.clear_talker_alias_stream,
)
protocols[system_name] = protocol
reactor.listenUDP(udp_port, protocol, interface=ip or "0.0.0.0")

Loading…
Cancel
Save

Powered by TurnKey Linux.