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

+350
View File
@@ -0,0 +1,350 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
WL-PRO 轮组机器人 遥控脚本(纯标准库,无第三方依赖,Windows/macOS/Linux 通用)
协议与网页 index.html 完全一致:ws://<host>:81/ ,发送 JSON 状态帧:
{"dir":..., "mode":"basic", "roll":0, "height":38, "linear":0,
"angular":0, "stable":0, "joy_x":0, "joy_y":0}
dir 取值:stop | forward | back | left | right | jump
用法:
python robot-ctl.py --host 192.168.4.1 forward # 前进 0.8s 后停
python robot-ctl.py jump # 跳一下
python robot-ctl.py --host 192.168.4.1 joy 0 100 # 摇杆 前推到底 0.8s
python robot-ctl.py joy -60 0 --ms 500 # 左移
python robot-ctl.py height 60 # 站高 60mm(32~85)
python robot-ctl.py roll 10 # 横滚 10°(-30~30)
python robot-ctl.py linear 150 --ms 600 # 线速度 150mm/s
python robot-ctl.py seq "height 60; forward 800; stop"
python robot-ctl.py stop # 急停
python robot-ctl.py probe # 只连不动作,打印回传
参数:--host(默认 192.168.4.1) --port(默认 81) --ms(动作持续毫秒)
--rate(推帧间隔毫秒,默认 150,与网页一致) --timeout(连接超时秒)
"""
import argparse
import base64
import hashlib
import json
import os
import socket
import struct
import sys
import time
DEFAULT_STATE = {
"roll": 0, "height": 38, "linear": 0, "angular": 0,
"stable": 0, "mode": "basic", "dir": "stop", "joy_x": 0, "joy_y": 0,
}
DIRS = {"forward", "back", "left", "right", "jump"}
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
class WS:
"""极简 WebSocket 客户端(仅文本帧 + ping/pong/close)"""
def __init__(self, host, port, timeout=8.0, verbose=True):
self.host, self.port, self.timeout, self.verbose = host, port, timeout, verbose
self.sock = None
self.buf = b""
def log(self, *a):
if self.verbose:
print("[robot]", *a, flush=True)
def connect(self):
s = socket.create_connection((self.host, self.port), timeout=self.timeout)
s.settimeout(self.timeout)
key = base64.b64encode(os.urandom(16)).decode()
req = (
"GET / HTTP/1.1\r\n"
f"Host: {self.host}:{self.port}\r\n"
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n"
f"Origin: http://{self.host}:{self.port}\r\n\r\n"
)
s.sendall(req.encode())
# 读响应头
head = b""
while b"\r\n\r\n" not in head:
chunk = s.recv(4096)
if not chunk:
raise RuntimeError("握手时连接被关闭")
head += chunk
if len(head) > 65536:
raise RuntimeError("响应头过大")
header, rest = head.split(b"\r\n\r\n", 1)
lines = header.decode("utf-8", "replace").split("\r\n")
status = lines[0]
if "101" not in status:
raise RuntimeError(f"设备未接受 WebSocket 升级:{status}")
hdrs = {}
for ln in lines[1:]:
if ":" in ln:
k, v = ln.split(":", 1)
hdrs[k.strip().lower()] = v.strip()
expect = base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode()
if hdrs.get("sec-websocket-accept") != expect:
raise RuntimeError("Sec-WebSocket-Accept 校验失败")
self.sock, self.buf = s, rest
self.log("已连接", f"ws://{self.host}:{self.port}/")
return self
def send_text(self, text):
if self.sock is None:
raise RuntimeError("未连接")
payload = text.encode()
n = len(payload)
header = bytearray([0x81])
if n < 126:
header.append(0x80 | n)
elif n < 65536:
header.append(0x80 | 126)
header += struct.pack("!H", n)
else:
header.append(0x80 | 127)
header += struct.pack("!Q", n)
mask = os.urandom(4)
header += mask
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self.sock.sendall(bytes(header) + masked)
def _recv_more(self):
chunk = self.sock.recv(4096)
if not chunk:
raise ConnectionError("连接已关闭")
self.buf += chunk
def recv_frame(self, timeout=0.2):
"""返回 (opcode, payload_bytes) 或 None(超时)"""
self.sock.settimeout(timeout)
try:
while True:
if len(self.buf) >= 2:
b0, b1 = self.buf[0], self.buf[1]
opcode = b0 & 0x0F
masked = b1 & 0x80
ln = b1 & 0x7F
off = 2
if ln == 126:
if len(self.buf) < 4:
self._recv_more(); continue
ln = struct.unpack("!H", self.buf[2:4])[0]; off = 4
elif ln == 127:
if len(self.buf) < 10:
self._recv_more(); continue
ln = struct.unpack("!Q", self.buf[2:10])[0]; off = 10
if masked:
if len(self.buf) < off + 4:
self._recv_more(); continue
mk = self.buf[off:off + 4]; off += 4
else:
mk = None
if len(self.buf) < off + ln:
self._recv_more(); continue
payload = self.buf[off:off + ln]
self.buf = self.buf[off + ln:]
if mk:
payload = bytes(b ^ mk[i % 4] for i, b in enumerate(payload))
if opcode == 9: # ping -> pong
self._send_control(0x0A, payload); continue
if opcode == 10:
continue
return opcode, payload
self._recv_more()
except socket.timeout:
return None
except (BlockingIOError, InterruptedError):
return None
def _send_control(self, opcode, payload=b""):
mask = os.urandom(4)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self.sock.sendall(bytes([0x80 | opcode, 0x80 | len(payload)]) + mask + masked)
def drain(self, seconds):
"""收一段时间的回传(设备可能会回状态/心跳)"""
end = time.time() + seconds
got = 0
while time.time() < end:
f = self.recv_frame(timeout=min(0.2, max(0.02, end - time.time())))
if f is None:
continue
op, payload = f
if op == 8:
break
if op == 1:
got += 1
try:
txt = payload.decode("utf-8", "replace")
except Exception:
txt = repr(payload)
self.log("收到:", txt[:400])
return got
def close(self):
if self.sock:
try:
self._send_control(0x08, struct.pack("!H", 1000))
except Exception:
pass
try:
self.sock.close()
except Exception:
pass
self.sock = None
class Robot:
def __init__(self, ws, rate=150, base=None):
self.ws, self.rate = ws, rate
self.state = dict(DEFAULT_STATE)
if base:
# 基线:先用设备当前的真实姿态覆盖默认值,避免首个动作帧改掉姿态
for k, v in base.items():
if v is not None and k in self.state:
self.state[k] = v
def send(self, **extra):
self.state.update(extra)
frame = {
"dir": self.state["dir"], "mode": self.state["mode"],
"roll": self.state["roll"], "height": self.state["height"],
"linear": self.state["linear"], "angular": self.state["angular"],
"stable": self.state["stable"],
"joy_x": self.state["joy_x"], "joy_y": self.state["joy_y"],
}
self.ws.send_text(json.dumps(frame, separators=(",", ":")))
return frame
def stream(self, extra, ms):
step = max(30, self.rate)
t0 = time.time()
self.send(**extra)
while (time.time() - t0) * 1000.0 < ms:
left = ms / 1000.0 - (time.time() - t0)
if left <= 0:
break
time.sleep(min(step / 1000.0, left))
if (time.time() - t0) * 1000.0 >= ms:
break
self.send(**extra)
def run(robot, cmd, args, opts):
def num(v, d):
return d if v is None or v == "" else float(v) if "." in str(v) else int(v)
if cmd == "stop":
robot.send(dir="stop", joy_x=0, joy_y=0)
robot.ws.log("已停止")
return
if cmd == "probe":
robot.send(dir="stop")
got = robot.ws.drain(float(args[0]) if args else 3.0)
robot.ws.log(f"监听结束,收到 {got} 帧")
return
if cmd == "raw":
obj = json.loads(" ".join(args))
robot.send(**obj)
robot.ws.log("已发:", json.dumps(obj, ensure_ascii=False))
return
if cmd == "height":
robot.send(height=num(args[0] if args else None, 38))
robot.ws.log(f"站高 = {robot.state['height']}mm")
return
if cmd == "roll":
robot.send(roll=num(args[0] if args else None, 0))
robot.ws.log(f"横滚 = {robot.state['roll']}°")
return
if cmd in ("linear", "angular"):
v = num(args[0] if args else None, 0)
ms = opts.ms or (num(args[1], 800) if len(args) > 1 else 800)
robot.stream({cmd: v}, ms)
robot.send(**{cmd: 0})
robot.ws.log(f"{cmd} = {v} 持续 {ms}ms 后归零")
return
if cmd == "stable":
robot.send(stable=1 if num(args[0] if args else None, 0) else 0)
robot.ws.log(f"stable = {robot.state['stable']}")
return
if cmd == "joy":
x = num(args[0] if args else None, 0)
y = num(args[1], 0) if len(args) > 1 else 0
ms = opts.ms or (num(args[2], 800) if len(args) > 2 else 800)
robot.stream({"joy_x": x, "joy_y": y}, ms)
robot.send(joy_x=0, joy_y=0, dir="stop")
robot.ws.log(f"摇杆 x={x} y={y} 持续 {ms}ms 后回中")
return
if cmd == "seq":
script = " ".join(args)
for part in [p.strip() for p in script.split(";") if p.strip()]:
seg = part.split()
run(robot, seg[0], seg[1:], opts)
time.sleep(0.12)
return
if cmd in DIRS:
ms = opts.ms or (300 if cmd == "jump" else 800)
robot.stream({"dir": cmd}, ms)
robot.send(dir="stop")
robot.ws.log(f"{cmd} 持续 {ms}ms 后停止")
return
raise SystemExit(
"未知命令: %s\n可用: stop forward back left right jump joy height roll "
"linear angular stable seq raw probe" % cmd
)
def main():
ap = argparse.ArgumentParser(add_help=True, description="WL-PRO 轮组机器人遥控")
ap.add_argument("--host", default="192.168.4.1")
ap.add_argument("--port", type=int, default=81)
ap.add_argument("--ms", type=int, default=0)
ap.add_argument("--rate", type=int, default=150)
ap.add_argument("--timeout", type=float, default=8.0)
# 设备当前真实姿态(设备不回传状态,必须由人告知,否则动作帧会改掉姿态)
ap.add_argument("--height", type=float, default=None, help="设备当前站高 mm")
ap.add_argument("--roll", type=float, default=None, help="设备当前横滚 deg")
ap.add_argument("--stable", type=int, default=None, help="设备当前 stable 0/1")
ap.add_argument("--linear", type=float, default=None, help="基线线速度,一般留空")
ap.add_argument("--angular", type=float, default=None, help="基线角速度,一般留空")
ap.add_argument("cmd", nargs="?", default=None)
ap.add_argument("args", nargs="*")
opts = ap.parse_args()
if not opts.cmd:
ap.print_help()
sys.exit(1)
ws = WS(opts.host, opts.port, timeout=opts.timeout)
code = 0
try:
ws.connect()
base = {
"height": opts.height, "roll": opts.roll, "stable": opts.stable,
"linear": opts.linear, "angular": opts.angular,
}
robot = Robot(ws, rate=opts.rate, base=base)
if opts.height is None and opts.cmd in ("forward", "back", "left", "right", "jump", "joy", "stop", "seq"):
print("[robot] 注意: 未用 --height 指定设备当前站高,将按默认 38mm 发送。"
"若设备实际站高不是 38mm,本命令会把它改到 38mm。", file=sys.stderr)
run(robot, opts.cmd, opts.args, opts)
time.sleep(0.15)
except KeyboardInterrupt:
code = 130
print("\n[robot] 已中断")
except Exception as e:
print(f"[robot] 失败: {e}", file=sys.stderr)
code = 1
finally:
try:
ws.close()
except Exception:
pass
sys.exit(code)
if __name__ == "__main__":
main()