Skip to main content

mecha10_behavior_runtime/actions/
set_go_to_location_target.rs

1//! Action node that turns a clicked point on the dashboard's video feed into a
2//! `go-to-location` navigation goal, expressed in the robot's own frame.
3
4use crate::{BehaviorNode, Context, NodeStatus};
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/// A click on the video feed, normalized to the video frame: `(0, 0)` is the top-left corner,
13/// `(1, 1)` is the bottom-right corner. `y = 1` (bottom of frame) is treated as directly in
14/// front of the robot; `y = 0` (top of frame) is treated as `max_distance_m` away.
15#[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
41/// Projects a normalized click into a `(forward_m, bearing_rad)` polar offset in the robot's
42/// own frame, using a simple ground-plane approximation.
43pub(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
53/// Builds the `base_link`-relative goal `Pose` for a projected `(forward_m, bearing_rad)`
54/// offset: the target point itself, facing along the bearing to it.
55pub(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}
82
83impl std::fmt::Debug for SetGoToLocationTargetNode {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.debug_struct("SetGoToLocationTargetNode")
86            .field("request_topic", &self.request_topic)
87            .field("max_distance_m", &self.max_distance_m)
88            .field("horizontal_fov_rad", &self.horizontal_fov_rad)
89            .field("subscribed", &self.receiver.is_some())
90            .field("active", &self.active)
91            .finish()
92    }
93}
94
95impl SetGoToLocationTargetNode {
96    /// Create a new node from JSON configuration. `null` is normalized to `{}` so this node can
97    /// be referenced with no config at all, matching `SetInstanceTrackTargetNode::from_json`.
98    pub fn from_json(config: Value) -> anyhow::Result<Self> {
99        let config = if config.is_null() {
100            serde_json::json!({})
101        } else {
102            config
103        };
104        let cfg: SetGoToLocationTargetConfig = serde_json::from_value(config)?;
105        Ok(Self {
106            request_topic: cfg.request_topic,
107            max_distance_m: cfg.max_distance_m,
108            horizontal_fov_rad: cfg.horizontal_fov_rad,
109            receiver: None,
110            active: false,
111        })
112    }
113
114    async fn publish_goal(&self, ctx: &Context, point: NormalizedPoint) -> anyhow::Result<()> {
115        let (forward_m, bearing_rad) = project_click(point, self.max_distance_m, self.horizontal_fov_rad);
116        let goal_id = format!("go-to-location-{}", mecha10_core::prelude::now_micros());
117
118        let goal = NavigationGoal {
119            timestamp: mecha10_core::prelude::now_micros(),
120            goal_id: goal_id.clone(),
121            target: relative_goal_pose(forward_m, bearing_rad),
122            max_linear_velocity: None,
123            max_angular_velocity: None,
124            position_tolerance: None,
125            orientation_tolerance: None,
126        };
127
128        ctx.publish_to(navigation::GOAL, &goal).await?;
129        tracing::debug!(
130            "SetGoToLocationTargetNode: published goal {} (forward={:.2}m, bearing={:.2}rad)",
131            goal_id,
132            forward_m,
133            bearing_rad
134        );
135        Ok(())
136    }
137
138    async fn publish_cancel(&self, ctx: &Context) -> anyhow::Result<()> {
139        ctx.publish_to(navigation::CANCEL, &true).await?;
140        tracing::debug!("SetGoToLocationTargetNode: published cancel");
141        Ok(())
142    }
143
144    /// Drains every pending request off `receiver`, returning the last one seen (if any) - only
145    /// the most recent click matters. A request with `point: null` (or no `point` key at all)
146    /// means "cancel the current goal".
147    fn poll_latest_request(receiver: &mut Receiver<Value>) -> Option<Option<NormalizedPoint>> {
148        let mut latest = None;
149        while let Ok(value) = receiver.try_recv() {
150            match serde_json::from_value::<GoToLocationTargetRequest>(value) {
151                Ok(request) => latest = Some(request.point),
152                Err(e) => tracing::warn!("SetGoToLocationTargetNode: dropping malformed target request: {}", e),
153            }
154        }
155        latest
156    }
157}
158
159#[async_trait]
160impl BehaviorNode for SetGoToLocationTargetNode {
161    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
162        if self.receiver.is_none() {
163            self.receiver = Some(ctx.subscribe_raw::<Value>(&self.request_topic).await?);
164        }
165
166        let latest = Self::poll_latest_request(self.receiver.as_mut().expect("just subscribed above"));
167        if let Some(point) = latest {
168            match point {
169                Some(point) => {
170                    self.publish_goal(ctx, point).await?;
171                    self.active = true;
172                }
173                None => {
174                    self.publish_cancel(ctx).await?;
175                    self.active = false;
176                }
177            }
178        }
179
180        Ok(if self.active {
181            NodeStatus::Running
182        } else {
183            NodeStatus::Failure
184        })
185    }
186
187    fn name(&self) -> &str {
188        "SetGoToLocationTargetNode"
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::test_helpers::create_test_context;
196    use std::f32::consts::PI;
197
198    #[test]
199    fn project_click_maps_bottom_center_to_directly_ahead() {
200        let (forward_m, bearing_rad) = project_click(NormalizedPoint { x: 0.5, y: 1.0 }, 3.0, PI / 3.0);
201        assert_eq!(forward_m, 0.0);
202        assert_eq!(bearing_rad, 0.0);
203    }
204
205    #[test]
206    fn project_click_maps_top_center_to_max_distance_ahead() {
207        let (forward_m, bearing_rad) = project_click(NormalizedPoint { x: 0.5, y: 0.0 }, 3.0, PI / 3.0);
208        assert_eq!(forward_m, 3.0);
209        assert_eq!(bearing_rad, 0.0);
210    }
211
212    #[test]
213    fn project_click_maps_left_edge_to_a_positive_left_bearing() {
214        let (_, bearing_rad) = project_click(NormalizedPoint { x: 0.0, y: 0.5 }, 3.0, PI / 3.0);
215        assert!((bearing_rad - PI / 6.0).abs() < 1e-6);
216    }
217
218    #[test]
219    fn project_click_maps_right_edge_to_a_negative_right_bearing() {
220        let (_, bearing_rad) = project_click(NormalizedPoint { x: 1.0, y: 0.5 }, 3.0, PI / 3.0);
221        assert!((bearing_rad + PI / 6.0).abs() < 1e-6);
222    }
223
224    #[test]
225    fn project_click_clamps_out_of_range_coordinates() {
226        let (forward_m, _) = project_click(NormalizedPoint { x: 0.5, y: -1.0 }, 3.0, PI / 3.0);
227        assert_eq!(forward_m, 3.0);
228        let (forward_m, _) = project_click(NormalizedPoint { x: 0.5, y: 2.0 }, 3.0, PI / 3.0);
229        assert_eq!(forward_m, 0.0);
230    }
231
232    #[test]
233    fn relative_goal_pose_is_base_link_relative_and_faces_the_bearing() {
234        let pose = relative_goal_pose(2.0, PI / 2.0);
235        assert_eq!(pose.frame_id, Some("base_link".to_string()));
236        assert!((pose.position[0] - 0.0).abs() < 1e-5);
237        assert!((pose.position[1] - 2.0).abs() < 1e-5);
238    }
239
240    #[test]
241    fn from_json_defaults_topic_distance_and_fov() {
242        let node = SetGoToLocationTargetNode::from_json(serde_json::json!({})).unwrap();
243        assert_eq!(node.request_topic, "/go-to-location/target/request");
244        assert_eq!(node.max_distance_m, 3.0);
245        assert_eq!(node.horizontal_fov_rad, std::f32::consts::FRAC_PI_3);
246        assert!(!node.active);
247    }
248
249    #[test]
250    fn from_json_accepts_null_the_same_as_an_empty_object() {
251        let node = SetGoToLocationTargetNode::from_json(Value::Null).unwrap();
252        assert_eq!(node.request_topic, "/go-to-location/target/request");
253    }
254
255    #[test]
256    fn from_json_reads_overridden_fields() {
257        let node = SetGoToLocationTargetNode::from_json(serde_json::json!({
258            "request_topic": "/custom/request",
259            "max_distance_m": 5.0,
260            "horizontal_fov_rad": 1.0
261        }))
262        .unwrap();
263        assert_eq!(node.request_topic, "/custom/request");
264        assert_eq!(node.max_distance_m, 5.0);
265        assert_eq!(node.horizontal_fov_rad, 1.0);
266    }
267
268    #[test]
269    fn poll_latest_request_returns_none_when_empty() {
270        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
271        let mut receiver = Receiver::from(rx);
272        assert_eq!(SetGoToLocationTargetNode::poll_latest_request(&mut receiver), None);
273    }
274
275    #[test]
276    fn poll_latest_request_keeps_only_the_most_recent_click() {
277        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
278        let mut receiver = Receiver::from(rx);
279        tx.send(serde_json::json!({ "point": { "x": 0.1, "y": 0.1 } })).unwrap();
280        tx.send(serde_json::json!({ "point": { "x": 0.9, "y": 0.9 } })).unwrap();
281
282        assert_eq!(
283            SetGoToLocationTargetNode::poll_latest_request(&mut receiver),
284            Some(Some(NormalizedPoint { x: 0.9, y: 0.9 }))
285        );
286    }
287
288    #[test]
289    fn poll_latest_request_accepts_a_null_point_as_a_cancel_request() {
290        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
291        let mut receiver = Receiver::from(rx);
292        tx.send(serde_json::json!({ "point": null })).unwrap();
293
294        assert_eq!(
295            SetGoToLocationTargetNode::poll_latest_request(&mut receiver),
296            Some(None)
297        );
298    }
299
300    #[test]
301    fn poll_latest_request_drops_malformed_requests_and_keeps_polling() {
302        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
303        let mut receiver = Receiver::from(rx);
304        tx.send(serde_json::json!({ "point": "not-a-point" })).unwrap();
305        tx.send(serde_json::json!({ "point": { "x": 0.5, "y": 0.5 } })).unwrap();
306
307        assert_eq!(
308            SetGoToLocationTargetNode::poll_latest_request(&mut receiver),
309            Some(Some(NormalizedPoint { x: 0.5, y: 0.5 }))
310        );
311    }
312
313    #[tokio::test]
314    #[ignore = "Requires Redis infrastructure"]
315    async fn tick_returns_failure_until_a_click_is_received() {
316        let ctx = create_test_context("test_set_go_to_location_target_idle").await;
317
318        let mut node = SetGoToLocationTargetNode::from_json(serde_json::json!({})).unwrap();
319        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
320    }
321
322    #[tokio::test]
323    #[ignore = "Requires Redis infrastructure"]
324    async fn tick_publishes_a_navigation_goal_for_a_relayed_click() {
325        let ctx = create_test_context("test_set_go_to_location_target_click").await;
326
327        let mut node = SetGoToLocationTargetNode::from_json(serde_json::json!({
328            "request_topic": "/go-to-location/target/request"
329        }))
330        .unwrap();
331
332        node.tick(&ctx).await.unwrap();
333
334        let mut goal_receiver = ctx.subscribe(navigation::GOAL).await.unwrap();
335        ctx.publish_raw(
336            "/go-to-location/target/request",
337            &serde_json::json!({ "point": { "x": 0.5, "y": 1.0 } }),
338        )
339        .await
340        .unwrap();
341
342        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
343        let status = node.tick(&ctx).await.unwrap();
344        assert_eq!(status, NodeStatus::Running);
345
346        let goal = tokio::time::timeout(std::time::Duration::from_millis(500), goal_receiver.recv())
347            .await
348            .expect("timed out waiting for published goal")
349            .expect("channel closed without a message");
350
351        assert_eq!(goal.target.frame_id, Some("base_link".to_string()));
352    }
353
354    #[tokio::test]
355    #[ignore = "Requires Redis infrastructure"]
356    async fn tick_publishes_cancel_for_a_cleared_click() {
357        let ctx = create_test_context("test_set_go_to_location_target_clear").await;
358
359        let mut node = SetGoToLocationTargetNode::from_json(serde_json::json!({
360            "request_topic": "/go-to-location/target/request"
361        }))
362        .unwrap();
363
364        node.tick(&ctx).await.unwrap();
365
366        let mut cancel_receiver = ctx.subscribe(navigation::CANCEL).await.unwrap();
367        ctx.publish_raw(
368            "/go-to-location/target/request",
369            &serde_json::json!({ "point": { "x": 0.5, "y": 1.0 } }),
370        )
371        .await
372        .unwrap();
373        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
374        node.tick(&ctx).await.unwrap();
375
376        ctx.publish_raw("/go-to-location/target/request", &serde_json::json!({ "point": null }))
377            .await
378            .unwrap();
379        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
380        let status = node.tick(&ctx).await.unwrap();
381        assert_eq!(status, NodeStatus::Failure);
382
383        let cancel = tokio::time::timeout(std::time::Duration::from_millis(500), cancel_receiver.recv())
384            .await
385            .expect("timed out waiting for cancel")
386            .expect("channel closed without a message");
387        assert!(cancel);
388    }
389}