Ядро протокола форкнуто из 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
288 строки
11 KiB
Python
288 строки
11 KiB
Python
"""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 "ЕСТЬ ОШИБКИ", "===")
|