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
Этот коммит содержится в:
2026-07-28 23:32:04 +09:00
co-authored by Claude Opus 4.8
Коммит 8e3b9144c3
17 изменённых файлов: 2680 добавлений и 0 удалений
+287
Просмотреть файл
@@ -0,0 +1,287 @@
"""DMRhub v2 · сборка DMR-голоса ИЗ ВОКОДЕРА в эфир (без parrot-шаблонов).
Нужно для инжекта чужого голоса (P25→DMR, TTS, приложение) DMR-пирам: собираем
голосовой суперкадр целиком в коде, с корректным FEC, без утечки чужой идентичности:
• Voice LC Header / Terminator = BPTC(196,96) над [LC9 + RS(12,9) 3Б] (+маска типа);
• Voice burst A = AMBE + BS voice sync;
• Voice bursts B,C,D,E = AMBE + EMB + фрагменты Embedded LC того же вызова;
• Voice burst F = AMBE + EMB (null embedded).
Порт проверенного энкодера RadioStar (бит-в-бит против реального захвата): RS(12,9),
Embedded LC vBPTC(128,72) с Hamming(16,11)+column parity, EMB QR(16,7,6). BPTC(196,96)
берём из v2 `bptc.encode`. Slot type/sync — BS-каркас; на RF MMDVMHost перегенерит под
свой Color Code. Чистый stdlib + bptc. Самотест: `python dmr_voice.py`.
"""
from __future__ import annotations
from . import bptc
# ── RS(12,9) над GF(256), prim.poly 0x11D, корни a^1..a^3 (сверено с захватом) ──
_EXP = [0] * 512
_LOG = [0] * 256
_a = 1
for _i in range(255):
_EXP[_i] = _a
_LOG[_a] = _i
_a <<= 1
if _a & 0x100:
_a ^= 0x11D
for _i in range(255, 512):
_EXP[_i] = _EXP[_i - 255]
def _gmul(x: int, y: int) -> int:
return 0 if (x == 0 or y == 0) else _EXP[_LOG[x] + _LOG[y]]
def _rs_gen():
g = [1]
for i in range(3):
root = _EXP[(1 + i) % 255]
ng = [0] * (len(g) + 1)
for j in range(len(g)):
ng[j] ^= g[j]
ng[j + 1] ^= _gmul(g[j], root)
g = ng
return g
_RSG = _rs_gen()
def rs129(lc9: bytes) -> bytes:
"""9 байт LC → 3 байта RS(12,9) чётности (raw, без маски типа)."""
out = list(lc9) + [0, 0, 0]
for i in range(9):
c = out[i]
if c:
for j in range(1, 4):
out[i + j] ^= _gmul(_RSG[j], c)
return bytes(out[9:])
# маски «полного LC» по типу данных (MMDVMHost CDMRFullLC)
_MASK_HEADER = (0x96, 0x96, 0x96)
_MASK_TERM = (0x99, 0x99, 0x99)
# ── Sync (BS-источник, ETSI TS 102 361-1). На RF MMDVMHost перегенерит ──
SYNC_BS_VOICE = 0x755FD7DF75F7
SYNC_BS_DATA = 0xDFF57D75DF5D
# slot type (CC1-каркас; MMDVMHost подставит свой CC на RF)
_SLOTTYPE_CC1 = {1: 0x11B8C, 2: 0x12A59}
def _slottype(cc: int, dt: int) -> int:
return _SLOTTYPE_CC1[dt]
# ── EMB QR(16,7,6) — таблица g4klx MMDVMHost ──
_ENC_1676 = (
0x0000, 0x0273, 0x04E5, 0x0696, 0x09C9, 0x0BBA, 0x0D2C, 0x0F5F, 0x11E2, 0x1391, 0x1507, 0x1774,
0x182B, 0x1A58, 0x1CCE, 0x1EBD, 0x21B7, 0x23C4, 0x2552, 0x2721, 0x287E, 0x2A0D, 0x2C9B, 0x2EE8,
0x3055, 0x3226, 0x34B0, 0x36C3, 0x399C, 0x3BEF, 0x3D79, 0x3F0A, 0x411E, 0x436D, 0x45FB, 0x4788,
0x48D7, 0x4AA4, 0x4C32, 0x4E41, 0x50FC, 0x528F, 0x5419, 0x566A, 0x5935, 0x5B46, 0x5DD0, 0x5FA3,
0x60A9, 0x62DA, 0x644C, 0x663F, 0x6960, 0x6B13, 0x6D85, 0x6FF6, 0x714B, 0x7338, 0x75AE, 0x77DD,
0x7882, 0x7AF1, 0x7C67, 0x7E14, 0x804F, 0x823C, 0x84AA, 0x86D9, 0x8986, 0x8BF5, 0x8D63, 0x8F10,
0x91AD, 0x93DE, 0x9548, 0x973B, 0x9864, 0x9A17, 0x9C81, 0x9EF2, 0xA1F8, 0xA38B, 0xA51D, 0xA76E,
0xA831, 0xAA42, 0xACD4, 0xAEA7, 0xB01A, 0xB269, 0xB4FF, 0xB68C, 0xB9D3, 0xBBA0, 0xBD36, 0xBF45,
0xC151, 0xC322, 0xC5B4, 0xC7C7, 0xC898, 0xCAEB, 0xCC7D, 0xCE0E, 0xD0B3, 0xD2C0, 0xD456, 0xD625,
0xD97A, 0xDB09, 0xDD9F, 0xDFEC, 0xE0E6, 0xE295, 0xE403, 0xE670, 0xE92F, 0xEB5C, 0xEDCA, 0xEFB9,
0xF104, 0xF377, 0xF5E1, 0xF792, 0xF8CD, 0xFABE, 0xFC28, 0xFE5B,
)
# LCSS (EMB): связь фрагментов Embedded LC (ETSI)
LCSS_SINGLE = 0b00
LCSS_FIRST = 0b01
LCSS_CONT = 0b11
LCSS_LAST = 0b10
def emb16(color_code: int, lcss: int, pi: int = 0) -> list[int]:
"""16-бит EMB: CC(4)+PI(1)+LCSS(2) + QR(16,7,6) parity → 16 бит MSB-first."""
value = ((color_code & 0x0F) << 3) | ((pi & 1) << 2) | (lcss & 0x03)
code = _ENC_1676[value & 0x7F]
return [(code >> i) & 1 for i in range(15, -1, -1)]
# ── bit-хелперы ──
def _bytes_to_bits(d):
o = []
for by in d:
for i in range(7, -1, -1):
o.append((by >> i) & 1)
return o
def _bits_to_bytes(bits):
out = bytearray()
for i in range(0, len(bits), 8):
b = 0
for j in range(8):
b = (b << 1) | bits[i + j]
out.append(b)
return bytes(out)
def _int_bits(v, n):
return [(v >> i) & 1 for i in range(n - 1, -1, -1)]
# ── Embedded LC vBPTC(128,72): Hamming(16,11)+column parity → 4 фрагмента B..E ──
def _csum5(lc9: bytes):
accum = sum(lc9[:9]) % 31
return [(accum >> i) & 1 for i in range(4, -1, -1)]
def _enc_16114(d):
"""Hamming(16,11,4) — 5 чётностей по 11 битам (dmr_utils3/hamming.py)."""
return [
d[0] ^ d[1] ^ d[2] ^ d[3] ^ d[5] ^ d[7] ^ d[8],
d[1] ^ d[2] ^ d[3] ^ d[4] ^ d[6] ^ d[8] ^ d[9],
d[2] ^ d[3] ^ d[4] ^ d[5] ^ d[7] ^ d[9] ^ d[10],
d[0] ^ d[1] ^ d[2] ^ d[4] ^ d[6] ^ d[7] ^ d[10],
d[0] ^ d[2] ^ d[5] ^ d[6] ^ d[8] ^ d[9] ^ d[10],
]
def encode_emblc(lc9: bytes) -> dict:
"""9-байтный Embedded LC → 4 фрагмента по 32 бита (бёрсты B,C,D,E)."""
cs = _csum5(lc9)
bits = _bytes_to_bits(lc9) # 72 бита
for pos, bit in zip((32, 43, 54, 65, 76), cs):
bits.insert(pos, bit)
for index in range(0, 112, 16): # Hamming(16,11) по 7 строкам
par = _enc_16114(bits[index:index + 11])
for hindex, hbit in zip(range(index + 11, index + 16), par):
bits.insert(hindex, hbit)
for index in range(16): # строка 7 = XOR строк 0..6
bits.insert(index + 112,
bits[index] ^ bits[index + 16] ^ bits[index + 32] ^ bits[index + 48]
^ bits[index + 64] ^ bits[index + 80] ^ bits[index + 96])
def frag(cols):
return [bits[c + r * 16] for c in cols for r in range(8)]
return {1: frag([0, 1, 2, 3]), 2: frag([4, 5, 6, 7]),
3: frag([8, 9, 10, 11]), 4: frag([12, 13, 14, 15])}
# ── Link Control + бёрсты ──
def lc9(dst: int, src: int, unit: bool = False) -> bytes:
"""9-байтный LC голосового вызова: FLCO + FID + svcOpts + dst(3) + src(3)."""
flco = 0x03 if unit else 0x00
return bytes([flco, 0x00, 0x00]) + dst.to_bytes(3, "big") + src.to_bytes(3, "big")
def _data_burst(info196, sync48: int, slottype20: int) -> bytes:
"""264-битный data-бёрст: info[0:98]+[166:264], slot type[98:108]+[156:166], sync[108:156]."""
bits = [0] * 264
bits[0:98] = info196[0:98]
bits[166:264] = info196[98:196]
st = _int_bits(slottype20, 20)
bits[98:108] = st[0:10]
bits[156:166] = st[10:20]
bits[108:156] = _int_bits(sync48, 48)
return _bits_to_bytes(bits)
def _full_lc_burst(dst, src, unit, cc, dt, mask) -> bytes:
l = lc9(dst, src, unit)
par = bytes(p ^ m for p, m in zip(rs129(l), mask))
info = bptc.encode(l + par) # 196 on-air info bits
return _data_burst(info, SYNC_BS_DATA, _slottype(cc, dt))
def voice_lc_header(dst, src, unit=False, cc=1) -> bytes:
"""Бёрст Voice LC Header (data type 1)."""
return _full_lc_burst(dst, src, unit, cc, 1, _MASK_HEADER)
def terminator(dst, src, unit=False, cc=1) -> bytes:
"""Бёрст Terminator with LC (data type 2)."""
return _full_lc_burst(dst, src, unit, cc, 2, _MASK_TERM)
def _put_ambe(ambe27: bytes) -> bytearray:
"""27 байт AMBE (3×9) → 33-байтный бёрст: AMBE в [0:108]+[156:264], середина = 0."""
out = bytearray(33)
out[0:13] = ambe27[0:13]
out[13] = ambe27[13] & 0xF0
out[19] = ambe27[13] & 0x0F
out[20:33] = ambe27[14:27]
return out
def ambe_from_burst(burst33: bytes) -> bytes:
"""Инверсия _put_ambe: из 33-байтного DMR голос-бёрста достать 27Б AMBE (3×9).
Середина ([13] млад.ниббл / [19] стар.ниббл) — sync/EMB, не AMBE."""
b = bytes(burst33[:33]).ljust(33, b"\x00")
out = bytearray(27)
out[0:13] = b[0:13]
out[13] = (b[13] & 0xF0) | (b[19] & 0x0F)
out[14:27] = b[20:33]
return bytes(out)
_LCSS_BY_VSEQ = {1: LCSS_FIRST, 2: LCSS_CONT, 3: LCSS_CONT, 4: LCSS_LAST}
def voice_burst(ambe27: bytes, vseq: int, dst, src, unit=False, cc=1) -> bytes:
"""Голосовой бёрст. vseq: 0=A(voice sync), 1..4=B..E(EMB+Embedded LC), 5=F(EMB null)."""
buf = _put_ambe(ambe27)
bits = _bytes_to_bits(buf)
if vseq % 6 == 0: # burst A — голосовой sync
bits[108:156] = _int_bits(SYNC_BS_VOICE, 48)
else:
v = vseq % 6
if v in (1, 2, 3, 4): # B,C,D,E — EMB + фрагмент Embedded LC
frag = encode_emblc(lc9(dst, src, unit))[v]
emb = emb16(cc, _LCSS_BY_VSEQ[v])
else: # F — EMB single, пустой фрагмент
frag = [0] * 32
emb = emb16(cc, LCSS_SINGLE)
bits[108:116] = emb[0:8]
bits[116:148] = list(frag)
bits[148:156] = emb[8:16]
return _bits_to_bytes(bits)
# ── Самотест: RS-вектор + round-trip LC-заголовка через bptc.decode ──
if __name__ == "__main__":
ok = True
L = bytes.fromhex("000000002706155cc1")
raw = rs129(L)
hdr = bytes(p ^ m for p, m in zip(raw, _MASK_HEADER))
trm = bytes(p ^ m for p, m in zip(raw, _MASK_TERM))
for name, got, exp in (("RS raw", raw.hex(), "505575"),
("RS header", hdr.hex(), "c6c3e3"),
("RS term", trm.hex(), "c9ccec")):
good = got == exp
ok = ok and good
print(f"{name:12}= {got} (эталон {exp}) {'OK' if good else 'FAIL'}")
# header round-trip: собрать бёрст → bptc.decode → те же LC9+parity
DST, SRC = 250, 1400001
h = voice_lc_header(DST, SRC, cc=1)
dec = bptc.decode(h[:33] if len(h) >= 33 else h) # 12 байт: LC9 + masked parity
l = lc9(DST, SRC)
exp12 = l + bytes(p ^ m for p, m in zip(rs129(l), _MASK_HEADER))
good = bytes(dec) == exp12
ok = ok and good
print(f"{'HDR bptc rt':12}= {bytes(dec).hex()} (эталон {exp12.hex()}) {'OK' if good else 'FAIL'}")
# Embedded LC B-E: 4 фрагмента по 32 бита
frags = encode_emblc(lc9(DST, SRC))
good = all(len(frags[i]) == 32 for i in (1, 2, 3, 4))
ok = ok and good
print(f"{'EmbLC frags':12}= {[len(frags[i]) for i in (1,2,3,4)]} {'OK' if good else 'FAIL'}")
# AMBE round-trip: voice_burst(ambe27) → ambe_from_burst == ambe27 (для A и B-бёрстов)
for vseq in (0, 1, 5):
a27 = bytes(range(27))
rt = ambe_from_burst(voice_burst(a27, vseq, DST, SRC))
g = rt == a27
ok = ok and g
print(f"{'AMBE rt v'+str(vseq):12}= {'OK' if g else 'FAIL ' + rt.hex()}")
print("=== ИТОГО:", "ВСЁ OK" if ok else "ЕСТЬ ОШИБКИ", "===")