mecha10_behavior_runtime/actions/
movement.rs1use crate::{BehaviorNode, Context, NodeStatus};
14use async_trait::async_trait;
15use mecha10_core::actuator::Twist;
16use mecha10_core::config::{controller_mode, generate_joint_command, ControllerMode};
17use mecha10_core::messages::JointState;
18use mecha10_core::topics::actuator as actuator_topics;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use std::time::{Duration, Instant};
22
23use super::elapsed_timer::{reset_timer_field, ElapsedTimer};
24
25fn default_motor_topic() -> String {
29 "/motor/cmd_vel".to_string()
30}
31
32#[derive(Debug)]
35enum MovementOutput {
36 Twist { topic: String, twist: Twist },
39 Joints { topic: String, joint_state: JointState },
41}
42
43fn resolve_movement_output(configured_topic: &str, linear: f64, angular: f64) -> MovementOutput {
61 match controller_mode() {
62 ControllerMode::Locomotion => MovementOutput::Twist {
63 topic: configured_topic.to_string(),
64 twist: Twist {
65 linear: linear as f32,
66 angular: angular as f32,
67 },
68 },
69 ControllerMode::Joints { joint_names } => {
70 let topic = if configured_topic == default_motor_topic() {
71 actuator_topics::JOINT_CMD.path().to_string()
72 } else {
73 configured_topic.to_string()
74 };
75 MovementOutput::Joints {
76 topic,
77 joint_state: joint_command(&joint_names, linear as f32, angular as f32),
78 }
79 }
80 }
81}
82
83fn joint_command(joint_names: &[String], linear: f32, angular: f32) -> JointState {
89 let mut names = Vec::new();
90 let mut positions = Vec::new();
91
92 if let Some(name) = joint_names.first() {
93 let cmd = generate_joint_command(name, linear);
94 names.extend(cmd.names);
95 positions.extend(cmd.positions);
96 }
97 if let Some(name) = joint_names.get(1) {
98 let cmd = generate_joint_command(name, angular);
99 names.extend(cmd.names);
100 positions.extend(cmd.positions);
101 }
102
103 JointState::with_positions(names, positions)
104}
105
106async fn publish_movement(ctx: &Context, configured_topic: &str, linear: f64, angular: f64) -> anyhow::Result<()> {
109 match resolve_movement_output(configured_topic, linear, angular) {
110 MovementOutput::Twist { topic, twist } => {
111 ctx.publish_to_path(&topic, &twist).await?;
112 }
113 MovementOutput::Joints { topic, joint_state } => {
114 ctx.publish_to_path(&topic, &joint_state).await?;
115 }
116 }
117 Ok(())
118}
119
120#[derive(Debug)]
121pub struct WanderNode {
122 topic: String,
123 linear_speed: f64,
124 angular_speed: f64,
125 change_interval: Duration,
126 last_change: Option<Instant>,
127 current_angular: f64,
128}
129
130#[derive(Debug, Serialize, Deserialize)]
131struct WanderConfig {
132 #[serde(default = "default_motor_topic")]
133 topic: String,
134 #[serde(default = "default_linear_speed")]
135 linear_speed: f64,
136 #[serde(default = "default_angular_speed")]
137 angular_speed: f64,
138 #[serde(default = "default_change_interval")]
139 change_interval_secs: f64,
140}
141
142fn default_linear_speed() -> f64 {
143 0.3
144}
145
146fn default_angular_speed() -> f64 {
147 0.5
148}
149
150fn default_change_interval() -> f64 {
151 3.0
152}
153
154impl WanderNode {
155 pub fn from_json(config: Value) -> anyhow::Result<Self> {
157 let cfg: WanderConfig = serde_json::from_value(config)?;
158 Ok(Self {
159 topic: cfg.topic,
160 linear_speed: cfg.linear_speed,
161 angular_speed: cfg.angular_speed,
162 change_interval: Duration::from_secs_f64(cfg.change_interval_secs),
163 last_change: None,
164 current_angular: 0.0,
165 })
166 }
167}
168
169#[async_trait]
170impl BehaviorNode for WanderNode {
171 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
172 let now = Instant::now();
173
174 if self.last_change.is_none() || now.duration_since(self.last_change.unwrap()) >= self.change_interval {
175 self.current_angular = (rand::random::<f64>() * 2.0 - 1.0) * self.angular_speed;
176 self.last_change = Some(now);
177 }
178
179 publish_movement(ctx, &self.topic, self.linear_speed, self.current_angular).await?;
180 Ok(NodeStatus::Running)
181 }
182
183 fn name(&self) -> &str {
184 "WanderNode"
185 }
186}
187
188#[derive(Debug, Default)]
189pub struct MoveNode {
190 topic: String,
191 linear: f64,
192 angular: f64,
193 duration: Option<Duration>,
194 timer: ElapsedTimer,
195}
196
197#[derive(Debug, Serialize, Deserialize)]
198struct MoveConfig {
199 #[serde(default = "default_motor_topic")]
200 topic: String,
201 linear: f64,
202 angular: f64,
203 duration_secs: Option<f64>,
204}
205
206impl MoveNode {
207 pub fn from_json(config: Value) -> anyhow::Result<Self> {
209 let cfg: MoveConfig = serde_json::from_value(config)?;
210 Ok(Self {
211 topic: cfg.topic,
212 linear: cfg.linear,
213 angular: cfg.angular,
214 duration: cfg.duration_secs.map(Duration::from_secs_f64),
215 timer: ElapsedTimer::default(),
216 })
217 }
218}
219
220#[async_trait]
221impl BehaviorNode for MoveNode {
222 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
223 if let Some(duration) = self.duration {
224 if self.timer.elapsed_since_start(duration) {
225 return Ok(NodeStatus::Success);
226 }
227 }
228
229 publish_movement(ctx, &self.topic, self.linear, self.angular).await?;
230
231 Ok(NodeStatus::Running)
232 }
233
234 reset_timer_field!(timer);
235
236 fn name(&self) -> &str {
237 "MoveNode"
238 }
239}
240
241#[derive(Debug)]
242pub struct StopNode {
243 topic: String,
244}
245
246#[derive(Debug, Serialize, Deserialize)]
247struct StopConfig {
248 #[serde(default = "default_motor_topic")]
249 topic: String,
250}
251
252impl StopNode {
253 pub fn from_json(config: Value) -> anyhow::Result<Self> {
255 let cfg: StopConfig = serde_json::from_value(config)?;
256 Ok(Self { topic: cfg.topic })
257 }
258}
259
260#[async_trait]
261impl BehaviorNode for StopNode {
262 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
263 publish_movement(ctx, &self.topic, 0.0, 0.0).await?;
264 tracing::debug!("StopNode: published stop command");
265 Ok(NodeStatus::Success)
266 }
267
268 fn name(&self) -> &str {
269 "StopNode"
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use mecha10_core::config::{ACTUATION_JOINT_NAMES_ENV_VAR, ACTUATOR_JOINT_LIMITS_ENV_VAR, CAPABILITIES_ENV_VAR};
277 use std::env;
278 use std::sync::Mutex;
279
280 static CONTROLLER_ENV_LOCK: Mutex<()> = Mutex::new(());
284
285 fn clear_controller_env() {
286 env::remove_var(CAPABILITIES_ENV_VAR);
287 env::remove_var(ACTUATION_JOINT_NAMES_ENV_VAR);
288 env::remove_var(ACTUATOR_JOINT_LIMITS_ENV_VAR);
289 }
290
291 #[test]
292 fn resolve_movement_output_is_a_twist_to_the_configured_topic_in_locomotion_mode() {
293 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
294 clear_controller_env();
295
296 let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
297
298 let MovementOutput::Twist { topic, twist } = output else {
299 panic!("expected Twist output");
300 };
301 assert_eq!(topic, "/motor/cmd_vel");
302 assert_eq!(twist.linear, 1.0);
303 assert_eq!(twist.angular, 0.5);
304
305 clear_controller_env();
306 }
307
308 #[test]
309 fn resolve_movement_output_ignores_declared_joints_when_locomotion_also_declared() {
310 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
311 clear_controller_env();
312 env::set_var(CAPABILITIES_ENV_VAR, r#"["joints","locomotion"]"#);
313 env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
314
315 let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
316
317 let MovementOutput::Twist { topic, twist } = output else {
318 panic!("expected Twist output");
319 };
320 assert_eq!(topic, "/motor/cmd_vel");
321 assert_eq!(twist.linear, 1.0);
322 assert_eq!(twist.angular, 0.5);
323
324 clear_controller_env();
325 }
326
327 #[test]
328 fn resolve_movement_output_is_a_joint_state_to_the_default_joint_topic_in_joints_mode() {
329 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
330 clear_controller_env();
331 env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
332 env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
333
334 let output = resolve_movement_output(&default_motor_topic(), 0.3, -0.2);
337
338 let MovementOutput::Joints { topic, joint_state } = output else {
339 panic!("expected Joints output");
340 };
341 assert_eq!(topic, actuator_topics::JOINT_CMD.path());
342 assert_eq!(joint_state.names, vec!["pan".to_string(), "tilt".to_string()]);
343 assert_eq!(joint_state.positions, vec![0.3, -0.2]);
344
345 clear_controller_env();
346 }
347
348 #[test]
349 fn resolve_movement_output_keeps_an_explicitly_overridden_topic_in_joints_mode() {
350 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
351 clear_controller_env();
352 env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
353 env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
354
355 let output = resolve_movement_output("/custom/joint_topic", 0.3, -0.2);
356
357 let MovementOutput::Joints { topic, .. } = output else {
358 panic!("expected Joints output");
359 };
360 assert_eq!(topic, "/custom/joint_topic");
361
362 clear_controller_env();
363 }
364
365 #[test]
366 fn resolve_movement_output_clamps_to_configured_joint_limits_in_joints_mode() {
367 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
368 clear_controller_env();
369 env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
370 env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
371 env::set_var(
372 ACTUATOR_JOINT_LIMITS_ENV_VAR,
373 r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0}]"#,
374 );
375
376 let output = resolve_movement_output(&default_motor_topic(), 5.0, 0.0);
377
378 let MovementOutput::Joints { joint_state, .. } = output else {
379 panic!("expected Joints output");
380 };
381 assert_eq!(joint_state.names, vec!["pan".to_string()]);
382 assert_eq!(joint_state.positions, vec![1.0]);
383
384 clear_controller_env();
385 }
386
387 #[test]
388 fn resolve_movement_output_drives_only_the_available_joint_when_fewer_than_two_are_configured() {
389 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
390 clear_controller_env();
391 env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
392 env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
393
394 let output = resolve_movement_output(&default_motor_topic(), 0.7, -0.4);
395
396 let MovementOutput::Joints { joint_state, .. } = output else {
397 panic!("expected Joints output");
398 };
399 assert_eq!(joint_state.names, vec!["pan".to_string()]);
400 assert_eq!(joint_state.positions, vec![0.7]);
401
402 clear_controller_env();
403 }
404
405 #[test]
406 fn wander_node_from_json_defaults_topic_to_the_locomotion_default() {
407 let node = WanderNode::from_json(serde_json::json!({})).unwrap();
408 assert_eq!(node.topic, default_motor_topic());
409 }
410
411 #[test]
412 fn move_node_from_json_defaults_topic_to_the_locomotion_default() {
413 let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0 })).unwrap();
414 assert_eq!(node.topic, default_motor_topic());
415 }
416
417 #[test]
418 fn stop_node_from_json_defaults_topic_to_the_locomotion_default() {
419 let node = StopNode::from_json(serde_json::json!({})).unwrap();
420 assert_eq!(node.topic, default_motor_topic());
421 }
422}