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:
commit
6dd1fa7e24
11 files changed
+1971
No files matched your search
+244
@@ -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);
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user