Files
dmr-server/dmrsrv/bptc.py
T
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

140 строки
5.2 KiB
Python
Исполняемый файл

"""
RadioStar — DMR BPTC(196,96) codec (pure Python).
Ported from dmr_utils3 (N0MJS, GPL) + ETSI TS 102 361-1. Used to decode/encode
the 96-bit payloads carried by DATA_HEADER and CSBK bursts (data headers, CSBK
preambles, etc.). Lets the server READ and BUILD those control frames.
A burst's 196 info bits live at burst-bit positions [0:98] + [166:264]; the
middle 68 bits (slot type + sync) are spliced from a real burst of the same type.
"""
from __future__ import annotations
# interleaver index (on-air[INDEX_181[i]] = logical[i])
INDEX_181 = (
0, 181, 166, 151, 136, 121, 106, 91, 76, 61, 46, 31, 16, 1, 182, 167, 152, 137,
122, 107, 92, 77, 62, 47, 32, 17, 2, 183, 168, 153, 138, 123, 108, 93, 78, 63,
48, 33, 18, 3, 184, 169, 154, 139, 124, 109, 94, 79, 64, 49, 34, 19, 4, 185, 170,
155, 140, 125, 110, 95, 80, 65, 50, 35, 20, 5, 186, 171, 156, 141, 126, 111, 96,
81, 66, 51, 36, 21, 6, 187, 172, 157, 142, 127, 112, 97, 82, 67, 52, 37, 22, 7,
188, 173, 158, 143, 128, 113, 98, 83, 68, 53, 38, 23, 8, 189, 174, 159, 144, 129,
114, 99, 84, 69, 54, 39, 24, 9, 190, 175, 160, 145, 130, 115, 100, 85, 70, 55, 40,
25, 10, 191, 176, 161, 146, 131, 116, 101, 86, 71, 56, 41, 26, 11, 192, 177, 162,
147, 132, 117, 102, 87, 72, 57, 42, 27, 12, 193, 178, 163, 148, 133, 118, 103, 88,
73, 58, 43, 28, 13, 194, 179, 164, 149, 134, 119, 104, 89, 74, 59, 44, 29, 14,
195, 180, 165, 150, 135, 120, 105, 90, 75, 60, 45, 30, 15)
# the 96 data-bit positions in the deinterleaved matrix (72 LC/header + 24 RS/CRC)
DATA_POS = [
136, 121, 106, 91, 76, 61, 46, 31,
152, 137, 122, 107, 92, 77, 62, 47, 32, 17, 2,
123, 108, 93, 78, 63, 48, 33, 18, 3, 184, 169,
94, 79, 64, 49, 34, 19, 4, 185, 170, 155, 140,
65, 50, 35, 20, 5, 186, 171, 156, 141, 126, 111,
36, 21, 6, 187, 172, 157, 142, 127, 112, 97, 82,
7, 188, 173, 158, 143, 128, 113, 98, 83,
68, 53, 174, 159, 144, 129, 114, 99, 84, 69, 54, 39,
24, 145, 130, 115, 100, 85, 70, 55, 40, 25, 10, 191,
]
def _enc_15113(d):
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],
]
def _enc_1393(d):
return [
d[0] ^ d[1] ^ d[3] ^ d[5] ^ d[6],
d[0] ^ d[1] ^ d[2] ^ d[4] ^ d[6] ^ d[7],
d[0] ^ d[1] ^ d[2] ^ d[3] ^ d[5] ^ d[7] ^ d[8],
d[0] ^ d[2] ^ d[4] ^ d[5] ^ d[8],
]
def _bytes_to_bits(data):
out = []
for byte in data:
for i in range(7, -1, -1):
out.append((byte >> i) & 1)
return out
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 _burst_info_bits(burst33):
"""Extract the 196 on-air info bits from a 33-byte burst ([0:98]+[166:264])."""
allbits = _bytes_to_bits(burst33)
return allbits[0:98] + allbits[166:264]
def decode(burst33: bytes) -> bytes:
"""Decode a BPTC(196,96) burst -> 12 bytes (96 bits). No error correction.
DATA_POS index the on-air (interleaved) info bits directly (per dmr_utils)."""
onair = _burst_info_bits(burst33) # 196 on-air bits
bits = [onair[p] for p in DATA_POS] # extract 96 data bits
return _bits_to_bytes(bits)
def encode(data12: bytes) -> list:
"""Encode 12 bytes -> 196 on-air info bits (list)."""
bits = _bytes_to_bits(data12) # 96 bits
for _ in range(4):
bits.insert(0, 0) # R0-R3
for index in range(9):
spos = index * 15 + 1
epos = spos + 11
par = _enc_15113(bits[spos:epos])
for pbit in range(4):
bits.insert(epos + pbit, par[pbit])
for _ in range(60):
bits.append(0)
for col in range(15):
spos = col + 1
column = []
for _ in range(9):
column.append(bits[spos])
spos += 15
colp = _enc_1393(column)
cpar = 136 + col
for pbit in range(4):
bits[cpar] = colp[pbit]
cpar += 15
onair = [0] * 196
for i in range(196):
onair[INDEX_181[i]] = bits[i] # interleave
return onair
def splice_into_burst(template33: bytes, onair196: list) -> bytes:
"""Build a burst: info bits from onair196, sync+slot-type from a template burst
of the same data type (positions [98:166])."""
allbits = _bytes_to_bits(template33)
allbits[0:98] = onair196[0:98]
allbits[166:264] = onair196[98:196]
return _bits_to_bytes(allbits)
if __name__ == "__main__":
# decode the captured RT-4D DATA_HEADER (TEST123, 1400001 -> 1234567)
hdr = bytes.fromhex("157a07fb5a6681a8b2828e4b058d5d7f77fd7573388475f5803549e54a87813418")
dec = decode(hdr)
print("DATA_HEADER 12 bytes:", dec.hex())
# round-trip: encode(decode) info bits must match the burst's info bits
rt = splice_into_burst(hdr, encode(dec))
print("round-trip burst == original:", rt == hdr)
# decode a captured CSBK preamble
csbk = bytes.fromhex("47e7160d92e007ac4e398c7244cd5d7f77fd757ac996047099099220839a4a9b4e")
print("CSBK 12 bytes:", decode(csbk).hex())