Author SHA1 Message Date
awe 5826c4ff67 deploy examples 2026-10-22 12:57:33 +03:00
awe 09a2aa7932 merge: свести документацию (взять версию с разделом про прошивку STM)
# Conflicts:
#	device_firmware/cart_firmware/docs/protocol.md
2026-10-19 16:16:56 +03:00
awe 75185c7e36 fix 2026-10-19 16:12:36 +03:00
aweandClaude Opus 4.8 5b70a9a4ce 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>
2026-10-19 13:19:09 +03:00
12 changed files with 480 additions and 49 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"cmake.sourceDirectory": "/home/awe/Documents/radar_system/data_acq_and_processing/gpr_cpp"
}
+2 -1
View File
@@ -5,6 +5,7 @@
"platformio.platformio-ide" "platformio.platformio-ide"
], ],
"unwantedRecommendations": [ "unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack" "ms-vscode.cpptools-extension-pack",
"pioarduino.pioarduino-ide"
] ]
} }
@@ -0,0 +1,53 @@
# systemd-службы сетевого моста
Автозапуск `tools/bridge.py` как служб: `cart-bridge-tx` (отправитель, рядом с
пультом) и `cart-bridge-rx` (приёмник, рядом с тележкой). На каждой машине
включается ровно одна роль.
## Установка
На машине-отправителе:
```sh
sudo ./install-bridge.sh tx
```
На машине-приёмнике:
```sh
sudo ./install-bridge.sh rx
```
Установщик подставляет в юнит реальные пути (python из `.venv` проекта, если
есть, иначе системный `python3`; `bridge.py`; рабочий каталог) и пользователя
(того, кто вызвал `sudo`). Юниты кладутся в `/etc/systemd/system/`, конфиги —
в `/etc/cart-bridge/{tx,rx}.env` (создаются из примеров, существующие не
перезаписываются). Без аргумента (`none`) — только установка, без включения.
Интерпретатор можно задать явно: `sudo PYTHON=/usr/bin/python3 ./install-bridge.sh rx`.
В выбранном python должен быть `pyserial`.
## Настройка
Отредактировать конфиг под свою сеть и ключ:
```sh
sudoedit /etc/cart-bridge/tx.env # peer = адрес приёмника, общий ключ
sudoedit /etc/cart-bridge/rx.env # bind, тот же ключ
sudo systemctl restart cart-bridge-tx # применить
```
Ключ `CART_BRIDGE_KEY` на обеих сторонах должен совпадать (HMAC-аутентификация
пакетов; обязателен для интернета).
## Эксплуатация
```sh
systemctl status cart-bridge-tx
journalctl -u cart-bridge-tx -f # логи; на отправителе видно «приёмник отвечает/НЕ отвечает»
sudo systemctl restart cart-bridge-tx
sudo systemctl disable --now cart-bridge-tx
```
Служба перезапускается при падении (`Restart=always`). Доступ к USB-порту STM
даётся группами `dialout`/`plugdev` (прописаны в юните).
@@ -0,0 +1,19 @@
# Конфигурация службы приёмника (cart-bridge-rx). Копируется в
# /etc/cart-bridge/rx.env установщиком. Переменные читает tools/bridge.py.
# Локальный UDP-адрес для приёма HOST:PORT.
CART_BRIDGE_BIND=0.0.0.0:5005
# Общий ключ HMAC (ОБЯЗАТЕЛЕН для интернета). Должен совпадать с отправителем.
CART_BRIDGE_KEY=change-me-shared-secret
# Необязательно: принимать пакеты только с этого адреса отправителя (фильтр по
# хосту). Для интернета за NAT обычно оставляют пустым и полагаются на HMAC.
#CART_BRIDGE_PEER=10.0.0.1
# Необязательно: путь к порту или подстрока product/serial. По умолчанию порт
# ищется по USB product string CART-RX.
#CART_BRIDGE_DEVICE=CART-RX
# Необязательно: уровень логов (DEBUG/INFO/WARNING).
#CART_BRIDGE_LOG_LEVEL=INFO
@@ -0,0 +1,18 @@
[Unit]
Description=Cart bridge receiver (role rx): сеть -> USB тележки
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0
[Service]
Type=simple
User=@USER@
SupplementaryGroups=dialout plugdev
EnvironmentFile=/etc/cart-bridge/rx.env
WorkingDirectory=@WORKDIR@
ExecStart=@PYTHON@ @BRIDGE@ --role rx
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,15 @@
# Конфигурация службы отправителя (cart-bridge-tx). Копируется в
# /etc/cart-bridge/tx.env установщиком. Переменные читает tools/bridge.py.
# Адрес приёмника HOST:PORT — куда слать управление.
CART_BRIDGE_PEER=10.0.0.2:5005
# Общий ключ HMAC (ОБЯЗАТЕЛЕН для интернета). Должен совпадать с приёмником.
CART_BRIDGE_KEY=change-me-shared-secret
# Необязательно: путь к порту или подстрока product/serial. По умолчанию порт
# ищется по USB product string CART-TX.
#CART_BRIDGE_DEVICE=CART-TX
# Необязательно: уровень логов (DEBUG/INFO/WARNING).
#CART_BRIDGE_LOG_LEVEL=INFO
@@ -0,0 +1,20 @@
[Unit]
Description=Cart bridge sender (role tx): пульт USB -> сеть
After=network-online.target
Wants=network-online.target
# не упираться в лимит перезапусков при флапе сети/USB
StartLimitIntervalSec=0
[Service]
Type=simple
User=@USER@
# доступ к последовательному порту STM (dialout) и к USB-дескрипторам (plugdev)
SupplementaryGroups=dialout plugdev
EnvironmentFile=/etc/cart-bridge/tx.env
WorkingDirectory=@WORKDIR@
ExecStart=@PYTHON@ @BRIDGE@ --role tx
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Установка systemd-служб сетевого моста тележки.
#
# sudo ./install-bridge.sh [tx|rx|both|none]
#
# Аргумент выбирает, какую службу включить и запустить на ЭТОЙ машине
# (tx — рядом с пультом, rx — рядом с тележкой). Без аргумента юниты только
# ставятся, но не включаются. Файлы юнитов и шаблоны конфигов берутся из этого
# каталога; пути к python и bridge.py подставляются автоматически.
set -euo pipefail
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOOLS="$(cd "$SELF/../tools" && pwd)"
REPO="$(cd "$SELF/../../.." && pwd)"
BRIDGE="$TOOLS/bridge.py"
WORKDIR="$TOOLS"
ENABLE="${1:-none}"
case "$ENABLE" in tx|rx|both|none) ;; *) echo "аргумент: tx|rx|both|none" >&2; exit 2;; esac
if [ "$(id -u)" -ne 0 ]; then
echo "нужны права root — перезапускаюсь через sudo"
exec sudo -E PYTHON="${PYTHON:-}" bash "$0" "$ENABLE"
fi
TARGET_USER="${SUDO_USER:-root}"
# интерпретатор: venv проекта, если есть, иначе системный python3
PYTHON="${PYTHON:-}"
if [ -z "$PYTHON" ]; then
if [ -x "$REPO/.venv/bin/python" ]; then
PYTHON="$REPO/.venv/bin/python"
else
PYTHON="$(command -v python3)"
fi
fi
echo "python : $PYTHON"
echo "bridge : $BRIDGE"
echo "user : $TARGET_USER"
if ! "$PYTHON" -c 'import serial' 2>/dev/null; then
echo "ВНИМАНИЕ: в $PYTHON нет pyserial — поставь: $PYTHON -m pip install pyserial" >&2
fi
gen_unit() { # <role>
local role="$1"
sed -e "s|@PYTHON@|$PYTHON|g" -e "s|@BRIDGE@|$BRIDGE|g" \
-e "s|@WORKDIR@|$WORKDIR|g" -e "s|@USER@|$TARGET_USER|g" \
"$SELF/cart-bridge-$role.service" > "/etc/systemd/system/cart-bridge-$role.service"
echo "установлен юнит /etc/systemd/system/cart-bridge-$role.service"
}
install -d -m 755 /etc/cart-bridge
for role in tx rx; do
gen_unit "$role"
conf="/etc/cart-bridge/$role.env"
if [ ! -e "$conf" ]; then
install -m 600 "$SELF/cart-bridge-$role.env.example" "$conf"
echo "создан конфиг $conf (ОТРЕДАКТИРУЙ: peer/bind и ключ)"
else
echo "конфиг $conf уже есть — не трогаю"
fi
done
systemctl daemon-reload
enable_one() { # <role>
systemctl enable --now "cart-bridge-$1.service"
echo "включена и запущена cart-bridge-$1.service"
}
case "$ENABLE" in
tx) enable_one tx ;;
rx) enable_one rx ;;
both) enable_one tx; enable_one rx ;;
none) echo "юниты установлены, не включены. Включить: systemctl enable --now cart-bridge-tx (или -rx)";;
esac
echo "готово. Логи: journalctl -u cart-bridge-tx -f (или -rx)"
@@ -81,6 +81,63 @@ STM-TX продолжает гнать SBUS на свой локальный п
96-битного UID чипа (на случай неверно прошитого устройства, рассинхрона версий 96-битного UID чипа (на случай неверно прошитого устройства, рассинхрона версий
или чужого CDC с тем же VID/PID). Serial-дескриптор (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 ## Кадр поверх USB CDC
Единственный источник истины — `src/link_protocol.h`, зеркало на Python — Единственный источник истины — `src/link_protocol.h`, зеркало на Python —
@@ -122,6 +179,18 @@ Payload `CONTROL` (LE): `seq:u32`, `t_ms:u32`, `flags:u8`, `ch[16]:u16` = 41 б
неаутентичные и принимает только с ожидаемого адреса (`--peer`). Рекомендуемый неаутентичные и принимает только с ожидаемого адреса (`--peer`). Рекомендуемый
способ наружу — WireGuard-туннель, внутри которого идёт простой UDP. «Голый» способ наружу — WireGuard-туннель, внутри которого идёт простой UDP. «Голый»
неаутентифицированный 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 ## FAILSAFE
@@ -149,3 +218,24 @@ Payload `CONTROL` (LE): `seq:u32`, `t_ms:u32`, `flags:u8`, `ch[16]:u16` = 41 б
Локальная проверка без сети: оба экземпляра на одной машине с `--peer 127.0.0.1` Локальная проверка без сети: оба экземпляра на одной машине с `--peer 127.0.0.1`
и двумя STM по USB; SBUS с RX снять `tools/decode.py` и сверить с осями стика TX. и двумя 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/`:
```sh
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`.
@@ -56,4 +56,4 @@ static const uint16_t CART_FW_VERSION = 0x0100; // 1.00
// --- тайминги (мс) --- // --- тайминги (мс) ---
static const uint32_t FRAME_PERIOD_MS = 50; // 20 Гц, темп старого пульта static const uint32_t FRAME_PERIOD_MS = 50; // 20 Гц, темп старого пульта
static const uint32_t RX_USB_TIMEOUT_MS = 150; // нет CONTROL дольше -> нейтраль на проводе static const uint32_t RX_USB_TIMEOUT_MS = 450; // нет CONTROL дольше -> нейтраль на проводе
+163 -30
View File
@@ -9,11 +9,17 @@
Роль rx: принимает UDP, пишет CONTROL в USB на STM-RX; при тишине из сети шлёт Роль rx: принимает UDP, пишет CONTROL в USB на STM-RX; при тишине из сети шлёт
явную нейтраль с флагом failsafe (главный предохранитель — на самом STM). явную нейтраль с флагом failsafe (главный предохранитель — на самом STM).
Обратный канал: rx шлёт tx heartbeat, поэтому на стороне отправителя видно,
отвечает ли приёмник и не ушёл ли он сам в failsafe.
Устройство выбирается по USB product string (CART-TX / CART-RX) и подтверждается Устройство выбирается по USB product string (CART-TX / CART-RX) и подтверждается
handshake-кадром IDENT (роль + версия протокола). Для интернета используй общий handshake-кадром IDENT (роль + версия протокола). Для интернета используй общий
ключ (--key) — каждый UDP-пакет аутентифицируется HMAC-SHA256; без ключа трафик ключ (--key) — каждый UDP-пакет аутентифицируется HMAC-SHA256; без ключа трафик
идёт в открытом виде (только для доверенной локальной сети). идёт в открытом виде (только для доверенной локальной сети).
Параметры можно задавать и через env (CART_BRIDGE_ROLE/PEER/BIND/DEVICE/KEY/
LOG_LEVEL) — так настраиваются systemd-службы (см. deploy/).
Запуск (из каталога прошивки): Запуск (из каталога прошивки):
.venv/bin/python tools/bridge.py --role tx --peer 10.0.0.2:5005 .venv/bin/python tools/bridge.py --role tx --peer 10.0.0.2:5005
.venv/bin/python tools/bridge.py --role rx --bind 0.0.0.0:5005 .venv/bin/python tools/bridge.py --role rx --bind 0.0.0.0:5005
@@ -21,7 +27,10 @@ handshake-кадром IDENT (роль + версия протокола). Дл
import argparse import argparse
import hmac import hmac
import logging import logging
import os
import select
import socket import socket
import struct
import sys import sys
import time import time
from hashlib import sha256 from hashlib import sha256
@@ -32,13 +41,55 @@ from serial.tools import list_ports
import link_proto as lp import link_proto as lp
HMAC_LEN = 8 # усечённый HMAC-SHA256 на пакет HMAC_LEN = 8 # усечённый HMAC-SHA256 на пакет
NET_TIMEOUT_S = 0.10 # нет UDP дольше -> rx начинает слать failsafe NET_TIMEOUT_S = 0.50 # нет UDP дольше -> rx начинает слать failsafe
HELLO_TIMEOUT_S = 1.0 HELLO_TIMEOUT_S = 1.0
SEQ_RESET_AFTER = 20 # столько подряд «несвежих» пакетов = перезапуск источника SEQ_RESET_AFTER = 20 # столько подряд «несвежих» пакетов = перезапуск источника
# Обратный канал «жив ли приёмник»: rx шлёт heartbeat в сторону tx (на адрес
# источника последнего CONTROL), tx по ним показывает, отвечает ли приёмник и
# не ушёл ли он сам в failsafe. Это PC-only служебный трафик (к STM отношения не
# имеет), поэтому живёт здесь, а не в зеркале протокола link_proto.
HEARTBEAT_MAGIC = b"\xCB\x01"
HEARTBEAT_PERIOD_S = 0.2 # как часто rx шлёт heartbeat
HEARTBEAT_TIMEOUT_S = 1.0 # нет heartbeat дольше -> tx считает приёмник недоступным
HEARTBEAT_STATUS_S = 5.0 # период печати статуса приёмника на tx
HB_FLAG_FAILSAFE = 0x01
log = logging.getLogger("bridge") log = logging.getLogger("bridge")
def pack_hb(failsafe: bool, last_seq: int) -> bytes:
flags = HB_FLAG_FAILSAFE if failsafe else 0
return HEARTBEAT_MAGIC + struct.pack("<BI", flags, last_seq & 0xFFFFFFFF)
def parse_hb(payload: bytes):
"""-> dict(failsafe, last_seq) либо None, если это не heartbeat."""
if len(payload) != len(HEARTBEAT_MAGIC) + 5 or payload[:2] != HEARTBEAT_MAGIC:
return None
flags, last_seq = struct.unpack_from("<BI", payload, 2)
return {"failsafe": bool(flags & HB_FLAG_FAILSAFE), "last_seq": last_seq}
_ANSI = {"green": "\033[1;32m", "red": "\033[1;31m", "yellow": "\033[1;33m", "reset": "\033[0m"}
def render_link_status(alive: bool, failsafe: bool, color: bool):
"""Живая одна строка для оператора: зелёная «СВЯЗЬ ЕСТЬ» / красная «СВЯЗИ
НЕТ», обновляется на месте (возврат каретки)."""
if not alive:
text, c = "СВЯЗИ НЕТ", "red"
elif failsafe:
text, c = "СВЯЗЬ ЕСТЬ (приёмник в failsafe)", "yellow"
else:
text, c = "СВЯЗЬ ЕСТЬ", "green"
body = "● " + text
if color:
body = _ANSI[c] + body + _ANSI["reset"]
sys.stdout.write("\r\033[2K" + body) # \033[2K — очистить строку
sys.stdout.flush()
# ---------------- выбор и handshake USB-устройства ---------------- # ---------------- выбор и handshake USB-устройства ----------------
def find_port(role: str, selector: str | None) -> str: def find_port(role: str, selector: str | None) -> str:
@@ -127,17 +178,60 @@ def run_tx(args, key):
parser = lp.FrameParser() parser = lp.FrameParser()
log.info("tx: %s -> UDP %s:%d", ser.port, *peer) log.info("tx: %s -> UDP %s:%d", ser.port, *peer)
sent = 0 sent = 0
rx_alive = False # получаем ли heartbeat от приёмника
rx_failsafe = False # сообщил ли приёмник, что сам в failsafe
last_hb = 0.0
last_status = 0.0
# В терминале — живая цветная строка; под systemd (journald, не TTY) — обычные
# события по изменению состояния.
tty = sys.stdout.isatty()
color = tty and os.environ.get("NO_COLOR") is None
while True: while True:
for ftype, payload in parser.feed(ser.read(256)): # read(1) блокирует до первого байта, затем забираем всё доступное —
# кадр уходит в сеть сразу, без ожидания таймаута чтения (иначе каждый
# кадр задерживался бы до таймаута порта).
data = ser.read(1)
if ser.in_waiting:
data += ser.read(ser.in_waiting)
for ftype, payload in parser.feed(data):
if ftype == lp.CONTROL: if ftype == lp.CONTROL:
sock.sendto(seal(payload, key), peer) sock.sendto(seal(payload, key), peer)
sent += 1 sent += 1
if sent % 100 == 0:
c = lp.unpack_control(payload)
log.debug("tx seq=%d ch2=%d ch4=%d", c["seq"], c["ch"][1], c["ch"][3])
elif ftype == lp.LOG: elif ftype == lp.LOG:
log.info("STM-TX: %s", payload.decode("ascii", "replace")) log.info("STM-TX: %s", payload.decode("ascii", "replace"))
now = time.monotonic()
prev_alive, prev_fs = rx_alive, rx_failsafe
# обратный канал: heartbeat от приёмника (приходит на тот же сокет)
while select.select([sock], [], [], 0)[0]:
try:
datagram, _ = sock.recvfrom(2048)
except BlockingIOError:
break
hb = parse_hb(unseal(datagram, key) or b"")
if hb is None:
continue
last_hb = now
rx_alive = True
rx_failsafe = hb["failsafe"]
if rx_alive and now - last_hb > HEARTBEAT_TIMEOUT_S:
rx_alive, rx_failsafe = False, False
if tty:
render_link_status(rx_alive, rx_failsafe, color) # живая строка
else:
if rx_alive != prev_alive:
(log.info if rx_alive else log.warning)(
"приёмник отвечает" if rx_alive else "приёмник НЕ отвечает")
elif rx_alive and rx_failsafe != prev_fs:
(log.warning if rx_failsafe else log.info)(
"приёмник в failsafe: наш поток не доходит" if rx_failsafe
else "приёмник принимает поток")
if now - last_status >= HEARTBEAT_STATUS_S:
last_status = now
log.info("приёмник: %s (отправлено %d)",
"отвечает" if rx_alive else "НЕ отвечает", sent)
# ---------------- роль RX: сеть -> USB ---------------- # ---------------- роль RX: сеть -> USB ----------------
@@ -149,48 +243,69 @@ def run_rx(args, key):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(bind) sock.bind(bind)
sock.settimeout(lp.FRAME_PERIOD_MS / 1000.0) sock.setblocking(False)
parser = lp.FrameParser() parser = lp.FrameParser()
log.info("rx: UDP %s:%d -> %s", bind[0], bind[1], ser.port) log.info("rx: UDP %s:%d -> %s", bind[0], bind[1], ser.port)
last_seq = None last_seq = None
last_net = time.monotonic() last_net = time.monotonic()
last_fs = 0.0 last_fs = 0.0
last_hb_sent = 0.0
fs_seq = 0 fs_seq = 0
in_failsafe = False in_failsafe = False
rejects = 0 # подряд отброшенных по seq -> признак перезапуска источника rejects = 0 # подряд отброшенных по seq -> признак перезапуска источника
tx_addr = None # куда слать heartbeat (источник последнего CONTROL)
while True: def accept(datagram, addr):
try: """Проверить пакет и обновить состояние; вернуть payload принятого
datagram, addr = sock.recvfrom(2048) свежего кадра либо None."""
nonlocal last_seq, last_net, in_failsafe, rejects, tx_addr
if allowed_host and addr[0] != allowed_host: if allowed_host and addr[0] != allowed_host:
log.warning("пакет с неожиданного адреса %s отброшен", addr[0]) log.warning("пакет с неожиданного адреса %s отброшен", addr[0])
datagram = None return None
payload = unseal(datagram, key) if datagram else None payload = unseal(datagram, key)
if payload and len(payload) == lp.CONTROL_LEN: if not payload or len(payload) != lp.CONTROL_LEN:
log.warning("битый/неаутентичный пакет отброшен")
return None
tx_addr = addr # аутентичный CONTROL -> знаем обратный адрес для heartbeat
c = lp.unpack_control(payload) c = lp.unpack_control(payload)
# Принять свежий seq ИЛИ новую сессию: источник (STM-TX) после # Принять свежий seq ИЛИ новую сессию: источник (STM-TX) после ребута
# ребута начинает seq заново с 0, и без сброса last_seq поток # начинает seq заново с 0, и без сброса last_seq поток завис бы в
# завис бы в failsafe навсегда. Новую сессию распознаём по пачке # failsafe навсегда. Новую сессию распознаём по пачке подряд «несвежих»
# подряд «несвежих» пакетов (устойчиво к одиночным reorder/dup). # пакетов (устойчиво к одиночным reorder/dup).
fresh = last_seq is None or seq_newer(c["seq"], last_seq) fresh = last_seq is None or seq_newer(c["seq"], last_seq)
if not fresh: if not fresh:
rejects += 1 rejects += 1
if rejects >= SEQ_RESET_AFTER: if rejects >= SEQ_RESET_AFTER:
log.warning("seq откатился (%d -> %d): новая сессия", last_seq, c["seq"]) log.warning("seq откатился (%d -> %d): новая сессия", last_seq, c["seq"])
fresh = True fresh = True
if fresh: if not fresh:
return None # устаревший/повторный — anti-replay
rejects = 0 rejects = 0
last_seq = c["seq"] last_seq = c["seq"]
last_net = time.monotonic() last_net = time.monotonic()
if in_failsafe: if in_failsafe:
log.info("связь восстановлена") log.info("связь восстановлена")
in_failsafe = False in_failsafe = False
ser.write(lp.build_frame(lp.CONTROL, payload)) return payload
elif datagram is not None:
log.warning("битый/неаутентичный пакет отброшен") while True:
except socket.timeout: # Ждём пакет до 20 мс (пауза цикла), затем БЕЗ блокировки выгребаем всю
pass # накопившуюся очередь сокета и шлём на STM только самый свежий кадр
# (latest-wins): так задержка не растёт, даже если пакеты пришли пачкой.
newest = None
ready = select.select([sock], [], [], lp.FRAME_PERIOD_MS / 2000.0)[0]
while ready:
try:
datagram, addr = sock.recvfrom(2048)
except BlockingIOError:
break
p = accept(datagram, addr)
if p is not None:
newest = p
ready = select.select([sock], [], [], 0)[0]
if newest is not None:
ser.write(lp.build_frame(lp.CONTROL, newest))
# сетевой failsafe: тишина -> активная нейтраль на STM в темпе 20 Гц # сетевой failsafe: тишина -> активная нейтраль на STM в темпе 20 Гц
now = time.monotonic() now = time.monotonic()
@@ -203,8 +318,14 @@ def run_rx(args, key):
fs_seq = (fs_seq + 1) & 0xFFFFFFFF fs_seq = (fs_seq + 1) & 0xFFFFFFFF
last_fs = now last_fs = now
# дренируем входящие LOG/IDENT от STM-RX # обратный heartbeat к tx: «приёмник жив», с признаком собственного failsafe
for ftype, payload in parser.feed(ser.read(256)): if tx_addr is not None and now - last_hb_sent >= HEARTBEAT_PERIOD_S:
sock.sendto(seal(pack_hb(in_failsafe, last_seq or 0), key), tx_addr)
last_hb_sent = now
# неблокирующий дренаж входящих LOG от STM-RX
if ser.in_waiting:
for ftype, payload in parser.feed(ser.read(ser.in_waiting)):
if ftype == lp.LOG: if ftype == lp.LOG:
log.info("STM-RX: %s", payload.decode("ascii", "replace")) log.info("STM-RX: %s", payload.decode("ascii", "replace"))
@@ -219,17 +340,26 @@ def parse_hostport(s: str, what: str):
def main(): def main():
# Значения по умолчанию берутся из env (CART_BRIDGE_*), чтобы systemd-юнит
# мог задавать всё через EnvironmentFile; аргументы CLI их переопределяют.
env = os.environ.get
ap = argparse.ArgumentParser(description="мост пульт<->тележка через сеть") ap = argparse.ArgumentParser(description="мост пульт<->тележка через сеть")
ap.add_argument("--role", required=True, choices=("tx", "rx")) ap.add_argument("--role", default=env("CART_BRIDGE_ROLE"), choices=("tx", "rx"))
ap.add_argument("--peer", help="tx: куда слать HOST:PORT; rx: разрешённый источник (опц.)") ap.add_argument("--peer", default=env("CART_BRIDGE_PEER"),
ap.add_argument("--bind", default="0.0.0.0:5005", help="rx: локальный UDP HOST:PORT") help="tx: куда слать HOST:PORT; rx: разрешённый источник (опц.)")
ap.add_argument("--device", help="путь к порту или подстрока product/serial (иначе по роли)") ap.add_argument("--bind", default=env("CART_BRIDGE_BIND", "0.0.0.0:5005"),
ap.add_argument("--key", help="общий ключ для HMAC-аутентификации пакетов (для интернета)") help="rx: локальный UDP HOST:PORT")
ap.add_argument("--log-level", default="INFO") ap.add_argument("--device", default=env("CART_BRIDGE_DEVICE"),
help="путь к порту или подстрока product/serial (иначе по роли)")
ap.add_argument("--key", default=env("CART_BRIDGE_KEY"),
help="общий ключ для HMAC-аутентификации пакетов (для интернета)")
ap.add_argument("--log-level", default=env("CART_BRIDGE_LOG_LEVEL", "INFO"))
args = ap.parse_args() args = ap.parse_args()
logging.basicConfig(level=args.log_level.upper(), logging.basicConfig(level=args.log_level.upper(),
format="%(asctime)s %(levelname)s %(message)s") format="%(asctime)s %(levelname)s %(message)s")
if args.role not in ("tx", "rx"):
raise SystemExit("нужна роль: --role tx|rx (или CART_BRIDGE_ROLE)")
key = args.key.encode() if args.key else None key = args.key.encode() if args.key else None
if args.role == "tx" and not args.peer: if args.role == "tx" and not args.peer:
raise SystemExit("--peer обязателен для роли tx") raise SystemExit("--peer обязателен для роли tx")
@@ -240,6 +370,9 @@ def main():
(run_tx if args.role == "tx" else run_rx)(args, key) (run_tx if args.role == "tx" else run_rx)(args, key)
except KeyboardInterrupt: except KeyboardInterrupt:
log.info("остановлено") log.info("остановлено")
finally:
if sys.stdout.isatty():
sys.stdout.write("\n") # увести живую строку от приглашения shell
if __name__ == "__main__": if __name__ == "__main__":
@@ -34,7 +34,7 @@ SBUS_MID = 1024
# тайминги (мс), зеркало link_protocol.h # тайминги (мс), зеркало link_protocol.h
FRAME_PERIOD_MS = 50 FRAME_PERIOD_MS = 50
RX_USB_TIMEOUT_MS = 150 RX_USB_TIMEOUT_MS = 400
def crc16(data: bytes) -> int: def crc16(data: bytes) -> int: