refactor: 重构架构并整理仓库
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "motor"
|
||||
description = "Protocol-independent motor device contract and registry"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
can = { workspace = true }
|
||||
motor-protocol = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
approx = { workspace = true }
|
||||
@@ -0,0 +1,964 @@
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
|
||||
|
||||
//! Protocol-independent motor device contracts.
|
||||
|
||||
pub mod traits {
|
||||
//! 能力面 trait:小而正交,按硬件事实拆分。
|
||||
//!
|
||||
//! 消费者只声明自己用到的能力(静态分发,无 `dyn`):
|
||||
//! - 底盘轮 → [`VelocityDrive`] + [`MotorFeedback`]
|
||||
//! - 云台 → [`PositionDrive`] + [`EffortDrive`](+ 上层 [`crate::HeadingSource`])
|
||||
//! - 拨弹 → [`VelocityDrive`] + [`PositionDrive`]
|
||||
//! - 台架 → [`VelocityDrive`] + [`MotorFeedback`] + [`Disable`]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{Command, MotorState};
|
||||
|
||||
/// 读反馈。所有可观察设备的最小面。
|
||||
pub trait MotorFeedback: Send + Sync {
|
||||
/// 控制器内的电机 ID(对 DJI 电调即电调 ID)。
|
||||
fn id(&self) -> u8;
|
||||
|
||||
/// 最新反馈快照。
|
||||
fn state(&self) -> MotorState;
|
||||
|
||||
/// 距最近一帧反馈的时长;`None` 表示从未收到。
|
||||
///
|
||||
/// 用于在线判定(反馈新鲜度),与 [`MotorState::has_feedback`]
|
||||
/// (历史上是否收到过)区分:上电门控之后运行中掉线要看本方法。
|
||||
fn feedback_age(&self) -> Option<Duration>;
|
||||
}
|
||||
|
||||
/// 开环控制量(协议 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 {}
|
||||
|
||||
/// One motor's data for one control-policy evaluation.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PolicySample {
|
||||
/// ESC identifier.
|
||||
pub id: u8,
|
||||
/// Latest direction-corrected physical feedback.
|
||||
pub state: MotorState,
|
||||
/// Latest command lease value.
|
||||
pub command: Command,
|
||||
/// Whether fresh feedback is available.
|
||||
pub online: bool,
|
||||
}
|
||||
|
||||
/// Snapshot passed to an external effort policy once per bus tick.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PolicyInput {
|
||||
/// Samples indexed by ESC id minus one.
|
||||
pub samples: [PolicySample; 8],
|
||||
/// Measured control period in seconds.
|
||||
pub dt: f32,
|
||||
}
|
||||
|
||||
/// Computes raw physical effort from motor commands and feedback.
|
||||
///
|
||||
/// `esc-bus` owns transport and safety but deliberately has no control-law
|
||||
/// dependency. Control crates implement this trait and the application composes
|
||||
/// the two sides.
|
||||
pub trait EffortPolicy: Send + 'static {
|
||||
/// Return physical effort raw values indexed by ESC id minus one.
|
||||
fn step(&mut self, input: &PolicyInput) -> [i16; 8];
|
||||
/// Clear controller state after an offline event or command-mode change.
|
||||
fn reset(&mut self, esc_id: u8);
|
||||
}
|
||||
}
|
||||
|
||||
pub mod state {
|
||||
//! 电机反馈快照(协议无关,方向修正后的物理量)。
|
||||
//!
|
||||
//! 只含**转子轴**物理量——减速比 / 输出轴换算由 [`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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod command {
|
||||
//! 驱动层命令寄存器(最后写入胜出)。
|
||||
|
||||
/// 发给驱动层的电机命令。
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod gearbox {
|
||||
//! 输出轴单位适配器:减速比换算。
|
||||
//!
|
||||
//! 把「输出轴单位」的驱动接口适配到「转子单位」的底层电机。
|
||||
//! `gear_ratio = 转子 : 输出轴`(GM3508 ≈ 19.203)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fn feedback_age(&self) -> Option<Duration> {
|
||||
self.inner.feedback_age()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
fn feedback_age(&self) -> Option<Duration> {
|
||||
Some(Duration::ZERO)
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod heading {
|
||||
//! 航向观测源(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,
|
||||
}
|
||||
}
|
||||
|
||||
pub mod registry {
|
||||
//! 电机注册表:**按总线 + 反馈帧 ID** 找回设备(ARCHITECTURE.md §5.4)。
|
||||
//!
|
||||
//! # 为什么需要它
|
||||
//!
|
||||
//! 单总线底盘上,`EscBus` 自己就能把反馈帧分发给对应的 `EscMotor`。但整机是
|
||||
//! **双 CAN**(§2.1:底盘 `can0` + 功能总线 `can1`),云台 / 拨弹的帧与底盘的
|
||||
//! 帧可能落在**相同的 ID** 上(GM6020 与 C620 都可能是 0x2xx)。此时"这帧是谁的"
|
||||
//! 只能由 `(总线, 帧 ID)` 共同决定。
|
||||
//!
|
||||
//! [`MotorRegistry`] 就是这张映射表:注册时校验冲突,分发时 O(1) 命中。
|
||||
//! 当前 `chassisd` 的单总线底盘路径不经过它;它是云台 / 拨弹接入功能总线时的
|
||||
//! seam(先有接口与测试,避免将来把分发逻辑硬编码进每个任务)。
|
||||
|
||||
use can::CanFrame;
|
||||
use motor_protocol::feedback_frame_id;
|
||||
|
||||
/// 物理总线标识(ARCHITECTURE.md §2.1 的双 CAN 拓扑)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum BusId {
|
||||
/// 底盘总线(C620/C610 驱动轮)。
|
||||
Chassis,
|
||||
/// 功能总线(云台 GM6020 + 拨弹 M2006/C610)。
|
||||
Function,
|
||||
}
|
||||
|
||||
impl BusId {
|
||||
/// 文本名。
|
||||
#[must_use]
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Chassis => "chassis",
|
||||
Self::Function => "function",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册表条目。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RegisteredMotor {
|
||||
/// 所属总线。
|
||||
pub bus: BusId,
|
||||
/// 反馈帧 ID。
|
||||
pub frame_id: u32,
|
||||
/// 轮名 / 设备名(配置里的 `name`)。
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// 注册错误。
|
||||
///
|
||||
/// 手写 `Display` / `Error` 而不是 `derive(thiserror::Error)`:注册表属于
|
||||
/// **总是可用**的设备抽象(关掉 `bus` feature 也要能编译),不应为一个派生宏
|
||||
/// 引入可选依赖。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RegistryError {
|
||||
/// 同一 `(总线, 帧 ID)` 被注册两次——几乎总是接线或配置写重了。
|
||||
Duplicate {
|
||||
/// 总线。
|
||||
bus: &'static str,
|
||||
/// 冲突的帧 ID。
|
||||
frame_id: u32,
|
||||
/// 先注册的名字。
|
||||
existing: String,
|
||||
/// 后注册的名字。
|
||||
name: String,
|
||||
},
|
||||
/// 电调 ID 越界(1–8)。
|
||||
BadEscId(u8),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RegistryError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Duplicate { bus, frame_id, existing, name } => write!(
|
||||
f,
|
||||
"总线 {bus} 上的帧 ID 0x{frame_id:03X} 已被 '{existing}' 占用,无法再注册 '{name}'"
|
||||
),
|
||||
Self::BadEscId(id) => write!(f, "电调 ID {id} 越界(合法范围 1–8)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RegistryError {}
|
||||
|
||||
/// 设备注册表。
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct MotorRegistry {
|
||||
entries: Vec<RegisteredMotor>,
|
||||
}
|
||||
|
||||
impl MotorRegistry {
|
||||
/// 空表。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 按电调 ID 注册(反馈帧 ID = `0x200 + 电调ID`,C620/C610 约定)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 电调 ID 越界或该帧 ID 已被占用时返回错误。
|
||||
pub fn register_esc(
|
||||
&mut self,
|
||||
bus: BusId,
|
||||
esc_id: u8,
|
||||
name: impl Into<String>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let frame_id = feedback_frame_id(esc_id).ok_or(RegistryError::BadEscId(esc_id))?;
|
||||
self.register_frame(bus, frame_id, name)
|
||||
}
|
||||
|
||||
/// 按反馈帧 ID 注册(GM6020 或非 1–8 的 ID 用它)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 该 `(总线, 帧 ID)` 已被占用时返回错误。
|
||||
pub fn register_frame(
|
||||
&mut self,
|
||||
bus: BusId,
|
||||
frame_id: u32,
|
||||
name: impl Into<String>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let name = name.into();
|
||||
if let Some(existing) = self.resolve(bus, frame_id) {
|
||||
return Err(RegistryError::Duplicate {
|
||||
bus: bus.name(),
|
||||
frame_id,
|
||||
existing: existing.name.clone(),
|
||||
name,
|
||||
});
|
||||
}
|
||||
self.entries.push(RegisteredMotor { bus, frame_id, name });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按 `(总线, 帧 ID)` 查找。
|
||||
#[must_use]
|
||||
pub fn resolve(&self, bus: BusId, frame_id: u32) -> Option<&RegisteredMotor> {
|
||||
self.entries.iter().find(|entry| entry.bus == bus && entry.frame_id == frame_id)
|
||||
}
|
||||
|
||||
/// 从一帧反馈找出它的归属设备(ARCHITECTURE.md §5.4 的 `update_from_frame`)。
|
||||
///
|
||||
/// 返回 `None` 表示这帧不属于任何已注册设备(例如 IMU 帧,或没配的电机)。
|
||||
#[must_use]
|
||||
pub fn dispatch(&self, bus: BusId, frame: &CanFrame) -> Option<&RegisteredMotor> {
|
||||
self.resolve(bus, frame.id)
|
||||
}
|
||||
|
||||
/// 已注册设备数。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// 是否为空。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// 全部条目(按注册顺序)。
|
||||
#[must_use]
|
||||
pub fn entries(&self) -> &[RegisteredMotor] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// 某条总线上的设备名。
|
||||
#[must_use]
|
||||
pub fn names_on(&self, bus: BusId) -> Vec<&str> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.bus == bus)
|
||||
.map(|entry| entry.name.as_str())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// 底盘四轮的注册顺序(与 `mecanum` 的 FL/FR/RL/RR 一致)。
|
||||
pub const CHASSIS_WHEEL_NAMES: [&str; 4] =
|
||||
["front_left", "front_right", "rear_left", "rear_right"];
|
||||
|
||||
/// 把底盘四轮按顺序注册进表(电调 ID 从 1 开始连续)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 任一电调 ID 越界或冲突时返回错误。
|
||||
pub fn register_chassis_wheels(
|
||||
registry: &mut MotorRegistry,
|
||||
esc_ids: [u8; 4],
|
||||
) -> Result<(), RegistryError> {
|
||||
for (name, esc_id) in CHASSIS_WHEEL_NAMES.iter().zip(esc_ids) {
|
||||
registry.register_esc(BusId::Chassis, esc_id, *name)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use motor_protocol::{MAX_ESC_ID, MIN_ESC_ID};
|
||||
|
||||
#[test]
|
||||
fn registers_and_dispatches_chassis_wheels() {
|
||||
let mut registry = MotorRegistry::new();
|
||||
register_chassis_wheels(&mut registry, [1, 2, 3, 4]).unwrap();
|
||||
assert_eq!(registry.len(), 4);
|
||||
assert_eq!(registry.names_on(BusId::Chassis).len(), 4);
|
||||
assert!(registry.names_on(BusId::Function).is_empty());
|
||||
|
||||
let frame = CanFrame::new(0x203, [0; 8]);
|
||||
let found = registry.dispatch(BusId::Chassis, &frame).unwrap();
|
||||
assert_eq!(found.name, "rear_left", "0x203 = 电调 3 = 后左");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_frame_id_on_another_bus_is_not_a_conflict() {
|
||||
let mut registry = MotorRegistry::new();
|
||||
registry.register_esc(BusId::Chassis, 1, "front_left").unwrap();
|
||||
// 功能总线上的 GM6020 ID 1 反馈同样是 0x205 —— 不同总线,互不冲突。
|
||||
registry.register_frame(BusId::Function, 0x205, "gimbal_yaw").unwrap();
|
||||
assert_eq!(registry.len(), 2);
|
||||
assert_eq!(registry.resolve(BusId::Chassis, 0x205), None);
|
||||
assert_eq!(registry.resolve(BusId::Function, 0x205).unwrap().name, "gimbal_yaw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_on_the_same_bus_are_rejected_with_both_names() {
|
||||
let mut registry = MotorRegistry::new();
|
||||
registry.register_esc(BusId::Chassis, 2, "front_right").unwrap();
|
||||
let error = registry.register_esc(BusId::Chassis, 2, "typo_front_right").unwrap_err();
|
||||
let text = error.to_string();
|
||||
assert!(text.contains("front_right"), "{text}");
|
||||
assert!(text.contains("typo_front_right"), "{text}");
|
||||
assert_eq!(registry.len(), 1, "失败的注册不得写入");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_esc_ids_are_rejected() {
|
||||
let mut registry = MotorRegistry::new();
|
||||
assert_eq!(
|
||||
registry.register_esc(BusId::Chassis, 0, "ghost"),
|
||||
Err(RegistryError::BadEscId(0))
|
||||
);
|
||||
assert_eq!(
|
||||
registry.register_esc(BusId::Chassis, 9, "ghost"),
|
||||
Err(RegistryError::BadEscId(9))
|
||||
);
|
||||
assert!(registry.is_empty());
|
||||
assert!(feedback_frame_id(MIN_ESC_ID).is_some());
|
||||
assert!(feedback_frame_id(MAX_ESC_ID).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_frames_dispatch_to_nothing() {
|
||||
let mut registry = MotorRegistry::new();
|
||||
register_chassis_wheels(&mut registry, [1, 2, 3, 4]).unwrap();
|
||||
// DETA10 的 IMU 帧不是任何电机。
|
||||
let imu = CanFrame::new(0x100, [0; 8]);
|
||||
assert!(registry.dispatch(BusId::Chassis, &imu).is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod testing {
|
||||
//! 测试替身:内存电机与航向源。
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
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)>>,
|
||||
last_feedback: Arc<Mutex<Option<Instant>>>,
|
||||
}
|
||||
|
||||
impl MockMotor {
|
||||
/// 创建指定 ID 的空电机。
|
||||
#[must_use]
|
||||
pub fn new(id: u8) -> Self {
|
||||
Self {
|
||||
id,
|
||||
published: Arc::new(Mutex::new((MotorState::default(), Command::Disabled))),
|
||||
last_feedback: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 注入反馈快照(记为「刚刚收到」)。
|
||||
pub fn inject_state(&self, state: MotorState) {
|
||||
self.inject_state_with_age(state, Duration::ZERO);
|
||||
}
|
||||
|
||||
/// 注入反馈快照并指定其“已过去”的时长(用于在线/超时测试)。
|
||||
pub fn inject_state_with_age(&self, state: MotorState, age: Duration) {
|
||||
lock(&self.published).0 = state;
|
||||
let at = Instant::now().checked_sub(age);
|
||||
*lock(&self.last_feedback) = Some(at.unwrap_or_else(Instant::now));
|
||||
}
|
||||
|
||||
/// 最近一次命令。
|
||||
#[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
|
||||
}
|
||||
fn feedback_age(&self) -> Option<Duration> {
|
||||
let at = *lock(&self.last_feedback);
|
||||
Some(at?.elapsed())
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
assert!(m.feedback_age().is_none());
|
||||
m.inject_state(MotorState { feedback_count: 5, ..MotorState::default() });
|
||||
assert!(m.state().has_feedback());
|
||||
assert!(m.feedback_age().is_some_and(|age| age < Duration::from_millis(50)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_motor_feedback_age_can_be_stale() {
|
||||
let m = MockMotor::new(1);
|
||||
m.inject_state_with_age(
|
||||
MotorState { feedback_count: 1, ..MotorState::default() },
|
||||
Duration::from_millis(250),
|
||||
);
|
||||
assert!(m.feedback_age().is_some_and(|age| age >= Duration::from_millis(200)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod motor_common {
|
||||
//! 电机种类:把"型号"变成可在设备层与配置层之间传递的事实。
|
||||
//!
|
||||
//! 设备层不关心电调怎么接、齿轮多大,但必须知道**协议族**:电流环在电调内部,
|
||||
//! 而不同协议族的控制字含义不同(电流 vs 电压)。[`MotorKind`] 就是这张表。
|
||||
|
||||
use motor_protocol::EscModel;
|
||||
|
||||
/// 支持的电机型号。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum MotorKind {
|
||||
/// GM3508 + C620(底盘驱动轮)。
|
||||
Gm3508,
|
||||
/// M2006 + C610(拨弹 / 小负载)。
|
||||
M2006,
|
||||
/// GM6020(云台直驱,**电压**控制)。
|
||||
Gm6020,
|
||||
}
|
||||
|
||||
impl MotorKind {
|
||||
/// 配套电调型号。
|
||||
#[must_use]
|
||||
pub const fn esc_model(self) -> EscModel {
|
||||
match self {
|
||||
// GM6020 的反馈标度与 C620 相同,但控制字是电压。
|
||||
Self::Gm3508 | Self::Gm6020 => EscModel::C620,
|
||||
Self::M2006 => EscModel::C610,
|
||||
}
|
||||
}
|
||||
|
||||
/// 控制量是否是**电压**(而非电流)。
|
||||
///
|
||||
/// 总线驱动据此选择组帧方式:C620/C610 走 `encode_current_frame`,
|
||||
/// GM6020 走 `motor_protocol::motor::gm6020::encode_voltage_frame`。
|
||||
#[must_use]
|
||||
pub const fn encodes_voltage(self) -> bool {
|
||||
matches!(self, Self::Gm6020)
|
||||
}
|
||||
|
||||
/// 该型号是否与其它型号**共用**电流控制帧(C620/C610 共用 `0x200`/`0x1FF`)。
|
||||
#[must_use]
|
||||
pub const fn shares_current_frames(self) -> bool {
|
||||
!self.encodes_voltage()
|
||||
}
|
||||
|
||||
/// 默认减速比(转子 : 输出轴)。接硬件前必须按实车核对。
|
||||
#[must_use]
|
||||
pub const fn default_gear_ratio(self) -> f32 {
|
||||
match self {
|
||||
Self::Gm3508 => 3591.0 / 187.0,
|
||||
// M2006 常见配置 36:1;与 ARCHITECTURE.md §8.2 的 motors.toml 一致。
|
||||
Self::M2006 => 36.0,
|
||||
// 云台直驱。
|
||||
Self::Gm6020 => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 型号名(配置与日志用)。
|
||||
#[must_use]
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Gm3508 => "GM3508",
|
||||
Self::M2006 => "M2006",
|
||||
Self::Gm6020 => "GM6020",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_gm6020_encodes_voltage() {
|
||||
assert!(MotorKind::Gm6020.encodes_voltage());
|
||||
assert!(!MotorKind::Gm3508.encodes_voltage());
|
||||
assert!(!MotorKind::M2006.encodes_voltage());
|
||||
assert!(MotorKind::Gm3508.shares_current_frames());
|
||||
assert!(!MotorKind::Gm6020.shares_current_frames());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_models_match_the_scale_documentation() {
|
||||
assert_eq!(MotorKind::Gm3508.esc_model(), EscModel::C620);
|
||||
assert_eq!(MotorKind::Gm6020.esc_model(), EscModel::C620);
|
||||
assert_eq!(MotorKind::M2006.esc_model(), EscModel::C610);
|
||||
assert_eq!(MotorKind::M2006.esc_model().max_current_raw(), 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_gear_ratios_are_sane() {
|
||||
assert!((MotorKind::Gm3508.default_gear_ratio() - 19.203).abs() < 5e-3);
|
||||
assert!((MotorKind::Gm6020.default_gear_ratio() - 1.0).abs() < 1e-6);
|
||||
assert!(MotorKind::M2006.default_gear_ratio() > 0.0);
|
||||
assert_eq!(MotorKind::Gm6020.name(), "GM6020");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod motor_gm6020 {
|
||||
//! GM6020 设备(云台直驱,**电压**控制)。
|
||||
//!
|
||||
//! # 当前状态(务必读)
|
||||
//!
|
||||
//! 协议层的电压帧编解码已经可用([`motor_protocol::gm6020`]),但**总线驱动
|
||||
//! `EscBus` 目前只实现 C620/C610 的电流路径**:它把 `set_effort_raw` 直接组
|
||||
//! 成电流帧。把 GM6020 接上云台还需要总线驱动增加"电压帧"分支
|
||||
//! ([`MotorKind::encodes_voltage`] 就是给那条分支准备的判据),这属于云台
|
||||
//! 部分的工作(ARCHITECTURE.md §5.7 `gimbal` 任务)。
|
||||
//!
|
||||
//! 在此之前,本模块提供的是**型号事实**(协议族、减速比、ID 映射),
|
||||
//! 不含"已经能驱动 GM6020"的暗示。
|
||||
|
||||
use crate::MotorKind;
|
||||
|
||||
/// 该型号的 [`MotorKind`]。
|
||||
pub const KIND: MotorKind = MotorKind::Gm6020;
|
||||
|
||||
/// 云台直驱的减速比。
|
||||
pub const GEAR_RATIO: f32 = 1.0;
|
||||
|
||||
/// 该型号的控制量是电压而非电流。
|
||||
pub const ENCODES_VOLTAGE: bool = true;
|
||||
|
||||
/// 总线驱动是否已支持该型号的驱动路径。
|
||||
///
|
||||
/// 现在返回 `false`:电压帧分支随云台工作落地。用它做**运行期**判定,
|
||||
/// 而不是让配置静默接受一个驱动不了的型号。
|
||||
#[must_use]
|
||||
pub const fn drive_path_available() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use motor_protocol::gm6020::{COMMAND_FRAME_ID_PRIMARY, feedback_frame_id};
|
||||
|
||||
#[test]
|
||||
fn kind_facts_match_the_protocol_module() {
|
||||
assert_eq!(KIND, MotorKind::Gm6020);
|
||||
assert!(KIND.encodes_voltage());
|
||||
assert!((KIND.default_gear_ratio() - GEAR_RATIO).abs() < 1e-6);
|
||||
// 两个常量同源:`ENCODES_VOLTAGE` 就是 `KIND.encodes_voltage()` 的镜像。
|
||||
assert_eq!(ENCODES_VOLTAGE, KIND.encodes_voltage());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gm6020_uses_its_own_frame_ids() {
|
||||
assert_eq!(COMMAND_FRAME_ID_PRIMARY, 0x1FF);
|
||||
assert_eq!(feedback_frame_id(1), Some(0x205));
|
||||
// 与 C620 不同:C620 的 ID 1 控制帧是 0x200。
|
||||
assert_ne!(COMMAND_FRAME_ID_PRIMARY, motor_protocol::COMMAND_FRAME_ID_PRIMARY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_path_is_honestly_reported_as_unavailable() {
|
||||
assert!(!drive_path_available(), "云台电压路径落地后本断言才应改为 true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use command::*;
|
||||
pub use gearbox::*;
|
||||
pub use heading::*;
|
||||
pub use motor_common::MotorKind;
|
||||
pub use registry::*;
|
||||
pub use state::*;
|
||||
pub use testing::*;
|
||||
pub use traits::*;
|
||||
Reference in New Issue
Block a user