deploy examples
This commit is contained in:
@@ -9,11 +9,17 @@
|
||||
Роль rx: принимает UDP, пишет CONTROL в USB на STM-RX; при тишине из сети шлёт
|
||||
явную нейтраль с флагом failsafe (главный предохранитель — на самом STM).
|
||||
|
||||
Обратный канал: rx шлёт tx heartbeat, поэтому на стороне отправителя видно,
|
||||
отвечает ли приёмник и не ушёл ли он сам в failsafe.
|
||||
|
||||
Устройство выбирается по USB product string (CART-TX / CART-RX) и подтверждается
|
||||
handshake-кадром IDENT (роль + версия протокола). Для интернета используй общий
|
||||
ключ (--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 rx --bind 0.0.0.0:5005
|
||||
@@ -21,8 +27,10 @@ handshake-кадром IDENT (роль + версия протокола). Дл
|
||||
import argparse
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import select
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from hashlib import sha256
|
||||
@@ -33,13 +41,55 @@ from serial.tools import list_ports
|
||||
import link_proto as lp
|
||||
|
||||
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
|
||||
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")
|
||||
|
||||
|
||||
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-устройства ----------------
|
||||
|
||||
def find_port(role: str, selector: str | None) -> str:
|
||||
@@ -128,6 +178,14 @@ def run_tx(args, key):
|
||||
parser = lp.FrameParser()
|
||||
log.info("tx: %s -> UDP %s:%d", ser.port, *peer)
|
||||
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:
|
||||
# read(1) блокирует до первого байта, затем забираем всё доступное —
|
||||
# кадр уходит в сеть сразу, без ожидания таймаута чтения (иначе каждый
|
||||
@@ -139,12 +197,41 @@ def run_tx(args, key):
|
||||
if ftype == lp.CONTROL:
|
||||
sock.sendto(seal(payload, key), peer)
|
||||
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:
|
||||
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 ----------------
|
||||
|
||||
@@ -163,14 +250,16 @@ def run_rx(args, key):
|
||||
last_seq = None
|
||||
last_net = time.monotonic()
|
||||
last_fs = 0.0
|
||||
last_hb_sent = 0.0
|
||||
fs_seq = 0
|
||||
in_failsafe = False
|
||||
rejects = 0 # подряд отброшенных по seq -> признак перезапуска источника
|
||||
tx_addr = None # куда слать heartbeat (источник последнего CONTROL)
|
||||
|
||||
def accept(datagram, addr):
|
||||
"""Проверить пакет и обновить состояние; вернуть payload принятого
|
||||
свежего кадра либо None."""
|
||||
nonlocal last_seq, last_net, in_failsafe, rejects
|
||||
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
|
||||
@@ -178,6 +267,7 @@ def run_rx(args, 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 поток завис бы в
|
||||
@@ -228,6 +318,11 @@ def run_rx(args, key):
|
||||
fs_seq = (fs_seq + 1) & 0xFFFFFFFF
|
||||
last_fs = now
|
||||
|
||||
# обратный heartbeat к tx: «приёмник жив», с признаком собственного failsafe
|
||||
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)):
|
||||
@@ -245,17 +340,26 @@ def parse_hostport(s: str, what: str):
|
||||
|
||||
|
||||
def main():
|
||||
# Значения по умолчанию берутся из env (CART_BRIDGE_*), чтобы systemd-юнит
|
||||
# мог задавать всё через EnvironmentFile; аргументы CLI их переопределяют.
|
||||
env = os.environ.get
|
||||
ap = argparse.ArgumentParser(description="мост пульт<->тележка через сеть")
|
||||
ap.add_argument("--role", required=True, choices=("tx", "rx"))
|
||||
ap.add_argument("--peer", help="tx: куда слать HOST:PORT; rx: разрешённый источник (опц.)")
|
||||
ap.add_argument("--bind", default="0.0.0.0:5005", help="rx: локальный UDP HOST:PORT")
|
||||
ap.add_argument("--device", help="путь к порту или подстрока product/serial (иначе по роли)")
|
||||
ap.add_argument("--key", help="общий ключ для HMAC-аутентификации пакетов (для интернета)")
|
||||
ap.add_argument("--log-level", default="INFO")
|
||||
ap.add_argument("--role", default=env("CART_BRIDGE_ROLE"), choices=("tx", "rx"))
|
||||
ap.add_argument("--peer", default=env("CART_BRIDGE_PEER"),
|
||||
help="tx: куда слать HOST:PORT; rx: разрешённый источник (опц.)")
|
||||
ap.add_argument("--bind", default=env("CART_BRIDGE_BIND", "0.0.0.0:5005"),
|
||||
help="rx: локальный UDP HOST:PORT")
|
||||
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()
|
||||
|
||||
logging.basicConfig(level=args.log_level.upper(),
|
||||
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
|
||||
if args.role == "tx" and not args.peer:
|
||||
raise SystemExit("--peer обязателен для роли tx")
|
||||
@@ -266,6 +370,9 @@ def main():
|
||||
(run_tx if args.role == "tx" else run_rx)(args, key)
|
||||
except KeyboardInterrupt:
|
||||
log.info("остановлено")
|
||||
finally:
|
||||
if sys.stdout.isatty():
|
||||
sys.stdout.write("\n") # увести живую строку от приглашения shell
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -34,7 +34,7 @@ SBUS_MID = 1024
|
||||
|
||||
# тайминги (мс), зеркало link_protocol.h
|
||||
FRAME_PERIOD_MS = 50
|
||||
RX_USB_TIMEOUT_MS = 150
|
||||
RX_USB_TIMEOUT_MS = 400
|
||||
|
||||
|
||||
def crc16(data: bytes) -> int:
|
||||
|
||||
Reference in New Issue
Block a user