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
+22
@@ -0,0 +1,22 @@
|
||||
# 运行时产物
|
||||
daemon.log
|
||||
daemon.pid
|
||||
daemon-state.json
|
||||
*.log
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# 编辑器 / 系统
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
*.swp
|
||||
.git-credentials
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 WL-PRO Robot Control contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,157 @@
|
||||
# WL-PRO 轮组机器人遥控
|
||||
|
||||
用脚本驱动 WL-PRO 轮组机器人,绕开网页界面。
|
||||
|
||||
**版本**:1.0
|
||||
|
||||
---
|
||||
|
||||
## 这是什么
|
||||
|
||||
WL-PRO 的网页遥控页(`http://<device>/`)通过 WebSocket 向设备端口 **81** 发 JSON 帧。
|
||||
本项目直接复现这个协议,让你能从命令行、脚本或自动化流程里控制机器人。
|
||||
|
||||
包含两种工作模式:
|
||||
|
||||
| 模式 | 文件 | 适用 |
|
||||
|---|---|---|
|
||||
| 单次命令 | `robot-ctl.py` | 局域网直连、一次性动作 |
|
||||
| 常驻守护 | `robot-daemon.py` + `rc.sh` | 远程 / 需要持续保持连接 |
|
||||
|
||||
**远程场景请用常驻模式**,原因见 [docs/PROTOCOL.md](docs/PROTOCOL.md) 的「为什么必须常驻」。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 局域网直连(最简单)
|
||||
|
||||
```bash
|
||||
python3 robot-ctl.py --host 192.168.4.1 joy 0 50 1000
|
||||
```
|
||||
|
||||
### 远程常驻(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 启动守护进程(保持唯一长连接)
|
||||
setsid nohup python3 robot-daemon.py --host <公网IP> --port <映射端口> \
|
||||
> daemon.log 2>&1 < /dev/null &
|
||||
echo $! > daemon.pid
|
||||
|
||||
# 2. 下发动作
|
||||
bash rc.sh go 50 1000 # 前进 1 秒
|
||||
bash rc.sh jump # 跳一下
|
||||
bash rc.sh stop # 立即停
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 协议要点
|
||||
|
||||
设备只接受**摇杆**和**跳跃**,其他字段无效(实测)。
|
||||
|
||||
每帧完整 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"dir": "stop", "mode": "basic", "roll": 0, "height": 38,
|
||||
"linear": 0, "angular": 0, "stable": 1,
|
||||
"joy_x": 0, "joy_y": 50
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 范围 | 说明 |
|
||||
|---|---|---|
|
||||
| `joy_y` | -100 ~ 100 | 正=前进,负=后退 |
|
||||
| `joy_x` | -100 ~ 100 | 正=右转,负=左转 |
|
||||
| `dir` | `stop` / `jump` | 只有 `jump` 有效 |
|
||||
| `stable` | 0 / 1 | 主动保持平衡,**必须保持 1** |
|
||||
| `height` | 32 ~ 85 | 站高 mm |
|
||||
| `roll` | -30 ~ 30 | 设备侧无效,固定 0 |
|
||||
| `linear` / `angular` | — | 设备侧无效,固定 0 |
|
||||
|
||||
---
|
||||
|
||||
## 实测标定值(50% 速度)
|
||||
|
||||
设备响应**非线性**,以下仅在 `joy` 绝对值 50 附近有效。
|
||||
|
||||
| 动作 | 参数 | 实测 |
|
||||
|---|---|---|
|
||||
| 前进 / 后退 | `joy_y = ±50` | **≈ 8 cm/s** |
|
||||
| 左转 / 右转 | `joy_x = ±50` | **≈ 35 °/s** |
|
||||
| 跳跃 | `dir: jump` | 点动 300ms |
|
||||
|
||||
**距离换算**
|
||||
|
||||
| 目标 | 时长 |
|
||||
|---|---|
|
||||
| 5 cm | 600 ms |
|
||||
| 10 cm | 1200 ms |
|
||||
| 20 cm | 2500 ms |
|
||||
| 30 cm | 3800 ms |
|
||||
| 50 cm | 6000 ms |
|
||||
|
||||
**角度换算**
|
||||
|
||||
| 目标 | 时长 |
|
||||
|---|---|
|
||||
| 15° | 430 ms |
|
||||
| 35° | 1000 ms |
|
||||
| 45° | 1300 ms |
|
||||
| 90° | 2600 ms |
|
||||
| 180° | 5100 ms(建议分两段) |
|
||||
|
||||
> 速度低于约 50% 时推进力不足,机器人基本不动。超过 80% 明显更快但更难控。
|
||||
> 单次动作超过 3 秒,轮子打滑和地面不平会让误差累积,长距离建议分段执行并中途校正。
|
||||
|
||||
---
|
||||
|
||||
## 安全约束
|
||||
|
||||
本项目内置以下硬约束,防止误操作:
|
||||
|
||||
1. **`stable` 恒为 1** —— 主动保持平衡不可通过脚本关闭
|
||||
2. **`height` 恒为 38 / `roll` 恒为 0** —— 动作帧不会意外改变姿态
|
||||
3. **动作帧自动补停止帧** —— 到期立即归零
|
||||
|
||||
第 1 条在 `rc.sh` 里有显式拒绝分支:
|
||||
|
||||
```bash
|
||||
$ bash rc.sh stable 0
|
||||
[rc] 拒绝:主动保持平衡不允许关闭(用户明确指令)。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `robot-ctl.py` | 单次命令客户端,纯标准库,无依赖 |
|
||||
| `robot-daemon.py` | 常驻守护进程,维持唯一长连接 |
|
||||
| `rc.sh` | 向守护进程注入动作(写状态文件) |
|
||||
| `docs/PROTOCOL.md` | 协议逆向细节与踩坑记录 |
|
||||
| `docs/device-page.html` | 设备原始网页快照(协议参考) |
|
||||
| `tests/fake-robot-ws.js` | 假设备,用于离线验证帧格式 |
|
||||
|
||||
---
|
||||
|
||||
## 依赖
|
||||
|
||||
- Python 3.7+(`robot-ctl.py` / `robot-daemon.py` 仅用标准库)
|
||||
- Node.js 18+(仅 `tests/fake-robot-ws.js` 需要,需 `ws` 包)
|
||||
|
||||
---
|
||||
|
||||
## 已知限制
|
||||
|
||||
1. **设备不回传状态** —— 固件是 Arduino WebSocketsServer,纯单向,无法确认动作是否生效,只能靠肉眼观察
|
||||
2. **同一时刻只能一个客户端** —— 网页和脚本同时连会互相覆盖指令
|
||||
3. **无位置反馈** —— 不能闭环控制,长距离移动会累积误差
|
||||
|
||||
---
|
||||
|
||||
## 许可
|
||||
|
||||
MIT,见 [LICENSE](LICENSE)。
|
||||
@@ -0,0 +1,181 @@
|
||||
# 协议逆向笔记
|
||||
|
||||
记录 WL-PRO 轮组机器人的通信协议,以及踩过的坑。内容全部来自实测,不是推测。
|
||||
|
||||
---
|
||||
|
||||
## 设备识别
|
||||
|
||||
WebSocket 握手响应头泄露了固件身份:
|
||||
|
||||
```
|
||||
HTTP/1.1 101 Switching Protocols
|
||||
Server: arduino-WebSocketsServer
|
||||
Upgrade: websocket
|
||||
Connection: Upgrade
|
||||
Sec-WebSocket-Version: 13
|
||||
Sec-WebSocket-Accept: ...
|
||||
```
|
||||
|
||||
结论:固件基于 **Arduino WebSocketsServer** 库(ESP32 / Arduino 平台)。
|
||||
|
||||
这个库的关键特性:**纯单向**。
|
||||
|
||||
- 握手完成后不发送任何数据
|
||||
- 收到客户端帧后不返回任何 ack 或状态
|
||||
- 因此**永远无法确认动作是否执行**,只能靠肉眼观察
|
||||
|
||||
---
|
||||
|
||||
## 端点
|
||||
|
||||
| 用途 | 地址 |
|
||||
|---|---|
|
||||
| 遥控页 | `http://<device>/` |
|
||||
| 控制通道 | `ws://<device>:81/` |
|
||||
| WiFi 设置 | `http://<device>/wifi` |
|
||||
|
||||
控制通道固定 **81 端口**,路径为 `/`。
|
||||
|
||||
---
|
||||
|
||||
## 帧格式
|
||||
|
||||
完整的 JSON 文本帧,每次发送必须包含全部字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"dir": "stop",
|
||||
"mode": "basic",
|
||||
"roll": 0,
|
||||
"height": 38,
|
||||
"linear": 0,
|
||||
"angular": 0,
|
||||
"stable": 1,
|
||||
"joy_x": 0,
|
||||
"joy_y": 50
|
||||
}
|
||||
```
|
||||
|
||||
### 字段详解
|
||||
|
||||
| 字段 | 范围 | 有效 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `joy_y` | -100 ~ 100 | ✅ | 纵向摇杆。正值前进,负值后退 |
|
||||
| `joy_x` | -100 ~ 100 | ✅ | 横向摇杆。正值右转,负值左转 |
|
||||
| `dir` | 字符串 | ✅ 仅 `jump` | `jump` 触发跳跃;其余值(含 `forward`/`back`/`left`/`right`)**无效** |
|
||||
| `stable` | 0 / 1 | ✅ | 主动保持平衡开关。1 = 开启 |
|
||||
| `height` | 32 ~ 85 | ✅ | 站高,单位 mm |
|
||||
| `roll` | -30 ~ 30 | ❌ | 滑块存在但设备侧无响应 |
|
||||
| `linear` | -200 ~ 200 | ❌ | 单位 mm/s,设备侧无响应 |
|
||||
| `angular` | -100 ~ 100 | ❌ | 单位 °/s,设备侧无响应 |
|
||||
| `mode` | 字符串 | — | 固定 `basic` |
|
||||
|
||||
**只有摇杆(`joy_x`/`joy_y`)、跳跃(`dir:"jump"`)、`stable`、`height` 真正生效。**
|
||||
|
||||
`roll` / `linear` / `angular` 三个滑块在网页上有 UI,但拖动了机器人没反应——用户确认过,不要浪费时间试。
|
||||
|
||||
---
|
||||
|
||||
## 为什么必须常驻连接
|
||||
|
||||
这是本项目最重要的一条经验。
|
||||
|
||||
### 现象
|
||||
|
||||
早期实现是「每次命令新建连接 → 推几百毫秒帧 → 断开」。结果:
|
||||
|
||||
```
|
||||
17:32 执行 joy_y=-60 持续 600ms,命令已退出
|
||||
17:36 用户报告「还在退」
|
||||
```
|
||||
|
||||
**命令结束四分钟后,机器人还在移动。**
|
||||
|
||||
### 原因
|
||||
|
||||
设备对短连接的处理不可靠:客户端推完动作帧后立刻发 WebSocket close 帧,**归零帧可能随连接关闭一起丢失**,设备保留最后一次收到的速度值。
|
||||
|
||||
设备是纯单向的,不会告诉我们它记住了什么。
|
||||
|
||||
### 解法
|
||||
|
||||
保持**单条长连接永不主动断开**:
|
||||
|
||||
- 空闲时每 120ms 推一帧全零(`dir:"stop"`, `joy:0,0`),与网页空闲时的行为一致
|
||||
- 动作通过状态文件注入,带到期时间,到期自动归零
|
||||
- 断开前先补 12 帧零,再发 close
|
||||
|
||||
`robot-daemon.py` 实现的就是这个模式。
|
||||
|
||||
---
|
||||
|
||||
## 网页抢帧冲突
|
||||
|
||||
网页里这个定时器是隐形杀手:
|
||||
|
||||
```js
|
||||
setInterval(function () {
|
||||
send_data(); // dir 恒为 "stop",joy_x/joy_y 取摇杆当前位置
|
||||
}, 150);
|
||||
```
|
||||
|
||||
不动摇杆时摇杆自动回中,所以它**每 150ms 发一帧 `joy=0,0`**。
|
||||
|
||||
如果网页和脚本同时连着设备,两个独立 WS 客户端的帧会交错:
|
||||
|
||||
```
|
||||
脚本: joy_y = -60
|
||||
网页: joy_y = 0 ← 150ms 后
|
||||
脚本: joy_y = -60
|
||||
网页: joy_y = 0
|
||||
```
|
||||
|
||||
设备收到的是来回跳变的矛盾指令,表现就是"几乎没动"或原地抖动。
|
||||
|
||||
**结论:操作前必须关掉网页标签页**(关闭,不是最小化)。
|
||||
|
||||
同理,网页的方向键是 `mousedown` 发一帧 `dir`、`mouseup` 发一帧 `stop`,中间还有 150ms 定时器插一帧 `stop`——**实际效果是 ≤150ms 的瞬间点动**,不是持续移动。
|
||||
|
||||
早期我把 `dir:"forward"` 连推 1 秒(7 帧),等于连下 7 次动作指令不给停止,姿态控制被打乱,机器人直接摔倒。
|
||||
|
||||
---
|
||||
|
||||
## 非线性响应
|
||||
|
||||
速度与 `joy` 值不是线性的:
|
||||
|
||||
| `joy` 绝对值 | 实测表现 |
|
||||
|---|---|
|
||||
| 20 | 几乎不动 |
|
||||
| 30 | 前进 3 秒变化很小 |
|
||||
| 50 | ≈ 8 cm/s(可用基准) |
|
||||
| 80 | 明显更快 |
|
||||
|
||||
**低于约 50% 推进力不足**。标定表只在 50% 附近有效,换速度需重新标定。
|
||||
|
||||
---
|
||||
|
||||
## 远程访问
|
||||
|
||||
设备通常藏在局域网里。远程控制需要一条端口隧道:
|
||||
|
||||
- frpc 客户端在局域网机器上
|
||||
- 代理类型必须选 **tcp**(`http`/`https` 是 frps 的虚拟主机模式,需要 `vhostHTTPPort`,通常没开)
|
||||
- `localIP` 必须填**设备真实 IP**,不能填 `127.0.0.1`(那是客户端自己)
|
||||
- `localPort` = 81,`remotePort` = 云服务器上已放行的端口
|
||||
|
||||
云服务器安全组必须放行 `remotePort`。被 DROP 的端口表现为连接超时(不是拒绝),很容易误判成网络问题。
|
||||
|
||||
---
|
||||
|
||||
## 调试技巧
|
||||
|
||||
判断隧道是否真的打通,看 frps 的**出向流量**:
|
||||
|
||||
```
|
||||
in=579B / out=0B ← 后端没收到,或连错了对象
|
||||
in=2155B / out=964B ← 通了
|
||||
```
|
||||
|
||||
`in` 是我们发出的字节,`out` 是设备返回的字节。纯单向设备握手后不再说话,但**握手响应本身**会让 `out` 增长。`out=0` 说明数据根本没到达设备。
|
||||
@@ -0,0 +1,696 @@
|
||||
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>legged wheel robot web ctrl</title>
|
||||
<style>
|
||||
h2{
|
||||
width: auto;
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
text-align: center;
|
||||
font-family: 等线;
|
||||
color: white;
|
||||
background-color:cornflowerblue;
|
||||
border-radius: 12px;
|
||||
}
|
||||
input{
|
||||
width: 160px;
|
||||
height: 30px;
|
||||
margin: 0px;
|
||||
}
|
||||
.sliderLabel{
|
||||
float: left;
|
||||
text-align: center;
|
||||
line-height: 30px;
|
||||
height: 30px;
|
||||
/* border: 2px solid red; */
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.sliders{
|
||||
width: 400px;
|
||||
height: 150px;
|
||||
/* border: 2px red solid; */
|
||||
margin: 10px auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selects{
|
||||
width: 300px;
|
||||
height: 50px;
|
||||
padding: 10px;
|
||||
margin: 10px auto;
|
||||
/* border: 2px red solid; */
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
|
||||
}
|
||||
.view {
|
||||
width: 250px;
|
||||
height: 50px;
|
||||
padding: 0px;
|
||||
margin: 20px auto;
|
||||
/* border: 2px red solid; */
|
||||
}
|
||||
.view2 {
|
||||
width: 140px;
|
||||
height: 30px;
|
||||
padding: 0px;
|
||||
margin: 15px auto;
|
||||
vertical-align:middle;
|
||||
/* border: 2px red solid; */
|
||||
}
|
||||
.btn1 {
|
||||
width: 80px;
|
||||
height: 40px;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
border-radius: 10px;
|
||||
background-color: white;
|
||||
display: inline-block;
|
||||
}
|
||||
form {
|
||||
width: 80px;
|
||||
height: 40px;
|
||||
padding: 0px;
|
||||
margin: 10px auto;
|
||||
display: inline-block;
|
||||
}
|
||||
.buttons{
|
||||
width: 300px;
|
||||
height: 180px;
|
||||
padding: 10px;
|
||||
margin: 10px auto;
|
||||
position: relative;
|
||||
/* border: 2px red solid; */
|
||||
}
|
||||
.dir{
|
||||
font-size: 15px;
|
||||
/* font-family: 'Courier New', Courier, monospace; */
|
||||
/* font-weight: bold; */
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
text-align: center;
|
||||
border-radius: 12px;
|
||||
background-color: white;
|
||||
color: cornflowerblue;
|
||||
border: 3px solid cornflowerblue;
|
||||
padding: 0px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
option {
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
#reset {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
border-radius: 12px;
|
||||
border: 2px solid;
|
||||
font-size: 15px;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 0px;
|
||||
/* 上 右 下 左 */
|
||||
}
|
||||
#gait_select {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
border: 2px solid red;
|
||||
font-size: 15px;
|
||||
color: red;
|
||||
border-radius: 12px;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 110px;
|
||||
}
|
||||
#gait_submit {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
border: 2px solid red;
|
||||
color: red;
|
||||
font-size: 15px;
|
||||
display: inline-block;
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 220px;
|
||||
}
|
||||
#forward{
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 110px;
|
||||
}
|
||||
#back{
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
left: 110px;
|
||||
top: 140px;
|
||||
}
|
||||
#jump{
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
left: 110px;
|
||||
}
|
||||
#right{
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
left: 220px;
|
||||
}
|
||||
#left{
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* Switch开关样式 */
|
||||
/* 必须是input为 checkbox class 添加 switch 才能实现以下效果 */
|
||||
input[type='checkbox'].switch{
|
||||
outline: none;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
background: #ccc;
|
||||
border-radius: 10px;
|
||||
transition: border-color .3s, background-color .3s;
|
||||
margin: 0px 20px 0px 0px;
|
||||
}
|
||||
|
||||
input[type='checkbox'].switch::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height:1rem;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0, 0, 2px, #999;
|
||||
transition:.4s;
|
||||
top: 2px;
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
input[type='checkbox'].switch:checked {
|
||||
background: rgb(78, 78, 240);
|
||||
}
|
||||
/* 当input[type=checkbox]被选中时:伪元素显示下面样式 位置发生变化 */
|
||||
input[type='checkbox'].switch:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 55%;
|
||||
top: 2px;
|
||||
}
|
||||
*{
|
||||
-webkit-touch-callout:none;
|
||||
-webkit-user-select:none;
|
||||
-khtml-user-select:none;
|
||||
-moz-user-select:none;
|
||||
-ms-user-select:none;
|
||||
user-select:none;
|
||||
}
|
||||
|
||||
/*摇杆内容*/
|
||||
.row
|
||||
{
|
||||
display: inline-flex;
|
||||
clear: both;
|
||||
}
|
||||
.columnLateral
|
||||
{
|
||||
float: left;
|
||||
width: 15%;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
#joystick
|
||||
{
|
||||
border: 0px solid #FF0000;
|
||||
}
|
||||
|
||||
.dimSlide { transform: scaleX(2) rotate(0deg); width: 200px;position: relative}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<!-- onload 事件在页面载入完成后立即触发 -->
|
||||
<body onload="javascript:socket_init()">
|
||||
<h2>WL-PRO WiFi遥控模式</h2>
|
||||
<div style="text-align:center;margin:-4px 0 10px;">
|
||||
<a href="/wifi" style="color:#6495ed;font-size:15px;">WiFi 设置 / WiFi Settings</a>
|
||||
</div>
|
||||
<div class="view2">
|
||||
<input type="checkbox" id="stable" class="switch" onclick="is_stable()" style="vertical-align:middle">Robot Go!</input>
|
||||
</div>
|
||||
<center><body>
|
||||
<!-- Example of two JoyStick integrated in the page structure -->
|
||||
<div class="row">
|
||||
<div class="columnLateral">
|
||||
<div id="joy1Div" style="width:200px;height:200px;margin:10px"></div>
|
||||
|
||||
<!-- angular_vel=<input id="joy1X" type="text" style="border-style:none;width:30px;" /> -->
|
||||
<!-- linear_vel=<input id="joy1Y" type="text" style="border-style:none;width:30px;" /> -->
|
||||
</div>
|
||||
</center>
|
||||
<div class="sliders">
|
||||
<div>
|
||||
<input type="range" min="32" max="85" value="38" id="hSlider" oninput="setHeight()" />
|
||||
<label class="sliderLabel" for="hSlider" id="hLabel">BaseHeight: 38mm</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="range" min="-30" max="30" value="0" id="rollSlider" oninput="setroll()" />
|
||||
<label class="sliderLabel" for="rollSlider" id="rollLabel">Roll: 0°</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="range" min="-200" max="200" value="0" id="linearSlider" oninput="setLinear()" />
|
||||
<label class="sliderLabel" for="linearSlider" id="linearLabel">LinearVel: 0mm/s</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="range" min="-100" max="100" value="0" id="angularSlider" oninput="setAngular()" />
|
||||
<label class="sliderLabel" for="angularSlider" id="angularLabel">AngularVel: 0°/s</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button class="dir" id="forward">Forward</button>
|
||||
<button class="dir" id="back">Back</button>
|
||||
<button class="dir" id="left">Left</button>
|
||||
<button class="dir" id="right">Right</button>
|
||||
<button class="dir" id="jump">Jump</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- <p id="tips" align="center" style="font-size: 20;"></p> -->
|
||||
<script>
|
||||
|
||||
var socket; // socket通信
|
||||
var g_roll=0; g_h=38;
|
||||
var g_linear = 0; g_angular = 0; g_stable = 0;
|
||||
var joyX = 0;
|
||||
var joyY = 0;
|
||||
// socket_init在页面载入完成后触发
|
||||
function socket_init() {
|
||||
// 初始化websocket客户端
|
||||
//socket = new WebSocket('ws://' + '192.168.4.1' + ':81/'); // AP模式
|
||||
socket = new WebSocket('ws://' + window.location.hostname + ':81/'); // AP/STA通用
|
||||
}
|
||||
|
||||
function setroll() {
|
||||
val = document.getElementById("rollSlider").value;
|
||||
val = parseInt(val);
|
||||
document.getElementById("rollLabel").innerHTML = "Roll: " + val + "°";
|
||||
g_roll = val;
|
||||
send_data();
|
||||
}
|
||||
function setHeight() {
|
||||
val = document.getElementById("hSlider").value;
|
||||
val = parseInt(val);
|
||||
document.getElementById("hLabel").innerHTML = "BaseHeight: " + val + "mm";
|
||||
g_h = val;
|
||||
send_data();
|
||||
}
|
||||
|
||||
function setLinear() {
|
||||
val = document.getElementById("linearSlider").value;
|
||||
val = parseInt(val);
|
||||
document.getElementById("linearLabel").innerHTML = "LinearVel: " + val + "mm/s";
|
||||
g_linear = val;
|
||||
send_data();
|
||||
}
|
||||
function setAngular() {
|
||||
val = document.getElementById("angularSlider").value;
|
||||
val = parseInt(val);
|
||||
document.getElementById("angularLabel").innerHTML = "AngularVel: " + val + "°/s";
|
||||
g_angular = val;
|
||||
send_data();
|
||||
}
|
||||
|
||||
|
||||
function send_data() {
|
||||
var data = {'roll':g_roll,'height':g_h,
|
||||
'linear':g_linear,'angular':g_angular,'stable':g_stable,
|
||||
'mode':'basic','dir':"stop",
|
||||
'joy_y':joyY,'joy_x':joyX,};
|
||||
socket.send(JSON.stringify(data));
|
||||
// console.log(data);
|
||||
}
|
||||
function is_stable() {
|
||||
var obj = document.getElementById("stable");
|
||||
if(obj.checked) {
|
||||
// alert("is_stable checked");
|
||||
g_stable = 1;
|
||||
}else {
|
||||
// alert("is_stable unchecked");
|
||||
g_stable = 0;
|
||||
}
|
||||
send_data();
|
||||
}
|
||||
|
||||
var buttons = document.getElementsByClassName("dir");
|
||||
for(i=0;i<buttons.length;i++) {
|
||||
buttons[i].addEventListener("mousedown",move,true);
|
||||
buttons[i].addEventListener("mouseup",stop,true);
|
||||
buttons[i].addEventListener("touchstart",move,true);
|
||||
buttons[i].addEventListener("touchend",stop,true);
|
||||
}
|
||||
function move() {
|
||||
this.style = "background-color: cornflowerblue; color: white;";
|
||||
var data = {'dir':this.id,'mode':'basic',
|
||||
'roll':g_roll,'height':g_h,
|
||||
'linear':g_linear,'angular':g_angular,'stable':g_stable,
|
||||
'joy_x':joyX,'joy_y':joyY,};
|
||||
socket.send(JSON.stringify(data));
|
||||
// console.log(data);
|
||||
}
|
||||
function stop() {
|
||||
this.style = "background-color: white; color: cornflowerblue;";
|
||||
var data = {'dir':"stop",'mode':'basic',
|
||||
'roll':g_roll,'height':g_h,
|
||||
'linear':g_linear,'angular':g_angular,'stable':g_stable,
|
||||
'joy_x':joyX,'joy_y':joyY,};
|
||||
socket.send(JSON.stringify(data));
|
||||
// console.log(data); // 打印测试
|
||||
}
|
||||
|
||||
|
||||
/*摇杆内容*/
|
||||
var JoyStick = (function(container, parameters)
|
||||
{
|
||||
parameters = parameters || {};
|
||||
var title = (typeof parameters.title === "undefined" ? "joystick" : parameters.title),
|
||||
width = (typeof parameters.width === "undefined" ? 0 : parameters.width),
|
||||
height = (typeof parameters.height === "undefined" ? 0 : parameters.height),
|
||||
internalFillColor = (typeof parameters.internalFillColor === "undefined" ? "#00979C" : parameters.internalFillColor),
|
||||
internalLineWidth = (typeof parameters.internalLineWidth === "undefined" ? 2 : parameters.internalLineWidth),
|
||||
internalStrokeColor = (typeof parameters.internalStrokeColor === "undefined" ? "#00979C" : parameters.internalStrokeColor),
|
||||
externalLineWidth = (typeof parameters.externalLineWidth === "undefined" ? 2 : parameters.externalLineWidth),
|
||||
externalStrokeColor = (typeof parameters.externalStrokeColor === "undefined" ? "#0097BC" : parameters.externalStrokeColor),
|
||||
autoReturnToCenter = (typeof parameters.autoReturnToCenter === "undefined" ? true : parameters.autoReturnToCenter);
|
||||
|
||||
// Create Canvas element and add it in the Container object
|
||||
var objContainer = document.getElementById(container);
|
||||
var canvas = document.createElement("canvas");
|
||||
canvas.id = title;
|
||||
if(width === 0) { width = objContainer.clientWidth; }
|
||||
if(height === 0) { height = objContainer.clientHeight; }
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
objContainer.appendChild(canvas);
|
||||
var context=canvas.getContext("2d");
|
||||
|
||||
var isPressing = 0;
|
||||
var isMoving = 0;
|
||||
var isRelease = 0;
|
||||
|
||||
var circumference = 2 * Math.PI;
|
||||
var internalRadius = (canvas.width-((canvas.width/2)+10))/2;
|
||||
var maxMoveStick = internalRadius + 5;
|
||||
var externalRadius = internalRadius + 30;
|
||||
var centerX = canvas.width / 2;
|
||||
var centerY = canvas.height / 2;
|
||||
var directionHorizontalLimitPos = canvas.width / 10;
|
||||
var directionHorizontalLimitNeg = directionHorizontalLimitPos * -1;
|
||||
var directionVerticalLimitPos = canvas.height / 10;
|
||||
var directionVerticalLimitNeg = directionVerticalLimitPos * -1;
|
||||
// Used to save current position of stick
|
||||
var movedX = centerX;
|
||||
var movedY = centerY;
|
||||
|
||||
|
||||
|
||||
// Check if the device support the touch or not
|
||||
if("ontouchstart" in document.documentElement)
|
||||
{
|
||||
|
||||
canvas.addEventListener("touchstart", onTouchStart, true);
|
||||
canvas.addEventListener("touchmove", onTouchMove, true);
|
||||
document.addEventListener("touchend", onTouchEnd, true);
|
||||
/*
|
||||
canvas.addEventListener("dragstart", onDragStart, false);
|
||||
canvas.addEventListener("dragend", onDragEnd, false);
|
||||
canvas.addEventListener("touchcancel", onTouchCancel, false);
|
||||
*/
|
||||
}
|
||||
else
|
||||
{
|
||||
canvas.addEventListener("mousedown", onMouseDown, true);
|
||||
canvas.addEventListener("mousemove", onMouseMove, true);
|
||||
document.addEventListener("mouseup", onMouseUp, true);
|
||||
}
|
||||
// Draw the object
|
||||
drawExternal();
|
||||
drawInternal();
|
||||
|
||||
/**
|
||||
* @desc Draw the external circle used as reference position
|
||||
*/
|
||||
function drawExternal()
|
||||
{
|
||||
context.beginPath();
|
||||
context.arc(centerX, centerY, externalRadius, 0, circumference, false);
|
||||
context.lineWidth = externalLineWidth;
|
||||
context.strokeStyle = externalStrokeColor;
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc Draw the internal stick in the current position the user have moved it
|
||||
*/
|
||||
function drawInternal()
|
||||
{
|
||||
context.beginPath();
|
||||
if(movedX<internalRadius) { movedX=maxMoveStick; }
|
||||
if((movedX+internalRadius) > canvas.width) { movedX = canvas.width-(maxMoveStick); }
|
||||
if(movedY<internalRadius) { movedY=maxMoveStick; }
|
||||
if((movedY+internalRadius) > canvas.height) { movedY = canvas.height-(maxMoveStick); }
|
||||
context.arc(movedX, movedY, internalRadius, 0, circumference, false);
|
||||
// create radial gradient
|
||||
var grd = context.createRadialGradient(centerX, centerY, 5, centerX, centerY, 200);
|
||||
// Light color
|
||||
grd.addColorStop(0, internalFillColor);
|
||||
// Dark color
|
||||
grd.addColorStop(1, internalStrokeColor);
|
||||
context.fillStyle = grd;
|
||||
context.fill();
|
||||
context.lineWidth = internalLineWidth;
|
||||
context.strokeStyle = internalStrokeColor;
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function postCoordinate()
|
||||
{
|
||||
joyX = (100*((movedX - centerX)/maxMoveStick)).toFixed();
|
||||
joyY = ((100*((movedY - centerY)/maxMoveStick))*-1).toFixed();
|
||||
|
||||
send_data();
|
||||
}
|
||||
|
||||
function releaseControl()
|
||||
{
|
||||
joyX = 0;
|
||||
joyY = 0;
|
||||
|
||||
send_data();
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc Events for manage touch
|
||||
*/
|
||||
function noTouch(event)
|
||||
{
|
||||
isPressing = 0;
|
||||
isMoving = 0;
|
||||
isRelease = 0;
|
||||
}
|
||||
|
||||
function onTouchStart(event)
|
||||
{
|
||||
isPressing = 1;
|
||||
isMoving = 0;
|
||||
isRelease = 0;
|
||||
}
|
||||
|
||||
function onTouchMove(event)
|
||||
{
|
||||
// Prevent the browser from doing its default thing (scroll, zoom)
|
||||
event.preventDefault();
|
||||
if(isPressing === 1 && event.targetTouches[0].target === canvas)
|
||||
{
|
||||
isMoving = 1;
|
||||
isRelease = 0;
|
||||
|
||||
movedX = event.targetTouches[0].pageX;
|
||||
movedY = event.targetTouches[0].pageY;
|
||||
// Manage offset
|
||||
if(canvas.offsetParent.tagName.toUpperCase() === "BODY")
|
||||
{
|
||||
movedX -= canvas.offsetLeft;
|
||||
movedY -= canvas.offsetTop;
|
||||
}
|
||||
else
|
||||
{
|
||||
movedX -= canvas.offsetParent.offsetLeft;
|
||||
movedY -= canvas.offsetParent.offsetTop;
|
||||
}
|
||||
// Delete canvas
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
// Redraw object
|
||||
drawExternal();
|
||||
drawInternal();
|
||||
|
||||
postCoordinate();
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchEnd(event)
|
||||
{
|
||||
//event.preventDefault();
|
||||
isPressing = 0;
|
||||
isMoving = 0;
|
||||
isRelease = 1;
|
||||
|
||||
// If required reset position store variable
|
||||
if(autoReturnToCenter)
|
||||
{
|
||||
movedX = centerX;
|
||||
movedY = centerY;
|
||||
}
|
||||
// Delete canvas
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
// Redraw object
|
||||
drawExternal();
|
||||
drawInternal();
|
||||
//canvas.unbind('touchmove');
|
||||
|
||||
releaseControl();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @desc Events for manage mouse
|
||||
*/
|
||||
function noMouse(event)
|
||||
{
|
||||
isPressing = 0;
|
||||
isMoving = 0;
|
||||
isRelease = 0;
|
||||
}
|
||||
|
||||
function onMouseDown(event)
|
||||
{
|
||||
isPressing = 1;
|
||||
isMoving = 0;
|
||||
isRelease = 0;
|
||||
}
|
||||
|
||||
function onMouseMove(event)
|
||||
{
|
||||
if(isPressing === 1)
|
||||
{
|
||||
isMoving = 1;
|
||||
isRelease = 0;
|
||||
|
||||
movedX = event.pageX;
|
||||
movedY = event.pageY;
|
||||
// Manage offset
|
||||
if(canvas.offsetParent.tagName.toUpperCase() === "BODY")
|
||||
{
|
||||
movedX -= canvas.offsetLeft;
|
||||
movedY -= canvas.offsetTop;
|
||||
}
|
||||
else
|
||||
{
|
||||
movedX -= canvas.offsetParent.offsetLeft;
|
||||
movedY -= canvas.offsetParent.offsetTop;
|
||||
}
|
||||
// Delete canvas
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
// Redraw object
|
||||
drawExternal();
|
||||
drawInternal();
|
||||
|
||||
postCoordinate();
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp(event)
|
||||
{
|
||||
isPressing = 0;
|
||||
isMoving = 0;
|
||||
isRelease = 1;
|
||||
|
||||
// If required reset position store variable
|
||||
if(autoReturnToCenter)
|
||||
{
|
||||
movedX = centerX;
|
||||
movedY = centerY;
|
||||
}
|
||||
// Delete canvas
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
// Redraw object
|
||||
drawExternal();
|
||||
drawInternal();
|
||||
//canvas.unbind('mousemove');
|
||||
|
||||
releaseControl();
|
||||
}
|
||||
|
||||
|
||||
this.GetX = function ()
|
||||
{
|
||||
return (100*((movedX - centerX)/maxMoveStick)).toFixed();
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Normalizzed value of Y move of stick
|
||||
* @return Integer from -100 to +100
|
||||
*/
|
||||
this.GetY = function ()
|
||||
{
|
||||
return ((100*((movedY - centerY)/maxMoveStick))*-1).toFixed();
|
||||
};
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
var joy1Param = { "title": "1" };
|
||||
var Joy1 = new JoyStick('joy1Div', joy1Param);
|
||||
var joy1X = document.getElementById("joy1X");
|
||||
var joy1Y = document.getElementById("joy1Y");
|
||||
setInterval(function()
|
||||
{
|
||||
joyX = Joy1.GetX();
|
||||
joyY = Joy1.GetY();
|
||||
send_data();
|
||||
}, 150);
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# 通过常驻守护进程注入动作(只写状态文件,不自己开连接)
|
||||
#
|
||||
# 前提:robot-daemon.py 正在运行,保持唯一长连接。
|
||||
#
|
||||
# 用法:
|
||||
# bash rc.sh go -40 800 # 后退 40% 速度 0.8 秒
|
||||
# bash rc.sh go 40 800 # 前进
|
||||
# bash rc.sh strafe 40 800 # 右转(负值左转)
|
||||
# bash rc.sh joy 30 -40 800 # 任意摇杆组合
|
||||
# bash rc.sh jump # 跳一下
|
||||
# bash rc.sh stop # 立即归零
|
||||
# bash rc.sh status # 看当前注入状态 + 守护进程是否活着
|
||||
# bash rc.sh hold # 持续注入零帧(确保静止,默认 5 秒)
|
||||
|
||||
set -u
|
||||
D="$(cd "$(dirname "$0")" && pwd)"
|
||||
STATE="${RC_STATE:-$D/daemon-state.json}"
|
||||
PIDF="$D/daemon.pid"
|
||||
|
||||
die() { echo "[rc] $*" >&2; exit 1; }
|
||||
|
||||
alive() {
|
||||
[ -f "$PIDF" ] || return 1
|
||||
local pid; pid="$(cat "$PIDF" 2>/dev/null || true)"
|
||||
[ -n "$pid" ] || return 1
|
||||
kill -0 "$pid" 2>/dev/null || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
write_state() { # joy_x joy_y dir seconds
|
||||
local jx="$1" jy="$2" dr="$3" secs="$4"
|
||||
local until
|
||||
if [ "${secs:-0}" = "0" ]; then
|
||||
until=0
|
||||
else
|
||||
until="$(python3 -c "import time;print(f'{time.time()+float($secs):.3f}')")"
|
||||
fi
|
||||
python3 - "$STATE" "$jx" "$jy" "$dr" "$until" <<'PY'
|
||||
import json, sys, os, tempfile
|
||||
path, jx, jy, dr, until = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), sys.argv[4], float(sys.argv[5])
|
||||
data = {"joy_x": jx, "joy_y": jy, "dir": dr, "until": until}
|
||||
d = os.path.dirname(os.path.abspath(path))
|
||||
fd, tmp = tempfile.mkstemp(dir=d)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(data, f)
|
||||
os.replace(tmp, path)
|
||||
PY
|
||||
}
|
||||
|
||||
CMD="${1:-}"; shift || true
|
||||
alive || die "守护进程未运行。先启动:setsid nohup python3 $D/robot-daemon.py > $D/daemon.log 2>&1 &"
|
||||
|
||||
# 平衡开关硬锁:拒绝关闭
|
||||
case "$CMD" in
|
||||
stable|balance)
|
||||
die "拒绝:主动保持平衡不允许关闭(用户明确指令)。"
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$CMD" in
|
||||
go)
|
||||
Y="${1:-50}"; MS="${2:-800}"
|
||||
write_state 0 "$Y" stop "$(python3 -c "print(float($MS)/1000)")"
|
||||
echo "[rc] 摇杆 y=$Y 持续 ${MS}ms" ;;
|
||||
strafe)
|
||||
X="${1:-50}"; MS="${2:-800}"
|
||||
write_state "$X" 0 stop "$(python3 -c "print(float($MS)/1000)")"
|
||||
echo "[rc] 摇杆 x=$X 持续 ${MS}ms" ;;
|
||||
joy)
|
||||
X="${1:-0}"; Y="${2:-0}"; MS="${3:-800}"
|
||||
write_state "$X" "$Y" stop "$(python3 -c "print(float($MS)/1000)")"
|
||||
echo "[rc] 摇杆 x=$X y=$Y 持续 ${MS}ms" ;;
|
||||
jump)
|
||||
MS="${1:-300}"
|
||||
write_state 0 0 jump "$(python3 -c "print(float($MS)/1000)")"
|
||||
echo "[rc] 跳 ${MS}ms" ;;
|
||||
stop)
|
||||
write_state 0 0 stop 0
|
||||
echo "[rc] 已归零" ;;
|
||||
hold)
|
||||
MS="${1:-5000}"
|
||||
write_state 0 0 stop "$(python3 -c "print(float($MS)/1000)")"
|
||||
echo "[rc] 保持零帧 ${MS}ms" ;;
|
||||
status)
|
||||
echo "[rc] 守护进程 PID: $(cat "$PIDF")"
|
||||
echo "[rc] 状态文件: $STATE"
|
||||
cat "$STATE" 2>/dev/null || echo "(无)"
|
||||
echo
|
||||
echo "[rc] 最近日志:"; tail -6 "$D/daemon.log" 2>/dev/null ;;
|
||||
*)
|
||||
sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
|
||||
esac
|
||||
+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);
|
||||
}
|
||||
})();
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
#!/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()
|
||||
Executable
+191
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
常驻长连接驱动:保持单条 WebSocket 不断开,持续推零帧,按需注入动作。
|
||||
|
||||
设计要点(针对 Arduino WebSocketsServer + 短连接丢尾帧的问题):
|
||||
* 全程只开一条连接,运行期间绝不关闭 → 避免"断开瞬间丢归零帧 → 速度残留"
|
||||
* 空闲时每 120ms 推一帧全零(dir=stop, joy=0,0),与网页空闲行为一致
|
||||
* 动作靠写状态文件注入,带硬性到期时间,到期自动归零
|
||||
* stable 恒为 1(主动保持平衡),本进程拒绝任何关闭请求
|
||||
* height 恒为 38,roll 恒为 0
|
||||
|
||||
状态文件(默认 ./daemon-state.json):
|
||||
{"joy_x":0, "joy_y":0, "dir":"stop", "until": <epoch秒>}
|
||||
控制脚本只写这个文件;本进程读取并执行。
|
||||
|
||||
用法:
|
||||
python3 robot-daemon.py [--host H] [--port P] [--state FILE] [--interval 0.12]
|
||||
# 后台运行建议 nohup + setsid
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from importlib.machinery import SourceFileLoader
|
||||
|
||||
_ctl = SourceFileLoader("rctl", os.path.join(os.path.dirname(os.path.abspath(__file__)), "robot-ctl.py")).load_module()
|
||||
|
||||
HEIGHT = 38
|
||||
ROLL = 0
|
||||
STABLE = 1
|
||||
|
||||
ZERO = {"joy_x": 0, "joy_y": 0, "dir": "stop"}
|
||||
|
||||
|
||||
def log(*a):
|
||||
print(time.strftime("[%H:%M:%S]"), *a, flush=True)
|
||||
|
||||
|
||||
def frame_of(joy_x, joy_y, direction):
|
||||
return {
|
||||
"dir": direction,
|
||||
"mode": "basic",
|
||||
"roll": ROLL,
|
||||
"height": HEIGHT,
|
||||
"linear": 0,
|
||||
"angular": 0,
|
||||
"stable": STABLE,
|
||||
"joy_x": joy_x,
|
||||
"joy_y": joy_y,
|
||||
}
|
||||
|
||||
|
||||
def read_state(path):
|
||||
"""读状态文件;不存在或损坏则返回零状态"""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
jx = int(d.get("joy_x", 0) or 0)
|
||||
jy = int(d.get("joy_y", 0) or 0)
|
||||
dr = str(d.get("dir", "stop") or "stop")
|
||||
until = float(d.get("until", 0) or 0)
|
||||
jx = max(-100, min(100, jx))
|
||||
jy = max(-100, min(100, jy))
|
||||
if dr not in ("stop", "jump"):
|
||||
dr = "stop"
|
||||
return {"joy_x": jx, "joy_y": jy, "dir": dr, "until": until}
|
||||
except Exception:
|
||||
return {"joy_x": 0, "joy_y": 0, "dir": "stop", "until": 0}
|
||||
|
||||
|
||||
class Daemon:
|
||||
def __init__(self, host, port, state_path, interval):
|
||||
self.host, self.port = host, port
|
||||
self.state_path = state_path
|
||||
self.interval = interval
|
||||
self.ws = None
|
||||
self.running = True
|
||||
self.last_key = None
|
||||
self.sent = 0
|
||||
self.connects = 0
|
||||
|
||||
def connect(self):
|
||||
while self.running:
|
||||
try:
|
||||
self.ws = _ctl.WS(self.host, self.port, timeout=8, verbose=False)
|
||||
self.ws.connect()
|
||||
self.connects += 1
|
||||
log(f"已连接 {self.host}:{self.port}(第 {self.connects} 次)")
|
||||
return True
|
||||
except Exception as e:
|
||||
log(f"连接失败: {e},2 秒后重试")
|
||||
time.sleep(2)
|
||||
return False
|
||||
|
||||
def run(self):
|
||||
if not self.connect():
|
||||
return 1
|
||||
log(f"常驻开始 | 间隔 {int(self.interval*1000)}ms | stable={STABLE} height={HEIGHT}")
|
||||
last_report = time.time()
|
||||
try:
|
||||
while self.running:
|
||||
st = read_state(self.state_path)
|
||||
now = time.time()
|
||||
|
||||
# 到期 → 归零
|
||||
if st["until"] and now >= st["until"]:
|
||||
st = {"joy_x": 0, "joy_y": 0, "dir": "stop", "until": 0}
|
||||
|
||||
f = frame_of(st["joy_x"], st["joy_y"], st["dir"])
|
||||
payload = json.dumps(f, separators=(",", ":"))
|
||||
|
||||
try:
|
||||
self.ws.send_text(payload)
|
||||
self.sent += 1
|
||||
except Exception as e:
|
||||
log(f"发送失败: {e} → 重连")
|
||||
try:
|
||||
self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
if not self.connect():
|
||||
break
|
||||
continue
|
||||
|
||||
# 状态变化时打日志
|
||||
key = (st["joy_x"], st["joy_y"], st["dir"])
|
||||
if key != self.last_key:
|
||||
log(f"状态 → joy_x={st['joy_x']} joy_y={st['joy_y']} dir={st['dir']}"
|
||||
+ (f" 剩余 {st['until']-now:.1f}s" if st["until"] else ""))
|
||||
self.last_key = key
|
||||
|
||||
if time.time() - last_report >= 30:
|
||||
log(f"心跳 | 已发 {self.sent} 帧 | 状态 {key}")
|
||||
last_report = time.time()
|
||||
|
||||
time.sleep(self.interval)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
self.shutdown()
|
||||
return 0
|
||||
|
||||
def shutdown(self):
|
||||
"""退出前归零:多推几帧再关,尽量不让设备残留速度"""
|
||||
log("开始收尾:推零帧")
|
||||
try:
|
||||
zero = json.dumps(frame_of(0, 0, "stop"), separators=(",", ":"))
|
||||
for _ in range(12):
|
||||
try:
|
||||
self.ws.send_text(zero)
|
||||
except Exception:
|
||||
break
|
||||
time.sleep(0.12)
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
log(f"收尾异常: {e}")
|
||||
try:
|
||||
if self.ws:
|
||||
self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
log("已断开")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", default="203.0.113.10")
|
||||
ap.add_argument("--port", type=int, default=4782)
|
||||
ap.add_argument("--state", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "daemon-state.json"))
|
||||
ap.add_argument("--interval", type=float, default=0.12)
|
||||
opts = ap.parse_args()
|
||||
|
||||
d = Daemon(opts.host, opts.port, opts.state, opts.interval)
|
||||
|
||||
def on_sig(signum, _frame):
|
||||
d.running = False
|
||||
|
||||
signal.signal(signal.SIGTERM, on_sig)
|
||||
signal.signal(signal.SIGINT, on_sig)
|
||||
sys.exit(d.run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
// 用 ws 库实现的假机器人,仅用于本地验证 robot-ctl.js 的帧格式
|
||||
const { WebSocketServer } = require('/root/.local/share/pnpm/global/5/.pnpm/ws@8.21.3/node_modules/ws');
|
||||
const port = Number(process.argv[2] || 8905);
|
||||
const wss = new WebSocketServer({ port, host: '127.0.0.1' });
|
||||
let n = 0;
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('CONNECTED');
|
||||
ws.on('message', (buf) => {
|
||||
n++;
|
||||
const txt = buf.toString();
|
||||
console.log('FRAME#' + n, txt);
|
||||
ws.send(JSON.stringify({ ok: true, n, echo: JSON.parse(txt) }));
|
||||
});
|
||||
});
|
||||
console.log('fake robot ws on', port);
|
||||
Reference in new issue
Block a user