Files
deepseek Bot 6dd1fa7e24 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/方向键无效
- 网页与脚本不可同时连接,会互相覆盖帧
2026-10-25 17:53:11 +08:00

351 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()