Ядро протокола форкнуто из 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
253 строки
12 KiB
Python
253 строки
12 KiB
Python
"""DMRhub v2 · M1 — полный анализатор DMR/Homebrew-пакета (оффлайн, без сети).
|
||
|
||
Задача: по ЛЮБОМУ UDP-датаграмму отдать полностью разобранный объект.
|
||
• Homebrew (HBP) управляющий пакет → тип + peer_id (RPTL/RPTK/RPTC/RPTPING/…).
|
||
• DMRD (бинарный кадр DMR) → src, dst, peer_id, слот, call_type, frame_type,
|
||
data_type (с именем), stream_id, seq, BER/RSSI + классификация
|
||
(старт/конец голоса, CSBK, данные/SMS). Глубокий декод полезной нагрузки
|
||
(текст SMS, embedded Link Control / Talker Alias, CSBK-опкод) — шаги M1b/M1c.
|
||
|
||
Всё big-endian. Референс: ETSI TS 102 361-1, MMDVMHost/HBlink3, наш обкатанный
|
||
master/homebrew.py. M1a — ТОЛЬКО заголовки/классификация, детерминированно и без
|
||
внешних зависимостей → легко тестировать на дампах.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import struct
|
||
from dataclasses import dataclass, field
|
||
from enum import Enum
|
||
|
||
# ─────────────────────────── Homebrew (HBP) теги ───────────────────────────
|
||
# peer → master
|
||
TAG_LOGIN = b"RPTL" # RPTL + peer_id(4)
|
||
TAG_AUTH = b"RPTK" # RPTK + peer_id(4) + sha256(32)
|
||
TAG_CONFIG = b"RPTC" # RPTC + peer_id(4) + config(~306)
|
||
TAG_CLOSE = b"RPTCL" # RPTCL + peer_id(4)
|
||
TAG_PING = b"RPTPING" # RPTPING + peer_id(4)
|
||
TAG_OPTIONS = b"RPTO" # RPTO + peer_id(4) + options(...)
|
||
TAG_DMRD = b"DMRD" # DMRD + 51-байт payload (всего 55)
|
||
# master → peer
|
||
TAG_ACK = b"RPTACK" # RPTACK + 4 байта (salt при логине / id при успехе)
|
||
TAG_NAK = b"MSTNAK" # MSTNAK + peer_id(4)
|
||
TAG_PONG = b"MSTPONG" # MSTPONG + peer_id(4)
|
||
TAG_MSTCL = b"MSTCL" # MSTCL + peer_id(4)
|
||
|
||
|
||
class Kind(str, Enum):
|
||
LOGIN = "login"; AUTH = "auth"; CONFIG = "config"; CLOSE = "close"
|
||
PING = "ping"; OPTIONS = "options"; DMRD = "dmrd"
|
||
ACK = "ack"; NAK = "nak"; PONG = "pong"; MSTCL = "mstcl"
|
||
UNKNOWN = "unknown"
|
||
|
||
|
||
# ─────────────────────────── DMR: типы кадров ──────────────────────────────
|
||
class FrameType(int, Enum):
|
||
VOICE = 0x0 # голос-бёрст БЕЗ sync (B–E/F): несёт EMB + фрагмент embedded LC / Talker Alias
|
||
VOICE_SYNC = 0x1 # голос-бёрст A с sync-паттерном (embedded signalling тут НЕТ)
|
||
DATA_SYNC = 0x2 # данные/сигнализация (header, terminator, CSBK, data)
|
||
|
||
|
||
# data_type (младший ниббл при FrameType.DATA_SYNC) — ETSI TS 102 361-1
|
||
class DataType(int, Enum):
|
||
PI_HEADER = 0x0 # индикатор шифрования
|
||
VOICE_LC_HEADER = 0x1 # старт голосового вызова (Link Control)
|
||
TERMINATOR_LC = 0x2 # конец передачи (Link Control)
|
||
CSBK = 0x3 # Control Signalling Block (Call Alert/Radio Check/Stun/…)
|
||
MBC_HEADER = 0x4 # Multi-Block Control header
|
||
MBC_CONT = 0x5 # MBC continuation
|
||
DATA_HEADER = 0x6 # старт пакетных данных (SMS/GPS)
|
||
RATE12_DATA = 0x7 # блок данных rate-1/2
|
||
RATE34_DATA = 0x8 # блок данных rate-3/4
|
||
IDLE = 0x9
|
||
RATE1_DATA = 0xA # блок данных rate-1
|
||
|
||
|
||
CALL_GROUP = "group"
|
||
CALL_UNIT = "unit" # приват (рация↔рация)
|
||
|
||
|
||
def auth_hash(salt: bytes, passphrase: bytes) -> bytes:
|
||
"""sha256(salt + passphrase) — значение для RPTK."""
|
||
return hashlib.sha256(salt + passphrase).digest()
|
||
|
||
|
||
# ─────────────────────────── разобранные объекты ───────────────────────────
|
||
@dataclass
|
||
class HBP:
|
||
"""Разобранный управляющий (не-DMRD) Homebrew-пакет."""
|
||
kind: Kind
|
||
peer_id: int | None
|
||
raw: bytes
|
||
# для AUTH: присланный sha256; для CONFIG/OPTIONS: сырой хвост
|
||
auth: bytes | None = None
|
||
tail: bytes | None = None
|
||
# для CONFIG: разобранные поля RPTC (частоты, координаты, софт — «что шлёт Pi-Star»)
|
||
config: dict | None = None
|
||
|
||
|
||
# ── RPTC config: фикс-ширины ASCII (MMDVMHost/HBlink3). Смещения от начала пакета
|
||
# ('RPTC'[0:4] + id[4:8]). Старый мастер это НЕ парсил — теперь ловим всё. ──
|
||
_RPTC_FIELDS = [
|
||
("callsign", 8, 16, "s"), ("rx_freq", 16, 25, "i"), ("tx_freq", 25, 34, "i"),
|
||
("tx_power", 34, 36, "i"), ("color_code", 36, 38, "i"),
|
||
("lat", 38, 46, "f"), ("lon", 46, 55, "f"), ("height", 55, 58, "i"),
|
||
("location", 58, 78, "s"), ("description", 78, 97, "s"), ("slots", 97, 98, "i"),
|
||
("url", 98, 222, "s"), ("software_id", 222, 262, "s"), ("package_id", 262, 302, "s"),
|
||
]
|
||
|
||
|
||
def parse_rptc_config(raw: bytes) -> dict:
|
||
"""Разобрать конфиг хотспота из RPTC: частоты, координаты, локация, софт/версия."""
|
||
out: dict = {}
|
||
for name, a, b, typ in _RPTC_FIELDS:
|
||
chunk = raw[a:b]
|
||
if len(chunk) < (b - a):
|
||
continue
|
||
s = chunk.decode("ascii", "ignore").strip().strip("\x00").strip()
|
||
try:
|
||
if typ == "i":
|
||
out[name] = int(s) if s else None
|
||
elif typ == "f":
|
||
out[name] = float(s) if s and s not in ("-", ".") else None
|
||
else:
|
||
out[name] = s or None
|
||
except ValueError:
|
||
out[name] = s or None
|
||
return out
|
||
|
||
|
||
@dataclass
|
||
class DMR:
|
||
"""Полностью разобранный DMRD-кадр (заголовок + классификация).
|
||
Глубокий декод нагрузки заполняется на M1b/M1c (поля decoded/*)."""
|
||
raw: bytes
|
||
seq: int
|
||
src: int # ID рации-отправителя (talker)
|
||
dst: int # TG (group) или ID рации (unit)
|
||
peer_id: int # ID хотспота/репитера-источника
|
||
slot: int # 1 | 2
|
||
call_type: str # group | unit
|
||
frame_type: FrameType
|
||
data_type: int # DataType при DATA_SYNC, иначе voice-seq (0..5)
|
||
stream_id: int # одна непрерывная передача
|
||
ber: int | None = None # трейлер: BER (%)
|
||
rssi: int | None = None # трейлер: RSSI (−dBm)
|
||
decoded: dict = field(default_factory=dict) # M1b/M1c: sms/lc/ta/csbk
|
||
|
||
# ── классификация (что это за кадр) ──
|
||
@property
|
||
def data_type_name(self) -> str:
|
||
if self.frame_type == FrameType.DATA_SYNC:
|
||
try:
|
||
return DataType(self.data_type).name
|
||
except ValueError:
|
||
return f"DATA_0x{self.data_type:X}"
|
||
return f"VOICE_SEQ_{self.data_type}" # A..F голос-кадры несут vseq 0..5
|
||
|
||
@property
|
||
def is_voice_start(self) -> bool:
|
||
return self.frame_type == FrameType.DATA_SYNC and self.data_type == DataType.VOICE_LC_HEADER
|
||
|
||
@property
|
||
def is_terminator(self) -> bool:
|
||
return self.frame_type == FrameType.DATA_SYNC and self.data_type == DataType.TERMINATOR_LC
|
||
|
||
@property
|
||
def is_voice(self) -> bool:
|
||
return self.frame_type in (FrameType.VOICE, FrameType.VOICE_SYNC)
|
||
|
||
@property
|
||
def is_csbk(self) -> bool:
|
||
return self.frame_type == FrameType.DATA_SYNC and self.data_type == DataType.CSBK
|
||
|
||
@property
|
||
def is_data(self) -> bool:
|
||
"""Пакетные данные/SMS-поток (header + rate-блоки), НЕ голос и НЕ CSBK."""
|
||
return self.frame_type == FrameType.DATA_SYNC and self.data_type in (
|
||
DataType.DATA_HEADER, DataType.RATE12_DATA, DataType.RATE34_DATA,
|
||
DataType.RATE1_DATA, DataType.MBC_HEADER, DataType.MBC_CONT)
|
||
|
||
@property
|
||
def dmr_payload(self) -> bytes:
|
||
"""33 байта DMR-слота (FEC-кодировано; декод — по data_type на M1b)."""
|
||
return self.raw[20:53]
|
||
|
||
def summary(self) -> str:
|
||
who = f"src={self.src}→{'TG' if self.call_type==CALL_GROUP else 'unit'} {self.dst}"
|
||
q = f" ber={self.ber}%" if self.ber is not None else ""
|
||
return (f"DMRD peer={self.peer_id} TS{self.slot} {self.call_type} "
|
||
f"{self.frame_type.name}/{self.data_type_name} {who} "
|
||
f"stream={self.stream_id:08x} seq={self.seq}{q}")
|
||
|
||
|
||
# ─────────────────────────────── анализатор ────────────────────────────────
|
||
def _peer4(data: bytes, off: int) -> int | None:
|
||
return struct.unpack(">I", data[off:off + 4])[0] if len(data) >= off + 4 else None
|
||
|
||
|
||
def parse_dmrd(data: bytes) -> DMR:
|
||
"""DMRD (≥53 байта). Раскладка:
|
||
[0:4]'DMRD' [4]seq [5:8]src [8:11]dst [11:15]peer [15]bits
|
||
[16:20]stream [20:53]DMR-данные [53]BER? [54]RSSI?
|
||
Байт 15: slot=bit7, call(unit)=bit6, frame_type=bits5-4, data_type=bits3-0."""
|
||
seq = data[4]
|
||
src = int.from_bytes(data[5:8], "big")
|
||
dst = int.from_bytes(data[8:11], "big")
|
||
peer_id = int.from_bytes(data[11:15], "big")
|
||
bits = data[15]
|
||
slot = 2 if (bits & 0x80) else 1
|
||
call_type = CALL_UNIT if (bits & 0x40) else CALL_GROUP
|
||
frame_type = FrameType((bits & 0x30) >> 4)
|
||
data_type = bits & 0x0F
|
||
stream_id = int.from_bytes(data[16:20], "big")
|
||
ber = data[53] if len(data) >= 54 else None
|
||
rssi = data[54] if len(data) >= 55 else None
|
||
return DMR(raw=data, seq=seq, src=src, dst=dst, peer_id=peer_id, slot=slot,
|
||
call_type=call_type, frame_type=frame_type, data_type=data_type,
|
||
stream_id=stream_id, ber=ber, rssi=rssi)
|
||
|
||
|
||
def build_dmrd(dmr: "DMR", burst33: bytes) -> bytes:
|
||
"""Собрать 55-байт Homebrew DMRD-кадр из DMR-модели (для пересылки Homebrew-пиру)."""
|
||
b15 = ((0x80 if dmr.slot == 2 else 0) | (0x40 if dmr.call_type == CALL_UNIT else 0)
|
||
| ((int(dmr.frame_type) & 0x3) << 4) | (dmr.data_type & 0x0F))
|
||
return (TAG_DMRD + bytes([dmr.seq & 0xFF]) + dmr.src.to_bytes(3, "big") +
|
||
dmr.dst.to_bytes(3, "big") + dmr.peer_id.to_bytes(4, "big") + bytes([b15]) +
|
||
(dmr.stream_id & 0xFFFFFFFF).to_bytes(4, "big") +
|
||
bytes(burst33[:33]).ljust(33, b"\x00") +
|
||
bytes([dmr.ber or 0, dmr.rssi or 0]))
|
||
|
||
|
||
def analyze(data: bytes) -> HBP | DMR:
|
||
"""Классифицировать любой входящий UDP-датаграмм."""
|
||
if len(data) < 4:
|
||
return HBP(Kind.UNKNOWN, None, data)
|
||
if data[:4] == TAG_DMRD:
|
||
if len(data) < 53:
|
||
return HBP(Kind.UNKNOWN, _peer4(data, 11), data)
|
||
return parse_dmrd(data)
|
||
# управляющие: тег + peer_id(4) (у RPTPING/RPTCL/RPTACK/… тег разной длины)
|
||
if data[:4] == TAG_LOGIN:
|
||
return HBP(Kind.LOGIN, _peer4(data, 4), data)
|
||
if data[:4] == TAG_AUTH:
|
||
return HBP(Kind.AUTH, _peer4(data, 4), data, auth=data[8:40] if len(data) >= 40 else None)
|
||
# ВНИМАНИЕ: RPTCL (close, 5 байт) начинается с 'RPTC' — проверяем ДО RPTC (config).
|
||
if data[:5] == TAG_CLOSE:
|
||
return HBP(Kind.CLOSE, _peer4(data, 5), data)
|
||
if data[:4] == TAG_CONFIG:
|
||
return HBP(Kind.CONFIG, _peer4(data, 4), data, tail=data[8:], config=parse_rptc_config(data))
|
||
if data[:4] == TAG_OPTIONS:
|
||
return HBP(Kind.OPTIONS, _peer4(data, 4), data, tail=data[8:])
|
||
if data[:7] == TAG_PING:
|
||
return HBP(Kind.PING, _peer4(data, 7), data)
|
||
if data[:6] == TAG_ACK:
|
||
return HBP(Kind.ACK, _peer4(data, 6), data)
|
||
if data[:6] == TAG_NAK:
|
||
return HBP(Kind.NAK, _peer4(data, 6), data)
|
||
if data[:7] == TAG_PONG:
|
||
return HBP(Kind.PONG, _peer4(data, 7), data)
|
||
if data[:5] == TAG_MSTCL:
|
||
return HBP(Kind.MSTCL, _peer4(data, 5), data)
|
||
return HBP(Kind.UNKNOWN, None, data)
|