refactor: enforce layered crates and Rust audit gates

This commit is contained in:
Horldsence
2026-09-24 13:00:03 +08:00
parent af151525d1
commit 8a582fc102
67 changed files with 3918 additions and 581 deletions
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "esc-bus"
description = "Asynchronous ESC frame pump, state, leases, and safety"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
can = { workspace = true }
motor = { workspace = true }
motor-protocol = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
approx = { workspace = true }
[package.metadata.sentry-chassis]
layer = "device/esc"
+461
View File
@@ -0,0 +1,461 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! Generic asynchronous ESC frame pump, state, leases, and safety.
use std::marker::PhantomData;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
use can::{CanFrame, CanLink};
use motor::{
Command, CurrentEscDrive, Disable, EffortDrive, EffortPolicy, MotorFeedback, MotorSpec,
MotorState, PolicyInput, PolicySample, PositionDrive, VelocityDrive,
};
use motor_protocol::{EffortScale, FeedbackFields, MotorProtocol, TurnTracker};
use thiserror::Error;
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use tokio::time::{Instant, Interval, MissedTickBehavior};
use tracing::{debug, info, warn};
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Configuration for one typed motor.
#[derive(Debug, Clone)]
pub struct EscConfig<S: MotorSpec = motor::Gm3508> {
pub name: String,
pub esc_id: u8,
pub gear_ratio: f32,
pub direction: f32,
pub current_limit: i16,
marker: PhantomData<S>,
}
impl<S: MotorSpec> EscConfig<S> {
#[must_use]
pub fn new(name: impl Into<String>, esc_id: u8, direction: f32) -> Self {
Self {
name: name.into(),
esc_id,
gear_ratio: S::GEAR_RATIO,
direction,
current_limit: S::scale().max_raw() / 4 * 3,
marker: PhantomData,
}
}
fn validate(&self) -> Result<(), String> {
if <S::Protocol as MotorProtocol>::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));
}
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..=S::scale().max_raw()).contains(&self.current_limit) {
return Err(format!(
"current_limit {} out of range 1..={}",
self.current_limit,
S::scale().max_raw()
));
}
Ok(())
}
}
impl EscConfig<motor::Gm3508> {
#[must_use]
pub fn gm3508(name: impl Into<String>, esc_id: u8, direction: f32) -> Self {
let mut cfg = Self::new(name, esc_id, direction);
cfg.current_limit = 12_000;
cfg
}
}
impl EscConfig<motor::M2006> {
#[must_use]
pub fn m2006(name: impl Into<String>, esc_id: u8, direction: f32) -> Self {
let mut cfg = Self::new(name, esc_id, direction);
cfg.current_limit = 8_000;
cfg
}
}
/// Bus timing and lease options.
#[derive(Debug, Clone)]
pub struct BusOptions {
pub period: Duration,
pub feedback_timeout: Duration,
pub command_timeout: Duration,
}
impl Default for BusOptions {
fn default() -> Self {
Self {
period: Duration::from_millis(1),
feedback_timeout: Duration::from_millis(100),
command_timeout: Duration::from_millis(50),
}
}
}
/// Startup error.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum EscBusError {
#[error("duplicate ESC id {0}")]
DuplicateEscId(u8),
#[error("invalid config for motor {name}: {reason}")]
InvalidConfig { name: String, reason: String },
#[error("no motors configured")]
NoMotors,
}
struct MotorShared<S: MotorSpec> {
cfg: EscConfig<S>,
state: Mutex<MotorState>,
cmd: Mutex<Command>,
cmd_at: Mutex<Option<Instant>>,
turn: Mutex<TurnTracker>,
last_feedback: Mutex<Option<Instant>>,
offline_warned: std::sync::atomic::AtomicBool,
}
impl<S: MotorSpec> MotorShared<S> {
fn new(cfg: EscConfig<S>) -> Self {
Self {
cfg,
state: Mutex::new(MotorState::default()),
cmd: Mutex::new(Command::Disabled),
cmd_at: Mutex::new(None),
turn: Mutex::new(TurnTracker::new()),
last_feedback: Mutex::new(None),
offline_warned: std::sync::atomic::AtomicBool::new(false),
}
}
fn store_command(&self, cmd: Command) {
*lock(&self.cmd) = cmd;
*lock(&self.cmd_at) = Some(Instant::now());
}
fn apply_feedback<F: FeedbackFields>(&self, fb: &F, 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 sample(
&self,
feedback_timeout: Duration,
command_timeout: Duration,
now: Instant,
) -> PolicySample {
let online = matches!(
*lock(&self.last_feedback),
Some(t) if now.saturating_duration_since(t) <= feedback_timeout
);
if !online && !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 output");
}
let fresh = matches!(
*lock(&self.cmd_at),
Some(t) if now.saturating_duration_since(t) <= command_timeout
);
let command = if online && fresh { *lock(&self.cmd) } else { Command::Disabled };
PolicySample { id: self.cfg.esc_id, state: *lock(&self.state), command, online }
}
}
/// Typed motor handle.
pub struct EscMotor<S: MotorSpec = motor::Gm3508> {
shared: Arc<MotorShared<S>>,
}
impl<S: MotorSpec> Clone for EscMotor<S> {
fn clone(&self) -> Self {
Self { shared: Arc::clone(&self.shared) }
}
}
impl<S: MotorSpec> EscMotor<S> {
#[must_use]
pub fn config(&self) -> &EscConfig<S> {
&self.shared.cfg
}
#[must_use]
pub fn name(&self) -> &str {
&self.shared.cfg.name
}
#[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<S: MotorSpec> MotorFeedback for EscMotor<S> {
fn id(&self) -> u8 {
self.shared.cfg.esc_id
}
fn state(&self) -> MotorState {
*lock(&self.shared.state)
}
fn feedback_age(&self) -> Option<Duration> {
Some(Instant::now().saturating_duration_since(*lock(&self.shared.last_feedback).as_ref()?))
}
}
impl<S: MotorSpec> VelocityDrive for EscMotor<S> {
fn set_velocity(&self, rotor_rpm: f32) {
self.shared.store_command(Command::Velocity(rotor_rpm));
}
}
impl<S: MotorSpec> EffortDrive for EscMotor<S> {
fn set_effort_raw(&self, raw: i16) {
self.shared.store_command(Command::EffortRaw(raw));
}
}
impl<S: MotorSpec> PositionDrive for EscMotor<S> {
fn set_position(&self, rotor_rev: f32) {
self.shared.store_command(Command::Position(rotor_rev));
}
}
impl<S: MotorSpec> Disable for EscMotor<S> {
fn disable(&self) {
self.shared.store_command(Command::Disabled);
}
}
/// Generic current-frame ESC bus.
pub struct EscBus<S: MotorSpec = motor::Gm3508>(PhantomData<S>);
impl<S: CurrentEscDrive> EscBus<S> {
/// Start receive and transmit tasks.
///
/// # Errors
///
/// Returns an error when configuration is empty, duplicated, or invalid.
pub fn spawn<P: EffortPolicy>(
link: CanLink,
configs: Vec<EscConfig<S>>,
options: BusOptions,
policy: P,
) -> Result<EscBusHandle<P, S>, 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 policy = Arc::new(Mutex::new(policy));
let rx_handle =
tokio::spawn(rx_loop::<S>(incoming_rx, Arc::clone(&motors), shutdown_rx.clone()));
let tx_handle = tokio::spawn(tx_loop::<S, P>(
outgoing_tx,
Arc::clone(&motors),
Arc::clone(&policy),
options,
shutdown_rx,
));
Ok(EscBusHandle {
motors,
policy,
shutdown_tx,
tasks: vec![rx_handle, tx_handle],
marker: PhantomData,
})
}
}
/// Control handle for a running bus.
pub struct EscBusHandle<P, S: MotorSpec = motor::Gm3508> {
motors: Arc<Vec<EscMotor<S>>>,
policy: Arc<Mutex<P>>,
shutdown_tx: watch::Sender<bool>,
tasks: Vec<JoinHandle<()>>,
marker: PhantomData<S>,
}
impl<P: EffortPolicy, S: CurrentEscDrive> EscBusHandle<P, S> {
#[must_use]
pub fn motor(&self, esc_id: u8) -> Option<&EscMotor<S>> {
self.motors.iter().find(|m| m.id() == esc_id)
}
#[must_use]
pub fn motors(&self) -> &[EscMotor<S>] {
&self.motors
}
pub fn with_policy<R>(&self, update: impl FnOnce(&mut P) -> R) -> R {
update(&mut lock(&self.policy))
}
pub async fn shutdown(self) {
let _ = self.shutdown_tx.send(true);
for task in self.tasks {
let _ = task.await;
}
}
}
async fn rx_loop<S: CurrentEscDrive>(
mut incoming: mpsc::Receiver<CanFrame>,
motors: Arc<Vec<EscMotor<S>>>,
mut shutdown: watch::Receiver<bool>,
) {
loop {
let frame = tokio::select! {
_ = shutdown.changed() => break,
frame = incoming.recv() => match frame {
Some(frame) => frame,
None => break,
},
};
match <S::Protocol as MotorProtocol>::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<S: CurrentEscDrive, P: EffortPolicy>(
outgoing: mpsc::Sender<CanFrame>,
motors: Arc<Vec<EscMotor<S>>>,
policy: Arc<Mutex<P>>,
options: BusOptions,
mut shutdown: watch::Receiver<bool>,
) {
let nominal_dt = options.period.as_secs_f32();
let mut ticker = tick_every(options.period);
let mut last_tick = Instant::now();
loop {
tokio::select! {
_ = shutdown.changed() => break,
_ = ticker.tick() => {}
}
let now = Instant::now();
let raw_dt = now.saturating_duration_since(last_tick).as_secs_f32();
last_tick = now;
let dt = raw_dt.clamp(nominal_dt * 0.5, nominal_dt * 4.0);
let mut samples: [PolicySample; 8] = std::array::from_fn(|index| PolicySample {
id: u8::try_from(index + 1).unwrap_or(0),
state: MotorState::default(),
command: Command::Disabled,
online: false,
});
for motor in motors.iter() {
let sample =
motor.shared.sample(options.feedback_timeout, options.command_timeout, now);
if let Some(slot) = samples.get_mut(usize::from(sample.id.saturating_sub(1))) {
*slot = sample;
}
}
let efforts = lock(&policy).step(&PolicyInput { samples, dt });
for group_start in [1u8, 5u8] {
let Some(frame_id) = <S::Protocol as MotorProtocol>::command_frame_id(group_start)
else {
continue;
};
let group: Vec<&EscMotor<S>> = motors
.iter()
.filter(|m| (group_start..group_start + 4).contains(&m.id()))
.collect();
if group.is_empty() {
continue;
}
let mut commands = [S::Protocol::command_from_raw(0); 4];
for motor in &group {
let slot = usize::from(motor.id() - group_start);
let policy_index = usize::from(motor.id().saturating_sub(1));
let physical = efforts.get(policy_index).copied().unwrap_or(0);
let directed = (f32::from(physical) * motor.shared.cfg.direction) as i16;
let limited =
directed.clamp(-motor.shared.cfg.current_limit, motor.shared.cfg.current_limit);
commands[slot] = S::Protocol::command_from_raw(S::scale().clamp_raw(limited));
}
if outgoing.send(S::Protocol::encode_group(frame_id, commands)).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::*;
use motor::{Gm3508, M2006};
#[test]
fn typed_defaults_are_sane() {
let cfg = EscConfig::<Gm3508>::gm3508("front_left", 1, -1.0);
assert!((cfg.gear_ratio - 19.2).abs() < 0.01);
assert_eq!(cfg.current_limit, 12_000);
assert!(cfg.validate().is_ok());
let cfg = EscConfig::<M2006>::m2006("feeder", 5, 1.0);
assert!((cfg.gear_ratio - 36.0).abs() < 0.01);
assert!(cfg.validate().is_ok());
}
#[test]
fn config_validate_rejects_bad_values() {
let mut cfg = EscConfig::<Gm3508>::gm3508("m", 1, 1.0);
cfg.esc_id = 0;
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 = 16_385;
assert!(cfg.validate().is_err());
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "delta10"
description = "DETA10 IMU driver"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
can = { workspace = true }
imu = { workspace = true }
delta10-protocol = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
[package.metadata.sentry-chassis]
layer = "device/imu"
+193
View File
@@ -0,0 +1,193 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! DETA10 IMU 驱动:把总线上的 IMU 帧变成 [`ImuData`](ARCHITECTURE.md §5.3)。
//!
//! 本驱动只负责**收发与筛选**:帧格式与载荷布局的解析在
//! [`motor_protocol::delta10`],那里也记录了为什么载荷布局仍是待确认的决策点 D1。
//! 因此本模块在默认配置下会返回 [`ImuError::LayoutUnconfirmed`]——
//! 这是**有意的**:宁可明确报错,也不把猜出来的姿态角送进云台闭环。
use can::CanBus;
use delta10_protocol::{self as delta10, Delta10Config};
use imu::{Imu, ImuData, ImuError};
fn now_ns() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or_default()
}
/// 挂在任意 [`CanBus`] 上的 DETA10 驱动。
///
/// 与电机共享总线(ARCHITECTURE.md §2.1),因此 [`Delta10Driver::read`] 会
/// **跳过**所有不是 IMU 的帧(总线上大部分帧是电机反馈)。
#[derive(Debug)]
pub struct Delta10Driver<B> {
bus: B,
config: Delta10Config,
}
impl<B> Delta10Driver<B> {
/// 组装。
#[must_use]
pub fn new(bus: B, config: Delta10Config) -> Self {
Self { bus, config }
}
/// 当前配置(D1 确认后由上层换成带布局的配置)。
#[must_use]
pub const fn config(&self) -> &Delta10Config {
&self.config
}
/// 热更新配置(例如运行期从配置中心注入确认后的布局)。
pub fn set_config(&mut self, config: Delta10Config) {
self.config = config;
}
/// 取回底层总线。
#[must_use]
pub fn into_inner(self) -> B {
self.bus
}
}
impl<B: CanBus> Delta10Driver<B> {
/// 阻塞读取下一帧**姿态**数据,跳过非 IMU 帧。
///
/// # Errors
///
/// - 总线关闭 → [`ImuError::Closed`];
/// - 帧 ID 命中但布局未确认 → [`ImuError::LayoutUnconfirmed`](D1);
/// - 帧不合法 → [`ImuError::BadFrame`]。
pub async fn read(&mut self) -> Result<ImuData, ImuError> {
loop {
let frame = self.bus.recv().await.map_err(|_| ImuError::Closed)?;
// 非 IMU 帧(总线上大部分是电机反馈)直接丢弃,继续等下一帧。
if let Some(data) = delta10::try_decode(&self.config, &frame, now_ns())? {
return Ok(data);
}
}
}
/// 非阻塞读取:本轮没有 IMU 帧时返回 `Ok(None)`。
///
/// # Errors
///
/// 同 [`Delta10Driver::read`](只是不会等待)。
pub fn try_read(&mut self) -> Result<Option<ImuData>, ImuError> {
loop {
let frame = match self.bus.try_recv() {
Ok(Some(frame)) => frame,
Ok(None) => return Ok(None),
Err(_) => return Err(ImuError::Closed),
};
if let Some(data) = delta10::try_decode(&self.config, &frame, now_ns())? {
return Ok(Some(data));
}
}
}
}
impl<B: CanBus> Imu for Delta10Driver<B> {
type Error = ImuError;
async fn read(&mut self) -> Result<ImuData, Self::Error> {
Self::read(self).await
}
fn try_read(&mut self) -> Result<Option<ImuData>, Self::Error> {
Self::try_read(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use can::CanFrame;
use delta10_protocol::{Delta10Layout, FieldLayout, FieldWidth};
use can::MockBus;
fn fitted_layout() -> Delta10Layout {
Delta10Layout {
roll: FieldLayout::new(0, FieldWidth::I16, 0.001),
pitch: FieldLayout::new(2, FieldWidth::I16, 0.001),
yaw: FieldLayout::new(4, FieldWidth::I16, 0.001),
}
}
fn imu_frame(roll_mdeg: i16) -> CanFrame {
let mut data = [0u8; 8];
data[0..2].copy_from_slice(&roll_mdeg.to_le_bytes());
CanFrame::new(0x100, data)
}
#[tokio::test]
async fn read_skips_motor_frames_and_returns_attitude() {
let mut bus = MockBus::new();
bus.inject(CanFrame::new(0x201, [0; 8])); // 电机反馈:必须被跳过
bus.inject(CanFrame::new(0x202, [0; 8]));
bus.inject(imu_frame(2500));
let mut driver = Delta10Driver::new(
bus,
Delta10Config::default().with_can_id(0x100).with_layout(fitted_layout()),
);
let data = driver.read().await.unwrap();
assert!((data.roll - 2.5).abs() < 1e-6, "{}", data.roll);
assert!(data.is_finite());
}
#[tokio::test]
async fn default_config_refuses_to_guess() {
let mut bus = MockBus::new();
bus.inject(imu_frame(1000));
let mut driver = Delta10Driver::new(bus, Delta10Config::default().with_can_id(0x100));
assert_eq!(driver.read().await.unwrap_err(), ImuError::LayoutUnconfirmed);
}
#[tokio::test]
async fn try_read_polls_without_blocking() {
let bus = MockBus::new();
let mut driver = Delta10Driver::new(
bus,
Delta10Config::default().with_can_id(0x100).with_layout(fitted_layout()),
);
assert_eq!(driver.try_read().unwrap(), None, "空总线不是错误");
driver.into_inner().inject(imu_frame(-1500));
// 重新组装以取回总线(inject 需要 &mut)。
let mut bus = MockBus::new();
bus.inject(imu_frame(-1500));
let mut driver = Delta10Driver::new(
bus,
Delta10Config::default().with_can_id(0x100).with_layout(fitted_layout()),
);
let data = driver.try_read().unwrap().unwrap();
assert!((data.roll + 1.5).abs() < 1e-6, "{}", data.roll);
}
#[tokio::test]
async fn closed_bus_is_reported_as_closed() {
let mut bus = MockBus::new();
bus.close();
let mut driver = Delta10Driver::new(
bus,
Delta10Config::default().with_can_id(0x100).with_layout(fitted_layout()),
);
assert_eq!(driver.read().await.unwrap_err(), ImuError::Closed);
assert_eq!(driver.try_read().unwrap_err(), ImuError::Closed);
}
#[tokio::test]
async fn works_through_the_imu_trait() {
let mut bus = MockBus::new();
bus.inject(imu_frame(3000));
let mut driver = Delta10Driver::new(
bus,
Delta10Config::default().with_can_id(0x100).with_layout(fitted_layout()),
);
// 经 `Imu` trait 调用(证明驱动满足 L1 的抽象边界)。
let data: ImuData = Imu::read(&mut driver).await.unwrap();
assert!((data.roll - 3.0).abs() < 1e-6);
}
}
+21
View File
@@ -0,0 +1,21 @@
[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 }
[package.metadata.sentry-chassis]
layer = "device/motor"
+912
View File
@@ -0,0 +1,912 @@
#![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 use command::*;
pub use gearbox::*;
pub use heading::*;
pub use registry::*;
pub use state::*;
pub use testing::*;
pub use traits::*;
pub mod spec {
//! Generic motor identity and mechanical characteristics.
use motor_protocol::{CurrentC610, CurrentC620, CurrentEsc, Gm6020Voltage, MotorProtocol};
/// Control quantity represented by a motor's wire command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ControlMode {
/// Current command.
Current,
/// Voltage command.
Voltage,
}
/// Mechanical and protocol identity for one motor model.
pub trait MotorSpec: Send + Sync + 'static {
type Protocol: MotorProtocol;
const NAME: &'static str;
const GEAR_RATIO: f32;
const ENCODER_TICKS: u16;
const CONTROL_MODE: ControlMode;
fn scale() -> <Self::Protocol as MotorProtocol>::Scale;
}
/// Marker for models driven by the shared C620/C610 current frame path.
pub trait CurrentEscDrive: MotorSpec {}
/// Marker for models driven by a voltage frame path.
pub trait VoltageEscDrive: MotorSpec {}
/// GM3508 + C620.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Gm3508;
/// M2006 + C610.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct M2006;
/// GM6020 direct-drive voltage motor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Gm6020;
impl MotorSpec for Gm3508 {
type Protocol = CurrentEsc<CurrentC620>;
const NAME: &'static str = "GM3508";
const GEAR_RATIO: f32 = 3591.0 / 187.0;
const ENCODER_TICKS: u16 = 8192;
const CONTROL_MODE: ControlMode = ControlMode::Current;
fn scale() -> CurrentC620 {
CurrentC620
}
}
impl MotorSpec for M2006 {
type Protocol = CurrentEsc<CurrentC610>;
const NAME: &'static str = "M2006";
const GEAR_RATIO: f32 = 36.0;
const ENCODER_TICKS: u16 = 8192;
const CONTROL_MODE: ControlMode = ControlMode::Current;
fn scale() -> CurrentC610 {
CurrentC610
}
}
impl MotorSpec for Gm6020 {
type Protocol = Gm6020Voltage;
const NAME: &'static str = "GM6020";
const GEAR_RATIO: f32 = 1.0;
const ENCODER_TICKS: u16 = 8192;
const CONTROL_MODE: ControlMode = ControlMode::Voltage;
fn scale() -> motor_protocol::Voltage30000 {
motor_protocol::Voltage30000
}
}
impl CurrentEscDrive for Gm3508 {}
impl CurrentEscDrive for M2006 {}
impl VoltageEscDrive for Gm6020 {}
/// Keep scale construction available to generic callers.
pub fn scale_of<S: MotorSpec>() -> <S::Protocol as MotorProtocol>::Scale {
S::scale()
}
/// Compile-time assertion helper for current-path models.
pub fn assert_current_drive<S: CurrentEscDrive>() {}
/// Compile-time assertion helper for voltage-path models.
pub fn assert_voltage_drive<S: VoltageEscDrive>() {}
}
pub use spec::{ControlMode, CurrentEscDrive, Gm3508, Gm6020, M2006, MotorSpec, VoltageEscDrive};
#[cfg(test)]
mod spec_tests {
use super::*;
#[test]
fn motor_markers_select_the_right_protocol_family() {
spec::assert_current_drive::<Gm3508>();
spec::assert_current_drive::<M2006>();
spec::assert_voltage_drive::<Gm6020>();
assert_eq!(Gm3508::NAME, "GM3508");
assert_eq!(M2006::NAME, "M2006");
assert_eq!(Gm6020::NAME, "GM6020");
assert_eq!(Gm3508::CONTROL_MODE, ControlMode::Current);
assert_eq!(Gm6020::CONTROL_MODE, ControlMode::Voltage);
}
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "dr16"
description = "DJI DR16 D-BUS serial receiver driver"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
dr16-protocol = { workspace = true }
serial = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
[package.metadata.sentry-chassis]
layer = "device/remote"
+244
View File
@@ -0,0 +1,244 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! DJI DR16 D-BUS 串口接收驱动。
//!
//! DR16 使用非标准 100000 baud、8 data bits、even parity、1 stop bit。阻塞串口
//! I/O 运行在专用线程上,异步侧只消费**最新**样本,避免遥控数据形成积压队列。
use std::io::{self, Read};
use std::thread;
use std::time::{Duration, Instant};
use dr16_protocol::{Dr16Sample, FrameDecoder};
use serial::prelude::*;
use tokio::sync::watch;
use tracing::{debug, error};
/// D-BUS UART 波特率。
pub const BAUD_RATE: usize = 100_000;
/// 串口读取参数。
#[derive(Debug, Clone, Copy)]
pub struct Dr16Options {
/// 阻塞串口读超时;也用于让读取线程定期观察接收端关闭。
pub read_timeout: Duration,
}
impl Default for Dr16Options {
fn default() -> Self {
Self { read_timeout: Duration::from_millis(20) }
}
}
/// 带接收时间的 DR16 样本。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StampedSample {
/// 解码后的遥控输入。
pub sample: Dr16Sample,
/// 样本到达用户态读取循环的时刻。
pub received_at: Instant,
}
/// 最新样本发送端。
#[derive(Debug, Clone)]
pub struct SampleSender {
tx: watch::Sender<Option<StampedSample>>,
}
impl SampleSender {
/// 发布一个新样本并返回带时间戳的副本。
#[must_use]
pub fn send(&self, sample: Dr16Sample) -> StampedSample {
let stamped = StampedSample { sample, received_at: Instant::now() };
self.tx.send_replace(Some(stamped));
stamped
}
/// 当前是否有异步消费者。
#[must_use]
pub fn is_closed(&self) -> bool {
self.tx.is_closed()
}
}
/// 创建一对最新样本发送端和接收流。
#[must_use]
pub fn sample_channel() -> (SampleSender, SampleStream) {
let (tx, rx) = watch::channel(None);
(SampleSender { tx }, SampleStream { rx })
}
/// 最新 DR16 样本流。
///
/// 内部使用 `watch`:慢消费者看到的是最新遥控状态,不会执行延迟的旧摇杆位置。
#[derive(Debug)]
pub struct SampleStream {
rx: watch::Receiver<Option<StampedSample>>,
}
impl SampleStream {
/// 等待下一个新样本;读取线程退出且没有样本时返回 `None`。
pub async fn recv(&mut self) -> Option<StampedSample> {
loop {
self.rx.changed().await.ok()?;
if let Some(sample) = *self.rx.borrow_and_update() {
return Some(sample);
}
}
}
/// 读取当前最新样本;尚未收到帧时返回 `None`。
#[must_use]
pub fn latest(&self) -> Option<StampedSample> {
*self.rx.borrow()
}
}
/// 打开 DR16 串口并开始接收。
///
/// # Errors
///
/// 设备不存在、权限不足、串口不支持 100000 baud 或配置 8E1 失败。
pub fn open(device: &str) -> io::Result<SampleStream> {
open_with(device, Dr16Options::default())
}
/// 使用指定读取参数打开 DR16 串口。
///
/// # Errors
///
/// 同 [`open`]。
pub fn open_with(device: &str, options: Dr16Options) -> io::Result<SampleStream> {
let mut port = serial::open(device)?;
port.reconfigure(&|settings| {
settings.set_baud_rate(serial::BaudOther(BAUD_RATE))?;
settings.set_char_size(serial::Bits8);
settings.set_parity(serial::ParityEven);
settings.set_stop_bits(serial::Stop1);
settings.set_flow_control(serial::FlowNone);
Ok(())
})?;
port.set_timeout(options.read_timeout)?;
debug!(device, baud = BAUD_RATE, "DR16 serial opened");
Ok(spawn_reader(port))
}
/// 在专用线程上泵送任意字节 reader,返回最新样本流。
#[must_use]
pub fn spawn_reader<R>(reader: R) -> SampleStream
where
R: Read + Send + 'static,
{
let (tx, stream) = sample_channel();
let thread = thread::Builder::new().name("dr16-reader".into()).spawn(move || {
pump_reader(reader, &tx);
});
if let Err(error) = thread {
error!(%error, "failed to start DR16 reader thread");
}
stream
}
fn pump_reader<R: Read>(mut reader: R, tx: &SampleSender) {
let mut decoder = FrameDecoder::new();
let mut buffer = [0u8; 64];
loop {
match reader.read(&mut buffer) {
Ok(0) => {
debug!("DR16 reader reached end of input");
return;
}
Ok(count) => {
for sample in decoder.push(&buffer[..count]) {
let _ = tx.send(sample);
}
}
Err(error) if is_transient(&error) => {
// 读超时内没有任何字节,已经超过正常 D-BUS 帧间隔;清掉半帧,避免错位。
decoder.clear();
if tx.is_closed() {
debug!("DR16 sample consumer closed");
return;
}
}
Err(error) => {
error!(%error, "DR16 serial read failed");
return;
}
}
if tx.is_closed() {
debug!("DR16 sample consumer closed");
return;
}
}
}
fn is_transient(error: &io::Error) -> bool {
matches!(
error.kind(),
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
)
}
#[cfg(test)]
mod tests {
use std::cmp;
use dr16_protocol::{FRAME_LEN, SwitchPosition};
use super::*;
struct ChunkReader {
chunks: Vec<Vec<u8>>,
index: usize,
}
impl Read for ChunkReader {
fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
let Some(chunk) = self.chunks.get(self.index) else {
return Ok(0);
};
self.index += 1;
let count = cmp::min(output.len(), chunk.len());
output[..count].copy_from_slice(&chunk[..count]);
Ok(count)
}
}
fn frame() -> [u8; FRAME_LEN] {
let mut frame = [0u8; FRAME_LEN];
frame[1] = 0x04;
frame[2] = 0x20;
frame[4] = 0x01;
frame[5] = 0x78;
frame
}
#[tokio::test]
async fn generic_reader_publishes_decoded_latest_sample() {
let raw = frame();
let reader = ChunkReader {
chunks: vec![raw[..5].to_vec(), raw[5..13].to_vec(), raw[13..].to_vec()],
index: 0,
};
let mut stream = spawn_reader(reader);
let stamped = stream.recv().await.unwrap();
assert_eq!(stamped.sample.s1, SwitchPosition::Up);
assert_eq!(stamped.sample.s2, SwitchPosition::Mid);
assert_eq!(stream.latest(), Some(stamped));
}
#[tokio::test]
async fn closed_reader_reports_end_of_stream() {
let reader = ChunkReader { chunks: Vec::new(), index: 0 };
let mut stream = spawn_reader(reader);
assert_eq!(stream.recv().await, None);
}
#[test]
fn transient_read_errors_are_not_fatal() {
assert!(is_transient(&io::Error::from(io::ErrorKind::TimedOut)));
assert!(is_transient(&io::Error::from(io::ErrorKind::WouldBlock)));
assert!(!is_transient(&io::Error::from(io::ErrorKind::BrokenPipe)));
}
}