1use crate::{BehaviorNode, Context, NodeStatus, TreeStatusSnapshot};
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 last_status: Option<NodeStatus>,
129}
130
131#[derive(Debug, Serialize, Deserialize)]
132struct WanderConfig {
133 #[serde(default = "default_motor_topic")]
134 topic: String,
135 #[serde(default = "default_linear_speed")]
136 linear_speed: f64,
137 #[serde(default = "default_angular_speed")]
138 angular_speed: f64,
139 #[serde(default = "default_change_interval")]
140 change_interval_secs: f64,
141}
142
143fn default_linear_speed() -> f64 {
144 0.3
145}
146
147fn default_angular_speed() -> f64 {
148 0.5
149}
150
151fn default_change_interval() -> f64 {
152 3.0
153}
154
155impl WanderNode {
156 pub fn from_json(config: Value) -> anyhow::Result<Self> {
158 let cfg: WanderConfig = serde_json::from_value(config)?;
159 Ok(Self {
160 topic: cfg.topic,
161 linear_speed: cfg.linear_speed,
162 angular_speed: cfg.angular_speed,
163 change_interval: Duration::from_secs_f64(cfg.change_interval_secs),
164 last_change: None,
165 current_angular: 0.0,
166 last_status: None,
167 })
168 }
169}
170
171#[async_trait]
172impl BehaviorNode for WanderNode {
173 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
174 let now = Instant::now();
175
176 if self.last_change.is_none() || now.duration_since(self.last_change.unwrap()) >= self.change_interval {
177 self.current_angular = (rand::random::<f64>() * 2.0 - 1.0) * self.angular_speed;
178 self.last_change = Some(now);
179 }
180
181 publish_movement(ctx, &self.topic, self.linear_speed, self.current_angular).await?;
182 self.last_status = Some(NodeStatus::Running);
183 Ok(NodeStatus::Running)
184 }
185
186 fn name(&self) -> &str {
187 "WanderNode"
188 }
189
190 fn status_snapshot(&self) -> TreeStatusSnapshot {
191 TreeStatusSnapshot {
192 name: self.name().to_string(),
193 status: self.last_status,
194 active_capability: self.active_capability().map(String::from),
195 children: Vec::new(),
196 }
197 }
198}
199
200#[derive(Debug, Default)]
201pub struct MoveNode {
202 topic: String,
203 linear: f64,
204 angular: f64,
205 duration: Option<Duration>,
206 timer: ElapsedTimer,
207 last_status: Option<NodeStatus>,
208}
209
210#[derive(Debug, Serialize, Deserialize)]
211struct MoveConfig {
212 #[serde(default = "default_motor_topic")]
213 topic: String,
214 linear: f64,
215 angular: f64,
216 duration_secs: Option<f64>,
217}
218
219impl MoveNode {
220 pub fn from_json(config: Value) -> anyhow::Result<Self> {
222 let cfg: MoveConfig = serde_json::from_value(config)?;
223 Ok(Self {
224 topic: cfg.topic,
225 linear: cfg.linear,
226 angular: cfg.angular,
227 duration: cfg.duration_secs.map(Duration::from_secs_f64),
228 timer: ElapsedTimer::default(),
229 last_status: None,
230 })
231 }
232}
233
234#[async_trait]
235impl BehaviorNode for MoveNode {
236 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
237 if let Some(duration) = self.duration {
238 if self.timer.elapsed_since_start(duration) {
239 self.last_status = Some(NodeStatus::Success);
240 return Ok(NodeStatus::Success);
241 }
242 }
243
244 publish_movement(ctx, &self.topic, self.linear, self.angular).await?;
245
246 self.last_status = Some(NodeStatus::Running);
247 Ok(NodeStatus::Running)
248 }
249
250 reset_timer_field!(timer);
251
252 fn name(&self) -> &str {
253 "MoveNode"
254 }
255
256 fn status_snapshot(&self) -> TreeStatusSnapshot {
257 TreeStatusSnapshot {
258 name: self.name().to_string(),
259 status: self.last_status,
260 active_capability: self.active_capability().map(String::from),
261 children: Vec::new(),
262 }
263 }
264}
265
266#[derive(Debug)]
267pub struct StopNode {
268 topic: String,
269 last_status: Option<NodeStatus>,
270}
271
272#[derive(Debug, Serialize, Deserialize)]
273struct StopConfig {
274 #[serde(default = "default_motor_topic")]
275 topic: String,
276}
277
278impl StopNode {
279 pub fn from_json(config: Value) -> anyhow::Result<Self> {
281 let cfg: StopConfig = serde_json::from_value(config)?;
282 Ok(Self {
283 topic: cfg.topic,
284 last_status: None,
285 })
286 }
287}
288
289#[async_trait]
290impl BehaviorNode for StopNode {
291 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
292 publish_movement(ctx, &self.topic, 0.0, 0.0).await?;
293 tracing::debug!("StopNode: published stop command");
294 self.last_status = Some(NodeStatus::Success);
295 Ok(NodeStatus::Success)
296 }
297
298 fn name(&self) -> &str {
299 "StopNode"
300 }
301
302 fn status_snapshot(&self) -> TreeStatusSnapshot {
303 TreeStatusSnapshot {
304 name: self.name().to_string(),
305 status: self.last_status,
306 active_capability: self.active_capability().map(String::from),
307 children: Vec::new(),
308 }
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use mecha10_core::config::{ACTUATION_JOINT_NAMES_ENV_VAR, ACTUATOR_JOINT_LIMITS_ENV_VAR, CAPABILITIES_ENV_VAR};
316 use std::env;
317 use std::sync::Mutex;
318
319 static CONTROLLER_ENV_LOCK: Mutex<()> = Mutex::new(());
323
324 fn clear_controller_env() {
325 env::remove_var(CAPABILITIES_ENV_VAR);
326 env::remove_var(ACTUATION_JOINT_NAMES_ENV_VAR);
327 env::remove_var(ACTUATOR_JOINT_LIMITS_ENV_VAR);
328 }
329
330 #[test]
331 fn resolve_movement_output_is_a_twist_to_the_configured_topic_in_locomotion_mode() {
332 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
333 clear_controller_env();
334
335 let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
336
337 let MovementOutput::Twist { topic, twist } = output else {
338 panic!("expected Twist output");
339 };
340 assert_eq!(topic, "/motor/cmd_vel");
341 assert_eq!(twist.linear, 1.0);
342 assert_eq!(twist.angular, 0.5);
343
344 clear_controller_env();
345 }
346
347 #[test]
348 fn resolve_movement_output_ignores_declared_joints_when_locomotion_also_declared() {
349 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
350 clear_controller_env();
351 env::set_var(CAPABILITIES_ENV_VAR, r#"["joints","locomotion"]"#);
352 env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
353
354 let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
355
356 let MovementOutput::Twist { topic, twist } = output else {
357 panic!("expected Twist output");
358 };
359 assert_eq!(topic, "/motor/cmd_vel");
360 assert_eq!(twist.linear, 1.0);
361 assert_eq!(twist.angular, 0.5);
362
363 clear_controller_env();
364 }
365
366 #[test]
367 fn resolve_movement_output_is_a_joint_state_to_the_default_joint_topic_in_joints_mode() {
368 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
369 clear_controller_env();
370 env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
371 env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
372
373 let output = resolve_movement_output(&default_motor_topic(), 0.3, -0.2);
376
377 let MovementOutput::Joints { topic, joint_state } = output else {
378 panic!("expected Joints output");
379 };
380 assert_eq!(topic, actuator_topics::JOINT_CMD.path());
381 assert_eq!(joint_state.names, vec!["pan".to_string(), "tilt".to_string()]);
382 assert_eq!(joint_state.positions, vec![0.3, -0.2]);
383
384 clear_controller_env();
385 }
386
387 #[test]
388 fn resolve_movement_output_keeps_an_explicitly_overridden_topic_in_joints_mode() {
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","tilt"]"#);
393
394 let output = resolve_movement_output("/custom/joint_topic", 0.3, -0.2);
395
396 let MovementOutput::Joints { topic, .. } = output else {
397 panic!("expected Joints output");
398 };
399 assert_eq!(topic, "/custom/joint_topic");
400
401 clear_controller_env();
402 }
403
404 #[test]
405 fn resolve_movement_output_clamps_to_configured_joint_limits_in_joints_mode() {
406 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
407 clear_controller_env();
408 env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
409 env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
410 env::set_var(
411 ACTUATOR_JOINT_LIMITS_ENV_VAR,
412 r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0}]"#,
413 );
414
415 let output = resolve_movement_output(&default_motor_topic(), 5.0, 0.0);
416
417 let MovementOutput::Joints { joint_state, .. } = output else {
418 panic!("expected Joints output");
419 };
420 assert_eq!(joint_state.names, vec!["pan".to_string()]);
421 assert_eq!(joint_state.positions, vec![1.0]);
422
423 clear_controller_env();
424 }
425
426 #[test]
427 fn resolve_movement_output_drives_only_the_available_joint_when_fewer_than_two_are_configured() {
428 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
429 clear_controller_env();
430 env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
431 env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
432
433 let output = resolve_movement_output(&default_motor_topic(), 0.7, -0.4);
434
435 let MovementOutput::Joints { joint_state, .. } = output else {
436 panic!("expected Joints output");
437 };
438 assert_eq!(joint_state.names, vec!["pan".to_string()]);
439 assert_eq!(joint_state.positions, vec![0.7]);
440
441 clear_controller_env();
442 }
443
444 #[test]
445 fn wander_node_from_json_defaults_topic_to_the_locomotion_default() {
446 let node = WanderNode::from_json(serde_json::json!({})).unwrap();
447 assert_eq!(node.topic, default_motor_topic());
448 }
449
450 #[test]
451 fn move_node_from_json_defaults_topic_to_the_locomotion_default() {
452 let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0 })).unwrap();
453 assert_eq!(node.topic, default_motor_topic());
454 }
455
456 #[test]
457 fn stop_node_from_json_defaults_topic_to_the_locomotion_default() {
458 let node = StopNode::from_json(serde_json::json!({})).unwrap();
459 assert_eq!(node.topic, default_motor_topic());
460 }
461}