Skip to main content

mecha10_nodes_motor/
lib.rs

1//! Motor Node
2//!
3//! This node manages motor control with configurable controller backends.
4//! It provides velocity control through the standard `/motor/cmd_vel` topic
5//! and publishes odometry data.
6//!
7//! # Controller Selection
8//!
9//! The node supports multiple motor controllers, selected via configuration:
10//!
11//! - **mock**: Simulated motors for testing/development (no hardware required)
12//! - **l298n**: L298N dual H-bridge driver for DC motors
13//!
14//! # Topics
15//!
16//! - Subscribes: `/motor/cmd_vel` (Twist) - Velocity commands
17//! - Publishes: `/odom` (Odometry) - Odometry data
18//! - Publishes: `/motor/status` (MotorStatus) - Motor status
19//!
20//! # Configuration
21//!
22//! ```json
23//! {
24//!   "controller": {
25//!     "type": "mock",  // or "l298n"
26//!     "wheel_base": 0.3,
27//!     "max_velocity": 1.0
28//!   },
29//!   "odom_rate_hz": 50.0,
30//!   "max_linear_vel": 0.5,
31//!   "max_angular_vel": 1.0
32//! }
33//! ```
34//!
35//! See [config.rs](config.rs) for full configuration options.
36
37mod config;
38mod factory;
39
40pub use config::{ControllerConfig, L298nControllerConfig, L298nMotorPins, MockControllerConfig, MotorConfig};
41pub use factory::{create_motor_controller, BoxedMotorController, DynMotorController};
42
43use mecha10_core::actuator::{MotorStatus, Twist};
44use mecha10_core::prelude::*;
45use mecha10_core::sensor::Odometry;
46use mecha10_core::topics::Topic;
47use std::time::Duration;
48
49/// Motor node topics
50pub mod topics {
51    use super::*;
52
53    // Input topics
54    pub const CMD_VEL: Topic<Twist> = Topic::new("/motor/cmd_vel");
55
56    // Output topics
57    pub const ODOM: Topic<Odometry> = Topic::new("/odom");
58    pub const MOTOR_STATUS: Topic<MotorStatus> = Topic::new("/motor/status");
59}
60
61/// Motor node
62///
63/// Manages motor control using a configurable controller backend.
64pub struct MotorNode {
65    config: MotorConfig,
66    controller: BoxedMotorController,
67    // Odometry state (dead reckoning from commanded velocities)
68    x: f32,
69    y: f32,
70    theta: f32,
71    // Current velocity command
72    cmd_linear: f32,
73    cmd_angular: f32,
74    // Command timeout tracking
75    last_cmd_time: u64,
76    timed_out: bool,
77    // Status tracking
78    update_counter: u64,
79}
80
81impl std::fmt::Debug for MotorNode {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("MotorNode")
84            .field("config", &self.config)
85            .field("controller", &self.controller.name())
86            .field("update_counter", &self.update_counter)
87            .finish()
88    }
89}
90
91#[async_trait]
92impl NodeImpl for MotorNode {
93    type Config = MotorConfig;
94
95    async fn init(config: Self::Config) -> Result<Self> {
96        info!("Initializing motor node");
97
98        // Create controller based on config
99        let controller = factory::create_motor_controller(config.controller.clone())
100            .await
101            .map_err(|e| Mecha10Error::NodeInit(format!("Failed to create controller: {}", e)))?;
102
103        info!("Motor controller created: {}", controller.name());
104        info!(
105            "Odometry rate: {} Hz, Max linear: {} m/s, Max angular: {} rad/s",
106            config.odom_rate_hz, config.max_linear_vel, config.max_angular_vel
107        );
108
109        Ok(Self {
110            config,
111            controller,
112            x: 0.0,
113            y: 0.0,
114            theta: 0.0,
115            cmd_linear: 0.0,
116            cmd_angular: 0.0,
117            last_cmd_time: 0,
118            timed_out: false,
119            update_counter: 0,
120        })
121    }
122
123    async fn run(&mut self, ctx: &Context) -> Result<()> {
124        // Start the controller
125        self.controller
126            .start()
127            .await
128            .map_err(|e| Mecha10Error::NodeInit(format!("Failed to start controller: {}", e)))?;
129
130        // Subscribe to velocity commands
131        let mut cmd_vel_rx = ctx.subscribe(topics::CMD_VEL).await?;
132
133        // Setup timers
134        let odom_interval = Duration::from_secs_f32(1.0 / self.config.odom_rate_hz);
135        let mut odom_timer = tokio::time::interval(odom_interval);
136
137        let status_interval = Duration::from_secs_f32(1.0 / self.config.status_rate_hz);
138        let mut status_timer = tokio::time::interval(status_interval);
139
140        // Command timeout check interval (100ms)
141        let timeout_interval = Duration::from_millis(100);
142        let mut timeout_timer = tokio::time::interval(timeout_interval);
143
144        info!("Motor node running");
145        info!("Controller: {}", self.controller.name());
146        info!("Odometry rate: {} Hz", self.config.odom_rate_hz);
147        info!("Status rate: {} Hz", self.config.status_rate_hz);
148        if self.config.cmd_timeout_sec > 0.0 {
149            info!("Command timeout: {} sec", self.config.cmd_timeout_sec);
150        } else {
151            info!("Command timeout: disabled");
152        }
153        info!("Listening on: /motor/cmd_vel");
154        info!("Publishing to: /odom, /motor/status");
155
156        loop {
157            tokio::select! {
158                // Handle velocity commands
159                Some(cmd) = cmd_vel_rx.recv() => {
160                    self.handle_cmd_vel(cmd).await?;
161                }
162
163                // Periodic odometry updates
164                _ = odom_timer.tick() => {
165                    self.update_odometry();
166                    self.publish_odometry(ctx).await?;
167                    self.update_counter += 1;
168
169                    if self.update_counter % 1000 == 0 {
170                        debug!("Published {} odometry updates", self.update_counter);
171                    }
172                }
173
174                // Periodic status updates
175                _ = status_timer.tick() => {
176                    self.publish_status(ctx).await?;
177                }
178
179                // Check command timeout
180                _ = timeout_timer.tick() => {
181                    self.check_command_timeout().await?;
182                }
183            }
184        }
185    }
186
187    async fn shutdown(&mut self) -> Result<()> {
188        info!("Shutting down motor node");
189
190        // Emergency stop first
191        if let Err(e) = self.controller.emergency_stop().await {
192            warn!("Emergency stop failed: {}", e);
193        }
194
195        // Stop the controller
196        if let Err(e) = self.controller.stop().await {
197            warn!("Controller stop failed: {}", e);
198        }
199
200        info!("Motor node shutdown complete");
201        Ok(())
202    }
203}
204
205impl MotorNode {
206    /// Handle incoming velocity command
207    async fn handle_cmd_vel(&mut self, cmd: Twist) -> Result<()> {
208        // Update command timestamp
209        self.last_cmd_time = now_micros();
210        self.timed_out = false;
211
212        // Clamp velocities to configured limits
213        self.cmd_linear = cmd
214            .linear
215            .clamp(-self.config.max_linear_vel, self.config.max_linear_vel);
216        self.cmd_angular = cmd
217            .angular
218            .clamp(-self.config.max_angular_vel, self.config.max_angular_vel);
219
220        debug!(
221            "Velocity command: linear={:.2} m/s, angular={:.2} rad/s",
222            self.cmd_linear, self.cmd_angular
223        );
224
225        // Convert twist to wheel velocities using differential drive kinematics
226        let wheel_base = self.get_wheel_base();
227        let left_vel = self.cmd_linear - (self.cmd_angular * wheel_base / 2.0);
228        let right_vel = self.cmd_linear + (self.cmd_angular * wheel_base / 2.0);
229
230        // Send to controller
231        self.controller
232            .set_velocity(left_vel, right_vel)
233            .await
234            .map_err(|e| Mecha10Error::NodeInit(format!("Failed to set velocity: {}", e)))?;
235
236        Ok(())
237    }
238
239    /// Check for command timeout and stop motors if exceeded
240    async fn check_command_timeout(&mut self) -> Result<()> {
241        // Skip if timeout disabled
242        if self.config.cmd_timeout_sec <= 0.0 {
243            return Ok(());
244        }
245
246        // Skip if no command received yet
247        if self.last_cmd_time == 0 {
248            return Ok(());
249        }
250
251        let now = now_micros();
252        let timeout_micros = (self.config.cmd_timeout_sec * 1_000_000.0) as u64;
253        let elapsed = now.saturating_sub(self.last_cmd_time);
254
255        if elapsed > timeout_micros && !self.timed_out {
256            // Timeout exceeded, stop motors
257            warn!(
258                "Command timeout exceeded ({:.1}s > {:.1}s), stopping motors",
259                elapsed as f32 / 1_000_000.0,
260                self.config.cmd_timeout_sec
261            );
262
263            self.cmd_linear = 0.0;
264            self.cmd_angular = 0.0;
265            self.timed_out = true;
266
267            // Send stop command to controller
268            self.controller
269                .set_velocity(0.0, 0.0)
270                .await
271                .map_err(|e| Mecha10Error::NodeInit(format!("Failed to stop motors: {}", e)))?;
272        }
273
274        Ok(())
275    }
276
277    /// Get wheel base from controller config
278    fn get_wheel_base(&self) -> f32 {
279        match &self.config.controller {
280            ControllerConfig::Mock(cfg) => cfg.wheel_base,
281            ControllerConfig::L298n(cfg) => cfg.wheel_base,
282        }
283    }
284
285    /// Get wheel radius from controller config
286    fn get_wheel_radius(&self) -> f32 {
287        match &self.config.controller {
288            ControllerConfig::Mock(cfg) => cfg.wheel_radius,
289            ControllerConfig::L298n(cfg) => cfg.wheel_radius,
290        }
291    }
292
293    /// Update odometry using dead reckoning
294    fn update_odometry(&mut self) {
295        let dt = 1.0 / self.config.odom_rate_hz;
296
297        // Simple differential drive kinematics
298        let delta_theta = self.cmd_angular * dt;
299        let delta_x = self.cmd_linear * dt * self.theta.cos();
300        let delta_y = self.cmd_linear * dt * self.theta.sin();
301
302        self.x += delta_x;
303        self.y += delta_y;
304        self.theta += delta_theta;
305
306        // Normalize theta to [-PI, PI]
307        while self.theta > std::f32::consts::PI {
308            self.theta -= 2.0 * std::f32::consts::PI;
309        }
310        while self.theta < -std::f32::consts::PI {
311            self.theta += 2.0 * std::f32::consts::PI;
312        }
313    }
314
315    /// Publish odometry data
316    async fn publish_odometry(&self, ctx: &Context) -> Result<()> {
317        let odom = Odometry {
318            timestamp: now_micros(),
319            x: self.x,
320            y: self.y,
321            theta: self.theta,
322            linear_vel: self.cmd_linear,
323            angular_vel: self.cmd_angular,
324        };
325
326        ctx.publish_to(topics::ODOM, &odom).await?;
327        Ok(())
328    }
329
330    /// Publish motor status
331    async fn publish_status(&mut self, ctx: &Context) -> Result<()> {
332        // Try to get status from controller
333        let status = match self.controller.get_status().await {
334            Ok(s) => s,
335            Err(e) => {
336                debug!("Could not get controller status: {}", e);
337                // Fall back to calculated values
338                self.calculate_status()
339            }
340        };
341
342        ctx.publish_to(topics::MOTOR_STATUS, &status).await?;
343        Ok(())
344    }
345
346    /// Calculate motor status from commanded velocities
347    fn calculate_status(&self) -> MotorStatus {
348        // Calculate motor RPMs based on wheel kinematics
349        let wheel_base = self.get_wheel_base();
350        let wheel_radius = self.get_wheel_radius();
351        let linear_to_rpm = 60.0 / (2.0 * std::f32::consts::PI * wheel_radius);
352
353        let left_vel = self.cmd_linear - (self.cmd_angular * wheel_base / 2.0);
354        let right_vel = self.cmd_linear + (self.cmd_angular * wheel_base / 2.0);
355
356        MotorStatus {
357            timestamp: now_micros(),
358            left_motor_rpm: left_vel * linear_to_rpm,
359            right_motor_rpm: right_vel * linear_to_rpm,
360            battery_voltage: 0.0, // Unknown without sensing
361            temperature_c: 0.0,   // Unknown without sensing
362        }
363    }
364}
365
366/// Run the motor node with V2 configuration system
367pub async fn run() -> Result<()> {
368    // Create context first (needed for config loading)
369    let ctx = Context::new("motor").await?;
370
371    // Load config using V2 system with environment-aware file-level fallback
372    let config: MotorConfig = ctx.load_node_config("motor").await?;
373
374    // Run the node with Critical priority (safety-critical actuator control)
375    run_node::<MotorNode>(config, ctx, HealthReportingConfig::critical()).await
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn test_wheel_base_extraction() {
384        let mock_config = MotorConfig {
385            controller: ControllerConfig::Mock(MockControllerConfig {
386                wheel_base: 0.4,
387                ..Default::default()
388            }),
389            ..Default::default()
390        };
391
392        let l298n_config = MotorConfig {
393            controller: ControllerConfig::L298n(L298nControllerConfig {
394                wheel_base: 0.5,
395                ..Default::default()
396            }),
397            ..Default::default()
398        };
399
400        // We can't easily test get_wheel_base without creating the full node,
401        // but we can at least test that the configs are correctly structured
402        assert!(matches!(mock_config.controller, ControllerConfig::Mock(_)));
403        assert!(matches!(l298n_config.controller, ControllerConfig::L298n(_)));
404    }
405}