Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5826c4ff67 | ||
|
|
09a2aa7932 | ||
|
|
75185c7e36 | ||
|
|
5b70a9a4ce |
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"cmake.sourceDirectory": "/home/awe/Documents/radar_system/data_acq_and_processing/gpr_cpp"
|
||||||
|
}
|
||||||
+2
-1
@@ -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
@@ -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 дольше -> нейтраль на проводе
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
def accept(datagram, addr):
|
||||||
|
"""Проверить пакет и обновить состояние; вернуть payload принятого
|
||||||
|
свежего кадра либо None."""
|
||||||
|
nonlocal last_seq, last_net, in_failsafe, rejects, tx_addr
|
||||||
|
if allowed_host and addr[0] != allowed_host:
|
||||||
|
log.warning("пакет с неожиданного адреса %s отброшен", addr[0])
|
||||||
|
return None
|
||||||
|
payload = unseal(datagram, key)
|
||||||
|
if not payload or len(payload) != lp.CONTROL_LEN:
|
||||||
|
log.warning("битый/неаутентичный пакет отброшен")
|
||||||
|
return None
|
||||||
|
tx_addr = addr # аутентичный CONTROL -> знаем обратный адрес для heartbeat
|
||||||
|
c = lp.unpack_control(payload)
|
||||||
|
# Принять свежий seq ИЛИ новую сессию: источник (STM-TX) после ребута
|
||||||
|
# начинает seq заново с 0, и без сброса last_seq поток завис бы в
|
||||||
|
# failsafe навсегда. Новую сессию распознаём по пачке подряд «несвежих»
|
||||||
|
# пакетов (устойчиво к одиночным reorder/dup).
|
||||||
|
fresh = last_seq is None or seq_newer(c["seq"], last_seq)
|
||||||
|
if not fresh:
|
||||||
|
rejects += 1
|
||||||
|
if rejects >= SEQ_RESET_AFTER:
|
||||||
|
log.warning("seq откатился (%d -> %d): новая сессия", last_seq, c["seq"])
|
||||||
|
fresh = True
|
||||||
|
if not fresh:
|
||||||
|
return None # устаревший/повторный — anti-replay
|
||||||
|
rejects = 0
|
||||||
|
last_seq = c["seq"]
|
||||||
|
last_net = time.monotonic()
|
||||||
|
if in_failsafe:
|
||||||
|
log.info("связь восстановлена")
|
||||||
|
in_failsafe = False
|
||||||
|
return payload
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
# Ждём пакет до 20 мс (пауза цикла), затем БЕЗ блокировки выгребаем всю
|
||||||
datagram, addr = sock.recvfrom(2048)
|
# накопившуюся очередь сокета и шлём на STM только самый свежий кадр
|
||||||
if allowed_host and addr[0] != allowed_host:
|
# (latest-wins): так задержка не растёт, даже если пакеты пришли пачкой.
|
||||||
log.warning("пакет с неожиданного адреса %s отброшен", addr[0])
|
newest = None
|
||||||
datagram = None
|
ready = select.select([sock], [], [], lp.FRAME_PERIOD_MS / 2000.0)[0]
|
||||||
payload = unseal(datagram, key) if datagram else None
|
while ready:
|
||||||
if payload and len(payload) == lp.CONTROL_LEN:
|
try:
|
||||||
c = lp.unpack_control(payload)
|
datagram, addr = sock.recvfrom(2048)
|
||||||
# Принять свежий seq ИЛИ новую сессию: источник (STM-TX) после
|
except BlockingIOError:
|
||||||
# ребута начинает seq заново с 0, и без сброса last_seq поток
|
break
|
||||||
# завис бы в failsafe навсегда. Новую сессию распознаём по пачке
|
p = accept(datagram, addr)
|
||||||
# подряд «несвежих» пакетов (устойчиво к одиночным reorder/dup).
|
if p is not None:
|
||||||
fresh = last_seq is None or seq_newer(c["seq"], last_seq)
|
newest = p
|
||||||
if not fresh:
|
ready = select.select([sock], [], [], 0)[0]
|
||||||
rejects += 1
|
if newest is not None:
|
||||||
if rejects >= SEQ_RESET_AFTER:
|
ser.write(lp.build_frame(lp.CONTROL, newest))
|
||||||
log.warning("seq откатился (%d -> %d): новая сессия", last_seq, c["seq"])
|
|
||||||
fresh = True
|
|
||||||
if fresh:
|
|
||||||
rejects = 0
|
|
||||||
last_seq = c["seq"]
|
|
||||||
last_net = time.monotonic()
|
|
||||||
if in_failsafe:
|
|
||||||
log.info("связь восстановлена")
|
|
||||||
in_failsafe = False
|
|
||||||
ser.write(lp.build_frame(lp.CONTROL, payload))
|
|
||||||
elif datagram is not None:
|
|
||||||
log.warning("битый/неаутентичный пакет отброшен")
|
|
||||||
except socket.timeout:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# сетевой failsafe: тишина -> активная нейтраль на STM в темпе 20 Гц
|
# сетевой failsafe: тишина -> активная нейтраль на STM в темпе 20 Гц
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
@@ -203,10 +318,16 @@ 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:
|
||||||
if ftype == lp.LOG:
|
sock.sendto(seal(pack_hb(in_failsafe, last_seq or 0), key), tx_addr)
|
||||||
log.info("STM-RX: %s", payload.decode("ascii", "replace"))
|
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:
|
||||||
|
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user