python部分官方化对齐接口
This commit is contained in:
23
README.md
23
README.md
@@ -16,6 +16,29 @@ Unitree Go1 **PRO** 机型的低层电机控制 Python SDK。完整逆向 PRO
|
||||
|
||||
## 快速上手
|
||||
|
||||
### 官方 Python SDK 兼容接口
|
||||
|
||||
需要复用官方 `unitree_legged_sdk` Python 示例时,可以继续使用原来的模块名和
|
||||
调用顺序:
|
||||
|
||||
```python
|
||||
import robot_interface as sdk
|
||||
|
||||
udp = sdk.UDP(sdk.LOWLEVEL, 8080, "192.168.123.10", 8007)
|
||||
safe = sdk.Safety(sdk.LeggedType.Go1)
|
||||
cmd, state = sdk.LowCmd(), sdk.LowState()
|
||||
udp.InitCmdData(cmd)
|
||||
|
||||
udp.Recv()
|
||||
udp.GetRecv(state)
|
||||
safe.PowerProtect(cmd, state, 1)
|
||||
udp.SetSend(cmd)
|
||||
udp.Send()
|
||||
```
|
||||
|
||||
兼容范围和协议差异见 [`docs/OFFICIAL_API_COMPATIBILITY.md`](docs/OFFICIAL_API_COMPATIBILITY.md)。
|
||||
官方 `HighCmd/HighState` UDP 通道和 C++ ABI 当前不在兼容范围内。
|
||||
|
||||
### 高层控制 (走/跳/姿态, 通过 sportMode 系统)
|
||||
|
||||
```python
|
||||
|
||||
89
docs/OFFICIAL_API_COMPATIBILITY.md
Normal file
89
docs/OFFICIAL_API_COMPATIBILITY.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Unitree 官方 SDK 接口兼容说明
|
||||
|
||||
对比基准:Unitree [`unitree_legged_sdk`](https://github.com/unitreerobotics/unitree_legged_sdk)
|
||||
`go1` 分支,README 版本 `v3.8.6`,提交
|
||||
`4539a6c10dfbc9781cea6fcb7d51bc6ddc6f71e1`(2023-07-11)。
|
||||
|
||||
## 结论
|
||||
|
||||
本项目和官方 SDK 使用相近的低层对象模型,但此前不是调用兼容:本项目入口是
|
||||
`MCUClient.send()/recv_state()`,官方 Python wrapper 入口是
|
||||
`robot_interface.UDP.SetSend()/Send()/Recv()/GetRecv()`。
|
||||
|
||||
当前项目已提供 `robot_interface` 兼容模块。官方低层 Python 示例可保留原来的
|
||||
导入和主要调用流程,同时底层会使用 Go1 PRO 所需的 Blowfish 加密和 616 字节
|
||||
私有 `LowCmd` 协议。
|
||||
|
||||
## 接口矩阵
|
||||
|
||||
| 官方接口 | 本项目原接口 | 当前兼容状态 |
|
||||
|---|---|---|
|
||||
| `robot_interface.LowCmd/LowState` | 同名类型 | 已对齐命令和常用状态字段名 |
|
||||
| `MotorCmd/MotorState/IMU` | 同名类型 | 常用字段已对齐 |
|
||||
| `BmsState` | `BMS` | 名称已对齐;PRO 状态区较短,见下文 |
|
||||
| `BmsCmd`, `Cartesian`, `UDPState` | 原先缺失 | 已补齐 |
|
||||
| `UDP(LOWLEVEL, ...)` | `MCUClient(...)` | 已适配标准四参数构造 |
|
||||
| `InitCmdData` | 原先缺失 | 已实现官方 stop sentinel 初始化 |
|
||||
| `Recv/GetRecv/SetSend/Send` | `recv_latest/send` | 已适配 |
|
||||
| `Safety.PositionLimit` | `position_limit` | 已适配,跳过 `PosStopF` |
|
||||
| `Safety.PowerProtect` | `power_protect` | 调用兼容,算法不是官方闭源实现 |
|
||||
| `Safety.PositionProtect` | `position_protect` | 调用兼容,保护动作更保守 |
|
||||
| `HighCmd/HighState` | MQTT `Go1/Go1MQTT` | 类型已提供,UDP 传输不支持 |
|
||||
| `Loop/LoopFunc` | 无 | 官方 Python wrapper 本身也未导出 |
|
||||
| C++ headers/static library | 无 | 不兼容 C++ 源码或 ABI |
|
||||
|
||||
## 关键协议差异
|
||||
|
||||
官方 SDK 的公开结构不能直接描述本项目实测的 Go1 PRO 线协议。PRO 低层通道需要:
|
||||
|
||||
- 616 字节 `LowCmd`,CRC 位于 612;
|
||||
- Blowfish ECB 加密;
|
||||
- `bandWidth` 使用实测字节序和值;
|
||||
- 858 字节加密状态包中取 856 字节块解密,再解析 807 字节有效状态。
|
||||
|
||||
因此兼容层只对齐用户代码接口,不替换本项目现有 codec,也不承诺二进制包与官方
|
||||
EDU SDK 相同。
|
||||
|
||||
## 官方风格用法
|
||||
|
||||
```python
|
||||
import robot_interface as sdk
|
||||
|
||||
udp = sdk.UDP(sdk.LOWLEVEL, 8080, "192.168.123.10", 8007)
|
||||
safe = sdk.Safety(sdk.LeggedType.Go1)
|
||||
cmd = sdk.LowCmd()
|
||||
state = sdk.LowState()
|
||||
udp.InitCmdData(cmd)
|
||||
|
||||
udp.Recv()
|
||||
udp.GetRecv(state)
|
||||
|
||||
cmd.motorCmd[sdk.FR_1].q = 1.2
|
||||
cmd.motorCmd[sdk.FR_1].dq = 0.0
|
||||
cmd.motorCmd[sdk.FR_1].Kp = 5.0
|
||||
cmd.motorCmd[sdk.FR_1].Kd = 1.0
|
||||
safe.PositionLimit(cmd)
|
||||
safe.PowerProtect(cmd, state, 1)
|
||||
udp.SetSend(cmd)
|
||||
udp.Send()
|
||||
```
|
||||
|
||||
`UDP(HIGHLEVEL, ..., "192.168.123.161", 8082)` 不会被静默映射到 MQTT。两种通道
|
||||
状态模型和时序不同,兼容层会明确抛出 `NotImplementedError`。高层控制继续使用
|
||||
`Go1` 或 `Go1MQTT`。
|
||||
|
||||
## 尚未完全对齐的风险
|
||||
|
||||
- 官方 `PowerProtect` 实现在静态库中,头文件只公开签名。本项目当前保护逻辑按
|
||||
关节力矩限幅和实测过载执行,不等价于官方内部的累计功率算法。
|
||||
- 官方 `BmsState` 是 34 字节,本项目实测的 PRO 状态布局只给 BMS 分配 24 字节;
|
||||
`cell_vol` 后半部分不能视为完整的官方数据。
|
||||
- `LowState.tick` 当前没有从 PRO 包中解析,仍为默认值;`footForce` 和
|
||||
`footForceEst` 的偏移来自逆向推断,尚未达到官方字段的同等可信度。
|
||||
- `head/SN/version/reserve/crc` 在本地原生类型中部分使用 `bytes`,官方 wrapper
|
||||
使用定长整数数组或整数。命令 builder 接受两种常见赋值形态,但读取后的 Python
|
||||
类型不保证完全相同。
|
||||
- 本项目 `LowState` 是 PRO 抓包格式的解释,不应按官方 `#pragma pack(1)` 结构大小
|
||||
直接做内存映射。
|
||||
- 官方 C++ 用户需要单独的 C++ facade 和库链接方案;Python 兼容模块不能让现有
|
||||
C++ 程序直接重编译通过。
|
||||
@@ -30,7 +30,7 @@ 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,
|
||||
IMU, BMS, BmsCmd, BmsState,
|
||||
RemoteState, parse_remote, BUTTON_NAMES,
|
||||
LowState, LowCmd,
|
||||
)
|
||||
@@ -39,7 +39,10 @@ from .safety import (
|
||||
PowerProtectViolation,
|
||||
)
|
||||
from .utils import (
|
||||
MCU_IP, MCU_PORT,
|
||||
MCU_IP, MCU_PORT, HIGHLEVEL, LOWLEVEL, TRIGERLEVEL, PosStopF, VelStopF,
|
||||
FR_, FL_, RR_, RL_,
|
||||
FR_0, FR_1, FR_2, FL_0, FL_1, FL_2,
|
||||
RR_0, RR_1, RR_2, RL_0, RL_1, RL_2,
|
||||
JOINT_NAMES, JOINT_TYPE, JOINT_LIMITS, JOINT_LIMITS_MEASURED,
|
||||
TAU_MAX, KT, DAMPING_POSE,
|
||||
decode_sn, decode_version,
|
||||
@@ -54,7 +57,7 @@ __all__ = [
|
||||
'MCUClient',
|
||||
# 数据结构
|
||||
'MotorCmd', 'MotorState', 'MotorMode',
|
||||
'IMU', 'BMS',
|
||||
'IMU', 'BMS', 'BmsCmd', 'BmsState',
|
||||
'RemoteState', 'parse_remote', 'BUTTON_NAMES',
|
||||
'LowState', 'LowCmd',
|
||||
# 编解码
|
||||
@@ -65,6 +68,10 @@ __all__ = [
|
||||
'PowerProtectViolation',
|
||||
# 常量
|
||||
'MCU_IP', 'MCU_PORT',
|
||||
'HIGHLEVEL', 'LOWLEVEL', 'TRIGERLEVEL', 'PosStopF', 'VelStopF',
|
||||
'FR_', 'FL_', 'RR_', 'RL_',
|
||||
'FR_0', 'FR_1', 'FR_2', 'FL_0', 'FL_1', 'FL_2',
|
||||
'RR_0', 'RR_1', 'RR_2', 'RL_0', 'RL_1', 'RL_2',
|
||||
'JOINT_NAMES', 'JOINT_TYPE', 'JOINT_LIMITS', 'JOINT_LIMITS_MEASURED',
|
||||
'TAU_MAX', 'KT', 'DAMPING_POSE',
|
||||
'decode_sn', 'decode_version',
|
||||
|
||||
@@ -33,34 +33,67 @@ from ..utils.common import gen_crc
|
||||
from .blowfish import Blowfish
|
||||
|
||||
|
||||
def _pack_bytes(value, size: int, field_name: str) -> bytes:
|
||||
packed = bytes(value)
|
||||
if len(packed) != size:
|
||||
raise ValueError(f'{field_name} 必须是 {size} 字节, got {len(packed)}')
|
||||
return packed
|
||||
|
||||
|
||||
def _pack_u32_pair(value, field_name: str) -> bytes:
|
||||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||
packed = bytes(value)
|
||||
else:
|
||||
try:
|
||||
packed = struct.pack('<2I', *(int(item) for item in value))
|
||||
except (TypeError, ValueError, struct.error) as exc:
|
||||
raise ValueError(f'{field_name} 必须是 8 字节或 2 个 uint32') from exc
|
||||
if len(packed) != 8:
|
||||
raise ValueError(f'{field_name} 必须是 8 字节, got {len(packed)}')
|
||||
return packed
|
||||
|
||||
|
||||
def _pack_u32(value, field_name: str) -> bytes:
|
||||
if isinstance(value, int):
|
||||
return struct.pack('<I', value)
|
||||
packed = bytes(value)
|
||||
if len(packed) != 4:
|
||||
raise ValueError(f'{field_name} 必须是 4 字节, got {len(packed)}')
|
||||
return packed
|
||||
|
||||
|
||||
def build_low_cmd_plain(lowcmd: LowCmd) -> bytes:
|
||||
"""构造 PRO 格式 616B 明文 LowCmd."""
|
||||
cmd = bytearray(616)
|
||||
|
||||
# 0..2 head
|
||||
cmd[0:2] = lowcmd.head
|
||||
cmd[0:2] = _pack_bytes(lowcmd.head, 2, 'head')
|
||||
# 2..3 levelFlag
|
||||
cmd[2] = lowcmd.levelFlag
|
||||
# 3..4 frameReserve
|
||||
cmd[3] = lowcmd.frameReserve
|
||||
# 4..12 SN
|
||||
cmd[4:12] = lowcmd.SN
|
||||
cmd[4:12] = _pack_u32_pair(lowcmd.SN, 'SN')
|
||||
# 12..20 version
|
||||
cmd[12:20] = lowcmd.version
|
||||
cmd[12:20] = _pack_u32_pair(lowcmd.version, 'version')
|
||||
# 20..22 bandWidth BE
|
||||
cmd[20:22] = struct.pack('>H', lowcmd.bandWidth)
|
||||
# 22..562 motorCmd[20] × 27B
|
||||
if len(lowcmd.motorCmd) != 20:
|
||||
raise ValueError(f'motorCmd 必须有 20 项, got {len(lowcmd.motorCmd)}')
|
||||
offset = 22
|
||||
for motor in lowcmd.motorCmd:
|
||||
motor_bytes = motor.to_bytes()
|
||||
assert len(motor_bytes) == 27, f'motorCmd 序列化长度错: {len(motor_bytes)}'
|
||||
cmd[offset:offset + 27] = motor_bytes
|
||||
offset += 27
|
||||
# 562..566 bms (默认 0)
|
||||
# 562..566 bms
|
||||
cmd[562] = int(lowcmd.bms.off) & 0xff
|
||||
cmd[563:566] = _pack_bytes(lowcmd.bms.reserve, 3, 'bms.reserve')
|
||||
# 566..606 wirelessRemote
|
||||
cmd[566:606] = lowcmd.wirelessRemote
|
||||
cmd[566:606] = _pack_bytes(lowcmd.wirelessRemote, 40, 'wirelessRemote')
|
||||
# 606..610 reserve
|
||||
cmd[606:610] = lowcmd.reserve
|
||||
cmd[606:610] = _pack_u32(lowcmd.reserve, 'reserve')
|
||||
# 610..612 固定填充 0x0000
|
||||
# 612..616 CRC
|
||||
cmd[612:616] = gen_crc(memoryview(cmd)[:612])
|
||||
|
||||
4
go1_pro_sdk/compat/__init__.py
Normal file
4
go1_pro_sdk/compat/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Compatibility APIs for third-party Unitree SDK code."""
|
||||
|
||||
from .robot_interface import *
|
||||
from .robot_interface import __all__
|
||||
328
go1_pro_sdk/compat/robot_interface.py
Normal file
328
go1_pro_sdk/compat/robot_interface.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""Compatibility surface for Unitree's ``robot_interface`` Python module.
|
||||
|
||||
Only Go1 PRO low-level transport is implemented. The public API mirrors the
|
||||
official v3.8.6 Python wrapper while the wire codec remains the PRO-specific
|
||||
encrypted protocol implemented by this project.
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field, fields, is_dataclass
|
||||
from enum import IntEnum
|
||||
from typing import List, Optional
|
||||
|
||||
from ..connection.mcu_client import MCUClient
|
||||
from ..safety import position_limit, position_protect, power_protect
|
||||
from ..safety import PowerProtectViolation
|
||||
from ..types import BmsCmd, BmsState, IMU, LowCmd, LowState, MotorCmd, MotorState
|
||||
from ..utils.constants import (
|
||||
HIGHLEVEL, LOWLEVEL, TRIGERLEVEL, PosStopF, VelStopF,
|
||||
MCU_IP, MCU_PORT,
|
||||
FR_, FL_, RR_, RL_,
|
||||
FR_0, FR_1, FR_2, FL_0, FL_1, FL_2,
|
||||
RR_0, RR_1, RR_2, RL_0, RL_1, RL_2,
|
||||
)
|
||||
|
||||
|
||||
class LeggedType(IntEnum):
|
||||
Aliengo = 0
|
||||
A1 = 1
|
||||
Go1 = 2
|
||||
B1 = 3
|
||||
|
||||
|
||||
Aliengo = LeggedType.Aliengo
|
||||
A1 = LeggedType.A1
|
||||
Go1 = LeggedType.Go1
|
||||
B1 = LeggedType.B1
|
||||
|
||||
|
||||
class RecvEnum(IntEnum):
|
||||
nonBlock = 0x00
|
||||
block = 0x01
|
||||
blockTimeout = 0x02
|
||||
|
||||
|
||||
nonBlock = RecvEnum.nonBlock
|
||||
block = RecvEnum.block
|
||||
blockTimeout = RecvEnum.blockTimeout
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cartesian:
|
||||
x: float = 0.0
|
||||
y: float = 0.0
|
||||
z: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class LED:
|
||||
r: int = 0
|
||||
g: int = 0
|
||||
b: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class HighState:
|
||||
head: List[int] = field(default_factory=lambda: [0, 0])
|
||||
levelFlag: int = 0
|
||||
frameReserve: int = 0
|
||||
SN: List[int] = field(default_factory=lambda: [0, 0])
|
||||
version: List[int] = field(default_factory=lambda: [0, 0])
|
||||
bandWidth: int = 0
|
||||
imu: IMU = field(default_factory=IMU)
|
||||
motorState: List[MotorState] = field(default_factory=lambda: [MotorState() for _ in range(20)])
|
||||
bms: BmsState = field(default_factory=BmsState)
|
||||
footForce: List[int] = field(default_factory=lambda: [0] * 4)
|
||||
footForceEst: List[int] = field(default_factory=lambda: [0] * 4)
|
||||
mode: int = 0
|
||||
progress: float = 0.0
|
||||
gaitType: int = 0
|
||||
footRaiseHeight: float = 0.0
|
||||
position: List[float] = field(default_factory=lambda: [0.0] * 3)
|
||||
bodyHeight: float = 0.0
|
||||
velocity: List[float] = field(default_factory=lambda: [0.0] * 3)
|
||||
yawSpeed: float = 0.0
|
||||
rangeObstacle: List[float] = field(default_factory=lambda: [0.0] * 4)
|
||||
footPosition2Body: List[Cartesian] = field(default_factory=lambda: [Cartesian() for _ in range(4)])
|
||||
footSpeed2Body: List[Cartesian] = field(default_factory=lambda: [Cartesian() for _ in range(4)])
|
||||
wirelessRemote: List[int] = field(default_factory=lambda: [0] * 40)
|
||||
reserve: int = 0
|
||||
crc: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class HighCmd:
|
||||
head: List[int] = field(default_factory=lambda: [0, 0])
|
||||
levelFlag: int = 0
|
||||
frameReserve: int = 0
|
||||
SN: List[int] = field(default_factory=lambda: [0, 0])
|
||||
version: List[int] = field(default_factory=lambda: [0, 0])
|
||||
bandWidth: int = 0
|
||||
mode: int = 0
|
||||
gaitType: int = 0
|
||||
speedLevel: int = 0
|
||||
footRaiseHeight: float = 0.0
|
||||
bodyHeight: float = 0.0
|
||||
position: List[float] = field(default_factory=lambda: [0.0] * 2)
|
||||
euler: List[float] = field(default_factory=lambda: [0.0] * 3)
|
||||
velocity: List[float] = field(default_factory=lambda: [0.0] * 2)
|
||||
yawSpeed: float = 0.0
|
||||
bms: BmsCmd = field(default_factory=BmsCmd)
|
||||
led: List[LED] = field(default_factory=lambda: [LED() for _ in range(4)])
|
||||
wirelessRemote: List[int] = field(default_factory=lambda: [0] * 40)
|
||||
reserve: int = 0
|
||||
crc: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class UDPState:
|
||||
TotalCount: int = 0
|
||||
SendCount: int = 0
|
||||
RecvCount: int = 0
|
||||
SendError: int = 0
|
||||
FlagError: int = 0
|
||||
RecvCRCError: int = 0
|
||||
RecvLoseError: int = 0
|
||||
|
||||
|
||||
def _copy_object(source, target) -> None:
|
||||
if is_dataclass(target):
|
||||
names = (item.name for item in fields(target))
|
||||
else:
|
||||
names = vars(source).keys()
|
||||
for name in names:
|
||||
if hasattr(source, name):
|
||||
setattr(target, name, deepcopy(getattr(source, name)))
|
||||
|
||||
|
||||
class UDP:
|
||||
"""Official-style UDP facade backed by :class:`MCUClient`.
|
||||
|
||||
The standard ``UDP(level, localPort, targetIP, targetPort)`` constructor is
|
||||
supported for ``LOWLEVEL``. Custom packet-length constructors and the
|
||||
official high-level UDP protocol are intentionally not emulated.
|
||||
"""
|
||||
|
||||
def __init__(self, *args):
|
||||
if len(args) >= 4 and isinstance(args[2], str):
|
||||
self.level = int(args[0])
|
||||
self.localPort = int(args[1])
|
||||
self.targetIP = args[2]
|
||||
self.targetPort = int(args[3])
|
||||
self._recv_type = RecvEnum.nonBlock
|
||||
self._custom_lengths = False
|
||||
elif len(args) >= 5 and isinstance(args[1], str):
|
||||
self.level = None
|
||||
self.localPort = int(args[0])
|
||||
self.targetIP = args[1]
|
||||
self.targetPort = int(args[2])
|
||||
self._recv_type = RecvEnum(args[6]) if len(args) > 6 else RecvEnum.nonBlock
|
||||
self._custom_lengths = True
|
||||
elif len(args) >= 3:
|
||||
self.level = None
|
||||
self.localPort = int(args[0])
|
||||
self.targetIP = MCU_IP
|
||||
self.targetPort = MCU_PORT
|
||||
self._recv_type = RecvEnum(args[4]) if len(args) > 4 else RecvEnum.nonBlock
|
||||
self._custom_lengths = True
|
||||
else:
|
||||
raise TypeError("UDP expects one of the three official constructor signatures")
|
||||
|
||||
self.localIP = "0.0.0.0"
|
||||
self.accessible = False
|
||||
self.udpState = UDPState()
|
||||
self._client: Optional[MCUClient] = None
|
||||
self._send_cmd: Optional[LowCmd] = None
|
||||
self._recv_state: Optional[LowState] = None
|
||||
self._recv_timeout_ms = 1000
|
||||
self._disconnect_time = None
|
||||
self._accessible_time = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
def _ensure_client(self) -> MCUClient:
|
||||
if self.level == HIGHLEVEL:
|
||||
raise NotImplementedError(
|
||||
"HighCmd/HighState UDP (:8082) is not implemented by this "
|
||||
"compatibility layer; use go1_pro_sdk.Go1 or Go1MQTT"
|
||||
)
|
||||
if self.level not in (LOWLEVEL, None) or self._custom_lengths:
|
||||
raise NotImplementedError("only the official LOWLEVEL constructor is supported")
|
||||
if self._client is None:
|
||||
self._client = MCUClient(
|
||||
mcu_ip=self.targetIP,
|
||||
mcu_port=self.targetPort,
|
||||
local_port=self.localPort,
|
||||
)
|
||||
self.localPort = self._client.local_port
|
||||
return self._client
|
||||
|
||||
def SetIpPort(self, targetIP, targetPort):
|
||||
if self._client is not None:
|
||||
raise RuntimeError("SetIpPort must be called before the first Send/Recv")
|
||||
self.targetIP = str(targetIP)
|
||||
self.targetPort = int(targetPort)
|
||||
|
||||
def SetRecvTimeout(self, time):
|
||||
self._recv_timeout_ms = max(0, int(time))
|
||||
|
||||
def SetDisconnectTime(self, callback_dt, disconnectTime):
|
||||
self._disconnect_time = (float(callback_dt), float(disconnectTime))
|
||||
|
||||
def SetAccessibleTime(self, callback_dt, accessibleTime):
|
||||
self._accessible_time = (float(callback_dt), float(accessibleTime))
|
||||
|
||||
def InitCmdData(self, cmd):
|
||||
if isinstance(cmd, HighCmd):
|
||||
_copy_object(HighCmd(levelFlag=HIGHLEVEL), cmd)
|
||||
return None
|
||||
if not isinstance(cmd, LowCmd):
|
||||
raise TypeError("InitCmdData expects LowCmd or HighCmd")
|
||||
|
||||
template = LowCmd(levelFlag=LOWLEVEL)
|
||||
template.motorCmd = [
|
||||
MotorCmd(mode=0x0A, q=PosStopF, dq=VelStopF)
|
||||
for _ in range(20)
|
||||
]
|
||||
_copy_object(template, cmd)
|
||||
return None
|
||||
|
||||
def SetSend(self, cmd):
|
||||
if isinstance(cmd, HighCmd):
|
||||
self._send_cmd = deepcopy(cmd)
|
||||
return 0
|
||||
if not isinstance(cmd, LowCmd):
|
||||
raise TypeError("SetSend expects LowCmd or HighCmd")
|
||||
self._send_cmd = deepcopy(cmd)
|
||||
return 0
|
||||
|
||||
def Send(self):
|
||||
if self._send_cmd is None:
|
||||
raise RuntimeError("call InitCmdData and SetSend before Send")
|
||||
if isinstance(self._send_cmd, HighCmd):
|
||||
self._ensure_client()
|
||||
self.udpState.TotalCount += 1
|
||||
try:
|
||||
sent = self._ensure_client().send(self._send_cmd)
|
||||
except Exception:
|
||||
self.udpState.SendError += 1
|
||||
raise
|
||||
self.udpState.SendCount += 1
|
||||
return sent
|
||||
|
||||
def Recv(self):
|
||||
client = self._ensure_client()
|
||||
self.udpState.TotalCount += 1
|
||||
if self._recv_type == RecvEnum.nonBlock:
|
||||
state = client.recv_latest()
|
||||
else:
|
||||
timeout = self._recv_timeout_ms / 1000.0
|
||||
state = client.recv_state(timeout=timeout)
|
||||
if state is None:
|
||||
return 0
|
||||
self._recv_state = state
|
||||
self.accessible = True
|
||||
self.udpState.RecvCount += 1
|
||||
return 858
|
||||
|
||||
def GetRecv(self, state):
|
||||
if isinstance(state, HighState):
|
||||
self._ensure_client()
|
||||
if not isinstance(state, LowState):
|
||||
raise TypeError("GetRecv expects LowState or HighState")
|
||||
if self._recv_state is not None:
|
||||
_copy_object(self._recv_state, state)
|
||||
return None
|
||||
|
||||
|
||||
class Safety:
|
||||
"""Official method names with conservative local safety behavior."""
|
||||
|
||||
def __init__(self, legged_type):
|
||||
self.legged_type = LeggedType(legged_type)
|
||||
if self.legged_type != LeggedType.Go1:
|
||||
raise ValueError("go1_pro_sdk only supports LeggedType.Go1")
|
||||
|
||||
def PositionLimit(self, lowcmd):
|
||||
position_limit(lowcmd)
|
||||
return None
|
||||
|
||||
def PowerProtect(self, lowcmd, lowstate, factor):
|
||||
try:
|
||||
power_protect(lowcmd, lowstate, factor)
|
||||
except PowerProtectViolation:
|
||||
return -1
|
||||
return 0
|
||||
|
||||
def PositionProtect(self, lowcmd, lowstate, limit=0.087):
|
||||
return -1 if position_protect(lowcmd, lowstate, limit) else 0
|
||||
|
||||
|
||||
def VersionSDK():
|
||||
return "go1_pro_sdk robot_interface compatibility (official API v3.8.6)"
|
||||
|
||||
|
||||
def InitEnvironment():
|
||||
return 0
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HIGHLEVEL", "LOWLEVEL", "TRIGERLEVEL", "PosStopF", "VelStopF",
|
||||
"FR_", "FL_", "RR_", "RL_",
|
||||
"FR_0", "FR_1", "FR_2", "FL_0", "FL_1", "FL_2",
|
||||
"RR_0", "RR_1", "RR_2", "RL_0", "RL_1", "RL_2",
|
||||
"LeggedType", "Aliengo", "A1", "Go1", "B1",
|
||||
"RecvEnum", "nonBlock", "block", "blockTimeout",
|
||||
"UDP", "Safety", "BmsCmd", "BmsState", "Cartesian", "IMU", "LED",
|
||||
"MotorState", "MotorCmd", "LowState", "LowCmd", "HighState", "HighCmd",
|
||||
"UDPState", "VersionSDK", "InitEnvironment",
|
||||
]
|
||||
@@ -7,7 +7,7 @@
|
||||
"""
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from ..utils.constants import (
|
||||
JOINT_LIMITS, JOINT_NAMES, JOINT_TYPE, TAU_MAX, CRITICAL_FACTOR,
|
||||
JOINT_LIMITS, JOINT_NAMES, JOINT_TYPE, TAU_MAX, CRITICAL_FACTOR, PosStopF,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -26,6 +26,8 @@ def position_limit(lowcmd: 'LowCmd') -> int:
|
||||
jt = JOINT_TYPE[i]
|
||||
lo, hi = JOINT_LIMITS[jt]
|
||||
m = lowcmd.motorCmd[i]
|
||||
if abs(m.q) >= PosStopF * 0.1:
|
||||
continue
|
||||
if m.q < lo:
|
||||
m.q = lo; clamped += 1
|
||||
elif m.q > hi:
|
||||
@@ -78,6 +80,8 @@ def position_protect(lowcmd: 'LowCmd', lstate: 'LowState', limit_rad: float) ->
|
||||
affected = 0
|
||||
for i in range(12):
|
||||
m = lowcmd.motorCmd[i]
|
||||
if abs(m.q) >= PosStopF * 0.1:
|
||||
continue
|
||||
try:
|
||||
actual_q = lstate.motorState[i].q
|
||||
except (AttributeError, IndexError):
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""数据结构 (MotorCmd, MotorState, LowState, LowCmd, IMU, BMS, Remote)."""
|
||||
from .motor import MotorCmd, MotorState, MotorMode
|
||||
from .imu import IMU
|
||||
from .bms import BMS
|
||||
from .bms import BMS, BmsCmd, BmsState
|
||||
from .remote import RemoteState, parse_remote, BUTTON_NAMES
|
||||
from .low_state import LowState
|
||||
from .low_cmd import LowCmd
|
||||
|
||||
__all__ = [
|
||||
'MotorCmd', 'MotorState', 'MotorMode',
|
||||
'IMU', 'BMS',
|
||||
'IMU', 'BMS', 'BmsCmd', 'BmsState',
|
||||
'RemoteState', 'parse_remote', 'BUTTON_NAMES',
|
||||
'LowState', 'LowCmd',
|
||||
]
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
"""电池管理系统 (BMS) 数据."""
|
||||
"""电池管理系统 (BMS) 命令和状态数据."""
|
||||
import struct
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
|
||||
@dataclass
|
||||
class BmsCmd:
|
||||
"""官方 SDK 同名的电池命令结构."""
|
||||
off: int = 0
|
||||
reserve: List[int] = field(default_factory=lambda: [0, 0, 0])
|
||||
|
||||
|
||||
@dataclass
|
||||
class BMS:
|
||||
"""BMS 反馈."""
|
||||
@@ -46,3 +53,7 @@ class BMS:
|
||||
# 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
|
||||
|
||||
|
||||
# 官方 Python wrapper 使用 BmsState;保留 BMS 作为项目原有名称。
|
||||
BmsState = BMS
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""LowCmd — 主控发给 MCU 的控制命令 (PRO 格式 616B 明文 / Blowfish 加密后 616B)."""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
from .bms import BmsCmd
|
||||
from .motor import MotorCmd
|
||||
|
||||
|
||||
@@ -17,8 +18,10 @@ class LowCmd:
|
||||
version: bytes = b'\x00' * 8 # PRO 不填, 全 0
|
||||
bandWidth: int = 0x3ac0 # 真包实测值
|
||||
motorCmd: List[MotorCmd] = field(default_factory=lambda: [MotorCmd() for _ in range(20)])
|
||||
bms: BmsCmd = field(default_factory=BmsCmd)
|
||||
wirelessRemote: bytes = b'\x00' * 40
|
||||
reserve: bytes = b'\x00' * 4
|
||||
crc: int = 0 # 发送时由 builder 重新计算
|
||||
|
||||
def set_motor(self, index_or_name, cmd: MotorCmd):
|
||||
"""设置某个电机命令.
|
||||
|
||||
@@ -7,7 +7,10 @@ from .common import (
|
||||
decode_sn, decode_version,
|
||||
)
|
||||
from .constants import (
|
||||
MCU_IP, MCU_PORT,
|
||||
MCU_IP, MCU_PORT, HIGHLEVEL, LOWLEVEL, TRIGERLEVEL, PosStopF, VelStopF,
|
||||
FR_, FL_, RR_, RL_,
|
||||
FR_0, FR_1, FR_2, FL_0, FL_1, FL_2,
|
||||
RR_0, RR_1, RR_2, RL_0, RL_1, RL_2,
|
||||
JOINT_NAMES, JOINT_TYPE, JOINT_ATTR,
|
||||
JOINT_LIMITS, JOINT_LIMITS_MEASURED,
|
||||
TAU_MAX, KT, CRITICAL_FACTOR,
|
||||
@@ -23,6 +26,10 @@ __all__ = [
|
||||
'decode_sn', 'decode_version',
|
||||
# constants
|
||||
'MCU_IP', 'MCU_PORT',
|
||||
'HIGHLEVEL', 'LOWLEVEL', 'TRIGERLEVEL', 'PosStopF', 'VelStopF',
|
||||
'FR_', 'FL_', 'RR_', 'RL_',
|
||||
'FR_0', 'FR_1', 'FR_2', 'FL_0', 'FL_1', 'FL_2',
|
||||
'RR_0', 'RR_1', 'RR_2', 'RL_0', 'RL_1', 'RL_2',
|
||||
'JOINT_NAMES', 'JOINT_TYPE', 'JOINT_ATTR',
|
||||
'JOINT_LIMITS', 'JOINT_LIMITS_MEASURED',
|
||||
'TAU_MAX', 'KT', 'CRITICAL_FACTOR',
|
||||
|
||||
@@ -8,6 +8,12 @@ MCU_IP = '192.168.123.10'
|
||||
MCU_PORT = 8007
|
||||
"""MCU UDP 端口 (从这里发/收 LowCmd/LowState)"""
|
||||
|
||||
HIGHLEVEL = 0xEE
|
||||
LOWLEVEL = 0xFF
|
||||
TRIGERLEVEL = 0xF0
|
||||
PosStopF = 2.146e9
|
||||
VelStopF = 16000.0
|
||||
|
||||
|
||||
# ===== 关节命名/索引 =====
|
||||
|
||||
@@ -18,6 +24,9 @@ JOINT_NAMES = [
|
||||
'RL_0', 'RL_1', 'RL_2', # 9..11 左后
|
||||
]
|
||||
|
||||
FR_, FL_, RR_, RL_ = range(4)
|
||||
FR_0, FR_1, FR_2, FL_0, FL_1, FL_2, RR_0, RR_1, RR_2, RL_0, RL_1, RL_2 = range(12)
|
||||
|
||||
JOINT_TYPE = {i: ('hip' if i % 3 == 0 else 'thigh' if i % 3 == 1 else 'knee')
|
||||
for i in range(12)}
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ Homepage = "https://github.com/your-username/go1_pro_sdk"
|
||||
Documentation = "https://github.com/your-username/go1_pro_sdk/blob/main/docs/"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["robot_interface"]
|
||||
packages = ["go1_pro_sdk",
|
||||
"go1_pro_sdk.compat",
|
||||
"go1_pro_sdk.codec",
|
||||
"go1_pro_sdk.connection",
|
||||
"go1_pro_sdk.highlevel",
|
||||
|
||||
4
robot_interface.py
Normal file
4
robot_interface.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Drop-in import name for Unitree's official Python wrapper."""
|
||||
|
||||
from go1_pro_sdk.compat.robot_interface import *
|
||||
from go1_pro_sdk.compat.robot_interface import __all__
|
||||
140
tests/test_official_compat.py
Normal file
140
tests/test_official_compat.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Contract tests for the official ``robot_interface`` compatibility API."""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
import robot_interface as sdk
|
||||
from go1_pro_sdk import build_low_cmd_plain
|
||||
|
||||
|
||||
class _FakeMCUClient:
|
||||
instances = []
|
||||
|
||||
def __init__(self, mcu_ip, mcu_port, local_port):
|
||||
self.mcu_ip = mcu_ip
|
||||
self.mcu_port = mcu_port
|
||||
self.local_port = local_port or 49152
|
||||
self.next_state = sdk.LowState()
|
||||
self.next_state.motorState[sdk.FR_1].q = 1.23
|
||||
self.sent = []
|
||||
self.closed = False
|
||||
self.__class__.instances.append(self)
|
||||
|
||||
def send(self, cmd):
|
||||
self.sent.append(deepcopy(cmd))
|
||||
return 616
|
||||
|
||||
def recv_latest(self):
|
||||
return deepcopy(self.next_state)
|
||||
|
||||
def recv_state(self, timeout):
|
||||
return deepcopy(self.next_state)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_client(monkeypatch):
|
||||
_FakeMCUClient.instances.clear()
|
||||
monkeypatch.setattr(
|
||||
"go1_pro_sdk.compat.robot_interface.MCUClient",
|
||||
_FakeMCUClient,
|
||||
)
|
||||
return _FakeMCUClient
|
||||
|
||||
|
||||
def test_official_names_and_data_types_are_available():
|
||||
assert sdk.LOWLEVEL == 0xff
|
||||
assert sdk.LeggedType.Go1 == 2
|
||||
assert sdk.Go1 is sdk.LeggedType.Go1
|
||||
assert sdk.FR_0 == 0
|
||||
assert sdk.RL_2 == 11
|
||||
assert sdk.BmsState().SOC == 0
|
||||
assert sdk.HighCmd().velocity == [0.0, 0.0]
|
||||
|
||||
|
||||
def test_init_cmd_data_uses_official_stop_sentinels():
|
||||
udp = sdk.UDP(sdk.LOWLEVEL, 0, "192.168.123.10", 8007)
|
||||
cmd = sdk.LowCmd()
|
||||
|
||||
assert udp.InitCmdData(cmd) is None
|
||||
assert cmd.levelFlag == sdk.LOWLEVEL
|
||||
assert len(cmd.motorCmd) == 20
|
||||
assert all(m.mode == 0x0A for m in cmd.motorCmd)
|
||||
assert all(m.q == sdk.PosStopF for m in cmd.motorCmd)
|
||||
assert all(m.dq == sdk.VelStopF for m in cmd.motorCmd)
|
||||
|
||||
|
||||
def test_official_lowlevel_send_receive_sequence(fake_client):
|
||||
udp = sdk.UDP(sdk.LOWLEVEL, 0, "192.168.123.10", 8007)
|
||||
cmd = sdk.LowCmd()
|
||||
state = sdk.LowState()
|
||||
udp.InitCmdData(cmd)
|
||||
|
||||
assert udp.Recv() == 858
|
||||
assert udp.GetRecv(state) is None
|
||||
assert state.motorState[sdk.FR_1].q == pytest.approx(1.23)
|
||||
|
||||
cmd.motorCmd[sdk.FR_1].q = 1.2
|
||||
cmd.motorCmd[sdk.FR_1].dq = 0.0
|
||||
cmd.motorCmd[sdk.FR_1].Kp = 5.0
|
||||
cmd.motorCmd[sdk.FR_1].Kd = 1.0
|
||||
assert udp.SetSend(cmd) == 0
|
||||
assert udp.Send() == 616
|
||||
|
||||
client = fake_client.instances[0]
|
||||
assert client.sent[0].motorCmd[sdk.FR_1].q == pytest.approx(1.2)
|
||||
assert udp.udpState.SendCount == 1
|
||||
assert udp.udpState.RecvCount == 1
|
||||
|
||||
|
||||
def test_position_limit_skips_stop_sentinel():
|
||||
cmd = sdk.LowCmd()
|
||||
sdk.UDP(sdk.LOWLEVEL, 0, "192.168.123.10", 8007).InitCmdData(cmd)
|
||||
cmd.motorCmd[sdk.FR_1].q = 99.0
|
||||
|
||||
safe = sdk.Safety(sdk.LeggedType.Go1)
|
||||
assert safe.PositionLimit(cmd) is None
|
||||
|
||||
assert cmd.motorCmd[sdk.FR_0].q == sdk.PosStopF
|
||||
assert cmd.motorCmd[sdk.FR_1].q == pytest.approx(3.5)
|
||||
|
||||
|
||||
def test_bms_command_is_serialized():
|
||||
cmd = sdk.LowCmd()
|
||||
cmd.bms.off = 0xA5
|
||||
cmd.bms.reserve = [1, 2, 3]
|
||||
|
||||
plain = build_low_cmd_plain(cmd)
|
||||
assert plain[562:566] == b"\xa5\x01\x02\x03"
|
||||
|
||||
|
||||
def test_official_reserved_field_shapes_are_serialized():
|
||||
cmd = sdk.LowCmd()
|
||||
cmd.SN = [0x04030201, 0x08070605]
|
||||
cmd.version = [0x01020304, 0x05060708]
|
||||
cmd.reserve = 0x44332211
|
||||
|
||||
plain = build_low_cmd_plain(cmd)
|
||||
assert plain[4:12] == bytes.fromhex("0102030405060708")
|
||||
assert plain[12:20] == bytes.fromhex("0403020108070605")
|
||||
assert plain[606:610] == bytes.fromhex("11223344")
|
||||
|
||||
|
||||
def test_fixed_size_official_arrays_are_validated():
|
||||
cmd = sdk.LowCmd()
|
||||
cmd.wirelessRemote = [0] * 39
|
||||
with pytest.raises(ValueError, match="wirelessRemote"):
|
||||
build_low_cmd_plain(cmd)
|
||||
|
||||
|
||||
def test_highlevel_udp_reports_the_unsupported_boundary():
|
||||
udp = sdk.UDP(sdk.HIGHLEVEL, 8080, "192.168.123.161", 8082)
|
||||
cmd = sdk.HighCmd()
|
||||
udp.InitCmdData(cmd)
|
||||
udp.SetSend(cmd)
|
||||
|
||||
with pytest.raises(NotImplementedError, match="HighCmd/HighState UDP"):
|
||||
udp.Send()
|
||||
Reference in New Issue
Block a user