Ядро протокола форкнуто из dmrhubv2 (~1300 строк из 31700): analyzer, server, parrot, dmr_voice, bptc. Отрезаны портал, БД, мосты, алерты, динамические подписки. Своё: конфиг, состояние, маршрутизатор, панель, установщик. Ноль зависимостей — на Debian 13 действует PEP 668, pip в систему запрещён. Две правки под Pi-Star, которых в исходном коде не было: • peer_id переписывается на получателя (байты 11-14) через хук encode_for — так делают HBlink3/HBlink4 и BrandMeister, а DMRGateway на Pi-Star стоит между MMDVMHost и сетью; • приватный вызов при неизвестном абоненте уходит всем, а не в никуда (взято из HBlink4: user cache with broadcast fallback). docs/PROTOCOL.md фиксирует, что тест-вектор auth-хеша в спецификации 2015 года ОШИБОЧЕН (посчитан от ASCII-строки соли вместо четырёх байт). Правка кода по нему сломала бы вход всем хотспотам. tests/test_e2e.py: 15 проверок настоящими UDP-кадрами MMDVMHost. Все проходят. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XvX94Vwy7vXWEuiFm1g9Av
151 строка
8.5 KiB
Python
151 строка
8.5 KiB
Python
"""DMRhub v2 · Паррот (эхотест) на TG 9990.
|
|
|
|
Записывает групповой вызов на 9990 и после отпускания PTT проигрывает его ОБРАТНО —
|
|
абонент слышит себя («слышно ли в эфире»).
|
|
|
|
Ключ: проигрываем ЗАПИСАННЫЕ кадры ДОСЛОВНО (payload [20:53] как есть), только с новым
|
|
stream_id. У кадров рации уже корректные sync/EMB/slot-type/AMBE — ничего не пересобираем
|
|
(реконструкция фрейминга давала «секундное молчание»: рация видела вызов, но не декодировала
|
|
голос). Это бит-в-бит повтор принятого — гарантированно декодируется.
|
|
|
|
Доставка — НАПРЯМУЮ в хотспот вызвавшего (master._send на его addr), минуя роутер/гвард:
|
|
• не идёт через _route_dmr → не попадает обратно в feed (петли исключены by design);
|
|
• не портит last-heard рации и не триггерит анти-эхо мостов;
|
|
• слышит только тестирующий. Темп 60мс/кадр (как реальный слот TDMA).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import random
|
|
|
|
from .analyzer import DMR, FrameType, CALL_GROUP, CALL_UNIT, build_dmrd
|
|
from .server import PeerState
|
|
from .log import get_logger
|
|
from . import dmr_voice
|
|
|
|
log = get_logger(__name__)
|
|
|
|
# ДИАГНОСТИКА (env DMRHUB_PARROT_RECON): изоляция причины «тишины» v1. Дефолт пусто —
|
|
# дословный повтор (рабочий). Режимы:
|
|
# "voice" — пересобрать payload голос-бёрстов (voice_burst), заголовок/терм дословно;
|
|
# "header" — пересобрать заголовок/терминатор (voice_lc_header/terminator), голос дословно.
|
|
RECON = os.getenv("DMRHUB_PARROT_RECON", "")
|
|
RECON_VOICE = RECON in ("1", "voice")
|
|
RECON_HEADER = RECON == "header"
|
|
|
|
PARROT_TG = 9990
|
|
PARROT_SLOT = 2 # слот эхотеста (переопределяется из конфига)
|
|
PARROT_PEER = 0x0F990000 # peer_id в собранных DMRD (для direct-send не критичен)
|
|
FRAME_S = 0.06 # 60мс на кадр (шаг слота TDMA)
|
|
GAP_S = 1.0 # пауза после отпускания PTT перед проигрыванием
|
|
MAX_FRAMES = 4000 # предохранитель (~4 мин) от бесконечной записи
|
|
|
|
|
|
class _Rec:
|
|
__slots__ = ("caller_peer", "src", "frames", "group")
|
|
|
|
def __init__(self, caller_peer: int, src: int, group: bool = True):
|
|
self.caller_peer = caller_peer
|
|
self.src = src
|
|
self.group = group # True=групповой эхотест на 9990, False=приватный
|
|
self.frames: list[tuple] = [] # (frame_type, data_type, payload33) в порядке приёма
|
|
|
|
|
|
class Parrot:
|
|
"""Эхотест TG9990. `feed(dmr)` цепляется на on_dmrd мастера (как P25-мост)."""
|
|
|
|
def __init__(self, master):
|
|
self.master = master
|
|
self._rec: dict[tuple, _Rec] = {} # (peer_id, stream_id) → запись
|
|
|
|
def feed(self, dmr) -> None:
|
|
if dmr.peer_id == PARROT_PEER: # свой replay не писать (страховка)
|
|
return
|
|
if dmr.dst != PARROT_TG or dmr.slot != PARROT_SLOT:
|
|
return
|
|
if dmr.call_type not in (CALL_GROUP, CALL_UNIT): # групповой И приватный эхотест
|
|
return
|
|
is_group = dmr.call_type == CALL_GROUP
|
|
key = (dmr.peer_id, dmr.stream_id)
|
|
rec = self._rec.get(key)
|
|
if rec is None:
|
|
if dmr.is_terminator: # хвост без записи — игнор
|
|
return
|
|
rec = self._rec[key] = _Rec(dmr.peer_id, dmr.src, is_group)
|
|
|
|
payload = bytes(dmr.raw[20:53])
|
|
if len(payload) >= 33 and len(rec.frames) < MAX_FRAMES:
|
|
rec.frames.append((dmr.frame_type, int(dmr.data_type), payload[:33]))
|
|
|
|
if dmr.is_terminator:
|
|
self._rec.pop(key, None)
|
|
if any(ft in (FrameType.VOICE, FrameType.VOICE_SYNC) for ft, _, _ in rec.frames):
|
|
self._schedule(rec) # проигрываем, только если был голос
|
|
|
|
|
|
def _schedule(self, rec: _Rec) -> None:
|
|
try:
|
|
asyncio.ensure_future(self._replay(rec))
|
|
except RuntimeError:
|
|
log.warning("паррот: нет event-loop для проигрывания")
|
|
|
|
# ── сборка кадров эха из записанных payload (тестируемо отдельно от пейсинга) ──
|
|
def build_frames(self, rec: _Rec, sid: int) -> list[DMR]:
|
|
"""Пересобрать DMR-кадры эха. Группа — дословно (тот же payload, новый stream_id).
|
|
Приват — обратным адресом (src=9990 → dst=вызывающий, unit): дословный повтор не
|
|
годится, там dst=9990, рация не примет как «мне»."""
|
|
from analyzer import DataType
|
|
out: list[DMR] = []
|
|
if not rec.group: # ── приватный эхотест ──
|
|
dst, src = rec.src, PARROT_TG # эхо приватом ОБРАТНО вызывающему
|
|
for i, (ft, dt, payload33) in enumerate(rec.frames):
|
|
if ft in (FrameType.VOICE, FrameType.VOICE_SYNC):
|
|
pl = dmr_voice.voice_burst(dmr_voice.ambe_from_burst(payload33),
|
|
dt, dst, src, unit=True)
|
|
elif ft == FrameType.DATA_SYNC and dt == DataType.VOICE_LC_HEADER:
|
|
pl = dmr_voice.voice_lc_header(dst, src, unit=True)
|
|
elif ft == FrameType.DATA_SYNC and dt == DataType.TERMINATOR_LC:
|
|
pl = dmr_voice.terminator(dst, src, unit=True)
|
|
else:
|
|
pl = payload33
|
|
d = DMR(raw=b"", seq=i & 0xFF, src=src, dst=dst, peer_id=PARROT_PEER,
|
|
slot=PARROT_SLOT, call_type=CALL_UNIT, frame_type=ft,
|
|
data_type=dt, stream_id=sid)
|
|
d.raw = build_dmrd(d, pl)
|
|
out.append(d)
|
|
return out
|
|
for i, (ft, dt, payload33) in enumerate(rec.frames):
|
|
pl = payload33
|
|
is_voice = ft in (FrameType.VOICE, FrameType.VOICE_SYNC)
|
|
if RECON_VOICE and is_voice: # пересобрать голос-бёрст (dt = vseq 0..5)
|
|
pl = dmr_voice.voice_burst(dmr_voice.ambe_from_burst(payload33),
|
|
dt, PARROT_TG, rec.src)
|
|
elif RECON_HEADER and ft == FrameType.DATA_SYNC and dt == DataType.VOICE_LC_HEADER:
|
|
pl = dmr_voice.voice_lc_header(PARROT_TG, rec.src)
|
|
elif RECON_HEADER and ft == FrameType.DATA_SYNC and dt == DataType.TERMINATOR_LC:
|
|
pl = dmr_voice.terminator(PARROT_TG, rec.src)
|
|
d = DMR(raw=b"", seq=i & 0xFF, src=rec.src, dst=PARROT_TG, peer_id=PARROT_PEER,
|
|
slot=PARROT_SLOT, call_type=CALL_GROUP, frame_type=ft,
|
|
data_type=dt, stream_id=sid)
|
|
d.raw = build_dmrd(d, pl)
|
|
out.append(d)
|
|
return out
|
|
|
|
async def _replay(self, rec: _Rec) -> None:
|
|
peer = self.master.peers.get(rec.caller_peer)
|
|
if peer is None or peer.state != PeerState.CONNECTED or not self.master._send:
|
|
return
|
|
await asyncio.sleep(GAP_S) # дать рации перейти на приём
|
|
frames = self.build_frames(rec, random.getrandbits(32))
|
|
log.info("паррот: эхо src=%s → хотспот %s, %d кадров",
|
|
rec.src, rec.caller_peer, len(frames))
|
|
for i, d in enumerate(frames):
|
|
try:
|
|
self.master._send(d.raw, peer.addr)
|
|
except Exception:
|
|
log.exception("паррот: ошибка отправки эха")
|
|
return
|
|
if i < len(frames) - 1:
|
|
await asyncio.sleep(FRAME_S) # шаг слота 60мс между кадрами
|