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

94 строки
6.4 KiB
Markdown

# Homebrew/MMDVM — что реально в эфире
Сверено с исходниками MMDVMHost (`DMRDirectNetwork.cpp`), DMRGateway, HBlink3 (`hblink.py`)
и спецификацией DL5DI/G4KLX/DG1HT 2015 года. **Где спецификация расходится с кодом —
авторитетен код:** именно он крутится на каждом Pi-Star, а спецификацию никто до конца
не реализовал.
## ⚠️ Тест-вектор из спецификации ОШИБОЧЕН — не «чините» по нему код
Спецификация приводит: salt `0A7ED498`, пароль `DL5DI` →
`cbf0e29abbd11c6573825d36a664e3064441f91fc815ac5dc5ca570d4c4a5f85`.
Это значение получается из **ASCII-строки** `"0A7ED498"`, хотя тремя абзацами выше та же
спецификация требует «трактовать 32-битное значение как битовый шаблон, а не как число».
Проверено:
```
sha256(b'\x0a\x7e\xd4\x98' + b'DL5DI') = a763d5c7… ← так делают ВСЕ реализации
sha256(b'0A7ED498' + b'DL5DI') = cbf0e29a… ← так написано в тест-векторе
```
Сырые байты используют MMDVMHost (`memcpy(in, m_salt, 4)`), HBlink3 (`bytes_4(SALT) + PASSPHRASE`)
и наш `analyzer.auth_hash`. **Выбивается только бумажка.** Если «поправить» код под
тест-вектор — вход перестанут проходить все хотспоты.
## Расхождения спецификации и реальности
| | спецификация | на самом деле |
|---|---|---|
| Ответ мастера | `MSTACK` | **`RPTACK`** |
| Keepalive | хотспот `MSTPING`, мастер `RPTPONG` | **хотспот `RPTPING`, мастер `MSTPONG`** |
| RPTL | «семь байт» | **8 байт** |
| Порядок в RPTC | callsign, затем id | **id@4, затем callsign@8** |
| Описание/слоты | description 20 байт, поля слотов нет | **description 19@78 + slots 1@97** |
| Длина DMRD | 53 | **53 ядро + BER/RSSI = 55 в эфире** |
| Маски байта 15 | `0x01/0x02/0x0C/0xF0` | **`0x80/0x40/0x30/0x0F`** |
| Период пинга | «раз в минуту» | **5–15 с** |
## Кадр DMRD
| Смещение | Длина | Поле |
|---:|---:|---|
| 0 | 4 | `DMRD` |
| 4 | 1 | последовательность |
| 5 | 3 | src (uint24 BE) |
| 8 | 3 | dst — talkgroup или абонент (uint24 BE) |
| 11 | 4 | **peer_id (uint32 BE) — переписывается на получателя** |
| 15 | 1 | биты |
| 16 | 4 | stream_id — **непрозрачные байты, не число** |
| 20 | 33 | бёрст DMR |
| 53 | 1 | BER (расширение G4KLX) |
| 54 | 1 | RSSI (расширение G4KLX) |
Байт 15: `0x80` слот (0=TS1, 1=TS2) · `0x40` тип вызова (0=группа, 1=приват) ·
`0x30` тип кадра (`00` голос, `01` voice sync, `10` data sync) · `0x0F` тип данных
либо номер голосового кадра. Начало передачи — data sync + `0x01`, конец — data sync + `0x02`.
## Три вещи, которые легко испортить
1. **Переписывать peer_id (11–14) на номер получателя.** Смысл поля — «ретранслятор,
которого касается кадр на этом соединении». Делает `router.retarget()` через хук
`encode_for`. MMDVMHost поле на приёме не проверяет, но DMRGateway на многих Pi-Star
стоит между ним и сетью.
2. **Не возвращать кадр отправителю** — мгновенная петля в эфире на дуплексном
ретрансляторе. Исключение — попугай, он отвечает намеренно и напрямую.
3. **Отправлять 55 байт, принимать 53/55/71.** DMRGateway молча выбрасывает 53-байтные
кадры (`length == HOMEBREW_DATA_PACKET_LENGTH`).
Терминаторы и заголовки регулярно теряются в эфире, поэтому начало передачи определяется
ещё и сменой `stream_id`, а конец — таймаутом 360 мс (6 пропущенных голосовых кадров).
## Попугай (TG 9990)
Записанные кадры проигрываются **дословно** — `payload[20:53]` как есть, меняется только
`stream_id`. Это не оптимизация, а необходимость: в DMRhub пробовали пересобирать фрейминг,
и получалось «секундное молчание» — рация видела вызов, но голос не декодировала. У кадров
из рации уже корректные sync/EMB/slot-type/AMBE, трогать их нечего.
Исключение — **приватный** эхотест: там нужен обратный адрес (`src=9990 → dst=вызывающий`),
дословный повтор не годится, потому что в нём `dst=9990` и рация не примет вызов как «мне».
Только для этого случая пересобираются LC-заголовок и терминатор (`dmr_voice` + `bptc`).
Темп проигрывания — 60 мс на кадр (шаг слота TDMA), пауза перед началом — 1 с.
Доставка — напрямую в хотспот вызывавшего, минуя маршрутизатор: так исключены петли
и не портится last-heard.
## Источники
- Спецификация: `DMRplus_IPSC_Protocol_for_HB_repeater.pdf` (DL5DI/G4KLX/DG1HT, 2015-07-26)
- MMDVMHost `DMRDirectNetwork.cpp` @ `7008c62` (до удаления прямого доступа к мастерам)
- DMRGateway `DMRNetwork.cpp`, HBlink3 `hblink.py`/`const.py`/`playback.py`
- Проверенная реализация: `viktor/dmrhubv2`, откуда взяты `analyzer.py`, `server.py`,
`parrot.py`, `dmr_voice.py`, `bptc.py`