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