cart: документация сетевого моста в protocol.md

Разделы про роли и идентификацию устройства, прошивку STM (сборка/заливка
через ST-Link по SWD, подключения по ролям, проверка роли), кадр USB CDC,
сетевой слой (UDP, seq/новая сессия, HMAC, WireGuard), многоуровневый
failsafe и запуск моста.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
awe
2026-10-19 13:19:09 +03:00
co-authored by Claude Opus 4.8
parent 50f07caa64
commit 5b70a9a4ce
@@ -55,3 +55,154 @@
- Отправлять 25-байтовый кадр по таймеру каждые 50 мс. - Отправлять 25-байтовый кадр по таймеру каждые 50 мс.
- Нейтраль: все каналы 1024; управление — каналы 2 и 4 в измеренных - Нейтраль: все каналы 1024; управление — каналы 2 и 4 в измеренных
диапазонах. диапазонах.
# Сетевой мост «пульт → тележка»
Та же команда управления может идти не по проводу рядом, а через локальную
сеть/интернет, не ломая прежний проводной режим. Цепочка:
```
[пульт] ADC→ STM(CART-TX) ─USB─► bridge --role tx ─UDP─► bridge --role rx ─USB─► STM(CART-RX) ─SBUS─► драйвер
```
STM-TX продолжает гнать SBUS на свой локальный провод как раньше и
дополнительно отдаёт те же значения каналов по USB. STM-RX принимает их по USB
и выдаёт на свой провод так, будто сигнал пришёл с пульта.
## Роли и идентификация устройства
Роль выбирается на сборке (`platformio.ini`): `pio run -e cart_tx` или
`-e cart_rx`. Общий код (`sbus`, `usb_link`) собирается в оба env, специфичный —
через `build_src_filter`, мёртвого кода неактивной роли нет.
Каждая прошивка публикует USB **product string**: `CART-TX` / `CART-RX`. По ней
`tools/bridge.py` находит нужный порт (`serial.tools.list_ports`). После открытия
порта ПК шлёт `HELLO` и ждёт `IDENT` — подтверждение роли, версии протокола и
96-битного UID чипа (на случай неверно прошитого устройства, рассинхрона версий
или чужого CDC с тем же VID/PID). Serial-дескриптор (UID) различает экземпляры.
## Прошивка STM
Плата — WeAct STM32G431CB. Нужен PlatformIO Core (`pio`); если не установлен:
`pip install platformio` (в репозитории он лежит в `~/.platformio/penv/bin/pio`).
Все команды выполняются из каталога прошивки `device_firmware/cart_firmware`.
Две роли — две отдельные прошивки на две платы. По умолчанию загрузчик — ST-Link
по SWD (`upload_protocol = stlink` в `platformio.ini`).
**1. Подключить ST-Link к плате по SWD** (4 провода):
| ST-Link | WeAct G431CB |
|---|---|
| SWDIO | PA13 (SWDIO) |
| SWCLK | PA14 (SWCLK) |
| GND | GND |
| 3V3 | 3V3 |
**2. Собрать и прошить роль** (прошивать по одной плате за раз, один ST-Link):
```
# плата-передатчик (к ней подключён пульт)
pio run -e cart_tx -t upload
# плата-приёмник (к ней подключён провод драйвера колёс)
pio run -e cart_rx -t upload
```
- Только собрать без заливки: `pio run -e cart_tx`. Артефакты —
`.pio/build/<env>/firmware.{elf,bin}`.
- Если подключено несколько ST-Link, указать конкретный:
`pio run -e cart_tx -t upload --upload-port <serial-ST-Link>`.
- Сменить роль платы — просто залить другой env, флеш перезаписывается целиком.
**3. Физические подключения по ролям** (распиновка общая, см. выше по документу):
| | CART-TX | CART-RX |
|---|---|---|
| Стик X / Y | PA0 / PA1 (АЦП) | — не используется |
| Выход SBUS | PA9 → локальный драйвер (как раньше) | PA9 → провод драйвера колёс |
| USB | → ПК (bridge --role tx) | → ПК (bridge --role rx) |
**4. Проверить роль после прошивки.** Подключить плату по USB к ПК и прочитать
product string (роль зашита в неё):
```
python3 -c "from serial.tools import list_ports; \
[print(p.device, p.product, p.serial_number) for p in list_ports.comports()]"
# ожидаемо: /dev/ttyACM0 CART-TX <UID> либо CART-RX
```
Либо просто запустить `tools/bridge.py` соответствующей роли — на старте он
делает handshake и логирует подтверждённые роль, версию прошивки и UID.
> USB CDC теперь несёт **бинарный** протокол, а не текстовый лог. `pio device
> monitor` покажет «мусор» — это нормально. Отладочный текст идёт кадрами `LOG`,
> которые печатает `bridge.py`.
## Кадр поверх USB CDC
Единственный источник истины — `src/link_protocol.h`, зеркало на Python —
`tools/link_proto.py` (сверка: `python3 tools/link_proto.py --selftest`).
```
[0xA5][0x5A][VER=1][TYPE][LEN][PAYLOAD..LEN][CRC16_LO][CRC16_HI]
```
- CRC16-CCITT (poly `0x1021`, init `0xFFFF`) по байтам `[VER..конец PAYLOAD]`, LE.
Контрольный вектор: `crc16("123456789") = 0x29B1`.
- Приёмник (STM и Python) — стейт-машина: ищет синхрослово, читает по `LEN`,
проверяет CRC; при сбое сдвиг на байт и ресинхронизация (устойчивость к мусору
и потере байта).
- Типы: `0x01 CONTROL`, `0x02 IDENT`, `0x03 HELLO`, `0x04 LOG`. Отладочный текст
идёт только кадром `LOG` — «голых» `Serial.print`, ломающих бинарный поток, нет.
Payload `CONTROL` (LE): `seq:u32`, `t_ms:u32`, `flags:u8`, `ch[16]:u16` = 41 байт.
`ch[]` — значения SBUS-каналов 0..2047, ровно те, что TX кладёт на свой провод.
Это общая «валюта» всей цепочки: ПК перекладывает payload в UDP и обратно, RX
распаковывает и пакует в SBUS. `flags` bit0 = failsafe.
## Сетевой слой (между экземплярами bridge)
- Транспорт — **UDP**: для управления 20 Гц свежий кадр ценнее
доставленного; TCP при потере встал бы в head-of-line blocking и копил лаг.
- Полезная нагрузка UDP — тот же `CONTROL` payload. `seq` даёт отбрасывание
устаревших/повторных пакетов (защита от переупорядочивания и дубликатов UDP)
с корректной арифметикой переполнения uint32. Перезапуск источника (seq снова
с 0) распознаётся по пачке подряд «несвежих» пакетов и трактуется как новая
сессия — иначе поток завис бы в failsafe навсегда. Это best-effort защита от
повтора старых команд, не криптографическая: аутентичность даёт HMAC, против
целевого replay на недоверенной сети используй WireGuard.
- Приём из сети и выдача SBUS на провод — **разные таймеры**: джиттер сети не
попадает на провод, STM-RX гонит SBUS строго каждые 50 мс из последнего
состояния.
- **Безопасность.** Для интернета обязателен общий ключ (`--key`): каждый UDP-
пакет аутентифицируется усечённым HMAC-SHA256 (8 байт), RX отбрасывает
неаутентичные и принимает только с ожидаемого адреса (`--peer`). Рекомендуемый
способ наружу — WireGuard-туннель, внутри которого идёт простой UDP. «Голый»
неаутентифицированный UDP допустим только в доверенной локальной сети.
## FAILSAFE
Потеря связи никогда не оставляет тележку на последней команде — выход всегда
нейтраль (все каналы 1024), а не удержание.
- **Главный предохранитель — на самом STM-RX.** Если валидный `CONTROL` по USB
не приходил дольше `RX_USB_TIMEOUT_MS = 150` мс (3 кадра), STM-RX сам выдаёт на
провод нейтраль с флагом failsafe (SBUS bit3). Срабатывает при обрыве USB,
зависании или падении ПК-процесса. SBUS на провод идёт непрерывно 20 Гц в любом
случае — драйвер не видит пропажи сигнала.
- **Сетевой failsafe на ПК-RX.** При тишине из сети дольше 100 мс bridge активно
шлёт на STM-RX нейтральные `CONTROL` с флагом `CTRL_FLAG_FAILSAFE` в темпе
20 Гц, чтобы причина была явной. STM-RX транслирует этот флаг в бит failsafe
SBUS-кадра (byte 23 bit3). Восстановление — по первому свежему валидному пакету.
## Запуск
```
# сторона пульта
.venv/bin/python tools/bridge.py --role tx --peer 10.0.0.2:5005 --key SECRET
# сторона тележки
.venv/bin/python tools/bridge.py --role rx --bind 0.0.0.0:5005 --peer 10.0.0.1 --key SECRET
```
Локальная проверка без сети: оба экземпляра на одной машине с `--peer 127.0.0.1`
и двумя STM по USB; SBUS с RX снять `tools/decode.py` и сверить с осями стика TX.