feat: 初始化

This commit is contained in:
Horldsence
2026-09-22 20:05:16 +08:00
commit 7d87987e41
54 changed files with 6446 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# 跨平台交叉编译配置。
#
# armv7(目标板)使用 musl 静态链接:目标产物不依赖板端 libc 版本,
# 且 rust-std 自带全部 crt 对象,配合 Rust 自带的 rust-lld 即可在
# macOS / Linux 宿主机上直接交叉编译,无需安装外部交叉工具链。
#
# rustup target add armv7-unknown-linux-musleabihf
# cargo build --release -p chassisd --target armv7-unknown-linux-musleabihf
[target.armv7-unknown-linux-musleabihf]
linker = "rust-lld"
+81
View File
@@ -0,0 +1,81 @@
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
lint-and-test:
name: fmt / clippy / test / doc
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: rustfmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: test
run: cargo test --workspace
- name: doc
env:
RUSTDOCFLAGS: -D warnings
run: cargo doc --workspace --no-deps
vcan-integration:
name: vcan0 integration (real kernel CAN stack)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: set up vcan0
run: |
sudo modprobe vcan
sudo ip link add dev vcan0 type vcan
sudo ip link set up vcan0
- name: run vcan tests
run: cargo test -p dji-esc-socketcan -- --ignored --test-threads 1
cross-aarch64:
name: cross check aarch64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-gnu
- uses: Swatinem/rust-cache@v2
- run: cargo check --workspace --target aarch64-unknown-linux-gnu
build-armv7:
name: build armv7 static (board target)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2
- run: cargo build --release -p chassisd --target armv7-unknown-linux-musleabihf
coverage:
name: coverage (llvm-cov)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@cargo-llvm-cov
- run: cargo llvm-cov --workspace --lcov --output-path lcov.info
- uses: actions/upload-artifact@v4
with:
name: coverage-lcov
path: lcov.info
+1
View File
@@ -0,0 +1 @@
/target
+384
View File
@@ -0,0 +1,384 @@
# 审计 + 电机抽象层改造 + 实车台架单轮测试
## 目标(用户已确认)
1. **审计**现有抽象层级与抽象功能是否正确。
2. **改造抽象层**(本次就做),为后续派生铺路:
- 云台 = **GM6020**(位置环,绝对/多圈角度)
- 拨弹轮 = **M2006 + C610**(速度环 + 定量拨弹位置环)
3. **小车轮子测试** = 实车台架单轮验证(现有 4× GM3508 轮子)。
4. **硬约束**:合适的 Rust 抽象层与 crate,**明确单一职责**。
5. **架构决策**(本轮确认):
- **双 CAN 总线**:底盘一条 CAN,云台/拨弹另一条 CAN(协议隔离,不用避让 ID)。
- **必须预留 IMU 融合口**:云台绝对角需要 `HeadingSource` 类 seam。
- **wheel-bench 分阶段自动跑**。
---
## 一、审计结论
### 1.1 分层现状(正确的部分,保留)
```
chassisd ──► mecanum ──► motor-core::Motor ◄── dji-esc (EscBus) ──► CanLink ──► dji-esc-socketcan
```
| 做对的事 | 证据 |
|---|---|
| 协议与 I/O 解耦 | `CanLink` = tokio mpsc 对;`motor-core`/`dji-esc` 零真实 I/O |
| 命令 / 反馈分离 | `Command` enum vs `MotorState` struct |
| 方向修正对称 | `EscConfig::direction=±1`,读写双向翻转(`bus.rs:163-203`) |
| 纯算法可测 | `Pid`(抗饱和、D-on-measurement、滤波)、`VelocityController` |
| 多圈累计 | `TurnTracker` 8192 编码器回绕(`turn.rs`) |
| 安全三件套 | 上电门控 / 反馈离线 0 电流 / 命令超时归零 |
| 仿真闭环 | `sim::spawn_plant` + `closed_loop.rs` + `--mock` 全平台可跑 |
| 运动学纯数学 | `mecanum` 正逆解 + proptest |
| 组合根 | `chassisd` 装配 + `TwistSource` seam |
**结论:轮子(C620 电流环 + 主机速度环)路径是通的。**
### 1.2 单一职责违规 / 抽象缺口(必须改)
| # | 问题 | 位置 | SRP 违规 |
|---|---|---|---|
| G1 | `Motor` 无位置 API | `motor-core/src/lib.rs:51-79` | 能力面不足 |
| G2 | `MotorState.wheel_speed_rpm` | `lib.rs:32`, `bus.rs:171` | **底盘/减速比泄漏进协议无关层** |
| G3 | 无 `PositionController` | `motor-core` | 云台/拨弹阻塞 |
| G4 | 主机速度环写死在 `EscBus::compute_command` | `bus.rs:181-203` | **控制律与协议组帧/安全耦合** |
| G5 | `EscModel` 无 GM6020 | `codec.rs:23-30` | 电压协议族缺失 |
| G6 | `tx_loop` 硬编码 0x200/0x1FF 电流槽 | `bus.rs:369-388` | 协议族绑定过死 |
| G7 | `ChassisController` 依赖 `EscMotor` + 自己乘 gear | `controller.rs:25,83` | 应用层依赖协议 crate |
| G8 | `Command` 切模式不复位 PID | `bus.rs:192-199` | 拨弹速度↔位置积分冲击 |
| G9 | 无 `MockMotor` | — | 上层无法脱离 EscBus 测 |
| G10 | 配置写死恰好 4 电机 | `config.rs:181` | 无法扩设备段 |
| G11 | `CanFrame`/`CanLink` 在 `dji-esc::frame` | `frame.rs` | **传输被单个协议族占有** |
| G12 | 无航向/绝对角抽象 | — | 云台场地绝对角需 IMU 融合(用户要求预留) |
### 1.3 CAN ID 问题 —— 由「双 CAN」架构决策消解
`0x1FF` / `0x205` 在 C620 与 GM6020 两族中含义不同(电流 vs 电压)。**用户选择双 CAN 总线**:
| 总线 | 设备 | 协议 | ID | 命令帧 |
|---|---|---|---|---|
| **can0 底盘** | FL/FR/RL/RR 轮 | C620+GM3508 | 1–4 | `0x200` |
| **can1 功能** | 拨弹轮 | C610+M2006 | 1–4(本总线内) | `0x200` |
| **can1 功能** | 云台 | GM6020 | 1–4(本总线内) | `0x1FF` |
- 每条 CAN 一个独立 `CanLink` + 一个协议族 bus;**物理隔离**后 ID 空间独立,无需避让。
- 仍建议配置层按「每总线一协议族」校验,避免同总线混接时踩坑。
- 组合根按接口名装配:`chassisd --interface can0 --gimbal-interface can1`(或配置 `[can]`/`[can.function]`)。
---
## 二、目标设计(单一职责)
### 2.1 目标 crate / 模块树
```
crates/
motor-core/ # 协议无关电机域:能力 trait、状态、命令寄存器、控制算法、mock(零依赖、无 tokio)
src/
lib.rs # 导出 + 单位/方向约定
state.rs # MotorState(转子轴物理量,无 gear_ratio)
command.rs # Command + CommandMode
traits.rs # MotorFeedback / EffortDrive / VelocityDrive / PositionDrive / Disable
heading.rs # HeadingSource(IMU/绝对角融合 seam,新增)
pid.rs # Pid(保持)
velocity.rs # VelocityController(输出泛化为 effort_raw)
position.rs # PositionController(新增:Direct | Cascade)
gearbox.rs # OutputShaft<T>:输出轴单位适配(新增)
testing.rs # MockMotor / MockHeading(新增)
can-link/ # CAN 传输:CanFrame + CanLink(新增;无协议知识)
dji-esc/ # C620/C610 电流协议族:编解码 + 总线调度/安全 + sim
dji-gm6020/ # GM6020 电压协议族(本次只留 seam 文档,实现=后续)
dji-esc-socketcan/ # SocketCAN 桥(只依赖 can-link;支持多接口)
examples/
spin.rs # 保留:最小抽象演示
wheel-bench.rs # 新增:实车台架单轮验证(分阶段自动)
mecanum/ # 纯运动学(不动)
chassisd/ # 组合根:配置 / 1kHz 底盘循环 / 安全 / --mock
docs/
architecture.md # 更新分层图(双 CAN)
motor-abstraction.md # trait 契约与单位(新增)
bench-single-wheel.md# 台架流程(新增)
```
**裁剪原则**:只新增 `can-link` + `dji-gm6020` seam + `heading.rs` 模块。不拆 `motor-controllers`、暂不建 `gimbal`/`feeder` crate。
### 2.2 motor-core:能力拆分(小 trait)
```rust
// traits.rs —— 能力面,可组合,静态分发
pub trait MotorFeedback: Send + Sync {
fn id(&self) -> u8;
fn state(&self) -> MotorState;
}
pub trait EffortDrive: Send + Sync {
/// 开环 raw(ESC=电流,GM6020=电压)。
fn set_effort_raw(&self, raw: i16);
}
pub trait VelocityDrive: Send + Sync {
/// 目标转子转速(rpm,物理方向)。
fn set_velocity(&self, rotor_rpm: f32);
}
pub trait PositionDrive: Send + Sync {
/// 目标转子多圈位置(圈,物理方向)。
fn set_position(&self, rotor_rev: f32);
}
pub trait Disable: Send + Sync {
fn disable(&self);
}
/// 便利别名(blanket impl,零成本)。
pub trait Motor: MotorFeedback + VelocityDrive + EffortDrive + Disable {}
impl<T: MotorFeedback + VelocityDrive + EffortDrive + Disable> Motor for T {}
```
**为什么不是胖 `Motor` trait**:能力是硬件事实。消费者只声明用到的能力:
- 底盘 → `VelocityDrive + MotorFeedback`
- 云台 → `PositionDrive + EffortDrive`(+ 上层 `HeadingSource`)
- 拨弹 → `VelocityDrive + PositionDrive`
- 台架 → `VelocityDrive + MotorFeedback + Disable`
**`EffortDrive` 统一命名**:两族都是 i16 开环命令,仅物理量不同(A vs V);协议 crate 文档化单位。
### 2.3 IMU / 绝对角融合 seam(用户要求)
```rust
// heading.rs —— 不属于电机,属于「航向观测」
/// 绝对航向观测源(IMU / 磁力计 / 视觉等)。
pub trait HeadingSource: Send {
/// 机体航向角(rad,连续;定义与 chassis ISO 约定一致)。
fn heading_rad(&self) -> impl Future<Output = f32> + Send;
}
/// 云台目标角的参考系。
pub enum HeadingFrame {
/// 电机转子多圈(上电相对)。
RotorMultiTurn,
/// 场地绝对角(经 HeadingSource 对齐)。
AbsoluteField,
}
```
- **motor 层只保证 `rotor_revolutions` 连续**;场地绝对角由未来 `gimbal` crate 融合 IMU(`HeadingSource`)与电机多圈。
- `PositionDrive::set_position` 仍是转子圈;绝对角 → 转子圈的映射在云台控制器,不在 motor-core。
- `MockHeading` 放 `testing.rs`。
### 2.4 `Command` / `MotorState`
```rust
pub enum Command {
Disabled,
EffortRaw(i16), // 原 CurrentRaw
Velocity(f32), // rotor rpm
Position(f32), // rotor revolutions(圈,连续)
}
pub enum CommandMode { Disabled, EffortRaw, Velocity, Position }
pub struct MotorState {
pub rotor_angle_raw: u16,
pub rotor_revolutions: f32,
pub rotor_speed_rpm: f32,
pub effort_raw: i16, // 原 torque_current_raw
pub temperature_c: i8,
pub feedback_count: u64,
}
// 删除 wheel_speed_rpm(G2)
```
**模式切换复位**(G8):`compute_effort` 在 `cmd.mode()` 变化时 reset 控制器。
### 2.5 motor-core:控制算法
| 类型 | 模式 | 输入 → 输出 |
|---|---|---|
| `Pid` | — | setpoint, meas, dt → f32 |
| `VelocityController` | — | target_rpm, meas_rpm, dt → effort_raw |
| `PositionController` **新增** | `Direct(Pid)` | pos_rev, meas_rev, dt → effort_raw(云台电压) |
| | `Cascade{pos, vel}` | 位置→限速→速度环→effort_raw(拨弹定量) |
控制律纯函数放 motor-core;**执行节拍/安全/方向/限幅**留在各协议族 bus。
### 2.6 gearbox:`OutputShaft<T>`
```rust
/// 输出轴单位 ↔ 转子单位。gear_ratio = 转子 : 输出轴。
pub struct OutputShaft<T> { inner: T, gear_ratio: f32 }
impl<T: VelocityDrive> VelocityDrive for OutputShaft<T> {
fn set_velocity(&self, output_rpm: f32) {
self.inner.set_velocity(output_rpm * self.gear_ratio);
}
}
// Position 同理;Feedback/Effort/Disable 透传
```
### 2.7 can-link(新 crate)
`CanFrame`、`CanLink`、`CanLink::pair`。**不含 DJI ID 常量**。`motor-core` 保持无 tokio。
### 2.8 dji-esc(电流协议族)
- `EscConfig::m2006(name, esc_id, direction)`(C610、36:1 待核)。
- 算法改调 motor-core;`compute_effort` 加 `Position` 分支 + 模式 reset。
- 电流槽分组保持(协议事实)。
### 2.9 dji-gm6020(设计 seam,不实机)
- 实现 5 个能力 trait。
- 命令 `0x1FF`(ID1–4)/`0x2FF`(ID5–7) 电压槽;反馈 `0x204+id`;`TurnTracker` 复用。
- 云台位置环:`PositionController` → voltage raw。
- 绝对角:未来 `gimbal` crate 用 `HeadingSource` + `HeadingFrame::AbsoluteField` 对齐到 `rotor_revolutions` 基准后再 `set_position`。
### 2.10 拨弹 M2006/C610 派生路径(设计,不实现)
- 复用 `dji-esc` + `EscConfig::m2006`;定量 = `Position` + Cascade;连续 = `Velocity`。
- FSM 在上层,不在 motor-core。
### 2.11 chassisd(组合根)
```rust
pub struct ChassisController<W> {
kinematics: Mecanum,
wheels: [W; 4],
limits: (f32, f32, f32),
command_timeout: Duration,
}
impl<W: VelocityDrive + MotorFeedback> ChassisController<W> { ... }
```
- **不 import `dji_esc`**。
- 配置:底盘 `[can]` + 四轮;预留 `[can.function]` / `[gimbal]` / `[feeder]` seam 注释(双 CAN 接口名)。
- 不写云台/拨弹逻辑。
---
## 三、实车台架单轮验证(小车轮子测试)
### 3.1 工具:`wheel-bench`(分阶段自动跑)
```sh
cargo run --release -p dji-esc-socketcan --example wheel-bench -- \
--interface can0 --esc-id 1 --direction 1 --gear-ratio 19.203 \
--current-limit 4000 --rpm 300 --secs 3
```
一条命令顺序执行 stage 0–6,输出 CSV + 结论;ctrl-c 任意点安全停机。
| Stage | 动作 | 观察 | 通过标准 |
|---|---|---|---|
| 0 Link | `disable()` | 反馈帧率 ~1kHz、温度 | 2s 内有反馈 |
| 1 Direction | `set_effort_raw(±limit/4)` 点动 | `rotor_speed_rpm` 符号 | 与 `direction`/前进约定一致 |
| 2 Vel step | `set_velocity(+target)` | 超调、稳态误差 | 1s 后 \|err\|<5% |
| 3 gear_ratio | 对比转子 rpm 与手测轮端 | 比值 | ≈19.203 ±3% |
| 4 Reverse | `set_velocity(-target)` | 对称性 | 对称 |
| 5 Disable | `disable()` | rpm→0 | 停转 |
| 6 Offline(可选) | 运行中断电调 | 离线强制 0 电流 | 无重启冲击 |
安全内建:默认低限流、总时限、退出路径 `disable()+50ms` 刷帧、无反馈拒启动、单电机、打印「架空禁止落地」。保留 `spin.rs`。
### 3.2 流程(`docs/bench-single-wheel.md`)
1. **架空车轮**,急停可及;`ip link set can0 up type can bitrate 1000000 restart-ms 100`。
2. 首次 `--current-limit 3000~5000`。
3. 一次只驱一台;顺序 FL→FR→RL→RR。
4. 跑完自动 stage;方向错**改配置 `direction`**。
5. 记录 `direction` 终值、实测 gear_ratio、PID 备注。
6. 填表归档后才考虑落地四轮联动(不在本次)。
**通过标准**:反馈无连续丢帧;方向一致;无持续振荡、稳态 <5%;减速比偏差 <3%;`disable`/离线停转、电流 0。
---
## 四、迁移步骤(每步可编译、测试绿)
| 步 | 内容 |
|---|---|
| M0 | 基线 `cargo test --workspace && just lint` |
| M1 | 新建 `can-link`;`dji-esc::frame` re-export 兼容 |
| M2 | `dji-esc-socketcan` 改依赖 `can-link`;收缩 `frame.rs` |
| M3 | motor-core 拆 `state/command/traits/heading`;**一次破坏性重命名** `CurrentRaw→EffortRaw`、`torque_current_raw→effort_raw`;删 `wheel_speed_rpm` |
| M4 | 能力 trait 落地;`EscMotor` 实现;`Motor` blanket 别名 |
| M5 | `OutputShaft<T>`;`ChassisController` 泛型化;daemon 测试 gear 换算 |
| M6 | `Command::Position` + `CommandMode` 模式 reset;`PositionController` + 单测;`dji-esc` Position 分支 |
| M7 | `MockMotor`/`MockHeading`;改写 1 个 chassis 单测证明 seam |
| M8 | `EscConfig::m2006()`;双 CAN 配置 seam + 每总线单协议族校验 |
| M9 | `wheel-bench`(分阶段自动)+ `docs/bench-single-wheel.md` |
| M10 | 文档:`architecture.md`(双 CAN 图)、`motor-abstraction.md`、README |
| M11(后续) | `dji-gm6020` codec+bus+sim |
| M12(后续) | `gimbal` crate:`HeadingSource` 融合 + 云台控制 |
---
## 五、关键文件
**新建**
- `crates/can-link/{Cargo.toml,src/lib.rs}`
- `crates/motor-core/src/{state,command,traits,heading,position,gearbox,testing}.rs`
- `crates/dji-esc-socketcan/examples/wheel-bench.rs`
- `docs/{motor-abstraction,bench-single-wheel}.md`
**修改**
- `Cargo.toml`(workspace)
- `crates/motor-core/src/{lib,velocity}.rs`
- `crates/dji-esc/src/{lib,bus,codec,sim}.rs`、`tests/closed_loop.rs`
- `crates/dji-esc-socketcan/src/bridge.rs`、`examples/spin.rs`、`Cargo.toml`
- `crates/chassisd/src/{controller,config,main}.rs`、`tests/daemon.rs`
- `config/chassis.toml.example`、`docs/architecture.md`、`README.md`、`justfile`
**删除 / 迁出**
- `crates/dji-esc/src/frame.rs`(类型迁 can-link)
- `MotorState.wheel_speed_rpm`、`Command::CurrentRaw`、`torque_current_raw`
---
## 六、验证
```sh
cargo test --workspace
just lint
cargo run -p chassisd -- --mock
just vcan-test
just build-armv7
cargo build --release -p dji-esc-socketcan --example wheel-bench
cargo build --release -p dji-esc-socketcan --example spin
# 实车台架见 §3
```
**Definition of Done**
1. `cargo test --workspace` + `just lint` 绿。
2. `chassisd --mock` 行为不变。
3. `rg 'wheel_speed_rpm|CurrentRaw|torque_current_raw'` 为空。
4. `rg 'dji_esc' crates/chassisd/src/controller.rs` 为空。
5. `motor-core` 无运行时依赖。
6. `wheel-bench` + 台架文档就位;实车四轮台架表归档。
7. `HeadingSource` seam + 双 CAN 配置 seam 就位;**`chassisd` 无云台/拨弹逻辑**。
---
## 七、风险
| 风险 | 缓解 |
|---|---|
| 双 CAN 板端接口(can1)是否可用 | 配置可单 CAN 起步(只填 `[can]`);功能段可选 |
| GM6020 电压/反馈标度未核手册 | 常量隔离在 `dji-gm6020::codec`;上电前台架核对 |
| C610/M2006 36:1?±10000↔±10A | `m2006()` 标注接入前核对 |
| `OutputShaft` 单位混淆 | 参数名 `output_rpm`/`output_rev` + 文档 |
| 删 `wheel_speed_rpm` 破坏日志/测试 | M3/M5 同步改 |
| 模式切换 reset 惊吓 | 文档 + `debug!` |
| `HeadingSource` 的异步形态是否合适 | 先定 trait 契约;M12 可按 IMU 驱动改 `async fn` / watch |
| 同总线误接混协议 | 配置校验「每总线一协议族」+ 文档警告 |
---
## 八、不在本次范围
- 云台 / 拨弹控制逻辑与整定
- GM6020 实机驱动完整实现(M11)
- IMU 驱动与航向融合实现(M12,仅 seam)
- 四轮落地联动测试
- 遥控器 / 导航 TwistSource
@@ -0,0 +1,75 @@
# 麦轮底盘仓库基础搭建方案(Linux ARM 主控 + 4×GM3508/C620)
## 已确认决策
- **主控**:Linux ARM 板,CAN 经 **SocketCAN (can0)** 接入(USB转CAN / CAN HAT)
- **并发模型**:tokio 异步
- **架构**:5 crate 分层,通用职能命名(不含仓库名 sentry-chassis,无 rm- 前缀;五个名字已在 crates.io 确认全部未占用)
## 关键协议事实(编码实现依据,来自 C620 官方手册调研)
- 控制:ID **0x200** 携带电调 1–4 的电流命令,**0x1FF** 携带 5–8;每电机 i16 **大端**,±16384 ↔ ±20A(C620)
- 反馈:ID **0x200+电调ID**(0x201–0x208),8 字节 = 机械角(0..8191, u16) + 转速 rpm(i16) + 转矩电流(i16) + 温度℃(i8)
- C620 **只闭电流环**,速度环/位置环须在主控侧用 PID 实现;GM3508 减速比 3591/187 ≈ 19.2
## 仓库结构(Cargo workspace,虚拟清单,删除现有 src/main.rs)
```
sentry-chassis/
├── Cargo.toml # [workspace] resolver="3" + workspace.package/dependencies/lints
├── rust-toolchain.toml # stable + rustfmt/clippy 组件
├── rustfmt.toml / clippy.toml
├── justfile # lint / test / vcan-test / run-mock
├── .github/workflows/ci.yml
├── README.md / LICENSE(MIT) / docs/architecture.md / deploy/chassisd.service
└── crates/
├── motor-core/ # 电机抽象层(纯逻辑,无 IO)
├── dji-esc/ # C620/C610 协议层(帧编解码 + 异步驱动,纯逻辑可全平台测)
├── dji-esc-socketcan/ # SocketCAN 后端(Linux only,非 Linux 编译为空壳)
├── mecanum/ # 麦轮正逆运动学(纯数学)
└── chassisd/ # 守护进程二进制(组装一切,支持 --mock 无硬件运行)
```
## 各 crate 设计
### 1. `motor-core` — 抽象与控制(依赖:仅 tokio/thiserror)
- `MotorState`:转子原始角、连续累计角(多圈)、转子 rpm、电流、温度、减速比换算的轮端速度
- `Motor` trait:`id() / state() / set_velocity(rpm) / set_current_raw(i16) / disable()`,句柄可克隆、内部共享状态,供 chassisd 与测试用 mock 泛化
- `Pid`(f32,显式 dt):输出限幅、积分抗饱和(clamping)、测量值微分 + 一阶低通滤波
- `VelocityController`:速度环封装(由驱动层按反馈节拍调用)
### 2. `dji-esc` — DJI 电调协议(依赖 motor-core + tokio)
- 自带最小 `CanFrame{ id, data:[u8;8] }` 与异步 `CanPort` trait(send/recv)——不依赖 socketcan,测试用 channel 实现
- 纯函数编解码:`encode_current_frame`(0x200/0x1FF 分组、大端、饱和 ±16384)、`decode_feedback`(0x201–0x208),C620/C610 双型号量程
- `TurnTracker`:0..8191 → 连续多圈角(正反向回绕处理)
- `EscBus<P: CanPort>`:spawn 收发任务;rx 分发反馈到各 `C620Motor` 句柄(含方向翻转、速度 PID tick),tx 每 1kHz 汇装两帧控制报文;`EscConfig{ esc_id, 型号, 减速比, 方向, 电流限幅 }`
- 反馈超时检测(离线 → 电流清零)
### 3. `dji-esc-socketcan` — SocketCAN 后端(依赖 dji-esc + socketcan v4 "tokio" feature)
- `SocketCanPort::open("can0")` 实现 `CanPort`;socketcan Frame ↔ CanFrame 转换
- crate 主体 `#[cfg(target_os="linux")]`,macOS 上编译为空壳 → 全 workspace 可在 mac 开发机直接 build/test
### 4. `mecanum` — 麦轮运动学(纯数学,零依赖)
- `Mecanum{ lx, ly, wheel_radius }`,ISO 约定(x前 y左 ω逆正,45°辊子)
- `inverse(Twist) -> [4] 轮角速度`(FL/FR/RL/RR)、`forward([4]) -> Twist`(伪逆)、rpm↔rad/s 换算
### 5. `chassisd` — 守护进程(依赖以上全部 + clap/serde/toml/tracing)
- CLI:`--interface can0 --config chassis.toml --mock`
- `--mock`:进程内 CanPort + 一阶电机模型,**无硬件/macOS 可跑通全链路**
- 1kHz 控制循环:Twist → mecanum 逆解 → 4 路 `set_velocity`;`TwistSource` trait(当前 Zero/Mock 实现,遥控器后续接入)
- 安全:命令超时清零、反馈离线清零、上电等首帧反馈后才使能;tracing 日志、ctrl-c 优雅退出、systemd 部署样例
## 底层库配置(workspace.dependencies 统一管理)
tokio(rt-multi-thread,macros,sync,time) / socketcan v4+tokio(仅 Linux target 依赖;若 4.0 有问题回退 3.6.2)/ thiserror / tracing + tracing-subscriber / serde+toml(配置) / clap(bin) / proptest+approx(dev)。`[workspace.lints]`:clippy pedantic 子集 + 库 crate 禁 unwrap/expect。
## 充分测试策略
1. **单元(全平台)**:编解码官方字节级样例与边界值(±16384 饱和、负值、全部 ID 路由)、多圈回绕序列、PID(阶跃/限幅/抗饱和恢复/微分滤波)、运动学(纯平移/纯旋转/对角线/零点 + proptest 往返一致性)、配置解析
2. **闭环仿真(全平台)**:mock CanPort + 一阶电机模型(电流→rpm 增益与限速),4 电机速度环收敛、群帧组装、离线安全、`--mock` 守护进程全链路(tokio::time::pause 控时)
3. **vcan 集成(Linux 专属,#[ignore] + CI 显式跑)**:vcan0 上双 socket 收发,真实内核 CAN 栈回环验证
4. **CI(GitHub Actions)**:fmt --check / clippy -D warnings / test / doc / vcan job(modprobe vcan)/ **aarch64-unknown-linux-gnu 交叉 check**(目标板架构)/ llvm-cov 覆盖率
## 实施步骤
1. 工作区脚手架:根 Cargo.toml(虚拟清单)、toolchain/lint/fmt 配置、删 src/main.rs、README、docs、justfile、CI、LICENSE
2. `motor-core`:类型 + Motor trait + PID + 单测
3. `dji-esc`:CanFrame/CanPort、编解码、TurnTracker、EscBus 异步驱动、闭环仿真测试
4. `dji-esc-socketcan`:CanPort 实现 + vcan 集成测试
5. `mecanum`:正逆解 + proptest
6. `chassisd`:配置/CLI/mock 模式/控制循环/安全逻辑 + 测试、deploy 样例
7. 本机验证:cargo fmt / clippy / test 全绿(macOS 上 Linux 专属测试自动跳过)
+33
View File
@@ -0,0 +1,33 @@
/target
# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
.git/
.hg/
.svn/
node_modules/
bower_components/
jspm_packages/
__pycache__/
site-packages/
venv/
coverage/
htmlcov/
lcov-report/
cmakefiles/
cmake-build-*/
bazel-*/
pods/
deriveddata/
storybook-static/
playwright-report/
test-results/
allure-results/
allure-report/
cdk.out/
*.egg-info/
*.dist-info/
eggs/
pip-wheel-metadata/
wheels/
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
# 自定义规则写在下方(本行提示可删除)
Generated
+1226
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
[workspace]
resolver = "3"
members = [
"crates/motor-core",
"crates/can-link",
"crates/dji-esc",
"crates/dji-esc-socketcan",
"crates/mecanum",
"crates/chassisd",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.89"
license = "MIT"
# 部署二进制去符号表(armv7 3.2MB → ~1MB)
[profile.release]
strip = true
[workspace.dependencies]
# 异步运行时
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
# CAN 总线(仅 Linux,由 dji-esc-socketcan 按 target 引入)
socketcan = { version = "4", features = ["tokio"] }
# 错误定义与结构化日志
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# 配置与命令行(chassisd)
serde = { version = "1", features = ["derive"] }
toml = "0.9"
clap = { version = "4", features = ["derive"] }
# 测试
proptest = "1"
approx = "0.5"
# workspace 内部 crate
motor-core = { path = "crates/motor-core" }
can-link = { path = "crates/can-link" }
dji-esc = { path = "crates/dji-esc" }
dji-esc-socketcan = { path = "crates/dji-esc-socketcan" }
mecanum = { path = "crates/mecanum" }
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
pedantic = { level = "warn", priority = -1 }
unwrap_used = "deny"
expect_used = "deny"
dbg_macro = "deny"
# 协议编解码与嵌入式数值转换中大量的 as 转换靠测试保证正确性
cast_possible_truncation = "allow"
cast_precision_loss = "allow"
cast_sign_loss = "allow"
# 字节 ↔ 有符号整数的补码重解释是协议解码的本意
cast_possible_wrap = "allow"
# 中文文档中的品牌 / 型号词(RoboMaster、GM3508 等)不需要反引号
doc_markdown = "allow"
# 以下 pedantic 项对内部库收益低
module_name_repetitions = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
must_use_candidate = "allow"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 sentry-chassis contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+61
View File
@@ -0,0 +1,61 @@
# sentry-chassis
机甲大师哨兵机器人麦轮底盘固件:Linux ARM 主控 + 4 × GM3508(C620 电调),SocketCAN 接入,tokio 异步架构。
## 架构一览
| crate | 职责 | 平台 |
| --- | --- | --- |
| [`motor-core`](crates/motor-core) | 电机抽象:`Motor` trait、`MotorState`、PID / 速度环控制器 | 纯逻辑,全平台 |
| [`dji-esc`](crates/dji-esc) | C620/C610 CAN 协议:帧编解码、多圈累计、`EscBus` 异步驱动、仿真器 | 纯逻辑,全平台 |
| [`dji-esc-socketcan`](crates/dji-esc-socketcan) | SocketCAN 后端:真实 CAN 接口 ↔ 内存通道桥接 | Linux |
| [`mecanum`](crates/mecanum) | 麦轮正 / 逆运动学(纯数学) | 全平台 |
| [`chassisd`](crates/chassisd) | 底盘守护进程:配置、1kHz 控制循环、安全逻辑、`--mock` 模式 | 全平台(实车需 Linux) |
分层原则:`motor-core` 与 `dji-esc` 不做任何真实 I/O——CAN 端口被抽象为一对 tokio 通道(`CanLink`),
因此协议、运动学与控制逻辑可以在 macOS / CI 上完整测试;真实总线只出现在最外层。
## 开发
需要 Rust stable(仓库通过 `rust-toolchain.toml` 锁定)。
```sh
cargo test --workspace # 单测 + 闭环仿真测试(无需硬件)
cargo run -p chassisd -- --mock # 无硬件全链路演示
just lint # fmt + clippy(与 CI 同标准)
```
### Linux 虚拟 CAN(vcan0)集成测试
```sh
just vcan-test # 或见 justfile 内的 modprobe / ip link 命令
```
### 部署到目标板(armv7,实测:创龙 T153 / Buildroot)
二进制为 musl 静态链接,不依赖板端 libc,无需交叉工具链:
```sh
just deploy # 编译 + 推送 + mock 试运行(just deploy root@169.254.63.4)
# 或手动:
cargo build --release -p chassisd --target armv7-unknown-linux-musleabihf
scp target/armv7-unknown-linux-musleabihf/release/chassisd root@169.254.63.4:/tmp/
```
板端拉起 CAN(C620 标准 1Mbps;`restart-ms` 让 bus-off 后自动恢复):
```sh
ip link set can0 up type can bitrate 1000000 restart-ms 100
```
实车运行:`/tmp/chassisd --config /tmp/chassis.toml --interface can0`。板上运行样例见
[`deploy/chassisd.service`](deploy/chassisd.service),配置样例见
[`config/chassis.toml.example`](config/chassis.toml.example)。
> 注意:电调未上电 / CAN 线未接时,控制器会因无 ACK 进入 bus-off(可用
> `ip -d link show can0` 查看),chassisd 侧表现为四电机反馈离线、强制零电流——
> 这正是安全设计的预期行为。
## 文档
- [架构设计](docs/architecture.md)——分层、CAN 协议要点、单位与方向约定、测试策略
+4
View File
@@ -0,0 +1,4 @@
# 异步代码库中禁止阻塞睡眠
disallowed-methods = [
{ path = "std::thread::sleep", reason = "异步代码请使用 tokio::time::sleep" },
]
+67
View File
@@ -0,0 +1,67 @@
# chassisd 配置样例
# 复制到 /etc/chassisd/chassis.toml 并按实车修改。
[can]
interface = "can0"
[chassis]
# 控制循环周期(毫秒)。C620 期望 1kHz 命令。
control_period_ms = 1
# 上层 twist 命令超时(毫秒),超时后目标速度归零。
command_timeout_ms = 200
# 电机反馈离线判定(毫秒),离线后强制 0 电流。
feedback_timeout_ms = 100
[mecanum]
# 轮轴线到底盘中心的纵向 / 横向半距(米)与轮半径(米)。
half_length = 0.18
half_width = 0.20
wheel_radius = 0.0762
# 四轮顺序:前左、前右、后左、后右(与 mecanum crate 约定一致)。
[[motor]]
name = "front_left"
esc_id = 1
direction = 1
gear_ratio = 19.203
# 电流限幅(协议 raw 值,C620 满量程 16384)。
current_limit = 12000
# 速度环(输入 rpm,输出 raw 电流)。按实车整定。
kp = 18.0
ki = 0.6
kd = 0.0
# 前馈(raw 电流/rpm)。
feedforward = 9.0
[[motor]]
name = "front_right"
esc_id = 2
direction = -1
gear_ratio = 19.203
current_limit = 12000
kp = 18.0
ki = 0.6
kd = 0.0
feedforward = 9.0
[[motor]]
name = "rear_left"
esc_id = 3
direction = 1
gear_ratio = 19.203
current_limit = 12000
kp = 18.0
ki = 0.6
kd = 0.0
feedforward = 9.0
[[motor]]
name = "rear_right"
esc_id = 4
direction = -1
gear_ratio = 19.203
current_limit = 12000
kp = 18.0
ki = 0.6
kd = 0.0
feedforward = 9.0
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "can-link"
description = "CAN 传输抽象:帧与链路通道(无协议知识)"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
tokio = { workspace = true }
[lints]
workspace = true
+75
View File
@@ -0,0 +1,75 @@
//! CAN 传输抽象:帧与链路通道。
//!
//! 本 crate **不含任何协议知识**(无 DJI ID 常量):只定义经典 CAN 数据帧
//! 与一对 tokio 通道构成的链路端点。协议族(`dji-esc` / 未来 `dji-gm6020`)
//! 与总线后端(SocketCAN 桥、仿真器)共享此类型,互不依赖。
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
use tokio::sync::mpsc;
/// 经典 CAN 数据帧(数据长度固定 8 字节)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CanFrame {
/// 11 位标准 ID。
pub id: u32,
/// 数据域。
pub data: [u8; 8],
}
impl CanFrame {
/// 构造一帧。
#[must_use]
pub const fn new(id: u32, data: [u8; 8]) -> Self {
Self { id, data }
}
}
/// CAN 链路端点。
///
/// - `outgoing_tx`:本端发往总线的帧;
/// - `incoming_rx`:本端从总线收到的帧。
///
/// 一条链路由两个端点构成(见 [`CanLink::pair`])。持有本端、把对端交给
/// 对侧(SocketCAN 桥、仿真器)即可通信。
#[derive(Debug)]
pub struct CanLink {
/// 发往总线。
pub outgoing_tx: mpsc::Sender<CanFrame>,
/// 来自总线。
pub incoming_rx: mpsc::Receiver<CanFrame>,
}
impl CanLink {
/// 由已有通道组装端点。
#[must_use]
pub const fn new(
outgoing_tx: mpsc::Sender<CanFrame>,
incoming_rx: mpsc::Receiver<CanFrame>,
) -> Self {
Self { outgoing_tx, incoming_rx }
}
/// 创建一对互联端点:`a` 发出的帧会被 `b` 收到,反之亦然。
///
/// `bound` 为每方向缓冲帧数(建议 ≥ 64,1kHz 收发下留足余量)。
#[must_use]
pub fn pair(bound: usize) -> (Self, Self) {
let (tx_a_to_b, rx_at_b) = mpsc::channel(bound);
let (tx_b_to_a, rx_at_a) = mpsc::channel(bound);
(Self::new(tx_a_to_b, rx_at_a), Self::new(tx_b_to_a, rx_at_b))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn pair_loops_both_directions() {
let (mut a, mut b) = CanLink::pair(8);
a.outgoing_tx.send(CanFrame::new(0x200, [1, 0, 0, 0, 0, 0, 0, 0])).await.unwrap();
b.outgoing_tx.send(CanFrame::new(0x201, [2, 0, 0, 0, 0, 0, 0, 0])).await.unwrap();
assert_eq!(b.incoming_rx.recv().await.unwrap().id, 0x200);
assert_eq!(a.incoming_rx.recv().await.unwrap().id, 0x201);
}
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "chassisd"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Mecanum chassis daemon: configuration, 1 kHz control loop, safety supervision, and mock mode"
[lints]
workspace = true
[dependencies]
motor-core = { workspace = true }
dji-esc = { workspace = true }
dji-esc-socketcan = { workspace = true }
mecanum = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
clap = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
approx = { workspace = true }
tokio = { workspace = true, features = ["test-util"] }
+504
View File
@@ -0,0 +1,504 @@
//! `chassis.toml` 配置模型与加载。
use std::time::Duration;
use dji_esc::{BusOptions, EscConfig, EscModel};
use mecanum::Mecanum;
use serde::Deserialize;
use thiserror::Error;
/// 配置错误。
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ConfigError {
/// TOML 语法 / 结构错误。
#[error("failed to parse config: {0}")]
Parse(String),
/// 语义校验失败。
#[error("invalid config: {0}")]
Invalid(String),
}
/// 顶层配置。
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
/// CAN 配置。
pub can: CanConfig,
/// 底盘控制参数。
#[serde(default)]
pub chassis: ChassisConfig,
/// 麦轮几何。
pub mecanum: MecanumConfig,
/// 四电机配置,顺序固定:前左、前右、后左、后右。
#[serde(rename = "motor")]
pub motors: Vec<MotorConfig>,
}
/// CAN 接口配置(双 CAN 总线 seam)。
///
/// 底盘与功能总线物理隔离,**每条总线只允许一种协议族**(见 [`ProtocolFamily`])。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CanConfig {
/// 底盘总线 SocketCAN 接口名(如 `can0`)——C620 驱动轮,ESC 电流协议族。
pub interface: String,
/// 功能总线(云台 / 拨弹):与底盘物理隔离。
///
/// 预留 seam;填写后由上层功能模块(gimbal / feeder)使用。
/// 典型:`can1`,承载 GM6020 云台 + M2006/C610 拨弹。
#[serde(default)]
pub function: Option<FunctionBusConfig>,
}
/// 功能总线配置(双 CAN 之二)。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FunctionBusConfig {
/// SocketCAN 接口名(如 `can1`)。
pub interface: String,
}
/// 协议族——同一总线上**不得混用**。
///
/// C620/C610(电流)与 GM6020(电压)的控制/反馈帧 ID 空间重叠:
/// `0x1FF` 既可作 C620 5–8 电流也可作 GM6020 1–4 电压;`0x205` 同理。
/// 双 CAN 物理隔离是首选消解手段;单总线时必须保证同族。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ProtocolFamily {
/// C620/C610 电流协议(驱动轮、拨弹轮)。
Esc,
/// GM6020 电压协议(云台)。
Gm6020,
}
impl ProtocolFamily {
/// 电调型号所属协议族。
#[must_use]
pub const fn of_esc_model(model: EscModelSerde) -> Self {
match model {
EscModelSerde::C620 | EscModelSerde::C610 => Self::Esc,
}
}
}
/// 底盘控制参数(全部可省略,采用默认值)。
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct ChassisConfig {
/// 控制循环周期(毫秒)。
pub control_period_ms: u64,
/// 上层指令超时(毫秒):超时后目标速度归零。
pub command_timeout_ms: u64,
/// 电机反馈离线阈值(毫秒)。
pub feedback_timeout_ms: u64,
/// 最大前进 / 横移速度(m/s)。
pub max_vx: f32,
/// 最大横移速度(m/s)。
pub max_vy: f32,
/// 最大角速度(rad/s)。
pub max_omega: f32,
}
impl Default for ChassisConfig {
fn default() -> Self {
Self {
control_period_ms: 1,
command_timeout_ms: 200,
feedback_timeout_ms: 100,
max_vx: 3.0,
max_vy: 3.0,
max_omega: 6.0,
}
}
}
/// 麦轮几何(米)。
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MecanumConfig {
/// 前后轮轴线到中心的纵向半距。
pub half_length: f32,
/// 左右轮到中心的横向半距。
pub half_width: f32,
/// 车轮半径。
pub wheel_radius: f32,
}
/// 单电机配置。
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MotorConfig {
/// 便于日志辨认的名字(`front_left` 等)。
pub name: String,
/// 电调 ID(1–8)。
pub esc_id: u8,
/// 电调型号(`C620` / `C610`)。
#[serde(default = "default_model")]
pub model: EscModelSerde,
/// 安装方向:`1` 或 `-1`。
#[serde(default = "default_direction")]
pub direction: f32,
/// 减速比(GM3508 ≈ 19.203)。
pub gear_ratio: f32,
/// 电流限幅(raw)。
#[serde(default = "default_current_limit")]
pub current_limit: i16,
/// 速度环 kp。
#[serde(default)]
pub kp: f32,
/// 速度环 ki。
#[serde(default)]
pub ki: f32,
/// 速度环 kd。
#[serde(default)]
pub kd: f32,
/// 速度前馈(raw/rpm)。
#[serde(default)]
pub feedforward: f32,
}
fn default_model() -> EscModelSerde {
EscModelSerde::C620
}
fn default_direction() -> f32 {
1.0
}
fn default_current_limit() -> i16 {
12_000
}
/// 配置文件中的电调型号。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "UPPERCASE")]
pub enum EscModelSerde {
/// C620(GM3508)。
C620,
/// C610(M2006)。
C610,
}
impl From<EscModelSerde> for EscModel {
fn from(value: EscModelSerde) -> Self {
match value {
EscModelSerde::C620 => Self::C620,
EscModelSerde::C610 => Self::C610,
}
}
}
impl Config {
/// 从 TOML 文本解析并校验。
///
/// # Errors
///
/// 解析失败或语义校验失败(电机数量、几何、方向等)。
pub fn from_toml(text: &str) -> Result<Self, ConfigError> {
let config: Self = toml::from_str(text).map_err(|e| ConfigError::Parse(e.to_string()))?;
config.validate()?;
Ok(config)
}
/// 从文件读取并解析。
///
/// # Errors
///
/// 读取失败、解析失败或校验失败。
pub fn load(path: &std::path::Path) -> Result<Self, ConfigError> {
let text = std::fs::read_to_string(path)
.map_err(|e| ConfigError::Parse(format!("{}: {e}", path.display())))?;
Self::from_toml(&text)
}
/// 语义校验。
///
/// # Errors
///
/// 任一约束不满足时返回 [`ConfigError::Invalid`]。
pub fn validate(&self) -> Result<(), ConfigError> {
if self.motors.len() != 4 {
return Err(ConfigError::Invalid(format!(
"mecanum chassis needs exactly 4 motors (FL, FR, RL, RR), got {}",
self.motors.len()
)));
}
let m = &self.mecanum;
if !(m.half_length > 0.0 && m.half_width > 0.0 && m.wheel_radius > 0.0) {
return Err(ConfigError::Invalid(format!("mecanum geometry must be positive: {m:?}")));
}
let c = &self.chassis;
if c.control_period_ms == 0 || c.command_timeout_ms == 0 || c.feedback_timeout_ms == 0 {
return Err(ConfigError::Invalid(
"control/command/feedback timeouts must be > 0 ms".into(),
));
}
if !(c.max_vx > 0.0 && c.max_vy > 0.0 && c.max_omega > 0.0) {
return Err(ConfigError::Invalid("speed limits must be > 0".into()));
}
for motor in &self.motors {
let gear_ratio_valid = motor.gear_ratio > 0.0;
if !gear_ratio_valid {
return Err(ConfigError::Invalid(format!(
"motor {}: gear_ratio must be > 0",
motor.name
)));
}
}
// 每总线单协议族:底盘四轮必须同属 ESC 族(C620/C610)。
let chassis_families: std::collections::HashSet<ProtocolFamily> =
self.motors.iter().map(|m| ProtocolFamily::of_esc_model(m.model)).collect();
if chassis_families.len() > 1 {
return Err(ConfigError::Invalid(
"chassis bus mixes protocol families; keep one family per CAN bus".into(),
));
}
// 功能总线接口名不得与底盘总线相同(双 CAN 物理隔离)。
if let Some(function) = &self.can.function {
if function.interface == self.can.interface {
return Err(ConfigError::Invalid(format!(
"function bus interface must differ from chassis interface (both '{}')",
self.can.interface
)));
}
}
// 电调 ID、方向、限流的细粒度校验由 EscBus::spawn 复核。
Ok(())
}
/// `--mock` 演示 / 测试默认配置(与 `config/chassis.toml.example` 同构,
/// PID 与仿真器增益匹配)。
#[must_use]
pub fn mock_default() -> Self {
let motor = |name: &str, esc_id: u8, direction: f32| MotorConfig {
name: name.into(),
esc_id,
model: EscModelSerde::C620,
direction,
gear_ratio: 3591.0 / 187.0,
current_limit: 12_000,
kp: 8.0,
ki: 2.0,
kd: 0.0,
feedforward: 16384.0 / 9254.0,
};
Self {
can: CanConfig { interface: "vcan0".into(), function: None },
chassis: ChassisConfig::default(),
mecanum: MecanumConfig { half_length: 0.2, half_width: 0.25, wheel_radius: 0.0762 },
motors: vec![
motor("front_left", 1, 1.0),
motor("front_right", 2, -1.0),
motor("rear_left", 3, 1.0),
motor("rear_right", 4, -1.0),
],
}
}
/// 转换为 `EscBus` 配置(顺序 FL、FR、RL、RR)。
#[must_use]
pub fn to_esc_configs(&self) -> Vec<EscConfig> {
self.motors
.iter()
.map(|m| EscConfig {
name: m.name.clone(),
esc_id: m.esc_id,
model: m.model.into(),
gear_ratio: m.gear_ratio,
direction: m.direction,
current_limit: m.current_limit,
kp: m.kp,
ki: m.ki,
kd: m.kd,
feedforward: m.feedforward,
})
.collect()
}
/// 转换为 `EscBus` 运行参数。
#[must_use]
pub fn bus_options(&self) -> BusOptions {
BusOptions {
period: Duration::from_millis(self.chassis.control_period_ms),
feedback_timeout: Duration::from_millis(self.chassis.feedback_timeout_ms),
}
}
/// 麦轮运动学模型。
#[must_use]
pub fn kinematics(&self) -> Mecanum {
Mecanum::new(self.mecanum.half_length, self.mecanum.half_width, self.mecanum.wheel_radius)
}
}
#[cfg(test)]
mod tests {
use super::*;
const EXAMPLE: &str = r#"
[can]
interface = "can0"
[chassis]
control_period_ms = 1
command_timeout_ms = 200
feedback_timeout_ms = 100
[mecanum]
half_length = 0.18
half_width = 0.20
wheel_radius = 0.0762
[[motor]]
name = "front_left"
esc_id = 1
direction = 1
gear_ratio = 19.203
current_limit = 12000
kp = 18.0
ki = 0.6
kd = 0.0
feedforward = 9.0
[[motor]]
name = "front_right"
esc_id = 2
direction = -1
gear_ratio = 19.203
current_limit = 12000
kp = 18.0
ki = 0.6
feedforward = 9.0
[[motor]]
name = "rear_left"
esc_id = 3
direction = 1
gear_ratio = 19.203
current_limit = 12000
kp = 18.0
ki = 0.6
feedforward = 9.0
[[motor]]
name = "rear_right"
esc_id = 4
direction = -1
gear_ratio = 19.203
current_limit = 12000
kp = 18.0
ki = 0.6
feedforward = 9.0
"#;
#[test]
fn parses_example() {
let cfg = Config::from_toml(EXAMPLE).unwrap();
assert_eq!(cfg.can.interface, "can0");
assert_eq!(cfg.motors.len(), 4);
assert_eq!(cfg.motors[0].name, "front_left");
assert_eq!(cfg.motors[3].esc_id, 4);
assert_eq!(cfg.motors[1].direction.to_bits(), (-1.0f32).to_bits());
assert!(cfg.validate().is_ok());
assert_eq!(cfg.bus_options().period, Duration::from_millis(1));
assert_eq!(cfg.to_esc_configs().len(), 4);
}
#[test]
fn minimal_config_uses_defaults() {
let cfg = Config::from_toml(
r#"
[can]
interface = "can1"
[mecanum]
half_length = 0.2
half_width = 0.25
wheel_radius = 0.0762
[[motor]]
name = "fl"
esc_id = 1
gear_ratio = 19.2
[[motor]]
name = "fr"
esc_id = 2
gear_ratio = 19.2
[[motor]]
name = "rl"
esc_id = 3
gear_ratio = 19.2
[[motor]]
name = "rr"
esc_id = 4
gear_ratio = 19.2
"#,
)
.unwrap();
assert_eq!(cfg.chassis, ChassisConfig::default());
assert_eq!(cfg.motors[0].direction.to_bits(), 1.0f32.to_bits());
assert_eq!(cfg.motors[0].current_limit, 12_000);
assert_eq!(cfg.motors[0].model, EscModelSerde::C620);
}
#[test]
fn rejects_wrong_motor_count() {
let mut cfg = Config::from_toml(EXAMPLE).unwrap();
cfg.motors.pop();
assert!(matches!(cfg.validate(), Err(ConfigError::Invalid(_))));
}
#[test]
fn rejects_bad_geometry() {
let mut cfg = Config::from_toml(EXAMPLE).unwrap();
cfg.mecanum.wheel_radius = 0.0;
assert!(matches!(cfg.validate(), Err(ConfigError::Invalid(_))));
}
#[test]
fn rejects_unknown_fields() {
let text = EXAMPLE.replace("[can]", "[can]\nstray = 1");
assert!(matches!(Config::from_toml(&text), Err(ConfigError::Parse(_))));
}
#[test]
fn mock_default_is_valid_and_wired_like_example() {
let cfg = Config::mock_default();
assert!(cfg.validate().is_ok());
assert_eq!(cfg.motors.len(), 4);
let esc: Vec<u8> = cfg.to_esc_configs().iter().map(|m| m.esc_id).collect();
assert_eq!(esc, [1, 2, 3, 4]);
}
#[test]
fn function_bus_must_differ_from_chassis_bus() {
let mut cfg = Config::mock_default();
cfg.can.function = Some(FunctionBusConfig { interface: "vcan0".into() });
assert!(matches!(cfg.validate(), Err(ConfigError::Invalid(_))));
cfg.can.function = Some(FunctionBusConfig { interface: "vcan1".into() });
assert!(cfg.validate().is_ok());
}
#[test]
fn rejects_mixed_protocol_families_on_chassis_bus() {
let mut cfg = Config::mock_default();
// 当前 ESC 族包含 C620 与 C610;若引入 GM6020 到底盘总线必须拒绝。
// 这里验证家族集合逻辑:全部 C620/C610 仍为单族。
cfg.motors[0].model = EscModelSerde::C610;
assert!(cfg.validate().is_ok());
// 人为构造跨族:通过 of_esc_model 映射覆盖后仍应单族(C610→Esc)。
assert_eq!(ProtocolFamily::of_esc_model(EscModelSerde::C620), ProtocolFamily::Esc);
assert_eq!(ProtocolFamily::of_esc_model(EscModelSerde::C610), ProtocolFamily::Esc);
}
#[test]
fn parses_function_bus_seam() {
let text = EXAMPLE.replace(
"[can]\ninterface = \"can0\"",
"[can]\ninterface = \"can0\"\n\n[can.function]\ninterface = \"can1\"",
);
let cfg = Config::from_toml(&text).unwrap();
let function = cfg.can.function.as_ref().unwrap();
assert_eq!(function.interface, "can1");
}
}
+203
View File
@@ -0,0 +1,203 @@
//! 底盘控制循环:麦轮逆解 → 四电机速度指令,含安全逻辑。
use std::time::Duration;
use mecanum::{Mecanum, Twist};
use motor_core::{Disable, MotorFeedback, OutputShaft, VelocityDrive};
use tokio::sync::watch;
use tokio::task::JoinHandle;
use tokio::time::{Instant, MissedTickBehavior, interval};
use tracing::{info, warn};
/// 底盘控制器。
///
/// 职责(1kHz 节拍):
/// 1. 读取最新 twist 指令,**指令超时则归零**;
/// 2. 速度限幅(保持方向按比例收缩);
/// 3. **上电门控**:四台电机都收到过反馈才下发速度,否则全部停用;
/// 4. 麦轮逆解 → 各轮目标转速(rpm,轮端)→ 经 [`OutputShaft`] 换算到转子。
///
/// 电机反馈离线安全在驱动层完成(强制 0 电流)。
pub struct ChassisController<W> {
kinematics: Mecanum,
/// 顺序固定:FL、FR、RL、RR。轮端单位(经 `OutputShaft` 包装)。
wheels: [W; 4],
/// (max_vx, max_vy, max_omega)。
limits: (f32, f32, f32),
command_timeout: Duration,
}
impl<W> ChassisController<W>
where
W: VelocityDrive + MotorFeedback + Disable + 'static,
{
/// 组装控制器。`wheels` 顺序必须为 FL、FR、RL、RR。
#[must_use]
pub fn new(
kinematics: Mecanum,
wheels: [W; 4],
limits: (f32, f32, f32),
command_timeout: Duration,
) -> Self {
Self { kinematics, wheels, limits, command_timeout }
}
/// 启动控制循环任务。
pub fn spawn(self, commands: watch::Receiver<Twist>, period: Duration) -> JoinHandle<()> {
tokio::spawn(self.run(commands, period))
}
async fn run(self, mut commands: watch::Receiver<Twist>, period: Duration) {
let mut ticker = interval(period);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut latest = Twist::ZERO;
let mut last_command_at: Option<Instant> = None;
let mut ticks: u64 = 0;
loop {
ticker.tick().await;
let now = Instant::now();
if commands.has_changed().unwrap_or(false) {
latest = *commands.borrow_and_update();
last_command_at = Some(now);
}
let timed_out = match last_command_at {
Some(t) => now.duration_since(t) > self.command_timeout,
// 尚未收到任何指令:保持静止。
None => true,
};
let twist = if timed_out {
Twist::ZERO
} else {
latest.clamped(self.limits.0, self.limits.1, self.limits.2)
};
let all_seen_feedback = self.wheels.iter().all(|m| m.state().has_feedback());
if all_seen_feedback {
let wheel_rpms = self.kinematics.wheel_rpms(&twist);
for (motor, wheel_rpm) in self.wheels.iter().zip(wheel_rpms) {
motor.set_velocity(wheel_rpm);
}
} else {
for motor in &self.wheels {
motor.disable();
}
if ticks.is_multiple_of(1000) {
warn!("waiting for first feedback from all motors");
}
}
ticks += 1;
if ticks.is_multiple_of(1000) {
self.log_status();
}
}
}
fn log_status(&self) {
let (rotor_rpm, effort_raw): (Vec<f32>, Vec<i32>) = self
.wheels
.iter()
.map(|m| {
let st = m.state();
(st.rotor_speed_rpm, i32::from(st.effort_raw))
})
.unzip();
info!(?rotor_rpm, ?effort_raw, "chassis status");
}
}
/// 由 `EscMotor` 列表组装轮端包装(`gear_ratio` = 转子 : 输出轴)。
#[must_use]
pub fn wrap_wheels<T: Clone, const N: usize>(
motors: [T; N],
gear_ratios: [f32; N],
) -> [OutputShaft<T>; N] {
let mut out = Vec::with_capacity(N);
let mut gears = gear_ratios.into_iter();
for m in motors {
let g = gears.next().unwrap_or(1.0);
out.push(OutputShaft::new(m, g));
}
out.try_into().unwrap_or_else(|_| unreachable!())
}
#[cfg(test)]
mod tests {
use super::*;
use motor_core::{Command, MockMotor, MotorState};
const GEAR: f32 = 19.203;
fn mock_wheel(id: u8) -> MockMotor {
let m = MockMotor::new(id);
m.inject_state(MotorState { feedback_count: 1, ..MotorState::default() });
m
}
/// 证明 `ChassisController` 只依赖能力 trait:换 `MockMotor` 原样工作。
#[tokio::test(start_paused = true)]
async fn controller_drives_wheels_through_capability_traits() {
let kinematics = Mecanum::new(0.2, 0.25, 0.0762);
let mocks = [mock_wheel(1), mock_wheel(2), mock_wheel(3), mock_wheel(4)];
let ctrl = ChassisController::new(
kinematics,
wrap_wheels(mocks.clone(), [GEAR; 4]),
(3.0, 3.0, 6.0),
Duration::from_millis(200),
);
let (tx, rx) = watch::channel(Twist::ZERO);
let handle = ctrl.spawn(rx, Duration::from_millis(1));
let twist = Twist { vx: 1.0, ..Twist::ZERO };
tx.send_replace(twist);
tokio::time::sleep(Duration::from_millis(5)).await;
let expected = kinematics.wheel_rpms(&twist);
for (i, mock) in mocks.iter().enumerate() {
match mock.last_command() {
Command::Velocity(rotor_rpm) => {
let want = expected[i] * GEAR;
assert!(
(rotor_rpm - want).abs() < 1e-2,
"wheel {i}: rotor_rpm {rotor_rpm} != {want}"
);
}
ref cmd => panic!("wheel {i} expected Velocity, got {cmd:?}"),
}
}
handle.abort();
}
/// 反馈未到齐时必须全部停用,不得下发速度。
#[tokio::test(start_paused = true)]
async fn disables_wheels_until_all_feedback_seen() {
let kinematics = Mecanum::new(0.2, 0.25, 0.0762);
let ready = mock_wheel(1);
let missing = MockMotor::new(2); // 无反馈
let mocks_ready = [ready.clone(), mock_wheel(3), mock_wheel(4)];
let _ = mocks_ready;
let wheels = wrap_wheels(
[ready.clone(), missing.clone(), mock_wheel(3), mock_wheel(4)],
[GEAR; 4],
);
let ctrl = ChassisController::new(
kinematics,
wheels,
(3.0, 3.0, 6.0),
Duration::from_millis(200),
);
let (tx, rx) = watch::channel(Twist::ZERO);
let handle = ctrl.spawn(rx, Duration::from_millis(1));
tx.send_replace(Twist { vx: 1.0, ..Twist::ZERO });
tokio::time::sleep(Duration::from_millis(5)).await;
assert_eq!(ready.last_command(), Command::Disabled);
assert_eq!(missing.last_command(), Command::Disabled);
handle.abort();
}
}
+15
View File
@@ -0,0 +1,15 @@
//! `chassisd` 库部分:配置模型、底盘控制循环与指令源抽象。
//!
//! 二进制入口见 `src/main.rs`。
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub mod config;
pub mod controller;
pub mod source;
pub use config::{
CanConfig, ChassisConfig, Config, ConfigError, FunctionBusConfig, MecanumConfig, MotorConfig,
ProtocolFamily,
};
pub use controller::{ChassisController, wrap_wheels};
pub use source::{ChannelSource, TwistSource, ZeroSource, pump_source};
+161
View File
@@ -0,0 +1,161 @@
//! `chassisd`:麦轮底盘守护进程(4 × GM3508 / C620,SocketCAN)。
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use clap::Parser;
use mecanum::Twist;
use tokio::sync::watch;
use tracing::{error, info};
use tracing_subscriber::EnvFilter;
use chassisd::config::Config;
use chassisd::controller::{ChassisController, wrap_wheels};
use motor_core::Disable;
/// 命令行参数。
#[derive(Debug, Parser)]
#[command(name = "chassisd", version, about = "麦轮底盘守护进程(4×GM3508/C620)")]
struct Args {
/// 配置文件路径(实车模式必填;`--mock` 下可省略,使用内置演示配置)。
#[arg(short, long)]
config: Option<PathBuf>,
/// 覆盖配置文件中的 CAN 接口名。
#[arg(short, long)]
interface: Option<String>,
/// 无硬件仿真模式:进程内电机模型,全链路可离线运行。
#[arg(long)]
mock: bool,
}
fn main() -> ExitCode {
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,chassisd=debug"));
tracing_subscriber::fmt().with_env_filter(filter).init();
let args = Args::parse();
let config = match load_config(&args) {
Ok(config) => config,
Err(e) => {
error!("{e}");
return ExitCode::FAILURE;
}
};
let interface = args.interface.clone().unwrap_or_else(|| config.can.interface.clone());
let runtime = match tokio::runtime::Builder::new_multi_thread().enable_all().build() {
Ok(runtime) => runtime,
Err(e) => {
error!("failed to start tokio runtime: {e}");
return ExitCode::FAILURE;
}
};
match runtime.block_on(run(config, &interface, args.mock)) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
error!("{e}");
ExitCode::FAILURE
}
}
}
fn load_config(args: &Args) -> Result<Config, String> {
match (&args.config, args.mock) {
(Some(path), _) => Config::load(path).map_err(|e| e.to_string()),
(None, true) => {
info!("mock 模式未提供 --config,使用内置演示配置");
Ok(Config::mock_default())
}
(None, false) => {
Err("实车模式需要 --config 指向配置文件(样例见 config/chassis.toml.example)".into())
}
}
}
async fn run(
config: Config,
interface: &str,
mock: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let bus_options = config.bus_options();
// 1) CAN 链路:仿真或真实 SocketCAN。
let link = if mock {
let (bus_side, plant_side) = dji_esc::CanLink::pair(512);
let esc_ids: Vec<u8> = config.motors.iter().map(|m| m.esc_id).collect();
dji_esc::spawn_plant(plant_side, &esc_ids, bus_options.period);
info!(?esc_ids, "mock 模式:进程内电机仿真");
bus_side
} else {
open_real_link(interface)?
};
// 2) 电调总线。
let period = bus_options.period;
let bus = dji_esc::EscBus::spawn(link, config.to_esc_configs(), bus_options)?;
let names: Vec<&str> = bus.motors().iter().map(dji_esc::EscMotor::name).collect();
info!(
interface,
mock,
motors = ?names,
period_ms = period.as_millis(),
"chassisd 启动"
);
// 3) 控制循环(当前无指令源:watch 保持 ZERO,安全静止;
// 遥控 / 自主导航后续经 chassisd::source 接入)。
let (twist_tx, twist_rx) = watch::channel(Twist::ZERO);
let limits = (config.chassis.max_vx, config.chassis.max_vy, config.chassis.max_omega);
let motors: [dji_esc::EscMotor; 4] =
bus.motors().to_vec().try_into().unwrap_or_else(|_| unreachable!("exactly 4 motors"));
let gears: [f32; 4] = config
.motors
.iter()
.map(|m| m.gear_ratio)
.collect::<Vec<_>>()
.try_into()
.unwrap_or_else(|_| unreachable!("exactly 4 motors"));
let controller = ChassisController::new(
config.kinematics(),
wrap_wheels(motors, gears),
limits,
Duration::from_millis(config.chassis.command_timeout_ms),
)
.spawn(twist_rx, period);
let _twist_tx = twist_tx; // 保留发送端供后续指令源使用
// 4) 优雅退出:ctrl-c 或控制任务意外退出。
tokio::select! {
_ = tokio::signal::ctrl_c() => info!("收到 ctrl-c,正在停车…"),
result = controller => match result {
Ok(()) => error!("控制任务意外退出"),
Err(e) => error!("控制任务 panic: {e}"),
},
}
// 5) 停车:显式停用电机并等若干节拍把 0 电流帧刷入总线。
for motor in bus.motors() {
motor.disable();
}
tokio::time::sleep(Duration::from_millis(20)).await;
bus.shutdown().await;
info!("chassisd 已退出");
Ok(())
}
/// 打开真实总线(仅 Linux;其他平台给出明确错误)。
fn open_real_link(interface: &str) -> Result<dji_esc::CanLink, Box<dyn std::error::Error>> {
#[cfg(target_os = "linux")]
{
Ok(dji_esc_socketcan::open_link(interface)?)
}
#[cfg(not(target_os = "linux"))]
{
let _ = interface;
Err("实车模式仅支持 Linux(SocketCAN);请使用 --mock 演示".into())
}
}
+81
View File
@@ -0,0 +1,81 @@
//! 上层速度指令源(twist source)。
//!
//! 守护进程通过 [`watch`] 通道把指令交给控制循环;指令源在独立任务中
//! 持续产出 twist。遥控器(DR16)、自主导航等后续接入时各自实现
//! [`TwistSource`] 即可。
use std::future::Future;
use mecanum::Twist;
use tokio::sync::{mpsc, watch};
/// 速度指令源。
pub trait TwistSource: Send {
/// 取下一条指令;`None` 表示源关闭。
fn next_twist(&mut self) -> impl Future<Output = Option<Twist>> + Send;
}
/// 恒零占位源:永不产出(watch 保持初值 `Twist::ZERO`,指令超时逻辑兜底)。
pub struct ZeroSource;
impl TwistSource for ZeroSource {
async fn next_twist(&mut self) -> Option<Twist> {
std::future::pending().await
}
}
/// 通道指令源(测试 / 简单进程内注入用)。
pub struct ChannelSource {
rx: mpsc::Receiver<Twist>,
}
impl ChannelSource {
/// 由 mpsc 接收端构造。
#[must_use]
pub const fn new(rx: mpsc::Receiver<Twist>) -> Self {
Self { rx }
}
/// 创建一对(发送端, 源)。
#[must_use]
pub fn pair(bound: usize) -> (mpsc::Sender<Twist>, Self) {
let (tx, rx) = mpsc::channel(bound);
(tx, Self { rx })
}
}
impl TwistSource for ChannelSource {
async fn next_twist(&mut self) -> Option<Twist> {
self.rx.recv().await
}
}
/// 把任意指令源持续泵入 watch 通道(源关闭或接收端关闭时退出)。
pub async fn pump_source(mut source: impl TwistSource, tx: watch::Sender<Twist>) {
while let Some(twist) = source.next_twist().await {
if tx.send(twist).is_err() {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(start_paused = true)]
async fn channel_source_pumps_into_watch() {
let (tx, source) = ChannelSource::pair(8);
let (watch_tx, mut watch_rx) = watch::channel(Twist::ZERO);
let pump = tokio::spawn(pump_source(source, watch_tx));
tx.send(Twist { vx: 1.0, vy: 0.0, omega: 0.0 }).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
assert_eq!(*watch_rx.borrow_and_update(), Twist { vx: 1.0, vy: 0.0, omega: 0.0 });
// 源关闭 → 泵任务退出。
drop(tx);
pump.await.unwrap();
assert!((watch_rx.borrow().vx - 1.0).abs() < 1e-6); // watch 保持最后值
}
}
+169
View File
@@ -0,0 +1,169 @@
//! 守护进程端到端测试:`--mock` 同构栈(配置 → EscBus → 通道 → 仿真电机
//! → 控制循环 → 麦轮运动学),全平台可跑。
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::time::Duration;
use chassisd::config::Config;
use chassisd::controller::{ChassisController, wrap_wheels};
use dji_esc::{CanLink, EscBus, EscBusHandle, spawn_plant};
use mecanum::Twist;
use motor_core::MotorFeedback;
use tokio::sync::watch;
use tokio::time::sleep;
/// 搭建与 `chassisd --mock` 完全同构的软件栈。
///
/// `command_timeout_ms` 由调用方指定:稳态测试用大值(模拟持续刷新的
/// 指令源),超时测试用配置原值。
fn stack(config: &Config, command_timeout_ms: u64) -> (EscBusHandle, watch::Sender<Twist>) {
let (bus_side, plant_side) = CanLink::pair(512);
let esc_ids: Vec<u8> = config.motors.iter().map(|m| m.esc_id).collect();
spawn_plant(plant_side, &esc_ids, config.bus_options().period);
let bus = EscBus::spawn(bus_side, config.to_esc_configs(), config.bus_options()).unwrap();
let (twist_tx, twist_rx) = watch::channel(Twist::ZERO);
let motors: [dji_esc::EscMotor; 4] =
bus.motors().to_vec().try_into().unwrap_or_else(|_| unreachable!("exactly 4 motors"));
let gears: [f32; 4] = config
.motors
.iter()
.map(|m| m.gear_ratio)
.collect::<Vec<_>>()
.try_into()
.unwrap_or_else(|_| unreachable!("exactly 4 motors"));
let controller = ChassisController::new(
config.kinematics(),
wrap_wheels(motors, gears),
(config.chassis.max_vx, config.chassis.max_vy, config.chassis.max_omega),
Duration::from_millis(command_timeout_ms),
);
controller.spawn(twist_rx, config.bus_options().period);
(bus, twist_tx)
}
async fn wait_until(total_ms: u64, mut cond: impl FnMut() -> bool) -> bool {
let mut waited = 0u64;
while waited < total_ms {
if cond() {
return true;
}
sleep(Duration::from_millis(5)).await;
waited += 5;
}
cond()
}
fn wheel_rpms(bus: &EscBusHandle) -> [f32; 4] {
bus.motors()
.iter()
.map(|m| m.state().rotor_speed_rpm / m.config().gear_ratio)
.collect::<Vec<_>>()
.try_into()
.unwrap()
}
/// mock 默认底盘(r = 0.0762 m)上 1 m/s 对应的轮转速。
const RPM_PER_MPS: f32 = 60.0 / (std::f32::consts::TAU * 0.0762);
#[tokio::test(start_paused = true)]
async fn forward_command_drives_all_wheels_equally() {
let config = Config::mock_default();
let (bus, twist_tx) = stack(&config, 10_000);
twist_tx.send_replace(Twist { vx: 1.0, ..Twist::ZERO });
let target = RPM_PER_MPS;
let ok = wait_until(3000, || {
wheel_rpms(&bus).iter().all(|&rpm| (rpm - target).abs() < 0.05 * target)
})
.await;
let rpm = wheel_rpms(&bus);
assert!(ok, "未收敛到 {target} rpm: {rpm:?}");
// 停车:指令归零后各轮减速到停。
twist_tx.send_replace(Twist::ZERO);
let stopped = wait_until(2000, || wheel_rpms(&bus).iter().all(|rpm| rpm.abs() < 15.0)).await;
let rpm = wheel_rpms(&bus);
assert!(stopped, "未停车: {rpm:?}");
bus.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn rotation_command_respects_wheel_signs() {
let config = Config::mock_default();
let (bus, twist_tx) = stack(&config, 10_000);
twist_tx.send_replace(Twist { omega: 1.0, ..Twist::ZERO });
// a·ω = (0.2 + 0.25) m/s 的轮线速度。
let target = 0.45 * RPM_PER_MPS;
let ok = wait_until(3000, || {
let rpm = wheel_rpms(&bus);
// 逆时针:左侧(FL、RL)为负,右侧(FR、RR)为正。
[
(rpm[0] + target).abs(),
(rpm[1] - target).abs(),
(rpm[2] + target).abs(),
(rpm[3] - target).abs(),
]
.iter()
.all(|e| *e < 0.05 * target)
})
.await;
let rpm = wheel_rpms(&bus);
assert!(ok, "旋转轮序符号错误, 期望 ±{target}: {rpm:?}");
bus.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn command_timeout_stops_chassis() {
let config = Config::mock_default();
// 指令超时使用配置原值(200ms)。
let (bus, twist_tx) = stack(&config, config.chassis.command_timeout_ms);
// 只发一次指令,随后沉默 → 超过 command_timeout(200ms)后应停车。
twist_tx.send_replace(Twist { vx: 1.0, ..Twist::ZERO });
let moving =
wait_until(1000, || wheel_rpms(&bus).iter().any(|rpm| rpm.abs() > 0.2 * RPM_PER_MPS)).await;
assert!(moving, "指令后底盘应当运动");
sleep(Duration::from_millis(400)).await; // 超过 200ms 超时
let stopped = wait_until(1000, || wheel_rpms(&bus).iter().all(|rpm| rpm.abs() < 15.0)).await;
let rpm = wheel_rpms(&bus);
assert!(stopped, "指令超时后未停车: {rpm:?}");
bus.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn speed_limits_scale_motion() {
let mut config = Config::mock_default();
config.chassis.max_vx = 1.0; // 限速 1 m/s
let (bus, twist_tx) = stack(&config, 10_000);
twist_tx.send_replace(Twist {
vx: 3.0, // 超限指令
vy: 2.0,
omega: 0.0,
});
// 限幅按比例收缩:(3, 2) → (1, 2/3)。四轮 = vx ∓ vy:
// FL/RR = (1 - 2/3) m/s,FR/RL = (1 + 2/3) m/s。
let diag_slow = (1.0 - 2.0 / 3.0) * RPM_PER_MPS;
let diag_fast = (1.0 + 2.0 / 3.0) * RPM_PER_MPS;
let tol = 0.08 * diag_fast;
let ok = wait_until(3000, || {
let rpm = wheel_rpms(&bus);
[
(rpm[0] - diag_slow).abs(),
(rpm[1] - diag_fast).abs(),
(rpm[2] - diag_fast).abs(),
(rpm[3] - diag_slow).abs(),
]
.iter()
.all(|e| *e < tol)
})
.await;
let rpm = wheel_rpms(&bus);
assert!(ok, "限幅后未收敛到 [{diag_slow}, {diag_fast}, {diag_fast}, {diag_slow}] rpm: {rpm:?}");
bus.shutdown().await;
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "dji-esc-socketcan"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "SocketCAN backend bridging real Linux CAN interfaces to can-link channels"
[lints]
workspace = true
[dependencies]
can-link = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
socketcan = { workspace = true }
[target.'cfg(target_os = "linux")'.dev-dependencies]
can-link = { workspace = true }
dji-esc = { workspace = true }
motor-core = { workspace = true }
socketcan = { workspace = true }
tokio = { workspace = true }
+110
View File
@@ -0,0 +1,110 @@
//! 单电机验证工具:让一个 C620 / GM3508 转起来,验证电机抽象全链路。
//!
//! ```sh
//! spin [interface] [esc_id] [rotor_rpm] [duration_secs]
//! # 例:can0 上电调 1 以 1000 rpm(转子)转 10 秒
//! cargo run --release -p dji-esc-socketcan --example spin -- can0 1 1000 10
//! ```
//!
//! 关键点在 [`spin_one`]:它只依赖 `motor_core::Motor` 抽象,完全不知道
//! 背后是 C620 协议还是别的电机——换任何实现该 trait 的电机,这段代码
//! 原样可用。抽象与协议实现(`dji-esc`)、总线后端(本 crate)在此汇合。
//!
//! 仅 Linux(需要 SocketCAN);其他平台编译为报错桩。
#![allow(clippy::unwrap_used, clippy::expect_used)]
#[cfg(target_os = "linux")]
use std::time::Duration;
#[cfg(target_os = "linux")]
use dji_esc::{BusOptions, EscBus, EscConfig};
#[cfg(target_os = "linux")]
use dji_esc_socketcan::open_link;
#[cfg(target_os = "linux")]
use motor_core::{Disable, EffortDrive, Motor, MotorFeedback, VelocityDrive};
#[cfg(target_os = "linux")]
use tokio::time::{MissedTickBehavior, interval};
#[cfg(target_os = "linux")]
#[tokio::main]
async fn main() {
let mut args = std::env::args().skip(1);
let interface = args.next().unwrap_or_else(|| "can0".into());
let esc_id: u8 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1);
let rotor_rpm: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1000.0);
let duration_secs: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(10.0);
println!("=== 单电机抽象验证 ===");
println!(
"接口 {interface} | 电调 {esc_id} | 目标 {rotor_rpm} rpm(转子)| 时长 {duration_secs}s"
);
let link = open_link(&interface).unwrap_or_else(|e| {
eprintln!(
"打开 {interface} 失败:{e}(先 ip link set {interface} up type can bitrate 1000000)"
);
std::process::exit(1);
});
let config = EscConfig::gm3508("spin_test", esc_id, 1.0);
println!(
"电机配置:{:?} | 减速比 {:.3} | 电流限幅 {}",
config.model, config.gear_ratio, config.current_limit
);
let bus = EscBus::spawn(link, vec![config], BusOptions::default()).expect("EscBus 配置非法");
let motor = bus.motor(esc_id).expect("刚配置的电机必然存在");
spin_one(motor, rotor_rpm, Duration::from_secs_f32(duration_secs)).await;
bus.shutdown().await;
println!("已退出");
}
/// 验证目标:本函数只面向 [`Motor`] 抽象——设定转速、周期读状态、停机。
/// 它不知道 CAN,不知道 C620,换任何 Motor 实现都原样工作。
#[cfg(target_os = "linux")]
async fn spin_one(motor: &impl Motor, target_rpm: f32, duration: Duration) {
motor.set_velocity(target_rpm);
println!("已下发目标转速,开始观测(100ms 周期)……\n");
let mut ticker = interval(Duration::from_millis(100));
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let deadline = tokio::time::Instant::now() + duration;
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
println!("\n收到 ctrl-c");
break;
}
_ = ticker.tick() => {}
}
if tokio::time::Instant::now() >= deadline {
break;
}
let st = motor.state();
if st.has_feedback() {
println!(
"转子 {:>8.1} rpm | effort {:>6} raw | 温度 {:>3} ℃ | 累计角度 {:>8.3} 圈 | 反馈 {} 帧",
st.rotor_speed_rpm,
st.effort_raw,
st.temperature_c,
st.rotor_revolutions,
st.feedback_count
);
} else {
println!("⚠ 尚未收到任何反馈——检查电调供电与 CAN 接线");
}
}
motor.disable();
// 让 0 电流帧至少发出一拍再关总线。
tokio::time::sleep(Duration::from_millis(50)).await;
println!("已停机(0 电流)");
}
#[cfg(not(target_os = "linux"))]
fn main() {
eprintln!("spin 仅支持 Linux(需要 SocketCAN)");
}
+96
View File
@@ -0,0 +1,96 @@
//! 真实总线桥接实现(Linux)。
use std::io;
use std::sync::Arc;
use can_link::{CanFrame as LinkFrame, CanLink};
use socketcan::tokio::CanSocket;
use socketcan::{
CanDataFrame, CanFilter, CanFrame as ScFrame, EmbeddedFrame as _, Frame as _,
SocketOptions as _,
};
use tokio::sync::mpsc;
use tracing::{debug, error};
/// 单个链路的通道缓冲(1kHz × 8 电调反馈余量)。
const CHANNEL_BOUND: usize = 512;
/// 打开 CAN 接口并桥接为 [`CanLink`],返回面向总线驱动的端点。
///
/// 内部:
/// - 硬件过滤只放行电调反馈 ID 段(`0x200..=0x20F`,覆盖 0x201–0x208,
/// 非反馈帧再由协议层解码拒绝,双保险);
/// - 两个桥接任务:socket → 通道(反馈)、通道 → socket(命令)。
///
/// # Errors
///
/// 接口不存在 / 权限不足等 `io::Error`。
pub fn open_link(interface: &str) -> io::Result<CanLink> {
let socket = CanSocket::open(interface)?;
socket.set_filters(&[CanFilter::new(0x200, 0x7F0)])?;
let socket = Arc::new(socket);
let (bus_side, bridge_side) = CanLink::pair(CHANNEL_BOUND);
let CanLink { outgoing_tx, incoming_rx } = bridge_side;
tokio::spawn(rx_bridge(Arc::clone(&socket), outgoing_tx));
tokio::spawn(tx_bridge(socket, incoming_rx));
Ok(bus_side)
}
/// socket → 通道:真实总线反馈进入协议 bus。
async fn rx_bridge(socket: Arc<CanSocket>, tx: mpsc::Sender<LinkFrame>) {
loop {
match socket.read_frame().await {
Ok(frame) => {
if let Some(frame) = to_link_frame(&frame) {
if tx.send(frame).await.is_err() {
debug!("bus side closed, rx bridge exiting");
return;
}
}
}
Err(e) => {
// 接口脱落(如 USB 转 CAN 拔出)等致命错误:退出守护进程,
// 由 systemd 拉起重连。
error!(error = %e, "CAN read failed, rx bridge exiting");
return;
}
}
}
}
/// 通道 → socket:协议 bus 命令写入真实总线。
async fn tx_bridge(socket: Arc<CanSocket>, mut rx: mpsc::Receiver<LinkFrame>) {
while let Some(frame) = rx.recv().await {
match to_socketcan_frame(&frame) {
Some(sc_frame) => {
if let Err(e) = socket.write_frame(sc_frame).await {
error!(error = %e, "CAN write failed, tx bridge exiting");
return;
}
}
None => {
debug!(id = frame.id, "drop frame with non-standard CAN id");
}
}
}
debug!("bus side closed, tx bridge exiting");
}
/// socketcan 帧 → 链路帧(仅 8 字节数据帧)。
fn to_link_frame(frame: &ScFrame) -> Option<LinkFrame> {
match frame {
ScFrame::Data(df) if df.data().len() == 8 => {
Some(LinkFrame::new(df.raw_id(), df.data().try_into().ok()?))
}
_ => None,
}
}
/// 链路帧 → socketcan 帧(ID 必须落在 11 位标准 ID 范围内)。
fn to_socketcan_frame(frame: &LinkFrame) -> Option<ScFrame> {
let id = socketcan::StandardId::new(frame.id as u16)?;
let df = CanDataFrame::new(id, &frame.data)?;
Some(ScFrame::Data(df))
}
+11
View File
@@ -0,0 +1,11 @@
//! SocketCAN 桥接:把真实 Linux CAN 接口接入 [`can_link::CanLink`] 通道对。
//!
//! 本 crate 主体仅在 Linux 下编译(依赖 socketcan)。其他平台编译为空壳,
//! 使整个 workspace 可以在任意开发机上构建与测试;实车部署目标板均为
//! Linux。
#[cfg(target_os = "linux")]
mod bridge;
#[cfg(target_os = "linux")]
pub use bridge::open_link;
+132
View File
@@ -0,0 +1,132 @@
//! vcan 集成测试:在真实 Linux 内核 CAN 栈上验证桥接。
//!
//! 仅 Linux 下编译;默认 `#[ignore]`,需要 vcan0(见 justfile `vcan-test`)。
#![cfg(target_os = "linux")]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::time::Duration;
use dji_esc::{CanFrame, CanLink};
use motor_core::Motor;
use socketcan::tokio::CanSocket;
use socketcan::{CanDataFrame, CanFrame as ScFrame, EmbeddedFrame as _, Frame as _, StandardId};
use tokio::time::timeout;
const IFACE: &str = "vcan0";
fn sc_data_frame(id: u16, data: [u8; 8]) -> ScFrame {
ScFrame::Data(CanDataFrame::new(StandardId::new(id).unwrap(), &data).unwrap())
}
#[tokio::test]
#[ignore = "requires vcan0 (see `just vcan-test`)"]
async fn bridge_relays_command_to_bus() {
let link = dji_esc_socketcan::open_link(IFACE).unwrap();
let observer = CanSocket::open(IFACE).unwrap();
let command = CanFrame::new(0x200, [0x04, 0xD2, 0, 0, 0, 0, 0, 0]);
link.outgoing_tx.send(command).await.unwrap();
let got = timeout(Duration::from_secs(2), observer.read_frame())
.await
.expect("timeout waiting for command frame")
.unwrap();
match got {
ScFrame::Data(df) => {
assert_eq!(df.raw_id(), 0x200);
assert_eq!(df.data(), &[0x04, 0xD2, 0, 0, 0, 0, 0, 0]);
}
other => panic!("expected data frame, got {other:?}"),
}
}
#[tokio::test]
#[ignore = "requires vcan0 (see `just vcan-test`)"]
async fn bridge_relays_feedback_from_bus() {
let CanLink { outgoing_tx: _, mut incoming_rx } = dji_esc_socketcan::open_link(IFACE).unwrap();
let simulator = CanSocket::open(IFACE).unwrap();
// 电调 1 反馈:角度 8191、-364rpm、电流 123、45℃。
let feedback = sc_data_frame(0x201, [0x1F, 0xFF, 0xFE, 0x94, 0x00, 0x7B, 45, 0]);
simulator.write_frame(feedback).await.unwrap();
let got = timeout(Duration::from_secs(2), incoming_rx.recv())
.await
.expect("timeout waiting for feedback frame")
.unwrap();
assert_eq!(got.id, 0x201);
assert_eq!(got.data, [0x1F, 0xFF, 0xFE, 0x94, 0x00, 0x7B, 45, 0]);
}
#[tokio::test]
#[ignore = "requires vcan0 (see `just vcan-test`)"]
async fn esc_bus_end_to_end_over_vcan() {
// EscBus ↔ 真实内核 CAN 栈 ↔ 模拟电调(第二个 socket)。
let link = dji_esc_socketcan::open_link(IFACE).unwrap();
let mut esc = EscSim::new(IFACE, &[1]);
let bus = dji_esc::EscBus::spawn(
link,
vec![dji_esc::EscConfig::gm3508("m1", 1, 1.0)],
dji_esc::BusOptions::default(),
)
.unwrap();
bus.motor(1).unwrap().set_velocity(2000.0);
// 仿真 2s:模拟电调按命令回反馈,速度环应收敛。
let motor = bus.motor(1).unwrap().clone();
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
loop {
esc.step_once().await;
if motor.state().rotor_speed_rpm > 1900.0 {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"not converged: {}",
motor.state().rotor_speed_rpm
);
tokio::time::sleep(Duration::from_millis(5)).await;
}
bus.shutdown().await;
}
/// 最小电调模拟:收命令帧 → 更新转速 → 回反馈帧。
struct EscSim {
socket: CanSocket,
speed_rpm: f32,
angle: u16,
}
impl EscSim {
fn new(iface: &str, _esc_ids: &[u8]) -> Self {
Self { socket: CanSocket::open(iface).unwrap(), speed_rpm: 0.0, angle: 0 }
}
/// 排空当前命令帧并推进模型一步(dt = 5ms)。
async fn step_once(&mut self) {
const DT: f32 = 0.005;
let mut current = 0i16;
while let Ok(frame) = self.socket.try_read_frame() {
if let ScFrame::Data(df) = frame {
if df.raw_id() == 0x200 {
current = i16::from_be_bytes([df.data()[0], df.data()[1]]);
}
}
}
let target = f32::from(current) * 9254.0 / 16384.0;
self.speed_rpm += (target - self.speed_rpm) * (1.0 - (-DT / 0.12).exp());
self.angle =
(u32::from(self.angle) + (self.speed_rpm / 60.0 * 8192.0 * DT) as u32) as u16 % 8192;
let data = {
let mut d = [0u8; 8];
d[0..2].copy_from_slice(&self.angle.to_be_bytes());
d[2..4].copy_from_slice(&(self.speed_rpm as i16).to_be_bytes());
d[4..6].copy_from_slice(&current.to_be_bytes());
d[6] = 30;
d
};
self.socket.write_frame(sc_data_frame(0x201, data)).await.unwrap();
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "dji-esc"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "DJI RoboMaster ESC (C620/C610) CAN protocol: codec, multi-turn tracking, and async bus driver"
[lints]
workspace = true
[dependencies]
motor-core = { workspace = true }
can-link = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
+512
View File
@@ -0,0 +1,512 @@
//! `EscBus` 异步驱动:协议状态机与 1kHz 控制节拍。
//!
//! [`EscBus::spawn`] 消费一个 [`CanLink`],启动收发两个任务:
//!
//! - 接收:解码反馈帧,更新各电机共享状态(含多圈累计与方向修正);
//! - 发送:按 `period` 节拍为每台电机求值当前命令(速度/位置环 PID / 直发
//! effort),按 `0x200`/`0x1FF` 分组组帧发送。
//!
//! 电机离线(从未收到反馈或反馈超时)时强制 0 输出并复位控制器。
//! 命令模式切换时同样复位控制器,避免积分冲击。
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
use motor_core::{
Command, CommandMode, Disable, EffortDrive, MotorFeedback, MotorState, Pid, PositionController,
PositionDrive, VelocityController, VelocityDrive,
};
use thiserror::Error;
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use tokio::time::{Instant, Interval, MissedTickBehavior};
use tracing::{debug, info, warn};
use crate::codec::{
EscFeedback, EscModel, clamp_current, command_frame_id, decode_feedback, encode_current_frame,
};
use crate::frame::{COMMAND_FRAME_ID_PRIMARY, COMMAND_FRAME_ID_SECONDARY, CanFrame, CanLink};
use crate::turn::TurnTracker;
/// 锁辅助:中毒锁直接恢复内部数据(锁中毒只意味着持锁线程恐慌,
/// 状态本身仍一致,驱动机不应因此停摆)。
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// 单台电机的配置。
#[derive(Debug, Clone)]
pub struct EscConfig {
/// 便于日志辨认的名字(如 `front_left`)。
pub name: String,
/// 电调 ID(1–8,即 CAN 拨码)。
pub esc_id: u8,
/// 电调型号。
pub model: EscModel,
/// 减速比(电机转子 : 输出轴)。
pub gear_ratio: f32,
/// 安装方向:`+1.0` 或 `-1.0`。反装电机的读写符号在此统一翻转,
/// 上层永远以"输出轴正转 = 前进方向"工作。
pub direction: f32,
/// effort 限幅(raw,`1..=型号满量程`)。
pub current_limit: i16,
/// 速度环比例增益(raw / rpm)。
pub kp: f32,
/// 速度环积分增益(raw / (rpm·s))。
pub ki: f32,
/// 速度环微分增益。
pub kd: f32,
/// 速度前馈(raw / rpm)。
pub feedforward: f32,
}
impl EscConfig {
/// GM3508 + C620 的常用配置。`kp/ki/ff` 为起步值,需按整车整定。
#[must_use]
pub fn gm3508(name: impl Into<String>, esc_id: u8, direction: f32) -> Self {
Self {
name: name.into(),
esc_id,
model: EscModel::C620,
// 3591 / 187 ≈ 19.203
gear_ratio: 3591.0 / 187.0,
direction,
current_limit: 12_000,
kp: 10.0,
ki: 0.5,
kd: 0.0,
feedforward: 1.5,
}
}
/// M2006 + C610(拨弹轮)的常用配置。
///
/// 减速比按 P36 起步(36.0),接硬件前需核对;电流标度见 [`EscModel::C610`]。
#[must_use]
pub fn m2006(name: impl Into<String>, esc_id: u8, direction: f32) -> Self {
Self {
name: name.into(),
esc_id,
model: EscModel::C610,
// M2006 P36 减速比(接入前核对)
gear_ratio: 36.0,
direction,
current_limit: 8_000,
kp: 8.0,
ki: 0.4,
kd: 0.0,
feedforward: 1.0,
}
}
fn validate(&self) -> Result<(), String> {
if command_frame_id(self.esc_id).is_none() {
return Err(format!("esc_id {} out of range 1..=8", self.esc_id));
}
if !(self.gear_ratio.is_finite() && self.gear_ratio > 0.0) {
return Err(format!("gear_ratio must be > 0, got {}", self.gear_ratio));
}
// 逐位比较:direction 只允许精确的 ±1.0。
let dir_bits = self.direction.to_bits();
if dir_bits != 1.0f32.to_bits() && dir_bits != (-1.0f32).to_bits() {
return Err(format!("direction must be +1.0 or -1.0, got {}", self.direction));
}
if !(1..=self.model.max_current_raw()).contains(&self.current_limit) {
return Err(format!(
"current_limit {} out of range 1..={} for {:?}",
self.current_limit,
self.model.max_current_raw(),
self.model
));
}
Ok(())
}
}
/// 总线运行参数。
#[derive(Debug, Clone)]
pub struct BusOptions {
/// 控制节拍(C620 期望 1kHz 命令)。
pub period: Duration,
/// 反馈离线阈值:超过该时长未收到某电机反馈 → 强制 0 输出。
pub feedback_timeout: Duration,
}
impl Default for BusOptions {
fn default() -> Self {
Self { period: Duration::from_millis(1), feedback_timeout: Duration::from_millis(100) }
}
}
/// 启动错误。
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum EscBusError {
/// 两台电机配置了同一电调 ID。
#[error("duplicate ESC id {0}")]
DuplicateEscId(u8),
/// 电机配置非法(原因见 `reason`)。
#[error("invalid config for motor {name}: {reason}")]
InvalidConfig {
/// 电机名。
name: String,
/// 非法原因。
reason: String,
},
/// 一台电机都没有配置。
#[error("no motors configured")]
NoMotors,
}
/// 单电机共享状态(驱动任务与 [`EscMotor`] 句柄之间)。
struct MotorShared {
cfg: EscConfig,
state: Mutex<MotorState>,
cmd: Mutex<Command>,
last_mode: Mutex<CommandMode>,
vel_ctrl: Mutex<VelocityController>,
pos_ctrl: Mutex<PositionController>,
turn: Mutex<TurnTracker>,
last_feedback: Mutex<Option<Instant>>,
offline_warned: std::sync::atomic::AtomicBool,
}
impl MotorShared {
fn new(cfg: EscConfig) -> Self {
let limit = cfg.current_limit;
let pid = Pid::new(cfg.kp, cfg.ki, cfg.kd, -f32::from(limit), f32::from(limit));
let pos_pid = Pid::new(cfg.kp, cfg.ki, cfg.kd, -f32::from(limit), f32::from(limit));
Self {
vel_ctrl: Mutex::new(VelocityController::new(pid, cfg.feedforward)),
pos_ctrl: Mutex::new(PositionController::direct(pos_pid)),
cfg,
state: Mutex::new(MotorState::default()),
cmd: Mutex::new(Command::Disabled),
last_mode: Mutex::new(CommandMode::Disabled),
turn: Mutex::new(TurnTracker::new()),
last_feedback: Mutex::new(None),
offline_warned: std::sync::atomic::AtomicBool::new(false),
}
}
/// 应用一帧反馈(原始电调侧值 → 方向修正后的物理量)。
fn apply_feedback(&self, fb: EscFeedback, now: Instant) {
let esc_rev = lock(&self.turn).update(fb.angle_raw);
let dir = self.cfg.direction;
{
let mut st = lock(&self.state);
st.rotor_angle_raw = fb.angle_raw;
st.rotor_revolutions = dir * esc_rev;
st.rotor_speed_rpm = dir * f32::from(fb.speed_rpm);
st.effort_raw = (dir * f32::from(fb.current_raw)) as i16;
st.temperature_c = fb.temperature_c;
st.feedback_count = st.feedback_count.saturating_add(1);
}
*lock(&self.last_feedback) = Some(now);
self.offline_warned.store(false, std::sync::atomic::Ordering::Relaxed);
}
/// 复位主机侧控制器(离线或模式切换时)。
fn reset_controllers(&self) {
lock(&self.vel_ctrl).reset();
lock(&self.pos_ctrl).reset();
}
/// 求值本节拍应下发的 effort 命令(电调侧 raw,含方向翻转与限幅)。
fn compute_effort(&self, dt: f32, feedback_timeout: Duration, now: Instant) -> i16 {
let online = matches!(*lock(&self.last_feedback), Some(t) if now.duration_since(t) <= feedback_timeout);
if !online {
if !self.offline_warned.swap(true, std::sync::atomic::Ordering::Relaxed) {
warn!(motor = %self.cfg.name, esc_id = self.cfg.esc_id, "feedback offline, forcing zero current");
}
// 复位控制器,避免重新上线时旧积分冲击。
self.reset_controllers();
*lock(&self.last_mode) = CommandMode::Disabled;
return 0;
}
let cmd = *lock(&self.cmd);
// 模式切换复位:避免积分/微分记忆冲击。
{
let mut last_mode = lock(&self.last_mode);
if *last_mode != cmd.mode() {
debug!(motor = %self.cfg.name, from = ?*last_mode, to = ?cmd.mode(), "command mode switch, resetting controllers");
self.reset_controllers();
*last_mode = cmd.mode();
}
}
let dir = self.cfg.direction;
let raw = match cmd {
Command::Disabled => 0,
Command::EffortRaw(raw) => (f32::from(raw) * dir) as i16,
Command::Velocity(target) => {
let measured = lock(&self.state).rotor_speed_rpm;
let out = lock(&self.vel_ctrl).update(target, measured, dt);
(out * dir) as i16
}
Command::Position(target) => {
let (measured_rev, measured_rpm) = {
let st = lock(&self.state);
(st.rotor_revolutions, st.rotor_speed_rpm)
};
let out = lock(&self.pos_ctrl).update(target, measured_rev, measured_rpm, dt);
(out * dir) as i16
}
};
let limit = self.cfg.current_limit;
clamp_current(self.cfg.model, raw.clamp(-limit, limit))
}
}
/// 电机的公共句柄:可克隆,跨任务共享。
#[derive(Clone)]
pub struct EscMotor {
shared: Arc<MotorShared>,
}
impl EscMotor {
/// 电机名。
#[must_use]
pub fn name(&self) -> &str {
&self.shared.cfg.name
}
/// 电机配置。
#[must_use]
pub fn config(&self) -> &EscConfig {
&self.shared.cfg
}
/// 当前命令。
#[must_use]
pub fn command(&self) -> Command {
*lock(&self.shared.cmd)
}
/// 最近一帧反馈的时刻(诊断用)。
#[must_use]
pub fn last_feedback(&self) -> Option<Instant> {
*lock(&self.shared.last_feedback)
}
}
impl MotorFeedback for EscMotor {
fn id(&self) -> u8 {
self.shared.cfg.esc_id
}
fn state(&self) -> MotorState {
*lock(&self.shared.state)
}
}
impl VelocityDrive for EscMotor {
fn set_velocity(&self, rotor_rpm: f32) {
*lock(&self.shared.cmd) = Command::Velocity(rotor_rpm);
}
}
impl EffortDrive for EscMotor {
fn set_effort_raw(&self, raw: i16) {
*lock(&self.shared.cmd) = Command::EffortRaw(raw);
}
}
impl PositionDrive for EscMotor {
fn set_position(&self, rotor_rev: f32) {
*lock(&self.shared.cmd) = Command::Position(rotor_rev);
}
}
impl Disable for EscMotor {
fn disable(&self) {
*lock(&self.shared.cmd) = Command::Disabled;
}
}
/// 运行中的电调总线。
pub struct EscBus;
impl EscBus {
/// 启动收发任务,返回句柄。
///
/// # Errors
///
/// 配置为空、电调 ID 重复或单台配置非法时返回 [`EscBusError`]。
pub fn spawn(
link: CanLink,
configs: Vec<EscConfig>,
options: BusOptions,
) -> Result<EscBusHandle, EscBusError> {
if configs.is_empty() {
return Err(EscBusError::NoMotors);
}
let mut motors = Vec::with_capacity(configs.len());
let mut seen = std::collections::HashSet::new();
for cfg in configs {
cfg.validate()
.map_err(|reason| EscBusError::InvalidConfig { name: cfg.name.clone(), reason })?;
if !seen.insert(cfg.esc_id) {
return Err(EscBusError::DuplicateEscId(cfg.esc_id));
}
motors.push(EscMotor { shared: Arc::new(MotorShared::new(cfg)) });
}
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let CanLink { outgoing_tx, incoming_rx } = link;
let motors = Arc::new(motors);
let rx_handle =
tokio::spawn(rx_loop(incoming_rx, Arc::clone(&motors), shutdown_rx.clone()));
let tx_handle =
tokio::spawn(tx_loop(outgoing_tx, Arc::clone(&motors), options, shutdown_rx));
Ok(EscBusHandle { motors, shutdown_tx, tasks: vec![rx_handle, tx_handle] })
}
}
/// `EscBus` 的控制句柄。
pub struct EscBusHandle {
motors: Arc<Vec<EscMotor>>,
shutdown_tx: watch::Sender<bool>,
tasks: Vec<JoinHandle<()>>,
}
impl EscBusHandle {
/// 按电调 ID 取电机句柄。
#[must_use]
pub fn motor(&self, esc_id: u8) -> Option<&EscMotor> {
self.motors.iter().find(|m| m.id() == esc_id)
}
/// 全部电机(按配置顺序)。
#[must_use]
pub fn motors(&self) -> &[EscMotor] {
&self.motors
}
/// 停止收发任务并等待退出。
pub async fn shutdown(self) {
let _ = self.shutdown_tx.send(true);
for task in self.tasks {
let _ = task.await;
}
}
}
async fn rx_loop(
mut incoming: mpsc::Receiver<CanFrame>,
motors: Arc<Vec<EscMotor>>,
mut shutdown: watch::Receiver<bool>,
) {
loop {
let frame = tokio::select! {
_ = shutdown.changed() => break,
frame = incoming.recv() => match frame {
Some(frame) => frame,
None => break,
},
};
match decode_feedback(&frame) {
Ok((esc_id, fb)) => {
if let Some(motor) = motors.iter().find(|m| m.id() == esc_id) {
motor.shared.apply_feedback(fb, Instant::now());
}
}
Err(e) => {
debug!(error = %e, id = frame.id, "ignore non-feedback frame");
}
}
}
}
async fn tx_loop(
outgoing: mpsc::Sender<CanFrame>,
motors: Arc<Vec<EscMotor>>,
options: BusOptions,
mut shutdown: watch::Receiver<bool>,
) {
let dt = options.period.as_secs_f32();
let mut ticker = tick_every(options.period);
loop {
tokio::select! {
_ = shutdown.changed() => break,
_ = ticker.tick() => {}
}
let now = Instant::now();
for (frame_id, group_start) in
[(COMMAND_FRAME_ID_PRIMARY, 1u8), (COMMAND_FRAME_ID_SECONDARY, 5u8)]
{
let group: Vec<&EscMotor> = motors
.iter()
.filter(|m| (group_start..group_start + 4).contains(&m.id()))
.collect();
if group.is_empty() {
continue;
}
let mut currents = [0i16; 4];
for motor in &group {
let slot = usize::from(motor.id() - group_start);
currents[slot] = motor.shared.compute_effort(dt, options.feedback_timeout, now);
}
if outgoing.send(encode_current_frame(frame_id, currents)).await.is_err() {
info!("can link closed, tx loop exiting");
return;
}
}
}
}
fn tick_every(period: Duration) -> Interval {
let mut ticker = tokio::time::interval(period);
// 错过节拍后直接跳到当前节拍,避免补发帧风暴。
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
ticker
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_validate_rejects_bad_values() {
let mut cfg = EscConfig::gm3508("m", 1, 1.0);
cfg.esc_id = 0;
assert!(cfg.validate().is_err());
cfg.esc_id = 9;
assert!(cfg.validate().is_err());
cfg.esc_id = 5;
cfg.gear_ratio = 0.0;
assert!(cfg.validate().is_err());
cfg.gear_ratio = 19.2;
cfg.direction = 0.5;
assert!(cfg.validate().is_err());
cfg.direction = -1.0;
cfg.current_limit = 0;
assert!(cfg.validate().is_err());
cfg.current_limit = 16385;
assert!(cfg.validate().is_err());
cfg.current_limit = 12_000;
assert!(cfg.validate().is_ok());
}
#[test]
fn gm3508_defaults_are_sane() {
let cfg = EscConfig::gm3508("front_left", 1, -1.0);
assert_eq!(cfg.model, EscModel::C620);
assert!((cfg.gear_ratio - 19.2).abs() < 0.01);
assert_eq!(cfg.current_limit, 12_000);
assert_eq!(cfg.direction.to_bits(), (-1.0f32).to_bits());
assert!(cfg.validate().is_ok());
}
#[test]
fn m2006_defaults_are_sane() {
let cfg = EscConfig::m2006("feeder", 5, 1.0);
assert_eq!(cfg.model, EscModel::C610);
assert!((cfg.gear_ratio - 36.0).abs() < 0.01);
assert!(cfg.validate().is_ok());
}
}
+249
View File
@@ -0,0 +1,249 @@
//! DJI 电调(C620 / C610)CAN 协议编解码。
//!
//! 帧格式(大端):
//!
//! - 控制(`0x200` 电调 1–4 / `0x1FF` 电调 5–8):
//! `[I1_H, I1_L, I2_H, I2_L, I3_H, I3_L, I4_H, I4_L]`,i16 电流命令,
//! 槽位按组内电调顺序。
//! - 反馈(`0x200 + 电调ID`):
//! `u16 角度(0..8191) + i16 转速rpm + i16 转矩电流 + i8 温度℃ + 保留`。
//!
//! 本模块只做纯编解码;主机侧速度/位置环由 [`motor_core`] 算法在
//! [`crate::bus`] 中按节拍调用。
use crate::frame::{
COMMAND_FRAME_ID_PRIMARY, COMMAND_FRAME_ID_SECONDARY, CanFrame, FEEDBACK_FRAME_ID_BASE,
};
use thiserror::Error;
/// 电调 ID 下限。
pub const MIN_ESC_ID: u8 = 1;
/// 电调 ID 上限。
pub const MAX_ESC_ID: u8 = 8;
/// 电调型号决定电流标度。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EscModel {
/// C620(配 GM3508):±16384 ↔ ±20 A。
C620,
/// C610(配 M2006):±10000 ↔ ±10 A。
///
/// 接入前需实测核对。
C610,
}
impl EscModel {
/// 满量程电流命令(raw)。
#[must_use]
pub const fn max_current_raw(self) -> i16 {
match self {
Self::C620 => 16384,
Self::C610 => 10_000,
}
}
/// 满量程电流(安培)。
#[must_use]
pub const fn max_current_amp(self) -> f32 {
match self {
Self::C620 => 20.0,
Self::C610 => 10.0,
}
}
/// 安培 → raw(限幅)。
#[must_use]
pub fn raw_from_amp(self, amp: f32) -> i16 {
let raw = amp * f32::from(self.max_current_raw()) / self.max_current_amp();
(raw.clamp(-f32::from(self.max_current_raw()), f32::from(self.max_current_raw())) as i16)
.clamp(-self.max_current_raw(), self.max_current_raw())
}
/// raw → 安培。
#[must_use]
pub fn amp_from_raw(self, raw: i16) -> f32 {
f32::from(raw) * self.max_current_amp() / f32::from(self.max_current_raw())
}
}
/// 一帧电调反馈(原始、电调侧,未经方向修正)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EscFeedback {
/// 转子机械角原始值(0..8191,对应 0..360°)。
pub angle_raw: u16,
/// 转子转速(rpm,电调侧符号)。
pub speed_rpm: i16,
/// 转矩电流 raw(电调侧符号)。
pub current_raw: i16,
/// 电机温度(℃)。
pub temperature_c: i8,
}
/// 帧解码错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum DecodeError {
/// 帧不属于电调反馈范围(0x201..=0x208)。
#[error("frame id 0x{id:03X} is not an ESC feedback frame (0x201..=0x208)")]
NotFeedback {
/// 收到的帧 ID。
id: u32,
},
}
/// 电调 ID → 其电流命令所在的组控制帧 ID。
///
/// 返回 `None` 表示 ID 越界。
#[must_use]
pub const fn command_frame_id(esc_id: u8) -> Option<u32> {
match esc_id {
1..=4 => Some(COMMAND_FRAME_ID_PRIMARY),
5..=8 => Some(COMMAND_FRAME_ID_SECONDARY),
_ => None,
}
}
/// 将 raw 电流限幅到电调满量程。
#[must_use]
pub const fn clamp_current(model: EscModel, raw: i16) -> i16 {
if raw > model.max_current_raw() {
model.max_current_raw()
} else if raw < -model.max_current_raw() {
-model.max_current_raw()
} else {
raw
}
}
/// 组装一个控制组帧(`0x200` 或 `0x1FF`)。
///
/// `currents` 按组内槽位排列:主组对应电调 1–4,副组对应电调 5–8。
/// 本函数只做纯编码;电流限幅由驱动层按各电机型号与限流配置完成
/// (见 [`clamp_current`])。
#[must_use]
pub fn encode_current_frame(frame_id: u32, currents: [i16; 4]) -> CanFrame {
let mut data = [0u8; 8];
for (slot, raw) in currents.iter().enumerate() {
data[slot * 2] = (raw >> 8) as u8;
data[slot * 2 + 1] = (raw & 0xFF) as u8;
}
CanFrame::new(frame_id, data)
}
/// 组装一帧电调反馈(仿真器 / 测试用)。
#[must_use]
pub fn encode_feedback_frame(esc_id: u8, feedback: &EscFeedback) -> CanFrame {
let mut data = [0u8; 8];
data[0..2].copy_from_slice(&feedback.angle_raw.to_be_bytes());
data[2..4].copy_from_slice(&feedback.speed_rpm.to_be_bytes());
data[4..6].copy_from_slice(&feedback.current_raw.to_be_bytes());
data[6] = feedback.temperature_c as u8;
CanFrame::new(FEEDBACK_FRAME_ID_BASE + u32::from(esc_id), data)
}
/// 解码一帧电调反馈,返回 `(电调ID, 反馈)`。
///
/// # Errors
///
/// 帧不属于反馈 ID 范围(0x201..=0x208)时返回 [`DecodeError::NotFeedback`]。
pub fn decode_feedback(frame: &CanFrame) -> Result<(u8, EscFeedback), DecodeError> {
if frame.id <= FEEDBACK_FRAME_ID_BASE
|| frame.id > FEEDBACK_FRAME_ID_BASE + u32::from(MAX_ESC_ID)
{
return Err(DecodeError::NotFeedback { id: frame.id });
}
let esc_id = (frame.id - FEEDBACK_FRAME_ID_BASE) as u8;
Ok((
esc_id,
EscFeedback {
angle_raw: u16::from_be_bytes([frame.data[0], frame.data[1]]),
speed_rpm: i16::from_be_bytes([frame.data[2], frame.data[3]]),
current_raw: i16::from_be_bytes([frame.data[4], frame.data[5]]),
temperature_c: frame.data[6] as i8,
},
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_frame_id_routing() {
assert_eq!(command_frame_id(1), Some(0x200));
assert_eq!(command_frame_id(4), Some(0x200));
assert_eq!(command_frame_id(5), Some(0x1FF));
assert_eq!(command_frame_id(8), Some(0x1FF));
assert_eq!(command_frame_id(0), None);
assert_eq!(command_frame_id(9), None);
}
#[test]
fn encode_current_frame_bytes() {
let frame = encode_current_frame(0x200, [16384, -16384, 0, 258]);
assert_eq!(frame.id, 0x200);
assert_eq!(frame.data, [0x40, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x01, 0x02]);
let frame = encode_current_frame(0x1FF, [10000, -10000, 0, 0]);
assert_eq!(frame.id, 0x1FF);
assert_eq!(frame.data, [0x27, 0x10, 0xD8, 0xF0, 0x00, 0x00, 0x00, 0x00]);
}
#[test]
fn decode_feedback_known_bytes() {
let frame = CanFrame::new(0x205, [0x1F, 0xFF, 0xFE, 0x94, 0x00, 0x7B, 45, 0]);
let (esc_id, fb) = decode_feedback(&frame).unwrap();
assert_eq!(esc_id, 5);
assert_eq!(fb.angle_raw, 8191);
assert_eq!(fb.speed_rpm, -364);
assert_eq!(fb.current_raw, 123);
assert_eq!(fb.temperature_c, 45);
}
#[test]
fn decode_feedback_rejects_non_feedback_ids() {
for id in [0x000, 0x1FF, 0x200, 0x209, 0x7FF] {
let frame = CanFrame::new(id, [0; 8]);
assert_eq!(
decode_feedback(&frame),
Err(DecodeError::NotFeedback { id }),
"id = 0x{id:03X}"
);
}
}
#[test]
fn feedback_roundtrip_all_ids() {
let fb = EscFeedback {
angle_raw: 8191,
speed_rpm: -9254,
current_raw: -16384,
temperature_c: -40,
};
for esc_id in MIN_ESC_ID..=MAX_ESC_ID {
let frame = encode_feedback_frame(esc_id, &fb);
assert_eq!(frame.id, 0x200 + u32::from(esc_id));
assert_eq!(decode_feedback(&frame).unwrap(), (esc_id, fb));
}
}
#[test]
fn amp_conversion() {
let c620 = EscModel::C620;
assert!((c620.amp_from_raw(16384) - 20.0).abs() < 1e-4);
assert!((c620.amp_from_raw(-8192) + 10.0).abs() < 1e-4);
assert_eq!(c620.raw_from_amp(20.0), 16384);
assert_eq!(c620.raw_from_amp(-5.0), -4096);
// 超量程限幅。
assert_eq!(c620.raw_from_amp(99.0), 16384);
let c610 = EscModel::C610;
assert!((c610.amp_from_raw(10000) - 10.0).abs() < 1e-4);
assert_eq!(c610.raw_from_amp(10.0), 10000);
}
#[test]
fn clamp_current_respects_model() {
assert_eq!(clamp_current(EscModel::C620, 20000), 16384);
assert_eq!(clamp_current(EscModel::C610, 15000), 10000);
assert_eq!(clamp_current(EscModel::C620, 1000), 1000);
}
}
+13
View File
@@ -0,0 +1,13 @@
//! DJI 电调协议帧 ID 常量。
//!
//! 帧与链路类型([`CanFrame`] / [`CanLink`])由 [`can_link`] 提供,
//! 本模块只保留 **C620/C610 电流协议族**的 ID 约定。
pub use can_link::{CanFrame, CanLink};
/// 控制帧 ID:电调 1–4 的电流命令(8 字节 = 4 × i16 大端)。
pub const COMMAND_FRAME_ID_PRIMARY: u32 = 0x200;
/// 控制帧 ID:电调 5–8 的电流命令。
pub const COMMAND_FRAME_ID_SECONDARY: u32 = 0x1FF;
/// 反馈帧 ID 基址:`0x200 + 电调ID`(0x201–0x208)。
pub const FEEDBACK_FRAME_ID_BASE: u32 = 0x200;
+29
View File
@@ -0,0 +1,29 @@
//! DJI RoboMaster 电调(C620/C610)CAN 协议与异步驱动。
//!
//! 分层:
//!
//! - [`codec`]:纯函数帧编解码(C620 手册协议);
//! - [`turn`]:单圈编码器 → 连续多圈角;
//! - [`frame`]:协议帧 ID 常量(帧/链路类型见 [`can_link`]);
//! - [`bus`]:[`EscBus`] 驱动——反馈分发、主机速度/位置环、分组组帧、离线安全;
//! - [`sim`]:被控对象仿真(测试与 `--mock` 演示复用)。
//!
//! 电机以 [`motor_core`] 能力 trait 呈现,物理量均已按 `EscConfig::direction`
//! 完成方向修正。
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub mod bus;
pub mod codec;
pub mod frame;
pub mod sim;
pub mod turn;
pub use bus::{BusOptions, EscBus, EscBusError, EscBusHandle, EscConfig, EscMotor};
pub use can_link::{CanFrame, CanLink};
pub use codec::{
DecodeError, EscFeedback, EscModel, MAX_ESC_ID, MIN_ESC_ID, clamp_current, command_frame_id,
decode_feedback, encode_current_frame, encode_feedback_frame,
};
pub use frame::{COMMAND_FRAME_ID_PRIMARY, COMMAND_FRAME_ID_SECONDARY, FEEDBACK_FRAME_ID_BASE};
pub use sim::{PlantMotor, spawn_plant};
pub use turn::{ENCODER_TICKS, TurnTracker};
+116
View File
@@ -0,0 +1,116 @@
//! 电机被控对象仿真:一阶转速模型 + 编码器积分。
//!
//! 与 `EscBus` 接在同一对 [`CanLink`] 上即可构成全链路闭环
//! (测试、`chassisd --mock` 均复用)。
use std::collections::HashMap;
use std::time::Duration;
use tokio::task::JoinHandle;
use tokio::time::{MissedTickBehavior, interval};
use tracing::trace;
use crate::codec::{EscFeedback, encode_feedback_frame};
use crate::frame::{COMMAND_FRAME_ID_PRIMARY, COMMAND_FRAME_ID_SECONDARY, CanFrame, CanLink};
/// 单台电机被控对象(转子侧、电调坐标系:正电流 → 正转速)。
///
/// 模型:`rpm_target = current_raw × gain`(限幅于 `max_rpm`),转速以时间常数
/// `tau` 一阶趋近目标;机械角按转速积分并按 8192 取模。
#[derive(Debug, Clone)]
pub struct PlantMotor {
gain_rpm_per_raw: f32,
tau: f32,
max_rpm: f32,
speed_rpm: f32,
angle_raw: f32,
}
impl PlantMotor {
/// GM3508 量级:16384 raw 空载约 9254 rpm,机电时间常数约 120ms。
#[must_use]
pub fn gm3508() -> Self {
Self {
gain_rpm_per_raw: 9254.0 / 16384.0,
tau: 0.12,
max_rpm: 9254.0,
speed_rpm: 0.0,
angle_raw: 0.0,
}
}
/// 当前转子转速(rpm,电调侧)。
#[must_use]
pub fn speed_rpm(&self) -> f32 {
self.speed_rpm
}
/// 推进一个时间步,返回本步反馈帧内容。
pub fn step(&mut self, current_raw: i16, dt: f32) -> EscFeedback {
let target =
(f32::from(current_raw) * self.gain_rpm_per_raw).clamp(-self.max_rpm, self.max_rpm);
let alpha = 1.0 - (-dt / self.tau).exp();
self.speed_rpm += (target - self.speed_rpm) * alpha;
self.angle_raw = (self.angle_raw + self.speed_rpm / 60.0 * 8192.0 * dt).rem_euclid(8192.0);
EscFeedback {
angle_raw: self.angle_raw as u16,
speed_rpm: self.speed_rpm as i16,
current_raw,
temperature_c: 30,
}
}
}
/// 仿真整条电调总线:消费命令帧(0x200/0x1FF),按 `tick` 节拍产出反馈帧。
///
/// 返回任务句柄,测试结束或退出演示时 `abort()` 即可。
pub fn spawn_plant(mut link: CanLink, esc_ids: &[u8], tick: Duration) -> JoinHandle<()> {
let mut plants: HashMap<u8, PlantMotor> =
esc_ids.iter().map(|&id| (id, PlantMotor::gm3508())).collect();
let mut commands: HashMap<u8, i16> = esc_ids.iter().map(|&id| (id, 0)).collect();
let dt = tick.as_secs_f32();
let mut ticker = interval(tick);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
tokio::spawn(async move {
loop {
tokio::select! {
frame = link.incoming_rx.recv() => match frame {
Some(frame) => apply_command_frame(&frame, &mut commands),
None => break,
},
_ = ticker.tick() => {
for (&id, plant) in &mut plants {
let cmd = commands.get(&id).copied().unwrap_or(0);
let feedback = plant.step(cmd, dt);
if link
.outgoing_tx
.send(encode_feedback_frame(id, &feedback))
.await
.is_err()
{
return;
}
}
}
}
}
trace!("plant exiting");
})
}
fn apply_command_frame(frame: &CanFrame, commands: &mut HashMap<u8, i16>) {
let group_start = match frame.id {
COMMAND_FRAME_ID_PRIMARY => 1u8,
COMMAND_FRAME_ID_SECONDARY => 5u8,
_ => return,
};
for slot in 0..4u8 {
if let Some(cmd) = commands.get_mut(&(group_start + slot)) {
*cmd = i16::from_be_bytes([
frame.data[(slot * 2) as usize],
frame.data[(slot * 2 + 1) as usize],
]);
}
}
}
+134
View File
@@ -0,0 +1,134 @@
//! 多圈累计:单圈编码器原始角 → 连续圈数。
/// C620/C610 反馈编码器单圈计数(0..8191 对应 0..360°)。
pub const ENCODER_TICKS: u16 = 8192;
/// 半圈阈值:帧间角变化超过它视为发生回绕。
const HALF_TURN_TICKS: i32 = 4096;
/// 单圈机械角跟踪器:把 0..8191 的原始角序列转成连续累计角(单位:圈)。
///
/// 假设相邻两帧间转子转动不超过半圈。1kHz 反馈下 GM3508 转子上限约
/// 9300 rpm ≈ 0.155 圈/ms,余量充足。
#[derive(Debug, Clone, Default)]
pub struct TurnTracker {
revolutions: i32,
prev_raw: Option<u16>,
last_angle_rev: f32,
}
impl TurnTracker {
/// 创建跟踪器。
#[must_use]
pub const fn new() -> Self {
Self { revolutions: 0, prev_raw: None, last_angle_rev: 0.0 }
}
/// 送入新的原始角,返回连续累计角(圈)。
pub fn update(&mut self, raw: u16) -> f32 {
if let Some(prev) = self.prev_raw {
let delta = i32::from(raw) - i32::from(prev);
if delta > HALF_TURN_TICKS {
// 正向跨越 8191 → 0。
self.revolutions -= 1;
} else if delta < -HALF_TURN_TICKS {
// 反向跨越 0 → 8191。
self.revolutions += 1;
}
}
self.prev_raw = Some(raw);
self.last_angle_rev = self.revolutions as f32 + f32::from(raw) / 8192.0;
self.last_angle_rev
}
/// 最近一次 [`TurnTracker::update`] 的返回值。
#[must_use]
pub const fn last_angle_rev(&self) -> f32 {
self.last_angle_rev
}
/// 清零回到初始状态。
pub fn reset(&mut self) {
*self = Self::new();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_reading_is_raw_fraction() {
let mut t = TurnTracker::new();
assert!((t.update(4096) - 0.5).abs() < 1e-4);
}
#[test]
fn forward_wrap_counts_up() {
let mut t = TurnTracker::new();
t.update(8000);
let after = t.update(100); // 8000 → 8191 → 0 → 100
assert!((after - (8192.0 + 100.0) / 8192.0).abs() < 1e-4, "{after}");
}
#[test]
fn reverse_wrap_counts_down() {
let mut t = TurnTracker::new();
t.update(100);
let after = t.update(8000); // 100 → 0 → 8191 → 8000(反向)
assert!((after - (8000.0 - 8192.0) / 8192.0).abs() < 1e-4, "{after}");
}
#[test]
fn small_deltas_do_not_wrap() {
let mut t = TurnTracker::new();
t.update(0);
assert!((t.update(4000) - 4000.0 / 8192.0).abs() < 1e-4);
assert!((t.update(4100) - 4100.0 / 8192.0).abs() < 1e-4);
}
#[test]
fn multi_turn_forward_then_reverse_returns() {
let mut t = TurnTracker::new();
// 正向 3 整圈:每圈 0 → 4000 → 8000 → 100(跨 8191 回绕,净 +1)。
for _ in 0..3 {
t.update(0);
t.update(4000);
t.update(8000);
t.update(100);
}
let fwd = t.last_angle_rev();
let expected_fwd = 3.0 + 100.0 / 8192.0;
assert!((fwd - expected_fwd).abs() < 1e-4, "{fwd} vs {expected_fwd}");
// 沿同一路径反向退回 3 圈:100 → 8000(反向回绕)→ 4000 → 0,净 -1/圈。
for _ in 0..3 {
t.update(8000);
t.update(4000);
t.update(0);
}
let back = t.last_angle_rev();
assert!(back.abs() < 1e-4, "should return to 0, got {back}");
}
#[test]
fn jitter_at_boundary_stays_put() {
// 0 附近抖动不产生虚假圈数。
let mut t = TurnTracker::new();
t.update(8191);
assert!((t.update(0) - 1.0).abs() < 1e-4); // 真实跨越一次
assert!((t.update(2) - (2.0 / 8192.0 + 1.0)).abs() < 1e-4);
assert!((t.update(8190) - (8190.0 / 8192.0)).abs() < 1e-4); // 抖回
assert!((t.update(5) - (1.0 + 5.0 / 8192.0)).abs() < 1e-4);
}
#[test]
fn reset_forgets_history() {
let mut t = TurnTracker::new();
t.update(8000);
t.update(100);
t.reset();
assert_eq!(t.last_angle_rev().to_bits(), 0.0f32.to_bits());
assert!((t.update(4096) - 0.5).abs() < 1e-4);
}
}
+275
View File
@@ -0,0 +1,275 @@
//! 闭环集成测试:`EscBus` + 内存通道 + 一阶电机仿真。
//!
//! 全部可在任意平台(macOS / Linux / CI)运行,无需硬件。
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::time::Duration;
use dji_esc::{
BusOptions, CanFrame, CanLink, EscBus, EscBusError, EscConfig, EscFeedback,
encode_feedback_frame, spawn_plant,
};
use motor_core::{Disable, EffortDrive, MotorFeedback, VelocityDrive};
use tokio::time::{sleep, timeout};
/// 与 `PlantMotor::gm3508` 增益精确匹配的速度环参数(ff = 16384 / 9254)。
fn tuned(esc_id: u8, direction: f32) -> EscConfig {
let mut cfg = EscConfig::gm3508(format!("m{esc_id}"), esc_id, direction);
cfg.kp = 8.0;
cfg.ki = 2.0;
cfg.kd = 0.0;
cfg.feedforward = 16384.0 / 9254.0;
cfg
}
/// 轮询直到条件满足或超时(毫秒,仿真时间)。
async fn wait_until(total_ms: u64, mut cond: impl FnMut() -> bool) -> bool {
let mut waited = 0u64;
while waited < total_ms {
if cond() {
return true;
}
sleep(Duration::from_millis(5)).await;
waited += 5;
}
cond()
}
/// 手动向总线注入一帧所有电机的反馈(新鲜度 100ms 内有效)。
async fn send_feedback(peer: &CanLink, esc_ids: &[u8]) {
for &id in esc_ids {
let fb = EscFeedback { angle_raw: 0, speed_rpm: 0, current_raw: 0, temperature_c: 30 };
peer.outgoing_tx.send(encode_feedback_frame(id, &fb)).await.unwrap();
}
}
async fn recv_command(peer: &mut CanLink) -> CanFrame {
timeout(Duration::from_millis(100), peer.incoming_rx.recv())
.await
.expect("timed out waiting for command frame")
.expect("link closed")
}
fn slot_current(frame: &CanFrame, slot: usize) -> i16 {
i16::from_be_bytes([frame.data[slot * 2], frame.data[slot * 2 + 1]])
}
// ---------------------------------------------------------------- 收敛性
#[tokio::test(start_paused = true)]
async fn velocity_loop_converges() {
let (bus_link, plant_link) = CanLink::pair(512);
let plant = spawn_plant(plant_link, &[1], Duration::from_millis(1));
let bus = EscBus::spawn(bus_link, vec![tuned(1, 1.0)], BusOptions::default()).unwrap();
let motor = bus.motor(1).unwrap().clone();
motor.set_velocity(3000.0);
let ok = wait_until(2000, || motor.state().rotor_speed_rpm >= 2950.0).await;
assert!(ok, "not converged, rpm = {}", motor.state().rotor_speed_rpm);
// 稳态保持(速度无漂移、反馈持续流入)。
let count_before = motor.state().feedback_count;
sleep(Duration::from_millis(300)).await;
let st = motor.state();
assert!((2950.0..=3050.0).contains(&st.rotor_speed_rpm), "steady state drifted: {st:?}");
assert!(
st.feedback_count >= count_before + 280,
"feedback should keep flowing: {} → {}",
count_before,
st.feedback_count
);
plant.abort();
bus.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn reversed_motor_converges_in_physical_frame() {
// direction = -1:物理 +2000 rpm ⇔ 电调侧 -2000 rpm。
let (bus_link, plant_link) = CanLink::pair(512);
let plant = spawn_plant(plant_link, &[2], Duration::from_millis(1));
let bus = EscBus::spawn(bus_link, vec![tuned(2, -1.0)], BusOptions::default()).unwrap();
let motor = bus.motor(2).unwrap().clone();
motor.set_velocity(2000.0);
let ok = wait_until(2000, || motor.state().rotor_speed_rpm >= 1950.0).await;
assert!(ok, "not converged, rpm = {}", motor.state().rotor_speed_rpm);
let st = motor.state();
assert!((1950.0..=2050.0).contains(&st.rotor_speed_rpm), "{st:?}");
// 物理转速为正 ⇒ 电调电流为负 ⇒ 物理电流 = dir × esc > 0。
assert!(st.effort_raw > 0, "{st:?}");
plant.abort();
bus.shutdown().await;
}
// ---------------------------------------------------------------- 安全
#[tokio::test(start_paused = true)]
async fn motor_without_feedback_commands_zero_current() {
let (bus_link, mut peer) = CanLink::pair(64);
// 不接 plant → 永远没有反馈。
let bus = EscBus::spawn(bus_link, vec![tuned(1, 1.0)], BusOptions::default()).unwrap();
bus.motor(1).unwrap().set_velocity(5000.0);
for _ in 0..5 {
let frame = recv_command(&mut peer).await;
assert_eq!(frame.id, 0x200);
assert_eq!(frame.data, [0; 8], "must send zero current while offline");
}
bus.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn current_limit_clamps_saturation() {
let (bus_link, mut peer) = CanLink::pair(64);
let mut cfg = tuned(1, 1.0);
cfg.current_limit = 5000;
let bus = EscBus::spawn(bus_link, vec![cfg], BusOptions::default()).unwrap();
send_feedback(&peer, &[1]).await;
bus.motor(1).unwrap().set_velocity(30000.0); // 不可达目标 → 深度饱和
sleep(Duration::from_millis(5)).await;
let mut saw_saturated = false;
for _ in 0..5 {
let frame = recv_command(&mut peer).await;
let raw = slot_current(&frame, 0);
assert!(raw <= 5000, "current {raw} exceeds limit 5000");
saw_saturated |= raw == 5000;
}
assert!(saw_saturated, "loop should reach the configured limit");
bus.shutdown().await;
}
// ---------------------------------------------------------------- 组帧与路由
#[tokio::test(start_paused = true)]
async fn current_commands_route_to_group_slots() {
let (bus_link, mut peer) = CanLink::pair(256);
let configs = (1..=5).map(|id| tuned(id, 1.0)).collect();
let bus = EscBus::spawn(bus_link, configs, BusOptions::default()).unwrap();
bus.motor(3).unwrap().set_effort_raw(1234);
bus.motor(5).unwrap().set_effort_raw(-567);
send_feedback(&peer, &(1..=5).collect::<Vec<_>>()).await;
sleep(Duration::from_millis(5)).await;
let mut saw_200 = false;
let mut saw_1ff = false;
for _ in 0..20 {
let frame = recv_command(&mut peer).await;
match frame.id {
0x200 if !saw_200 => {
assert_eq!(frame.data, [0, 0, 0, 0, 0x04, 0xD2, 0, 0], "esc3 slot = 1234");
saw_200 = true;
}
0x1FF if !saw_1ff => {
// -567 = 0xFDC9(i16 补码,大端)。
assert_eq!(frame.data, [0xFD, 0xC9, 0, 0, 0, 0, 0, 0], "esc5 slot = -567");
saw_1ff = true;
}
_ => {}
}
if saw_200 && saw_1ff {
break;
}
}
assert!(saw_200, "no 0x200 frame observed");
assert!(saw_1ff, "no 0x1FF frame observed");
// disable 后槽位清零。
bus.motor(3).unwrap().disable();
let zeroed = wait_until(200, || {
try_find_frame(&mut peer, |frame| frame.id == 0x200 && slot_current(frame, 2) == 0)
})
.await;
assert!(zeroed, "esc3 slot should return to 0 after disable");
bus.shutdown().await;
}
/// 非阻塞地扫一眼通道里已有的帧。
fn try_find_frame(peer: &mut CanLink, pred: impl Fn(&CanFrame) -> bool) -> bool {
while let Ok(frame) = peer.incoming_rx.try_recv() {
if pred(&frame) {
return true;
}
}
false
}
// ---------------------------------------------------------------- 反馈 → 状态
#[tokio::test]
async fn feedback_updates_state_with_direction() {
let (bus_link, peer) = CanLink::pair(64);
let bus = EscBus::spawn(bus_link, vec![tuned(2, -1.0)], BusOptions::default()).unwrap();
// 两帧正向跨圈:8000 → 100,电调侧圈数 ≈ 1.0122。
let fb = EscFeedback { angle_raw: 8000, speed_rpm: 600, current_raw: -300, temperature_c: 47 };
peer.outgoing_tx.send(encode_feedback_frame(2, &fb)).await.unwrap();
let fb2 = EscFeedback { angle_raw: 100, ..fb };
peer.outgoing_tx.send(encode_feedback_frame(2, &fb2)).await.unwrap();
sleep(Duration::from_millis(50)).await;
let st = bus.motor(2).unwrap().state();
assert_eq!(st.rotor_angle_raw, 100);
assert!((st.rotor_speed_rpm + 600.0).abs() < 1e-3); // direction = -1
assert_eq!(st.effort_raw, 300);
assert_eq!(st.temperature_c, 47);
assert_eq!(st.feedback_count, 2);
let esc_rev = 1.0 + 100.0 / 8192.0;
assert!(
(st.rotor_revolutions + esc_rev).abs() < 1e-3,
"revolutions = {}",
st.rotor_revolutions
);
// 输出轴换算由 `OutputShaft` 负责(见 motor-core gearbox 单测)。
bus.shutdown().await;
}
// ---------------------------------------------------------------- 生命周期
#[tokio::test(start_paused = true)]
async fn shutdown_stops_tasks_and_closes_link() {
let (bus_link, mut peer) = CanLink::pair(128);
// 不接 plant,直接验证任务生命周期。
let bus = EscBus::spawn(bus_link, vec![tuned(1, 1.0)], BusOptions::default()).unwrap();
bus.motor(1).unwrap().set_velocity(1000.0);
sleep(Duration::from_millis(20)).await;
// 有命令帧在流动。
assert!(peer.incoming_rx.recv().await.is_some());
bus.shutdown().await;
// 排空残留缓冲帧后应立即得到 None(发送端已全部退出);
// 若任务仍在运行,帧会持续流入并触发超时。
loop {
match timeout(Duration::from_millis(100), peer.incoming_rx.recv()).await {
Ok(None) => break,
Ok(Some(_)) => {} // 排空残留缓冲帧
Err(elapsed) => panic!("bus tasks still running after shutdown (waited {elapsed:?})"),
}
}
}
#[test]
fn spawn_rejects_bad_configs() {
let link = || CanLink::pair(8).0;
assert_eq!(
EscBus::spawn(link(), vec![], BusOptions::default()).err(),
Some(EscBusError::NoMotors)
);
assert_eq!(
EscBus::spawn(link(), vec![tuned(2, 1.0), tuned(2, -1.0)], BusOptions::default()).err(),
Some(EscBusError::DuplicateEscId(2))
);
let mut bad = tuned(1, 1.0);
bad.direction = 0.0;
let err = EscBus::spawn(link(), vec![bad], BusOptions::default()).err();
assert!(
matches!(err, Some(EscBusError::InvalidConfig { .. })),
"expected InvalidConfig, got {err:?}"
);
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "mecanum"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Mecanum wheel chassis forward/inverse kinematics"
[lints]
workspace = true
[dependencies]
[dev-dependencies]
proptest = { workspace = true }
approx = { workspace = true }
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 698ba63bf948a3702145ece17ed0f014133de0d0b3797fd8bdda514c5d2480a7 # shrinks to vx = 0.0, vy = 0.0, omega = -2.3901656
+284
View File
@@ -0,0 +1,284 @@
//! 麦克纳姆轮底盘正 / 逆运动学。
//!
//! # 约定
//!
//! - 机器人坐标系(ISO):x 前、y 左、ω 绕 z 逆时针为正;
//! - 轮序固定为 `[前左, 前右, 后左, 后右]`(常量 [`FL`] / [`FR`] / [`RL`] /
//! [`RR`]);
//! - 辊子 45°、俯视呈 X 型布置(RoboMaster 底盘标准布局);
//! - 角速度单位 rad/s,线速度 m/s,换算见 [`wheel_rpm_from_angular`]。
//!
//! 四轮线速度(`a = half_length + half_width`):
//!
//! ```text
//! v_FL = vx - vy - a·ω
//! v_FR = vx + vy + a·ω
//! v_RL = vx + vy - a·ω
//! v_RR = vx - vy + a·ω
//! ```
//!
//! 正解为逆解的左伪逆(JᵀJ 对角,可解析求平均)。
use std::f32::consts::TAU;
/// 前左轮下标。
pub const FL: usize = 0;
/// 前右轮下标。
pub const FR: usize = 1;
/// 后左轮下标。
pub const RL: usize = 2;
/// 后右轮下标。
pub const RR: usize = 3;
/// 底盘平面速度:x 前进(m/s)、y 左移(m/s)、ω 逆时针(rad/s)。
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Twist {
/// 前进速度(m/s,前正)。
pub vx: f32,
/// 横移速度(m/s,左正)。
pub vy: f32,
/// 角速度(rad/s,逆时针正)。
pub omega: f32,
}
impl Twist {
/// 全零速度。
pub const ZERO: Self = Self { vx: 0.0, vy: 0.0, omega: 0.0 };
/// 幅值限幅:任一分量超界时按比例收缩整体,保持运动方向不变。
///
/// `max_forward` / `max_strafe` 单位 m/s,`max_spin` 单位 rad/s。
#[must_use]
pub fn clamped(self, max_forward: f32, max_strafe: f32, max_spin: f32) -> Self {
let scale = [
(self.vx.abs() / max_forward).max(f32::EPSILON),
(self.vy.abs() / max_strafe).max(f32::EPSILON),
(self.omega.abs() / max_spin).max(f32::EPSILON),
]
.into_iter()
.fold(1.0f32, f32::max);
Self { vx: self.vx / scale, vy: self.vy / scale, omega: self.omega / scale }
}
}
/// 麦轮底盘几何参数。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Mecanum {
/// 前后轮轴线到底盘中心的纵向半距 lx(米,正)。
pub half_length: f32,
/// 左右轮到底盘中心的横向半距 ly(米,正)。
pub half_width: f32,
/// 车轮半径(米,正)。
pub wheel_radius: f32,
}
impl Mecanum {
/// 按几何参数构造。
///
/// # Panics
///
/// debug 构建下参数非正时 panic(配置层应先行校验)。
#[must_use]
pub fn new(half_length: f32, half_width: f32, wheel_radius: f32) -> Self {
debug_assert!(
half_length > 0.0 && half_width > 0.0 && wheel_radius > 0.0,
"mecanum geometry must be positive"
);
Self { half_length, half_width, wheel_radius }
}
/// 逆解:底盘速度 → 四轮角速度(rad/s,顺序 FL/FR/RL/RR)。
#[must_use]
pub fn wheel_speeds(&self, twist: &Twist) -> [f32; 4] {
let a = self.half_length + self.half_width;
[
twist.vx - twist.vy - a * twist.omega,
twist.vx + twist.vy + a * twist.omega,
twist.vx + twist.vy - a * twist.omega,
twist.vx - twist.vy + a * twist.omega,
]
.map(|v| v / self.wheel_radius)
}
/// 正解:四轮角速度(rad/s)→ 底盘速度(最小二乘意义)。
#[must_use]
pub fn twist(&self, wheels: &[f32; 4]) -> Twist {
let w = wheels.map(|x| x * self.wheel_radius);
let a = self.half_length + self.half_width;
Twist {
vx: (w[FL] + w[FR] + w[RL] + w[RR]) / 4.0,
vy: (-w[FL] + w[FR] + w[RL] - w[RR]) / 4.0,
omega: (-w[FL] + w[FR] - w[RL] + w[RR]) / (4.0 * a),
}
}
/// 逆解并直接输出四轮转速(rpm)。
#[must_use]
pub fn wheel_rpms(&self, twist: &Twist) -> [f32; 4] {
self.wheel_speeds(twist).map(wheel_rpm_from_angular)
}
/// 由四轮转速(rpm)正解底盘速度。
#[must_use]
pub fn twist_from_wheel_rpms(&self, wheel_rpms: &[f32; 4]) -> Twist {
self.twist(&wheel_rpms.map(wheel_angular_from_rpm))
}
}
/// 轮角速度(rad/s)→ 转速(rpm)。
#[must_use]
pub const fn wheel_rpm_from_angular(rad_per_s: f32) -> f32 {
rad_per_s * 60.0 / TAU
}
/// 轮转速(rpm)→ 角速度(rad/s)。
#[must_use]
pub const fn wheel_angular_from_rpm(rpm: f32) -> f32 {
rpm * TAU / 60.0
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
/// 0.2m 半轴距 / 0.25m 半轮距 / 76.2mm 轮半径的典型 RM 底盘。
fn chassis() -> Mecanum {
Mecanum::new(0.2, 0.25, 0.0762)
}
fn assert_twist_eq(a: &Twist, b: &Twist) {
assert_relative_eq!(a.vx, b.vx, epsilon = 1e-5);
assert_relative_eq!(a.vy, b.vy, epsilon = 1e-5);
assert_relative_eq!(a.omega, b.omega, epsilon = 1e-5);
}
#[test]
fn pure_forward_drives_all_wheels_equally() {
let m = chassis();
let w = m.wheel_speeds(&Twist { vx: 1.0, ..Twist::ZERO });
for x in w {
assert_relative_eq!(x, 1.0 / 0.0762, epsilon = 1e-5);
}
}
#[test]
fn pure_strafe_left_opposes_diagonal_wheels() {
let m = chassis();
let w = m.wheel_speeds(&Twist { vy: 2.0, ..Twist::ZERO });
let v = 2.0 / 0.0762;
assert_relative_eq!(w[FL], -v, epsilon = 1e-5);
assert_relative_eq!(w[FR], v, epsilon = 1e-5);
assert_relative_eq!(w[RL], v, epsilon = 1e-5);
assert_relative_eq!(w[RR], -v, epsilon = 1e-5);
}
#[test]
fn pure_rotation_splits_left_right() {
let m = chassis();
let w = m.wheel_speeds(&Twist { omega: 1.0, ..Twist::ZERO });
let v = (0.2 + 0.25) / 0.0762;
// 逆时针:左侧后退、右侧前进。
assert_relative_eq!(w[FL], -v, epsilon = 1e-5);
assert_relative_eq!(w[FR], v, epsilon = 1e-5);
assert_relative_eq!(w[RL], -v, epsilon = 1e-5);
assert_relative_eq!(w[RR], v, epsilon = 1e-5);
}
#[test]
fn diagonal_motion_idles_two_wheels() {
// 前左 45° 对角线:vx = vy ⇒ FL、RR 停转。
let m = chassis();
let w = m.wheel_speeds(&Twist { vx: 1.0, vy: 1.0, omega: 0.0 });
assert_relative_eq!(w[FL], 0.0, epsilon = 1e-6);
assert_relative_eq!(w[RR], 0.0, epsilon = 1e-6);
assert_relative_eq!(w[FR], 2.0 / 0.0762, epsilon = 1e-5);
assert_relative_eq!(w[RL], 2.0 / 0.0762, epsilon = 1e-5);
}
#[test]
fn zero_twist_zero_wheels() {
let wheels = chassis().wheel_speeds(&Twist::ZERO);
assert!(wheels.iter().all(|w| f64::from(*w).abs() < 1e-12), "{wheels:?}");
}
#[test]
fn inverse_then_forward_recovers_twist() {
let m = chassis();
let t = Twist { vx: 1.5, vy: -0.8, omega: 2.2 };
assert_twist_eq(&m.twist(&m.wheel_speeds(&t)), &t);
}
#[test]
fn rpm_helpers_roundtrip() {
assert_relative_eq!(
wheel_rpm_from_angular(wheel_angular_from_rpm(482.0)),
482.0,
epsilon = 1e-4
);
assert_relative_eq!(wheel_angular_from_rpm(60.0 / TAU), 1.0, epsilon = 1e-6);
}
#[test]
fn rpm_path_matches_angular_path() {
let m = chassis();
let t = Twist { vx: 0.3, vy: 1.1, omega: -0.7 };
assert_twist_eq(&m.twist_from_wheel_rpms(&m.wheel_rpms(&t)), &t);
}
#[test]
fn clamp_preserves_direction_and_limits() {
let t = Twist { vx: 4.0, vy: 1.0, omega: 0.5 }.clamped(2.0, 2.0, 3.0);
assert_relative_eq!(t.vx, 2.0, epsilon = 1e-6);
assert_relative_eq!(t.vy, 0.5, epsilon = 1e-6);
assert_relative_eq!(t.omega, 0.25, epsilon = 1e-6);
// 限内不缩放。
let keep = Twist { vx: 1.0, vy: -1.0, omega: 2.0 }.clamped(2.0, 2.0, 3.0);
assert_eq!(keep, Twist { vx: 1.0, vy: -1.0, omega: 2.0 });
}
}
#[cfg(test)]
mod proptests {
use super::*;
use approx::assert_relative_eq;
use proptest::prelude::*;
proptest! {
/// 正逆解互逆:forward(inverse(t)) == t(对任意合理几何与速度)。
#[test]
fn kinematics_roundtrip(
lx in 0.05f32..0.5,
ly in 0.05f32..0.5,
r in 0.03f32..0.2,
vx in -3.0f32..3.0,
vy in -3.0f32..3.0,
omega in -6.0f32..6.0,
) {
let m = Mecanum::new(lx, ly, r);
let t = Twist { vx, vy, omega };
let back = m.twist(&m.wheel_speeds(&t));
assert_relative_eq!(back.vx, t.vx, epsilon = 1e-4);
assert_relative_eq!(back.vy, t.vy, epsilon = 1e-4);
assert_relative_eq!(back.omega, t.omega, epsilon = 1e-4);
}
/// 左右镜像对称:vy、ω 取反(镜像底盘)等价于 FL↔FR、RL↔RR 对调。
#[test]
fn strafe_symmetry(
vx in -2.0f32..2.0,
vy in -2.0f32..2.0,
omega in -4.0f32..4.0,
) {
let m = Mecanum::new(0.2, 0.25, 0.0762);
let t = Twist { vx, vy, omega };
let mirrored = Twist { vx, vy: -vy, omega: -omega };
let w = m.wheel_speeds(&t);
let w2 = m.wheel_speeds(&mirrored);
assert_relative_eq!(w[FL], w2[FR], epsilon = 1e-4);
assert_relative_eq!(w[FR], w2[FL], epsilon = 1e-4);
assert_relative_eq!(w[RL], w2[RR], epsilon = 1e-4);
assert_relative_eq!(w[RR], w2[RL], epsilon = 1e-4);
}
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "motor-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Protocol-agnostic motor abstraction: Motor trait, feedback state, and PID controllers"
[lints]
workspace = true
[dependencies]
+41
View File
@@ -0,0 +1,41 @@
//! 驱动层命令寄存器(最后写入胜出)。
/// 发给驱动层的电机命令。
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Command {
/// 停用:目标清零,下发 0 输出。
Disabled,
/// 直接下发开环 raw(协议量程,物理方向),绕过闭环。
/// ESC = 电流 raw;GM6020 = 电压 raw。
EffortRaw(i16),
/// 速度环跟踪目标转子转速(rpm,物理方向)。
Velocity(f32),
/// 位置环跟踪目标转子多圈位置(圈,物理方向)。
Position(f32),
}
/// 命令模式标签(不含设定值)——用于切换时复位控制器。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandMode {
/// 停用。
Disabled,
/// 开环 raw。
EffortRaw,
/// 速度环。
Velocity,
/// 位置环。
Position,
}
impl Command {
/// 模式标签。
#[must_use]
pub const fn mode(self) -> CommandMode {
match self {
Self::Disabled => CommandMode::Disabled,
Self::EffortRaw(_) => CommandMode::EffortRaw,
Self::Velocity(_) => CommandMode::Velocity,
Self::Position(_) => CommandMode::Position,
}
}
}
+173
View File
@@ -0,0 +1,173 @@
//! 输出轴单位适配器:减速比换算。
//!
//! 把「输出轴单位」的驱动接口适配到「转子单位」的底层电机。
//! `gear_ratio = 转子 : 输出轴`(GM3508 ≈ 19.203)。
use crate::{Disable, EffortDrive, MotorFeedback, MotorState, PositionDrive, VelocityDrive};
/// 输出轴包装:对外接口使用输出轴单位,对内换算到转子单位。
pub struct OutputShaft<T> {
inner: T,
gear_ratio: f32,
}
impl<T> OutputShaft<T> {
/// 包装底层电机。`gear_ratio` = 转子 : 输出轴(> 0)。
///
/// # Panics
///
/// `gear_ratio` 非有限或 ≤ 0 时 panic。
#[must_use]
pub fn new(inner: T, gear_ratio: f32) -> Self {
assert!(
gear_ratio.is_finite() && gear_ratio > 0.0,
"gear_ratio must be > 0, got {gear_ratio}"
);
Self { inner, gear_ratio }
}
/// 减速比。
#[must_use]
pub fn gear_ratio(&self) -> f32 {
self.gear_ratio
}
/// 底层电机引用。
#[must_use]
pub fn inner(&self) -> &T {
&self.inner
}
/// 转子量 → 输出轴量。
#[must_use]
pub fn to_output(&self, rotor: f32) -> f32 {
rotor / self.gear_ratio
}
/// 输出轴量 → 转子量。
#[must_use]
pub fn to_rotor(&self, output: f32) -> f32 {
output * self.gear_ratio
}
/// 由反馈快照推算输出轴转速(rpm)。
#[must_use]
pub fn output_speed_rpm(&self, state: &MotorState) -> f32 {
state.rotor_speed_rpm / self.gear_ratio
}
}
impl<T: MotorFeedback> MotorFeedback for OutputShaft<T> {
fn id(&self) -> u8 {
self.inner.id()
}
fn state(&self) -> MotorState {
// 只透传转子量;不伪造输出轴字段。
self.inner.state()
}
}
impl<T: VelocityDrive> VelocityDrive for OutputShaft<T> {
/// `output_rpm` 为**输出轴**转速。
fn set_velocity(&self, output_rpm: f32) {
self.inner.set_velocity(self.to_rotor(output_rpm));
}
}
impl<T: EffortDrive> EffortDrive for OutputShaft<T> {
fn set_effort_raw(&self, raw: i16) {
self.inner.set_effort_raw(raw);
}
}
impl<T: PositionDrive> PositionDrive for OutputShaft<T> {
/// `output_rev` 为**输出轴**圈数。
fn set_position(&self, output_rev: f32) {
self.inner.set_position(self.to_rotor(output_rev));
}
}
impl<T: Disable> Disable for OutputShaft<T> {
fn disable(&self) {
self.inner.disable();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Command;
struct Stub {
cmd: std::sync::Mutex<Command>,
}
impl Stub {
fn new() -> Self {
Self { cmd: std::sync::Mutex::new(Command::Disabled) }
}
}
impl MotorFeedback for Stub {
fn id(&self) -> u8 {
1
}
fn state(&self) -> MotorState {
MotorState::default()
}
}
impl VelocityDrive for Stub {
fn set_velocity(&self, rotor_rpm: f32) {
*self.cmd.lock().unwrap() = Command::Velocity(rotor_rpm);
}
}
impl EffortDrive for Stub {
fn set_effort_raw(&self, raw: i16) {
*self.cmd.lock().unwrap() = Command::EffortRaw(raw);
}
}
impl PositionDrive for Stub {
fn set_position(&self, rotor_rev: f32) {
*self.cmd.lock().unwrap() = Command::Position(rotor_rev);
}
}
impl Disable for Stub {
fn disable(&self) {
*self.cmd.lock().unwrap() = Command::Disabled;
}
}
#[test]
fn velocity_scales_by_gear() {
let shaft = OutputShaft::new(Stub::new(), 19.203);
shaft.set_velocity(100.0);
match *shaft.inner().cmd.lock().unwrap() {
Command::Velocity(rpm) => assert!((rpm - 1920.3).abs() < 0.1, "{rpm}"),
ref c => panic!("unexpected {c:?}"),
}
}
#[test]
fn position_scales_by_gear() {
let shaft = OutputShaft::new(Stub::new(), 36.0);
shaft.set_position(2.0);
match *shaft.inner().cmd.lock().unwrap() {
Command::Position(rev) => assert!((rev - 72.0).abs() < 1e-3, "{rev}"),
ref c => panic!("unexpected {c:?}"),
}
}
#[test]
fn effort_passes_through() {
let shaft = OutputShaft::new(Stub::new(), 10.0);
shaft.set_effort_raw(-1234);
assert_eq!(*shaft.inner().cmd.lock().unwrap(), Command::EffortRaw(-1234));
}
#[test]
#[should_panic = "gear_ratio"]
fn rejects_nonpositive_gear() {
let _ = OutputShaft::new(Stub::new(), 0.0);
}
}
+21
View File
@@ -0,0 +1,21 @@
//! 航向观测源(IMU / 磁力计 / 视觉等)——云台绝对角融合 seam。
//!
//! 电机抽象只保证 [`crate::MotorState::rotor_revolutions`] 连续(上电相对)。
//! 场地绝对角由上层(未来 `gimbal` crate)融合 IMU 与电机多圈得到。
use std::future::Future;
/// 绝对航向观测源。
pub trait HeadingSource: Send {
/// 机体航向角(rad,连续;与 chassis ISO 约定一致:x 前、逆时针为正)。
fn heading_rad(&self) -> impl Future<Output = f32> + Send;
}
/// 云台目标角的参考系。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeadingFrame {
/// 电机转子多圈(上电相对)。
RotorMultiTurn,
/// 场地绝对角(经 [`HeadingSource`] 对齐到转子基准后再下发)。
AbsoluteField,
}
+93
View File
@@ -0,0 +1,93 @@
//! 协议无关的电机抽象层。
//!
//! 本 crate 不做任何 I/O:它定义电机能力 trait([`MotorFeedback`] /
//! [`EffortDrive`] / [`VelocityDrive`] / [`PositionDrive`] / [`Disable`])、
//! 公共数据类型([`MotorState`]、[`Command`])以及纯控制算法([`Pid`]、
//! [`VelocityController`]、[`PositionController`])。具体总线协议在其上实现。
//!
//! # 单一职责
//!
//! - [`traits`]:能力面(小而正交);
//! - [`state`] / [`command`]:反馈快照与命令寄存器;
//! - [`pid`] / [`velocity`] / [`position`]:纯控制律;
//! - [`gearbox`]:减速比单位适配(输出轴 ↔ 转子);
//! - [`heading`]:航向观测 seam(云台绝对角融合用);
//! - [`testing`]:测试替身。
//!
//! # 单位与方向约定
//!
//! - 所有呈现给上层的量都是**安装方向修正后**的物理量:`direction = -1`
//! 的电机由驱动层在读写两个方向上统一翻转符号。
//! - 转速一律为电机**转子**转速(rpm);输出轴换算见 [`OutputShaft`]。
//! - 开环控制量以协议原始值(raw)为主(C620 电流:±16384 ↔ ±20 A;
//! GM6020 电压:±30000 ≈ 满电压)。
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub mod command;
pub mod gearbox;
pub mod heading;
pub mod pid;
pub mod position;
pub mod state;
pub mod testing;
pub mod traits;
pub mod velocity;
pub use command::{Command, CommandMode};
pub use gearbox::OutputShaft;
pub use heading::{HeadingFrame, HeadingSource};
pub use pid::Pid;
pub use position::{PositionController, PositionLoop};
pub use state::MotorState;
pub use testing::{MockHeading, MockMotor};
pub use traits::{Disable, EffortDrive, Motor, MotorFeedback, PositionDrive, VelocityDrive};
pub use velocity::VelocityController;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_state_has_no_feedback() {
let state = MotorState::default();
assert!(!state.has_feedback());
assert_eq!(state.feedback_count, 0);
}
#[test]
fn feedback_gate_flips_on_first_frame() {
let state = MotorState { feedback_count: 1, ..MotorState::default() };
assert!(state.has_feedback());
}
#[test]
fn command_variants_roundtrip_by_value() {
let cmds = [
Command::Disabled,
Command::EffortRaw(-16384),
Command::Velocity(4820.5),
Command::Position(2.5),
];
assert_eq!(cmds[0], Command::Disabled);
assert_eq!(cmds[1], Command::EffortRaw(-16384));
assert_eq!(cmds[2], Command::Velocity(4820.5));
assert_eq!(cmds[3], Command::Position(2.5));
}
#[test]
fn command_mode_labels() {
assert_eq!(Command::Disabled.mode(), CommandMode::Disabled);
assert_eq!(Command::EffortRaw(0).mode(), CommandMode::EffortRaw);
assert_eq!(Command::Velocity(0.0).mode(), CommandMode::Velocity);
assert_eq!(Command::Position(0.0).mode(), CommandMode::Position);
}
#[test]
fn mock_motor_is_full_motor() {
fn takes_motor(m: &impl Motor) {
m.set_velocity(1.0);
m.disable();
}
takes_motor(&MockMotor::new(1));
}
}
+264
View File
@@ -0,0 +1,264 @@
//! 通用 PID 控制器。
/// 并联式 PID 控制器(f32,显式时间步)。
///
/// 特性:
/// - 输出限幅 `[out_min, out_max]`;
/// - 积分抗饱和(clamping:输出已饱和且误差继续同向时暂停积分);
/// - 微分作用于**测量值**(避免设定值阶跃的微分冲击),可选一阶低通滤波。
///
/// 时间步 `dt` 由调用方显式传入(秒)。`dt <= 0` 或非有限值时本次调用
/// 直接返回上次输出且不更新内部状态(防御跳帧 / 首帧场景)。
#[derive(Debug, Clone)]
pub struct Pid {
kp: f32,
ki: f32,
kd: f32,
out_min: f32,
out_max: f32,
/// 微分低通时间常数(秒);`0` 关闭滤波。
derivative_tau: f32,
integral: f32,
prev_measurement: Option<f32>,
derivative_filtered: f32,
last_output: f32,
}
impl Pid {
/// 创建 PID。`out_min` 必须小于 `out_max`。
///
/// # Panics
///
/// 参数非有限或 `out_min >= out_max` 时 panic。
#[must_use]
pub fn new(kp: f32, ki: f32, kd: f32, out_min: f32, out_max: f32) -> Self {
assert!(
out_min < out_max && [kp, ki, kd, out_min, out_max].iter().all(|v| v.is_finite()),
"invalid PID parameters"
);
Self {
kp,
ki,
kd,
out_min,
out_max,
derivative_tau: 0.0,
integral: 0.0,
prev_measurement: None,
derivative_filtered: 0.0,
last_output: 0.0,
}
}
/// 设置微分低通时间常数(秒),`0` 关闭滤波。链式调用。
#[must_use]
pub fn with_derivative_tau(mut self, tau: f32) -> Self {
self.derivative_tau = tau.max(0.0);
self
}
/// 单步更新,返回限幅后的输出。
pub fn update(&mut self, setpoint: f32, measurement: f32, dt: f32) -> f32 {
// NaN 与 +inf 的比较结果均为 false,故 `dt > 0` 已隐含有限性。
let input_valid = dt > 0.0 && setpoint.is_finite() && measurement.is_finite();
if !input_valid {
return self.last_output;
}
let error = setpoint - measurement;
// 微分作用于测量值(反向),一阶低通滤波。
let d_raw = match self.prev_measurement {
Some(prev) => -(measurement - prev) / dt,
None => 0.0,
};
if self.derivative_tau > 0.0 {
let alpha = dt / (self.derivative_tau + dt);
self.derivative_filtered += alpha * (d_raw - self.derivative_filtered);
} else {
self.derivative_filtered = d_raw;
}
let d_term = self.kd * self.derivative_filtered;
// 积分抗饱和(clamping):仅在不会把输出进一步推入饱和时积分。
let i_candidate = self.integral + self.ki * error * dt;
let unsat = self.kp * error + i_candidate + d_term;
let saturating_up = unsat > self.out_max && error > 0.0;
let saturating_down = unsat < self.out_min && error < 0.0;
if !saturating_up && !saturating_down {
self.integral = i_candidate;
}
let output = (self.kp * error + self.integral + d_term).clamp(self.out_min, self.out_max);
self.prev_measurement = Some(measurement);
self.last_output = output;
output
}
/// 清空积分、微分记忆与上次输出。
pub fn reset(&mut self) {
self.integral = 0.0;
self.prev_measurement = None;
self.derivative_filtered = 0.0;
self.last_output = 0.0;
}
/// 当前积分累加项。
#[must_use]
pub fn integral(&self) -> f32 {
self.integral
}
/// 比例增益。
#[must_use]
pub fn kp(&self) -> f32 {
self.kp
}
/// 积分增益。
#[must_use]
pub fn ki(&self) -> f32 {
self.ki
}
/// 微分增益。
#[must_use]
pub fn kd(&self) -> f32 {
self.kd
}
/// 输出下限。
#[must_use]
pub fn output_min(&self) -> f32 {
self.out_min
}
/// 输出上限。
#[must_use]
pub fn output_max(&self) -> f32 {
self.out_max
}
}
#[cfg(test)]
mod tests {
use super::*;
const DT: f32 = 0.001;
#[test]
fn pure_proportional() {
let mut pid = Pid::new(2.0, 0.0, 0.0, -100.0, 100.0);
let out = pid.update(10.0, 4.0, DT);
assert!((out - 12.0).abs() < 1e-4, "out = {out}");
}
#[test]
fn output_clamps_both_ends() {
let mut pid = Pid::new(100.0, 0.0, 0.0, -10.0, 10.0);
assert!((pid.update(1.0, 0.0, DT) - 10.0).abs() < 1e-5);
assert!((pid.update(-1.0, 0.0, DT) - (-10.0)).abs() < 1e-5);
}
#[test]
fn integral_accumulates() {
let mut pid = Pid::new(0.0, 10.0, 0.0, -1e6, 1e6);
pid.update(1.0, 0.0, DT); // integral += 10 * 1 * 0.001
pid.update(1.0, 0.0, DT);
let out = pid.update(1.0, 0.0, DT);
let expected = 10.0 * 1.0 * DT * 3.0;
assert!((out - expected).abs() < 1e-4, "out = {out}, expected {expected}");
}
#[test]
fn anti_windup_bounds_integral_when_saturated() {
// 大误差持续饱和 200 步:kp = 0 时输出即积分项。clamping 抗饱和要求
// 积分不超过 out_max,且进入保持区后输出不再增长。
let ki = 5.0;
let mut pid = Pid::new(0.0, ki, 0.0, -16384.0, 16384.0);
for _ in 0..200 {
pid.update(1e6, 0.0, DT);
}
assert!(pid.integral() <= 16384.0, "integral = {}", pid.integral());
let frozen = pid.update(1e6, 0.0, DT);
let still = pid.update(1e6, 0.0, DT);
assert!(
(frozen - still).abs() < 1e-3,
"saturated output must stop growing: {frozen} vs {still}"
);
}
#[test]
fn anti_windup_recovers_quickly_on_reverse() {
// 饱和后目标反向,输出应在一个反向误差步内脱离正饱和。
let mut pid = Pid::new(1.0, 10.0, 0.0, -100.0, 100.0);
for _ in 0..500 {
pid.update(1000.0, 0.0, DT);
}
assert!((pid.update(1000.0, 0.0, DT) - 100.0).abs() < 1e-3);
// 一步反向大误差(kp=1, e=-1000)足以击穿下限,说明无深度饱和。
let out = pid.update(-1000.0, 0.0, DT);
assert!(out <= 0.0, "expected non-positive output, got {out}");
}
#[test]
fn derivative_acts_on_measurement() {
// 设定值恒 0,测量值阶跃:输出应为负向微分冲击(-kd*Δ/dt)。
let mut pid = Pid::new(0.0, 0.0, 0.01, -1e6, 1e6);
pid.update(0.0, 0.0, DT); // 建立初值,首帧微分为 0
let out = pid.update(0.0, 0.5, DT);
let expected = -0.01 * (0.5 / DT);
assert!((out - expected).abs() < 1.0, "out = {out}, expected {expected}");
}
#[test]
fn derivative_filter_softens_spike() {
let step = |tau: f32| {
let mut pid = Pid::new(0.0, 0.0, 10.0, -1e9, 1e9).with_derivative_tau(tau);
pid.update(0.0, 0.0, DT);
pid.update(0.0, 1.0, DT).abs()
};
let unfiltered = step(0.0);
let filtered = step(0.05);
assert!(
filtered < unfiltered * 0.05,
"filtered {filtered} should be far below unfiltered {unfiltered}"
);
}
#[test]
fn nonpositive_dt_returns_last_output_and_keeps_state() {
let mut pid = Pid::new(1.0, 1.0, 0.0, -100.0, 100.0);
let first = pid.update(10.0, 0.0, DT);
let integral_before = pid.integral().to_bits();
// 逐位相等:无效输入必须原样返回上次输出。
assert_eq!(pid.update(99.0, 0.0, 0.0).to_bits(), first.to_bits());
assert_eq!(pid.update(99.0, 0.0, f32::NAN).to_bits(), first.to_bits());
assert_eq!(pid.integral().to_bits(), integral_before);
}
#[test]
fn nan_input_returns_last_output() {
let mut pid = Pid::new(1.0, 0.0, 0.0, -100.0, 100.0);
let first = pid.update(1.0, 0.0, DT);
assert_eq!(pid.update(f32::NAN, 0.0, DT).to_bits(), first.to_bits());
assert_eq!(pid.update(1.0, f32::INFINITY, DT).to_bits(), first.to_bits());
}
#[test]
fn reset_clears_history() {
let mut pid = Pid::new(1.0, 10.0, 1.0, -1e6, 1e6);
pid.update(5.0, 1.0, DT);
pid.update(5.0, 1.0, DT);
pid.reset();
assert_eq!(pid.integral().to_bits(), 0.0f32.to_bits());
// 重置后首帧微分为 0,输出 = P 项 + 本步积分(ki*e*dt = 0.04)。
let out = pid.update(5.0, 1.0, DT);
assert!((out - 4.04).abs() < 1e-4, "out = {out}");
}
#[test]
#[should_panic = "invalid PID parameters"]
fn new_rejects_inverted_limits() {
let _pid = Pid::new(1.0, 0.0, 0.0, 1.0, -1.0);
}
}
+103
View File
@@ -0,0 +1,103 @@
//! 位置环控制器:单环(直出)或级联(位置→限速→速度环)。
use crate::pid::Pid;
use crate::velocity::VelocityController;
/// 位置环拓扑。
#[derive(Debug, Clone)]
pub enum PositionLoop {
/// 单环:位置误差 → effort_raw(适合电压硬闭环 / 刚性云台)。
Direct(Pid),
/// 级联:位置 → 目标转速 → 速度环 → effort_raw。
Cascade {
/// 外环位置 PID(输出限幅 = 最大 rpm)。
pos: Pid,
/// 内环速度控制器(输出限幅 = 最大 effort)。
vel: VelocityController,
},
}
/// 位置环封装。输入为目标 / 实测多圈位置(圈),输出为 effort_raw。
#[derive(Debug, Clone)]
pub struct PositionController {
inner: PositionLoop,
}
impl PositionController {
/// 单环位置控制器。
#[must_use]
pub fn direct(pid: Pid) -> Self {
Self { inner: PositionLoop::Direct(pid) }
}
/// 级联位置控制器(限速)。
#[must_use]
pub fn cascade(pos: Pid, vel: VelocityController) -> Self {
Self { inner: PositionLoop::Cascade { pos, vel } }
}
/// 单步更新,返回 effort_raw(已含限幅)。
///
/// `measured_rpm` 仅级联内环使用;单环忽略该参数。
pub fn update(
&mut self,
target_rev: f32,
measured_rev: f32,
measured_rpm: f32,
dt: f32,
) -> f32 {
match &mut self.inner {
PositionLoop::Direct(pid) => pid.update(target_rev, measured_rev, dt),
PositionLoop::Cascade { pos, vel } => {
let vel_target = pos.update(target_rev, measured_rev, dt);
vel.update(vel_target, measured_rpm, dt)
}
}
}
/// 清空内部状态(切换模式 / 重新使能时调用)。
pub fn reset(&mut self) {
match &mut self.inner {
PositionLoop::Direct(pid) => pid.reset(),
PositionLoop::Cascade { pos, vel } => {
pos.reset();
vel.reset();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const DT: f32 = 0.001;
#[test]
fn direct_outputs_pid() {
let mut pc = PositionController::direct(Pid::new(2.0, 0.0, 0.0, -100.0, 100.0));
let out = pc.update(1.0, 0.5, 0.0, DT);
assert!((out - 1.0).abs() < 1e-4, "out = {out}");
}
#[test]
fn cascade_limits_velocity_then_effort() {
// 外环 kp=10 → 误差 1 圈输出 10 rpm(限幅 5 rpm);内环纯前馈 2 raw/rpm。
let pos = Pid::new(10.0, 0.0, 0.0, -5.0, 5.0);
let vel = VelocityController::new(Pid::new(0.0, 0.0, 0.0, -1000.0, 1000.0), 2.0);
let mut pc = PositionController::cascade(pos, vel);
let out = pc.update(1.0, 0.0, 0.0, DT);
// 外环饱和到 5 rpm,内环 ff=2 → 10 raw。
assert!((out - 10.0).abs() < 1e-3, "out = {out}");
}
#[test]
fn reset_clears_state() {
let mut pc = PositionController::direct(Pid::new(0.0, 10.0, 0.0, -1e6, 1e6));
pc.update(1.0, 0.0, 0.0, DT);
pc.reset();
let out = pc.update(1.0, 0.0, 0.0, DT);
// 重置后只剩本步积分:10 * 1 * 0.001。
assert!((out - 0.01).abs() < 1e-5, "out = {out}");
}
}
+29
View File
@@ -0,0 +1,29 @@
//! 电机反馈快照(协议无关,方向修正后的物理量)。
//!
//! 只含**转子轴**物理量——减速比 / 输出轴换算由 [`crate::gearbox::OutputShaft`]
//! 或上层完成,不在此泄漏底盘语义。
/// 电机反馈快照。
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct MotorState {
/// 最近一帧反馈的转子机械原始角(编码器计数,量程由具体协议决定)。
pub rotor_angle_raw: u16,
/// 连续累计转子角(多圈回绕处理,单位:圈;随时间单调于转动方向)。
pub rotor_revolutions: f32,
/// 转子转速(rpm,物理方向)。
pub rotor_speed_rpm: f32,
/// 开环控制量反馈 raw(ESC=转矩电流,GM6020=电流相关量;物理方向)。
pub effort_raw: i16,
/// 电机温度(℃)。
pub temperature_c: i8,
/// 已收到的反馈帧总数;`0` 表示尚未收到任何反馈(上电门控用)。
pub feedback_count: u64,
}
impl MotorState {
/// 是否已收到过反馈(上电后首帧门控)。
#[must_use]
pub fn has_feedback(&self) -> bool {
self.feedback_count > 0
}
}
+116
View File
@@ -0,0 +1,116 @@
//! 测试替身:内存电机与航向源。
use std::future::Future;
use std::sync::{Arc, Mutex, MutexGuard};
use crate::{
Command, Disable, EffortDrive, HeadingSource, MotorFeedback, MotorState, PositionDrive,
VelocityDrive,
};
/// 锁辅助:中毒锁直接恢复内部数据。
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// 内存电机:记录命令、可注入状态。供上层单测,无需总线。
///
/// 句柄可克隆(内部 `Arc` 共享),便于一边注入/断言一边把所有权交给控制器。
#[derive(Clone)]
pub struct MockMotor {
id: u8,
published: Arc<Mutex<(MotorState, Command)>>,
}
impl MockMotor {
/// 创建指定 ID 的空电机。
#[must_use]
pub fn new(id: u8) -> Self {
Self { id, published: Arc::new(Mutex::new((MotorState::default(), Command::Disabled))) }
}
/// 注入反馈快照。
pub fn inject_state(&self, state: MotorState) {
lock(&self.published).0 = state;
}
/// 最近一次命令。
#[must_use]
pub fn last_command(&self) -> Command {
lock(&self.published).1
}
}
impl MotorFeedback for MockMotor {
fn id(&self) -> u8 {
self.id
}
fn state(&self) -> MotorState {
lock(&self.published).0
}
}
impl VelocityDrive for MockMotor {
fn set_velocity(&self, rotor_rpm: f32) {
lock(&self.published).1 = Command::Velocity(rotor_rpm);
}
}
impl EffortDrive for MockMotor {
fn set_effort_raw(&self, raw: i16) {
lock(&self.published).1 = Command::EffortRaw(raw);
}
}
impl PositionDrive for MockMotor {
fn set_position(&self, rotor_rev: f32) {
lock(&self.published).1 = Command::Position(rotor_rev);
}
}
impl Disable for MockMotor {
fn disable(&self) {
lock(&self.published).1 = Command::Disabled;
}
}
/// 恒定航向测试源。
pub struct MockHeading {
rad: f32,
}
impl MockHeading {
/// 创建固定航向源。
#[must_use]
pub const fn new(rad: f32) -> Self {
Self { rad }
}
}
impl HeadingSource for MockHeading {
fn heading_rad(&self) -> impl Future<Output = f32> + Send {
std::future::ready(self.rad)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mock_motor_records_commands() {
let m = MockMotor::new(3);
m.set_velocity(1000.0);
assert_eq!(m.last_command(), Command::Velocity(1000.0));
m.disable();
assert_eq!(m.last_command(), Command::Disabled);
}
#[test]
fn mock_motor_injects_state() {
let m = MockMotor::new(1);
assert!(!m.state().has_feedback());
m.inject_state(MotorState { feedback_count: 5, ..MotorState::default() });
assert!(m.state().has_feedback());
}
}
+47
View File
@@ -0,0 +1,47 @@
//! 能力面 trait:小而正交,按硬件事实拆分。
//!
//! 消费者只声明自己用到的能力(静态分发,无 `dyn`):
//! - 底盘轮 → [`VelocityDrive`] + [`MotorFeedback`]
//! - 云台 → [`PositionDrive`] + [`EffortDrive`](+ 上层 [`crate::HeadingSource`])
//! - 拨弹 → [`VelocityDrive`] + [`PositionDrive`]
//! - 台架 → [`VelocityDrive`] + [`MotorFeedback`] + [`Disable`]
use crate::MotorState;
/// 读反馈。所有可观察设备的最小面。
pub trait MotorFeedback: Send + Sync {
/// 控制器内的电机 ID(对 DJI 电调即电调 ID)。
fn id(&self) -> u8;
/// 最新反馈快照。
fn state(&self) -> MotorState;
}
/// 开环控制量(协议 raw:ESC=电流,GM6020=电压)。
pub trait EffortDrive: Send + Sync {
/// 直接下发开环 raw(协议量程,物理方向)。
fn set_effort_raw(&self, raw: i16);
}
/// 速度目标(转子 rpm,物理方向);由驱动层速度环跟踪。
pub trait VelocityDrive: Send + Sync {
/// 设定目标转子转速。
fn set_velocity(&self, rotor_rpm: f32);
}
/// 位置目标(转子多圈,圈;物理方向)。
pub trait PositionDrive: Send + Sync {
/// 设定目标转子多圈位置。
fn set_position(&self, rotor_rev: f32);
}
/// 停用(零输出)。
pub trait Disable: Send + Sync {
/// 停用电机。
fn disable(&self);
}
/// 便利别名(blanket impl,零成本)。实现方**不必**实现它。
pub trait Motor: MotorFeedback + VelocityDrive + EffortDrive + Disable {}
impl<T> Motor for T where T: MotorFeedback + VelocityDrive + EffortDrive + Disable {}
+87
View File
@@ -0,0 +1,87 @@
//! 速度环控制器:PID + 速度前馈。
use crate::pid::Pid;
/// 速度环封装:在 [`Pid`] 之上叠加比例前馈。
///
/// 输入为目标 / 实测转速(rpm,物理方向),输出为开环控制量
/// effort_raw(协议 raw:ESC=电流,GM6020=电压)。
/// 前馈系数 `feedforward` 的单位是 **raw / rpm**——稳态下所需控制量
/// 与转速近似成正比(GM3508 电流典型值约 9 raw/rpm,需按实车整定)。
///
/// 由驱动层按控制节拍(如 C620 的 1kHz 发送周期)调用。
#[derive(Debug, Clone)]
pub struct VelocityController {
pid: Pid,
feedforward: f32,
}
impl VelocityController {
/// 创建速度环。`pid` 的输出限幅应设为该电机的 effort 限幅(raw)。
#[must_use]
pub fn new(pid: Pid, feedforward: f32) -> Self {
Self { pid, feedforward }
}
/// 单步更新,返回 effort_raw(已含 PID 输出限幅)。
pub fn update(&mut self, target_rpm: f32, measured_rpm: f32, dt: f32) -> f32 {
let pid_out = self.pid.update(target_rpm, measured_rpm, dt);
(pid_out + self.feedforward * target_rpm).clamp(
// 前馈可能把输出推出限幅之外,这里以 PID 限幅为准再夹一次。
self.pid.output_min(),
self.pid.output_max(),
)
}
/// 清空 PID 内部状态(切换模式 / 重新使能时调用)。
pub fn reset(&mut self) {
self.pid.reset();
}
/// 前馈系数(raw / rpm)。
#[must_use]
pub fn feedforward(&self) -> f32 {
self.feedforward
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pure_feedforward_tracks_target() {
let mut vc = VelocityController::new(Pid::new(0.0, 0.0, 0.0, -16384.0, 16384.0), 9.0);
assert!((vc.update(1000.0, 0.0, 0.001) - 9000.0).abs() < 1e-3);
// 前馈 -18000 被限幅到 -16384。
assert!((vc.update(-2000.0, 0.0, 0.001) + 16384.0).abs() < 1e-3);
}
#[test]
fn feedforward_plus_pid_respects_limit() {
let mut vc = VelocityController::new(Pid::new(50.0, 0.0, 0.0, -12000.0, 12000.0), 9.0);
// 前馈 9000 + P 项 50*100 = 14000 → 限幅到 12000。
let out = vc.update(1000.0, 0.0, 0.001);
assert!((out - 12000.0).abs() < 1e-3, "out = {out}");
}
#[test]
fn zero_error_yields_pure_feedforward() {
// 误差为 0 时 PID 贡献为 0,输出只剩前馈。
let mut vc = VelocityController::new(Pid::new(10.0, 5.0, 0.0, -1e6, 1e6), 9.0);
let out = vc.update(1000.0, 1000.0, 0.001);
assert!((out - 9000.0).abs() < 1e-4, "out = {out}");
}
#[test]
fn reset_clears_pid_state() {
let mut vc = VelocityController::new(Pid::new(10.0, 100.0, 0.0, -1e6, 1e6), 0.0);
for _ in 0..10 {
vc.update(100.0, 0.0, 0.001);
}
vc.reset();
// 重置后首步输出 = 纯 P + 本步积分(ki*e*dt = 10)。
let out = vc.update(100.0, 0.0, 0.001);
assert!((out - 1010.0).abs() < 1e-3, "out = {out}");
}
}
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Sentry chassis daemon (mecanum, 4x GM3508/C620)
After=network.target
# 若使用 USB 转 CAN,等待接口就绪:
# After=systemd-networkd.service
# RequiresMountsFor=/sys/class/net/can0
[Service]
Type=simple
ExecStart=/usr/local/bin/chassisd --interface can0 --config /etc/chassisd/chassis.toml
Restart=always
RestartSec=1
# 实时性:提升调度优先级(按需调整)
# CPUSchedulingPolicy=fifo
# CPUSchedulingPriority=20
# LimitRTPRIO=95
[Install]
WantedBy=multi-user.target
+77
View File
@@ -0,0 +1,77 @@
# 架构设计
## 分层
```
┌─────────────────────────────────────────────┐
│ chassisd(守护进程) │
│ 配置 / CLI / 1kHz 控制循环 / 安全逻辑 / 日志 │
└──────┬───────────────────────────┬──────────┘
│ mecanum(麦轮正逆解) │
▼ ▼
┌────────────┐ ┌──────────────────┐
│ motor-core │◄─实现 trait──│ dji-esc │
│ Motor trait│ │ C620/C610 协议 │
│ MotorState │ │ EscBus 异步驱动 │
│ Pid │ │ TurnTracker 多圈 │
└────────────┘ └────────┬─────────┘
│ CanLink(tokio mpsc 通道对)
▼
┌──────────────────────────┐
│ dji-esc-socketcan(Linux) │
│ socketcan ↔ CanLink 桥接 │
└──────────────────────────┘
```
核心解耦点:`CanLink` 是一对 tokio 通道(发送 / 接收 `CanFrame`)。`dji-esc` 的协议状态机
只面向通道工作,因此:
- 测试中用内存通道 + 一阶电机模型(`dji_esc::sim`)构成闭环仿真,全平台可跑;
- `dji-esc-socketcan` 把真实 SocketCAN 接口桥接到通道,只在实车上出现;
- 未来更换总线介质(串口转 CAN、MCU 转发)只需新增一个桥接 crate。
## DJI 电调 CAN 协议要点(C620 手册)
| 项目 | 值 |
| --- | --- |
| 控制帧 ID | `0x200`(电调 1–4)、`0x1FF`(电调 5–8) |
| 控制帧格式 | 8 字节 = 4 × i16 大端电流命令,按电调 ID 顺序 |
| C620 电流标度 | ±16384 ↔ ±20 A |
| C610 电流标度 | ±10000 ↔ ±10 A(M2006 组合,接硬件前需核对) |
| 反馈帧 ID | `0x200 + 电调ID`(`0x201`–`0x208`) |
| 反馈帧格式 | u16 角度(0..8191) + i16 转速rpm + i16 转矩电流 + i8 温度℃,均大端 |
| 反馈频率 | 每电调 1kHz |
C620 内部**只闭电流环**:转速 / 位置环由主控侧 PID 实现(`motor-core::Pid`,
挂在 `EscBus` 的 1kHz 发送节拍上)。GM3508 减速比 3591 / 187 ≈ 19.203。
## 单位与方向约定
- 机器人坐标系:ISO 约定——x 前、y 左、ω 逆时针为正(见 `mecanum`)。
- `Motor` 接口呈现的是**物理量**(安装方向修正后):
`EscConfig::direction = -1` 时,驱动层在写命令与读反馈时统一翻转符号,
上层永远以"轮子正转 = 底盘前进方向"的正向工作。
- `MotorState::rotor_speed_rpm` 为电机转子转速;轮端转速 = 转子转速 / 减速比。
- 电流一律以协议原始值(raw)为主,安培换算通过 `EscModel` 常量提供。
## 安全设计
1. **上电门控**:底盘循环在收到任一电机首帧反馈前不下发速度命令。
2. **反馈离线**:任一电机反馈超时(默认 100ms)→ 该电机命令强制 0 电流并告警。
3. **命令超时**:上层 twist 命令超时(默认 200ms)→ 目标速度归零。
4. 所有超时可经 `chassis.toml` 配置。
## 测试策略
| 层级 | 内容 | 平台 |
| --- | --- | --- |
| 单元测试 | 帧编解码字节级样例 / 饱和、多圈回绕、PID(阶跃、抗饱和、滤波)、运动学解析解 + 属性测试 | 全平台 |
| 闭环仿真 | 内存通道 + 一阶电机模型:4 电机速度环收敛、群帧组装、离线安全、守护进程全链路 | 全平台 |
| vcan 集成 | vcan0 双 socket 收发,真实内核 CAN 栈 | Linux(`#[ignore]`,CI 显式跑) |
| 交叉检查 | `aarch64-unknown-linux-gnu` 全 workspace check | CI |
## 部署
`chassisd` 为单二进制守护进程:`--interface can0 --config /etc/chassisd/chassis.toml`。
systemd 样例见 `deploy/chassisd.service`。板端接口就绪依赖:
USB 转 CAN(gs_usb / candleLight)或 SPI CAN HAT(MCP2515)驱动加载后的 `can0`。
+48
View File
@@ -0,0 +1,48 @@
# 开发常用命令。未安装 just 时可直接使用 README 中的 cargo 命令。
default:
@just --list
# 格式化全部代码
fmt:
cargo fmt --all
# 本地检查(与 CI 同标准)
lint:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
# 运行全部单测与集成测试(Linux 专属 vcan 测试自动跳过)
test:
cargo test --workspace
# Linux:搭建 vcan0 并运行真实内核 CAN 栈集成测试
vcan-test:
sudo modprobe vcan
sudo ip link add dev vcan0 type vcan || true
sudo ip link set up vcan0
cargo test -p dji-esc-socketcan -- --ignored --test-threads 1
# 无硬件全链路演示(macOS / Linux 均可)
run-mock:
cargo run -p chassisd -- --mock
# 交叉检查目标板架构
check-aarch64:
cargo check --workspace --target aarch64-unknown-linux-gnu
# 交叉编译 armv7 静态二进制(目标板:创龙 T153 / Buildroot)
build-armv7:
rustup target add armv7-unknown-linux-musleabihf
cargo build --release -p chassisd --target armv7-unknown-linux-musleabihf
# 推送到板子并试运行(默认 mock 模式)
deploy board="root@169.254.63.4" mode="--mock":
just build-armv7
ssh {{board}} "mkdir -p /tmp/chassisd"
scp -q target/armv7-unknown-linux-musleabihf/release/chassisd {{board}}:/tmp/chassisd/chassisd
ssh {{board}} "/tmp/chassisd/chassisd {{mode}}"
# 板端:拉起 CAN 接口(1Mbps,bus-off 后 100ms 自动恢复)
can-up board="root@169.254.63.4" iface="can0":
ssh {{board}} "ip link set {{iface}} down; ip link set {{iface}} up type can bitrate 1000000 restart-ms 100 && ip -br link show {{iface}}"
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
+2
View File
@@ -0,0 +1,2 @@
max_width = 100
use_small_heuristics = "Max"
+5
View File
@@ -0,0 +1,5 @@
sed -i -e 's@//ports.ubuntu.com/\? @//ports.ubuntu.com/ubuntu-ports @g' \
-e 's@//ports.ubuntu.com@//mirrors.ustc.edu.cn@g' \
/etc/apt/sources.list
passwd 114514