refactor: 重构架构并整理仓库

This commit is contained in:
Horldsence
2026-09-23 10:56:57 +08:00
parent 680adae339
commit f8aeafdfc2
101 changed files with 14417 additions and 11399 deletions
+18
View File
@@ -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 }
+964
View File
@@ -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::*;