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
Этот коммит содержится в:
@@ -0,0 +1,93 @@
|
||||
# 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`
|
||||
Ссылка в новой задаче
Block a user