From f9319449c5f73f97188d032adb184fc91dbc6c97 Mon Sep 17 00:00:00 2001 From: jbernavaprah Date: Sun, 28 Jun 2026 18:45:22 +0200 Subject: [PATCH] feat(ddsm115)!: migrate the driver to the new framework (phoxal 0.15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the ddsm115 wheel-motor driver from the old phoxal 0.9 authoring surface to the new one: a single `#[derive(phoxal::Runtime)]` + `#[phoxal(id = "ddsm115", api = y2026_1)]` driver with `#[setup]`/`#[step]`/`#[shutdown]` and a blocking `phoxal::run::()`. It reads its per-instance binding via `ctx.component()` (D47/D53) and binds the dynamic per-component `motor_command` (subscribe) / `encoder_sample` (publish) topics (D17/D38) — the framework's typed contracts, no hand-rolled bus code. - Replace `src/{main,runtime}.rs` (old split + old `RuntimeProcess`/`clap`) with one `main.rs`; drop the `async-trait`/`clap`/`tokio`/`tracing` deps (the runner owns the process now) — deps are just `phoxal` + `anyhow` (+ `serde_json` dev-dep for the emit-apis test). - Pin `phoxal = "0.15"` directly (not the workspace's 0.9): cargo keeps both in the lock, so this driver builds on 0.15 while the other four catalog drivers stay on 0.9 until they migrate. `component.yaml` / URDF / simulation.yaml are unchanged (the component grammar is separate). This is the first driver on the new framework — it also confirms component drivers live here and are built from git source (not framework images), per REPOSITORIES.md. `cargo check --workspace` (0.9 + 0.15 coexisting), `cargo test -p phoxal-component-ddsm115` (3 tests), clippy, fmt all green. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 53 +++++++-- ddsm115/Cargo.toml | 13 +- ddsm115/src/main.rs | 195 ++++++++++++++++++++++++------ ddsm115/src/runtime.rs | 261 ----------------------------------------- 4 files changed, 214 insertions(+), 308 deletions(-) delete mode 100644 ddsm115/src/runtime.rs diff --git a/Cargo.lock b/Cargo.lock index 0dc8999..5bc1364 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1952,36 +1952,59 @@ dependencies = [ ] [[package]] -name = "phoxal-component-bno085" -version = "0.1.0" +name = "phoxal" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca6758cb378ea4682e8eec317b0e6fc52e50c2a46ef8e35b6236d35279604f7" dependencies = [ "anyhow", - "async-trait", + "arc-swap", "clap", - "phoxal", + "derive-new", + "dotenvy", + "nalgebra", + "phoxal-macros", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", "tokio", "tracing", + "tracing-subscriber", + "urdf-rs", + "zenoh", ] [[package]] -name = "phoxal-component-ddsm115" +name = "phoxal-component-bno085" version = "0.1.0" dependencies = [ "anyhow", "async-trait", "clap", - "phoxal", + "phoxal 0.9.0", "tokio", "tracing", ] +[[package]] +name = "phoxal-component-ddsm115" +version = "0.1.0" +dependencies = [ + "anyhow", + "phoxal 0.15.0", + "serde_json", +] + [[package]] name = "phoxal-component-oak_d_lite" version = "0.1.0" dependencies = [ "anyhow", "clap", - "phoxal", + "phoxal 0.9.0", "tokio", "tracing", ] @@ -1993,7 +2016,7 @@ dependencies = [ "anyhow", "async-trait", "clap", - "phoxal", + "phoxal 0.9.0", "tokio", "tracing", ] @@ -2004,11 +2027,23 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", - "phoxal", + "phoxal 0.9.0", "tokio", "tracing", ] +[[package]] +name = "phoxal-macros" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3296316af6a96165976e24f0f2763fc05d691be2b8ba79fec32115d27b9d84" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" diff --git a/ddsm115/Cargo.toml b/ddsm115/Cargo.toml index bf8b3d3..75c6986 100644 --- a/ddsm115/Cargo.toml +++ b/ddsm115/Cargo.toml @@ -13,10 +13,13 @@ repository.workspace = true [lints] workspace = true +# Migrated to the new framework authoring surface: the driver is `#[derive(Runtime)]` +# + `phoxal::run`, so it needs only `phoxal` + `anyhow` (no clap/tokio/async-trait in +# user code). Pinned directly to 0.15 — the other catalog drivers are still on the +# workspace's 0.9 until they migrate; cargo keeps both in the lock. [dependencies] anyhow = { workspace = true } -async-trait = { workspace = true } -clap = { workspace = true, features = ["derive", "env"] } -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "signal", "sync", "time"] } -tracing = { workspace = true } -phoxal = { workspace = true } +phoxal = "0.15" + +[dev-dependencies] +serde_json = "1" diff --git a/ddsm115/src/main.rs b/ddsm115/src/main.rs index 8ab1cac..14eb848 100644 --- a/ddsm115/src/main.rs +++ b/ddsm115/src/main.rs @@ -1,36 +1,165 @@ -mod runtime; +//! `ddsm115` — a Waveshare DDSM115 wheel-motor component driver (D54). +//! +//! Drivers are first-class users of the framework's macro + runner — there is no +//! separate `Driver` trait. The runner launches one participant **per** +//! `components.instances` entry, each with a distinct `participant_id` and its own +//! `component_instance` (D47/D53), read here via `ctx.component()`. This driver binds +//! its instance's per-component motor-command (subscribe) and encoder-sample +//! (publish) topics (dynamic keys, D17/D38), applies commands to the hardware, and +//! feeds back encoder samples. `#[shutdown]` parks the motor before the bus closes. +//! +//! This crate lives in `phoxal/components` and is built from git source by +//! `phoxal-cli` at check/deploy time (not shipped as a framework image). use anyhow::Result; -use clap::Parser; -use phoxal::runtime::{DriverRuntimeArgs, RuntimeProcess}; -use phoxal::util::init_tracing; -use tracing::info; - -use crate::runtime::Config; - -#[tokio::main] -async fn main() -> Result<()> { - init_tracing()?; - let args = DriverRuntimeArgs::parse(); - let robot = args.runtime.robot()?; - let bus = args.runtime.connect_bus().await?; - let binding = robot.driver_binding(&args.component_id)?; - let runtime_clock_ms = binding.driver.runtime_clock_ms; - let config = Config::new(&binding.component_id, binding.component)?; - - info!( - actuator = %config.actuator_id, - actuator_type = ?config.actuator_type, - encoder = ?config.encoder_id(), - "DDSM115 runtime ready" - ); - RuntimeProcess::new( - &bus, - args.simulation(), - config.clock_period(std::time::Duration::from_millis(runtime_clock_ms)), - ) - .run::(config) - .await?; - - Ok(()) +use phoxal::api::y2026_1 as api; +use phoxal::prelude::*; + +/// The motor / encoder capability names on a ddsm115 component instance +/// (matching `component.yaml`). +const MOTOR_CAPABILITY: &str = "motor"; +const ENCODER_CAPABILITY: &str = "encoder"; + +#[derive(phoxal::Runtime)] +#[phoxal(id = "ddsm115", api = y2026_1)] +struct Ddsm115 { + // Driver-private hardware state. + instance: String, + position_rad: f64, + velocity_radps: f32, + // Handles on this instance's dynamic per-component topics. + command: Subscriber, + encoder: Publisher, +} + +#[phoxal::runtime] +impl Ddsm115 { + #[setup] + async fn setup(ctx: &mut SetupContext) -> Result { + let instance = ctx.component()?.to_string(); + // Prove the instance exists in the robot model (binds this driver to it). + let _ = ctx.robot()?.component_instance(&instance)?; + + let command = ctx + .subscribe( + api::topic::new() + .component() + .motor_command(&instance, MOTOR_CAPABILITY), + ) + .subscriber() + .await?; + let encoder = ctx + .publisher( + api::topic::new() + .component() + .encoder_sample(&instance, ENCODER_CAPABILITY), + ) + .await?; + + Ok(Self { + instance, + position_rad: 0.0, + velocity_radps: 0.0, + command, + encoder, + }) + } + + #[step(hz = 100)] + async fn step(&mut self, step: StepContext) -> Result<()> { + let now = step.time(); + + // Apply the most recent command(s) to the motor. + while let Some(received) = self.command.try_recv() { + self.velocity_radps = velocity_from(received.body, self.velocity_radps); + } + + // Integrate the wheel position from the commanded velocity. + self.position_rad = integrate( + self.position_rad, + self.velocity_radps, + step.dt().as_secs_f64(), + ); + + self.encoder + .publish_at( + now, + api::component::EncoderSample { + position_rad: self.position_rad, + velocity_radps: self.velocity_radps, + }, + ) + .await?; + Ok(()) + } + + #[shutdown] + async fn shutdown(&mut self, _ctx: ShutdownContext) -> Result<()> { + // Park: command the motor to a stop. A real driver flushes the bus/CAN here + // before the session closes. + self.velocity_radps = 0.0; + let _ = &self.instance; + Ok(()) + } +} + +/// The new commanded velocity for a motor command (`Stop` → 0; `Torque` is not +/// modeled by this driver, so it holds the last velocity). +fn velocity_from(command: api::component::MotorCommand, current: f32) -> f32 { + match command { + api::component::MotorCommand::Velocity(v) => v, + api::component::MotorCommand::Stop => 0.0, + api::component::MotorCommand::Torque(_) => current, + } +} + +/// Integrate wheel position from a commanded angular velocity over `dt`. +fn integrate(position_rad: f64, velocity_radps: f32, dt_s: f64) -> f64 { + position_rad + f64::from(velocity_radps) * dt_s +} + +fn main() -> phoxal::Result<()> { + phoxal::run::() +} + +#[cfg(test)] +mod tests { + use super::{integrate, velocity_from}; + use phoxal::api::ContractBody; + use phoxal::api::y2026_1 as api; + + #[test] + fn command_maps_to_velocity() { + assert_eq!( + velocity_from(api::component::MotorCommand::Velocity(2.5), 1.0), + 2.5 + ); + assert_eq!(velocity_from(api::component::MotorCommand::Stop, 3.0), 0.0); + assert_eq!( + velocity_from(api::component::MotorCommand::Torque(9.0), 1.5), + 1.5 + ); + } + + #[test] + fn integration_advances_position() { + let p = integrate(0.0, 10.0, 0.1); + assert!((p - 1.0).abs() < 1e-9); + } + + #[test] + fn emit_apis_reports_per_component_contracts() { + let json = phoxal::runtime::emit_apis_json::(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["artifact"]["id"], "ddsm115"); + let contracts = value["required_contracts"].as_array().unwrap(); + assert!(contracts.iter().any(|c| { + c["family"] == ::FAMILY + && c["direction"] == "subscribe" + })); + assert!(contracts.iter().any(|c| { + c["family"] == ::FAMILY + && c["direction"] == "publish" + })); + } } diff --git a/ddsm115/src/runtime.rs b/ddsm115/src/runtime.rs deleted file mode 100644 index 1b97a93..0000000 --- a/ddsm115/src/runtime.rs +++ /dev/null @@ -1,261 +0,0 @@ -use std::time::Duration; - -use anyhow::{Result, bail}; -use phoxal::api::component::capability::encoder::{self, v1 as encoder_v1}; -use phoxal::api::component::capability::motor::{self, v1 as motor_v1}; -use phoxal::api::topic; -use phoxal::model::component::v1::CapabilityRef; -use phoxal::model::component::v1::capability::{Capability, MotorCommand}; -use phoxal::runtime::clock::{Schedule, SchedulePolicy, Step}; -use phoxal::runtime::{EmptyArgs, Io, RobotRuntimeArgs, Runtime, RuntimeInputs, TopicPublisher}; -use tracing::info; - -#[derive(Clone)] -pub struct Config { - pub actuator_id: CapabilityRef, - pub actuator_type: MotorCommand, - pub encoder: Option, -} - -impl Config { - pub fn new( - component_id: &str, - component: &phoxal::model::component::v1::Component, - ) -> Result { - let mut actuator_id = None; - let mut actuator_type = None; - let mut encoder = None; - - for (local_capability_id, capability) in &component.capabilities { - let capability_ref = CapabilityRef::new(component_id, local_capability_id); - match capability { - Capability::Motor(motor_config) => { - if actuator_id.is_some() { - bail!("ddsm115 supports exactly one actuator component"); - } - actuator_id = Some(capability_ref); - actuator_type = Some(motor_config.command); - } - Capability::Encoder(encoder_config) => { - if encoder.is_some() { - bail!("ddsm115 supports at most one encoder component"); - } - encoder = Some(SampledEncoder::new( - capability_ref, - encoder_config.publish_rate_hz, - )?); - } - _ => {} - } - } - - Ok(Self { - actuator_id: actuator_id - .ok_or_else(|| anyhow::anyhow!("ddsm115 requires an actuator component"))?, - actuator_type: actuator_type - .ok_or_else(|| anyhow::anyhow!("ddsm115 requires an actuator component"))?, - encoder, - }) - } - - pub fn encoder_id(&self) -> Option<&CapabilityRef> { - self.encoder.as_ref().map(|encoder| &encoder.capability) - } - - pub fn clock_period(&self, fallback: Duration) -> Duration { - self.encoder - .as_ref() - .map(SampledEncoder::clock_period) - .unwrap_or(fallback) - } -} - -#[derive(Debug, Clone)] -pub struct SampledEncoder { - capability: CapabilityRef, - publish_rate_hz: f64, -} - -impl SampledEncoder { - fn new(capability: CapabilityRef, publish_rate_hz: f64) -> Result { - if !publish_rate_hz.is_finite() || publish_rate_hz <= 0.0 { - bail!("component '{}' publish_rate_hz must be > 0", capability); - } - - Ok(Self { - capability, - publish_rate_hz, - }) - } - - fn clock_period(&self) -> Duration { - Duration::from_secs_f64(1.0 / self.publish_rate_hz) - } -} - -pub enum Input { - Command(motor_v1::Command), -} - -#[derive(Debug)] -struct StubActuatorBackend { - supported_command: MotorCommand, -} - -impl StubActuatorBackend { - async fn apply_command(&mut self, command: motor_v1::Command) -> Result<()> { - match command { - motor_v1::Command::Velocity(velocity_command) => { - self.ensure_supported(MotorCommand::Velocity)?; - info!(velocity = velocity_command, "StubActuator set velocity"); - } - motor_v1::Command::Position(position_command) => { - self.ensure_supported(MotorCommand::Position)?; - info!(position = position_command, "StubActuator set position"); - } - motor_v1::Command::Torque(torque_command) => { - self.ensure_supported(MotorCommand::Torque)?; - info!(torque = torque_command, "StubActuator set torque"); - } - } - - Ok(()) - } - - fn ensure_supported(&self, expected: MotorCommand) -> Result<()> { - if self.supported_command == expected { - Ok(()) - } else { - bail!( - "received {:?} command for {:?} motor", - expected, - self.supported_command - ) - } - } -} - -#[derive(Debug, Default)] -struct StubEncoderBackend; - -impl StubEncoderBackend { - async fn read(&mut self) -> Result { - Ok(0) - } -} - -pub struct Ddsm115Runtime { - actuator_id: CapabilityRef, - encoder_enabled: bool, - actuator_backend: StubActuatorBackend, - encoder_backend: StubEncoderBackend, - encoder_schedule: Option, - encoder_pub: Option>, -} - -impl Ddsm115Runtime { - async fn apply_command(&mut self, command: motor_v1::Command) { - if let Err(error) = self.actuator_backend.apply_command(command).await { - tracing::warn!(actuator_id = %self.actuator_id, error = %error, "motor command rejected by backend"); - } - } -} - -#[async_trait::async_trait] -impl Runtime for Ddsm115Runtime { - const RUNTIME_ID: &'static str = "ddsm115"; - - type Args = EmptyArgs; - type Config = Config; - type Input = Input; - - fn config(_args: &Self::Args, _common: &RobotRuntimeArgs) -> Result { - bail!("component drivers resolve config from DriverRuntimeArgs") - } - - fn clock_period(config: &Self::Config) -> Duration { - config.clock_period(Duration::from_millis(20)) - } - - async fn new(io: &mut Io, config: Self::Config) -> Result { - let command_topic = topic::new() - .component(&config.actuator_id.component_id) - .motor(&config.actuator_id.capability_id) - .command(); - io.subscribe_topic(command_topic, |received| match received.value { - motor::Command::V1(command) => Input::Command(command), - }) - .await?; - - let encoder_pub = declare_encoder_publisher(io, config.encoder.as_ref()).await?; - let encoder_enabled = config.encoder.is_some(); - Ok(Self { - actuator_id: config.actuator_id, - encoder_enabled, - actuator_backend: StubActuatorBackend { - supported_command: config.actuator_type, - }, - encoder_backend: StubEncoderBackend, - encoder_schedule: config.encoder.map(|encoder| { - Schedule::from_publish_hz(encoder.publish_rate_hz, SchedulePolicy::Skip) - }), - encoder_pub, - }) - } - - async fn step(&mut self, step: Step, inputs: RuntimeInputs) -> Result<()> { - for input in inputs { - match input { - Input::Command(command) => { - if !valid_command(&command) { - tracing::warn!(actuator_id = %self.actuator_id, "ignored invalid motor command"); - continue; - } - self.apply_command(command).await; - } - } - } - - let Some(schedule) = &mut self.encoder_schedule else { - return Ok(()); - }; - if !self.encoder_enabled || schedule.due_steps(step.tick.time_ns()) == 0 { - return Ok(()); - } - - if let Some(publisher) = &self.encoder_pub { - let sample = encoder_v1::Sample::new(self.encoder_backend.read().await?); - publisher - .put(step.tick.time_ns(), &encoder::Sample::V1(sample)) - .await?; - } - - Ok(()) - } -} - -fn valid_command(command: &motor_v1::Command) -> bool { - match command { - motor_v1::Command::Velocity(value) - | motor_v1::Command::Position(value) - | motor_v1::Command::Torque(value) => value.is_finite(), - } -} - -async fn declare_encoder_publisher( - io: &mut Io, - encoder: Option<&SampledEncoder>, -) -> Result>> { - let Some(encoder) = encoder else { - return Ok(None); - }; - Ok(Some( - io.publisher_topic( - topic::new() - .component(&encoder.capability.component_id) - .encoder(&encoder.capability.capability_id) - .data(), - ) - .await?, - )) -}