Files
viktorиClaude Opus 4.8 8e3b9144c3 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

288 строки
11 KiB
Python
Исходник Постоянная ссылка Ответственный История

Этот файл содержит неоднозначные символы Юникода
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 "ЕСТЬ ОШИБКИ", "===")