DMR-сервер: мастер Homebrew на stdlib для Pi-Star
Ядро протокола форкнуто из 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
Этот коммит содержится в:
@@ -0,0 +1,150 @@
|
||||
"""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мс между кадрами
|
||||
Ссылка в новой задаче
Block a user