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
@@ -24,3 +24,6 @@ anyhow = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
[package.metadata.sentry-chassis]
layer = "application/bench"
@@ -15,6 +15,8 @@ motor = { workspace = true }
esc-bus = { workspace = true }
motor-control = { workspace = true }
mecanum = { workspace = true }
dr16-protocol = { workspace = true }
dr16 = { workspace = true }
chassis-config = { workspace = true }
sim-harness = { workspace = true }
socketcan = { workspace = true }
@@ -26,3 +28,6 @@ tracing-subscriber = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
approx = { workspace = true }
[package.metadata.sentry-chassis]
layer = "application/daemon"
@@ -166,62 +166,63 @@ pub mod messages {
pub use messages::*;
pub mod source {
//! 上层速度指令源(twist source)。
//! 上层底盘指令源。
//!
//! 守护进程通过 [`watch`] 通道把指令交给控制循环;指令源在独立任务中
//! 持续产出 twist。遥控器(DR16)、自主导航等后续接入时各自实现
//! [`TwistSource`] 即可。
//! 守护进程通过 [`watch`] 通道把指令交给控制循环;指令源持续产出
//! [`RemoteCommand`]。遥控器、自主导航等来源共享同一接口,失能语义不会在
//! 各来源里重复实现。
use std::future::Future;
use mecanum::Twist;
use tokio::sync::{mpsc, watch};
/// 速度指令源。
pub trait TwistSource: Send {
use crate::RemoteCommand;
/// 底盘指令源。
pub trait CommandSource: Send {
/// 取下一条指令;`None` 表示源关闭。
fn next_twist(&mut self) -> impl Future<Output = Option<Twist>> + Send;
fn next_command(&mut self) -> impl Future<Output = Option<RemoteCommand>> + Send;
}
/// 恒零占位源:永不产出(watch 保持初值 `Twist::ZERO`,指令超时逻辑兜底)。
/// 恒失能占位源:永不产出,控制器超时逻辑兜底停车。
pub struct ZeroSource;
impl TwistSource for ZeroSource {
async fn next_twist(&mut self) -> Option<Twist> {
impl CommandSource for ZeroSource {
async fn next_command(&mut self) -> Option<RemoteCommand> {
std::future::pending().await
}
}
/// 通道指令源(测试 / 简单进程内注入用)。
pub struct ChannelSource {
rx: mpsc::Receiver<Twist>,
rx: mpsc::Receiver<RemoteCommand>,
}
impl ChannelSource {
/// 由 mpsc 接收端构造。
#[must_use]
pub const fn new(rx: mpsc::Receiver<Twist>) -> Self {
pub const fn new(rx: mpsc::Receiver<RemoteCommand>) -> Self {
Self { rx }
}
/// 创建一对(发送端, 源)。
#[must_use]
pub fn pair(bound: usize) -> (mpsc::Sender<Twist>, Self) {
pub fn pair(bound: usize) -> (mpsc::Sender<RemoteCommand>, Self) {
let (tx, rx) = mpsc::channel(bound);
(tx, Self { rx })
}
}
impl TwistSource for ChannelSource {
async fn next_twist(&mut self) -> Option<Twist> {
impl CommandSource for ChannelSource {
async fn next_command(&mut self) -> Option<RemoteCommand> {
self.rx.recv().await
}
}
/// 把任意指令源持续泵入 watch 通道(源关闭或接收端关闭时退出)。
pub async fn pump_source(mut source: impl TwistSource, tx: watch::Sender<Twist>) {
while let Some(twist) = source.next_twist().await {
if tx.send(twist).is_err() {
pub async fn pump_source(mut source: impl CommandSource, tx: watch::Sender<RemoteCommand>) {
while let Some(command) = source.next_command().await {
if tx.send(command).is_err() {
break;
}
}
@@ -229,28 +230,249 @@ pub mod source {
#[cfg(test)]
mod tests {
use mecanum::Twist;
use super::*;
#[tokio::test(start_paused = true)]
async fn channel_source_pumps_into_watch() {
let (tx, source) = ChannelSource::pair(8);
let (watch_tx, mut watch_rx) = watch::channel(Twist::ZERO);
let (watch_tx, mut watch_rx) = watch::channel(RemoteCommand::DISABLED);
let pump = tokio::spawn(pump_source(source, watch_tx));
tx.send(Twist { vx: 1.0, vy: 0.0, omega: 0.0 }).await.unwrap();
let command = RemoteCommand::new(Twist { vx: 1.0, vy: 0.0, omega: 0.0 });
tx.send(command).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
assert_eq!(*watch_rx.borrow_and_update(), Twist { vx: 1.0, vy: 0.0, omega: 0.0 });
assert_eq!(*watch_rx.borrow_and_update(), command);
// 源关闭 → 泵任务退出。
drop(tx);
pump.await.unwrap();
assert!((watch_rx.borrow().vx - 1.0).abs() < 1e-6); // watch 保持最后值
assert_eq!(watch_rx.borrow().twist, command.twist);
}
}
}
pub use source::*;
pub mod remote {
//! DR16 遥控输入到安全底盘指令的映射。
//!
//! 左摇杆上下控制前后、左右控制旋转;右摇杆左右控制横移。摇杆先过死区,
//! 再做平方响应曲线。S1 上档是唯一的运动使能开关。
use std::future::pending;
use std::time::{Duration, Instant};
use chassis_config::RemoteConfig;
use dr16::{SampleStream, StampedSample};
use dr16_protocol::{Dr16Sample, SwitchPosition};
use mecanum::Twist;
use crate::{CommandSource, RemoteCommand};
/// DR16 摇杆满量程的协议值。
const FULL_SCALE: f32 = 660.0;
/// 遥控输入映射器。
#[derive(Debug, Clone, Copy)]
pub struct RemoteMapper {
deadband: f32,
max_vx: f32,
max_vy: f32,
max_omega: f32,
invert_vx: bool,
invert_vy: bool,
invert_omega: bool,
}
impl RemoteMapper {
/// 创建映射器;配置语义由 `chassis-config::Config::validate` 保证。
#[must_use]
pub const fn new(
config: &RemoteConfig,
max_forward: f32,
max_strafe: f32,
max_spin: f32,
) -> Self {
Self {
deadband: config.deadband,
max_vx: max_forward,
max_vy: max_strafe,
max_omega: max_spin,
invert_vx: config.invert_vx,
invert_vy: config.invert_vy,
invert_omega: config.invert_omega,
}
}
/// 将一个合法 DR16 样本映射为底盘意图。
#[must_use]
pub fn map(&self, sample: &Dr16Sample) -> RemoteCommand {
let vx = self.axis(sample.channel_t(), self.max_vx, self.invert_vx);
// ISO 坐标 y 向左为正,右摇杆向右应产生负 y。
let vy = -self.axis(sample.channel_a(), self.max_vy, self.invert_vy);
// ISO 坐标 omega 逆时针为正,左摇杆向右应产生顺时针负 omega。
let omega = -self.axis(sample.channel_r(), self.max_omega, self.invert_omega);
RemoteCommand {
twist: Twist { vx, vy, omega },
enabled: sample.s1 == SwitchPosition::Up,
}
}
fn axis(&self, raw: i16, maximum: f32, invert: bool) -> f32 {
let sign = if invert { -1.0 } else { 1.0 };
sign * self.shaped(raw) * maximum
}
fn shaped(&self, raw: i16) -> f32 {
let normalized = (f32::from(raw) / FULL_SCALE).clamp(-1.0, 1.0);
let magnitude = normalized.abs();
if magnitude <= self.deadband {
return 0.0;
}
let scaled = (magnitude - self.deadband) / (1.0 - self.deadband);
normalized.signum() * scaled * scaled
}
}
/// DR16 样本源:超时和串口关闭都会立即发布失能指令。
pub struct Dr16CommandSource {
samples: SampleStream,
mapper: RemoteMapper,
timeout: Duration,
last_received_at: Instant,
timed_out: bool,
closed: bool,
}
impl Dr16CommandSource {
/// 创建 DR16 指令源。
#[must_use]
pub fn new(samples: SampleStream, mapper: RemoteMapper, timeout: Duration) -> Self {
Self {
samples,
mapper,
timeout,
last_received_at: Instant::now(),
timed_out: false,
closed: false,
}
}
fn command(&mut self, stamped: StampedSample) -> RemoteCommand {
self.last_received_at = stamped.received_at;
self.timed_out = false;
self.mapper.map(&stamped.sample)
}
}
impl CommandSource for Dr16CommandSource {
async fn next_command(&mut self) -> Option<RemoteCommand> {
if self.closed {
return pending().await;
}
if self.timed_out {
return if let Some(stamped) = self.samples.recv().await {
Some(self.command(stamped))
} else {
self.closed = true;
Some(RemoteCommand::DISABLED)
};
}
let elapsed = Instant::now().saturating_duration_since(self.last_received_at);
let remaining = self.timeout.saturating_sub(elapsed);
match tokio::time::timeout(remaining, self.samples.recv()).await {
Ok(Some(stamped)) => Some(self.command(stamped)),
Ok(None) => {
self.closed = true;
Some(RemoteCommand::DISABLED)
}
Err(_) => {
self.timed_out = true;
Some(RemoteCommand::DISABLED)
}
}
}
}
#[cfg(test)]
mod tests {
use dr16::sample_channel;
use dr16_protocol::ExtendedInput;
use super::*;
fn sample(channels: [i16; 4], s1: SwitchPosition) -> Dr16Sample {
Dr16Sample { channels, s1, s2: SwitchPosition::Mid, extended: ExtendedInput::default() }
}
fn mapper() -> RemoteMapper {
RemoteMapper::new(&RemoteConfig::default(), 3.0, 2.0, 4.0)
}
#[test]
fn maps_left_stick_to_forward_and_rotation_and_right_stick_to_strafe() {
let command = mapper().map(&sample([660, 0, 660, 660], SwitchPosition::Up));
assert!(command.enabled);
assert!((command.twist.vx - 3.0).abs() < 1e-6);
assert!((command.twist.vy + 2.0).abs() < 1e-6);
assert!((command.twist.omega + 4.0).abs() < 1e-6);
}
#[test]
fn applies_deadband_square_curve_and_inversions() {
let config = RemoteConfig {
invert_vx: true,
invert_vy: true,
invert_omega: true,
..RemoteConfig::default()
};
let mapper = RemoteMapper::new(&config, 1.0, 1.0, 1.0);
assert!(mapper.map(&sample([0, 0, 33, 0], SwitchPosition::Up)).twist.vx.abs() < 1e-6);
let command = mapper.map(&sample([660, 0, 660, 660], SwitchPosition::Up));
assert!((command.twist.vx + 1.0).abs() < 1e-6);
assert!((command.twist.vy - 1.0).abs() < 1e-6);
assert!((command.twist.omega - 1.0).abs() < 1e-6);
}
#[test]
fn s1_must_be_up_and_bad_switch_is_disabled() {
for position in [SwitchPosition::Down, SwitchPosition::Mid] {
assert!(!mapper().map(&sample([660, 660, 660, 660], position)).enabled);
}
}
#[tokio::test(start_paused = true)]
async fn timeout_publishes_disabled_once_and_recovers() {
let (sender, samples) = sample_channel();
let mut source = Dr16CommandSource::new(samples, mapper(), Duration::from_millis(100));
let _ = sender.send(sample([660, 0, 0, 0], SwitchPosition::Up));
assert!(source.next_command().await.unwrap().enabled);
let disabled = source.next_command().await.unwrap();
assert!(!disabled.enabled);
assert_eq!(disabled.effective_twist(), Twist::ZERO);
let _ = sender.send(sample([0, 0, 0, 0], SwitchPosition::Up));
assert!(source.next_command().await.unwrap().enabled);
}
#[tokio::test(start_paused = true)]
async fn closed_stream_disables_immediately() {
let (sender, samples) = sample_channel();
drop(sender);
let mut source = Dr16CommandSource::new(samples, mapper(), Duration::from_millis(100));
let command = source.next_command().await.unwrap();
assert!(!command.enabled);
assert_eq!(command.effective_twist(), Twist::ZERO);
}
}
}
pub mod telemetry {
//! 诊断输出:控制周期抖动统计(ARCHITECTURE.md §7.2)。
//!
@@ -569,11 +791,52 @@ pub mod controller {
use tokio::time::{Instant, MissedTickBehavior, interval};
use tracing::{info, warn};
use crate::RemoteCommand;
/// 逐轴速度斜坡。
#[derive(Debug, Clone, Copy)]
struct TwistRamp {
current: Twist,
step_vx: f32,
step_vy: f32,
step_omega: f32,
}
impl TwistRamp {
fn new(limits: (f32, f32, f32), period: Duration, ramp: Duration) -> Self {
let ratio = (period.as_secs_f32() / ramp.as_secs_f32()).clamp(0.0, 1.0);
Self {
current: Twist::ZERO,
step_vx: limits.0 * ratio,
step_vy: limits.1 * ratio,
step_omega: limits.2 * ratio,
}
}
fn advance(&mut self, target: Twist) -> Twist {
self.current = Twist {
vx: approach(self.current.vx, target.vx, self.step_vx),
vy: approach(self.current.vy, target.vy, self.step_vy),
omega: approach(self.current.omega, target.omega, self.step_omega),
};
self.current
}
fn reset(&mut self) {
self.current = Twist::ZERO;
}
}
fn approach(current: f32, target: f32, maximum_step: f32) -> f32 {
let delta = target - current;
if delta.abs() <= maximum_step { target } else { current + delta.signum() * maximum_step }
}
/// 底盘控制器。
///
/// 职责(1kHz 节拍):
/// 1. 读取最新 twist 指令,**指令超时则归零**;
/// 2. 速度限幅(保持方向按比例收缩);
/// 1. 读取最新 [`RemoteCommand`],超时或失能时立即归零;
/// 2. 速度限幅(保持方向按比例收缩),再逐轴斜坡平滑;
/// 3. **上电门控**:四台电机都收到过反馈才下发速度,否则全部停用;
/// 4. 麦轮逆解 → 各轮目标转速(rpm,轮端)→ 经 [`OutputShaft`] 换算到转子。
///
@@ -585,6 +848,7 @@ pub mod controller {
/// (max_vx, max_vy, max_omega)。
limits: (f32, f32, f32),
command_timeout: Duration,
ramp_time: Duration,
}
impl<W> ChassisController<W>
@@ -598,19 +862,25 @@ pub mod controller {
wheels: [W; 4],
limits: (f32, f32, f32),
command_timeout: Duration,
ramp_time: Duration,
) -> Self {
Self { kinematics, wheels, limits, command_timeout }
Self { kinematics, wheels, limits, command_timeout, ramp_time }
}
/// 启动控制循环任务。
pub fn spawn(self, commands: watch::Receiver<Twist>, period: Duration) -> JoinHandle<()> {
pub fn spawn(
self,
commands: watch::Receiver<RemoteCommand>,
period: Duration,
) -> JoinHandle<()> {
tokio::spawn(self.run(commands, period))
}
async fn run(self, mut commands: watch::Receiver<Twist>, period: Duration) {
async fn run(self, mut commands: watch::Receiver<RemoteCommand>, period: Duration) {
let mut ticker = interval(period);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut latest = Twist::ZERO;
let mut ramp = TwistRamp::new(self.limits, period, self.ramp_time);
let mut latest = RemoteCommand::DISABLED;
let mut last_command_at: Option<Instant> = None;
let mut ticks: u64 = 0;
@@ -627,13 +897,21 @@ pub mod controller {
// 尚未收到任何指令:保持静止。
None => true,
};
let twist = if timed_out {
let all_seen_feedback = self.wheels.iter().all(|m| m.state().has_feedback());
let stop = timed_out || !latest.enabled || !all_seen_feedback;
let twist = if stop {
ramp.reset();
Twist::ZERO
} else {
latest.clamped(self.limits.0, self.limits.1, self.limits.2)
let target = latest.effective_twist().clamped(
self.limits.0,
self.limits.1,
self.limits.2,
);
ramp.advance(target)
};
let all_seen_feedback = self.wheels.iter().all(|m| m.state().has_feedback());
if all_seen_feedback {
let wheel_rpms = self.kinematics.wheel_rpms(&twist);
for (motor, wheel_rpm) in self.wheels.iter().zip(wheel_rpms) {
@@ -696,6 +974,46 @@ pub mod controller {
m
}
#[test]
fn ramp_reaches_full_scale_in_requested_time_and_resets() {
let mut ramp = TwistRamp::new(
(2.0, 2.0, 2.0),
Duration::from_millis(1),
Duration::from_millis(500),
);
let target = Twist { vx: 2.0, ..Twist::ZERO };
for _ in 0..250 {
ramp.advance(target);
}
assert!((ramp.current.vx - 1.0).abs() < 1e-5);
for _ in 0..250 {
ramp.advance(target);
}
assert!((ramp.current.vx - target.vx).abs() < 1e-4);
ramp.reset();
assert_eq!(ramp.current, Twist::ZERO);
}
#[test]
fn ramp_reverses_through_zero_without_overshoot() {
let mut ramp = TwistRamp::new(
(1.0, 1.0, 1.0),
Duration::from_millis(1),
Duration::from_millis(10),
);
for _ in 0..10 {
ramp.advance(Twist { vy: 1.0, ..Twist::ZERO });
}
let mut crossed_zero = false;
for _ in 0..20 {
let output = ramp.advance(Twist { vy: -1.0, ..Twist::ZERO });
assert!(output.vy <= 1.0 + 1e-6 && output.vy >= -1.0 - 1e-6);
crossed_zero |= output.vy.abs() < 1e-6;
}
assert!(crossed_zero);
assert!((ramp.current.vy + 1.0).abs() < 1e-5);
}
/// 证明 `ChassisController` 只依赖能力 trait:换 `MockMotor` 原样工作。
#[tokio::test(start_paused = true)]
async fn controller_drives_wheels_through_capability_traits() {
@@ -706,12 +1024,13 @@ pub mod controller {
wrap_wheels(mocks.clone(), [GEAR; 4]),
(3.0, 3.0, 6.0),
Duration::from_millis(200),
Duration::from_millis(1),
);
let (tx, rx) = watch::channel(Twist::ZERO);
let (tx, rx) = watch::channel(RemoteCommand::DISABLED);
let handle = ctrl.spawn(rx, Duration::from_millis(1));
let twist = Twist { vx: 1.0, ..Twist::ZERO };
tx.send_replace(twist);
tx.send_replace(RemoteCommand::new(twist));
tokio::time::sleep(Duration::from_millis(5)).await;
let expected = kinematics.wheel_rpms(&twist);
@@ -721,7 +1040,7 @@ pub mod controller {
let want = expected[i] * GEAR;
assert!(
(rotor_rpm - want).abs() < 1e-2,
"wheel {i}: rotor_rpm {rotor_rpm} != {want}"
"wheel {i}: {rotor_rpm} != {want}"
);
}
ref cmd => panic!("wheel {i} expected Velocity, got {cmd:?}"),
@@ -736,9 +1055,7 @@ pub mod controller {
async fn disables_wheels_until_all_feedback_seen() {
let kinematics = Mecanum::new(0.2, 0.25, 0.0762);
let ready = mock_wheel(1);
let missing = MockMotor::new(2); // 无反馈
let mocks_ready = [ready.clone(), mock_wheel(3), mock_wheel(4)];
let _ = mocks_ready;
let missing = MockMotor::new(2);
let wheels = wrap_wheels(
[ready.clone(), missing.clone(), mock_wheel(3), mock_wheel(4)],
[GEAR; 4],
@@ -748,11 +1065,12 @@ pub mod controller {
wheels,
(3.0, 3.0, 6.0),
Duration::from_millis(200),
Duration::from_millis(500),
);
let (tx, rx) = watch::channel(Twist::ZERO);
let (tx, rx) = watch::channel(RemoteCommand::DISABLED);
let handle = ctrl.spawn(rx, Duration::from_millis(1));
tx.send_replace(Twist { vx: 1.0, ..Twist::ZERO });
tx.send_replace(RemoteCommand::new(Twist { vx: 1.0, ..Twist::ZERO }));
tokio::time::sleep(Duration::from_millis(5)).await;
assert_eq!(ready.last_command(), Command::Disabled);
@@ -760,6 +1078,29 @@ pub mod controller {
handle.abort();
}
#[tokio::test(start_paused = true)]
async fn disabled_command_stops_immediately_without_ramping() {
let kinematics = Mecanum::new(0.2, 0.25, 0.0762);
let mocks = [mock_wheel(1), mock_wheel(2), mock_wheel(3), mock_wheel(4)];
let ctrl = ChassisController::new(
kinematics,
wrap_wheels(mocks.clone(), [GEAR; 4]),
(3.0, 3.0, 6.0),
Duration::from_millis(200),
Duration::from_millis(500),
);
let (tx, rx) = watch::channel(RemoteCommand::DISABLED);
let handle = ctrl.spawn(rx, Duration::from_millis(1));
tx.send_replace(RemoteCommand::new(Twist { vx: 3.0, ..Twist::ZERO }));
tokio::time::sleep(Duration::from_millis(200)).await;
tx.send_replace(RemoteCommand { enabled: false, ..RemoteCommand::STOP });
tokio::time::sleep(Duration::from_millis(2)).await;
for mock in mocks {
assert_eq!(mock.last_command(), Command::Velocity(0.0));
}
handle.abort();
}
}
}
@@ -771,11 +1112,11 @@ use std::process::ExitCode;
use std::time::Duration;
use clap::Parser;
use mecanum::Twist;
use tokio::sync::watch;
use tracing::{error, info};
use tracing_subscriber::EnvFilter;
use crate::remote::{Dr16CommandSource, RemoteMapper};
use crate::{ChassisController, wrap_wheels};
use can::CanLink;
use chassis_config::Config;
@@ -802,6 +1143,10 @@ struct Args {
/// 只装配配置、链路、总线和控制任务,短跑后安全退出。
#[arg(long)]
dry_run: bool,
/// 覆盖配置文件中的 DR16 串口设备(如 `/dev/ttyS2`)。
#[arg(long)]
rc_device: Option<String>,
}
fn main() -> ExitCode {
@@ -811,13 +1156,18 @@ fn main() -> ExitCode {
let args = Args::parse();
let config = match load_config(&args) {
let mut config = match load_config(&args) {
Ok(config) => config,
Err(e) => {
error!("{e}");
return ExitCode::FAILURE;
}
};
if let Some(device) = args.rc_device.clone() {
let mut remote = config.remote.take().unwrap_or_default();
remote.device = device;
config.remote = Some(remote);
}
let interface = args.interface.clone().unwrap_or_else(|| config.can.interface.clone());
let runtime = match tokio::runtime::Builder::new_multi_thread().enable_all().build() {
@@ -856,6 +1206,25 @@ async fn run(
dry_run: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let bus_options = config.bus_options();
let limits = (config.chassis.max_vx, config.chassis.max_vy, config.chassis.max_omega);
let remote_source = if mock {
info!("mock 模式不打开 DR16 串口,遥控输入保持失能");
None
} else {
match config.remote.clone() {
Some(remote_config) => {
let samples = dr16::open(&remote_config.device)?;
let mapper = RemoteMapper::new(&remote_config, limits.0, limits.1, limits.2);
let source = Dr16CommandSource::new(
samples,
mapper,
Duration::from_millis(remote_config.timeout_ms),
);
Some((source, remote_config.ramp_ms))
}
None => None,
}
};
// 1) CAN 链路:仿真或真实 SocketCAN。
let link = if mock {
@@ -884,10 +1253,8 @@ async fn run(
"chassisd 启动"
);
// 3) 控制循环(当前无指令源:watch 保持 ZERO,安全静止;
// 遥控 / 自主导航后续经 chassis_config::source 接入)。
let (twist_tx, twist_rx) = watch::channel(Twist::ZERO);
let limits = (config.chassis.max_vx, config.chassis.max_vy, config.chassis.max_omega);
// 3) 控制循环与遥控指令源。
let (command_tx, command_rx) = watch::channel(crate::RemoteCommand::DISABLED);
let motors: [EscMotor; 4] =
bus.motors().to_vec().try_into().unwrap_or_else(|_| unreachable!("exactly 4 motors"));
let gears: [f32; 4] = config
@@ -902,11 +1269,16 @@ async fn run(
wrap_wheels(motors, gears),
limits,
Duration::from_millis(config.chassis.command_timeout_ms),
Duration::from_millis(remote_source.as_ref().map_or(500, |(_, ramp_ms)| *ramp_ms)),
)
.spawn(twist_rx, period);
let _twist_tx = twist_tx; // 保留发送端供后续指令源使用
.spawn(command_rx, period);
let remote_task = remote_source
.map(|(source, _)| tokio::spawn(crate::source::pump_source(source, command_tx.clone())));
if dry_run {
if let Some(task) = remote_task {
task.abort();
}
tokio::time::sleep(Duration::from_millis(20)).await;
for motor in bus.motors() {
motor.disable();
@@ -927,6 +1299,9 @@ async fn run(
}
// 5) 停车:显式停用电机并等若干节拍把 0 电流帧刷入总线。
if let Some(task) = remote_task {
task.abort();
}
for motor in bus.motors() {
motor.disable();
}
@@ -953,6 +1328,7 @@ fn open_real_link(interface: &str) -> Result<CanLink, Box<dyn std::error::Error>
mod daemon_integration {
use super::*;
use esc_bus::EscBusHandle;
use mecanum::Twist;
use motor::MotorFeedback;
use sim_harness::spawn_plant;
use tokio::time::sleep;
@@ -964,7 +1340,7 @@ mod daemon_integration {
fn stack(
config: &Config,
command_timeout_ms: u64,
) -> (EscBusHandle<MotorPolicy>, watch::Sender<Twist>) {
) -> (EscBusHandle<MotorPolicy>, watch::Sender<RemoteCommand>) {
let (bus_side, plant_side) = CanLink::pair(512);
let esc_ids: Vec<u8> = config.motors.iter().map(|m| m.esc_id).collect();
spawn_plant(plant_side, &esc_ids, config.bus_options().period);
@@ -976,7 +1352,7 @@ mod daemon_integration {
let bus =
EscBus::spawn(bus_side, config.to_esc_configs(), config.bus_options(), policy).unwrap();
let (twist_tx, twist_rx) = watch::channel(Twist::ZERO);
let (twist_tx, twist_rx) = watch::channel(RemoteCommand::DISABLED);
let motors: [EscMotor; 4] =
bus.motors().to_vec().try_into().unwrap_or_else(|_| unreachable!("exactly 4 motors"));
let gears: [f32; 4] = config
@@ -991,6 +1367,7 @@ mod daemon_integration {
wrap_wheels(motors, gears),
(config.chassis.max_vx, config.chassis.max_vy, config.chassis.max_omega),
Duration::from_millis(command_timeout_ms),
Duration::from_millis(2),
);
controller.spawn(twist_rx, config.bus_options().period);
(bus, twist_tx)
@@ -1025,7 +1402,7 @@ mod daemon_integration {
let config = Config::mock_default();
let (bus, twist_tx) = stack(&config, 10_000);
twist_tx.send_replace(Twist { vx: 1.0, ..Twist::ZERO });
twist_tx.send_replace(RemoteCommand::new(Twist { vx: 1.0, ..Twist::ZERO }));
let target = RPM_PER_MPS;
let ok = wait_until(3000, || {
wheel_rpms(&bus).iter().all(|&rpm| (rpm - target).abs() < 0.05 * target)
@@ -1035,7 +1412,7 @@ mod daemon_integration {
assert!(ok, "未收敛到 {target} rpm: {rpm:?}");
// 停车:指令归零后各轮减速到停。
twist_tx.send_replace(Twist::ZERO);
twist_tx.send_replace(RemoteCommand::STOP);
let stopped =
wait_until(2000, || wheel_rpms(&bus).iter().all(|rpm| rpm.abs() < 15.0)).await;
let rpm = wheel_rpms(&bus);
@@ -1048,7 +1425,7 @@ mod daemon_integration {
let config = Config::mock_default();
let (bus, twist_tx) = stack(&config, 10_000);
twist_tx.send_replace(Twist { omega: 1.0, ..Twist::ZERO });
twist_tx.send_replace(RemoteCommand::new(Twist { omega: 1.0, ..Twist::ZERO }));
// a·ω = (0.2 + 0.25) m/s 的轮线速度。
let target = 0.45 * RPM_PER_MPS;
let ok = wait_until(3000, || {
@@ -1076,7 +1453,7 @@ mod daemon_integration {
let (bus, twist_tx) = stack(&config, config.chassis.command_timeout_ms);
// 只发一次指令,随后沉默 → 超过 command_timeout(200ms)后应停车。
twist_tx.send_replace(Twist { vx: 1.0, ..Twist::ZERO });
twist_tx.send_replace(RemoteCommand::new(Twist { vx: 1.0, ..Twist::ZERO }));
let moving =
wait_until(1000, || wheel_rpms(&bus).iter().any(|rpm| rpm.abs() > 0.2 * RPM_PER_MPS))
.await;
@@ -1096,11 +1473,11 @@ mod daemon_integration {
config.chassis.max_vx = 1.0; // 限速 1 m/s
let (bus, twist_tx) = stack(&config, 10_000);
twist_tx.send_replace(Twist {
twist_tx.send_replace(RemoteCommand::new(Twist {
vx: 3.0, // 超限指令
vy: 2.0,
omega: 0.0,
});
}));
// 限幅按比例收缩:(3, 2) → (1, 2/3)。四轮 = vx ∓ vy:
// FL/RR = (1 - 2/3) m/s,FR/RL = (1 + 2/3) m/s。
let diag_slow = (1.0 - 2.0 / 3.0) * RPM_PER_MPS;
@@ -16,6 +16,8 @@ esc-bus = { workspace = true }
experiment = { workspace = true }
motor-control = { workspace = true }
mecanum = { workspace = true }
dr16-protocol = { workspace = true }
dr16 = { workspace = true }
slcan = { workspace = true }
socketcan = { workspace = true }
tokio = { workspace = true }
@@ -25,3 +27,6 @@ tracing-subscriber = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
[package.metadata.sentry-chassis]
layer = "application/tools"
@@ -574,6 +574,93 @@ mod chassis {
}
}
mod rc_monitor {
//! 只读 DR16 遥控监视器:不打开 CAN,不驱动任何电机。
use std::process::ExitCode;
use std::time::{Duration, Instant};
use clap::Parser;
use dr16::open;
use dr16_protocol::SwitchPosition;
#[derive(Debug, Parser)]
#[command(name = "spin-rc", version, about = "只读监视 DR16/D-BUS 遥控输入")]
struct Args {
/// DR16 D-BUS UART 设备。
#[arg(long, default_value = "/dev/ttyS2")]
device: String,
/// 显示打印周期(毫秒)。
#[arg(long, default_value_t = 100)]
report_ms: u64,
/// 映射显示用死区。
#[arg(long, default_value_t = 0.05)]
deadband: f32,
}
fn shaped(raw: i16, deadband: f32) -> f32 {
let normalized = (f32::from(raw) / 660.0).clamp(-1.0, 1.0);
let magnitude = normalized.abs();
if magnitude <= deadband {
return 0.0;
}
normalized.signum() * ((magnitude - deadband) / (1.0 - deadband)).powi(2)
}
pub async fn main_with_args(parameters: Vec<String>) -> ExitCode {
let args = Args::parse_from(std::iter::once(String::from("spin-rc")).chain(parameters));
let stream = match open(&args.device) {
Ok(stream) => stream,
Err(error) => {
eprintln!("✗ 打开 DR16 串口 {} 失败:{error}", args.device);
return ExitCode::FAILURE;
}
};
println!("=== DR16/D-BUS 只读监视({})===", args.device);
println!("不接触 CAN / 电机;ctrl-c 退出。满量程映射:vx=3.0 vy=3.0 omega=6.0(仅显示)\n");
let mut report = tokio::time::interval(Duration::from_millis(args.report_ms.max(1)));
report.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
println!("\n已退出");
return ExitCode::SUCCESS;
}
_ = report.tick() => {
let Some(stamped) = stream.latest() else {
println!("等待 DR16 帧……");
continue;
};
let sample = stamped.sample;
let age = Instant::now().saturating_duration_since(stamped.received_at);
let vx = shaped(sample.channel_t(), args.deadband) * 3.0;
let vy = -shaped(sample.channel_a(), args.deadband) * 3.0;
let omega = -shaped(sample.channel_r(), args.deadband) * 6.0;
println!(
"age {:>4}ms | A {:>4} E {:>4} T {:>4} R {:>4} | S1 {:?}({}) S2 {:?}({}) | vx {:+.2} vy {:+.2} omega {:+.2}",
age.as_millis(),
sample.channel_a(),
sample.channel_e(),
sample.channel_t(),
sample.channel_r(),
sample.s1,
sample.s1.wire_value(),
sample.s2,
sample.s2.wire_value(),
vx,
vy,
omega,
);
if sample.s1 != SwitchPosition::Up {
println!(" S1 非上档:底盘使能关闭");
}
}
}
}
}
}
mod motor_one {
//! 单电机验证工具:让一个 C620 / GM3508 转起来,验证电机抽象全链路。
//!
@@ -710,12 +797,13 @@ async fn main() -> std::process::ExitCode {
motor_one::main_with_args(args.collect()).await;
std::process::ExitCode::SUCCESS
}
Some("rc") => rc_monitor::main_with_args(args.collect()).await,
Some("--help" | "-h") => {
println!("usage: spin <chassis|motor> [options]");
println!("usage: spin <chassis|motor|rc> [options]");
std::process::ExitCode::SUCCESS
}
_ => {
eprintln!("usage: spin <chassis|motor> [options]");
eprintln!("usage: spin <chassis|motor|rc> [options]");
std::process::ExitCode::from(2)
}
}
@@ -45,3 +45,6 @@ plotters = { workspace = true, optional = true }
[dev-dependencies]
approx = { workspace = true }
[package.metadata.sentry-chassis]
layer = "application/tuning"
@@ -122,7 +122,7 @@ pub mod report {
#[cfg(feature = "plot")]
if let Some(trace) = &ctx.best_trace {
let svg_path = ctx.out_dir.join("best.svg");
crate::tune::plot::write_step_svg(&svg_path, trace, &ctx.scenario)?;
crate::plot::write_step_svg(&svg_path, trace, &ctx.scenario)?;
written.push(svg_path);
}
@@ -932,14 +932,15 @@ use esc_bus::EscConfig;
use experiment::SetpointProfile;
use indicatif::ProgressBar;
use motor_control::VelocityGains;
use motor_model::Gm3508Model;
use motor_protocol::EscModel;
use serde::Serialize;
use sim_harness::{LoopLimits, PlantEnsemble};
use tuning::{
AcceptanceSpec, BackendSpec, BenchEnvelope, BenchSpec, CostSpec, DEFAULT_PRESET_PATH,
Evaluator, Objective, PlantSpec, Preset, PresetFile, ProfileSpec, RangeSpec, Ranked, ScaleSpec,
ScenarioSpec, Score, SearchSpec, SimSpec, SolverSpec, SpaceSpec, Tuning, Verdict, parse_axis,
sweep,
Evaluator, Objective, PlantProfile, Preset, PresetFile, ProfileSpec, RangeSpec, Ranked,
ScaleSpec, ScenarioSpec, Score, SearchSpec, SimSpec, SolverSpec, SpaceSpec, Tuning, Verdict,
parse_axis, sweep,
};
/// 子命令。
@@ -1121,7 +1122,7 @@ struct CommonArgs {
/// 仿真后端参数(覆盖预设)。
#[derive(Debug, Clone, ClapArgs)]
struct SimArgs {
/// 被控对象模型(内置默认 `gm3508-bench`)
/// 被控对象模型(内置默认 `bench`)
#[arg(long, value_enum)]
plant: Option<PlantArg>,
/// 集成成员数(域随机化;1 = 只用名义模型;内置默认 8)
@@ -1268,16 +1269,16 @@ impl From<SolverArg> for SolverSpec {
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum PlantArg {
/// 理想一阶模型(无摩擦、无延迟)
Gm3508,
Ideal,
/// 台架量级:静 / 库仑摩擦 + 2 拍延迟
Gm3508Bench,
Bench,
}
impl From<PlantArg> for PlantSpec {
impl From<PlantArg> for PlantProfile {
fn from(value: PlantArg) -> Self {
match value {
PlantArg::Gm3508 => Self::Gm3508,
PlantArg::Gm3508Bench => Self::Gm3508Bench,
PlantArg::Ideal => Self::Ideal,
PlantArg::Bench => Self::Bench,
}
}
}
@@ -1411,7 +1412,9 @@ impl CommonArgs {
let source =
if prefix.is_empty() { "内置默认".to_owned() } else { format!("{prefix}{name}") };
let tuning = merged.to_tuning().with_context(|| format!("解算预设 '{source}' 失败"))?;
let tuning = merged
.to_tuning::<Gm3508Model>()
.with_context(|| format!("解算预设 '{source}' 失败"))?;
let envelope = merged
.bench_envelope()
.with_context(|| format!("解算预设 '{source}' 的安全包络失败"))?;
@@ -1922,7 +1925,8 @@ fn presets_cmd(args: &PresetsArgs) -> Result<ExitCode> {
println!("共 {} 个预设", file.preset.len());
for (name, preset) in &file.preset {
let merged = Preset::builtin().overlay(preset);
let tuning = merged.to_tuning().with_context(|| format!("解算预设 '{name}' 失败"))?;
let tuning =
merged.to_tuning::<Gm3508Model>().with_context(|| format!("解算预设 '{name}' 失败"))?;
let envelope =
merged.bench_envelope().with_context(|| format!("解算预设 '{name}' 的安全包络失败"))?;
let mark =
@@ -2369,7 +2373,6 @@ fn open_bench(
let mut esc = EscConfig::gm3508(hardware.name.clone(), hardware.esc_id, hardware.direction);
esc.gear_ratio = hardware.gear_ratio;
esc.current_limit = limits.current_limit_raw;
esc.model = limits.model;
let envelope = SafetyEnvelope::new()
.with_overspeed(envelope_spec.max_rpm, Duration::from_millis(60))
@@ -2452,7 +2455,14 @@ feedforward = 0.0
/// 仓库里随代码一起评审的预设文件。
fn repo_preset_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../config/tune.toml")
let mut directory = Path::new(env!("CARGO_MANIFEST_DIR"));
loop {
let candidate = directory.join("config/tune.toml");
if candidate.exists() {
return candidate;
}
directory = directory.parent().expect("workspace root must exist");
}
}
fn args_with(extra: &[&str]) -> CommonArgs {
@@ -2573,7 +2583,7 @@ feedforward = 0.0
assert_eq!(tuning.search.seed, 20_260_101);
assert!((tuning.scenario.target_rpm - 1500.0).abs() < 1e-6);
assert!((tuning.acceptance.max_overshoot_pct - 20.0).abs() < 1e-6);
assert_eq!(tuning.plant_spec, PlantSpec::Gm3508Bench);
assert_eq!(tuning.plant_spec, PlantProfile::Bench);
assert_eq!(settings.envelope.validate_runs, 3);
}
@@ -2722,7 +2732,7 @@ samples = 99
for (name, preset) in &file.preset {
let merged = Preset::builtin().overlay(preset);
let tuning = merged
.to_tuning()
.to_tuning::<Gm3508Model>()
.unwrap_or_else(|error| panic!("预设 '{name}' 无法解算:{error}"));
merged
.bench_envelope()
@@ -10,6 +10,7 @@ license.workspace = true
workspace = true
[dependencies]
motor = { workspace = true }
motor-control = { workspace = true }
mecanum = { workspace = true }
esc-bus = { workspace = true }
@@ -20,3 +21,6 @@ thiserror = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
[package.metadata.sentry-chassis]
layer = "config/chassis"
@@ -12,6 +12,7 @@ use std::time::Duration;
use esc_bus::{BusOptions, EscConfig};
use mecanum::Mecanum;
use motor::Gm3508;
use motor_control::VelocityGains;
use motor_protocol::EscModel;
use serde::{Deserialize, Serialize};
@@ -37,6 +38,9 @@ pub struct Config {
/// 底盘控制参数。
#[serde(default)]
pub chassis: ChassisConfig,
/// 可选遥控输入;缺省时守护进程保持安全静止。
#[serde(default)]
pub remote: Option<RemoteConfig>,
/// 麦轮几何。
pub mecanum: MecanumConfig,
/// 四电机配置,顺序固定:前左、前右、后左、后右。
@@ -128,6 +132,69 @@ impl Default for ChassisConfig {
}
}
/// 遥控输入类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum RemoteKind {
/// DJI DR16 接收机的 D-BUS 串口。
Dr16,
}
/// 遥控输入配置。
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RemoteConfig {
/// 接收机协议。
pub kind: RemoteKind,
/// T153 UART 设备节点。
pub device: String,
/// 合法遥控帧断流超时(毫秒)。
#[serde(default = "default_remote_timeout")]
pub timeout_ms: u64,
/// 摇杆死区,按满量程比例计。
#[serde(default = "default_remote_deadband")]
pub deadband: f32,
/// 从零到满量程的速度斜坡时间(毫秒)。
#[serde(default = "default_remote_ramp")]
pub ramp_ms: u64,
/// 反转前后方向。
#[serde(default)]
pub invert_vx: bool,
/// 反转左右方向。
#[serde(default)]
pub invert_vy: bool,
/// 反转旋转方向。
#[serde(default)]
pub invert_omega: bool,
}
impl Default for RemoteConfig {
fn default() -> Self {
Self {
kind: RemoteKind::Dr16,
device: "/dev/ttyS2".into(),
timeout_ms: default_remote_timeout(),
deadband: default_remote_deadband(),
ramp_ms: default_remote_ramp(),
invert_vx: false,
invert_vy: false,
invert_omega: false,
}
}
}
fn default_remote_timeout() -> u64 {
100
}
fn default_remote_deadband() -> f32 {
0.05
}
fn default_remote_ramp() -> u64 {
500
}
/// 麦轮几何(米)。
#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
@@ -341,6 +408,23 @@ impl Config {
if !(c.max_vx > 0.0 && c.max_vy > 0.0 && c.max_omega > 0.0) {
return Err(ConfigError::Invalid("speed limits must be > 0".into()));
}
if let Some(remote) = &self.remote {
if remote.device.trim().is_empty() {
return Err(ConfigError::Invalid("remote.device must not be empty".into()));
}
if remote.timeout_ms == 0 || remote.ramp_ms == 0 {
return Err(ConfigError::Invalid("remote timeout/ramp must be > 0 ms".into()));
}
if !remote.deadband.is_finite() || !(0.0..0.5).contains(&remote.deadband) {
return Err(ConfigError::Invalid("remote.deadband must be in [0, 0.5)".into()));
}
if remote.timeout_ms > c.command_timeout_ms {
return Err(ConfigError::Invalid(format!(
"remote.timeout_ms ({}) must be <= command_timeout_ms ({})",
remote.timeout_ms, c.command_timeout_ms
)));
}
}
for motor in &self.motors {
let gear_ratio_valid = motor.gear_ratio > 0.0;
if !gear_ratio_valid {
@@ -390,6 +474,7 @@ impl Config {
Self {
can: CanConfig { interface: "vcan0".into(), function: None },
chassis: ChassisConfig::default(),
remote: None,
mecanum: MecanumConfig { half_length: 0.2, half_width: 0.25, wheel_radius: 0.0762 },
motors: vec![
motor("front_left", 1, 1.0),
@@ -402,16 +487,14 @@ impl Config {
/// 转换为 `EscBus` 配置(顺序 FL、FR、RL、RR)。
#[must_use]
pub fn to_esc_configs(&self) -> Vec<EscConfig> {
pub fn to_esc_configs(&self) -> Vec<EscConfig<Gm3508>> {
self.motors
.iter()
.map(|m| EscConfig {
name: m.name.clone(),
esc_id: m.esc_id,
model: m.model.into(),
gear_ratio: m.gear_ratio,
direction: m.direction,
current_limit: m.current_limit,
.map(|m| {
let mut cfg = EscConfig::<Gm3508>::gm3508(m.name.clone(), m.esc_id, m.direction);
cfg.gear_ratio = m.gear_ratio;
cfg.current_limit = m.current_limit;
cfg
})
.collect()
}
@@ -441,6 +524,16 @@ mod tests {
[can]
interface = "can0"
[remote]
kind = "dr16"
device = "/dev/ttyS2"
timeout_ms = 100
deadband = 0.05
ramp_ms = 500
invert_vx = false
invert_vy = false
invert_omega = false
[chassis]
control_period_ms = 1
command_timeout_ms = 200
@@ -497,6 +590,10 @@ feedforward = 9.0
fn parses_example() {
let cfg = Config::from_toml(EXAMPLE).unwrap();
assert_eq!(cfg.can.interface, "can0");
let remote = cfg.remote.as_ref().unwrap();
assert_eq!(remote.kind, RemoteKind::Dr16);
assert_eq!(remote.device, "/dev/ttyS2");
assert_eq!(remote.timeout_ms, 100);
assert_eq!(cfg.motors.len(), 4);
assert_eq!(cfg.motors[0].name, "front_left");
assert_eq!(cfg.motors[3].esc_id, 4);
@@ -536,6 +633,7 @@ gear_ratio = 19.2
)
.unwrap();
assert_eq!(cfg.chassis, ChassisConfig::default());
assert!(cfg.remote.is_none());
assert_eq!(cfg.motors[0].direction.to_bits(), 1.0f32.to_bits());
assert_eq!(cfg.motors[0].current_limit, 12_000);
assert_eq!(cfg.motors[0].model, EscModelSerde::C620);
@@ -611,6 +709,7 @@ gear_ratio = 19.2
let reparsed = Config::from_toml(&text).unwrap();
assert_eq!(reparsed, original);
// 落盘文本必须仍然是合法配置(守护进程直接读它)。
assert!(text.contains("[remote]"), "{text}");
assert!(text.contains("[[motor]]"), "{text}");
assert!(text.contains("interface = \"can0\""), "{text}");
}
@@ -620,9 +719,11 @@ gear_ratio = 19.2
let mut original = Config::mock_default();
original.can.function = Some(FunctionBusConfig { interface: "can1".into() });
original.chassis.motor_command_timeout_ms = 37;
original.remote = Some(RemoteConfig { invert_omega: true, ..RemoteConfig::default() });
let reparsed = Config::from_toml(&original.to_toml_string().unwrap()).unwrap();
assert_eq!(reparsed, original);
assert_eq!(reparsed.chassis.motor_command_timeout_ms, 37);
assert!(reparsed.remote.unwrap().invert_omega);
assert_eq!(reparsed.can.function.unwrap().interface, "can1");
}
@@ -666,6 +767,19 @@ gear_ratio = 19.2
));
}
#[test]
fn remote_validation_rejects_unsafe_values() {
let mut cfg = Config::mock_default();
cfg.remote = Some(RemoteConfig { timeout_ms: 0, ..RemoteConfig::default() });
assert!(matches!(cfg.validate(), Err(ConfigError::Invalid(_))));
cfg.remote = Some(RemoteConfig { deadband: 0.5, ..RemoteConfig::default() });
assert!(matches!(cfg.validate(), Err(ConfigError::Invalid(_))));
cfg.remote = Some(RemoteConfig { timeout_ms: 201, ..RemoteConfig::default() });
assert!(matches!(cfg.validate(), Err(ConfigError::Invalid(_))));
}
#[test]
fn save_and_load_file_round_trip() {
let dir = std::env::temp_dir().join("chassis-config-roundtrip");
@@ -17,3 +17,6 @@ tokio = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
[package.metadata.sentry-chassis]
layer = "contract/can"
@@ -11,3 +11,6 @@ workspace = true
[dependencies]
[package.metadata.sentry-chassis]
layer = "contract/imu"
@@ -14,3 +14,6 @@ workspace = true
[dev-dependencies]
approx = { workspace = true }
[package.metadata.sentry-chassis]
layer = "control/algorithm"
@@ -15,3 +15,6 @@ workspace = true
[dev-dependencies]
proptest = { workspace = true }
approx = { workspace = true }
[package.metadata.sentry-chassis]
layer = "control/chassis"
@@ -15,3 +15,6 @@ motor = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
[package.metadata.sentry-chassis]
layer = "control/motor"
@@ -18,9 +18,8 @@ thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
sim-harness = { workspace = true }
motor-control = { workspace = true }
motor-model = { workspace = true }
experiment = { workspace = true }
tokio = { workspace = true, features = ["test-util"] }
approx = { workspace = true }
[package.metadata.sentry-chassis]
layer = "device/esc"
@@ -1,132 +1,96 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! `EscBus` 异步驱动:协议状态机与 1kHz 控制节拍。
//!
//! [`EscBus::spawn`] 消费一个 [`CanLink`],启动收发两个任务:
//!
//! - 接收:解码反馈帧,更新各电机共享状态(含多圈累计与方向修正);
//! - 发送:按 `period` 节拍为每台电机求值当前命令(速度/位置环 PID / 直发
//! effort),按 `0x200`/`0x1FF` 分组组帧发送。
//!
//! 电机离线(从未收到反馈或反馈超时)时强制 0 输出并复位控制器。
//! 命令模式切换时同样复位控制器,避免积分冲击。
//! 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_protocol::common::{
EscFeedback, EscModel, clamp_current, command_frame_id, decode_feedback, encode_current_frame,
use motor::{
Command, CurrentEscDrive, Disable, EffortDrive, EffortPolicy, MotorFeedback, MotorSpec,
MotorState, PolicyInput, PolicySample, PositionDrive, VelocityDrive,
};
use motor_protocol::{COMMAND_FRAME_ID_PRIMARY, COMMAND_FRAME_ID_SECONDARY, TurnTracker};
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};
use motor::{
Command, Disable, EffortDrive, EffortPolicy, MotorFeedback, MotorState, PolicyInput,
PolicySample, PositionDrive, VelocityDrive,
};
/// 锁辅助:中毒锁直接恢复内部数据(锁中毒只意味着持锁线程恐慌,
/// 状态本身仍一致,驱动机不应因此停摆)。
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 {
/// 便于日志辨认的名字(如 `front_left`)。
pub struct EscConfig<S: MotorSpec = motor::Gm3508> {
pub name: String,
/// 电调 ID(1–8,即 CAN 拨码)。
pub esc_id: u8,
/// 电调型号。
pub model: EscModel,
/// 减速比(电机转子 : 输出轴)。
///
/// 驱动层**不使用**此字段;由上层 `crate::OutputShaft` 应用。
/// 保留于此作为配置元数据,`chassisd` 包装轮子时必须读取本字段(单一来源)。
pub gear_ratio: f32,
/// 安装方向:`+1.0` 或 `-1.0`。反装电机的读写符号在此统一翻转,
/// 上层永远以"输出轴正转 = 前进方向"工作。
pub direction: f32,
/// effort 限幅(raw,`1..=型号满量程`)。
pub current_limit: i16,
marker: PhantomData<S>,
}
impl EscConfig {
/// GM3508 + C620 的常用配置。`kp/ki/ff` 为起步值,需按整车整定。
impl<S: MotorSpec> EscConfig<S> {
#[must_use]
pub fn gm3508(name: impl Into<String>, esc_id: u8, direction: f32) -> Self {
pub fn new(name: impl Into<String>, esc_id: u8, direction: f32) -> Self {
Self {
name: name.into(),
esc_id,
model: EscModel::C620,
// 3591 / 187 ≈ 19.203
gear_ratio: 3591.0 / 187.0,
gear_ratio: S::GEAR_RATIO,
direction,
current_limit: 12_000,
}
}
/// M2006 + C610(拨弹轮)的常用配置。
///
/// 减速比按 P36 起步(36.0),接硬件前需核对;电流标度见 [`EscModel::C610`]。
#[must_use]
pub fn m2006(name: impl Into<String>, esc_id: u8, direction: f32) -> Self {
Self {
name: name.into(),
esc_id,
model: EscModel::C610,
// M2006 P36 减速比(接入前核对)
gear_ratio: 36.0,
direction,
current_limit: 8_000,
current_limit: S::scale().max_raw() / 4 * 3,
marker: PhantomData,
}
}
fn validate(&self) -> Result<(), String> {
if command_frame_id(self.esc_id).is_none() {
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));
}
// 逐位比较:direction 只允许精确的 ±1.0。
let dir_bits = self.direction.to_bits();
if dir_bits != 1.0f32.to_bits() && dir_bits != (-1.0f32).to_bits() {
return Err(format!("direction must be +1.0 or -1.0, got {}", self.direction));
}
if !(1..=self.model.max_current_raw()).contains(&self.current_limit) {
if !(1..=S::scale().max_raw()).contains(&self.current_limit) {
return Err(format!(
"current_limit {} out of range 1..={} for {:?}",
"current_limit {} out of range 1..={}",
self.current_limit,
self.model.max_current_raw(),
self.model
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 {
/// 控制节拍(C620 期望 1kHz 命令)。
pub period: Duration,
/// 反馈离线阈值:超过该时长未收到某电机反馈 → 强制 0 输出。
pub feedback_timeout: Duration,
/// **命令租约**:上层超过该时长未重新下发命令 → 视为 `Disabled`(0 输出)。
///
/// 命令是**租约**而非一次性设定值:`set_velocity` / `set_effort_raw` /
/// `set_position` 每次调用都会续租。上层必须按自己的控制节拍周期性下发
/// (`chassisd` 的 1kHz 循环天然满足;台架脚本每拍重发即可),
/// 否则驱动会判定"上层任务已死"并强制零输出——这是防失控的安全设计。
///
/// 设置为 `Duration::MAX` 可退化为纯设定值语义(仅建议在无失控风险的台架场景)。
pub command_timeout: Duration,
}
@@ -135,45 +99,34 @@ impl Default for BusOptions {
Self {
period: Duration::from_millis(1),
feedback_timeout: Duration::from_millis(100),
// 1kHz 下约 50 拍未刷新即失效。
command_timeout: Duration::from_millis(50),
}
}
}
/// 启动错误。
/// Startup error.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum EscBusError {
/// 两台电机配置了同一电调 ID。
#[error("duplicate ESC id {0}")]
DuplicateEscId(u8),
/// 电机配置非法(原因见 `reason`)。
#[error("invalid config for motor {name}: {reason}")]
InvalidConfig {
/// 电机名。
name: String,
/// 非法原因。
reason: String,
},
/// 一台电机都没有配置。
InvalidConfig { name: String, reason: String },
#[error("no motors configured")]
NoMotors,
}
/// 单电机共享状态(驱动任务与 [`EscMotor`] 句柄之间)。
struct MotorShared {
cfg: EscConfig,
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 MotorShared {
fn new(cfg: EscConfig) -> Self {
impl<S: MotorSpec> MotorShared<S> {
fn new(cfg: EscConfig<S>) -> Self {
Self {
cfg,
state: Mutex::new(MotorState::default()),
@@ -190,24 +143,22 @@ impl MotorShared {
*lock(&self.cmd_at) = Some(Instant::now());
}
/// 应用一帧反馈(原始电调侧值 → 方向修正后的物理量)。
fn apply_feedback(&self, fb: EscFeedback, now: Instant) {
let esc_rev = lock(&self.turn).update(fb.angle_raw);
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_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.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);
}
/// Produce one policy sample while enforcing feedback and command leases.
fn sample(
&self,
feedback_timeout: Duration,
@@ -219,7 +170,7 @@ impl MotorShared {
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 current");
warn!(motor = %self.cfg.name, esc_id = self.cfg.esc_id, "feedback offline, forcing zero output");
}
let fresh = matches!(
*lock(&self.cmd_at),
@@ -230,103 +181,87 @@ impl MotorShared {
}
}
/// 电机的公共句柄:可克隆,跨任务共享。
///
/// # 命令租约
///
/// 下发命令([`VelocityDrive::set_velocity`] / [`EffortDrive::set_effort_raw`] /
/// [`PositionDrive::set_position`])即续租,租期见 [`BusOptions::command_timeout`]。
/// 只在启动时下发一次、之后不再刷新的脚本会在租约到期后自动失电(0 输出),
/// 因此台架工具必须在控制循环里逐拍重发同一个设定值。
#[derive(Clone)]
pub struct EscMotor {
shared: Arc<MotorShared>,
/// Typed motor handle.
pub struct EscMotor<S: MotorSpec = motor::Gm3508> {
shared: Arc<MotorShared<S>>,
}
impl EscMotor {
/// 电机名。
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 config(&self) -> &EscConfig {
&self.shared.cfg
}
/// 当前命令。
#[must_use]
pub fn command(&self) -> Command {
*lock(&self.shared.cmd)
}
/// 最近一帧反馈的时刻(诊断用)。
#[must_use]
pub fn last_feedback(&self) -> Option<Instant> {
*lock(&self.shared.last_feedback)
}
}
impl MotorFeedback for EscMotor {
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<std::time::Duration> {
let t = *lock(&self.shared.last_feedback);
Some(Instant::now().saturating_duration_since(t?))
fn feedback_age(&self) -> Option<Duration> {
Some(Instant::now().saturating_duration_since(*lock(&self.shared.last_feedback).as_ref()?))
}
}
impl VelocityDrive for EscMotor {
/// 设定目标转子转速(**租约**,见 [`EscMotor`] 的命令租约说明)。
impl<S: MotorSpec> VelocityDrive for EscMotor<S> {
fn set_velocity(&self, rotor_rpm: f32) {
self.shared.store_command(Command::Velocity(rotor_rpm));
}
}
impl EffortDrive for EscMotor {
/// 直发开环 raw(**租约**,见 [`EscMotor`] 的命令租约说明)。
impl<S: MotorSpec> EffortDrive for EscMotor<S> {
fn set_effort_raw(&self, raw: i16) {
self.shared.store_command(Command::EffortRaw(raw));
}
}
impl PositionDrive for EscMotor {
/// 设定目标转子多圈位置(**租约**,见 [`EscMotor`] 的命令租约说明)。
impl<S: MotorSpec> PositionDrive for EscMotor<S> {
fn set_position(&self, rotor_rev: f32) {
self.shared.store_command(Command::Position(rotor_rev));
}
}
impl Disable for EscMotor {
/// 停用(0 输出);同样受租约保护——上层若已死,租约到期后依旧归零。
impl<S: MotorSpec> Disable for EscMotor<S> {
fn disable(&self) {
self.shared.store_command(Command::Disabled);
}
}
/// 运行中的电调总线。
pub struct EscBus;
/// Generic current-frame ESC bus.
pub struct EscBus<S: MotorSpec = motor::Gm3508>(PhantomData<S>);
impl EscBus {
/// 启动收发任务,返回句柄。
impl<S: CurrentEscDrive> EscBus<S> {
/// Start receive and transmit tasks.
///
/// # Errors
///
/// 配置为空、电调 ID 重复或单台配置非法时返回 [`EscBusError`]。
/// Returns an error when configuration is empty, duplicated, or invalid.
pub fn spawn<P: EffortPolicy>(
link: CanLink,
configs: Vec<EscConfig>,
configs: Vec<EscConfig<S>>,
options: BusOptions,
policy: P,
) -> Result<EscBusHandle<P>, EscBusError> {
) -> Result<EscBusHandle<P, S>, EscBusError> {
if configs.is_empty() {
return Err(EscBusError::NoMotors);
}
@@ -346,46 +281,48 @@ impl EscBus {
let motors = Arc::new(motors);
let policy = Arc::new(Mutex::new(policy));
let rx_handle =
tokio::spawn(rx_loop(incoming_rx, Arc::clone(&motors), shutdown_rx.clone()));
let tx_handle = tokio::spawn(tx_loop(
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] })
Ok(EscBusHandle {
motors,
policy,
shutdown_tx,
tasks: vec![rx_handle, tx_handle],
marker: PhantomData,
})
}
}
/// `EscBus` 的控制句柄。
pub struct EscBusHandle<P> {
motors: Arc<Vec<EscMotor>>,
/// 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> EscBusHandle<P> {
/// 按电调 ID 取电机句柄。
impl<P: EffortPolicy, S: CurrentEscDrive> EscBusHandle<P, S> {
#[must_use]
pub fn motor(&self, esc_id: u8) -> Option<&EscMotor> {
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] {
pub fn motors(&self) -> &[EscMotor<S>] {
&self.motors
}
/// Mutate the composed control policy between bus ticks.
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 {
@@ -394,9 +331,9 @@ impl<P: EffortPolicy> EscBusHandle<P> {
}
}
async fn rx_loop(
async fn rx_loop<S: CurrentEscDrive>(
mut incoming: mpsc::Receiver<CanFrame>,
motors: Arc<Vec<EscMotor>>,
motors: Arc<Vec<EscMotor<S>>>,
mut shutdown: watch::Receiver<bool>,
) {
loop {
@@ -407,22 +344,20 @@ async fn rx_loop(
None => break,
},
};
match decode_feedback(&frame) {
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());
motor.shared.apply_feedback(&fb, Instant::now());
}
}
Err(e) => {
debug!(error = %e, id = frame.id, "ignore non-feedback frame");
}
Err(e) => debug!(error = %e, id = frame.id, "ignore non-feedback frame"),
}
}
}
async fn tx_loop<P: EffortPolicy>(
async fn tx_loop<S: CurrentEscDrive, P: EffortPolicy>(
outgoing: mpsc::Sender<CanFrame>,
motors: Arc<Vec<EscMotor>>,
motors: Arc<Vec<EscMotor<S>>>,
policy: Arc<Mutex<P>>,
options: BusOptions,
mut shutdown: watch::Receiver<bool>,
@@ -453,28 +388,29 @@ async fn tx_loop<P: EffortPolicy>(
}
}
let efforts = lock(&policy).step(&PolicyInput { samples, dt });
for (frame_id, group_start) in
[(COMMAND_FRAME_ID_PRIMARY, 1u8), (COMMAND_FRAME_ID_SECONDARY, 5u8)]
{
let group: Vec<&EscMotor> = motors
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 currents = [0i16; 4];
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;
currents[slot] = clamp_current(
motor.shared.cfg.model,
directed.clamp(-motor.shared.cfg.current_limit, motor.shared.cfg.current_limit),
);
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(encode_current_frame(frame_id, currents)).await.is_err() {
if outgoing.send(S::Protocol::encode_group(frame_id, commands)).await.is_err() {
info!("can link closed, tx loop exiting");
return;
}
@@ -484,7 +420,6 @@ async fn tx_loop<P: EffortPolicy>(
fn tick_every(period: Duration) -> Interval {
let mut ticker = tokio::time::interval(period);
// 错过节拍后直接跳到当前节拍,避免补发帧风暴。
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
ticker
}
@@ -492,49 +427,35 @@ fn tick_every(period: Duration) -> Interval {
#[cfg(test)]
mod tests {
use super::*;
use motor::{Gm3508, M2006};
#[test]
fn config_validate_rejects_bad_values() {
let mut cfg = EscConfig::gm3508("m", 1, 1.0);
cfg.esc_id = 0;
assert!(cfg.validate().is_err());
cfg.esc_id = 9;
assert!(cfg.validate().is_err());
cfg.esc_id = 5;
cfg.gear_ratio = 0.0;
assert!(cfg.validate().is_err());
cfg.gear_ratio = 19.2;
cfg.direction = 0.5;
assert!(cfg.validate().is_err());
cfg.direction = -1.0;
cfg.current_limit = 0;
assert!(cfg.validate().is_err());
cfg.current_limit = 16385;
assert!(cfg.validate().is_err());
cfg.current_limit = 12_000;
assert!(cfg.validate().is_ok());
}
#[test]
fn gm3508_defaults_are_sane() {
let cfg = EscConfig::gm3508("front_left", 1, -1.0);
assert_eq!(cfg.model, EscModel::C620);
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_eq!(cfg.direction.to_bits(), (-1.0f32).to_bits());
assert!(cfg.validate().is_ok());
}
#[test]
fn m2006_defaults_are_sane() {
let cfg = EscConfig::m2006("feeder", 5, 1.0);
assert_eq!(cfg.model, EscModel::C610);
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());
}
}
@@ -15,3 +15,6 @@ imu = { workspace = true }
delta10-protocol = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
[package.metadata.sentry-chassis]
layer = "device/imu"
@@ -16,3 +16,6 @@ thiserror = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
[package.metadata.sentry-chassis]
layer = "device/motor"
@@ -792,173 +792,121 @@ pub mod testing {
}
}
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::*;
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)));
}
}
@@ -14,3 +14,6 @@ workspace = true
[dev-dependencies]
approx = { workspace = true }
[package.metadata.sentry-chassis]
layer = "experiment/trace"
@@ -14,3 +14,6 @@ motor-protocol = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
[package.metadata.sentry-chassis]
layer = "model/motor"
@@ -108,6 +108,24 @@ pub mod dc_motor {
self.load_rpm = rpm;
self
}
/// Whether all parameters are finite and physically admissible.
#[must_use]
pub fn is_valid(&self) -> bool {
self.gain_rpm_per_raw.is_finite()
&& self.gain_rpm_per_raw >= 0.0
&& self.tau_s.is_finite()
&& self.tau_s >= 0.0
&& self.max_rpm.is_finite()
&& self.max_rpm >= 0.0
&& self.static_raw.is_finite()
&& self.static_raw >= 0.0
&& self.coulomb_raw.is_finite()
&& self.coulomb_raw >= 0.0
&& self.viscous_raw_per_rpm.is_finite()
&& self.viscous_raw_per_rpm >= 0.0
&& self.load_rpm.is_finite()
}
}
/// 单台电机被控对象。`Clone` 便于在同一组参数上重放多条场景。
@@ -169,6 +187,9 @@ pub mod dc_motor {
/// `static = coulomb = viscous = 0` 时三个分支都退化为 `net = u`,
/// 即历史的一阶模型。
pub fn step(&mut self, current_raw: i16, dt: f32) -> EscFeedback {
if !dt.is_finite() || dt <= 0.0 {
return self.feedback(current_raw);
}
let applied = self.apply_delay(f32::from(current_raw));
let net = self.net_torque_raw(applied);
let target = (net * self.params.gain_rpm_per_raw)
@@ -184,6 +205,10 @@ pub mod dc_motor {
let ticks = f32::from(ENCODER_TICKS);
self.angle_raw =
(self.angle_raw + self.speed_rpm / 60.0 * ticks * dt).rem_euclid(ticks);
self.feedback(applied as i16)
}
fn feedback(&self, current_raw: i16) -> EscFeedback {
EscFeedback {
angle_raw: self.angle_raw as u16,
speed_rpm: self.speed_rpm as i16,
@@ -356,6 +381,36 @@ pub mod dc_motor {
assert_eq!(fb.temperature_c, 30);
}
#[test]
fn delayed_feedback_reports_the_effective_command() {
let params = PlantParams::gm3508().with_delay_ticks(2);
let mut plant = PlantMotor::new(params);
assert_eq!(plant.step(1000, DT).current_raw, 0);
assert_eq!(plant.step(2000, DT).current_raw, 0);
assert_eq!(plant.step(3000, DT).current_raw, 1000);
}
#[test]
fn invalid_dt_is_a_strict_no_op() {
let mut plant = PlantMotor::gm3508();
plant.step(5000, DT);
let before_speed = plant.speed_rpm();
let before_angle = plant.angle_raw();
for dt in [0.0, -1.0, f32::NAN, f32::INFINITY] {
plant.step(10_000, dt);
assert_eq!(plant.speed_rpm().to_bits(), before_speed.to_bits());
assert_eq!(plant.angle_raw().to_bits(), before_angle.to_bits());
}
}
#[test]
fn parameter_validation_rejects_non_finite_values() {
let mut params = PlantParams::gm3508();
assert!(params.is_valid());
params.tau_s = f32::NAN;
assert!(!params.is_valid());
}
#[test]
fn reset_returns_to_rest() {
let mut plant = PlantMotor::gm3508();
@@ -371,7 +426,7 @@ pub mod dc_motor {
}
pub mod gm3508 {
//! GM3508 的被控对象参数(ARCHITECTURE.md §5.6 / §8.2)。
//! GM3508 的被控对象参数(docs/chassis.md §7.4)。
//!
//! 这里只放**有实测依据**的两组参数:
//!
@@ -424,3 +479,319 @@ pub mod gm3508 {
pub use dc_motor::{PlantMotor, PlantParams};
pub use gm3508::{bench_params, ideal_params};
pub mod generic {
//! Generic motor dynamic-model seam.
use core::marker::PhantomData;
use motor_protocol::{EscFeedback, FeedbackFields, MotorProtocol};
use crate::{PlantMotor, PlantParams};
/// Error returned by a dynamic model step.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelError {
/// The requested time step is not finite and positive.
InvalidDt,
/// A model parameter is outside its valid domain.
InvalidParams,
}
impl core::fmt::Display for ModelError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::InvalidDt => write!(f, "dt must be finite and positive"),
Self::InvalidParams => write!(f, "model parameters are invalid"),
}
}
}
impl core::error::Error for ModelError {}
/// Dynamic model contract for one protocol family.
pub trait MotorPlant {
type Protocol: MotorProtocol;
type Feedback: FeedbackFields + Copy + Send + Sync;
type Error;
fn step(
&mut self,
command: <Self::Protocol as MotorProtocol>::Command,
dt: f32,
) -> Result<Self::Feedback, Self::Error>;
fn reset(&mut self);
}
/// Parameter provider for a DC current-controlled model.
pub trait DcModelSpec: Send + Sync + 'static {
type Protocol: MotorProtocol<Command = i16, Feedback = EscFeedback>;
fn ideal_params() -> PlantParams;
fn bench_params() -> PlantParams;
}
/// DC motor plant generic over the motor model profile.
#[derive(Debug, Clone)]
pub struct DcMotorPlant<S: DcModelSpec> {
motor: PlantMotor,
marker: PhantomData<S>,
}
impl<S: DcModelSpec> DcMotorPlant<S> {
#[must_use]
pub fn new(params: PlantParams) -> Self {
Self { motor: PlantMotor::new(params), marker: PhantomData }
}
#[must_use]
pub fn ideal() -> Self {
Self::new(S::ideal_params())
}
#[must_use]
pub fn bench() -> Self {
Self::new(S::bench_params())
}
#[must_use]
pub fn speed_rpm(&self) -> f32 {
self.motor.speed_rpm()
}
#[must_use]
pub fn angle_raw(&self) -> f32 {
self.motor.angle_raw()
}
#[must_use]
pub fn params(&self) -> &PlantParams {
self.motor.params()
}
}
impl<S: DcModelSpec> MotorPlant for DcMotorPlant<S> {
type Protocol = S::Protocol;
type Feedback = EscFeedback;
type Error = ModelError;
fn step(
&mut self,
command: <Self::Protocol as MotorProtocol>::Command,
dt: f32,
) -> Result<Self::Feedback, Self::Error> {
if !dt.is_finite() || dt <= 0.0 {
return Err(ModelError::InvalidDt);
}
if !self.motor.params().is_valid() {
return Err(ModelError::InvalidParams);
}
Ok(self.motor.step(command, dt))
}
fn reset(&mut self) {
self.motor.reset();
}
}
/// GM3508 DC model profile.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Gm3508Model;
/// M2006 DC model profile.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct M2006Model;
impl DcModelSpec for Gm3508Model {
type Protocol = motor_protocol::CurrentEsc<motor_protocol::CurrentC620>;
fn ideal_params() -> PlantParams {
PlantParams::gm3508()
}
fn bench_params() -> PlantParams {
PlantParams::gm3508_bench()
}
}
impl DcModelSpec for M2006Model {
type Protocol = motor_protocol::CurrentEsc<motor_protocol::CurrentC610>;
fn ideal_params() -> PlantParams {
PlantParams::gm3508()
}
fn bench_params() -> PlantParams {
PlantParams::gm3508_bench()
}
}
}
pub use generic::{DcModelSpec, DcMotorPlant, Gm3508Model, M2006Model, ModelError, MotorPlant};
#[cfg(test)]
mod generic_tests {
use super::*;
#[test]
fn typed_dc_models_use_current_protocols() {
let mut plant = DcMotorPlant::<Gm3508Model>::ideal();
assert!(plant.step(1000, 0.001).is_ok());
assert!(plant.speed_rpm().is_finite());
let mut plant = DcMotorPlant::<M2006Model>::bench();
assert!(plant.step(1000, 0.001).is_ok());
}
#[test]
fn typed_models_reject_invalid_steps_and_parameters() {
let mut plant = DcMotorPlant::<Gm3508Model>::ideal();
assert_eq!(plant.step(1, 0.0), Err(ModelError::InvalidDt));
let mut params = PlantParams::gm3508();
params.load_rpm = f32::NAN;
let mut plant = DcMotorPlant::<Gm3508Model>::new(params);
assert_eq!(plant.step(1, 0.001), Err(ModelError::InvalidParams));
}
}
pub mod voltage {
//! Independent voltage-motor dynamics for GM6020-style direct-drive motors.
use core::marker::PhantomData;
use std::collections::VecDeque;
use motor_protocol::{Gm6020Command, Gm6020Feedback, Gm6020Voltage, MotorProtocol};
use crate::generic::{ModelError, MotorPlant};
/// Parameters for a voltage-controlled motor model.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VoltagePlantParams {
pub gain_rpm_per_raw: f32,
pub tau_s: f32,
pub max_rpm: f32,
pub delay_ticks: u32,
}
impl VoltagePlantParams {
#[must_use]
pub fn is_valid(self) -> bool {
self.gain_rpm_per_raw.is_finite()
&& self.gain_rpm_per_raw >= 0.0
&& self.tau_s.is_finite()
&& self.tau_s >= 0.0
&& self.max_rpm.is_finite()
&& self.max_rpm >= 0.0
}
}
/// Voltage model profile.
pub trait VoltageModelSpec: Send + Sync + 'static {
type Protocol: MotorProtocol<Command = Gm6020Command, Feedback = Gm6020Feedback>;
fn params() -> VoltagePlantParams;
}
/// Direct-drive voltage plant. It deliberately does not reuse DC current friction.
#[derive(Debug, Clone)]
pub struct VoltageMotorPlant<S: VoltageModelSpec> {
params: VoltagePlantParams,
speed_rpm: f32,
angle_raw: f32,
delay_line: VecDeque<i16>,
marker: PhantomData<S>,
}
impl<S: VoltageModelSpec> VoltageMotorPlant<S> {
#[must_use]
pub fn new(params: VoltagePlantParams) -> Self {
Self {
params,
speed_rpm: 0.0,
angle_raw: 0.0,
delay_line: VecDeque::new(),
marker: PhantomData,
}
}
#[must_use]
pub fn speed_rpm(&self) -> f32 {
self.speed_rpm
}
}
impl<S: VoltageModelSpec> MotorPlant for VoltageMotorPlant<S> {
type Protocol = Gm6020Voltage;
type Feedback = Gm6020Feedback;
type Error = ModelError;
fn step(&mut self, command: Gm6020Command, dt: f32) -> Result<Self::Feedback, Self::Error> {
if !dt.is_finite() || dt <= 0.0 {
return Err(ModelError::InvalidDt);
}
if !self.params.is_valid() {
return Err(ModelError::InvalidParams);
}
self.delay_line.push_back(command.voltage_raw);
let applied = if self.delay_line.len() > self.params.delay_ticks as usize {
self.delay_line.pop_front().unwrap_or(0)
} else {
0
};
let target = (f32::from(applied) * self.params.gain_rpm_per_raw)
.clamp(-self.params.max_rpm, self.params.max_rpm);
let alpha =
if self.params.tau_s > 0.0 { 1.0 - (-dt / self.params.tau_s).exp() } else { 1.0 };
self.speed_rpm += (target - self.speed_rpm) * alpha;
self.angle_raw =
(self.angle_raw + self.speed_rpm / 60.0 * 8192.0 * dt).rem_euclid(8192.0);
Ok(Gm6020Feedback {
angle_deg: self.angle_raw * 360.0 / 8192.0,
angle_raw: self.angle_raw as u16,
speed_rpm: self.speed_rpm as i16,
current_raw: 0,
temperature_c: 30,
})
}
fn reset(&mut self) {
self.speed_rpm = 0.0;
self.angle_raw = 0.0;
self.delay_line.clear();
}
}
/// GM6020 voltage model profile. Values are structural defaults, not field identification.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Gm6020Model;
impl VoltageModelSpec for Gm6020Model {
type Protocol = Gm6020Voltage;
fn params() -> VoltagePlantParams {
VoltagePlantParams {
gain_rpm_per_raw: 0.01,
tau_s: 0.1,
max_rpm: 300.0,
delay_ticks: 0,
}
}
}
}
pub use voltage::{Gm6020Model, VoltageModelSpec, VoltageMotorPlant, VoltagePlantParams};
#[cfg(test)]
mod voltage_tests {
use super::*;
use motor_protocol::Gm6020Command;
#[test]
fn voltage_model_is_independent_and_resets() {
let mut plant = VoltageMotorPlant::<Gm6020Model>::new(Gm6020Model::params());
let feedback = MotorPlant::step(&mut plant, Gm6020Command::new(10_000), 0.001).unwrap();
assert!(feedback.speed_rpm >= 0);
plant.reset();
assert!(plant.speed_rpm().abs() < f32::EPSILON);
assert_eq!(
MotorPlant::step(&mut plant, Gm6020Command::new(1), 0.0),
Err(ModelError::InvalidDt)
);
}
}
@@ -16,3 +16,6 @@ thiserror = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
[package.metadata.sentry-chassis]
layer = "protocol/imu"
@@ -16,3 +16,6 @@ thiserror = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
approx = { workspace = true }
[package.metadata.sentry-chassis]
layer = "protocol/motor"
@@ -909,3 +909,276 @@ pub use id::*;
pub use m2006::C610;
pub use m3508::C620;
pub use turn::*;
pub mod generic {
//! Generic wire-protocol contracts.
//!
//! The concrete codec modules remain the source of wire layout. This module
//! only gives upper layers a type-safe seam for current/voltage families.
use core::marker::PhantomData;
use crate::common::{self, DecodeError, EscFeedback};
use crate::frame::CanFrame;
use crate::gm6020::{self, Gm6020Command, Gm6020Feedback};
/// Protocol decoding error used by the generic codec seam.
pub type ProtocolError = DecodeError;
/// Protocol-independent view of a wire feedback frame.
pub trait FeedbackFields {
fn angle_raw(&self) -> u16;
fn speed_rpm(&self) -> i16;
fn current_raw(&self) -> i16;
fn temperature_c(&self) -> i8;
}
impl FeedbackFields for EscFeedback {
fn angle_raw(&self) -> u16 {
self.angle_raw
}
fn speed_rpm(&self) -> i16 {
self.speed_rpm
}
fn current_raw(&self) -> i16 {
self.current_raw
}
fn temperature_c(&self) -> i8 {
self.temperature_c
}
}
impl FeedbackFields for Gm6020Feedback {
fn angle_raw(&self) -> u16 {
self.angle_raw
}
fn speed_rpm(&self) -> i16 {
self.speed_rpm
}
fn current_raw(&self) -> i16 {
self.current_raw
}
fn temperature_c(&self) -> i8 {
self.temperature_c
}
}
/// Raw effort scaling and saturation for one controller family.
pub trait EffortScale: Copy + Send + Sync + 'static {
fn max_raw(self) -> i16;
fn max_amp(self) -> f32;
fn clamp_raw(self, raw: i16) -> i16;
fn raw_from_amp(self, amp: f32) -> i16;
fn amp_from_raw(self, raw: i16) -> f32;
}
/// C620 current scale: ±16384 raw ↔ ±20 A.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CurrentC620;
/// C610 current scale: ±10000 raw ↔ ±10 A.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CurrentC610;
/// GM6020 voltage scale: ±30000 raw.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Voltage30000;
impl EffortScale for CurrentC620 {
fn max_raw(self) -> i16 {
16_384
}
fn max_amp(self) -> f32 {
20.0
}
fn clamp_raw(self, raw: i16) -> i16 {
common::clamp_current(common::EscModel::C620, raw)
}
fn raw_from_amp(self, amp: f32) -> i16 {
common::EscModel::C620.raw_from_amp(amp)
}
fn amp_from_raw(self, raw: i16) -> f32 {
common::EscModel::C620.amp_from_raw(raw)
}
}
impl EffortScale for CurrentC610 {
fn max_raw(self) -> i16 {
10_000
}
fn max_amp(self) -> f32 {
10.0
}
fn clamp_raw(self, raw: i16) -> i16 {
common::clamp_current(common::EscModel::C610, raw)
}
fn raw_from_amp(self, amp: f32) -> i16 {
common::EscModel::C610.raw_from_amp(amp)
}
fn amp_from_raw(self, raw: i16) -> f32 {
common::EscModel::C610.amp_from_raw(raw)
}
}
impl EffortScale for Voltage30000 {
fn max_raw(self) -> i16 {
gm6020::VOLTAGE_MAX_RAW
}
fn max_amp(self) -> f32 {
f32::from(gm6020::VOLTAGE_MAX_RAW)
}
fn clamp_raw(self, raw: i16) -> i16 {
gm6020::clamp_voltage(raw)
}
fn raw_from_amp(self, amp: f32) -> i16 {
let raw = amp.clamp(-f32::from(self.max_raw()), f32::from(self.max_raw()));
self.clamp_raw(raw as i16)
}
fn amp_from_raw(self, raw: i16) -> f32 {
f32::from(self.clamp_raw(raw))
}
}
/// Wire protocol contract for one motor family.
pub trait MotorProtocol: Send + Sync + 'static {
type Command: Copy + Send + Sync;
type Feedback: FeedbackFields + Copy + Send + Sync;
type Scale: EffortScale;
fn command_frame_id(id: u8) -> Option<u32>;
fn feedback_frame_id(id: u8) -> Option<u32>;
fn encode_group(frame_id: u32, commands: [Self::Command; 4]) -> CanFrame;
fn decode_feedback(frame: &CanFrame) -> Result<(u8, Self::Feedback), ProtocolError>;
fn command_from_raw(raw: i16) -> Self::Command;
}
/// C620/C610 share the current frame layout; `S` only selects the scale.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CurrentEsc<S>(PhantomData<S>);
impl<S: EffortScale + DefaultScale> MotorProtocol for CurrentEsc<S> {
type Command = i16;
type Feedback = EscFeedback;
type Scale = S;
fn command_frame_id(id: u8) -> Option<u32> {
common::command_frame_id(id)
}
fn feedback_frame_id(id: u8) -> Option<u32> {
common::feedback_frame_id(id)
}
fn encode_group(frame_id: u32, commands: [Self::Command; 4]) -> CanFrame {
common::encode_current_frame(frame_id, commands)
}
fn decode_feedback(frame: &CanFrame) -> Result<(u8, Self::Feedback), ProtocolError> {
common::decode_feedback(frame)
}
fn command_from_raw(raw: i16) -> Self::Command {
S::default_scale().clamp_raw(raw)
}
}
/// GM6020 voltage protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Gm6020Voltage;
impl MotorProtocol for Gm6020Voltage {
type Command = Gm6020Command;
type Feedback = Gm6020Feedback;
type Scale = Voltage30000;
fn command_frame_id(id: u8) -> Option<u32> {
gm6020::command_frame_id(id)
}
fn feedback_frame_id(id: u8) -> Option<u32> {
gm6020::feedback_frame_id(id)
}
fn encode_group(frame_id: u32, commands: [Self::Command; 4]) -> CanFrame {
gm6020::encode_voltage_frame(
frame_id,
[
commands[0].voltage_raw,
commands[1].voltage_raw,
commands[2].voltage_raw,
commands[3].voltage_raw,
],
)
}
fn decode_feedback(frame: &CanFrame) -> Result<(u8, Self::Feedback), ProtocolError> {
gm6020::decode_feedback(frame)
}
fn command_from_raw(raw: i16) -> Self::Command {
Gm6020Command::new(raw)
}
}
pub trait DefaultScale: EffortScale {
fn default_scale() -> Self;
}
impl DefaultScale for CurrentC620 {
fn default_scale() -> Self {
Self
}
}
impl DefaultScale for CurrentC610 {
fn default_scale() -> Self {
Self
}
}
}
pub use generic::{
CurrentC610, CurrentC620, CurrentEsc, DefaultScale, EffortScale, FeedbackFields, Gm6020Voltage,
MotorProtocol, ProtocolError, Voltage30000,
};
#[cfg(test)]
mod generic_tests {
use super::*;
#[test]
fn current_protocol_round_trips_feedback_fields() {
let feedback = common::EscFeedback {
angle_raw: 17,
speed_rpm: -123,
current_raw: 456,
temperature_c: 31,
};
let frame = common::encode_feedback_frame(2, &feedback);
let (id, decoded) =
<generic::CurrentEsc<generic::CurrentC620> as generic::MotorProtocol>::decode_feedback(
&frame,
)
.unwrap();
assert_eq!(id, 2);
assert_eq!(decoded.angle_raw(), 17);
assert_eq!(decoded.speed_rpm(), -123);
assert_eq!(decoded.current_raw(), 456);
assert_eq!(decoded.temperature_c(), 31);
}
#[test]
fn current_scales_are_distinct_and_saturating() {
assert_eq!(generic::CurrentC620.max_raw(), 16_384);
assert_eq!(generic::CurrentC610.max_raw(), 10_000);
assert_eq!(generic::CurrentC610.clamp_raw(20_000), 10_000);
assert_eq!(generic::Voltage30000.clamp_raw(i16::MIN), -30_000);
}
#[test]
fn gm6020_uses_voltage_commands() {
let command = <generic::Gm6020Voltage as generic::MotorProtocol>::command_from_raw(1234);
assert_eq!(command.voltage_raw, 1234);
assert_eq!(
<generic::Gm6020Voltage as generic::MotorProtocol>::command_frame_id(1),
Some(gm6020::COMMAND_FRAME_ID_PRIMARY)
);
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "dr16-protocol"
description = "DJI DR16 D-BUS frame protocol"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
thiserror = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
[package.metadata.sentry-chassis]
layer = "protocol/remote"
+380
View File
@@ -0,0 +1,380 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! DJI DR16 接收机 D-BUS 帧协议。
//!
//! 协议帧固定为 18 字节:四个 11 bit 摇杆通道、两个三档开关、鼠标、按键与拨轮。
//! 本实现只描述线上的字节布局,不依赖串口、Tokio 或底盘控制类型。
//!
//! DT7/DR16 手册没有给出 D-BUS 字节布局;字段结构与开关编码参考
//! `RoboMaster/RoboRTS-Firmware`,本实现为独立 Rust 重写。
use std::collections::VecDeque;
use thiserror::Error;
/// 一帧 D-BUS 数据的字节数。
pub const FRAME_LEN: usize = 18;
/// 遥控器摇杆的协议中心值。
pub const CHANNEL_CENTER: i16 = 1024;
/// 摇杆有效范围:中心值 ±660。
pub const CHANNEL_MAX_ABS: i16 = 660;
/// 三档开关位置。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SwitchPosition {
/// 上档,线值 1。
Up,
/// 下档,线值 2。
Down,
/// 中档,线值 3。
Mid,
}
impl SwitchPosition {
/// 开关的协议线值。
#[must_use]
pub const fn wire_value(self) -> u8 {
match self {
Self::Up => 1,
Self::Down => 2,
Self::Mid => 3,
}
}
}
impl TryFrom<u8> for SwitchPosition {
type Error = DecodeError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::Up),
2 => Ok(Self::Down),
3 => Ok(Self::Mid),
_ => Err(DecodeError::InvalidSwitch(value)),
}
}
}
/// DR16 扩展输入:鼠标、按键与拨轮。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ExtendedInput {
/// 鼠标 x 位移。
pub mouse_x: i16,
/// 鼠标 y 位移。
pub mouse_y: i16,
/// 鼠标 z 位移。
pub mouse_z: i16,
/// 鼠标左键。
pub mouse_left: bool,
/// 鼠标右键。
pub mouse_right: bool,
/// 键盘按键位图。
pub key_code: u16,
/// 拨轮中心值 ±1024 后的值。
pub wheel: i16,
}
/// 一帧完整 DR16 输入。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Dr16Sample {
/// 摇杆通道 A/E/T/R,均已减去 1024,范围 -660..=660。
pub channels: [i16; 4],
/// 左侧三档开关 S1。
pub s1: SwitchPosition,
/// 右侧三档开关 S2。
pub s2: SwitchPosition,
/// 鼠标、按键和拨轮。
pub extended: ExtendedInput,
}
impl Dr16Sample {
/// 右摇杆左右通道 A/J1。
#[must_use]
pub const fn channel_a(self) -> i16 {
self.channels[0]
}
/// 右摇杆上下通道 E/J2。
#[must_use]
pub const fn channel_e(self) -> i16 {
self.channels[1]
}
/// 左摇杆上下通道 T/J3。
#[must_use]
pub const fn channel_t(self) -> i16 {
self.channels[2]
}
/// 左摇杆左右通道 R/J4。
#[must_use]
pub const fn channel_r(self) -> i16 {
self.channels[3]
}
}
/// D-BUS 解码错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum DecodeError {
/// 输入不是 18 字节完整帧。
#[error("invalid D-BUS frame length: expected {expected}, got {actual}")]
InvalidLength {
/// 协议要求的长度。
expected: usize,
/// 实际长度。
actual: usize,
},
/// 摇杆超出遥控器有效范围。
#[error("invalid D-BUS channel {channel}: {value}")]
InvalidChannel {
/// 通道下标 0..4。
channel: usize,
/// 中心化后的值。
value: i16,
},
/// 开关线值不是 1/2/3。
#[error("invalid D-BUS switch value: {0}")]
InvalidSwitch(u8),
/// 拨轮超出 ±1024。
#[error("invalid D-BUS wheel value: {0}")]
InvalidWheel(i16),
/// 鼠标按键字节不是 0/1。
#[error("invalid D-BUS mouse button byte: {0}")]
InvalidMouseButton(u8),
}
/// 解码一帧完整 D-BUS 数据。
///
/// # Errors
///
/// 任一摇杆超出 ±660 或开关线值非法时返回 [`DecodeError`]。
pub fn decode_frame(data: &[u8]) -> Result<Dr16Sample, DecodeError> {
if data.len() != FRAME_LEN {
return Err(DecodeError::InvalidLength { expected: FRAME_LEN, actual: data.len() });
}
let mut packed = [0u8; FRAME_LEN];
packed.copy_from_slice(data);
let raw_channels = [
(u16::from(packed[0]) | (u16::from(packed[1]) << 8)) & 0x07FF,
(u16::from(packed[1]) >> 3 | (u16::from(packed[2]) << 5)) & 0x07FF,
(u16::from(packed[2]) >> 6 | (u16::from(packed[3]) << 2) | (u16::from(packed[4]) << 10))
& 0x07FF,
(u16::from(packed[4]) >> 1 | (u16::from(packed[5]) << 7)) & 0x07FF,
];
let mut channels = [0i16; 4];
for (index, raw) in raw_channels.into_iter().enumerate() {
let value = raw as i16 - CHANNEL_CENTER;
if !(-CHANNEL_MAX_ABS..=CHANNEL_MAX_ABS).contains(&value) {
return Err(DecodeError::InvalidChannel { channel: index, value });
}
channels[index] = value;
}
let switch_bits = packed[5] >> 4;
let s1 = SwitchPosition::try_from((switch_bits & 0x0C) >> 2)?;
let s2 = SwitchPosition::try_from(switch_bits & 0x03)?;
let wheel = u16::from_le_bytes([packed[16], packed[17]]) as i16 - CHANNEL_CENTER;
if !(-1024..=1024).contains(&wheel) {
return Err(DecodeError::InvalidWheel(wheel));
}
if packed[12] > 1 {
return Err(DecodeError::InvalidMouseButton(packed[12]));
}
if packed[13] > 1 {
return Err(DecodeError::InvalidMouseButton(packed[13]));
}
Ok(Dr16Sample {
channels,
s1,
s2,
extended: ExtendedInput {
mouse_x: i16::from_le_bytes([packed[6], packed[7]]),
mouse_y: i16::from_le_bytes([packed[8], packed[9]]),
mouse_z: i16::from_le_bytes([packed[10], packed[11]]),
mouse_left: packed[12] == 1,
mouse_right: packed[13] == 1,
key_code: u16::from_le_bytes([packed[14], packed[15]]),
wheel,
},
})
}
/// 增量帧解码器:接受任意大小的串口读块,并在噪声后重新同步。
///
/// D-BUS 帧没有同步头或校验和,因此同步依靠固定长度和摇杆/开关的有效范围。
#[derive(Debug, Default)]
pub struct FrameDecoder {
bytes: VecDeque<u8>,
discarded_bytes: usize,
}
impl FrameDecoder {
/// 创建空解码器。
#[must_use]
pub const fn new() -> Self {
Self { bytes: VecDeque::new(), discarded_bytes: 0 }
}
/// 追加串口字节并返回其中可识别的完整帧。
#[must_use]
pub fn push(&mut self, input: &[u8]) -> Vec<Dr16Sample> {
self.bytes.extend(input.iter().copied());
let mut samples = Vec::new();
while self.bytes.len() >= FRAME_LEN {
let mut candidate: Option<(usize, Dr16Sample, usize)> = None;
let windows = self.bytes.len() - FRAME_LEN + 1;
for offset in 0..windows {
let mut frame = [0u8; FRAME_LEN];
for (index, byte) in self.bytes.iter().skip(offset).take(FRAME_LEN).enumerate() {
frame[index] = *byte;
}
if let Ok(sample) = decode_frame(&frame) {
// D-BUS 没有同步头。多个窗口都能通过范围校验时,优先选择
// 与 18 字节帧边界一致的候选,避免被噪声向前错一位。
let residue = (self.bytes.len() - (offset + FRAME_LEN)) % FRAME_LEN;
if candidate.is_none_or(|(_, _, best)| residue < best) {
candidate = Some((offset, sample, residue));
}
}
}
if let Some((offset, sample, _)) = candidate {
self.bytes.drain(0..offset + FRAME_LEN);
self.discarded_bytes += offset;
samples.push(sample);
} else {
let discard = self.bytes.len() - (FRAME_LEN - 1);
self.bytes.drain(0..discard);
self.discarded_bytes += discard;
}
}
samples
}
/// 因噪声或不完整候选帧而丢弃的累计字节数。
#[must_use]
pub const fn discarded_bytes(&self) -> usize {
self.discarded_bytes
}
/// 清空尚未组成帧的字节。
pub fn clear(&mut self) {
self.bytes.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn encode(channels: [i16; 4], s1: u8, s2: u8) -> [u8; FRAME_LEN] {
let raw: Vec<u16> = channels.iter().map(|value| (value + CHANNEL_CENTER) as u16).collect();
let mut frame = [0u8; FRAME_LEN];
frame[0] = (raw[0] & 0xFF) as u8;
frame[1] = ((raw[0] >> 8) as u8) | ((raw[1] & 0x1F) as u8) << 3;
frame[2] = ((raw[1] >> 5) as u8) | ((raw[2] & 0x03) as u8) << 6;
frame[3] = (raw[2] >> 2) as u8;
frame[4] = ((raw[2] >> 10) as u8) | ((raw[3] & 0x7F) as u8) << 1;
frame[5] = ((raw[3] >> 7) as u8) | (s2 & 0x03) << 4 | (s1 & 0x03) << 6;
frame
}
#[test]
fn decodes_channels_switches_mouse_keys_and_wheel() {
let mut frame = encode([-660, -5, 321, 660], 1, 3);
frame[5] = (frame[5] & 0x0F) | (1 << 6) | (3 << 4);
frame[6..8].copy_from_slice(&(-123i16).to_le_bytes());
frame[8..10].copy_from_slice(&456i16.to_le_bytes());
frame[10..12].copy_from_slice(&(-7i16).to_le_bytes());
frame[12] = 1;
frame[13] = 0;
frame[14..16].copy_from_slice(&0xA55Au16.to_le_bytes());
frame[16..18].copy_from_slice(&(2048u16).to_le_bytes());
let sample = decode_frame(&frame).unwrap();
assert_eq!(sample.channels, [-660, -5, 321, 660]);
assert_eq!(sample.s1, SwitchPosition::Up);
assert_eq!(sample.s2, SwitchPosition::Mid);
assert_eq!(sample.channel_a(), -660);
assert_eq!(sample.channel_t(), 321);
assert_eq!(
sample.extended,
ExtendedInput {
mouse_x: -123,
mouse_y: 456,
mouse_z: -7,
mouse_left: true,
mouse_right: false,
key_code: 0xA55A,
wheel: 1024,
}
);
}
#[test]
fn switch_wire_values_are_explicit() {
assert_eq!(SwitchPosition::Up.wire_value(), 1);
assert_eq!(SwitchPosition::Down.wire_value(), 2);
assert_eq!(SwitchPosition::Mid.wire_value(), 3);
assert_eq!(SwitchPosition::try_from(0), Err(DecodeError::InvalidSwitch(0)));
assert_eq!(SwitchPosition::try_from(4), Err(DecodeError::InvalidSwitch(4)));
}
#[test]
fn rejects_short_frame_and_out_of_range_values() {
assert_eq!(
decode_frame(&[0; FRAME_LEN - 1]),
Err(DecodeError::InvalidLength { expected: FRAME_LEN, actual: FRAME_LEN - 1 })
);
let frame = encode([661, 0, 0, 0], 1, 1);
assert!(matches!(
decode_frame(&frame),
Err(DecodeError::InvalidChannel { channel: 0, .. })
));
let frame = encode([0, 0, 0, 0], 0, 1);
assert_eq!(decode_frame(&frame), Err(DecodeError::InvalidSwitch(0)));
}
#[test]
fn decoder_accepts_fragmented_frames() {
let frame = encode([100, -200, 300, -400], 1, 2);
let mut decoder = FrameDecoder::new();
assert!(decoder.push(&frame[..7]).is_empty());
assert!(decoder.push(&frame[7..13]).is_empty());
let samples = decoder.push(&frame[13..]);
assert_eq!(samples.len(), 1);
assert_eq!(samples[0].channels, [100, -200, 300, -400]);
}
#[test]
fn decoder_resynchronizes_after_noise() {
let frame = encode([660, -660, 659, -659], 1, 3);
let mut input = vec![0xFF; 37];
input.extend_from_slice(&frame);
let mut decoder = FrameDecoder::new();
let samples = decoder.push(&input);
assert_eq!(samples.len(), 1, "噪声后应找到下一帧");
assert_eq!(samples[0].channels, [660, -660, 659, -659]);
assert!(decoder.discarded_bytes() >= 37);
}
#[test]
fn decoder_returns_multiple_frames_from_one_read() {
let first = encode([1, 2, 3, 4], 1, 1);
let second = encode([-1, -2, -3, -4], 2, 3);
let mut input = first.to_vec();
input.extend_from_slice(&second);
let mut decoder = FrameDecoder::new();
let samples = decoder.push(&input);
assert_eq!(samples.len(), 2);
assert_eq!(samples[0].channels, [1, 2, 3, 4]);
assert_eq!(samples[1].channels, [-1, -2, -3, -4]);
}
}
@@ -24,3 +24,6 @@ rand = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
approx = { workspace = true }
[package.metadata.sentry-chassis]
layer = "simulation/motor"
@@ -706,7 +706,7 @@ pub mod plant {
use motor_protocol::{EscFeedback, clamp_current};
use crate::harness::LoopLimits;
use motor_model::{PlantMotor, PlantParams};
use motor_model::{DcMotorPlant, Gm3508Model, MotorPlant, PlantParams};
/// 把被测电机的**转速**(rpm)当作输出、**effort raw** 当作输入的被控对象。
///
@@ -715,7 +715,7 @@ pub mod plant {
/// 不同的饱和行为。
#[derive(Debug, Clone)]
pub struct SimPlant {
motor: PlantMotor,
motor: DcMotorPlant<Gm3508Model>,
limits: LoopLimits,
steps: u64,
}
@@ -724,7 +724,7 @@ pub mod plant {
/// 由参数构造(默认限幅与 [`LoopLimits::gm3508`] 一致)。
#[must_use]
pub fn new(params: PlantParams) -> Self {
Self { motor: PlantMotor::new(params), limits: LoopLimits::gm3508(), steps: 0 }
Self { motor: DcMotorPlant::new(params), limits: LoopLimits::gm3508(), steps: 0 }
}
/// 换限幅与电调型号。
@@ -789,15 +789,17 @@ pub mod plant {
self.limits.model,
raw.clamp(-self.limits.current_limit_raw, self.limits.current_limit_raw),
);
let feedback = self.motor.step(command, dt);
self.steps += 1;
return Ok(f32::from(feedback.speed_rpm));
if let Ok(feedback) = MotorPlant::step(&mut self.motor, command, dt) {
self.steps += 1;
return Ok(f32::from(feedback.speed_rpm));
}
return Ok(self.motor.speed_rpm());
}
Ok(self.motor.speed_rpm())
}
fn reset(&mut self) {
self.motor.reset();
MotorPlant::reset(&mut self.motor);
self.steps = 0;
}
@@ -926,7 +928,7 @@ pub mod rig {
use tokio::time::{MissedTickBehavior, interval};
use tracing::trace;
use motor_model::{PlantMotor, PlantParams};
use motor_model::{DcMotorPlant, Gm3508Model, MotorPlant, PlantParams};
/// 用理想 `GM3508` 模型启动指定电调 ID 的假电调组。
///
@@ -943,8 +945,8 @@ pub mod rig {
tick: Duration,
) -> JoinHandle<()> {
let specs: Vec<(u8, PlantParams)> = specs.into_iter().collect();
let mut motors: HashMap<u8, PlantMotor> =
specs.iter().map(|&(id, params)| (id, PlantMotor::new(params))).collect();
let mut motors: HashMap<u8, DcMotorPlant<Gm3508Model>> =
specs.iter().map(|&(id, params)| (id, DcMotorPlant::new(params))).collect();
let mut commands: HashMap<u8, i16> = specs.iter().map(|&(id, _)| (id, 0)).collect();
let dt = tick.as_secs_f32();
let mut ticker = interval(tick);
@@ -960,7 +962,9 @@ pub mod rig {
_ = ticker.tick() => {
for (&id, motor) in &mut motors {
let cmd = commands.get(&id).copied().unwrap_or(0);
let feedback = motor.step(cmd, dt);
let Ok(feedback) = MotorPlant::step(motor, cmd, dt) else {
continue;
};
if link
.outgoing_tx
.send(encode_feedback_frame(id, &feedback))
@@ -29,3 +29,6 @@ motor-model = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
proptest = { workspace = true }
[package.metadata.sentry-chassis]
layer = "tooling/tuning"
@@ -1591,7 +1591,7 @@ pub mod preset {
use std::path::{Path, PathBuf};
use experiment::{Scenario, SetpointProfile};
use motor_model::PlantParams;
use motor_model::{DcModelSpec, PlantParams};
use serde::{Deserialize, Serialize};
use sim_harness::PlantTolerances;
use thiserror::Error;
@@ -1724,29 +1724,29 @@ pub mod preset {
/// 离线被控对象模型。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PlantSpec {
pub enum PlantProfile {
/// 理想一阶模型(无摩擦、无延迟)。
Gm3508,
Ideal,
/// 台架量级:静 / 库仑摩擦 + 2 拍延迟。
Gm3508Bench,
Bench,
}
impl PlantSpec {
impl PlantProfile {
/// 文本名。
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Gm3508 => "gm3508",
Self::Gm3508Bench => "gm3508-bench",
Self::Ideal => "ideal",
Self::Bench => "bench",
}
}
/// 展开为被控对象参数。
#[must_use]
pub fn params(self) -> PlantParams {
pub fn params<M: DcModelSpec>(self) -> PlantParams {
match self {
Self::Gm3508 => PlantParams::gm3508(),
Self::Gm3508Bench => PlantParams::gm3508_bench(),
Self::Ideal => M::ideal_params(),
Self::Bench => M::bench_params(),
}
}
}
@@ -1921,7 +1921,7 @@ pub mod preset {
/// 离线被控对象与域随机化。
SimSpec {
/// 名义模型。
plant: PlantSpec,
plant: PlantProfile,
/// 集成成员数(1 = 只用名义模型)。
ensemble: usize,
/// 鲁棒权重:0 = 只看均值,1 = 只看最坏成员。
@@ -2040,7 +2040,7 @@ pub mod preset {
threads: Some(0),
},
sim: SimSpec {
plant: Some(PlantSpec::Gm3508Bench),
plant: Some(PlantProfile::Bench),
ensemble: Some(8),
robust_weight: Some(0.5),
gain_pct: Some(0.2),
@@ -2082,8 +2082,8 @@ pub mod preset {
///
/// 缺项([`PresetError::Missing`])或区间 / 阈值 / 预算非法
/// ([`PresetError::Invalid`])时返回错误。
pub fn to_tuning(&self) -> Result<Tuning, PresetError> {
let plant_spec = self.sim.plant.unwrap_or(PlantSpec::Gm3508Bench);
pub fn to_tuning<M: DcModelSpec>(&self) -> Result<Tuning, PresetError> {
let plant_spec = self.sim.plant.unwrap_or(PlantProfile::Bench);
Ok(Tuning {
backend: self.backend.unwrap_or(BackendSpec::Sim),
space: self.gain_space()?,
@@ -2092,7 +2092,7 @@ pub mod preset {
acceptance: self.acceptance()?,
search: self.search()?,
plant_spec,
plant: plant_spec.params(),
plant: plant_spec.params::<M>(),
tolerances: self.tolerances()?,
ensemble: self.ensemble()?,
robust_weight: self.robust_weight()?,
@@ -2417,7 +2417,7 @@ pub mod preset {
/// 搜索配置。
pub search: SearchConfig,
/// 名义被控对象(枚举,供显示 / 复现)。
pub plant_spec: PlantSpec,
pub plant_spec: PlantProfile,
/// 名义被控对象(展开后的参数,供仿真重放)。
pub plant: PlantParams,
/// 域随机化幅度。
@@ -2641,14 +2641,16 @@ top_k = 3
let text = toml::to_string(&PresetFile::builtin()).unwrap();
let round_trip = PresetFile::parse_str(&text).unwrap();
assert_eq!(round_trip, PresetFile::builtin());
assert!(round_trip.select(None).unwrap().to_tuning().is_ok());
assert!(
round_trip.select(None).unwrap().to_tuning::<motor_model::Gm3508Model>().is_ok()
);
}
#[test]
fn builtin_matches_the_historical_cli_defaults() {
// 这些数字是 chassis-tune 历史的 CLI 默认值,改动等于改变整定的默认行为,
// 必须是有意为之。
let tuning = Preset::builtin().to_tuning().unwrap();
let tuning = Preset::builtin().to_tuning::<motor_model::Gm3508Model>().unwrap();
assert_eq!(tuning.backend, BackendSpec::Sim);
assert!((tuning.space.kp.min - 0.5).abs() < 1e-12);
assert!((tuning.space.kp.max - 30.0).abs() < 1e-12);
@@ -2671,7 +2673,7 @@ top_k = 3
fn preset_overlays_only_the_fields_it_sets() {
let file = PresetFile::parse_str(MINIMAL).unwrap();
let merged = Preset::builtin().overlay(file.select(None).unwrap());
let tuning = merged.to_tuning().unwrap();
let tuning = merged.to_tuning::<motor_model::Gm3508Model>().unwrap();
assert_eq!(tuning.search.samples, 32, "预设必须生效");
assert_eq!(tuning.search.top_k, 3);
// 未提及的项沿用内置默认。
@@ -2686,7 +2688,7 @@ top_k = 3
let mut overlay = Preset::default();
overlay.space.kp = Some(RangeSpec::new(1.0, 5.0));
let merged = Preset::builtin().overlay(&overlay);
let tuning = merged.to_tuning().unwrap();
let tuning = merged.to_tuning::<motor_model::Gm3508Model>().unwrap();
assert!((tuning.space.kp.min - 1.0).abs() < 1e-12);
assert!((tuning.space.kp.max - 5.0).abs() < 1e-12);
assert_eq!(tuning.space.kp.scale, Scale::Log, "刻度必须从内置默认继承");
@@ -2759,7 +2761,9 @@ top_k = 3
let build = |body: &str| -> Result<Tuning, PresetError> {
let text = format!("version = 1\n[preset.a]\n{body}");
let file = PresetFile::parse_str(&text)?;
Preset::builtin().overlay(file.select(None)?).to_tuning()
Preset::builtin()
.overlay(file.select(None)?)
.to_tuning::<motor_model::Gm3508Model>()
};
let error =
@@ -2794,13 +2798,13 @@ top_k = 3
// 缺 scale 的轴(内置换行默认提供了刻度,这里显式抹掉)。
let mut preset = Preset::builtin();
preset.space.kd = Some(RangeSpec { min: 0.1, max: 1.0, scale: None });
let error = preset.to_tuning().unwrap_err();
let error = preset.to_tuning::<motor_model::Gm3508Model>().unwrap_err();
assert!(matches!(&error, PresetError::Invalid { field: "kd", .. }), "{error}");
}
#[test]
fn missing_keys_are_reported_for_a_bare_preset() {
let error = Preset::default().to_tuning().unwrap_err();
let error = Preset::default().to_tuning::<motor_model::Gm3508Model>().unwrap_err();
assert!(matches!(error, PresetError::Missing { .. }), "{error}");
}
@@ -2810,16 +2814,16 @@ top_k = 3
preset.scenario.profile = Some(ProfileSpec::Staircase);
preset.scenario.stair_levels = None;
assert!(matches!(
preset.to_tuning(),
preset.to_tuning::<motor_model::Gm3508Model>(),
Err(PresetError::Missing { field: "scenario.stair_levels" })
));
preset.scenario.stair_levels = Some(0);
assert!(matches!(
preset.to_tuning(),
preset.to_tuning::<motor_model::Gm3508Model>(),
Err(PresetError::Invalid { field: "scenario.stair_levels", .. })
));
preset.scenario.stair_levels = Some(5);
assert!(preset.to_tuning().is_ok());
assert!(preset.to_tuning::<motor_model::Gm3508Model>().is_ok());
}
#[test]
@@ -2854,7 +2858,7 @@ top_k = 3
assert_eq!(spec.name(), text);
assert_eq!(parse(text).value, spec);
}
assert_eq!(PlantSpec::Gm3508Bench.name(), "gm3508-bench");
assert_eq!(PlantProfile::Bench.name(), "bench");
assert_eq!(SolverSpec::NelderMead.name(), "nelder-mead");
assert_eq!(BackendSpec::Bench.name(), "bench");
let profile: Wrapper<ProfileSpec> =
@@ -16,11 +16,10 @@ thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
esc-bus = { workspace = true }
motor = { workspace = true }
motor-control = { workspace = true }
tokio = { workspace = true }
motor-protocol = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
socketcan-rs = { workspace = true }
[package.metadata.sentry-chassis]
layer = "transport/can"
@@ -19,3 +19,6 @@ serial = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
[package.metadata.sentry-chassis]
layer = "transport/serial"
@@ -0,0 +1,28 @@
[package]
name = "integration-tests"
description = "Cross-layer integration and hardware-simulation tests"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
can = { workspace = true }
esc-bus = { workspace = true }
experiment = { workspace = true }
motor = { workspace = true }
motor-control = { workspace = true }
motor-model = { workspace = true }
motor-protocol = { workspace = true }
sim-harness = { workspace = true }
socketcan = { workspace = true }
tokio = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
[package.metadata.sentry-chassis]
layer = "verification/integration"
@@ -0,0 +1 @@
//! Cross-layer integration test package.