反向实现设备 WebSocket 控制协议,提供单次命令与常驻长连接两种模式。
核心内容:
- robot-ctl.py 单次命令客户端,纯标准库无依赖
- robot-daemon.py 常驻守护进程,维持唯一长连接,
解决断开丢尾帧导致的速度残留
- rc.sh 向守护进程注入动作,内置 stable=1 硬锁
- docs/PROTOCOL.md 协议逆向与踩坑记录
- tests/fake-robot-ws.js 离线验证用假设备
实测标定 (50% 速度):
- 前进/后退 ~8 cm/s
- 左转/右转 ~35 deg/s
已知限制:
- 设备为 Arduino WebSocketsServer,纯单向,不回传状态
- 仅摇杆与跳跃有效,linear/angular/方向键无效
- 网页与脚本不可同时连接,会互相覆盖帧
192 lines
6.1 KiB
Python
Executable File
192 lines
6.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
常驻长连接驱动:保持单条 WebSocket 不断开,持续推零帧,按需注入动作。
|
||
|
||
设计要点(针对 Arduino WebSocketsServer + 短连接丢尾帧的问题):
|
||
* 全程只开一条连接,运行期间绝不关闭 → 避免"断开瞬间丢归零帧 → 速度残留"
|
||
* 空闲时每 120ms 推一帧全零(dir=stop, joy=0,0),与网页空闲行为一致
|
||
* 动作靠写状态文件注入,带硬性到期时间,到期自动归零
|
||
* stable 恒为 1(主动保持平衡),本进程拒绝任何关闭请求
|
||
* height 恒为 38,roll 恒为 0
|
||
|
||
状态文件(默认 ./daemon-state.json):
|
||
{"joy_x":0, "joy_y":0, "dir":"stop", "until": <epoch秒>}
|
||
控制脚本只写这个文件;本进程读取并执行。
|
||
|
||
用法:
|
||
python3 robot-daemon.py [--host H] [--port P] [--state FILE] [--interval 0.12]
|
||
# 后台运行建议 nohup + setsid
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import signal
|
||
import sys
|
||
import time
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
from importlib.machinery import SourceFileLoader
|
||
|
||
_ctl = SourceFileLoader("rctl", os.path.join(os.path.dirname(os.path.abspath(__file__)), "robot-ctl.py")).load_module()
|
||
|
||
HEIGHT = 38
|
||
ROLL = 0
|
||
STABLE = 1
|
||
|
||
ZERO = {"joy_x": 0, "joy_y": 0, "dir": "stop"}
|
||
|
||
|
||
def log(*a):
|
||
print(time.strftime("[%H:%M:%S]"), *a, flush=True)
|
||
|
||
|
||
def frame_of(joy_x, joy_y, direction):
|
||
return {
|
||
"dir": direction,
|
||
"mode": "basic",
|
||
"roll": ROLL,
|
||
"height": HEIGHT,
|
||
"linear": 0,
|
||
"angular": 0,
|
||
"stable": STABLE,
|
||
"joy_x": joy_x,
|
||
"joy_y": joy_y,
|
||
}
|
||
|
||
|
||
def read_state(path):
|
||
"""读状态文件;不存在或损坏则返回零状态"""
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
d = json.load(f)
|
||
jx = int(d.get("joy_x", 0) or 0)
|
||
jy = int(d.get("joy_y", 0) or 0)
|
||
dr = str(d.get("dir", "stop") or "stop")
|
||
until = float(d.get("until", 0) or 0)
|
||
jx = max(-100, min(100, jx))
|
||
jy = max(-100, min(100, jy))
|
||
if dr not in ("stop", "jump"):
|
||
dr = "stop"
|
||
return {"joy_x": jx, "joy_y": jy, "dir": dr, "until": until}
|
||
except Exception:
|
||
return {"joy_x": 0, "joy_y": 0, "dir": "stop", "until": 0}
|
||
|
||
|
||
class Daemon:
|
||
def __init__(self, host, port, state_path, interval):
|
||
self.host, self.port = host, port
|
||
self.state_path = state_path
|
||
self.interval = interval
|
||
self.ws = None
|
||
self.running = True
|
||
self.last_key = None
|
||
self.sent = 0
|
||
self.connects = 0
|
||
|
||
def connect(self):
|
||
while self.running:
|
||
try:
|
||
self.ws = _ctl.WS(self.host, self.port, timeout=8, verbose=False)
|
||
self.ws.connect()
|
||
self.connects += 1
|
||
log(f"已连接 {self.host}:{self.port}(第 {self.connects} 次)")
|
||
return True
|
||
except Exception as e:
|
||
log(f"连接失败: {e},2 秒后重试")
|
||
time.sleep(2)
|
||
return False
|
||
|
||
def run(self):
|
||
if not self.connect():
|
||
return 1
|
||
log(f"常驻开始 | 间隔 {int(self.interval*1000)}ms | stable={STABLE} height={HEIGHT}")
|
||
last_report = time.time()
|
||
try:
|
||
while self.running:
|
||
st = read_state(self.state_path)
|
||
now = time.time()
|
||
|
||
# 到期 → 归零
|
||
if st["until"] and now >= st["until"]:
|
||
st = {"joy_x": 0, "joy_y": 0, "dir": "stop", "until": 0}
|
||
|
||
f = frame_of(st["joy_x"], st["joy_y"], st["dir"])
|
||
payload = json.dumps(f, separators=(",", ":"))
|
||
|
||
try:
|
||
self.ws.send_text(payload)
|
||
self.sent += 1
|
||
except Exception as e:
|
||
log(f"发送失败: {e} → 重连")
|
||
try:
|
||
self.ws.close()
|
||
except Exception:
|
||
pass
|
||
self.ws = None
|
||
if not self.connect():
|
||
break
|
||
continue
|
||
|
||
# 状态变化时打日志
|
||
key = (st["joy_x"], st["joy_y"], st["dir"])
|
||
if key != self.last_key:
|
||
log(f"状态 → joy_x={st['joy_x']} joy_y={st['joy_y']} dir={st['dir']}"
|
||
+ (f" 剩余 {st['until']-now:.1f}s" if st["until"] else ""))
|
||
self.last_key = key
|
||
|
||
if time.time() - last_report >= 30:
|
||
log(f"心跳 | 已发 {self.sent} 帧 | 状态 {key}")
|
||
last_report = time.time()
|
||
|
||
time.sleep(self.interval)
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
self.shutdown()
|
||
return 0
|
||
|
||
def shutdown(self):
|
||
"""退出前归零:多推几帧再关,尽量不让设备残留速度"""
|
||
log("开始收尾:推零帧")
|
||
try:
|
||
zero = json.dumps(frame_of(0, 0, "stop"), separators=(",", ":"))
|
||
for _ in range(12):
|
||
try:
|
||
self.ws.send_text(zero)
|
||
except Exception:
|
||
break
|
||
time.sleep(0.12)
|
||
time.sleep(0.5)
|
||
except Exception as e:
|
||
log(f"收尾异常: {e}")
|
||
try:
|
||
if self.ws:
|
||
self.ws.close()
|
||
except Exception:
|
||
pass
|
||
log("已断开")
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--host", default="203.0.113.10")
|
||
ap.add_argument("--port", type=int, default=4782)
|
||
ap.add_argument("--state", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "daemon-state.json"))
|
||
ap.add_argument("--interval", type=float, default=0.12)
|
||
opts = ap.parse_args()
|
||
|
||
d = Daemon(opts.host, opts.port, opts.state, opts.interval)
|
||
|
||
def on_sig(signum, _frame):
|
||
d.running = False
|
||
|
||
signal.signal(signal.SIGTERM, on_sig)
|
||
signal.signal(signal.SIGINT, on_sig)
|
||
sys.exit(d.run())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|