release: v1.0 - WL-PRO 轮组机器人遥控脚本

反向实现设备 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/方向键无效
- 网页与脚本不可同时连接,会互相覆盖帧
This commit is contained in:
deepseek Bot committed 2026-10-25 17:53:11 +08:00
commit 6dd1fa7e24
11 files changed
+1971

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
#!/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()