mecha10_behavior_runtime/actions/
set_go_to_location_target.rs1use crate::{BehaviorNode, Context, NodeStatus, TreeStatusSnapshot};
5use async_trait::async_trait;
6use mecha10_core::messages::{NavigationGoal, Pose};
7use mecha10_core::topics::navigation;
8use mecha10_core::Receiver;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
16pub(crate) struct NormalizedPoint {
17 x: f32,
18 y: f32,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub(crate) struct GoToLocationTargetRequest {
23 #[serde(default)]
24 pub(crate) point: Option<NormalizedPoint>,
25}
26
27impl mecha10_core::messages::Message for GoToLocationTargetRequest {}
28
29fn default_request_topic() -> String {
30 "/go-to-location/target/request".to_string()
31}
32
33fn default_max_distance_m() -> f32 {
34 3.0
35}
36
37fn default_horizontal_fov_rad() -> f32 {
38 std::f32::consts::FRAC_PI_3
39}
40
41pub(crate) fn project_click(point: NormalizedPoint, max_distance_m: f32, horizontal_fov_rad: f32) -> (f32, f32) {
44 let y = point.y.clamp(0.0, 1.0);
45 let x = point.x.clamp(0.0, 1.0);
46
47 let forward_m = max_distance_m * (1.0 - y);
48 let bearing_rad = (0.5 - x) * horizontal_fov_rad;
49
50 (forward_m, bearing_rad)
51}
52
53pub(crate) fn relative_goal_pose(forward_m: f32, bearing_rad: f32) -> Pose {
56 let mut pose = Pose::new_2d(
57 forward_m * bearing_rad.cos(),
58 forward_m * bearing_rad.sin(),
59 bearing_rad,
60 );
61 pose.frame_id = Some("base_link".to_string());
62 pose
63}
64
65#[derive(Debug, Serialize, Deserialize)]
66struct SetGoToLocationTargetConfig {
67 #[serde(default = "default_request_topic")]
68 request_topic: String,
69 #[serde(default = "default_max_distance_m")]
70 max_distance_m: f32,
71 #[serde(default = "default_horizontal_fov_rad")]
72 horizontal_fov_rad: f32,
73}
74
75pub struct SetGoToLocationTargetNode {
76 request_topic: String,
77 max_distance_m: f32,
78 horizontal_fov_rad: f32,
79 receiver: Option<Receiver<Value>>,
80 active: bool,
81 last_status: Option<NodeStatus>,
82}
83
84impl std::fmt::Debug for SetGoToLocationTargetNode {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 f.debug_struct("SetGoToLocationTargetNode")
87 .field("request_topic", &self.request_topic)
88 .field("max_distance_m", &self.max_distance_m)
89 .field("horizontal_fov_rad", &self.horizontal_fov_rad)
90 .field("subscribed", &self.receiver.is_some())
91 .field("active", &self.active)
92 .finish()
93 }
94}
95
96impl SetGoToLocationTargetNode {
97 pub fn from_json(config: Value) -> anyhow::Result<Self> {
100 let config = if config.is_null() {
101 serde_json::json!({})
102 } else {
103 config
104 };
105 let cfg: SetGoToLocationTargetConfig = serde_json::from_value(config)?;
106 Ok(Self {
107 request_topic: cfg.request_topic,
108 max_distance_m: cfg.max_distance_m,
109 horizontal_fov_rad: cfg.horizontal_fov_rad,
110 receiver: None,
111 active: false,
112 last_status: None,
113 })
114 }
115
116 async fn publish_goal(&self, ctx: &Context, point: NormalizedPoint) -> anyhow::Result<()> {
117 let (forward_m, bearing_rad) = project_click(point, self.max_distance_m, self.horizontal_fov_rad);
118 let goal_id = format!("go-to-location-{}", mecha10_core::prelude::now_micros());
119
120 let goal = NavigationGoal {
121 timestamp: mecha10_core::prelude::now_micros(),
122 goal_id: goal_id.clone(),
123 target: relative_goal_pose(forward_m, bearing_rad),
124 max_linear_velocity: None,
125 max_angular_velocity: None,
126 position_tolerance: None,
127 orientation_tolerance: None,
128 };
129
130 ctx.publish_to(navigation::GOAL, &goal).await?;
131 tracing::debug!(
132 "SetGoToLocationTargetNode: published goal {} (forward={:.2}m, bearing={:.2}rad)",
133 goal_id,
134 forward_m,
135 bearing_rad
136 );
137 Ok(())
138 }
139
140 async fn publish_cancel(&self, ctx: &Context) -> anyhow::Result<()> {
141 ctx.publish_to(navigation::CANCEL, &true).await?;
142 tracing::debug!("SetGoToLocationTargetNode: published cancel");
143 Ok(())
144 }
145
146 fn poll_latest_request(receiver: &mut Receiver<Value>) -> Option<Option<NormalizedPoint>> {
150 let mut latest = None;
151 while let Ok(value) = receiver.try_recv() {
152 match serde_json::from_value::<GoToLocationTargetRequest>(value) {
153 Ok(request) => latest = Some(request.point),
154 Err(e) => tracing::warn!("SetGoToLocationTargetNode: dropping malformed target request: {}", e),
155 }
156 }
157 latest
158 }
159}
160
161#[async_trait]
162impl BehaviorNode for SetGoToLocationTargetNode {
163 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
164 if self.receiver.is_none() {
165 self.receiver = Some(ctx.subscribe_raw::<Value>(&self.request_topic).await?);
166 }
167
168 let latest = Self::poll_latest_request(self.receiver.as_mut().expect("just subscribed above"));
169 if let Some(point) = latest {
170 match point {
171 Some(point) => {
172 self.publish_goal(ctx, point).await?;
173 self.active = true;
174 }
175 None => {
176 self.publish_cancel(ctx).await?;
177 self.active = false;
178 }
179 }
180 }
181
182 let status = if self.active {
183 NodeStatus::Running
184 } else {
185 NodeStatus::Failure
186 };
187 self.last_status = Some(status);
188 Ok(status)
189 }
190
191 fn name(&self) -> &str {
192 "SetGoToLocationTargetNode"
193 }
194
195 fn status_snapshot(&self) -> TreeStatusSnapshot {
196 TreeStatusSnapshot {
197 name: self.name().to_string(),
198 status: self.last_status,
199 active_capability: self.active_capability().map(String::from),
200 children: Vec::new(),
201 }
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::test_helpers::create_test_context;
209 use std::f32::consts::PI;
210
211 #[test]
212 fn project_click_maps_bottom_center_to_directly_ahead() {
213 let (forward_m, bearing_rad) = project_click(NormalizedPoint { x: 0.5, y: 1.0 }, 3.0, PI / 3.0);
214 assert_eq!(forward_m, 0.0);
215 assert_eq!(bearing_rad, 0.0);
216 }
217
218 #[test]
219 fn project_click_maps_top_center_to_max_distance_ahead() {
220 let (forward_m, bearing_rad) = project_click(NormalizedPoint { x: 0.5, y: 0.0 }, 3.0, PI / 3.0);
221 assert_eq!(forward_m, 3.0);
222 assert_eq!(bearing_rad, 0.0);
223 }
224
225 #[test]
226 fn project_click_maps_left_edge_to_a_positive_left_bearing() {
227 let (_, bearing_rad) = project_click(NormalizedPoint { x: 0.0, y: 0.5 }, 3.0, PI / 3.0);
228 assert!((bearing_rad - PI / 6.0).abs() < 1e-6);
229 }
230
231 #[test]
232 fn project_click_maps_right_edge_to_a_negative_right_bearing() {
233 let (_, bearing_rad) = project_click(NormalizedPoint { x: 1.0, y: 0.5 }, 3.0, PI / 3.0);
234 assert!((bearing_rad + PI / 6.0).abs() < 1e-6);
235 }
236
237 #[test]
238 fn project_click_clamps_out_of_range_coordinates() {
239 let (forward_m, _) = project_click(NormalizedPoint { x: 0.5, y: -1.0 }, 3.0, PI / 3.0);
240 assert_eq!(forward_m, 3.0);
241 let (forward_m, _) = project_click(NormalizedPoint { x: 0.5, y: 2.0 }, 3.0, PI / 3.0);
242 assert_eq!(forward_m, 0.0);
243 }
244
245 #[test]
246 fn relative_goal_pose_is_base_link_relative_and_faces_the_bearing() {
247 let pose = relative_goal_pose(2.0, PI / 2.0);
248 assert_eq!(pose.frame_id, Some("base_link".to_string()));
249 assert!((pose.position[0] - 0.0).abs() < 1e-5);
250 assert!((pose.position[1] - 2.0).abs() < 1e-5);
251 }
252
253 #[test]
254 fn from_json_defaults_topic_distance_and_fov() {
255 let node = SetGoToLocationTargetNode::from_json(serde_json::json!({})).unwrap();
256 assert_eq!(node.request_topic, "/go-to-location/target/request");
257 assert_eq!(node.max_distance_m, 3.0);
258 assert_eq!(node.horizontal_fov_rad, std::f32::consts::FRAC_PI_3);
259 assert!(!node.active);
260 }
261
262 #[test]
263 fn from_json_accepts_null_the_same_as_an_empty_object() {
264 let node = SetGoToLocationTargetNode::from_json(Value::Null).unwrap();
265 assert_eq!(node.request_topic, "/go-to-location/target/request");
266 }
267
268 #[test]
269 fn from_json_reads_overridden_fields() {
270 let node = SetGoToLocationTargetNode::from_json(serde_json::json!({
271 "request_topic": "/custom/request",
272 "max_distance_m": 5.0,
273 "horizontal_fov_rad": 1.0
274 }))
275 .unwrap();
276 assert_eq!(node.request_topic, "/custom/request");
277 assert_eq!(node.max_distance_m, 5.0);
278 assert_eq!(node.horizontal_fov_rad, 1.0);
279 }
280
281 #[test]
282 fn poll_latest_request_returns_none_when_empty() {
283 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
284 let mut receiver = Receiver::from(rx);
285 assert_eq!(SetGoToLocationTargetNode::poll_latest_request(&mut receiver), None);
286 }
287
288 #[test]
289 fn poll_latest_request_keeps_only_the_most_recent_click() {
290 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
291 let mut receiver = Receiver::from(rx);
292 tx.send(serde_json::json!({ "point": { "x": 0.1, "y": 0.1 } })).unwrap();
293 tx.send(serde_json::json!({ "point": { "x": 0.9, "y": 0.9 } })).unwrap();
294
295 assert_eq!(
296 SetGoToLocationTargetNode::poll_latest_request(&mut receiver),
297 Some(Some(NormalizedPoint { x: 0.9, y: 0.9 }))
298 );
299 }
300
301 #[test]
302 fn poll_latest_request_accepts_a_null_point_as_a_cancel_request() {
303 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
304 let mut receiver = Receiver::from(rx);
305 tx.send(serde_json::json!({ "point": null })).unwrap();
306
307 assert_eq!(
308 SetGoToLocationTargetNode::poll_latest_request(&mut receiver),
309 Some(None)
310 );
311 }
312
313 #[test]
314 fn poll_latest_request_drops_malformed_requests_and_keeps_polling() {
315 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
316 let mut receiver = Receiver::from(rx);
317 tx.send(serde_json::json!({ "point": "not-a-point" })).unwrap();
318 tx.send(serde_json::json!({ "point": { "x": 0.5, "y": 0.5 } })).unwrap();
319
320 assert_eq!(
321 SetGoToLocationTargetNode::poll_latest_request(&mut receiver),
322 Some(Some(NormalizedPoint { x: 0.5, y: 0.5 }))
323 );
324 }
325
326 #[tokio::test]
327 #[ignore = "Requires Redis infrastructure"]
328 async fn tick_returns_failure_until_a_click_is_received() {
329 let ctx = create_test_context("test_set_go_to_location_target_idle").await;
330
331 let mut node = SetGoToLocationTargetNode::from_json(serde_json::json!({})).unwrap();
332 assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
333 }
334
335 #[tokio::test]
336 #[ignore = "Requires Redis infrastructure"]
337 async fn tick_publishes_a_navigation_goal_for_a_relayed_click() {
338 let ctx = create_test_context("test_set_go_to_location_target_click").await;
339
340 let mut node = SetGoToLocationTargetNode::from_json(serde_json::json!({
341 "request_topic": "/go-to-location/target/request"
342 }))
343 .unwrap();
344
345 node.tick(&ctx).await.unwrap();
346
347 let mut goal_receiver = ctx.subscribe(navigation::GOAL).await.unwrap();
348 ctx.publish_raw(
349 "/go-to-location/target/request",
350 &serde_json::json!({ "point": { "x": 0.5, "y": 1.0 } }),
351 )
352 .await
353 .unwrap();
354
355 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
356 let status = node.tick(&ctx).await.unwrap();
357 assert_eq!(status, NodeStatus::Running);
358
359 let goal = tokio::time::timeout(std::time::Duration::from_millis(500), goal_receiver.recv())
360 .await
361 .expect("timed out waiting for published goal")
362 .expect("channel closed without a message");
363
364 assert_eq!(goal.target.frame_id, Some("base_link".to_string()));
365 }
366
367 #[tokio::test]
368 #[ignore = "Requires Redis infrastructure"]
369 async fn tick_publishes_cancel_for_a_cleared_click() {
370 let ctx = create_test_context("test_set_go_to_location_target_clear").await;
371
372 let mut node = SetGoToLocationTargetNode::from_json(serde_json::json!({
373 "request_topic": "/go-to-location/target/request"
374 }))
375 .unwrap();
376
377 node.tick(&ctx).await.unwrap();
378
379 let mut cancel_receiver = ctx.subscribe(navigation::CANCEL).await.unwrap();
380 ctx.publish_raw(
381 "/go-to-location/target/request",
382 &serde_json::json!({ "point": { "x": 0.5, "y": 1.0 } }),
383 )
384 .await
385 .unwrap();
386 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
387 node.tick(&ctx).await.unwrap();
388
389 ctx.publish_raw("/go-to-location/target/request", &serde_json::json!({ "point": null }))
390 .await
391 .unwrap();
392 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
393 let status = node.tick(&ctx).await.unwrap();
394 assert_eq!(status, NodeStatus::Failure);
395
396 let cancel = tokio::time::timeout(std::time::Duration::from_millis(500), cancel_receiver.recv())
397 .await
398 .expect("timed out waiting for cancel")
399 .expect("channel closed without a message");
400 assert!(cancel);
401 }
402}