python部分官方化对齐接口

This commit is contained in:
cyy_mac
2026-07-29 21:50:15 +08:00
parent 81f14e14ca
commit dbfcb95566
15 changed files with 678 additions and 14 deletions

View File

@@ -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

View 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++ 程序直接重编译通过。

View File

@@ -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',

View File

@@ -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])

View File

@@ -0,0 +1,4 @@
"""Compatibility APIs for third-party Unitree SDK code."""
from .robot_interface import *
from .robot_interface import __all__

View 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",
]

View File

@@ -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):

View File

@@ -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',
]

View File

@@ -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

View File

@@ -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):
"""设置某个电机命令.

View File

@@ -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',

View File

@@ -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)}

View File

@@ -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
View 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__

View 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()