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

245 lines
7.8 KiB
JavaScript
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 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);
}
})();