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

+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env node
/**
* WL-PRO 轮组机器人 遥控脚本(无外部依赖,Node >= 22 自带 WebSocket)
*
* 控制通道与网页一致的协议:ws://<host>:81/ 发送 JSON 状态帧:
* {dir, mode, roll, height, linear, angular, stable, joy_x, joy_y}
* dir: stop | forward | back | left | right | jump
*
* 用法示例:
* node robot-ctl.js --host 203.0.113.10 --port 81 stop
* node robot-ctl.js --host 192.168.4.1 forward --ms 800
* node robot-ctl.js jump # 原地跳一下
* node robot-ctl.js joy 0 100 --ms 1200 # 摇杆:x=0 y=100 持续 1.2s
* node robot-ctl.js height 60 # 站高 60mm(32~85)
* node robot-ctl.js roll 10 # 横滚 10°(-30~30)
* node robot-ctl.js linear 100 --ms 500 # 线速度 100mm/s
* node robot-ctl.js seq "height 50; forward 800; stop"
* node robot-ctl.js probe # 只连接并打印收到的帧
* node robot-ctl.js raw '{"dir":"jump"}'
*
* 通用参数:--host <ip> --port <p, 默认81> --timeout <ms> --rate <ms 推帧间隔, 默认150> --wet
* --wet:允许真正发出动作(不写它也能动,仅为显式确认用法保留)
*/
const DEFAULT_STATE = {
roll: 0,
height: 38,
linear: 0,
angular: 0,
stable: 0,
mode: 'basic',
dir: 'stop',
joy_x: 0,
joy_y: 0,
};
function parseArgs(argv) {
const opts = { host: '192.168.4.1', port: 81, timeout: 6000, rate: 150, ms: null, wet: false };
const tokens = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--host') opts.host = argv[++i];
else if (a === '--port') opts.port = Number(argv[++i]);
else if (a === '--timeout') opts.timeout = Number(argv[++i]);
else if (a === '--rate') opts.rate = Number(argv[++i]);
else if (a === '--ms') opts.ms = Number(argv[++i]);
else if (a === '--wet') opts.wet = true;
else tokens.push(a);
}
opts.cmd = tokens[0];
opts.args = tokens.slice(1);
return opts;
}
class Robot {
constructor(opts) {
this.opts = opts;
this.state = { ...DEFAULT_STATE };
this.ws = null;
this.log = (...m) => console.log('[robot]', ...m);
}
url() {
return `ws://${this.opts.host}:${this.opts.port}/`;
}
connect() {
if (typeof WebSocket === 'undefined') {
return Promise.reject(new Error('当前 Node 无全局 WebSocket,需要 Node >= 22'));
}
return new Promise((resolve, reject) => {
const ws = new WebSocket(this.url());
this.ws = ws;
const timer = setTimeout(() => {
try { ws.close(); } catch {}
reject(new Error(`连接超时 ${this.url()}`));
}, this.opts.timeout);
ws.onopen = () => {
clearTimeout(timer);
this.log('已连接', this.url());
resolve();
};
ws.onerror = (e) => {
clearTimeout(timer);
reject(new Error(`连接失败 ${this.url()} (${(e && (e.message || e.type)) || 'error'})`));
};
ws.onclose = (e) => {
this.log('连接关闭', e?.code ?? '', e?.reason ?? '');
};
ws.onmessage = (m) => {
const s = typeof m.data === 'string' ? m.data : '[binary]';
this.log('收到:', s.slice(0, 400));
};
});
}
send(extra = {}) {
if (!this.ws || this.ws.readyState !== 1) throw new Error('未连接');
Object.assign(this.state, extra);
const frame = {
dir: this.state.dir,
mode: this.state.mode,
roll: this.state.roll,
height: this.state.height,
linear: this.state.linear,
angular: this.state.angular,
stable: this.state.stable,
joy_x: this.state.joy_x,
joy_y: this.state.joy_y,
};
this.ws.send(JSON.stringify(frame));
return frame;
}
sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
/** 推一段时间状态帧(用于持续动作/摇杆) */
async stream(extra, ms, rate) {
const step = Math.max(30, rate || this.opts.rate);
const t0 = Date.now();
this.send(extra);
while (Date.now() - t0 < ms) {
await this.sleep(Math.min(step, Math.max(0, ms - (Date.now() - t0))));
if (Date.now() - t0 >= ms) break;
this.send(extra);
}
}
stop() {
if (this.ws && this.ws.readyState === 1) {
this.send({ dir: 'stop', joy_x: 0, joy_y: 0 });
}
}
close() {
try { this.stop(); } catch {}
try { if (this.ws) this.ws.close(); } catch {}
}
}
const DIRS = new Set(['forward', 'back', 'left', 'right', 'jump']);
async function runOne(robot, cmd, args, opts) {
const num = (v, d) => (v === undefined || v === '' ? d : Number(v));
switch (cmd) {
case 'stop':
robot.send({ dir: 'stop', joy_x: 0, joy_y: 0 });
robot.log('停止');
return;
case 'probe': {
const ms = Number(args[0] ?? 3000);
robot.send({ dir: 'stop' });
robot.log(`已同步状态,监听 ${ms}ms 内机器人回传…`);
await robot.sleep(ms);
return;
}
case 'raw': {
const json = args.join(' ');
const obj = JSON.parse(json);
const frame = robot.send(obj);
robot.log('已发:', JSON.stringify(frame));
return;
}
case 'height':
robot.send({ height: num(args[0], 38) });
robot.log(`站高 = ${robot.state.height}mm`);
return;
case 'roll':
robot.send({ roll: num(args[0], 0) });
robot.log(`横滚 = ${robot.state.roll}°`);
return;
case 'linear':
case 'angular': {
const v = num(args[0], 0);
const ms = opts.ms ?? num(args[1], 800);
await robot.stream({ [cmd]: v }, ms, opts.rate);
robot.send({ [cmd]: 0 });
robot.log(`${cmd} = ${v} 持续 ${ms}ms 后归零`);
return;
}
case 'stable':
robot.send({ stable: num(args[0], 0) ? 1 : 0 });
robot.log(`stable = ${robot.state.stable}`);
return;
case 'joy': {
const x = num(args[0], 0), y = num(args[1], 0);
const ms = opts.ms ?? num(args[2], 800);
await robot.stream({ joy_x: x, joy_y: y }, ms, opts.rate);
robot.send({ joy_x: 0, joy_y: 0, dir: 'stop' });
robot.log(`摇杆 x=${x} y=${y} 持续 ${ms}ms 后回中`);
return;
}
case 'seq': {
const script = args.join(' ');
for (const part of script.split(';').map((s) => s.trim()).filter(Boolean)) {
const seg = part.split(/\s+/);
await runOne(robot, seg[0], seg.slice(1), opts);
await robot.sleep(120);
}
return;
}
default: {
if (DIRS.has(cmd)) {
const ms = opts.ms ?? (cmd === 'jump' ? 300 : 800);
await robot.stream({ dir: cmd }, ms, opts.rate);
robot.send({ dir: 'stop' });
robot.log(`${cmd} 持续 ${ms}ms 后停止`);
return;
}
throw new Error(`未知命令: ${cmd}(可用: stop forward back left right jump joy height roll linear angular stable seq raw probe)`);
}
}
}
(async () => {
const opts = parseArgs(process.argv.slice(2));
if (!opts.cmd) {
console.log('用法: node robot-ctl.js [--host IP] [--port 81] [--ms N] <命令> [参数…]');
console.log('命令: stop | forward | back | left | right | jump | joy <x> <y> [ms] |');
console.log(' height <mm> | roll <deg> | linear <mm/s> [ms] | angular <deg/s> [ms] |');
console.log(' stable <0|1> | seq "<cmd…>; <cmd…>" | raw <json> | probe [ms]');
process.exit(1);
}
const robot = new Robot(opts);
let code = 0;
const cleanup = () => { try { robot.close(); } catch {} };
process.on('SIGINT', () => { cleanup(); process.exit(130); });
process.on('uncaughtException', (e) => { console.error('[robot] 错误:', e.message); cleanup(); process.exit(1); });
try {
await robot.connect();
await runOne(robot, opts.cmd, opts.args, opts);
await robot.sleep(120);
} catch (e) {
console.error('[robot] 失败:', e.message);
code = 1;
} finally {
cleanup();
await new Promise((r) => setTimeout(r, 150));
process.exit(code);
}
})();