Files
2026-10-22 12:57:33 +03:00

15 KiB
Raw Permalink Blame History

Протокол старого пульта тележки (реверс-инжиниринг)

Снято 2026-08-20 осциллографом Hantek DPO7204C с сигнального провода пульта (канал CH3). Инструменты: tools/capture.py (захват), tools/decode.py (декодер), сырые данные и картинки — в captures/.

Физический уровень

  • Один сигнальный провод, логика 3.3 В.
  • Инвертированный UART (стандартная полярность SBUS): в покое линия низкая (~0 В), импульсы вверх до ~3.3 В.
  • Скорость 100 000 бод, формат 8E2 (8 бит данных, чётность even, 2 стоп-бита), биты LSB-first. Длительность бита 10 мкс.

Кадровый уровень — SBUS

Стандартный кадр Futaba SBUS, 25 байт (3 мс на линии):

Смещение Размер Содержимое
0 1 Заголовок 0x0F
1 22 16 каналов × 11 бит, упакованы подряд LSB-first
23 1 Флаги: bit0=CH17, bit1=CH18, bit2=frame_lost, bit3=failsafe
24 1 Футер 0x00

Распаковка каналов: 22 байта складываются в 176-битное число LSB-first, канал N (N=0..15) = биты [11·N .. 11·N+10], диапазон значений 0–2047.

  • Кадры отправляются каждые 50 мс (20 Гц). Это медленнее стандартного SBUS (7/14 мс) — ответная часть с этим темпом работает.
  • Флаги во всех наблюдениях = 0x00.

Карта каналов (нумерация с 1)

Управление Канал Мин Нейтраль Макс
Стик вперёд/назад 2 433 (назад) 1024 1643 (вперёд)
Стик влево/вправо 4 446 (влево) 1024 1654 (вправо)
Остальные 14 — всегда 1024

Диапазон осей ~±600 от нейтрали (не полная шкала SBUS). Других органов управления на пульте нет.

Эталонный кадр нейтрали (hex):

0F 00 04 20 00 01 08 40 00 02 10 80 00 04 20 00 01 08 40 00 02 10 80 00 00

Воспроизведение на STM32G431

  • USART: 100000 бод, 8 бит + чётность even (в терминах STM32: M=1, 9-bit с PCE=1), 2 стоп-бита, TXINV=1 (аппаратная инверсия TX) — бит-бэнг не нужен.
  • Отправлять 25-байтовый кадр по таймеру каждые 50 мс.
  • Нейтраль: все каналы 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 допустим только в доверенной локальной сети.
  • Обратный heartbeat. RX шлёт на адрес источника последнего CONTROL маленький служебный пакет (~5 раз/с): magic CB 01, флаг собственного failsafe, последний принятый seq; при включённом ключе — тоже под HMAC. TX принимает их на тот же сокет (сквозь NAT проходит, т.к. он сам инициировал поток) и показывает в логе, отвечает ли приёмник и не ушёл ли тот в failsafe. Это чисто служебный PC-трафик, STM в нём не участвует, поэтому он не в зеркале протокола, а в самом bridge.py. На выдачу SBUS и на основной поток управления heartbeat не влияет.
    • Индикация для оператора. Если запустить tx в терминале, он показывает одну живую строку, обновляемую на месте: зелёное ● СВЯЗЬ ЕСТЬ, жёлтое ● СВЯЗЬ ЕСТЬ (приёмник в failsafe) или красное ● СВЯЗИ НЕТ. Под systemd (вывод не в терминал) вместо строки идут события в журнал по смене состояния.

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.

Параметры можно задавать через env (CART_BRIDGE_ROLE/PEER/BIND/DEVICE/KEY/ LOG_LEVEL), CLI их переопределяет.

Автозапуск: systemd-службы

Для постоянной работы мост ставится службами: cart-bridge-tx (отправитель) и cart-bridge-rx (приёмник), по одной роли на машину. Юниты, примеры конфигов и установщик — в каталоге deploy/:

cd deploy
sudo ./install-bridge.sh tx   # на машине у пульта
sudo ./install-bridge.sh rx   # на машине у тележки

Установщик подставляет реальные пути (python из .venv, bridge.py, рабочий каталог) и пользователя, кладёт конфиги в /etc/cart-bridge/{tx,rx}.env. Там задаются CART_BRIDGE_PEER/BIND и общий CART_BRIDGE_KEY (должен совпадать на обеих сторонах). Логи и индикация «отвечает ли приёмник» — journalctl -u cart-bridge-tx -f. Подробности — deploy/README.md.