refactor: 重构架构并整理仓库

This commit is contained in:
Horldsence
2026-09-23 10:56:57 +08:00
parent 680adae339
commit f8aeafdfc2
101 changed files with 14417 additions and 11399 deletions
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "spin"
description = "Chassis and single-motor hardware bench tool"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
bench = { workspace = true }
motor = { workspace = true }
esc-bus = { workspace = true }
experiment = { workspace = true }
motor-control = { workspace = true }
mecanum = { workspace = true }
slcan = { workspace = true }
socketcan = { workspace = true }
tokio = { workspace = true }
clap = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
+722
View File
@@ -0,0 +1,722 @@
mod chassis {
//! 实车台架工具:让**四个轮子同时**以固定**转子**转速旋转(默认 500 rpm)。
//!
//! ```sh
//! # T153 板载 CANable2(slcan 固件)→ /dev/ttyACM0,四轮 500 rpm 转子,缓升 1s 保持 10s
//! cargo run --release -p chassis-bench --bin chassis-spin -- \
//! --device /dev/ttyACM0 --rpm 500
//!
//! # 只验证链路与反馈,不发任何非零电流(接线排查用)
//! cargo run --release -p chassis-bench --bin chassis-spin -- --device /dev/ttyACM0 --dry-run
//! ```
//!
//! # 抽象边界
//!
//! 本工具是**组合根**:`dji-esc-slcan`(链路桥接)→ `dji-esc`(C620 协议 +
//! 速度环)→ `motor-core`(`VelocityDrive` / `MotorFeedback` / `Disable` 能力
//! trait)。激励场景与安全包络来自同 crate 的 `chassis-bench` 库,与
//! `chassis-tune` 的真机后端**共用同一套实现**。
//!
//! # 命令租约(重要)
//!
//! `EscBus` 的命令是**租约**:`command_timeout`(默认 50ms)内没有重新下发命令,
//! 驱动会强制 0 电流。所以控制循环必须**逐拍重发**同一个设定值——本工具在 1kHz
//! 节拍里重发(与 `chassisd` 的控制循环一致)。只在开头下发一次是**不行的**。
//!
//! # 安全
//!
//! 轮子必须架空或整车固定,急停可及。默认低限流、有限时长、ctrl-c 任意时刻
//! 安全停机(先 0 电流再关总线);未收到全部反馈前绝不下发速度。
use std::process::ExitCode;
use std::time::Duration;
use bench::{SafetyEnvelope, SafetyMonitor};
use clap::{Parser, ValueEnum};
use esc_bus::{BusOptions, EscBus, EscBusHandle, EscConfig, EscMotor};
use experiment::Scenario;
use motor::{Disable, MotorFeedback, VelocityDrive};
use motor_control::{MotorPolicy, VelocityGains};
use slcan::{CanSpeed, SlcanOptions, open_link_with};
use tokio::time::{Instant, MissedTickBehavior, interval, sleep};
/// 稳态判据:平均转子转速与目标的相对误差上限。
const TOLERANCE: f32 = 0.05;
/// 状态打印周期。
const REPORT_PERIOD: Duration = Duration::from_millis(200);
/// 反馈新鲜度阈值:`feedback_age` 超过它即视为该电调掉线。
const FRESH: Duration = Duration::from_millis(100);
/// 过速需要持续这么久才判定为失控并停机(避免单帧噪声误触发)。
const OVERSPEED_GRACE: Duration = Duration::from_millis(60);
/// 控制节拍(C620 期望 1kHz 命令)。
const CONTROL_PERIOD: Duration = Duration::from_millis(1);
/// 四轮默认名(顺序固定 FL、FR、RL、RR)。
const WHEEL_NAMES: [&str; 4] = ["front_left", "front_right", "rear_left", "rear_right"];
#[derive(Debug, Parser)]
#[command(name = "chassis-spin", version, about = "台架:四轮同时旋转(默认 500 rpm 转子)")]
struct Args {
/// slcan 串口设备(T153 板载 CANable2 为 /dev/ttyACM0)。
#[arg(long, default_value = "/dev/ttyACM0")]
device: String,
/// 串口波特率(CDC ACM 一般忽略;CH340 类适配器需与固件一致)。
#[arg(long, default_value_t = 2_000_000)]
serial_baud: u32,
/// 串口超时(毫秒)。读写共用:`serial` 的 `TTYPort::write` 也按它 poll(POLLOUT)。
#[arg(long, default_value_t = 50)]
serial_timeout_ms: u64,
/// CAN 波特率档位(C620 标准 = 1Mbps)。
#[arg(long, value_enum, default_value_t = CanSpeedArg::M1)]
can_speed: CanSpeedArg,
/// 电调 ID 列表,顺序 = FL、FR、RL、RR。
#[arg(long, value_delimiter = ',', default_value = "1,2,3,4")]
esc_ids: Vec<u8>,
/// 各轮安装方向 ±1(默认左右镜像 → 四轮物理同向)。
#[arg(
long,
value_delimiter = ',',
allow_hyphen_values = true,
default_value = "1,-1,1,-1"
)]
directions: Vec<f32>,
/// 目标**转子**转速(rpm);轮端 = 转子 ÷ 减速比。
#[arg(long, default_value_t = 500.0)]
rpm: f32,
/// 减速比(转子 : 输出轴,GM3508 ≈ 19.203)。
#[arg(long, default_value_t = 19.203)]
gear_ratio: f32,
/// 电流限幅(raw,C620 满量程 16384)。
///
/// 默认 1500 raw(≈1.8A):要越过静摩擦(实测约 400~600 raw)且留出整定余量。
/// 上限同时也是 PID 抗饱和的钳位点——**必须贴近实际所需电流**,
/// 否则积分会在堵转期间积累,一旦起动就冲过头。
#[arg(long, default_value_t = 1500)]
current_limit: i16,
/// 缓升时间:0 → 目标转速(毫秒)。
///
/// 默认 3000:空载 GM3508 的静摩擦需要约 400~600 raw 才能起动(stick-slip),
/// 缓升让积分在起动前平滑建立,避免"先憋住、一动就飞"。
#[arg(long, default_value_t = 3000)]
ramp_ms: u64,
/// 目标转速保持时长(秒)。
#[arg(long, default_value_t = 10.0)]
hold_secs: f32,
/// 等待全部电调反馈的超时(毫秒)。
#[arg(long, default_value_t = 3000)]
startup_timeout_ms: u64,
/// 只验证链路与反馈,不发出任何非零电流。
#[arg(long)]
dry_run: bool,
/// 允许缺失部分电调:仅驱动有反馈的电调继续测试(默认要求四个电调全部在线)。
#[arg(long)]
allow_partial: bool,
/// 速度环 kp(raw/rpm)。
///
/// 空载台架默认 2.0:被控对象近似积分器(≈12 rpm/s per raw),
/// 高 kp(如 8~18)会因反馈延迟震荡;低 kp 配合大 ki 才稳。
#[arg(long, default_value_t = 2.0)]
kp: f32,
/// 速度环 ki(raw/(rpm·s))。
///
/// 空载台架默认 10:静摩擦需要积分项缓慢"顶"到起动电流,ki 太小会长期
/// 停在 300~400 rpm 达不到目标;抗饱和由 `current_limit` 钳位兜底,
/// 所以大 ki 不会跑飞。
#[arg(long, default_value_t = 10.0)]
ki: f32,
/// 速度环 kd。
#[arg(long, default_value_t = 0.0)]
kd: f32,
/// 速度前馈(raw/rpm)。
///
/// **空载台架默认 0**:轮子架空时维持转速只需要克服摩擦的极小电流,
/// 任何接近 1/K(GM3508 空载 K ≈ 0.57 rpm/raw → 1/K ≈ 1.77)的前馈都会把
/// 空载轮子直接推到最高转速。只有带载工况才需要正前馈。
#[arg(long, default_value_t = 0.0)]
feedforward: f32,
/// 过速保护上限(rpm):任一参与测试的电调超过它即立即停机。
/// 默认 = 目标 × 2 + 200。
#[arg(long)]
max_rpm: Option<f32>,
}
/// CAN 波特率档位(对应 slcan 的 `Sn` 命令)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CanSpeedArg {
/// `S8`:1 Mbps。
#[value(name = "1m")]
M1,
/// `S6`:500 kbps。
#[value(name = "500k")]
K500,
}
impl From<CanSpeedArg> for CanSpeed {
fn from(value: CanSpeedArg) -> Self {
match value {
CanSpeedArg::M1 => Self::M1,
CanSpeedArg::K500 => Self::K500,
}
}
}
impl Args {
/// 过速保护上限:显式 `--max-rpm` 优先,否则目标 × 2 + 200。
fn overspeed_limit(&self) -> f32 {
self.max_rpm.unwrap_or(self.rpm * 2.0 + 200.0)
}
/// 参数自检:越界参数在碰硬件之前就拒绝。
fn validate(&self) -> Result<(), String> {
if self.esc_ids.is_empty() {
return Err("--esc-ids 不能为空".into());
}
if self.esc_ids.len() != self.directions.len() {
return Err(format!(
"--esc-ids({} 个)与 --directions({} 个)数量必须一致",
self.esc_ids.len(),
self.directions.len()
));
}
for &id in &self.esc_ids {
if !(1..=8).contains(&id) {
return Err(format!("电调 ID {id} 超出 1..=8"));
}
}
for &direction in &self.directions {
let bits = direction.to_bits();
if bits != 1.0f32.to_bits() && bits != (-1.0f32).to_bits() {
return Err(format!("方向必须是 +1 或 -1,得到 {direction}"));
}
}
if !(self.rpm.is_finite() && self.rpm >= 0.0) {
return Err(format!("--rpm 必须 ≥ 0,得到 {}", self.rpm));
}
if let Some(max) = self.max_rpm
&& !(max.is_finite() && max > self.rpm)
{
return Err(format!("--max-rpm 必须大于目标转速 {},得到 {max}", self.rpm));
}
if !(self.gear_ratio.is_finite() && self.gear_ratio > 0.0) {
return Err(format!("--gear-ratio 必须 > 0,得到 {}", self.gear_ratio));
}
if !(1..=16384).contains(&self.current_limit) {
return Err(format!(
"--current-limit 必须在 1..=16384,得到 {}",
self.current_limit
));
}
if self.hold_secs < 0.0 {
return Err(format!("--hold-secs 不能为负,得到 {}", self.hold_secs));
}
Ok(())
}
/// 四个电调配置(方向、减速比、电流限幅、PID 全部显式落地)。
fn esc_configs(&self) -> Vec<EscConfig> {
self.esc_ids
.iter()
.enumerate()
.map(|(index, &id)| {
let name = WHEEL_NAMES
.get(index)
.map_or_else(|| format!("wheel{id}"), |name| (*name).to_owned());
let mut cfg = EscConfig::gm3508(name, id, self.directions[index]);
cfg.gear_ratio = self.gear_ratio;
cfg.current_limit = self.current_limit;
cfg
})
.collect()
}
fn control_policy(&self) -> MotorPolicy {
let mut policy = MotorPolicy::new();
let gains = VelocityGains::new(self.kp, self.ki, self.kd, self.feedforward);
for id in &self.esc_ids {
policy.configure(*id, self.current_limit, gains);
}
policy
}
}
fn bus_options() -> BusOptions {
BusOptions {
period: CONTROL_PERIOD,
feedback_timeout: Duration::from_millis(100),
// 命令租约 50ms;控制循环每 1ms 续租。
command_timeout: Duration::from_millis(50),
}
}
pub async fn main_with_args(parameters: Vec<String>) -> ExitCode {
init_tracing();
let args =
Args::parse_from(std::iter::once(String::from("spin-chassis")).chain(parameters));
if let Err(problem) = args.validate() {
eprintln!("✗ 参数错误:{problem}");
return ExitCode::FAILURE;
}
print_banner(&args);
match run(&args).await {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::FAILURE,
Err(e) => {
eprintln!("✗ 运行失败:{e}");
ExitCode::FAILURE
}
}
}
/// 初始化日志:链路桥的读写失败/静默告警必须可见,
/// 否则现场只剩一句"没有反馈",无法区分适配器故障与整车接线问题。
fn init_tracing() {
let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("warn,slcan=debug,socketcan=debug")
});
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
}
fn print_banner(args: &Args) {
println!("=== 台架:四轮同时旋转(转子目标 {:.1} rpm)===", args.rpm);
println!(
"设备 {} | CAN {:?} | 电调 {:?} | 方向 {:?} | 减速比 {:.3}",
args.device, args.can_speed, args.esc_ids, args.directions, args.gear_ratio
);
println!(
"转子 {:.1} rpm ÷ {:.3} = 轮端 {:.2} rpm | 电流限幅 {} raw | 缓升 {}ms | 保持 {:.1}s{}",
args.rpm,
args.gear_ratio,
args.rpm / args.gear_ratio,
args.current_limit,
args.ramp_ms,
args.hold_secs,
if args.dry_run {
" | dry-run"
} else if args.allow_partial {
" | allow-partial"
} else {
""
}
);
if args.dry_run {
println!("ℹ dry-run:只读反馈,不发出非零电流。\n");
} else {
println!(
"速度环 kp={} ki={} kd={} ff={} | 过速保护 {:.0} rpm",
args.kp,
args.ki,
args.kd,
args.feedforward,
args.overspeed_limit()
);
println!("⚠ 轮子必须架空或整车固定,急停可及;ctrl-c 随时安全停机。\n");
}
}
/// 打开链路 → 装配协议 bus → 执行 → **任何路径都先停电流再关总线**。
async fn run(args: &Args) -> Result<bool, Box<dyn std::error::Error>> {
let options = SlcanOptions {
serial_baud: args.serial_baud,
speed: args.can_speed.into(),
// 读写共用该超时(serial crate 的 TTYPort::write 也先 poll(POLLOUT)):
// 设太小会把"这一拍写不出去"误判成写失败。总线繁忙时读立即返回,
// 放大它只影响总线完全静默时的下行延迟。
read_timeout: Duration::from_millis(args.serial_timeout_ms),
};
let link = open_link_with(&args.device, options).map_err(|e| {
format!("打开 slcan 设备 {} 失败:{e}(检查设备路径 / 权限 / 是否被占用)", args.device)
})?;
println!("✓ slcan 已打开:{}(CAN {:?})", args.device, args.can_speed);
let bus = EscBus::spawn(link, args.esc_configs(), bus_options(), args.control_policy())?;
let outcome = drive(args, &bus).await;
// 安全收尾:先 0 电流并让它至少发出一拍,再关总线。
for motor in bus.motors() {
motor.disable();
}
sleep(Duration::from_millis(50)).await;
bus.shutdown().await;
println!("已停机(0 电流)并关闭总线");
outcome
}
async fn drive(
args: &Args,
bus: &EscBusHandle<MotorPolicy>,
) -> Result<bool, Box<dyn std::error::Error>> {
let motors = bus.motors();
wait_for_feedback(motors, Duration::from_millis(args.startup_timeout_ms)).await;
let targets: Vec<EscMotor> = motors.iter().filter(|m| is_fresh(m)).cloned().collect();
let missing: Vec<u8> =
motors.iter().filter(|m| !is_fresh(m)).map(MotorFeedback::id).collect();
if !missing.is_empty() {
eprintln!(
"✗ 以下电调没有新鲜反馈:{missing:?}(收到 {:?})",
motors.iter().map(MotorFeedback::feedback_age).collect::<Vec<_>>()
);
eprintln!(
" 排查:CAN 波特率(C620 = 1Mbps = S8)| CANH/CANL 是否接反 | 120Ω 终端电阻 | 电调供电 | 电调 CAN ID 拨码 | 设备 {}",
args.device
);
if !args.allow_partial {
eprintln!(" 若确认台架只接了部分电调,可加 --allow-partial 降级测试。");
return Ok(false);
}
eprintln!("⚠ --allow-partial:仅驱动有反馈的电调继续测试。\n");
}
if targets.is_empty() {
eprintln!("✗ 没有任何电调在反馈,无法测试。");
return Ok(false);
}
println!(
"✓ 参与测试的电调:{:?}(新鲜度 < {FRESH:?})\n",
targets.iter().map(MotorFeedback::id).collect::<Vec<_>>()
);
if args.dry_run {
return Ok(dry_run(&targets).await);
}
Ok(spin(args, &targets).await)
}
/// 该电调是否有**新鲜**反馈(而非历史上有过反馈)。
fn is_fresh(motor: &EscMotor) -> bool {
motor.feedback_age().is_some_and(|age| age <= FRESH)
}
/// 上电门控:等到每个电调都有一帧**新鲜**反馈;超时即返回 `false`。
async fn wait_for_feedback(motors: &[EscMotor], timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if motors.iter().all(is_fresh) {
return true;
}
if Instant::now() >= deadline {
return false;
}
sleep(Duration::from_millis(20)).await;
}
}
/// dry-run:只观察反馈,不下发任何非零电流(总线仍在发 0 电流帧)。
async fn dry_run(targets: &[EscMotor]) -> bool {
let mut ticker = interval(REPORT_PERIOD);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
ticker.tick().await;
for motor in targets {
let st = motor.state();
println!(
" {:<11} esc{} | 转子 {:>7.1} rpm | 温度 {:>3}℃ | 反馈 {:>6} 帧 | 新鲜度 {:?}",
motor.name(),
motor.id(),
st.rotor_speed_rpm,
st.temperature_c,
st.feedback_count,
motor.feedback_age().map(|age| age.as_millis())
);
}
println!();
}
println!("✓ 链路与反馈验证完成(未发出非零电流)");
true
}
/// 本次台架的安全包络:只开过速保护(`chassis-spin` 的历史行为)。
fn envelope(args: &Args) -> SafetyEnvelope {
SafetyEnvelope::overspeed_only(args.overspeed_limit(), OVERSPEED_GRACE)
}
/// 缓升 → 保持 → 采样;返回稳态是否达标。
async fn spin(args: &Args, motors: &[EscMotor]) -> bool {
let ramp = Duration::from_millis(args.ramp_ms);
let hold = Duration::from_secs_f32(args.hold_secs);
let overspeed_limit = args.overspeed_limit();
// 设定值曲线与安全判定都来自 chassis-bench 库:与 chassis-tune 的真机
// 后端共用同一套实现,避免"台架测的行为"和"整定优化的行为"分叉。
let scenario = Scenario::ramp_and_hold(args.rpm, ramp.as_secs_f32(), hold.as_secs_f32());
let mut ticker = interval(CONTROL_PERIOD);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let start = Instant::now();
let mut monitor = SafetyMonitor::new(envelope(args), start);
let mut next_report = REPORT_PERIOD;
let mut samples: Vec<Vec<f32>> = vec![Vec::new(); motors.len()];
let mut overspeed_warned = false;
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
println!("\n收到 ctrl-c,中断测试");
return false;
}
_ = ticker.tick() => {}
}
let elapsed = start.elapsed();
if elapsed >= ramp + hold {
break;
}
// 逐拍重发(= 命令租约续期),斜坡段目标线性上升。
let setpoint = scenario.setpoint_at(elapsed.as_secs_f32());
for motor in motors {
motor.set_velocity(setpoint);
}
// 过速保护:控制器失稳时立刻停机,别让轮子飞到最高转速。
let peak =
motors.iter().map(|m| m.state().rotor_speed_rpm.abs()).fold(0.0f32, f32::max);
if peak > overspeed_limit && !overspeed_warned {
overspeed_warned = true;
eprintln!("⚠ 过速 {peak:.0} rpm > 上限 {overspeed_limit:.0} rpm,观察中……");
}
if let Some(abort) = monitor.check(motors, Instant::now()) {
eprintln!(
"✗ 过速保护触发:{peak:.0} rpm 超过上限 {overspeed_limit:.0} rpm,立即停机({abort})。\n 速度环整定不当(kp/ki/前馈过大)——空载台架前馈应为 0。"
);
return false;
}
if elapsed >= ramp {
for (slot, motor) in samples.iter_mut().zip(motors) {
slot.push(motor.state().rotor_speed_rpm);
}
}
if elapsed >= next_report {
report_line(motors, setpoint, elapsed);
next_report += REPORT_PERIOD;
}
}
summarize(args, motors, &samples)
}
fn report_line(motors: &[EscMotor], setpoint: f32, elapsed: Duration) {
let rotor: Vec<String> =
motors.iter().map(|m| format!("{:>7.1}", m.state().rotor_speed_rpm)).collect();
let current: Vec<String> =
motors.iter().map(|m| format!("{:>6}", m.state().effort_raw)).collect();
let temp: Vec<String> =
motors.iter().map(|m| format!("{:>3}", m.state().temperature_c)).collect();
println!(
"[t={:>5.1}s] 目标 {:>7.1} | 转子 {} | 电流 {} | 温度 {}",
elapsed.as_secs_f32(),
setpoint,
rotor.join(" "),
current.join(" "),
temp.join(" ")
);
}
fn summarize(args: &Args, motors: &[EscMotor], samples: &[Vec<f32>]) -> bool {
if samples.iter().all(Vec::is_empty) {
println!("保持时长为 0,未采集到稳态样本");
return true;
}
println!(
"\n=== 稳态结论(目标转子 {:.1} rpm ≈ 轮端 {:.2} rpm)===",
args.rpm,
args.rpm / args.gear_ratio
);
let mut all_ok = true;
for (index, motor) in motors.iter().enumerate() {
let mean = mean(&samples[index]);
let error = if args.rpm.abs() > f32::EPSILON {
(mean - args.rpm).abs() / args.rpm.abs()
} else {
0.0
};
let ok = error <= TOLERANCE;
all_ok &= ok;
println!(
" {:<11} esc{} | 稳态 {:>7.1} rpm | 轮端 {:>6.2} rpm | 误差 {:>5.1}% | 电流 {:>6} raw | 温度 {:>3}℃ | {}",
motor.name(),
motor.id(),
mean,
mean / args.gear_ratio,
error * 100.0,
motor.state().effort_raw,
motor.state().temperature_c,
if ok { "✓" } else { "✗" }
);
}
if all_ok {
println!("\n✓ 通过:{} 个轮子均达到目标转速 ±{:.0}%", motors.len(), TOLERANCE * 100.0);
} else {
println!(
"\n✗ 未通过:存在轮速偏差 > {:.0}%,检查 PID / 机械阻力 / 电流限幅 / 方向配置",
TOLERANCE * 100.0
);
}
all_ok
}
fn mean(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
samples.iter().sum::<f32>() / samples.len() as f32
}
}
mod motor_one {
//! 单电机验证工具:让一个 C620 / GM3508 转起来,验证电机抽象全链路。
//!
//! ```sh
//! socketcan-spin [interface] [esc_id] [rotor_rpm] [duration_secs]
//! # 例:can0 上电调 1 以 1000 rpm(转子)转 10 秒
//! cargo run --release -p bench --bin socketcan-spin -- can0 1 1000 10
//! ```
//!
//! 关键点在 `spin_one`:它只依赖 `motor::Motor` 抽象,完全不知道
//! 背后是 C620 协议还是别的电机——换任何实现该 trait 的电机,这段代码
//! 原样可用。抽象(`can / imu`)、协议实现(`motor-protocol`)、设备层(`motor / esc-bus`)与总线后端(`driver crates`)在此汇合。
//!
//! 仅 Linux(需要 SocketCAN);其他平台编译为报错桩。
#[cfg(target_os = "linux")]
use std::time::Duration;
#[cfg(target_os = "linux")]
use esc_bus::{BusOptions, EscBus, EscConfig};
#[cfg(target_os = "linux")]
use motor::Motor;
#[cfg(target_os = "linux")]
use motor_control::{MotorPolicy, VelocityGains};
#[cfg(target_os = "linux")]
use socketcan::open_link;
#[cfg(target_os = "linux")]
use tokio::time::{MissedTickBehavior, interval};
#[cfg(target_os = "linux")]
pub async fn main_with_args(argv: Vec<String>) {
let mut args = argv.into_iter();
let interface = args.next().unwrap_or_else(|| "can0".into());
let esc_id: u8 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1);
let rotor_rpm: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1000.0);
let duration_secs: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(10.0);
println!("=== 单电机抽象验证 ===");
println!(
"接口 {interface} | 电调 {esc_id} | 目标 {rotor_rpm} rpm(转子)| 时长 {duration_secs}s"
);
let link = open_link(&interface).unwrap_or_else(|e| {
eprintln!(
"打开 {interface} 失败:{e}(先 ip link set {interface} up type can bitrate 1000000)"
);
std::process::exit(1);
});
let config = EscConfig::gm3508("spin_test", esc_id, 1.0);
println!(
"电机配置:{:?} | 减速比 {:.3} | 电流限幅 {}",
config.model, config.gear_ratio, config.current_limit
);
let mut policy = MotorPolicy::new();
policy.configure(esc_id, config.current_limit, VelocityGains::new(10.0, 0.5, 0.0, 1.5));
let bus = EscBus::spawn(link, vec![config], BusOptions::default(), policy)
.expect("EscBus 配置非法");
let motor = bus.motor(esc_id).expect("刚配置的电机必然存在");
spin_one(motor, rotor_rpm, Duration::from_secs_f32(duration_secs)).await;
bus.shutdown().await;
println!("已退出");
}
/// 验证目标:本函数只面向 [`Motor`] 抽象——设定转速、周期读状态、停机。
/// 它不知道 CAN,不知道 C620,换任何 Motor 实现都原样工作。
///
/// 注意**命令租约**:`BusOptions::command_timeout` 默认 50ms,超过该时长未重发
/// 命令,驱动会强制 0 电流。因此这里用 10ms 的租约拍持续重发同一个设定值
/// (真实底盘的控制循环每 1ms 重发一次)。
#[cfg(target_os = "linux")]
async fn spin_one(motor: &impl Motor, target_rpm: f32, duration: Duration) {
const LEASE_PERIOD: Duration = Duration::from_millis(10);
const REPORT_PERIOD: Duration = Duration::from_millis(100);
motor.set_velocity(target_rpm);
println!("已下发目标转速,开始观测(100ms 周期)……\n");
let mut lease = interval(LEASE_PERIOD);
lease.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut report = interval(REPORT_PERIOD);
report.set_missed_tick_behavior(MissedTickBehavior::Skip);
let deadline = tokio::time::Instant::now() + duration;
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
println!("\n收到 ctrl-c");
break;
}
_ = lease.tick() => motor.set_velocity(target_rpm), // 续租
_ = report.tick() => {
let st = motor.state();
if st.has_feedback() {
println!(
"转子 {:>8.1} rpm | effort {:>6} raw | 温度 {:>3} ℃ | 累计角度 {:>8.3} 圈 | 反馈 {} 帧",
st.rotor_speed_rpm,
st.effort_raw,
st.temperature_c,
st.rotor_revolutions,
st.feedback_count
);
} else {
println!("⚠ 尚未收到任何反馈——检查电调供电与 CAN 接线");
}
}
}
if tokio::time::Instant::now() >= deadline {
break;
}
}
motor.disable();
// 让 0 电流帧至少发出一拍再关总线。
tokio::time::sleep(Duration::from_millis(50)).await;
println!("已停机(0 电流)");
}
#[cfg(not(target_os = "linux"))]
#[allow(clippy::unused_async)]
pub async fn main_with_args(_argv: Vec<String>) {
eprintln!("spin motor 仅支持 Linux(需要 SocketCAN)");
}
}
#[tokio::main]
async fn main() -> std::process::ExitCode {
let mut args = std::env::args().skip(1);
match args.next().as_deref() {
Some("chassis") => chassis::main_with_args(args.collect()).await,
Some("motor") => {
motor_one::main_with_args(args.collect()).await;
std::process::ExitCode::SUCCESS
}
Some("--help" | "-h") => {
println!("usage: spin <chassis|motor> [options]");
std::process::ExitCode::SUCCESS
}
_ => {
eprintln!("usage: spin <chassis|motor> [options]");
std::process::ExitCode::from(2)
}
}
}