反向实现设备 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/方向键无效
- 网页与脚本不可同时连接,会互相覆盖帧
245 lines
7.8 KiB
JavaScript
245 lines
7.8 KiB
JavaScript
#!/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);
|
||
}
|
||
})();
|