ROS 2 与 App 通讯 02:控制四台 ZDT 电机app/bricks/zdt_motor/README.md
# ZDT Motor Custom Brick
`zdt_motor` is a reusable, single-motor object API for ZDT second-generation
closed-loop stepper motors. V1 implements Classical CAN through Linux
SocketCAN and keeps motor commands independent from the transport.
## VENTUNO Q CAN 使用边界
```text
VENTUNO Q 螺钉式 CAN 接口
-> FDCAN1
-> 系统 CANnectivity
-> gs_usb
-> Linux SocketCAN
-> can0
-> SocketCANBackend
-> ZDTCanBus
-> ZDTMotor
```
这是当前 V1 唯一正式支持并经过实机验证的通信路径。VENTUNO Q 系统固件已经通过
CANnectivity 占用 FDCAN1,并把它作为 `gs_usb` 设备提供给 Linux `can0`。
因此,同一个 App 的 Sketch 不得再次调用 `CAN.begin()`、`CAN.write()`、
`CAN.available()` 或 `CAN.read()` 操作 FDCAN1。在当前 ArduinoCore-zephyr / loader
环境中,电机返回帧会进入 CANnectivity 到 Linux `can0` 的路径,但不会同时进入
Sketch 的 Arduino CAN 轮询接收队列。
The Brick never runs `sudo ip link`, changes bitrate, or brings an interface
up. System CAN setup stays outside the library.
## Layers
```text
ZDTMotor / RawMotorAPI
commands/common.py, commands/emm.py, commands/x.py
messages.py: LogicalCommand / ZDTResponse
ZDTCanBus
protocols/can.py: ZDTCanProtocol
CanBackend
SocketCANBackend
```
`ZDTMotor` does not import or call `python-can`. Only
`backends/socketcan.py` knows how SocketCAN is implemented.
## V1 Public API
以下名称属于 CAN V1 稳定公共 API,V1 冻结后不会因为内部重构轻易改名或删除:
```text
ZDTMotor
ZDTCanBus
ZDTBus
ZDTMotorBus
SocketCanEndpoint
BusKind
BusTrace
EndpointOwner
ChecksumType
Firmware
Direction
MotionMode
HomeMode
MotorConfig
ZDTError
ZDTBackendError
ZDTBusBusyError
ZDTCommandError
ZDTConfigurationError
ZDTFormatError
ZDTParameterError
ZDTProtocolError
ZDTTimeoutError
ZDTUnsupportedFeatureError
```
普通用户可以稳定使用 `motor.enable()`、`disable()`、`stop()`、`set_speed()`、
`move_relative()`、`move_absolute()`、`get_speed()`、`get_position()`、`get_status()`、
`home()`,以及 `bus.next_event()` 和 `bus.start_synchronized()`。
以下名称属于高级或内部实现 API:
```text
CanBackend
CanFrame
SocketCANBackend
LogicalCommand
ZDTResponse
ZDTCanProtocol
commands/*
protocols/*
backends/*
```
内部实现可以继续修正缺陷,但不能破坏上面的稳定公共 API。
### 请求超时与轮询等待时间
请求超时用于已经发送命令、正在等待电机应答的场景,必须是有限且大于零的数值:
```text
ZDTCanBus(default_timeout_s=...)
MotorConfig(timeout_s=...)
bus.request(..., timeout_s=...)
```
轮询等待时间允许填写 `0`,表示只检查当前队列并立即返回,不阻塞线程:
```text
SocketCANBackend.receive(timeout_s)
bus.next_event(timeout_s)
```
两类参数都拒绝布尔值、字符串、`NaN` 和正负无穷。请求超时还会拒绝 `0`;轮询等待
时间只拒绝负数。所有这些非法数值统一抛出 `ZDTConfigurationError`。
### response_address 契约
`ZDTMotorBus.request()` 的正式签名是:
```python
request(
address,
command,
*,
timeout_s=None,
response_address=None,
)
```
`response_address` 决定当前请求接受哪些电机地址的应答:
| 参数值 | 应答地址规则 |
| --- | --- |
| `None` | 只接受发送地址 `address` 返回的应答 |
| `int` | 只接受这个指定地址返回的应答 |
| `tuple/list/set/frozenset[int]` | 允许集合中的任意地址;第一个有效匹配应答完成请求 |
主要使用场景是 `set_motor_id()`:地址写入后,确认应答可能来自旧地址,也可能来自新地址。
普通电机命令不需要填写 `response_address`。空集合没有任何合法应答地址,会在打开
Backend 和发送 CAN 帧之前抛出 `ZDTConfigurationError`。
### 严格布尔参数
用户传入的 `enabled`、`synchronized`、`store` 和 `receive_own_messages` 只接受真正的
Python `True` 或 `False`。字符串 `"False"`、整数 `0/1`、`None`、列表和字典都不会被
自动转换,而是统一抛出 `ZDTConfigurationError`。
其中 `store` 决定配置是否写入电机 Flash,必须明确填写布尔值:
```python
motor.set_motor_id(2, store=True) # 永久写入
motor.set_motor_id(2, store=False) # 临时修改
```
不要使用 `store="False"`。V1 会在命令编码和 CAN 发送之前拒绝它,避免字符串被当成真值。
### Bus 生命周期并发规则
一个 `ZDTCanBus` 始终只拥有一个 Backend 和一个 Receiver Thread。`open()` 与 `close()`
使用独立的 lifecycle lock 串行化;它不复用保护 pending/assembly 的数据锁,也不复用
保证多帧连续发送的 send lock。
```text
_lock -> pending / assembly
_send_lock -> 一个LogicalCommand的多帧连续发送
_lifecycle_lock -> open / close
```
因此多台电机从多个线程同时第一次发起请求时,Backend 只打开一次,也只启动一个接收线程。
## Basic use: one motor on can0
```python
from zdt_motor import SocketCanEndpoint, ZDTCanBus, ZDTMotor
can_endpoint = SocketCanEndpoint(
interface="can0",
expected_bitrate=500_000,
physical_port="VENTUNO Q FDCAN1 via CANnectivity",
)
with ZDTCanBus(name="motor_can", endpoint=can_endpoint) as can_bus:
motor = ZDTMotor(
bus=can_bus,
model="X57S",
motor_id=1,
firmware="emm",
)
print(motor.get_position())
print(motor.get_speed())
print(motor.get_status())
```
`ZDTCanBus` clearly says that this object is a CAN bus. Its `endpoint`
records which Linux SocketCAN interface it uses. `expected_bitrate` is
configuration metadata for display and checking; it does not change Linux
network settings or run `sudo ip link`.
One physical CAN line should have one shared `ZDTCanBus` object. One or more
motor objects on that line reuse it:
```python
with ZDTCanBus(endpoint=SocketCanEndpoint(interface="can0")) as can_bus:
motors = [
ZDTMotor(bus=can_bus, model="X57S", motor_id=motor_id, firmware="emm")
for motor_id in (1, 2, 3, 4)
]
```
Old code that imports `ZDTBus` or passes `device="can0"` still works. New
code should use `ZDTCanBus` and `SocketCanEndpoint` because their meanings are
clearer.
Useful diagnostic fields:
```python
print(can_bus.kind.value) # can
print(can_bus.endpoint.interface) # can0
print(can_bus.endpoint.owner.value) # linux
print(can_bus.describe())
```
## User API
- Control: `enable()`, `disable()`, `stop()`, `safe_stop_and_disable()`
- Speed: `set_speed(rpm=..., direction=..., acceleration=...)`
- Position: `move_relative(degrees=...)`, `move_absolute(degrees=...)`
- Read: `get_version()`, `get_speed()`, `get_position()`,
`get_target_position()`, `get_position_error()`, `get_status()`,
`get_bus_voltage()`, `get_phase_current()`, `get_phase_parameters()`,
`get_encoder_degrees()`, `get_input_pulses()`
- Homing: `home()`, `abort_home()`, `get_home_status()`
- Configuration: `set_motor_id()`, `set_microstep()`,
`set_current_limit()`, `set_direction()`
- Capability: `supports(feature)`
- Raw logical command: `motor.raw.request(...)`
- CAN frame inspection: `can_bus.encode_frames(motor_id, command)`
`motor.raw` 只表示 ZDT 原始逻辑命令,不返回 `CanFrame`。需要检查实际 CAN 编码时,
应显式使用 `ZDTCanBus.encode_frames()`;CAN arbitration ID、分包和经典 CAN 帧都属于
CAN 层,而不是电机对象。
## Emm and X firmware
`firmware="emm"` and `firmware="x"` select different encoders for the F6 and
FD commands. Emm speed uses integer RPM plus an acceleration level from 0 to
255. X speed uses 0.1 RPM and acceleration in RPM/s. Emm position converts
degrees to pulses using motor step angle and microstep; X position sends
0.1-degree units with separate acceleration and deceleration.
Never select firmware based on guesswork. It must match the motor's `FWType`.
## X57S capabilities
The V1.0.5 manual confirms X57S in the product table and confirms common
second-generation commands. The following commands are deliberately disabled
because the manual marks them `(X42S/Y42)` rather than X57S:
- temperature and bus-current reads
- multi-motor aggregate command
- periodic-return command
- combined home/motor status and pin-level reads
- position-window, heartbeat, collision-return-angle, and broadcast-ID helpers
Calling a disabled method raises `ZDTUnsupportedFeatureError` before any CAN
frame is sent.
## CAN protocol
- Classical CAN, never CAN FD/BRS
- Extended ID: `(Addr << 8) | Packet`
- Packet numbering starts at zero
- CAN DATA omits the address
- Every continuation packet repeats the function code in byte zero
- Checksum covers the serial-form address, function code, and payload
- Supported checksum modes: fixed `0x6B`, XOR, and the exact manual CRC8 table
The response router checks address, packet sequence, function code, response
length, checksum, and status. `0x02`, `0x12`, `0x22`, `0xE2`, `0xEE`, and
`0x9F` are handled explicitly. Unmatched active returns are placed in the bus
event queue for later async APIs.
## App Lab container boundary
App Lab 0.12.1 runs `python/main.py` in a Docker network namespace. A host
network interface such as `can0` is not automatically visible in that
container. Therefore, use `arduino-app-cli app start` to build the App and its
virtual environment, then run hardware scripts on the VENTUNO Linux host with
`scripts/run_host_python.sh`. The helper reuses the Brick packages while
calling the host `python3`, whose SocketCAN namespace contains `can0`.
## 电机菜单配置前提
Brick 会按照构造 `ZDTMotor` 时提供的软件配置解释速度、角度和位置。使用前必须
确认这些值与电机菜单一致:
- `FW_Emm` 对应 `firmware="emm"`,`FW_X` 对应 `firmware="x"`。
- `checksum` 必须与电机菜单中的 `Checksum` 设置一致。
- `microstep` 必须与电机菜单中的 `MStep` 一致。
- `step_angle_degrees` 必须与电机菜单中的 `MotType` 一致,只能填写实际使用的
`0.9` 或 `1.8` 度步距角。
- Emm 固件的命令速度缩放选项按手册默认配置解释。如果电机菜单被改成“命令速度值
缩小 10 倍输入”,必须先恢复默认值或在确认换算规则后再使用 Brick。
- X 固件的命令位置角度缩放选项按手册默认配置解释。如果电机菜单被改成“命令位置
角度继续缩小 10 倍输入”,必须先恢复默认值或在确认换算规则后再使用 Brick。
`Response` 决定控制动作命令怎样应答,必须与当前请求模型匹配:
- `Receive`:适用于当前 Brick。电机收到控制命令后立即返回确认,`request()` 正常
返回;此模式不会返回动作完成 `0x9F`,所以 `bus.next_event()` 不一定能收到运动
完成事件。
- `Both`:需要异步运动完成通知时推荐使用。电机先返回即时确认,动作真正完成后再
返回 `0x9F`,完成事件进入 `bus.next_event()`。
- `Reached`:当前 V1 高层控制 API 不支持。电机可能只在动作完成后返回 `0x9F`,
而 `request()` 仍在等待即时确认,最终可能抛出 `ZDTTimeoutError`。
- `None`:当前 V1 高层控制 API 不支持。电机不返回控制命令确认,`request()` 会
超时。
- `Other`:位置命令可能只返回完成事件而不返回即时确认,与当前
`move_relative()`/`move_absolute()` 请求模型不兼容,可能出现电机已经动作但
Python 请求超时。
Brick 不会自动猜测、查询或修改电机的 `Response` 设置。
Brick 当前不会自动读取这些菜单参数,也不会静默猜测被修改过的配置。配置不一致时,
即使 CAN 通信正常,电机的实际速度或位置也可能与代码目标不同。
## V1 范围与未来扩展原则
V1 只正式支持 VENTUNO Q 上已经验证的 Linux SocketCAN `can0`。代码允许显式填写其他
已经存在的 SocketCAN 接口名,但不会自动发现 `can1`,也不代表其他接口已经在 VENTUNO Q
上验证。TTL、RS485、Modbus RTU、D0/D1 UART、Bridge UART、Bridge CAN 和其他 ZDT
通信协议当前都没有实现。
以后出现真实需求时,应为新的通信方式增加对应的 Bus、Protocol、Backend 和 Endpoint,
而不是把串口开关加入 `ZDTCanBus`,也不应重写 `ZDTMotor`、`commands/common.py`、
`commands/emm.py` 或 `commands/x.py`。V1 不为尚未使用的通信方式创建空类、空目录、
Factory、Registry 或 TransportManager。
当前冻结的依赖方向是:
```text
ZDTMotor
-> LogicalCommand
-> ZDTCanBus
-> ZDTCanProtocol
-> CanBackend
-> SocketCANBackend
-> Linux can0
```