init pro_sdk

This commit is contained in:
cyy_mac
2026-06-20 20:02:36 +08:00
commit 5ecd422a59
49 changed files with 4226 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
dist/
*.egg-info/
.installed.cfg
*.egg
# IDE
.vscode/
.idea/
*.swp
.DS_Store
# Test/coverage
.pytest_cache/
.coverage
htmlcov/
# Local data: 默认排除, 但 data/captures/ 是我们历史抓包数据 (显式入库)
data/*
!data/captures/
# Other captures (不入库)
captures/
*.local.pcap

24
LICENSE Normal file
View File

@@ -0,0 +1,24 @@
MIT License
Copyright (c) 2026 chenyouyuan
Based on free-dog-sdk (https://github.com/Bin4ry/free-dog-sdk)
Copyright (c) 2024 Bin4ry (Andreas Makris)
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.

129
README.md Normal file
View File

@@ -0,0 +1,129 @@
# Go1 PRO SDK
Unitree Go1 **PRO** 机型的低层电机控制 Python SDK。完整逆向 PRO 版的私有协议Blowfish 加密 + 私有 LowCmd 格式),不依赖官方 C++ SDKMac 直连可达 480Hz 控制频率。
> 这是为 PRO 准备的 SDK。如果你有 **EDU** 版机器狗,请用原版 [free-dog-sdk](https://github.com/Bin4ry/free-dog-sdk) — PRO 跟 EDU 协议有显著差异,互不兼容。
## 关键特性
- 完整 Blowfish ECB 加解密(用从机器狗内存提取的 state不依赖原始 key
- PRO 私有 LowCmd 格式616B / CRC@612 / bandWidth BE 字节序),跟 EDU 不同
- LowState 解析12 关节 + IMU + BMS + 足端力 + 遥控器)
- 实时遥控器按键/摇杆/L2 读取
- 三层安全保护PositionLimit / PowerProtect 1-10 / PositionProtect实测扭矩过载自动停机
- 实测频率: **Mac 直连 480Hz**(接近原版 EDU 500Hz
- 实测延迟: **每帧 1.74ms p50**Blowfish 加密 1ms + 解密 0.7ms + sendto 0.02ms
## 快速上手
```python
from go1_pro_sdk import MCUClient, LowCmd, MotorCmd, MotorMode
with MCUClient() as client:
client.wake_mcu() # 唤醒并切换为我们的客户端
state = client.recv_state() # 读一帧状态
print(f"FR_0 q = {state.motorState[0].q}")
print(f"电量: {state.bms.SOC}%")
print(f"遥控器按下: {state.remote.pressed}")
# 控制 FR 大腿 (注意先悬空 + 安全保护层)
cmd = LowCmd()
cmd.set_motor('FR_1', MotorCmd(
mode=MotorMode.Servo, q=1.2, Kp=5, Kd=1
))
client.send(cmd)
client.safe_stop() # 退出前发 damping
```
## 准备工作
### 1. 提取 Blowfish 密钥(仅需一次)
`go1_pro_sdk/_data/blowfish_state.bin` 已包含我们项目用的密钥。如果你的狗用的是不同的 key很少见除非固件升级需要重新提取
```bash
# SSH 到狗 (192.168.123.161)
scp tools/extract_blowfish_key.sh pi@192.168.123.161:/tmp/
ssh pi@192.168.123.161 "sudo bash /tmp/extract_blowfish_key.sh"
scp pi@192.168.123.161:/tmp/blowfish_dump.tar.gz ./data/
# Mac 端分析
python tools/analyze_blowfish_dump.py data/blowfish_dump.tar.gz
# → 输出 go1_pro_sdk/_data/blowfish_state.bin
```
### 2. 停掉狗上的抢占源
PRO 的 MCU 一次只接受一个客户端的命令。要让 SDK 工作,必须先停掉狗上的:
```bash
ssh pi@192.168.123.161 "sudo pkill -9 -f keep_sport_alive; \
sudo pkill -9 -f Legged_sport; \
sudo pkill -9 -f appTransit"
```
或用我们提供的:
```bash
bash tools/stop_sportmode.sh stop
```
### 3. 跑示例
```bash
# 只读监听 LowState
python examples/monitor_state.py --duration 30 --verbose
# 监听遥控器
python examples/monitor_remote.py
# 单腿 sin 摆动 (狗悬空, 振幅 0.3 rad)
python examples/example_sin_leg.py --amplitude 0.3 --freq 0.5
# 复刻原版 example_position(lowlevel).py
python examples/example_position.py
```
### 4. 测完恢复
```bash
bash tools/stop_sportmode.sh start
```
## ⚠️ 安全
直接控制 12 个电机是**危险操作**
- 第一次测试 **狗必须悬空** (脚架/吊带)
-`apply_safety(cmd, state, power_factor=1)` 限制力矩到 10%
- 准备 **拔电池** 作为终极停机 (Legged_sport 被杀后,遥控器 L2+B 不会工作)
-`docs/SAFETY.md`
## 包结构
```
go1_pro_sdk/
├── connection/ # MCUClient: 高层 UDP 客户端
├── codec/ # Blowfish + LowCmd 序列化 + LowState 解析
├── types/ # MotorCmd, LowState, IMU, BMS, RemoteState 等
├── safety/ # PositionLimit / PowerProtect / PositionProtect
├── utils/ # CRC, 浮点编解码, 关节常量
└── _data/ # blowfish_state.bin
```
## 文档
- `docs/PROTOCOL.md` — PRO LowCmd/LowState 字节级格式规格
- `docs/SAFETY.md` — 安全测试清单
- `docs/REVERSE_ENGINEERING.md` — 逆向工程过程精华
- `docs/ARCHITECTURE.md` — 包结构与数据流
## 致谢
- 原版 [free-dog-sdk](https://github.com/Bin4ry/free-dog-sdk) by Bin4ry (Andreas Makris) — 提供了 EDU 版的基础数据结构和 CRC 实现
- Unitree 官方 [unitree_legged_sdk](https://github.com/unitreerobotics/unitree_legged_sdk) — 公开了 safety.h 的接口定义
## License
MIT (跟 free-dog-sdk 一致)

142
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,142 @@
# 包结构与数据流
## 模块依赖图
```
┌──────────────────────┐
│ MCUClient │ ← 用户主要 API
│ (connection/) │
└──────┬───────────────┘
┌──────────────┼──────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ codec/ │ │ types/ │ │ safety/ │
│ Blowfish │ │ LowCmd │ │ apply_ │
│ build_* │ │ LowState │ │ safety │
│ parse_* │ │ MotorCmd │ │ │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└────────────────┼────────────────┘
┌─────────────┐
│ utils/ │
│ common.py │ ← CRC, float_hex
│ constants │ ← 关节限位, 默认地址
└─────────────┘
```
## 数据流: 收一帧 → 用户处理 → 发命令
```
UDP 858B 密文 UDP 616B 密文
▲ │
│ ▼
MCU :8007 MCU :8007
│ ▲
▼ │
┌─────────────────────────────────────────────────┐
│ MCUClient.recv_latest() │
│ socket.recvfrom() │
│ bf.decrypt_ecb(data[:856]) → 856B 明文 │
│ parse_low_state(...) → LowState 结构 │
└─────────────────────────────────────────────────┘
┌──────────┐ 用户逻辑 ┌──────────┐
│ LowState │ ──────────► │ LowCmd │
└──────────┘ qDes, Kp, └──────────┘
Kd, tau... │
┌─────────────────────────────────────────────────┐
│ apply_safety(cmd, state, power_factor=1) │
│ position_limit(cmd) │
│ power_protect(cmd, state, factor) │
│ position_protect(cmd, state, limit_rad) │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ MCUClient.send(cmd) │
│ build_low_cmd_plain(cmd) → 616B 明文 │
│ bf.encrypt_ecb(plain) → 616B 密文 │
│ sock.sendto(cipher, ...) │
└─────────────────────────────────────────────────┘
```
## 各包职责
### utils/
最底层。纯函数, 无副作用, 无依赖其他模块。
- `common.py`: CRC, float↔hex, tau↔2B, Kp↔2B, Kd↔2B, decode_sn/version
- `constants.py`: MCU 地址, 关节命名, 限位, TAU_MAX, DAMPING_POSE
### types/
数据结构 (dataclass)。依赖 utils, 不依赖 codec/safety/connection。
- `motor.py`: MotorCmd, MotorState, MotorMode
- `imu.py`: IMU
- `bms.py`: BMS
- `remote.py`: RemoteState, parse_remote
- `low_cmd.py`: LowCmd (12 motorCmd + 元数据)
- `low_state.py`: LowState (12 motorState + IMU + BMS + remote 等)
### codec/
加解密和序列化。依赖 types + utils。
- `blowfish.py`: 标准 Blowfish ECB (LE 字节序), 接受预生成 state
- `lowcmd_builder.py`: LowCmd → 616B 明文 → 加密后 616B
- `lowstate_parser.py`: 解密后 807B → LowState 结构
### safety/
控制保护层。依赖 types + utils。
- `safety.py`: PositionLimit/PowerProtect/PositionProtect 三个保护 + 统一入口 apply_safety
### connection/
UDP 高层抽象。依赖前面所有。
- `mcu_client.py`: MCUClient (socket + Blowfish + 序列化 + safe_stop)
## 用户三种用法
### Level 1: 高层 (推荐)
```python
from go1_pro_sdk import MCUClient, LowCmd, MotorCmd, MotorMode
with MCUClient() as client:
client.wake_mcu()
state = client.recv_state()
cmd = LowCmd()
cmd.set_motor('FR_1', MotorCmd(mode=MotorMode.Servo, q=1.2, Kp=5, Kd=1))
client.send(cmd)
client.safe_stop()
```
### Level 2: 中层 (自己管 socket, 用编解码)
```python
from go1_pro_sdk import Blowfish, build_low_cmd_encrypted, parse_low_state, LowCmd
import socket
bf = Blowfish.from_state_file('go1_pro_sdk/_data/blowfish_state.bin')
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('', 0))
cmd = LowCmd()
sock.sendto(build_low_cmd_encrypted(cmd, bf), ('192.168.123.10', 8007))
data, _ = sock.recvfrom(2048)
state = parse_low_state(bf.decrypt_ecb(data[:856]))
```
### Level 3: 底层 (诊断/逆向)
直接用 `Blowfish.encrypt_block(b8)` / `Blowfish.decrypt_block(b8)`, 自己处理 8B 块。
适合协议研究或字节级 diff。

145
docs/PROTOCOL.md Normal file
View File

@@ -0,0 +1,145 @@
# Go1 PRO 协议规格
完全实测/抓包验证 (2026-06-20)。
## LowCmd (主控 → MCU)
**616 字节 Blowfish ECB 密文** (明文 616B, 加密后还是 616B),通过 UDP 发到 `192.168.123.10:8007`
### 明文 616B 布局
| 偏移 | 字节 | 字段 | 说明 |
|-----|------|------|------|
| 0..2 | 2 | head | `fe ef` |
| 2..3 | 1 | levelFlag | `0xff` |
| 3..4 | 1 | frameReserve | 0 |
| 4..12 | 8 | SN | 全 0 (PRO 不填) |
| 12..20 | 8 | version | 全 0 (PRO 不填) |
| 20..22 | 2 | bandWidth | **`3a c0` (BE 字节序, 不是 LE!)** |
| 22..562 | 540 | motorCmd[20] | 每电机 27B (mode + q + dq + tau + Kp + Kd + reserve) |
| 562..566 | 4 | bms | 0 |
| 566..606 | 40 | wirelessRemote | 0 |
| 606..610 | 4 | reserve | 0 |
| **610..612** | 2 | **固定填充** | **`00 00`** |
| **612..616** | 4 | **CRC** | **`gen_crc(cmd[:612])` 裸 CRC, 不 XOR** |
### MotorCmd 单电机 27B
```
0..1: mode (uint8)
1..5: q (float, Unitree 自定义 hex 编码, 不是标准 IEEE 754)
5..9: dq (同上)
9..11: tau (2B 自定义)
11..13: Kp (2B 自定义)
13..15: Kd (2B 自定义)
15..27: reserve (12B = 3 × 4B)
```
各字段编解码见 `go1_pro_sdk/utils/common.py`
### 跟 EDU 版差异 (free-dog-sdk lowCmd)
| 项 | EDU | PRO |
|----|-----|-----|
| 总长 | 614B | **616B** |
| CRC 偏移 | 610..614 | **612..616** |
| CRC 算法 | `encryptCrc(genCrc(cmd[:-6]))` (XOR 0xedcab9de) | **裸 `gen_crc(cmd[:612])`** |
| bandWidth 字节序 | LE | **BE** |
| SN/version | 用户提供 | 全 0 |
| 是否加密 | 否 | **Blowfish ECB** |
## LowState (MCU → 主控)
**858 字节 Blowfish ECB 密文** (Blowfish 8B 块, 858 取 8 对齐 = 856B 解密)。
解密后 807B 是有效 LowState后面 49B 填充/MCU 内部。
### 明文 807B 布局 (跟 free-dog-sdk 的 ucl/lowState.py 一致)
| 偏移 | 字节 | 字段 |
|-----|------|------|
| 0..2 | 2 | head |
| 2..3 | 1 | levelFlag |
| 3..4 | 1 | frameReserve |
| 4..12 | 8 | SN |
| 12..20 | 8 | version |
| 20..22 | 2 | bandWidth |
| 22..75 | 53 | IMU (quaternion 16B + gyro 12B + accel 12B + rpy 12B + temp 1B) |
| 75..715 | 640 | motorState[20] × 32B |
| 715..739 | 24 | BMS |
| 739..759 | 20 | footForce / footForceEst (混合) |
| 759..799 | 40 | **wirelessRemote** (遥控器原始数据) |
| 799..803 | 4 | reserve |
| 803..807 | 4 | CRC |
## Blowfish
**ECB 模式****LE word 字节序** (Unitree ARM64 LDP 读取顺序,不是标准 BE Blowfish)。
state (4168B) = P[18] + S0[256] + S1[256] + S2[256] + S3[256],每项 4B uint32。
### F 函数
```python
def F(X):
a = (X >> 24) & 0xff
b = (X >> 16) & 0xff
c = (X >> 8) & 0xff
d = X & 0xff
return ((((S0[a] + S1[b]) & 0xFFFFFFFF) ^ S2[c]) + S3[d]) & 0xFFFFFFFF
```
**注意 Python 运算符优先级**: `+``^` 高,必须完整括号否则结果错。
### 16 轮 Feistel
标准 Blowfish 算法 (见 `go1_pro_sdk/codec/blowfish.py`),无定制。
### 已知明密文对 (用于验证 state)
```
encrypt(0000000000000000) = 12aa0354236b66e3
encrypt(feefff0004020305) = 5d9874ad7eee5851
encrypt(0802010000010900) = c5ce0887ceb86f91
```
## 遥控器 wirelessRemote (40B)
布局 (Unitree 公开 `xRockerBtnDataStruct`):
| 偏移 | 字节 | 字段 |
|-----|------|------|
| 0..2 | 2 | head 0x55 0xAA |
| 2..4 | 2 | btn bitmap (uint16 LE) |
| 4..8 | 4 | lx (float, -1..+1) |
| 8..12 | 4 | rx |
| 12..16 | 4 | ry |
| 16..20 | 4 | L2 (0..1, 模拟扳机) |
| 20..24 | 4 | ly |
| 24..40 | 16 | reserved |
### 按键 bitmap
| 位 | 按键 | 位 | 按键 |
|----|------|----|------|
| 0x0001 | R1 | 0x0100 | A |
| 0x0002 | L1 | 0x0200 | B |
| 0x0004 | START | 0x0400 | X |
| 0x0008 | SELECT | 0x0800 | Y |
| 0x0010 | R2 | 0x1000 | UP |
| 0x0020 | L2 | 0x2000 | RIGHT |
| 0x0040 | F1 | 0x4000 | DOWN |
| 0x0080 | F2 | 0x8000 | LEFT |
## MCU 客户端切换机制
MCU 按"最近发包者的 IP:port"切换客户端。也就是说:
- 我们 Mac 不需要绑特定端口,任意 OS 分配的端口都行
- 一旦我们发包MCU 把后续 LowState 回复给我们 (Legged_sport 不再收到)
- Legged_sport 没收到 state 就停止 sendto等价于"挂起"
**含义**: 必须先在 Pi 上杀 `keep_sport_alive.sh``Legged_sport``appTransit` 三个抢占者,否则会跟 MCU 反复切换导致控制失败。

103
docs/REVERSE_ENGINEERING.md Normal file
View File

@@ -0,0 +1,103 @@
# 逆向工程过程精华
完整的探索过程见原项目笔记 `free-dog-sdk/REVERSE_ENGINEERING_NOTES.md` (1700+ 行)。这里是事后整理的精华。
## 起点
Unitree Go1 **PRO** 版机器人, 跑 sportMode 系统:
- 树莓派 4 (192.168.123.161): MQTT broker + Legged_sport 控制循环 + appTransit
- 3 台 Jetson Nano (192.168.123.13/14/15): 相机/AI/SLAM
- MCU (192.168.123.10): 电机控制器 (UDP :8007)
PRO 跟 EDU 的关键区别 (Unitree 故意做的限制):
- MCU 用 Blowfish 加密 LowState 回包 (EDU 无加密)
- MCU 要求 LowCmd 也是 Blowfish 加密 (EDU 接受明文)
- Legged_sport 二进制 UPX 压缩 + 符号剥离, 拿不到密钥
## 走过的弯路 (记录, 避免再踩)
### 弯路 1: 误判加密算法是 TEA
看到 Feistel 结构 + 32 字节常数, 第一反应是 TEA. 花了几天用 Z3 求解 128-bit key, 全部超时.
**纠正**: TEA 的特征是 `lsl #4` + `lsr #5` 在同一函数. Blowfish 是 T-table 查表 + 加法 + 异或, 没有移位混合常数. 反汇编时区分这两点是关键.
### 弯路 2: 试图静态反推密钥
Blowfish 的 key schedule 是:
1. P[i] = P_initial[i] ^ key_word(i % keylen)
2. 用此 P 和初始 S-box 加密 (0,0), 用结果替换 P[0..1]
3. 继续替换 P[2..3] ... 直到 P[16..17]
4. 同样替换 S-box
**纠正**: 步骤 1 的中间值在步骤 2 中**立刻被覆盖**, 从最终 P-array 无法反推 key. 但**有 state 就够了**, 不需要原始 key. 运行时 state = 4168B = P[18] + S0..S3.
### 弯路 3: 误以为 LowCmd 不加密
heap 搜索没找到 `feefff00` 密文 → 错误结论 "LowCmd 是明文". 实际上加密发生在 sendto 缓冲区里, 加密后立刻被消费掉, 没留在 heap.
**纠正**: 用 `strace -e sendto` 抓到 Legged_sport 真实发送的字节, 解密后是 LowCmd 头, 确认必须加密.
### 弯路 4: 误以为 MCU 锁源端口
之前认为 MCU 锁 Legged_sport 的源端口 8008. 改 sin 测试用 :8008 还是不动.
**纠正**: MCU 实际是按"**最近发包者的 IP:port**"切换. 任意端口都行, 关键是要先停掉 Pi 上的抢占者.
### 弯路 5: 没杀干净 Legged_sport
`killall -9 Legged_sport` 杀完, 进程立刻又活. 看 ps 才发现父进程是 `keep_sport_alive.sh` (PID 970), 每 0.2 秒检查并重启 Legged_sport.
**纠正**: 必须**先杀看门狗**, 再杀 Legged_sport. 顺序错了等于没杀.
## 关键突破
### 突破 1: 内存提取 Blowfish state
```bash
# 在 Legged_sport 运行时 attach GDB (只读, 不设断点不写内存)
sudo bash extract_blowfish_key.sh # < 30 秒, 不影响运动控制
# 拉回 Mac, 在 heap dump 里搜索 4168B 候选, 用已知明密文对验证
python analyze_blowfish_dump.py blowfish_dump.tar.gz
# 命中: rw_7f86a1f000.bin offset 0x232190
```
### 突破 2: PRO 格式跟 EDU 不一样
用 tcpdump 抓 Legged_sport 真实发送的 616B 包, 解密后跟 free-dog-sdk `lowCmd.buildCmd()` 输出做字节级 diff:
| 字段 | EDU | PRO |
|------|-----|-----|
| 总长 | 614B | **616B** (多 2B 填充) |
| CRC 偏移 | 610..614 | **612..616** |
| CRC 算法 | `encryptCrc(genCrc(cmd[:608]))` XOR 0xedcab9de | **裸 `gen_crc(cmd[:612])`** |
| bandWidth 字节序 | LE | **BE** |
| SN/version | 填实际值 | 全 0 |
### 突破 3: 内存里的 wirelessRemote 数据
LowState 的 759..799 是 40 字节遥控器数据, 跟 EDU `unitree_legged_sdk/comm.h` 公开的 `xRockerBtnDataStruct` 完全一致. 直接 parse 就能拿到按键 + 摇杆 + L2 模拟值.
## 最终性能 (Mac 直连)
| 指标 | 实测 |
|------|------|
| 控制频率 | **480 Hz** (96% 目标) |
| 单步 p50 | 1.74 ms |
| Blowfish 加密 | 1.02 ms |
| Blowfish 解密 | 0.67 ms |
| sendto | 0.02 ms |
| Blowfish 解码率 | 100% |
## 工具集
| 工具 | 用途 |
|------|------|
| `tools/extract_blowfish_key.sh` | 狗端内存 dump |
| `tools/analyze_blowfish_dump.py` | Mac 端搜索 4168B state |
| `tools/calibrate_joints.py` | 手动转动法测真实关节限位 |
| `tools/capture_lowcmd.sh` | 抓 Legged_sport 真实发送的包 |
| `tools/diff_real_lowcmd.sh` | 抓包 + 字段级 diff |
| `tools/stop_sportmode.sh` | SSH 杀抢占源 |
| `tools/run_on_mac.sh` | Mac 端跑测试 (自动停抢占) |
| `tools/run_on_pi.sh` | Pi 端跑测试 (源 IP 跟 Legged_sport 一致) |

66
docs/SAFETY.md Normal file
View File

@@ -0,0 +1,66 @@
# 安全测试清单
## 上机调试前必读
直接控制 12 个电机是危险操作。第一次上机必须严格按以下清单。
## 物理准备
- [ ] 狗悬挂 (脚架/吊带), 脚不接地
- [ ] 周围 1 米无障碍物
- [ ] 准备 **拔电池**, 因为 Legged_sport 被杀后, 遥控器 L2+B 不会工作
## 软件准备
- [ ] `bash tools/stop_sportmode.sh stop` 杀掉 keep_sport_alive + Legged_sport + appTransit
- [ ] 验证 `pgrep -fl Legged_sport` 没输出
- [ ]`examples/monitor_state.py` 确认能正常解码 LowState
- [ ] 第一次跑必须用 `apply_safety(cmd, state, power_factor=1)` (10% 力矩)
- [ ] sin 振幅 ≤ 0.3 rad, 频率 ≤ 0.5 Hz
## 跑动中的实时监控
每个控制脚本应当包括:
```python
try:
apply_safety(cmd, state,
power_factor=1, # 1=最严
position_limit_on=True, # 关节硬限位
position_protect_limit=0.5, # 目标偏离实际>0.5rad 关 Kp/Kd
raise_on_critical=True) # 严重超载 raise
except PowerProtectViolation as e:
print(f"⚠️ 立即停机: {e}")
client.safe_stop()
break
```
## 异常处理
| 现象 | 立刻做什么 |
|------|----------|
| 脚本输出 ⚠️ PowerProtectViolation | 脚本会自动 safe_stop, 你拔电池保险 |
| 关节抖动 / 嗡嗡声 | Ctrl+C, 脚本自动发 damping |
| 狗腿快速摆动失控 | **立即拔电池** (按住电源 3 秒) |
| MCU 不响应 | 检查 Pi 抢占源是否清干净: `ssh pi@192.168.123.161 pgrep -fl Legged_sport` |
## 紧急停机优先级
1. **遥控器 L2+B****PRO 模式下杀了 Legged_sport 后不工作**, 不要依赖
2. **Ctrl+C** — 触发 `client.safe_stop()` 发 50 帧 damping
3. **拔电池** — 终极停机, 按住电源键 3 秒
## 测试完成后
```bash
bash tools/stop_sportmode.sh start
```
重启 sportMode 系统, 狗恢复正常.
## 不要做
- ❌ 不要在 sportMode 还在跑时同时跑你的控制 (会跟 Legged_sport 抢, 抖动)
- ❌ 不要从 PowerProtectViolation 异常中 silent 恢复 (出问题就是出问题, 不要藏)
- ❌ 不要使用 `power_factor > 5` 除非你已经成功跑过 factor=1
- ❌ 不要在狗站立或行走时直接 kill Legged_sport (狗会软瘫倒地)

61
examples/README.md Normal file
View File

@@ -0,0 +1,61 @@
# Examples
每个示例都假定:
- 你已经从狗上提取了 `blowfish_state.bin` (或用包内置的)
- 已经停掉 Pi 上的 keep_sport_alive + Legged_sport + appTransit
- 狗悬空 (脚架/吊带), 除非明确说不需要
## 只读类 (无风险)
### monitor_state.py — 实时监听状态
```bash
python examples/monitor_state.py --duration 30 --verbose
```
不触发任何电机, 持续发 damping LowCmd 维持 MCU 回包流, 解码后打印 12 关节角 + IMU + 电量.
### monitor_remote.py — 实时监听遥控器
```bash
python examples/monitor_remote.py --duration 60
```
按下按键/拨动摇杆都会实时输出. 包括 L2 模拟值.
## 控制类 (需要狗悬空!)
### example_sin_leg.py — 单腿小幅 sin
```bash
python examples/example_sin_leg.py --amplitude 0.3 --freq 0.5 --duration 10
```
**保守参数**, 适合首次电机控制. sin 中点 = 当前关节角, 不强行移动到指定位置.
### example_position.py — 复刻原版 example_position
```bash
python examples/example_position.py --max-steps 5000
```
跟 free-dog-sdk `example_position(lowlevel).py` **逻辑一模一样**:
- 0..10: 记录 qInit
- 10..400: 插值到 sin_mid_q = [0, 1.2, -2.0]
- 400+: 1Hz sin 摆动 (FR_1 ±0.6, FR_2 ±0.9)
Mac 上实测 480 Hz.
### example_remote_control.py — 摇杆控制 FR 腿
```bash
python examples/example_remote_control.py
```
- 左摇杆 X → 髋外摆
- 左摇杆 Y → 大腿前后
- L2 按下 → 退出
## 安全
所有控制类示例都内置 `apply_safety(cmd, state, power_factor=1)` (10% 力矩限制), 出问题会立即停机. 见 `docs/SAFETY.md`.

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""复刻 free-dog-sdk example_position(lowlevel).py 的 PRO 版本.
跟原版 EDU 例程逻辑一致:
0..10 步: 记录初始关节角 qInit
10..400 步: Kp=5 Kd=1 插值到 sin_mid_q = [0.0, 1.2, -2.0]
400+ 步: 1Hz sin 摆动 (FR_1 += 0.6*sin, FR_2 += -0.9*sin)
差异:
- 用 Go1 PRO SDK (Blowfish 加密 / PRO 格式 / 安全保护)
- 集成 safety 层 (power_factor=1, 位置硬限位)
⚠️ 狗必须悬空 (脚架/吊带). Ctrl+C 紧急停止.
"""
import argparse
import math
import signal
import sys
import time
from go1_pro_sdk import (
MCUClient, LowCmd, MotorCmd, MotorMode,
apply_safety, PowerProtectViolation,
JOINT_NAMES,
)
# 跟原版一致的常量
SIN_MID = {'hip': 0.0, 'thigh': 1.2, 'knee': -2.0}
DT = 0.002
running = True
def sigint(s, f):
global running
running = False
def interp(a, b, t):
t = max(0.0, min(1.0, t))
return a * (1 - t) + b * t
def main():
p = argparse.ArgumentParser()
p.add_argument('--state', default=None)
p.add_argument('--max-steps', type=int, default=5000)
p.add_argument('--freq-hz', type=float, default=1.0)
p.add_argument('--power-factor', type=int, default=1, choices=range(1, 11),
help='PowerProtect 1-10, 越大越宽松')
args = p.parse_args()
signal.signal(signal.SIGINT, sigint)
print('🐕 PRO example_position (Go1 PRO SDK)')
print(f' 控制周期 {DT*1000:.0f}ms ({1/DT:.0f} Hz)')
print(f' sin 中点: {SIN_MID}')
print(f' sin 频率: {args.freq_hz} Hz')
print(f' power_factor: {args.power_factor} (限 {args.power_factor*10}% 力矩)')
print(f' 最大步数: {args.max_steps} (≈{args.max_steps*DT:.1f}s)')
with MCUClient(state_path=args.state) as client:
print(f'\n[Phase 0] 唤醒 MCU...')
recv = client.wake_mcu(50)
if recv == 0:
print('❌ 无回包. 先停 Pi 上的 keep_sport_alive/Legged_sport/appTransit')
return 1
state = client.last_state
print(f' 当前 FR_0={state.motorState[0].q:.4f} '
f'FR_1={state.motorState[1].q:.4f} '
f'FR_2={state.motorState[2].q:.4f}')
# 主循环
qInit = [0.0, 0.0, 0.0]
qDes = [0.0, 0.0, 0.0]
Kp = [0.0, 0.0, 0.0]
Kd = [0.0, 0.0, 0.0]
sin_count = 0
rate_count = 0
motiontime = 0
freq_rad = args.freq_hz * 2 * math.pi
print(f'\n[主循环]')
loop_start_t = time.time()
loop_times = []
last_print = time.time()
try:
while running and motiontime < args.max_steps:
t0 = time.time()
motiontime += 1
state = client.recv_latest() or state
if motiontime < 10:
for i in range(3):
qInit[i] = state.motorState[i].q
if 10 <= motiontime < 400:
rate_count += 1
rate = rate_count / 200.0
Kp = [5, 5, 5]
Kd = [1, 1, 1]
qDes[0] = interp(qInit[0], SIN_MID['hip'], rate)
qDes[1] = interp(qInit[1], SIN_MID['thigh'], rate)
qDes[2] = interp(qInit[2], SIN_MID['knee'], rate)
if motiontime >= 400:
sin_count += 1
t = DT * sin_count
sin_j1 = 0.6 * math.sin(t * freq_rad)
sin_j2 = -0.9 * math.sin(t * freq_rad)
qDes[0] = SIN_MID['hip']
qDes[1] = SIN_MID['thigh'] + sin_j1
qDes[2] = SIN_MID['knee'] + sin_j2
# 构造命令: 只控 FR (索引 0,1,2)
cmd = LowCmd()
for i in range(3):
cmd.motorCmd[i] = MotorCmd(
mode=MotorMode.Servo,
q=qDes[i], dq=0,
tau=-0.65 if i == 0 else 0.0, # FR_0 预紧
Kp=Kp[i], Kd=Kd[i],
)
# 安全保护
try:
apply_safety(cmd, state, power_factor=args.power_factor,
position_limit_on=True,
position_protect_limit=0.5)
except PowerProtectViolation as e:
print(f'\n⚠️ {e}')
break
client.send(cmd)
loop_times.append(time.time() - t0)
now = time.time()
if now - last_print >= 0.5:
phase = 'qInit' if motiontime < 10 else 'ramp' if motiontime < 400 else 'sin '
print(f' t={motiontime*DT:5.2f}s [{phase}] '
f'des=[{qDes[0]:+.3f} {qDes[1]:+.3f} {qDes[2]:+.3f}] '
f'act=[{state.motorState[0].q:+.3f} '
f'{state.motorState[1].q:+.3f} {state.motorState[2].q:+.3f}]')
last_print = now
# 维持 DT
sleep_t = DT - (time.time() - t0)
if sleep_t > 0:
time.sleep(sleep_t)
# 时序统计
if loop_times:
total = time.time() - loop_start_t
avg_ms = sum(loop_times) / len(loop_times) * 1000
print(f'\n实际频率: {len(loop_times)/total:.1f} Hz, '
f'每步均值 {avg_ms:.2f}ms')
finally:
print('\n安全停机...')
client.safe_stop()
print('✅ 退出')
if __name__ == '__main__':
sys.exit(main() or 0)

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""用遥控器摇杆控制 FR 腿 (Demo: 摇杆 X → 髋外摆, 摇杆 Y → 大腿).
按键映射:
左摇杆 X → FR_0 hip (±0.4 rad)
左摇杆 Y → FR_1 thigh (mid ± 0.5 rad)
L2 按下 → 立即 damping 退出 (软急停)
其他腿 → Damping
⚠️ 狗必须悬空 (脚架/吊带)
"""
import argparse
import signal
import sys
import time
from go1_pro_sdk import (
MCUClient, LowCmd, MotorCmd, MotorMode,
apply_safety, PowerProtectViolation,
)
DT = 0.005 # 200 Hz 即可, 摇杆响应慢
running = True
def sigint(s, f):
global running
running = False
def main():
p = argparse.ArgumentParser()
p.add_argument('--state', default=None)
p.add_argument('--duration', type=float, default=60)
p.add_argument('--hip-range', type=float, default=0.4, help='hip 摆幅 (rad)')
p.add_argument('--thigh-range', type=float, default=0.5, help='thigh 摆幅 (rad)')
args = p.parse_args()
signal.signal(signal.SIGINT, sigint)
print('🎮 摇杆遥控 FR 腿')
print(f' 左 X → hip ±{args.hip_range}, 左 Y → thigh ±{args.thigh_range}')
print(f' L2 按下 → 退出')
with MCUClient(state_path=args.state) as client:
if client.wake_mcu(50) == 0:
print('❌ 无回包')
return 1
state = client.last_state
mid = [state.motorState[i].q for i in range(3)]
print(f' 初始角: hip={mid[0]:+.3f} thigh={mid[1]:+.3f} knee={mid[2]:+.3f}')
start = time.time()
try:
while running and time.time() - start < args.duration:
t0 = time.time()
state = client.recv_latest() or state
r = state.remote
# L2 按下 → 退出
if r.is_pressed('L2'):
print('\nL2 → 退出')
break
# 摇杆 → 目标 q
q_hip = mid[0] + r.lx * args.hip_range
q_thigh = mid[1] + r.ly * args.thigh_range
q_knee = mid[2] # 膝盖保持不变
cmd = LowCmd()
for i, q in enumerate([q_hip, q_thigh, q_knee]):
cmd.motorCmd[i] = MotorCmd(
mode=MotorMode.Servo, q=q, dq=0, tau=0, Kp=3, Kd=0.5)
try:
apply_safety(cmd, state, power_factor=1,
position_protect_limit=0.5)
except PowerProtectViolation as e:
print(f'\n⚠️ {e}')
break
client.send(cmd)
# 每秒打印一次
if int(time.time() * 2) != int((time.time() - DT) * 2):
print(f' L=({r.lx:+.2f},{r.ly:+.2f}) '
f'des=({q_hip:+.3f},{q_thigh:+.3f}) '
f'act=({state.motorState[0].q:+.3f},'
f'{state.motorState[1].q:+.3f})')
sleep_t = DT - (time.time() - t0)
if sleep_t > 0:
time.sleep(sleep_t)
finally:
print('\n安全停机...')
client.safe_stop()
print('✅ 退出')
if __name__ == '__main__':
sys.exit(main() or 0)

111
examples/example_sin_leg.py Normal file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""安全单腿正弦测试 (保守参数, 小幅摆动).
跟 example_position.py 的区别:
- 默认振幅小 (±0.3 rad), 频率慢 (0.5 Hz)
- sin 中点保持当前实际关节角, 不强行移到 -2.0
- 适合首次试电机控制
⚠️ 狗悬空, Ctrl+C 紧急停止
"""
import argparse
import math
import signal
import sys
import time
from go1_pro_sdk import (
MCUClient, LowCmd, MotorCmd, MotorMode,
apply_safety, PowerProtectViolation,
)
DT = 0.002
running = True
def sigint(s, f):
global running
running = False
def main():
p = argparse.ArgumentParser()
p.add_argument('--state', default=None)
p.add_argument('--amplitude', type=float, default=0.3, help='sin 振幅 (rad)')
p.add_argument('--freq', type=float, default=0.5, help='sin 频率 (Hz)')
p.add_argument('--duration', type=float, default=10)
p.add_argument('--power-factor', type=int, default=1)
args = p.parse_args()
signal.signal(signal.SIGINT, sigint)
print(f'🦿 单腿 sin 测试 — 振幅 {args.amplitude}, 频率 {args.freq}Hz, '
f'{args.duration}s, power={args.power_factor}')
with MCUClient(state_path=args.state) as client:
if client.wake_mcu(50) == 0:
print('❌ 无回包')
return 1
state = client.last_state
# 用实际值作为 sin 中点
mid = [state.motorState[i].q for i in range(3)]
print(f' sin 中点 (= 当前角): {[f"{x:+.3f}" for x in mid]}')
# Phase 1: 软启动 (Kp/Kd 从 0 慢慢升)
ramp_steps = 500
print(f' 软启动 {ramp_steps*DT:.1f}s...')
try:
n_steps = int(args.duration / DT)
for step in range(n_steps + ramp_steps):
if not running:
break
t0 = time.time()
state = client.recv_latest() or state
if step < ramp_steps:
# 软启动: Kp/Kd 从 0 到目标
ramp = step / ramp_steps
Kp = 3 * ramp
Kd = 0.5 * ramp
qDes = list(mid)
else:
Kp = 3
Kd = 0.5
t = (step - ramp_steps) * DT
sin_v = args.amplitude * math.sin(2 * math.pi * args.freq * t)
qDes = [mid[0], mid[1] + sin_v, mid[2] - sin_v * 1.5]
cmd = LowCmd()
for i in range(3):
cmd.motorCmd[i] = MotorCmd(
mode=MotorMode.Servo,
q=qDes[i], dq=0, tau=0,
Kp=Kp, Kd=Kd,
)
try:
apply_safety(cmd, state, power_factor=args.power_factor,
position_protect_limit=0.5)
except PowerProtectViolation as e:
print(f'⚠️ {e}')
break
client.send(cmd)
if step % 250 == 0 and step > 0:
actual = [state.motorState[i].q for i in range(3)]
print(f' t={step*DT:5.2f}s des={[f"{x:+.3f}" for x in qDes]} '
f'act={[f"{x:+.3f}" for x in actual]}')
sleep_t = DT - (time.time() - t0)
if sleep_t > 0:
time.sleep(sleep_t)
finally:
print('\n安全停机...')
client.safe_stop()
print('✅ 退出')
if __name__ == '__main__':
sys.exit(main() or 0)

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""实时监听遥控器状态 (按键 + 摇杆 + L2).
用法: python examples/monitor_remote.py --duration 60
"""
import argparse
import signal
import sys
import time
from go1_pro_sdk import MCUClient, LowCmd
running = True
def sigint(s, f):
global running
running = False
def main():
p = argparse.ArgumentParser()
p.add_argument('--state', default=None)
p.add_argument('--duration', type=float, default=60)
p.add_argument('--raw', action='store_true')
args = p.parse_args()
signal.signal(signal.SIGINT, sigint)
with MCUClient(state_path=args.state) as client:
recv = client.wake_mcu(50)
if recv == 0:
print('❌ 无回包')
return 1
damping = LowCmd().all_damping()
last_btn = 0
last_print = 0
start = time.time()
print(f'📡 监听遥控器 {args.duration}s, Ctrl+C 退出\n')
while running and time.time() - start < args.duration:
client.send(damping)
time.sleep(0.005)
state = client.recv_latest()
if state is None:
continue
r = state.remote
now = time.time()
# 按键事件
if r.btn != last_btn:
pressed_now = set(r.pressed)
pressed_before = set(n for m, n in __import__('go1_pro_sdk').BUTTON_NAMES if last_btn & m)
just_p = pressed_now - pressed_before
just_r = pressed_before - pressed_now
if just_p:
print(f"[{now-start:6.2f}s] ⬇ {'+'.join(sorted(just_p))}")
if just_r:
print(f"[{now-start:6.2f}s] ⬆ {'+'.join(sorted(just_r))}")
last_btn = r.btn
# 摇杆 (0.2s 一次)
if now - last_print > 0.2:
active = (abs(r.lx) > 0.02 or abs(r.ly) > 0.02 or
abs(r.rx) > 0.02 or abs(r.ry) > 0.02 or r.L2 > 0.02)
if active:
print(f'[{now-start:6.2f}s] L=({r.lx:+.2f},{r.ly:+.2f}) '
f'R=({r.rx:+.2f},{r.ry:+.2f}) L2={r.L2:+.2f}'
+ (f' raw={state.wirelessRemote.hex()}' if args.raw else ''))
last_print = now
print('\n✅ 退出')
if __name__ == '__main__':
sys.exit(main() or 0)

79
examples/monitor_state.py Normal file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""实时监听 LowState (只读, 不控制电机).
用法:
python examples/monitor_state.py --duration 30 --verbose
"""
import argparse
import math
import signal
import sys
import time
from go1_pro_sdk import MCUClient, JOINT_NAMES, decode_sn
running = True
def sigint(s, f):
global running
running = False
def main():
p = argparse.ArgumentParser()
p.add_argument('--state', default=None, help='Blowfish state 文件 (默认用包内置)')
p.add_argument('--duration', type=float, default=30)
p.add_argument('--rate', type=float, default=5, help='打印频率 Hz')
p.add_argument('--verbose', '-v', action='store_true')
args = p.parse_args()
signal.signal(signal.SIGINT, sigint)
print('📡 监听 LowState')
print(f' 时长 {args.duration}s, 打印 {args.rate}Hz')
with MCUClient(state_path=args.state) as client:
recv_count = client.wake_mcu(50)
print(f'唤醒: 收到 {recv_count}/50 帧')
if recv_count == 0:
print('❌ 无回包. 检查 Pi 抢占源或 state 是否正确')
return 1
start = time.time()
last_print = 0
n_decoded = 0
damping = __import__('go1_pro_sdk').LowCmd().all_damping()
print(f"\n{'时间':>6} {'电量':>5} {'FR_0':>8} {'FR_1':>8} {'FR_2':>8} {'rpy[°]':>20}")
print('-' * 70)
while running and time.time() - start < args.duration:
client.send(damping)
time.sleep(0.005)
state = client.recv_latest()
if state is None:
continue
n_decoded += 1
now = time.time()
if now - last_print >= 1.0 / args.rate:
t = now - start
rpy_str = (f"{math.degrees(state.imu.rpy[0]):+5.1f},"
f"{math.degrees(state.imu.rpy[1]):+5.1f},"
f"{math.degrees(state.imu.rpy[2]):+5.1f}")
print(f'{t:6.1f} {state.bms.SOC:4d}% '
f'{state.motorState[0].q:+8.3f} '
f'{state.motorState[1].q:+8.3f} '
f'{state.motorState[2].q:+8.3f} '
f'{rpy_str:>20}')
if args.verbose:
print(f' SN={decode_sn(state.SN)} '
f'acc_z={state.imu.accelerometer[2]:.2f} m/s² '
f'BMS current={state.bms.current_a:.2f}A')
last_print = now
print(f'\n解码 {n_decoded} 帧, 退出.')
if __name__ == '__main__':
sys.exit(main() or 0)

68
go1_pro_sdk/__init__.py Normal file
View File

@@ -0,0 +1,68 @@
"""Go1 PRO Python SDK.
完整的 Unitree Go1 PRO 低层控制 Python 实现, 经实测验证 (Mac 直连 480Hz):
- Blowfish ECB 加解密 (MCU 期待)
- PRO 私有 LowCmd 格式 (616B, CRC@612, bandWidth BE) — 跟 EDU 不同
- LowState 解析 (12 关节 + IMU + BMS + 足端力 + 遥控器)
- 安全保护层 (PositionLimit / PowerProtect / PositionProtect)
快速上手:
from go1_pro_sdk import MCUClient, LowCmd, MotorCmd, MotorMode
with MCUClient() as client:
client.wake_mcu() # 唤醒
state = client.recv_state() # 读一帧状态
print(f"FR_0 q = {state.motorState[0].q}")
cmd = LowCmd()
cmd.set_motor('FR_1', MotorCmd(
mode=MotorMode.Servo, q=1.2, Kp=5, Kd=1
))
client.send(cmd)
client.safe_stop() # 退出前发 damping
"""
from .connection.mcu_client import MCUClient
from .codec.blowfish import Blowfish, verify_state
from .codec.lowcmd_builder import build_low_cmd_plain, build_low_cmd_encrypted
from .codec.lowstate_parser import parse_low_state
from .types import (
MotorCmd, MotorState, MotorMode,
IMU, BMS,
RemoteState, parse_remote, BUTTON_NAMES,
LowState, LowCmd,
)
from .safety import (
apply_safety, position_limit, power_protect, position_protect,
PowerProtectViolation,
)
from .utils import (
MCU_IP, MCU_PORT,
JOINT_NAMES, JOINT_TYPE, JOINT_LIMITS, JOINT_LIMITS_MEASURED,
TAU_MAX, KT, DAMPING_POSE,
decode_sn, decode_version,
)
__version__ = '0.1.0'
__all__ = [
# 高层 API
'MCUClient',
# 数据结构
'MotorCmd', 'MotorState', 'MotorMode',
'IMU', 'BMS',
'RemoteState', 'parse_remote', 'BUTTON_NAMES',
'LowState', 'LowCmd',
# 编解码
'Blowfish', 'verify_state',
'build_low_cmd_plain', 'build_low_cmd_encrypted', 'parse_low_state',
# 安全
'apply_safety', 'position_limit', 'power_protect', 'position_protect',
'PowerProtectViolation',
# 常量
'MCU_IP', 'MCU_PORT',
'JOINT_NAMES', 'JOINT_TYPE', 'JOINT_LIMITS', 'JOINT_LIMITS_MEASURED',
'TAU_MAX', 'KT', 'DAMPING_POSE',
'decode_sn', 'decode_version',
]

View File

@@ -0,0 +1,11 @@
# _data/
## blowfish_state.bin (4168 字节)
从 Unitree Go1 PRO 内 Legged_sport 进程运行时内存提取的 Blowfish state.
布局: P[18] × 4B + S0[256] × 4B + S1[256] × 4B + S2[256] × 4B + S3[256] × 4B = 4168 字节.
**等同于一个对称密钥**, 用同一个 state 既能加密 LowCmd 也能解密 LowState.
提取方法见 `tools/extract_blowfish_key.sh`. 如果你的狗 Blowfish key 不一样 (固件升级后可能),
重新提取一份覆盖.

Binary file not shown.

View File

@@ -0,0 +1,10 @@
"""加解密 + LowCmd/LowState 序列化."""
from .blowfish import Blowfish
from .lowcmd_builder import build_low_cmd_plain, build_low_cmd_encrypted
from .lowstate_parser import parse_low_state
__all__ = [
'Blowfish',
'build_low_cmd_plain', 'build_low_cmd_encrypted',
'parse_low_state',
]

View File

@@ -0,0 +1,132 @@
"""Blowfish ECB 加解密 (使用从狗内存提取的 P-array + S-box).
跟标准 Blowfish 实现的区别:
- 不做 key schedule. 直接接受预先生成好的 4168 字节 state 文件 (P + S0..S3)
- 默认 LE 字节序 (Unitree ARM64 LDP 读取顺序), 也支持 BE 标准模式
- state 文件来源: tools/extract_blowfish_key.sh 从运行中 Legged_sport 内存 dump
完整 state 布局 (4168 B):
P[18] × 4B = 72B
S0[256] × 4B = 1024B
S1[256] × 4B = 1024B
S2[256] × 4B = 1024B
S3[256] × 4B = 1024B
合计 4168 B
已知明密文对 (供验证):
encrypt(0000000000000000) = 12aa0354236b66e3
encrypt(feefff0004020305) = 5d9874ad7eee5851
encrypt(0802010000010900) = c5ce0887ceb86f91
"""
import struct
from typing import Sequence
class Blowfish:
"""自定义 Blowfish: 接受预先初始化好的 P + S-box 直接加解密."""
def __init__(self, P: Sequence[int], S0: Sequence[int], S1: Sequence[int],
S2: Sequence[int], S3: Sequence[int], endian: str = 'little'):
"""
Args:
P: 18 个 uint32
S0..S3: 各 256 个 uint32
endian: 'little' (Unitree LE) 或 'big' (标准 Blowfish BE)
"""
assert len(P) == 18
assert len(S0) == 256 and len(S1) == 256 and len(S2) == 256 and len(S3) == 256
self.P = list(P)
self.S = [list(S0), list(S1), list(S2), list(S3)]
self.endian = endian
self._pack = '<II' if endian == 'little' else '>II'
@classmethod
def from_state_buffer(cls, buf: bytes, endian: str = 'little') -> 'Blowfish':
"""从 4168 字节内存 dump 构造."""
assert len(buf) >= 4168, f'state 至少 4168 字节, got {len(buf)}'
words = struct.unpack('<1042I', buf[:4168])
return cls(
words[0:18],
words[18:274],
words[274:530],
words[530:786],
words[786:1042],
endian=endian,
)
@classmethod
def from_state_file(cls, path: str, endian: str = 'little') -> 'Blowfish':
"""从文件加载 state."""
with open(path, 'rb') as f:
buf = f.read()
return cls.from_state_buffer(buf, endian=endian)
def _F(self, X: int) -> int:
"""F-function: ((S0[a]+S1[b]) ^ S2[c]) + S3[d].
注意 Python 运算符优先级: + 高于 ^, 必须完整括号!
"""
a = (X >> 24) & 0xff
b = (X >> 16) & 0xff
c = (X >> 8) & 0xff
d = X & 0xff
return ((((self.S[0][a] + self.S[1][b]) & 0xFFFFFFFF)
^ self.S[2][c]) + self.S[3][d]) & 0xFFFFFFFF
def encrypt_block(self, plaintext: bytes) -> bytes:
"""单个 8B 块加密."""
assert len(plaintext) == 8
L, R = struct.unpack(self._pack, plaintext)
for i in range(16):
L ^= self.P[i]
R ^= self._F(L)
L, R = R, L
L, R = R, L
R ^= self.P[16]
L ^= self.P[17]
return struct.pack(self._pack, L, R)
def decrypt_block(self, ciphertext: bytes) -> bytes:
"""单个 8B 块解密."""
assert len(ciphertext) == 8
L, R = struct.unpack(self._pack, ciphertext)
for i in range(17, 1, -1):
L ^= self.P[i]
R ^= self._F(L)
L, R = R, L
L, R = R, L
R ^= self.P[1]
L ^= self.P[0]
return struct.pack(self._pack, L, R)
def encrypt_ecb(self, data: bytes) -> bytes:
"""ECB 加密. 长度必须 8 的倍数."""
assert len(data) % 8 == 0
out = bytearray()
for i in range(0, len(data), 8):
out.extend(self.encrypt_block(data[i:i+8]))
return bytes(out)
def decrypt_ecb(self, data: bytes) -> bytes:
"""ECB 解密. 长度必须 8 的倍数."""
assert len(data) % 8 == 0
out = bytearray()
for i in range(0, len(data), 8):
out.extend(self.decrypt_block(data[i:i+8]))
return bytes(out)
# 已知明密文对, 用于验证 state 是否正确
KNOWN_PAIRS = [
(bytes.fromhex('0000000000000000'), bytes.fromhex('12aa0354236b66e3')),
(bytes.fromhex('feefff0004020305'), bytes.fromhex('5d9874ad7eee5851')),
(bytes.fromhex('0802010000010900'), bytes.fromhex('c5ce0887ceb86f91')),
]
def verify_state(bf: Blowfish) -> bool:
"""用已知明密文对验证 state 是否正确. 返回是否 3/3 通过."""
for pt, ct_expected in KNOWN_PAIRS:
if bf.encrypt_block(pt) != ct_expected:
return False
return True

View File

@@ -0,0 +1,82 @@
"""LowCmd 序列化 (PRO 格式 616B + Blowfish 加密).
PRO 格式跟 free-dog-sdk EDU 版的关键差异:
- 总长 616B (EDU 614B)
- CRC 偏移 612..616 (EDU 在 610..614)
- CRC 前有 2 字节 0x0000 固定填充 (位置 610..612)
- CRC 算法用裸 gen_crc, 不 XOR (EDU 用 encryptCrc XOR 0xedcab9de)
- bandWidth 字节序是 BE (EDU 用 LE)
- SN/version 全 0 (EDU 填实际值)
发送前必须 Blowfish 加密 (MCU 期待加密数据).
布局表 (616 字节明文):
offset bytes 字段
0..2 2 head 0xfeef
2..3 1 levelFlag 0xff
3..4 1 frameReserve
4..12 8 SN (全 0)
12..20 8 version (全 0)
20..22 2 bandWidth 0x3ac0 BE
22..562 540 motorCmd[20] × 27B
562..566 4 bms (全 0)
566..606 40 wirelessRemote (全 0)
606..610 4 reserve (全 0)
610..612 2 固定填充 0x0000
612..616 4 CRC = gen_crc(cmd[:612])
"""
import struct
from typing import Optional
from ..types.low_cmd import LowCmd
from ..types.motor import MotorCmd
from ..utils.common import gen_crc
from .blowfish import Blowfish
def build_low_cmd_plain(lowcmd: LowCmd) -> bytes:
"""构造 PRO 格式 616B 明文 LowCmd."""
cmd = bytearray(616)
# 0..2 head
cmd[0:2] = lowcmd.head
# 2..3 levelFlag
cmd[2] = lowcmd.levelFlag
# 3..4 frameReserve
cmd[3] = lowcmd.frameReserve
# 4..12 SN
cmd[4:12] = lowcmd.SN
# 12..20 version
cmd[12:20] = lowcmd.version
# 20..22 bandWidth BE
cmd[20:22] = struct.pack('>H', lowcmd.bandWidth)
# 22..562 motorCmd[20] × 27B
motor_bytes = b''.join(m.to_bytes() for m in lowcmd.motorCmd)
assert len(motor_bytes) == 540, f'motorCmd 序列化长度错: {len(motor_bytes)}'
cmd[22:562] = motor_bytes
# 562..566 bms (默认 0)
# 566..606 wirelessRemote
cmd[566:606] = lowcmd.wirelessRemote
# 606..610 reserve
cmd[606:610] = lowcmd.reserve
# 610..612 固定填充 0x0000
# 612..616 CRC
cmd[612:616] = gen_crc(bytes(cmd[:612]))
return bytes(cmd)
def build_low_cmd_encrypted(lowcmd: LowCmd, bf: Blowfish) -> bytes:
"""构造并加密 LowCmd (PRO 真实发包格式).
Returns:
616 字节 Blowfish ECB 密文, 直接 sendto MCU :8007.
"""
plain = build_low_cmd_plain(lowcmd)
return bf.encrypt_ecb(plain)
def make_damping_cmd() -> LowCmd:
"""快捷: 全 damping 的 LowCmd (常用于初始化/紧急停)."""
cmd = LowCmd()
cmd.all_damping()
return cmd

View File

@@ -0,0 +1,69 @@
"""LowState 解析 (Blowfish 解密后的 807B 明文 → 结构化 LowState).
LowState 布局 (807B, 跟 free-dog-sdk ucl/lowState.py 一致, 实测可用):
0..2 head (uint16 LE)
2..3 levelFlag
3..4 frameReserve
4..12 SN
12..20 version
20..22 bandWidth
22..75 IMU (53B)
75..715 motorState[20] × 32B = 640B
715..739 BMS (24B)
739..755 footForce + footForceEst (混合区间, 参考原版)
755..759 mode
759..799 wirelessRemote (40B) ← 遥控器数据
799..803 reserve
803..807 crc
"""
from ..types.low_state import LowState
from ..types.motor import MotorState
from ..types.imu import IMU
from ..types.bms import BMS
def parse_low_state(decrypted: bytes) -> LowState:
"""解析 Blowfish 解密后的 LowState (至少 807B)."""
assert len(decrypted) >= 807, f'LowState 至少 807 字节, got {len(decrypted)}'
data = decrypted
ls = LowState()
ls.head = int.from_bytes(data[0:2], 'little')
ls.levelFlag = data[2]
ls.frameReserve = data[3]
ls.SN = bytes(data[4:12])
ls.version = bytes(data[12:20])
ls.bandWidth = int.from_bytes(data[20:22], 'little')
# IMU (22..75)
ls.imu = IMU.from_bytes(data[22:75])
# motorState[20] × 32B (75..715)
ls.motorState = [
MotorState.from_bytes(data[75 + i*32 : 75 + (i+1)*32])
for i in range(20)
]
# BMS (715..739)
ls.bms = BMS.from_bytes(data[715:739])
# 足端力 / 估计力 (位置参考 free-dog-sdk 原版)
ls.footForce = (
int.from_bytes(data[739:741], 'little'),
int.from_bytes(data[751:753], 'little'),
int.from_bytes(data[753:755], 'little'),
int.from_bytes(data[755:757], 'little'),
)
ls.footForceEst = (
int.from_bytes(data[747:749], 'little'),
int.from_bytes(data[759:761], 'little'),
int.from_bytes(data[761:763], 'little'),
int.from_bytes(data[763:765], 'little'),
)
# wirelessRemote (759..799, 40B)
ls.wirelessRemote = bytes(data[759:799])
ls.reserve = bytes(data[799:803])
ls.crc = bytes(data[803:807])
return ls

View File

@@ -0,0 +1,4 @@
"""高层 UDP 客户端 (隐藏 socket/Blowfish 细节)."""
from .mcu_client import MCUClient
__all__ = ['MCUClient']

View File

@@ -0,0 +1,145 @@
"""高层 MCU 客户端: 一个对象封装 socket + Blowfish + LowCmd 序列化 + LowState 解析."""
import os
import socket
import time
from typing import Optional
from ..codec.blowfish import Blowfish
from ..codec.lowcmd_builder import build_low_cmd_encrypted
from ..codec.lowstate_parser import parse_low_state
from ..types.low_cmd import LowCmd
from ..types.low_state import LowState
from ..utils.constants import MCU_IP, MCU_PORT, RCVBUF_SIZE
def _default_state_path() -> str:
"""查找 _data/blowfish_state.bin 的默认路径."""
here = os.path.dirname(os.path.abspath(__file__))
return os.path.join(here, '..', '_data', 'blowfish_state.bin')
class MCUClient:
"""跟 Go1 PRO MCU 的高层 UDP 客户端.
用法:
with MCUClient() as client:
state = client.recv_state() # 阻塞收一帧
cmd = LowCmd()
cmd.set_motor('FR_1', MotorCmd(mode=MotorMode.Servo, q=1.2, Kp=5, Kd=1))
client.send(cmd)
"""
def __init__(self,
state_path: Optional[str] = None,
mcu_ip: str = MCU_IP,
mcu_port: int = MCU_PORT,
local_port: int = 0, # 0 = OS 分配
endian: str = 'little'):
self.mcu_ip = mcu_ip
self.mcu_port = mcu_port
self.bf = Blowfish.from_state_file(state_path or _default_state_path(), endian=endian)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, RCVBUF_SIZE)
sock.bind(('', local_port))
self.sock = sock
self.local_port = sock.getsockname()[1]
self._last_state: Optional[LowState] = None
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
def close(self):
if self.sock:
self.sock.close()
self.sock = None
# ===== 发送 =====
def send(self, cmd: LowCmd) -> int:
"""加密并发送 LowCmd. 返回发送字节数 (固定 616)."""
cipher = build_low_cmd_encrypted(cmd, self.bf)
self.sock.sendto(cipher, (self.mcu_ip, self.mcu_port))
return len(cipher)
def send_raw(self, raw_cipher: bytes) -> int:
"""发送已加密的字节 (高级用途, 自己构造 cipher)."""
self.sock.sendto(raw_cipher, (self.mcu_ip, self.mcu_port))
return len(raw_cipher)
# ===== 接收 =====
def recv_latest(self) -> Optional[LowState]:
"""非阻塞收所有积压的包, 只解析最新一帧.
Returns:
LowState 或 None (没有新包).
"""
last_data = None
while True:
try:
data, _ = self.sock.recvfrom(2048)
last_data = data
except (BlockingIOError, socket.timeout):
break
if last_data is None:
return None
aligned = (len(last_data) // 8) * 8
decrypted = self.bf.decrypt_ecb(last_data[:aligned])
if decrypted[:4] != bytes.fromhex('feefff00') or len(decrypted) < 807:
return None
self._last_state = parse_low_state(decrypted)
return self._last_state
def recv_state(self, timeout: float = 1.0) -> Optional[LowState]:
"""阻塞等待最多 timeout 秒, 直到收到至少一帧 LowState.
Returns:
LowState 或 None (超时).
"""
deadline = time.time() + timeout
while time.time() < deadline:
state = self.recv_latest()
if state is not None:
return state
time.sleep(0.001)
return None
@property
def last_state(self) -> Optional[LowState]:
"""最近一次 recv_latest/recv_state 拿到的状态."""
return self._last_state
# ===== 便捷工具 =====
def wake_mcu(self, n_frames: int = 50, dt: float = 0.01) -> int:
"""发 N 帧 damping 唤醒 MCU 并切到我们这个客户端.
Returns:
实际收到的有效 LowState 帧数.
"""
damping = LowCmd().all_damping()
recv_count = 0
for _ in range(n_frames):
self.send(damping)
time.sleep(dt)
if self.recv_latest() is not None:
recv_count += 1
return recv_count
def safe_stop(self, n_frames: int = 50, dt: float = 0.002):
"""发送 N 帧 damping 退出 (紧急停机/退出前调用)."""
damping = LowCmd().all_damping()
for _ in range(n_frames):
try:
self.send(damping)
except Exception:
pass
time.sleep(dt)

View File

@@ -0,0 +1,16 @@
"""安全保护层 (复刻 Unitree C SDK Safety 类)."""
from .safety import (
apply_safety,
position_limit,
power_protect,
position_protect,
PowerProtectViolation,
)
__all__ = [
'apply_safety',
'position_limit',
'power_protect',
'position_protect',
'PowerProtectViolation',
]

View File

@@ -0,0 +1,115 @@
"""Safety 层: PositionLimit / PowerProtect / PositionProtect.
跟 Unitree C SDK `safety.h` 三个保护接口语义对齐:
- PositionLimit: 关节角硬限位
- PowerProtect: factor 1-10 限制力矩 ((factor/10) * TAU_MAX), 严重超限 raise
- PositionProtect: 目标位置偏离实测过大就把 Kp/Kd 归零
"""
from typing import Optional, TYPE_CHECKING
from ..utils.constants import (
JOINT_LIMITS, JOINT_NAMES, JOINT_TYPE, TAU_MAX, CRITICAL_FACTOR,
)
if TYPE_CHECKING:
from ..types import LowCmd, LowState
class PowerProtectViolation(Exception):
"""严重过载, 应该立即停机 (类比 Unitree C SDK PowerProtect 返回负数 + exit -1)."""
pass
def position_limit(lowcmd: 'LowCmd') -> int:
"""关节硬限位 (in-place clamp). 返回被 clamp 的数量."""
clamped = 0
for i in range(12):
jt = JOINT_TYPE[i]
lo, hi = JOINT_LIMITS[jt]
m = lowcmd.motorCmd[i]
if m.q < lo:
m.q = lo; clamped += 1
elif m.q > hi:
m.q = hi; clamped += 1
return clamped
def power_protect(lowcmd: 'LowCmd', lstate: 'LowState', factor: int) -> int:
"""力矩限制.
Args:
factor: 1-10, 越大越宽松. 实际 tau 限 = TAU_MAX * factor / 10
Returns:
clamp 的数量
Raises:
PowerProtectViolation: 命令离谱 (> CRITICAL_FACTOR × max) 或电机实测已过载
"""
assert 1 <= factor <= 10, f'factor 必须 1-10, got {factor}'
clamped = 0
for i in range(12):
jt = JOINT_TYPE[i]
m = lowcmd.motorCmd[i]
tau_lim = TAU_MAX[jt] * factor / 10.0
critical = TAU_MAX[jt] * CRITICAL_FACTOR
if abs(m.tau) > critical:
raise PowerProtectViolation(
f'严重超限! 电机 {JOINT_NAMES[i]} 命令 tau={m.tau:.2f}, '
f'超过 critical {critical:.2f} ({CRITICAL_FACTOR}x max)')
if lstate is not None:
try:
actual_tau = lstate.motorState[i].tauEst
if abs(actual_tau) > TAU_MAX[jt]:
raise PowerProtectViolation(
f'电机 {JOINT_NAMES[i]} 实测 tauEst={actual_tau:.2f} 已超 max {TAU_MAX[jt]}, '
f'立即停机!')
except (AttributeError, IndexError):
pass
if m.tau > tau_lim:
m.tau = tau_lim; clamped += 1
elif m.tau < -tau_lim:
m.tau = -tau_lim; clamped += 1
return clamped
def position_protect(lowcmd: 'LowCmd', lstate: 'LowState', limit_rad: float) -> int:
"""软位置保护: 目标 q 跟实测偏差 > limit, 关 Kp/Kd 防止拉伤. 返回被关的电机数."""
affected = 0
for i in range(12):
m = lowcmd.motorCmd[i]
try:
actual_q = lstate.motorState[i].q
except (AttributeError, IndexError):
continue
if abs(m.q - actual_q) > limit_rad:
m.Kp = 0; m.Kd = 0
affected += 1
return affected
def apply_safety(lowcmd: 'LowCmd', lstate: Optional['LowState'] = None, *,
power_factor: Optional[int] = 1,
position_limit_on: bool = True,
position_protect_limit: Optional[float] = None,
raise_on_critical: bool = True) -> 'LowCmd':
"""统一入口: 一次调用应用所有保护. In-place.
推荐用法 (开发阶段):
apply_safety(cmd, state, power_factor=1, position_protect_limit=0.5)
"""
if position_limit_on:
position_limit(lowcmd)
if power_factor is not None and lstate is not None:
try:
power_protect(lowcmd, lstate, power_factor)
except PowerProtectViolation:
if raise_on_critical:
raise
# 降级: 全部 damping
for i in range(12):
m = lowcmd.motorCmd[i]
m.tau = 0; m.Kp = 0; m.Kd = 0; m.mode = 0
if position_protect_limit is not None and lstate is not None:
position_protect(lowcmd, lstate, position_protect_limit)
return lowcmd

View File

@@ -0,0 +1,14 @@
"""数据结构 (MotorCmd, MotorState, LowState, LowCmd, IMU, BMS, Remote)."""
from .motor import MotorCmd, MotorState, MotorMode
from .imu import IMU
from .bms import BMS
from .remote import RemoteState, parse_remote, BUTTON_NAMES
from .low_state import LowState
from .low_cmd import LowCmd
__all__ = [
'MotorCmd', 'MotorState', 'MotorMode',
'IMU', 'BMS',
'RemoteState', 'parse_remote', 'BUTTON_NAMES',
'LowState', 'LowCmd',
]

48
go1_pro_sdk/types/bms.py Normal file
View File

@@ -0,0 +1,48 @@
"""电池管理系统 (BMS) 数据."""
import struct
from dataclasses import dataclass, field
from typing import List
@dataclass
class BMS:
"""BMS 反馈."""
version_h: int = 0
version_l: int = 0
bms_status: int = 0
SOC: int = 0 # 电量百分比 0-100
current: int = 0 # mA (负值=放电)
cycle: int = 0 # 充放电循环数
BQ_NTC: List[int] = field(default_factory=lambda: [0, 0]) # 电池温度 °C
MCU_NTC: List[int] = field(default_factory=lambda: [0, 0]) # 控制板温度 °C
cell_vol: List[int] = field(default_factory=lambda: [0]*10) # 单串电压 mV (10 串)
@property
def voltage_mv(self) -> int:
"""整组电压 (mV) = 10 串单电压之和."""
return sum(self.cell_vol)
@property
def voltage_v(self) -> float:
return self.voltage_mv / 1000.0
@property
def current_a(self) -> float:
return self.current / 1000.0
@classmethod
def from_bytes(cls, data: bytes) -> 'BMS':
"""从 LowState 中的 34 字节解析 (offset 835..869 in highState, 类似在 lowState)."""
bms = cls()
bms.version_h = data[0]
bms.version_l = data[1]
bms.bms_status = data[2]
bms.SOC = data[3]
# current int32 LE (signed)
bms.current = struct.unpack('<i', data[4:8])[0]
bms.cycle = int.from_bytes(data[8:10], 'little')
bms.BQ_NTC = [data[10], data[11]]
bms.MCU_NTC = [data[12], data[13]]
# cell_vol 10 × uint16 LE
bms.cell_vol = [int.from_bytes(data[14 + i*2:14 + (i+1)*2], 'little') for i in range(10)]
return bms

31
go1_pro_sdk/types/imu.py Normal file
View File

@@ -0,0 +1,31 @@
"""IMU 数据结构."""
import struct
from dataclasses import dataclass
from typing import Tuple
from ..utils.common import hex_to_float
@dataclass
class IMU:
"""惯性测量单元反馈."""
quaternion: Tuple[float, float, float, float] = (1.0, 0.0, 0.0, 0.0) # (w, x, y, z)
gyroscope: Tuple[float, float, float] = (0.0, 0.0, 0.0) # rad/s
accelerometer: Tuple[float, float, float] = (0.0, 0.0, 0.0) # m/s²
rpy: Tuple[float, float, float] = (0.0, 0.0, 0.0) # rad
temperature: int = 0
@classmethod
def from_bytes(cls, data: bytes) -> 'IMU':
"""从 LowState 中的 53 字节解析 (offset 22..75)."""
imu = cls()
# quaternion 4×4 = 16B
imu.quaternion = tuple(hex_to_float(data[i*4:(i+1)*4]) for i in range(4))
# gyroscope 3×4 = 12B
imu.gyroscope = tuple(hex_to_float(data[16 + i*4:16 + (i+1)*4]) for i in range(3))
# accelerometer 3×4 = 12B
imu.accelerometer = tuple(hex_to_float(data[28 + i*4:28 + (i+1)*4]) for i in range(3))
# rpy 3×4 = 12B
imu.rpy = tuple(hex_to_float(data[40 + i*4:40 + (i+1)*4]) for i in range(3))
# temperature 1B
imu.temperature = data[52]
return imu

View File

@@ -0,0 +1,40 @@
"""LowCmd — 主控发给 MCU 的控制命令 (PRO 格式 616B 明文 / Blowfish 加密后 616B)."""
from dataclasses import dataclass, field
from typing import List
from .motor import MotorCmd
@dataclass
class LowCmd:
"""完整低层控制命令.
PRO 实际只在意 motorCmd[0..11] 和 bandWidth, 其他字段都是 0.
"""
head: bytes = b'\xfe\xef'
levelFlag: int = 0xff
frameReserve: int = 0
SN: bytes = b'\x00' * 8 # PRO 不填, 全 0
version: bytes = b'\x00' * 8 # PRO 不填, 全 0
bandWidth: int = 0x3ac0 # 真包实测值
motorCmd: List[MotorCmd] = field(default_factory=lambda: [MotorCmd() for _ in range(20)])
wirelessRemote: bytes = b'\x00' * 40
reserve: bytes = b'\x00' * 4
def set_motor(self, index_or_name, cmd: MotorCmd):
"""设置某个电机命令.
index_or_name: 0-19 (数字) 或 'FR_0', 'FL_1' 等字符串.
"""
from ..utils.constants import JOINT_NAMES
if isinstance(index_or_name, str):
idx = JOINT_NAMES.index(index_or_name)
else:
idx = int(index_or_name)
self.motorCmd[idx] = cmd
return self
def all_damping(self):
"""快捷: 把所有电机设为 damping (mode=0, 全零)."""
for i in range(20):
self.motorCmd[i] = MotorCmd() # 默认全 0 + Damping
return self

View File

@@ -0,0 +1,33 @@
"""LowState — MCU 解密后的状态包 (807B)."""
import struct
from dataclasses import dataclass, field
from typing import List, Tuple
from .motor import MotorState
from .imu import IMU
from .bms import BMS
from .remote import RemoteState, parse_remote
@dataclass
class LowState:
"""完整 MCU 状态. 由 lowstate_parser.parse_low_state() 填充."""
head: int = 0
levelFlag: int = 0
frameReserve: int = 0
SN: bytes = b'\x00' * 8
version: bytes = b'\x00' * 8
bandWidth: int = 0
imu: IMU = field(default_factory=IMU)
motorState: List[MotorState] = field(default_factory=lambda: [MotorState() for _ in range(20)])
footForce: Tuple[int, int, int, int] = (0, 0, 0, 0)
footForceEst: Tuple[int, int, int, int] = (0, 0, 0, 0)
bms: BMS = field(default_factory=BMS)
tick: int = 0
wirelessRemote: bytes = b'\x00' * 40
reserve: bytes = b'\x00' * 4
crc: bytes = b'\x00' * 4
@property
def remote(self) -> RemoteState:
"""解析后的遥控器状态 (每次访问重新解析, 始终最新)."""
return parse_remote(self.wirelessRemote)

View File

@@ -0,0 +1,83 @@
"""电机数据结构: MotorCmd, MotorState, MotorMode."""
from dataclasses import dataclass, field
from enum import IntEnum
from typing import List
from ..utils.common import (
float_to_hex, hex_to_float, tau_to_hex, hex_to_tau,
kp_to_hex, hex_to_kp, kd_to_hex, hex_to_kd,
)
class MotorMode(IntEnum):
"""电机模式."""
Damping = 0x00 # 阻尼模式 (电机失能, 关节自由)
Servo = 0x0A # 伺服模式 (PD 控制)
Overheat = 0x08 # 过热保护
@dataclass
class MotorCmd:
"""单电机命令.
实际控制律: tau_output = tau + Kp*(q-q_actual) + Kd*(dq-dq_actual)
"""
mode: int = MotorMode.Damping
q: float = 0.0 # 目标角度 (rad)
dq: float = 0.0 # 目标速度 (rad/s)
tau: float = 0.0 # 前馈力矩 (N·m)
Kp: float = 0.0 # 位置 stiffness
Kd: float = 0.0 # 速度 damping
reserve: List[int] = field(default_factory=lambda: [0, 0, 0])
def to_bytes(self) -> bytes:
"""序列化为 27 字节 (Unitree 私有格式)."""
mode = int(self.mode)
return (
mode.to_bytes(1, 'little')
+ float_to_hex(self.q) # 4B
+ float_to_hex(self.dq) # 4B
+ tau_to_hex(self.tau) # 2B
+ bytes(kp_to_hex(self.Kp)) # 2B
+ bytes(kd_to_hex(self.Kd)) # 2B
+ self.reserve[0].to_bytes(4, 'little')
+ self.reserve[1].to_bytes(4, 'little')
+ self.reserve[2].to_bytes(4, 'little')
)
@dataclass
class MotorState:
"""单电机反馈."""
mode: int = 0
q: float = 0.0
dq: float = 0.0
ddq: float = 0.0
tauEst: float = 0.0 # 估算扭矩 (N·m)
q_raw: float = 0.0
dq_raw: float = 0.0
ddq_raw: float = 0.0
temperature: int = 0
reserve: List[int] = field(default_factory=lambda: [0, 0])
@classmethod
def from_bytes(cls, data: bytes) -> 'MotorState':
"""从 32 字节反解 (Unitree LowState 单电机布局, 跟 free-dog-sdk 对齐)."""
ms = cls()
ms.mode = data[0]
ms.q = hex_to_float(data[1:5])
ms.dq = hex_to_float(data[5:9])
ms.ddq = float(int.from_bytes(data[9:11], 'little', signed=True))
ms.tauEst = float(int.from_bytes(data[11:13], 'little', signed=True)) * 0.00390625
ms.q_raw = hex_to_float(data[13:17])
ms.dq_raw = hex_to_float(data[17:21])
ms.ddq_raw = float(int.from_bytes(data[21:23], 'little', signed=True))
ms.temperature = data[24]
ms.reserve = [
int.from_bytes(data[24:28], 'little'),
int.from_bytes(data[28:32], 'little'),
]
return ms
def estimated_current(self, kt: float = 0.066) -> float:
"""估算电机电流 (A) = tauEst / Kt."""
return self.tauEst / kt

View File

@@ -0,0 +1,54 @@
"""Unitree 遥控器状态解码."""
import struct
from dataclasses import dataclass, field
from typing import List
# 按键 bitmap (Unitree 公开 xRockerBtnDataStruct)
BUTTON_NAMES = [
(0x0001, 'R1'), (0x0002, 'L1'), (0x0004, 'START'), (0x0008, 'SELECT'),
(0x0010, 'R2'), (0x0020, 'L2'), (0x0040, 'F1'), (0x0080, 'F2'),
(0x0100, 'A'), (0x0200, 'B'), (0x0400, 'X'), (0x0800, 'Y'),
(0x1000, 'UP'), (0x2000, 'RIGHT'), (0x4000, 'DOWN'), (0x8000, 'LEFT'),
]
@dataclass
class RemoteState:
"""遥控器实时状态.
head: 通常 [0x55, 0xAA] 包头
btn: 按键 bitmap (uint16)
lx/ly/rx/ry: 摇杆 -1..+1
L2: 模拟扳机 0..1
"""
head: bytes = b''
btn: int = 0
lx: float = 0.0
ly: float = 0.0
rx: float = 0.0
ry: float = 0.0
L2: float = 0.0
pressed: List[str] = field(default_factory=list)
def is_pressed(self, name: str) -> bool:
return name in self.pressed
def any_button(self) -> bool:
return self.btn != 0
def parse_remote(data40: bytes) -> RemoteState:
"""从 40 字节 wirelessRemote 字段解析."""
rs = RemoteState()
if len(data40) < 24:
return rs
rs.head = bytes(data40[:2])
rs.btn = struct.unpack('<H', data40[2:4])[0]
rs.lx = struct.unpack('<f', data40[4:8])[0]
rs.rx = struct.unpack('<f', data40[8:12])[0]
rs.ry = struct.unpack('<f', data40[12:16])[0]
rs.L2 = struct.unpack('<f', data40[16:20])[0]
rs.ly = struct.unpack('<f', data40[20:24])[0]
rs.pressed = [name for mask, name in BUTTON_NAMES if rs.btn & mask]
return rs

View File

@@ -0,0 +1,30 @@
"""Utility functions and constants."""
from .common import (
gen_crc, float_to_hex, hex_to_float,
tau_to_hex, hex_to_tau,
kp_to_hex, hex_to_kp,
kd_to_hex, hex_to_kd,
decode_sn, decode_version,
)
from .constants import (
MCU_IP, MCU_PORT,
JOINT_NAMES, JOINT_TYPE, JOINT_ATTR,
JOINT_LIMITS, JOINT_LIMITS_MEASURED,
TAU_MAX, KT, CRITICAL_FACTOR,
DAMPING_POSE,
)
__all__ = [
# common
'gen_crc', 'float_to_hex', 'hex_to_float',
'tau_to_hex', 'hex_to_tau',
'kp_to_hex', 'hex_to_kp',
'kd_to_hex', 'hex_to_kd',
'decode_sn', 'decode_version',
# constants
'MCU_IP', 'MCU_PORT',
'JOINT_NAMES', 'JOINT_TYPE', 'JOINT_ATTR',
'JOINT_LIMITS', 'JOINT_LIMITS_MEASURED',
'TAU_MAX', 'KT', 'CRITICAL_FACTOR',
'DAMPING_POSE',
]

170
go1_pro_sdk/utils/common.py Normal file
View File

@@ -0,0 +1,170 @@
"""通用编解码/工具函数 (基于 free-dog-sdk ucl/common.py 整理).
主要导出:
- CRC: gen_crc (PRO 用裸 CRC, 不 XOR)
- float ↔ Unitree 自定义 hex 字段: float_to_hex/hex_to_float
- 物理量 ↔ 字段: tau, Kp, Kd 各自的编码/解码
- SN/version 解码
来源: free-dog-sdk commit 0ececb5ed3b04c6bd88dbdc40c89db86bedc6543
作者: Bin4ry (Andreas Makris) — MIT License
"""
import struct
import binascii
# ===== CRC32 (用于 LowCmd 末尾 4 字节, PRO 不 XOR, EDU 才 XOR encryptCrc) =====
def gen_crc(data: bytes) -> bytes:
"""CRC-32 with polynomial 0x04c11db7 (custom 实现, 跟 Unitree MCU 对齐).
跟 Python zlib.crc32 不同 (zlib 用反射 + 0xEDB88320), 所以这个手动实现保留.
"""
crc = 0xFFFFFFFF
for j in struct.unpack("<%dI" % (len(data) // 4), data):
for b in range(32):
x = (crc >> 31) & 1
crc <<= 1
crc &= 0xFFFFFFFF
if x ^ (1 & (j >> (31 - b))):
crc ^= 0x04c11db7
return struct.pack('<I', crc)
# ===== float ↔ hex 字段 (Unitree 用 big-endian float 但小端整数读) =====
def float_to_hex(f: float) -> bytes:
return (struct.unpack('>I', struct.pack('>f', f))[0]).to_bytes(4, 'little')
def hex_to_float(data: bytes) -> float:
i = int.from_bytes(data, 'little')
return struct.unpack('>f', struct.pack('>I', i))[0]
# ===== 力矩 tau ↔ 2 字节 =====
def _fraction_to_hex(fraction, neg=False):
if fraction == 0.0:
neg = False
hex_value = int(fraction * 256)
if neg:
hex_value = 255 + hex_value + 1
return hex_value.to_bytes(1, 'little')
def _hex_to_fraction(hex_byte, neg=False):
if neg:
return -1 + round(hex_byte / 256, 2)
return round(hex_byte / 256, 2)
def tau_to_hex(tau: float) -> bytes:
tau = round(tau, 2)
integer_part = int(tau)
fractional_part = tau - integer_part
neg = False
if tau < 0:
neg = True
integer_part = 255 + integer_part
return _fraction_to_hex(fractional_part, neg) + integer_part.to_bytes(1, 'little')
def hex_to_tau(data: bytes) -> float:
ip = data[1:]
int_val = int.from_bytes(ip, 'little')
neg = False
if int_val > 126:
neg = True
int_val = -255 + int_val
return int_val + _hex_to_fraction(data[0], neg)
# ===== Kp ↔ 2 字节 =====
def kp_to_hex(Kp: float) -> bytearray:
base, frac = divmod(Kp, 1)
base = int(base)
frac = int(round(frac, 1) * 10)
val = 0
if frac < 5:
val = (base * 32) + frac * 3
if frac >= 5:
val = (base * 32) + ((frac - 1) * 3) + 4
val = f'%04x' % val
kp = bytearray(bytes.fromhex(val))
kp.reverse()
return kp
def hex_to_kp(byte_arr: bytes) -> float:
hex_bytes = binascii.hexlify(byte_arr)
h = bytearray(4)
h[0] = hex_bytes[2]
h[1] = hex_bytes[3]
h[2] = hex_bytes[0]
h[3] = hex_bytes[1]
val = int(h, 16)
base = val // 32
remainder = val % 32
if remainder < 15:
frac = remainder / 3
else:
frac = (remainder - 4) / 3 + 1
return base + round(frac, 1) / 10
# ===== Kd ↔ 2 字节 =====
_KD_FRAC_TO_HEX = {0.0: '0', 0.1: '1', 0.2: '3', 0.3: '4', 0.4: '6',
0.5: '8', 0.6: '9', 0.7: 'b', 0.8: 'c', 0.9: 'e'}
_KD_HEX_TO_FRAC = {v: k for k, v in _KD_FRAC_TO_HEX.items()}
def kd_to_hex(Kd: float) -> bytearray:
integer_part = int(Kd)
fractional_part = round(Kd - integer_part, 1)
hex_fractional_part = _KD_FRAC_TO_HEX.get(fractional_part, '0')
hex_integer_part = f'%03x' % integer_part
kd = bytearray(bytes.fromhex(hex_integer_part + hex_fractional_part))
kd.reverse()
return kd
def hex_to_kd(byte_arr: bytes) -> float:
hex_bytes = binascii.hexlify(byte_arr)
h = bytearray(4)
h[0] = hex_bytes[2]
h[1] = hex_bytes[3]
h[2] = hex_bytes[0]
h[3] = hex_bytes[1]
int_part = int(h[:3], 16)
frac_part = _KD_HEX_TO_FRAC.get(chr(h[3]), 0.0)
return int_part + frac_part
# ===== SN / Version 解码 =====
_TYPE_NAMES = {1: 'Laikago', 2: 'Aliengo', 3: 'A1', 4: 'Go1', 5: 'B1'}
_MODEL_NAMES = {1: 'AIR', 2: 'PRO', 3: 'EDU', 4: 'PC', 5: 'XX'}
def decode_sn(data: bytes):
"""SN 8 字节 → (product, id) 例如 ('Go1_PRO', '3-5-8[2]')."""
type_name = _TYPE_NAMES.get(data[0], 'UNKNOWN')
model_name = _MODEL_NAMES.get(data[1], 'UNKNOWN')
product = f'{type_name}_{model_name}'
id_str = f'{data[2]}-{data[3]}-{data[4]}[{data[5]}]'
return product, id_str
def decode_version(data: bytes):
"""Version 8 字节 → (hw, sw) 例如 ('0.1.9', '0.2.0')."""
hw = f'{data[0]}.{data[1]}.{data[2]}'
sw = f'{data[3]}.{data[4]}.{data[5]}'
return hw, sw
def byte_print(data: bytes) -> str:
"""格式化字节为 hex 字符串."""
return ''.join('{:02x}'.format(x) for x in data)

View File

@@ -0,0 +1,90 @@
"""Go1 PRO 常量集中定义.
来源: tools/onboard/* 实测 + Unitree URDF + 官方电机规格.
"""
# ===== 网络 =====
MCU_IP = '192.168.123.10'
MCU_PORT = 8007
"""MCU UDP 端口 (从这里发/收 LowCmd/LowState)"""
# ===== 关节命名/索引 =====
JOINT_NAMES = [
'FR_0', 'FR_1', 'FR_2', # 0..2 右前 (髋/大腿/小腿)
'FL_0', 'FL_1', 'FL_2', # 3..5 左前
'RR_0', 'RR_1', 'RR_2', # 6..8 右后
'RL_0', 'RL_1', 'RL_2', # 9..11 左后
]
JOINT_TYPE = {i: ('hip' if i % 3 == 0 else 'thigh' if i % 3 == 1 else 'knee')
for i in range(12)}
# motorCmdArray 用命名属性 (FR_0, FR_1, ...) 而不是 list, 这个表用于 getattr 索引
JOINT_ATTR = JOINT_NAMES # 命名一致, 可直接 getattr
# ===== 关节限位 (rad) — 2026-06-20 实测校准 =====
JOINT_LIMITS = {
# 推荐安全范围 (实测极限 + 5° 余量)
'hip': (-0.78, 0.78), # _0 (实测 ~±0.85~0.89)
'thigh': (-0.60, 3.50), # _1 (实测 -0.69 ~ +3.95, 上限保守取 3.5)
'knee': (-2.70, -0.95), # _2 (实测 -2.79 ~ -0.88, 注意是负值)
}
JOINT_LIMITS_URDF = {
# Unitree 官方 URDF 标称值, 实际可能偏保守
'hip': (-0.863, 0.863),
'thigh': (-0.686, 4.501),
'knee': (-2.818, -0.888),
}
JOINT_LIMITS_MEASURED = {
# 前两条腿 (FR/FL) 完整转动 90s 实测 (2026-06-20)
'FR_0': (-0.8627, +0.8515),
'FR_1': (-0.6930, +3.9173),
'FR_2': (-2.7648, -0.8847),
'FL_0': (-0.8922, +0.8229),
'FL_1': (-0.6676, +3.9508),
'FL_2': (-2.7850, -0.9001),
# 后腿 (RR/RL) 实测时未充分转动, 参考 FR/FL 对称
}
# ===== 电机最大力矩 (N·m) — Unitree 官方规格 =====
TAU_MAX = {
'hip': 23.7,
'thigh': 23.7,
'knee': 35.55,
}
KT = 0.066 # 力矩常数 N·m/A, 用于 tau ↔ 电流 换算 (Go1 GO-M8010-6)
CRITICAL_FACTOR = 5.0 # 命令 tau > CRITICAL_FACTOR × TAU_MAX = 离谱, 触发紧急停机
# ===== 趴地 damping 姿态参考 (2026-06-20 实测, 4 腿对称) =====
DAMPING_POSE = {
'FR_0': -0.37, 'FR_1': +1.18, 'FR_2': -2.77,
'FL_0': +0.43, 'FL_1': +1.14, 'FL_2': -2.75,
'RR_0': -0.37, 'RR_1': +1.18, 'RR_2': -2.76,
'RL_0': +0.41, 'RL_1': +1.15, 'RL_2': -2.76,
}
# 半起立姿态 (example_position 用的 sin 中点, 不是真趴地)
HALF_STAND_POSE = {
'_0': 0.0, # hip 中位
'_1': 1.2, # thigh 前蹬
'_2': -2.0, # knee 半弯曲 (比 damping 的 -2.77 伸开 44°)
}
# ===== 控制相关 =====
DT_DEFAULT = 0.002 # 控制周期 (s), Mac 上可达 480Hz
SEND_DECIMATION = 1 # 发包降频比例 (1 = 全速发, 5 = 100Hz)
RCVBUF_SIZE = 4096 # socket RCVBUF, 小=自动丢弃积压

40
pyproject.toml Normal file
View File

@@ -0,0 +1,40 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "go1_pro_sdk"
version = "0.1.0"
description = "Unitree Go1 PRO low-level motor control SDK (Python, with Blowfish encryption)"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.8"
authors = [{name = "chenyouyuan"}]
keywords = ["unitree", "go1", "robotics", "low-level-control"]
classifiers = [
"Development Status :: 4 - Beta",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Topic :: Scientific/Engineering :: Robotics",
]
dependencies = [] # 纯标准库
[project.optional-dependencies]
mqtt = ["paho-mqtt>=2.0.0"] # 用 MQTT 切换 sportMode 模式时需要
[project.urls]
Homepage = "https://github.com/your-username/go1_pro_sdk"
Documentation = "https://github.com/your-username/go1_pro_sdk/blob/main/docs/"
[tool.setuptools]
packages = ["go1_pro_sdk",
"go1_pro_sdk.codec",
"go1_pro_sdk.connection",
"go1_pro_sdk.safety",
"go1_pro_sdk.types",
"go1_pro_sdk.utils"]
[tool.setuptools.package-data]
"go1_pro_sdk" = ["_data/*.bin", "_data/*.md"]

48
tests/test_blowfish.py Normal file
View File

@@ -0,0 +1,48 @@
"""Blowfish 加解密测试 (用已知明密文对)."""
import os
import pytest
from go1_pro_sdk import Blowfish, verify_state
from go1_pro_sdk.codec.blowfish import KNOWN_PAIRS
STATE_FILE = os.path.join(
os.path.dirname(__file__), '..', 'go1_pro_sdk', '_data', 'blowfish_state.bin'
)
@pytest.fixture
def bf():
return Blowfish.from_state_file(STATE_FILE)
def test_state_loads(bf):
assert len(bf.P) == 18
assert len(bf.S) == 4
assert all(len(s) == 256 for s in bf.S)
def test_state_verify(bf):
"""3/3 已知明密文对应该全部匹配."""
assert verify_state(bf) is True
def test_encrypt_known_pairs(bf):
for pt, ct in KNOWN_PAIRS:
assert bf.encrypt_block(pt) == ct
def test_decrypt_known_pairs(bf):
for pt, ct in KNOWN_PAIRS:
assert bf.decrypt_block(ct) == pt
def test_encrypt_decrypt_roundtrip(bf):
data = b'\x12\x34\x56\x78' * 100 # 400B
encrypted = bf.encrypt_ecb(data)
decrypted = bf.decrypt_ecb(encrypted)
assert data == decrypted
def test_encrypt_size_must_be_8x(bf):
with pytest.raises(AssertionError):
bf.encrypt_ecb(b'\x00' * 7)

View File

@@ -0,0 +1,94 @@
"""LowCmd 序列化测试 (PRO 格式)."""
import os
import pytest
from go1_pro_sdk import (
Blowfish, LowCmd, MotorCmd, MotorMode,
build_low_cmd_plain, build_low_cmd_encrypted,
)
STATE_FILE = os.path.join(
os.path.dirname(__file__), '..', 'go1_pro_sdk', '_data', 'blowfish_state.bin'
)
@pytest.fixture
def bf():
return Blowfish.from_state_file(STATE_FILE)
def test_plain_length_616():
cmd = LowCmd()
plain = build_low_cmd_plain(cmd)
assert len(plain) == 616
def test_plain_head():
cmd = LowCmd()
plain = build_low_cmd_plain(cmd)
assert plain[:2] == b'\xfe\xef'
assert plain[2] == 0xff
def test_plain_sn_version_all_zero():
"""PRO 真实包 SN/version 全 0."""
cmd = LowCmd()
plain = build_low_cmd_plain(cmd)
assert plain[4:12] == b'\x00' * 8 # SN
assert plain[12:20] == b'\x00' * 8 # version
def test_plain_bandwidth_be():
"""bandWidth 是 BE 字节序 (3a c0, 不是 c0 3a)."""
cmd = LowCmd()
plain = build_low_cmd_plain(cmd)
assert plain[20:22] == b'\x3a\xc0'
def test_plain_padding_zeros():
"""610..612 是固定 0000."""
cmd = LowCmd()
plain = build_low_cmd_plain(cmd)
assert plain[610:612] == b'\x00\x00'
def test_plain_crc_at_612():
"""CRC 在 612..616, 不是 EDU 的 610..614."""
cmd = LowCmd()
plain = build_low_cmd_plain(cmd)
from go1_pro_sdk.utils.common import gen_crc
expected_crc = gen_crc(plain[:612])
assert plain[612:616] == expected_crc
def test_encrypted_damping_matches_real(bf):
"""全 damping 加密后, 前 16B 应跟真实 Legged_sport 抓包一致."""
cmd = LowCmd()
enc = build_low_cmd_encrypted(cmd, bf)
assert len(enc) == 616
# 真实抓包前 16B (strace 抓的 Legged_sport sendto 内容)
expected = bytes.fromhex('e79e1ccc4bae9cf812aa0354236b66e3')
assert enc[:16] == expected
def test_set_motor_by_name():
cmd = LowCmd()
cmd.set_motor('FR_1', MotorCmd(mode=MotorMode.Servo, q=1.2, Kp=5, Kd=1))
assert cmd.motorCmd[1].mode == MotorMode.Servo
assert cmd.motorCmd[1].q == 1.2
assert cmd.motorCmd[1].Kp == 5
def test_set_motor_by_index():
cmd = LowCmd()
cmd.set_motor(5, MotorCmd(q=2.0))
assert cmd.motorCmd[5].q == 2.0
def test_all_damping():
cmd = LowCmd()
cmd.motorCmd[0].mode = MotorMode.Servo
cmd.motorCmd[0].q = 1.5
cmd.all_damping()
assert cmd.motorCmd[0].mode == MotorMode.Damping
assert cmd.motorCmd[0].q == 0.0

82
tests/test_safety.py Normal file
View File

@@ -0,0 +1,82 @@
"""Safety 保护层测试."""
import pytest
from go1_pro_sdk import (
LowCmd, MotorCmd, MotorMode,
apply_safety, position_limit, power_protect,
PowerProtectViolation,
TAU_MAX,
)
class _FakeMotor:
tauEst = 0.0
q = 0.0
class _FakeState:
def __init__(self):
self.motorState = [_FakeMotor() for _ in range(20)]
def test_position_limit_clamps_hip_over():
cmd = LowCmd()
cmd.motorCmd[0].q = 5.0 # FR_0 hip 超限
position_limit(cmd)
assert cmd.motorCmd[0].q == 0.78
def test_position_limit_clamps_thigh_under():
cmd = LowCmd()
cmd.motorCmd[1].q = -3.0
position_limit(cmd)
assert cmd.motorCmd[1].q == -0.60
def test_power_protect_clamps():
cmd = LowCmd()
for i in range(12):
cmd.motorCmd[i].tau = 10.0
state = _FakeState()
n = power_protect(cmd, state, factor=1)
# factor=1 → 限制到 TAU_MAX * 0.1
assert cmd.motorCmd[0].tau == TAU_MAX['hip'] * 0.1
assert cmd.motorCmd[2].tau == TAU_MAX['knee'] * 0.1
def test_power_protect_critical_command_raises():
cmd = LowCmd()
cmd.motorCmd[2].tau = 200.0 # > 5 × 35.55
with pytest.raises(PowerProtectViolation):
power_protect(cmd, _FakeState(), factor=5)
def test_power_protect_overload_actual_raises():
"""实测 tauEst 已超 max, 也应 raise."""
cmd = LowCmd()
cmd.motorCmd[2].tau = 1.0
state = _FakeState()
state.motorState[2].tauEst = 40.0 # > 35.55
with pytest.raises(PowerProtectViolation):
power_protect(cmd, state, factor=1)
def test_apply_safety_degraded_mode():
"""raise_on_critical=False 应当降级到全 damping."""
cmd = LowCmd()
cmd.motorCmd[2].tau = 200.0
cmd.motorCmd[2].Kp = 10
apply_safety(cmd, _FakeState(), power_factor=5,
position_limit_on=False, raise_on_critical=False)
assert cmd.motorCmd[0].tau == 0
assert cmd.motorCmd[0].Kp == 0
assert cmd.motorCmd[0].mode == 0
def test_apply_safety_normal_flow():
"""正常命令应能通过. 返回 LowCmd."""
cmd = LowCmd()
cmd.motorCmd[0].q = 0.5
cmd.motorCmd[0].tau = 1.0
result = apply_safety(cmd, _FakeState(), power_factor=5)
assert result is cmd
assert cmd.motorCmd[0].q == 0.5 # 在限位内, 不变

79
tools/README.md Normal file
View File

@@ -0,0 +1,79 @@
# Tools
一次性诊断/校准工具. 不是 SDK 核心.
## 密钥提取
### extract_blowfish_key.sh
在狗上 (Pi) 运行, 从 Legged_sport 进程内存提取 Blowfish state.
```bash
scp tools/extract_blowfish_key.sh pi@192.168.123.161:/tmp/
ssh pi@192.168.123.161 "sudo bash /tmp/extract_blowfish_key.sh"
scp pi@192.168.123.161:/tmp/blowfish_dump.tar.gz ./
```
### analyze_blowfish_dump.py
Mac 端解析 dump, 找出 4168 字节 state.
```bash
python tools/analyze_blowfish_dump.py blowfish_dump.tar.gz
```
## 校准
### calibrate_joints.py
手动转动法测每个关节真实活动范围.
```bash
# 狗悬空 + L2+B damping, 然后:
python tools/calibrate_joints.py --duration 90
# 慢慢把每条腿的每个关节转到极限
# 退出时输出 JOINT_LIMITS_MEASURED 字典, 可粘贴到 utils/constants.py
```
## 抢占源管理
### stop_sportmode.sh
```bash
bash tools/stop_sportmode.sh stop # 杀 keep_sport_alive + Legged_sport + appTransit
bash tools/stop_sportmode.sh start # 恢复
bash tools/stop_sportmode.sh status # 看当前状态
```
### run_on_mac.sh
一键流程: 停抢占源 → 跑测试 → (手动恢复).
```bash
bash tools/run_on_mac.sh example
bash tools/run_on_mac.sh monitor
bash tools/run_on_mac.sh stop
bash tools/run_on_mac.sh start
```
### run_on_pi.sh
把测试脚本部署到 Pi 上跑 (源 IP 跟 Legged_sport 一致, 现在不需要了, 但保留作为备选).
## 协议研究
### capture_lowcmd.sh + _raw_capture.py
Pi 端 tcpdump 抓 Legged_sport 真实发送的 LowCmd 流量.
```bash
bash tools/capture_lowcmd.sh 3
```
### diff_real_lowcmd.sh + _compare_lowcmd.py
抓真实包 + 跟本地 buildCmd 输出做字节级 diff. 用于发现协议细节差异.
```bash
bash tools/diff_real_lowcmd.sh 3
```

212
tools/_compare_lowcmd.py Normal file
View File

@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""
解析 pcap, 取出真实 LowCmd 密文 → Blowfish 解密 → 跟本地 buildCmd() 字节对比
找出剩余差异 (SN/version/frameReserve/CRC/padding 等)
"""
import sys, os, struct, argparse
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
sys.path.insert(0, os.path.dirname(__file__))
from blowfish_codec import Blowfish
from ucl.lowCmd import lowCmd
from ucl.complex import motorCmd, motorCmdArray
# LowCmd 字段布局 (基于 buildCmd 源码)
LAYOUT = [
( 0, 2, 'head'),
( 2, 3, 'levelFlag'),
( 3, 4, 'frameReserve'),
( 4, 12, 'SN'),
( 12, 20, 'version'),
( 20, 22, 'bandWidth'),
( 22, 562, 'motorCmd[20]'),
(562, 566, 'bms'),
(566, 606, 'wirelessRemote'),
(606, 610, 'reserve'),
(610, 614, 'CRC'),
(614, 616, 'padding'),
]
def field_at(offset):
"""根据 offset 返回字段名"""
for start, end, name in LAYOUT:
if start <= offset < end:
return name, start, end
return '?', -1, -1
def parse_pcap(path):
"""提取所有 UDP payload (兼容 SLL / Ethernet)"""
packets = []
with open(path, 'rb') as f:
gh = f.read(24)
if len(gh) < 24: return packets
magic = struct.unpack('<I', gh[:4])[0]
endian = '<' if magic == 0xa1b2c3d4 else '>'
linktype = struct.unpack(f'{endian}I', gh[20:24])[0]
while True:
ph = f.read(16)
if len(ph) < 16: break
ts_sec, ts_usec, caplen, _ = struct.unpack(f'{endian}IIII', ph)
pkt = f.read(caplen)
if len(pkt) < caplen: break
if linktype == 1:
if len(pkt) < 14 or struct.unpack('>H', pkt[12:14])[0] != 0x0800: continue
ip_off = 14
elif linktype == 113: # SLL
if len(pkt) < 16 or struct.unpack('>H', pkt[14:16])[0] != 0x0800: continue
ip_off = 16
elif linktype == 276: # SLL2
if len(pkt) < 20 or struct.unpack('>H', pkt[:2])[0] != 0x0800: continue
ip_off = 20
else:
continue
if len(pkt) < ip_off + 20: continue
ihl = (pkt[ip_off] & 0x0f) * 4
if pkt[ip_off + 9] != 17: continue
udp_off = ip_off + ihl
if len(pkt) < udp_off + 8: continue
_, _, ulen, _ = struct.unpack('>HHHH', pkt[udp_off:udp_off+8])
payload = pkt[udp_off+8:udp_off+ulen]
packets.append(payload)
return packets
def build_local_lowcmd(encrypted=True):
"""本地生成 LowCmd (跟 test_sin_leg / lowcmd_codec 一致)"""
lcmd = lowCmd()
lcmd.SN = bytearray.fromhex('0402030508020100')
lcmd.version = bytearray.fromhex('000109000200fc7f')
mca = motorCmdArray()
for i in range(20):
mca.setMotorCmd(i, motorCmd(mode=0, q=0, dq=0, tau=0, Kp=0, Kd=0))
lcmd.motorCmd = mca
plain = lcmd.buildCmd(debug=False) # 614B
if not encrypted:
return plain
bf = Blowfish.from_state_file('data/lowlevel_captures/blowfish_state.bin')
padded = plain + b'\x00' * (8 - len(plain) % 8)
return padded, bf.encrypt_ecb(padded) # (明文616, 密文616)
def hex_dump(b, indent=' '):
for i in range(0, len(b), 32):
print(f"{indent}{i:04x}: {b[i:i+32].hex()}")
def diff(a, b, label_a='REAL', label_b='LOCAL'):
"""对比两段字节, 按字段分组报告差异"""
n = min(len(a), len(b))
print(f"\n{'='*70}")
print(f"对比 {label_a} ({len(a)}B) vs {label_b} ({len(b)}B)")
print(f"{'='*70}")
if len(a) != len(b):
print(f"⚠️ 长度不一致")
# 按字段分组
diffs_by_field = {}
for i in range(n):
if a[i] != b[i]:
name, _, _ = field_at(i)
diffs_by_field.setdefault(name, []).append(i)
if not diffs_by_field:
print(f"✅ 完全一致")
return
print(f"\n字段级差异:")
for name in [n for _, _, n in LAYOUT]:
if name not in diffs_by_field: continue
idxs = diffs_by_field[name]
start, end = None, None
for s, e, nm in LAYOUT:
if nm == name: start, end = s, e; break
seg_a = a[start:end]
seg_b = b[start:end]
print(f"\n 📍 {name} [{start}..{end}] ({len(idxs)}/{end-start} 字节不同):")
print(f" {label_a}: {seg_a.hex()}")
print(f" {label_b}: {seg_b.hex()}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument('pcap')
parser.add_argument('--cipher-out', help='保存第一帧密文')
parser.add_argument('--plain-out', help='保存第一帧解密后明文')
parser.add_argument('--state', default='data/lowlevel_captures/blowfish_state.bin')
args = parser.parse_args()
print(f"\n📥 解析 {args.pcap}...")
packets = parse_pcap(args.pcap)
print(f"{len(packets)} 个 UDP 包")
if not packets:
print(" ❌ 空 pcap")
return 1
# 长度统计
from collections import Counter
sizes = Counter(len(p) for p in packets)
print(f" 包大小分布:")
for sz, cnt in sorted(sizes.items()):
print(f" {sz}B: {cnt}")
# 取第一帧
real_cipher = packets[0]
print(f"\n📦 真实 LowCmd 密文 ({len(real_cipher)}B):")
hex_dump(real_cipher[:64])
print(f" ...")
hex_dump(real_cipher[-32:])
# 解密
bf = Blowfish.from_state_file(args.state)
aligned = (len(real_cipher) // 8) * 8
real_plain = bf.decrypt_ecb(real_cipher[:aligned])
print(f"\n🔓 解密后 ({len(real_plain)}B):")
hex_dump(real_plain[:64])
print(f" ...")
hex_dump(real_plain[-32:])
if not real_plain.startswith(b'\xfe\xef\xff'):
print(f"\n❌ 解密失败! 包头不是 feef ff")
print(f" Blowfish state 可能不对, 或者不是 LowCmd 流")
return 1
print(f"\n✅ 包头匹配 feef ff")
# 保存
if args.cipher_out:
with open(args.cipher_out, 'wb') as f:
f.write(real_cipher)
print(f"💾 密文 → {args.cipher_out}")
if args.plain_out:
with open(args.plain_out, 'wb') as f:
f.write(real_plain)
print(f"💾 明文 → {args.plain_out}")
# 本地生成对比
local_plain, local_cipher = build_local_lowcmd(encrypted=True)
print(f"\n🛠 本地生成 LowCmd (明文 614B padded 616B):")
hex_dump(local_plain[:64])
print(f" ...")
hex_dump(local_plain[-32:])
# 明文对比 (核心!)
diff(real_plain, local_plain, '真实(解密后)', '本地(buildCmd)')
# 密文对比 (验证加密是否一致)
diff(real_cipher, local_cipher, '真实密文', '本地加密')
# 看连续两帧真实包的变化 (说明哪些字段是动态的)
if len(packets) > 1:
p2 = bf.decrypt_ecb(packets[1][:aligned])
diff(real_plain, p2, '真实#0', '真实#1')
return 0
if __name__ == '__main__':
sys.exit(main())

121
tools/_raw_capture.py Normal file
View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
纯Python 抓包工具 (在Pi上运行, 无需 tcpdump)
用 AF_PACKET 原始套接字抓所有以太网包, 过滤出指定的 UDP 流, 写成标准 pcap 格式
用法: sudo python3 _raw_capture.py <输出pcap> <时长秒> [--filter-host IP] [--filter-port PORT]
"""
import socket, struct, sys, time, argparse
ETH_P_ALL = 0x0003
PCAP_MAGIC = 0xa1b2c3d4
LINKTYPE_ETHERNET = 1
def write_pcap_global_header(f):
f.write(struct.pack('<IHHIIII',
PCAP_MAGIC, 2, 4, 0, 0, 65535, LINKTYPE_ETHERNET))
def write_pcap_packet(f, ts, pkt):
ts_sec = int(ts)
ts_usec = int((ts - ts_sec) * 1e6)
f.write(struct.pack('<IIII', ts_sec, ts_usec, len(pkt), len(pkt)))
f.write(pkt)
def parse_packet(pkt):
"""返回 (src_ip, dst_ip, proto, src_port, dst_port) 或 None"""
if len(pkt) < 14: return None
eth_type = struct.unpack('>H', pkt[12:14])[0]
if eth_type != 0x0800: return None # 只要IPv4
# IP 头
if len(pkt) < 14 + 20: return None
ihl = (pkt[14] & 0x0f) * 4
proto = pkt[14 + 9]
if proto != 17: return None # 只要 UDP
src_ip = '.'.join(str(b) for b in pkt[14+12:14+16])
dst_ip = '.'.join(str(b) for b in pkt[14+16:14+20])
# UDP 头
udp_off = 14 + ihl
if len(pkt) < udp_off + 8: return None
src_port, dst_port = struct.unpack('>HH', pkt[udp_off:udp_off+4])
return src_ip, dst_ip, src_port, dst_port
def main():
parser = argparse.ArgumentParser()
parser.add_argument('output', help='输出pcap路径')
parser.add_argument('duration', type=float, help='时长(秒)')
parser.add_argument('--filter-host', help='只抓涉及此IP的')
parser.add_argument('--filter-port', type=int, help='只抓涉及此端口的')
parser.add_argument('--iface', default=None, help='绑定接口名(如eth0)')
args = parser.parse_args()
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETH_P_ALL))
if args.iface:
sock.bind((args.iface, 0))
print(f" 抓包接口: {args.iface}", flush=True)
else:
print(f" 抓包接口: ALL", flush=True)
sock.settimeout(0.5)
out = open(args.output, 'wb')
write_pcap_global_header(out)
end = time.time() + args.duration
n_total = 0
n_match = 0
n_ipv4 = 0
n_udp = 0
from collections import Counter
flow_counter = Counter() # 统计 UDP 流向
print(f" 抓包 {args.duration}s...", flush=True)
while time.time() < end:
try:
pkt, _ = sock.recvfrom(65535)
except socket.timeout:
continue
n_total += 1
# 粗略判断: 14字节以太网头 + IP头
if len(pkt) >= 14:
eth_type = struct.unpack('>H', pkt[12:14])[0]
if eth_type == 0x0800:
n_ipv4 += 1
if len(pkt) >= 14+20 and pkt[14+9] == 17:
n_udp += 1
info = parse_packet(pkt)
if info is None: continue
src_ip, dst_ip, sp, dp = info
flow_counter[(src_ip, sp, dst_ip, dp)] += 1
if args.filter_host:
if args.filter_host not in (src_ip, dst_ip): continue
if args.filter_port:
if args.filter_port not in (sp, dp): continue
# 每命中第一个新流就打印一下, 方便诊断
flow = (src_ip, sp, dst_ip, dp)
if not hasattr(main, '_seen'):
main._seen = set()
if flow not in main._seen:
main._seen.add(flow)
print(f" 新流: {src_ip}:{sp}{dst_ip}:{dp}", flush=True)
write_pcap_packet(out, time.time(), pkt)
n_match += 1
out.close()
sock.close()
print(f" 总包: {n_total}, IPv4: {n_ipv4}, UDP: {n_udp}, 命中: {n_match}", flush=True)
print(f" Top UDP 流 (前20):", flush=True)
for flow, cnt in flow_counter.most_common(20):
sip, spt, dip, dpt = flow
print(f" {sip}:{spt}{dip}:{dpt} ({cnt})", flush=True)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""
分析从狗上提取的Blowfish密钥dump
1. 在内存中搜索Blowfish P-array (key schedule后的运行时值)
2. 提取P-array和S-box,反推原始密钥
3. 用提取的P-array+S-box解密已知密文,验证正确性
"""
import os
import sys
import struct
import tarfile
import argparse
from pathlib import Path
# Blowfish标准初始常数 (从π推导)
P_INITIAL = [
0x243f6a88, 0x85a308d3, 0x13198a2e, 0x03707344,
0xa4093822, 0x299f31d0, 0x082efa98, 0xec4e6c89,
0x452821e6, 0x38d01377, 0xbe5466cf, 0x34e90c6c,
0xc0ac29b7, 0xc97c50dd, 0x3f84d5b5, 0xb5470917,
0x9216d5d9, 0x8979fb1b
]
S0_INITIAL_FIRST = 0xd1310ba6
S1_INITIAL_FIRST = 0x4b7a70e9
S2_INITIAL_FIRST = 0xe93d5a68
S3_INITIAL_FIRST = 0x3a39ce37
# 已知明文-密文对 (用于验证)
KNOWN_PAIRS = [
(bytes.fromhex('0000000000000000'), bytes.fromhex('12aa0354236b66e3')),
(bytes.fromhex('feefff0004020305'), bytes.fromhex('5d9874ad7eee5851')),
(bytes.fromhex('0802010000010900'), bytes.fromhex('c5ce0887ceb86f91')),
]
def find_pattern_locations(data, pattern_bytes):
"""在data中查找pattern的所有位置"""
positions = []
pos = 0
while True:
p = data.find(pattern_bytes, pos)
if p < 0:
break
positions.append(p)
pos = p + 1
return positions
def looks_like_blowfish_buffer(data, offset):
"""
检查offset处是否看起来像Blowfish state:
布局假设: P[18] + S0[256] + S1[256] + S2[256] + S3[256]
总大小: (18 + 4*256) * 4 = 4168 字节
"""
BUF_SIZE = 4168
if offset + BUF_SIZE > len(data):
return False
# 取出整个buffer
buf = data[offset:offset+BUF_SIZE]
# 解释为uint32列表
words = struct.unpack(f'<{BUF_SIZE//4}I', buf)
# 检查S-box是否包含至少部分初始值
# 运行时,P和S-box已被key schedule修改,但所有1042个值的统计特性应该接近随机
# 这里只能做粗略的"看起来像加密表"的检查
# 唯一性检查: 1042个32位值应该几乎都不同
unique = len(set(words))
if unique < 1000: # 太多重复说明不是表
return False
# 零值检查: 不应该有很多0
zeros = sum(1 for w in words if w == 0)
if zeros > 50:
return False
# 熵检查: 字节分布应该均匀
byte_counts = [0] * 256
for b in buf:
byte_counts[b] += 1
max_count = max(byte_counts)
if max_count > len(buf) // 50: # 任何字节出现频率超过2%可疑
return False
return True
def encrypt_with_state(plaintext_8, P, S0, S1, S2, S3, endian='<'):
"""
用给定的P-array + S-box加密8字节明文
endian: '<' (LE - Unitree style) 或 '>' (BE - standard)
"""
pack_fmt = endian + 'II'
L, R = struct.unpack(pack_fmt, plaintext_8)
for i in range(16):
L ^= P[i]
# F(L)
a = (L >> 24) & 0xff
b = (L >> 16) & 0xff
c = (L >> 8) & 0xff
d = L & 0xff
F = (((S0[a] + S1[b]) & 0xFFFFFFFF) ^ S2[c]) & 0xFFFFFFFF
F = (F + S3[d]) & 0xFFFFFFFF
R ^= F
L, R = R, L
L, R = R, L
R ^= P[16]
L ^= P[17]
return struct.pack(pack_fmt, L, R)
def try_buffer_as_blowfish(buf_data, label="?"):
"""
把一个4168字节的buffer解释为Blowfish state
用已知明密文对验证
"""
if len(buf_data) < 4168:
return None
# 假设布局1: P[0..17] + S0[0..255] + S1[0..255] + S2[0..255] + S3[0..255]
words = struct.unpack('<1042I', buf_data[:4168])
P = list(words[0:18])
S0 = list(words[18:18+256])
S1 = list(words[18+256:18+512])
S2 = list(words[18+512:18+768])
S3 = list(words[18+768:18+1024])
# 测试已知对 - 同时尝试LE和BE两种字节序
best_matches = 0
best_endian = None
for endian in ['<', '>']:
matches = 0
for pt, ct_expected in KNOWN_PAIRS:
try:
ct = encrypt_with_state(pt, P, S0, S1, S2, S3, endian=endian)
if ct == ct_expected:
matches += 1
except Exception:
pass
if matches > best_matches:
best_matches = matches
best_endian = endian
return best_matches, best_endian
def search_blowfish_state(data, source_name, verbose=False, save_candidates=False):
"""在内存dump中搜索Blowfish状态"""
print(f"\n=== 在 {source_name} 中搜索 Blowfish state ===")
print(f" 大小: {len(data)} 字节")
candidates_looking_like_bf = []
# 策略1: 找S-box[0]首字 (运行时S-box会被修改,但偶尔可能未变)
# 实际上key schedule后S-box几乎肯定变化,所以这条不会找到
s0_pos = find_pattern_locations(data, struct.pack('<I', S0_INITIAL_FIRST))
s1_pos = find_pattern_locations(data, struct.pack('<I', S1_INITIAL_FIRST))
print(f" S0初始首字位置数: {len(s0_pos)}")
print(f" S1初始首字位置数: {len(s1_pos)}")
if s0_pos:
# 这些是静态初始值,在二进制中也有 (说明是从binary mmap过来的)
for p in s0_pos[:3]:
print(f" 初始S0@0x{p:x}")
# 策略2: 暴力搜索 - 假设 4168字节连续Blowfish state
# 每4字节对齐位置都试
print(f" 暴力搜索 Blowfish state (4字节对齐)...")
found = []
STEP = 4
count = 0
total = (len(data) - 4168) // STEP
for offset in range(0, len(data) - 4168, STEP):
count += 1
if count % 50000 == 0:
print(f" 进度: {count}/{total} ({100*count/total:.1f}%)")
# 快速过滤: 必须looks_like
if not looks_like_blowfish_buffer(data, offset):
continue
candidates_looking_like_bf.append(offset)
if save_candidates and len(candidates_looking_like_bf) <= 20:
cand_path = f'data/lowlevel_captures/candidate_{source_name}_{offset:08x}.bin'
os.makedirs('data/lowlevel_captures', exist_ok=True)
with open(cand_path, 'wb') as fp:
fp.write(data[offset:offset+4168])
print(f" 💾 保存候选 -> {cand_path}")
result = try_buffer_as_blowfish(data[offset:offset+4168])
if result is None:
continue
matches, endian = result
if matches and matches > 0:
endian_name = 'LE' if endian == '<' else 'BE'
print(f"\n ⭐ offset 0x{offset:x}: {matches}/3 已知对匹配! ({endian_name})")
found.append((offset, matches, endian))
if matches == 3:
return found, data[offset:offset+4168]
if candidates_looking_like_bf:
print(f" 共找到 {len(candidates_looking_like_bf)} 个通过启发式过滤的候选位置")
return found, None
def extract_key_from_state(state_buf):
"""从运行时P-array+S-box反推原始密钥 (这是Blowfish key schedule的逆过程)
技术: Blowfish key schedule是
1. P[i] = P_INITIAL[i] ^ key_word(i % keylen)
2. 用此P和初始S-box加密 (0,0), 用结果替换P[0..1]
3. 继续加密前一次结果,替换P[2..3], 直到P[16..17]
4. 同样替换S-box
要反推key: 由于步骤2-4都是确定性的,只要知道key就能复现
而key只在步骤1出现, P[i] XOR P_INITIAL[i] = key_word(i % keylen)
"""
words = struct.unpack('<1042I', state_buf[:4168])
P_runtime = list(words[0:18])
# 但是! P-array在步骤2-4也被修改了, 所以不能直接 P XOR P_INITIAL
# 实际上, 步骤1后的P[i]立即在步骤2中被覆盖, 我们看到的是覆盖后的值
# 这意味着无法从最终state直接反推key
# 但我们已经有了完整的state, 不需要key就能加密/解密!
print("\n=== 提取的密钥state ===")
print(f"P-array (18 uint32):")
for i in range(18):
print(f" P[{i:2d}] = 0x{P_runtime[i]:08x}")
return P_runtime
def main():
parser = argparse.ArgumentParser()
parser.add_argument('input', help='blowfish_dump.tar.gz 或 包含.bin文件的目录')
parser.add_argument('--verbose', '-v', action='store_true')
parser.add_argument('--save-candidates', action='store_true',
help='保存所有"看起来像Blowfish state"的候选 (即使不能验证已知对)')
args = parser.parse_args()
input_path = Path(args.input)
# 提取/找到所有bin文件
bin_files = []
if input_path.is_file() and input_path.suffix == '.gz':
# tar.gz - 解压到临时目录
import tempfile
tmpdir = tempfile.mkdtemp(prefix='blowfish_analyze_')
print(f"解压到 {tmpdir}...")
with tarfile.open(input_path) as tf:
tf.extractall(tmpdir)
for f in Path(tmpdir).rglob('*.bin'):
bin_files.append(f)
elif input_path.is_dir():
for f in input_path.rglob('*.bin'):
bin_files.append(f)
else:
print(f"❌ 不支持的输入: {input_path}")
return 1
print(f"找到 {len(bin_files)} 个 .bin 文件")
for f in bin_files:
size = f.stat().st_size
print(f" {f.name} ({size} 字节)")
# 按大小排序,先搜索小的 (更快)
bin_files.sort(key=lambda f: f.stat().st_size)
found_state = None
for f in bin_files:
with open(f, 'rb') as fp:
data = fp.read()
results, state = search_blowfish_state(data, f.name, verbose=args.verbose, save_candidates=args.save_candidates)
if state is not None:
found_state = state
print(f"\n🎉 在 {f.name} 中找到完整匹配!")
break
elif results:
print(f"\n⚠️ 在 {f.name} 中找到 {len(results)} 个部分匹配位置")
if found_state:
P = extract_key_from_state(found_state)
# 保存
outpath = 'data/lowlevel_captures/blowfish_state.bin'
os.makedirs('data/lowlevel_captures', exist_ok=True)
with open(outpath, 'wb') as fp:
fp.write(found_state)
print(f"\n✅ Blowfish state已保存到 {outpath}")
print(f" 可以用此state直接加密/解密通信数据")
return 0
else:
print("\n❌ 未找到完整匹配的Blowfish state")
print(" 可能原因:")
print(" 1. 状态不在已dump的内存区域")
print(" 2. 状态被分散存储 (不连续)")
print(" 3. dump时机不对 (state还未初始化)")
return 1
if __name__ == '__main__':
sys.exit(main())

99
tools/calibrate_joints.py Normal file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""关节活动范围校准 - 手动转动法.
流程:
1. 狗悬空 + L2+B damping
2. 跑这个脚本
3. 慢慢把每条腿的每个关节转到极限两端 (软力, 听到阻力就停)
4. 输出每个关节实测的 min/max/range
"""
import argparse
import os
import signal
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from go1_pro_sdk import MCUClient, LowCmd, JOINT_NAMES
from go1_pro_sdk.utils.constants import JOINT_LIMITS_URDF
running = True
def sigint(s, f):
global running
running = False
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--state', default=None)
parser.add_argument('--duration', type=float, default=60)
args = parser.parse_args()
signal.signal(signal.SIGINT, sigint)
print('🦾 关节活动范围校准')
print(f' 采样时长: {args.duration}s')
print()
print('操作:')
print(' 1. 狗悬空, L2+B damping (电机失能)')
print(' 2. 按顺序逐条腿/逐个关节转到极限')
print(' 3. Ctrl+C 提前结束')
input('回车开始采样...')
with MCUClient(state_path=args.state) as client:
recv = client.wake_mcu(50)
if recv == 0:
print('❌ 无回包')
return 1
damping = LowCmd().all_damping()
q_min = [float('inf')] * 12
q_max = [float('-inf')] * 12
start = time.time()
last_print = 0
n_decoded = 0
while running and time.time() - start < args.duration:
client.send(damping)
time.sleep(0.01)
state = client.recv_latest()
if state is None:
continue
n_decoded += 1
for i in range(12):
q = state.motorState[i].q
if q < q_min[i]: q_min[i] = q
if q > q_max[i]: q_max[i] = q
now = time.time()
if now - last_print >= 2.0:
print(f'\r{now-start:5.1f}/{args.duration:.0f}s 解码 {n_decoded}',
end='', flush=True)
last_print = now
print('\n\n实测范围 vs URDF 标称:')
print('-' * 80)
print(f'{"关节":8} {"实测min":>9} {"实测max":>9} {"范围":>8} '
f'{"URDF min":>9} {"URDF max":>9}')
for i, name in enumerate(JOINT_NAMES):
jt = ('hip' if i % 3 == 0 else 'thigh' if i % 3 == 1 else 'knee')
urdf_lo, urdf_hi = JOINT_LIMITS_URDF[jt]
lo, hi = q_min[i], q_max[i]
if lo == float('inf'):
print(f'{name:8} {"未采到":>9}')
continue
print(f'{name:8} {lo:+9.4f} {hi:+9.4f} {hi-lo:8.4f} '
f'{urdf_lo:+9.4f} {urdf_hi:+9.4f}')
print('\nJOINT_LIMITS_MEASURED = {')
for i, name in enumerate(JOINT_NAMES):
if q_min[i] == float('inf'): continue
print(f" '{name}': ({q_min[i]:+.4f}, {q_max[i]:+.4f}),")
print('}')
if __name__ == '__main__':
sys.exit(main() or 0)

73
tools/capture_lowcmd.sh Normal file
View File

@@ -0,0 +1,73 @@
#!/bin/bash
# 抓 Legged_sport 发给 MCU :8007 的 LowCmd UDP 包, 拉回本地解析
#
# 用法: bash tools/onboard/capture_lowcmd.sh [seconds]
#
# 流程:
# 1. SSH 到狗启动 tcpdump 抓包 (持续N秒)
# 2. 拉回 pcap 到本地
# 3. 用 Python 解析 pcap, 提取 LowCmd 字节
# 4. 跟本地 buildCmd() 的输出对比
PI_HOST="${PI_HOST:-192.168.123.161}"
PI_USER="${PI_USER:-pi}"
DURATION="${1:-5}" # 抓包秒数
PCAP_REMOTE=/tmp/legged_sport.pcap
PCAP_LOCAL=data/lowlevel_captures/legged_sport_$(date +%Y%m%d_%H%M%S).pcap
echo "============================================"
echo "🔍 抓 Legged_sport → MCU 的 LowCmd 包"
echo "============================================"
echo " 抓包时长: ${DURATION}s"
echo " 远端 pcap: ${PCAP_REMOTE}"
echo " 本地保存: ${PCAP_LOCAL}"
echo ""
# 先确认 Legged_sport 在运行 (否则抓不到包)
echo "[1/4] 确认 Legged_sport 在运行..."
if ssh ${PI_USER}@${PI_HOST} "pgrep Legged_sport > /dev/null 2>&1"; then
echo " ✅ Legged_sport 在运行"
else
echo " ⚠️ Legged_sport 未运行! 先恢复 sportMode:"
echo " bash tools/onboard/stop_sportmode.sh start"
exit 1
fi
# 抓包: 用 tcpdump (已在 Pi 上安装)
echo ""
echo "[2/4] 启动 tcpdump 抓包 (${DURATION}s, eth0)..."
ssh ${PI_USER}@${PI_HOST} bash -s <<EOF || true
set +e
sudo rm -f ${PCAP_REMOTE}
sudo timeout ${DURATION} /usr/sbin/tcpdump -i eth0 -w ${PCAP_REMOTE} -s 0 -n \\
'udp and host 192.168.123.10 and port 8007' 2>/tmp/tcpdump.log
echo "tcpdump 退出码: \$?"
if [ -f ${PCAP_REMOTE} ]; then
sudo chmod 644 ${PCAP_REMOTE}
echo "pcap 大小: \$(stat -c%s ${PCAP_REMOTE}) 字节"
cat /tmp/tcpdump.log
else
echo "❌ pcap 未生成!"
cat /tmp/tcpdump.log
exit 1
fi
EOF
echo " ✅ 抓包完成"
# 拉回本地
echo ""
echo "[3/4] 拉回本地..."
mkdir -p $(dirname ${PCAP_LOCAL})
if ! scp ${PI_USER}@${PI_HOST}:${PCAP_REMOTE} ${PCAP_LOCAL}; then
echo "❌ 拉取失败"
exit 1
fi
echo "${PCAP_LOCAL} ($(stat -f%z ${PCAP_LOCAL} 2>/dev/null || stat -c%s ${PCAP_LOCAL}) 字节)"
# 解析+对比
echo ""
echo "[4/4] 解析+对比..."
conda run -n free_dog_sdk python tools/onboard/diff_lowcmd.py ${PCAP_LOCAL}

76
tools/diff_real_lowcmd.sh Normal file
View File

@@ -0,0 +1,76 @@
#!/bin/bash
# 抓 Legged_sport 真实 LowCmd → 解密 → 跟本地脚本生成的对比
#
# 前提: Legged_sport 必须在跑且正常向 MCU 发包 (狗在工作状态, 不能是idle)
# 用法: bash tools/onboard/diff_real_lowcmd.sh [seconds]
#
# 输出:
# data/lowlevel_captures/real_lowcmd_<ts>.pcap - 抓的原包
# data/lowlevel_captures/real_lowcmd_<ts>.bin - 一帧密文 (616B)
# data/lowlevel_captures/real_lowcmd_dec_<ts>.bin - 解密后 (616B)
# 字节级 diff 输出到屏幕
PI_HOST="${PI_HOST:-192.168.123.161}"
PI_USER="${PI_USER:-pi}"
DURATION="${1:-3}"
TS=$(date +%Y%m%d_%H%M%S)
PCAP_REMOTE=/tmp/real_lowcmd.pcap
PCAP_LOCAL=data/lowlevel_captures/real_lowcmd_${TS}.pcap
CIPHER_BIN=data/lowlevel_captures/real_lowcmd_${TS}.bin
PLAIN_BIN=data/lowlevel_captures/real_lowcmd_dec_${TS}.bin
mkdir -p $(dirname ${PCAP_LOCAL})
echo "============================================"
echo "🔍 抓真实 LowCmd 跟本地对比"
echo "============================================"
echo ""
# 0. 确认 Legged_sport 在发包 (而不是 idle)
echo "[0/4] 确认 Legged_sport 在向 MCU 发包..."
COUNT=$(ssh ${PI_USER}@${PI_HOST} \
"sudo timeout 1 /usr/sbin/tcpdump -i any -nn -c 5 'udp and dst host 192.168.123.10 and dst port 8007' 2>/dev/null | grep -c 'IP 192' || true")
if [ "$COUNT" -lt 1 ]; then
echo " ❌ Legged_sport 没在发 LowCmd!"
echo " 可能原因:"
echo " - 狗在 idle/damping 待机, 需要遥控器唤醒 (L2+A 让它站起来)"
echo " - MCU 被其他客户端 (我们 Mac 的脚本) 抢占了"
echo " - sportMode 服务挂了"
exit 1
fi
echo " ✅ 检测到 ${COUNT} 个发往 MCU 的包"
echo ""
# 1. 抓包
echo "[1/4] tcpdump 抓 ${DURATION}s ..."
ssh ${PI_USER}@${PI_HOST} bash -s <<EOF
set +e
sudo rm -f ${PCAP_REMOTE}
sudo timeout ${DURATION} /usr/sbin/tcpdump -i any -w ${PCAP_REMOTE} -s 0 -n \
'udp and dst host 192.168.123.10 and dst port 8007' 2>/tmp/tcpdump.log
sudo chmod 644 ${PCAP_REMOTE}
echo "pcap 大小: \$(stat -c%s ${PCAP_REMOTE}) 字节"
EOF
# 2. 拉回
echo ""
echo "[2/4] 拉回本地..."
scp ${PI_USER}@${PI_HOST}:${PCAP_REMOTE} ${PCAP_LOCAL}
echo "${PCAP_LOCAL}"
# 3. 解析+解密+对比
echo ""
echo "[3/4] 解析+解密..."
conda run -n free_dog_sdk python tools/onboard/_compare_lowcmd.py \
${PCAP_LOCAL} \
--cipher-out ${CIPHER_BIN} \
--plain-out ${PLAIN_BIN}
echo ""
echo "============================================"
echo "完成. 文件:"
echo " pcap: ${PCAP_LOCAL}"
echo " 密文: ${CIPHER_BIN}"
echo " 明文: ${PLAIN_BIN}"
echo "============================================"

View File

@@ -0,0 +1,208 @@
#!/bin/bash
# Blowfish密钥提取脚本 - 在树莓派上以root运行
# 用法: sudo bash extract_blowfish_key.sh [PID]
#
# 安全保证:
# - 只读取内存,不修改任何寄存器或内存
# - GDB attach时间最短化 (<30秒)
# - 自动detach,即使中途异常
set -e
PID=${1:-$(pgrep -f Legged_sport | head -1)}
OUTDIR=/tmp/blowfish_dump_$$
mkdir -p $OUTDIR
if [ -z "$PID" ]; then
echo "❌ 找不到 Legged_sport 进程"
exit 1
fi
# 检查PID存在
if [ ! -d "/proc/$PID" ]; then
echo "❌ PID $PID 不存在"
exit 1
fi
echo "============================================"
echo "🐕 Blowfish密钥提取"
echo "============================================"
echo "PID: $PID"
echo "输出: $OUTDIR"
echo ""
# 检查当前狗的状态 (通过/tmp下的状态文件,如果有的话)
# 提示用户确认安全状态
echo "⚠️ 确认狗的状态:"
echo " - 趴下(damping)或在脚架上"
echo " - 当前是高级模式 (未在执行运动指令)"
echo ""
read -p "确认安全状态? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
echo "❌ 用户取消"
rm -rf $OUTDIR
exit 1
fi
# ===== 第1步: 保存进程内存映射 =====
echo ""
echo "[1/5] 保存进程内存映射..."
cat /proc/$PID/maps > $OUTDIR/maps.txt
echo " ✅ /proc/$PID/maps -> $OUTDIR/maps.txt"
# 找出代码段范围 (anonymous r-x 段,是UPX解压后的代码)
ANON_RX=$(awk '/r-xp/ && !/\// {print $1}' $OUTDIR/maps.txt | head -1)
if [ -n "$ANON_RX" ]; then
ANON_RX_START=$(echo $ANON_RX | cut -d- -f1)
ANON_RX_END=$(echo $ANON_RX | cut -d- -f2)
echo " 📌 匿名代码段(UPX解压): 0x$ANON_RX_START - 0x$ANON_RX_END"
fi
# 找堆段
HEAP=$(awk '/\[heap\]/ {print $1}' $OUTDIR/maps.txt | head -1)
if [ -n "$HEAP" ]; then
HEAP_START=$(echo $HEAP | cut -d- -f1)
HEAP_END=$(echo $HEAP | cut -d- -f2)
echo " 📌 堆: 0x$HEAP_START - 0x$HEAP_END"
fi
# ===== 第2步: 准备GDB脚本 (只读,不修改) =====
echo ""
echo "[2/5] 生成GDB脚本..."
cat > $OUTDIR/gdb_script.gdb <<'GDBSCRIPT'
# GDB脚本: 只读模式提取Blowfish密钥
# 严格规定: 不修改任何寄存器、不写入任何内存、不调用任何函数
set pagination off
set print pretty on
set logging redirect on
set logging overwrite on
# 1. 进程基本信息
printf "=== 进程信息 ===\n"
info proc mappings
printf "\n=== 寄存器状态 ===\n"
info registers
printf "\n=== 调用栈 ===\n"
bt 5
# 2. 不设断点,只dump关键内存
# 完成
printf "\n=== 完成,准备detach ===\n"
GDBSCRIPT
echo "$OUTDIR/gdb_script.gdb"
# ===== 第3步: GDB attach (最短时间) =====
echo ""
echo "[3/5] GDB attach (目标<5秒)..."
START_TS=$(date +%s)
# 用-batch模式,自动执行后立即退出
timeout 30 gdb -batch \
-p $PID \
-x $OUTDIR/gdb_script.gdb \
> $OUTDIR/gdb_output.txt 2>&1
END_TS=$(date +%s)
ELAPSED=$((END_TS - START_TS))
echo " ✅ GDB完成,耗时 ${ELAPSED}s"
# 检查狗是否还在线
if ! kill -0 $PID 2>/dev/null; then
echo " ⚠️ 警告: Legged_sport进程消失!"
fi
# ===== 第4步: dump堆内存 (用/proc/PID/mem直接读取,比GDB快) =====
echo ""
echo "[4/5] 直接读取堆内存 (不用GDB)..."
if [ -n "$HEAP_START" ]; then
HEAP_SIZE=$((0x$HEAP_END - 0x$HEAP_START))
HEAP_OFFSET=$((0x$HEAP_START))
echo " 读取堆 ${HEAP_SIZE} 字节..."
# 用dd直接从/proc/$PID/mem读 (bs=65536大块读取,比bs=1快1000x)
dd if=/proc/$PID/mem of=$OUTDIR/heap.bin \
bs=65536 count=$HEAP_SIZE skip=$HEAP_OFFSET \
iflag=skip_bytes,count_bytes 2>/dev/null || \
echo " ⚠️ /proc/mem读取失败,可能需要ptrace"
if [ -f $OUTDIR/heap.bin ]; then
HEAPACTUAL=$(stat -c%s $OUTDIR/heap.bin)
echo " ✅ heap.bin ($HEAPACTUAL 字节)"
fi
fi
# 也dump匿名代码段 (UPX解压后的代码,含运行时S-box)
if [ -n "$ANON_RX_START" ]; then
RX_SIZE=$((0x$ANON_RX_END - 0x$ANON_RX_START))
RX_OFFSET=$((0x$ANON_RX_START))
echo " 读取匿名代码段 ${RX_SIZE} 字节..."
dd if=/proc/$PID/mem of=$OUTDIR/anon_code.bin \
bs=65536 count=$RX_SIZE skip=$RX_OFFSET \
iflag=skip_bytes,count_bytes 2>/dev/null
if [ -f $OUTDIR/anon_code.bin ]; then
CODEACTUAL=$(stat -c%s $OUTDIR/anon_code.bin)
echo " ✅ anon_code.bin ($CODEACTUAL 字节)"
fi
fi
# 也dump所有匿名rw段 (含运行时S-box和P-array)
echo " 读取所有匿名rw段..."
RW_COUNT=0
awk '/rw-p/ && !/\// && !/\[stack\]/ && !/\[vvar\]/ {print $1}' $OUTDIR/maps.txt | while read range; do
START=$(echo $range | cut -d- -f1)
END=$(echo $range | cut -d- -f2)
SIZE=$((0x$END - 0x$START))
OFFSET=$((0x$START))
# 只dump合理大小的段 (1KB - 64MB)
if [ $SIZE -gt 1024 ] && [ $SIZE -lt 67108864 ]; then
OUTFILE=$OUTDIR/rw_${START}.bin
dd if=/proc/$PID/mem of=$OUTFILE \
bs=65536 count=$SIZE skip=$OFFSET \
iflag=skip_bytes,count_bytes 2>/dev/null
if [ -f $OUTFILE ] && [ -s $OUTFILE ]; then
echo " rw_${START}.bin ($SIZE 字节)"
RW_COUNT=$((RW_COUNT + 1))
else
rm -f $OUTFILE
fi
fi
done
# ===== 第5步: 打包 =====
echo ""
echo "[5/5] 打包输出..."
cd /tmp
TARBALL=blowfish_dump_$(date +%Y%m%d_%H%M%S).tar.gz
tar czf $TARBALL -C $OUTDIR .
mv $TARBALL /tmp/blowfish_dump.tar.gz
echo " ✅ /tmp/blowfish_dump.tar.gz ($(stat -c%s /tmp/blowfish_dump.tar.gz) 字节)"
# 检查狗是否还在线
echo ""
if kill -0 $PID 2>/dev/null; then
echo "✅ Legged_sport仍在运行 (PID $PID)"
else
echo "❌ Legged_sport已退出!"
fi
echo ""
echo "============================================"
echo "完成!"
echo "============================================"
echo "数据位置: /tmp/blowfish_dump.tar.gz"
echo "中间文件: $OUTDIR/"
echo ""
echo "从Mac拉取:"
echo " scp pi@192.168.123.161:/tmp/blowfish_dump.tar.gz ./data/lowlevel_captures/"

83
tools/run_on_mac.sh Normal file
View File

@@ -0,0 +1,83 @@
#!/bin/bash
# 在 Mac 上直连 MCU 跑控制 (需要先停 Pi 上的抢占源)
#
# 用法:
# bash tools/onboard/run_on_mac.sh example # 跑 example_position_pro.py
# bash tools/onboard/run_on_mac.sh sin # 跑 test_sin_leg.py
# bash tools/onboard/run_on_mac.sh monitor # 只读监听
# bash tools/onboard/run_on_mac.sh stop # 只停 Pi 上的抢占源
# bash tools/onboard/run_on_mac.sh start # 恢复 sportMode
PI_HOST="${PI_HOST:-192.168.123.161}"
PI_USER="${PI_USER:-pi}"
TEST="${1:-example}"
stop_competitors() {
echo "=== 停 Pi 上的 MCU:8007 抢占源 ==="
ssh ${PI_USER}@${PI_HOST} bash <<'EOF' || true
set +e
if sudo pkill -9 -f keep_sport_alive 2>/dev/null; then echo ' ✅ keep_sport_alive.sh'; else echo ' (无看门狗)'; fi
sleep 0.3
if sudo pkill -9 -f Legged_sport 2>/dev/null; then echo ' ✅ Legged_sport'; else echo ' (无)'; fi
if sudo pkill -9 -f appTransit 2>/dev/null; then echo ' ✅ appTransit'; else echo ' (无)'; fi
sleep 0.5
echo '--- 验证残留 ---'
if pgrep -fl 'Legged_sport|appTransit|keep_sport_alive' 2>/dev/null; then
echo ' ⚠️ 仍有残留'
else
echo ' ✅ 全部清除'
fi
EOF
}
restart_sport() {
echo "=== 恢复 sportMode (Pi 端 watchdog 会拉起 Legged_sport) ==="
ssh ${PI_USER}@${PI_HOST} \
"sudo bash -c 'nohup /home/pi/Unitree/autostart/sportMode/keep_sport_alive.sh > /dev/null 2>&1 &' 2>/dev/null || \
sudo systemctl start sportMode 2>/dev/null || \
echo '⚠️ 请手动重启或重启狗'"
sleep 2
ssh ${PI_USER}@${PI_HOST} "pgrep -fl Legged_sport || echo '⚠️ Legged_sport 未运行'"
}
case "$TEST" in
stop)
stop_competitors
;;
start)
restart_sport
;;
example)
stop_competitors
echo ""
echo "=== Mac 直连跑 example_position_pro.py ==="
conda run -n free_dog_sdk python tools/onboard/example_position_pro.py \
--state data/lowlevel_captures/blowfish_state.bin \
--max-steps 5000
;;
sin)
stop_competitors
echo ""
echo "=== Mac 直连跑 test_sin_leg.py ==="
conda run -n free_dog_sdk python tools/onboard/test_sin_leg.py \
--amplitude 0.3 --freq 0.5 --duration 10
;;
monitor)
stop_competitors
echo ""
echo "=== Mac 直连只读监听 30 秒 ==="
conda run -n free_dog_sdk python tools/onboard/monitor_state.py \
--state data/lowlevel_captures/blowfish_state.bin \
--duration 30 --verbose
;;
*)
echo "用法: bash run_on_mac.sh [example|sin|monitor|stop|start]"
exit 1
;;
esac
echo ""
echo "============================================"
echo "测试结束. 恢复 sportMode:"
echo " bash tools/onboard/run_on_mac.sh start"
echo "============================================"

104
tools/run_on_pi.sh Normal file
View File

@@ -0,0 +1,104 @@
#!/bin/bash
# 部署+执行测试 (在树莓派上, 源IP=192.168.123.161)
#
# 用法:
# bash tools/onboard/run_on_pi.sh sin # 单腿正弦
# bash tools/onboard/run_on_pi.sh damping # 零力矩连通性
# bash tools/onboard/run_on_pi.sh deploy # 仅部署不执行
set -e
PI_HOST="${PI_HOST:-192.168.123.161}"
PI_USER="${PI_USER:-pi}"
TEST="${1:-sin}"
REMOTE_DIR=/tmp/dog_test
echo "=== 部署到 ${PI_USER}@${PI_HOST}:${REMOTE_DIR} ==="
# 1. 创建远程目录结构
ssh ${PI_USER}@${PI_HOST} "rm -rf ${REMOTE_DIR} && mkdir -p ${REMOTE_DIR}/ucl"
# 2. 拷贝文件 (所有.py平铺在REMOTE_DIR, ucl/子目录放SDK文件)
echo "[1/2] 拷贝文件..."
scp tools/onboard/blowfish_codec.py \
tools/onboard/build_pro_lowcmd.py \
tools/onboard/test_sin_leg.py \
tools/onboard/test_lowcmd.py \
tools/onboard/example_position_pro.py \
data/lowlevel_captures/blowfish_state.bin \
${PI_USER}@${PI_HOST}:${REMOTE_DIR}/
# 空 __init__.py 让 ucl 成为 package
ssh ${PI_USER}@${PI_HOST} "touch ${REMOTE_DIR}/ucl/__init__.py"
scp ucl/lowCmd.py ucl/lowState.py ucl/common.py ucl/complex.py ucl/enums.py \
${PI_USER}@${PI_HOST}:${REMOTE_DIR}/ucl/
# 提前杀掉 MCU:8007 的所有抢占者:
# 1. keep_sport_alive.sh - Legged_sport 看门狗 (每0.2秒重启Legged_sport, 必须先杀)
# 2. Legged_sport - 主控制循环
# 3. appTransit - 也跟 MCU:8007 通信
echo ""
echo "=== 停止 MCU:8007 抢占源 (顺序: 看门狗 → Legged_sport → appTransit) ==="
ssh ${PI_USER}@${PI_HOST} bash <<'EOF' || true
set +e
if sudo pkill -9 -f keep_sport_alive 2>/dev/null; then
echo ' ✅ keep_sport_alive.sh (看门狗)'
else
echo ' (无看门狗)'
fi
sleep 0.3
if sudo pkill -9 -f Legged_sport 2>/dev/null; then
echo ' ✅ Legged_sport'
else
echo ' (无)'
fi
if sudo pkill -9 -f appTransit 2>/dev/null; then
echo ' ✅ appTransit'
else
echo ' (无)'
fi
sleep 0.5
echo '--- 验证残留: ---'
if pgrep -fl 'Legged_sport|appTransit|keep_sport_alive' 2>/dev/null; then
echo ' ⚠️ 仍有残留, 可能看门狗在别处'
else
echo ' ✅ 全部清除'
fi
EOF
sleep 1
echo ""
echo "[2/2] 执行测试..."
case "$TEST" in
sin)
echo "=== 单腿正弦测试 (Pi端) ==="
ssh -t ${PI_USER}@${PI_HOST} \
"cd ${REMOTE_DIR} && python3 test_sin_leg.py --amplitude 0.3 --freq 0.5 --duration 10"
;;
damping)
echo "=== 零力矩连通性测试 (Pi端) ==="
ssh -t ${PI_USER}@${PI_HOST} \
"cd ${REMOTE_DIR} && python3 test_lowcmd.py --skip-stop --skip-mqtt"
;;
example)
echo "=== example_position(lowlevel) PRO 版本 (Pi端) ==="
ssh -t ${PI_USER}@${PI_HOST} \
"cd ${REMOTE_DIR} && python3 example_position_pro.py --max-steps 5000"
;;
deploy)
echo "✅ 已部署到 ${PI_USER}@${PI_HOST}:${REMOTE_DIR}"
echo " ssh ${PI_USER}@${PI_HOST}"
echo " cd ${REMOTE_DIR} && python3 test_sin_leg.py ..."
;;
*)
echo "用法: bash run_on_pi.sh [sin|damping|example|deploy]"
exit 1
;;
esac
echo ""
echo "============================================"
echo "恢复 sportMode:"
echo " bash tools/onboard/stop_sportmode.sh start"
echo " 或 ssh ${PI_USER}@${PI_HOST} sudo systemctl start sportMode"
echo "============================================"

121
tools/stop_sportmode.sh Executable file
View File

@@ -0,0 +1,121 @@
#!/bin/bash
# 停止/启动 sportMode 服务 (从Mac SSH到狗执行)
# 用法:
# bash tools/onboard/stop_sportmode.sh stop # 停止sportMode,进入低层控制
# bash tools/onboard/stop_sportmode.sh start # 重新启动sportMode
# bash tools/onboard/stop_sportmode.sh status # 检查状态
set -e
PI_HOST="${PI_HOST:-192.168.123.161}"
PI_USER="${PI_USER:-pi}"
# 等待用户按键继续的 pause 函数
pause() {
echo ""
read -p "按 Enter 继续..." dummy
}
cmd="${1:-status}"
case "$cmd" in
stop)
echo "============================================"
echo "⚠️ 停止 sportMode — 进入低层控制"
echo "============================================"
echo ""
echo "这将会:"
echo " 1. 先发 damping 让电机失能"
echo " 2. 停止 Legged_sport 进程"
echo " 3. 停止后, :8007 将只接受你的 LowCmd"
echo ""
echo "⚠️ 停止后狗会软瘫, 确保:"
echo " - 狗趴在地上或脚架上"
echo " - 遥控器在手边 (L2+B 紧急停止)"
echo " - 10分钟内必须重启 (安全看门狗)"
echo ""
read -p "确认停止? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
echo "取消"
exit 0
fi
echo ""
echo "[1/3] 发送 damping ..."
ssh ${PI_USER}@${PI_HOST} \
"mosquitto_pub -h localhost -t controller/action -m damping" 2>/dev/null || \
echo " (MQTT可能不可用, 继续...)"
sleep 2
echo "[2/3] 停止 Legged_sport 看门狗 + Legged_sport + appTransit ..."
# 必须按顺序: 先杀看门狗 keep_sport_alive.sh, 否则它每 0.2s 重拉 Legged_sport
# 同时杀 appTransit, 它也跟 MCU:8007 通信抢占
ssh ${PI_USER}@${PI_HOST} \
"sudo pkill -9 -f keep_sport_alive 2>/dev/null && echo ' ✅ keep_sport_alive (看门狗)' || echo ' (无看门狗)';
sudo pkill -9 -f Legged_sport 2>/dev/null && echo ' ✅ Legged_sport' || echo ' (无)';
sudo pkill -9 -f appTransit 2>/dev/null && echo ' ✅ appTransit' || echo ' (无)';
sudo systemctl stop sportMode 2>/dev/null || true"
sleep 1
echo "[3/3] 验证..."
if ssh ${PI_USER}@${PI_HOST} "! pgrep Legged_sport > /dev/null 2>&1"; then
echo " ✅ Legged_sport 已完全停止"
elif ssh ${PI_USER}@${PI_HOST} "pgrep Legged_sport > /dev/null 2>&1"; then
echo " ⚠️ Legged_sport 可能仍在运行(被STOP暂停)"
fi
echo ""
echo "============================================"
echo "✅ sportMode 已停止, 现在可以做低层控制测试"
echo ""
echo "恢复: bash tools/onboard/stop_sportmode.sh start"
echo "============================================"
;;
start)
echo "============================================"
echo "🔄 恢复 sportMode"
echo "============================================"
echo ""
echo "[1/2] 恢复 Legged_sport ..."
ssh ${PI_USER}@${PI_HOST} \
"sudo systemctl restart sportMode 2>/dev/null || sudo systemctl restart legged_sport 2>/dev/null || (sudo killall -CONT Legged_sport 2>/dev/null) || echo '尝试手动启动'" && \
echo " ✅ 重启命令已执行" || \
echo " ⚠️ 重启失败, 尝试: sudo systemctl start sportMode 或重启狗"
sleep 3
echo "[2/2] 验证..."
if ssh ${PI_USER}@${PI_HOST} "pgrep Legged_sport > /dev/null 2>&1"; then
echo " ✅ Legged_sport 已恢复运行"
else
echo " ⚠️ Legged_sport 未运行, 可能需要重启狗"
fi
echo ""
echo "✅ sportMode 已恢复"
;;
status)
echo "检查 sportMode 状态..."
echo ""
echo "--- Legged_sport 进程 ---"
ssh ${PI_USER}@${PI_HOST} "ps aux | grep -v grep | grep Legged_sport || echo ' 未运行'"
echo ""
echo "--- systemd 服务 ---"
ssh ${PI_USER}@${PI_HOST} \
"systemctl status sportMode 2>/dev/null || systemctl status Legged_sport 2>/dev/null || echo ' 服务名未知'" 2>/dev/null | head -10
echo ""
echo "--- MQTT 状态 ---"
ssh ${PI_USER}@${PI_HOST} \
"mosquitto_sub -h localhost -t programming/action -C 1 -W 2 2>/dev/null || echo ' (无消息/timeout)'"
;;
*)
echo "用法: bash stop_sportmode.sh [stop|start|status]"
exit 1
;;
esac