Skip to main content

mecha10_behavior_runtime/actions/
estop.rs

1//! Leaf condition node reporting whether the teleop emergency-stop signal is currently active -
2//! the real-time backing for the highest-priority (`priority: 255`) subsumption interrupt layer
3//! that preempts every behavior tree, including interrupt layers below it like
4//! `instance-track-interrupt`.
5//!
6//! This reuses the EXISTING teleop safety signal rather than inventing a new one:
7//! `mecha10_core::teleop::TeleopInput::emergency_stop(source, timestamp)` builds a
8//! `priority: 255`, `emergency_stop: true` command, and the CLI TUI's e-stop key
9//! (`packages/cli/src/ui/teleop.rs`'s `send_emergency_stop`) publishes it to the `/teleop/cmd`
10//! topic (that file's local `TELEOP_CMD: Topic<TeleopInput>` constant). This node subscribes to
11//! that same topic and only inspects the `emergency_stop` field of whatever arrives there,
12//! ignoring `source`/`priority`/`command`/`timestamp` - mirroring
13//! `CapabilityTaskNode::poll_target`'s minimal `TargetRequest` struct, which likewise only reads
14//! the one field it cares about out of a larger message.
15
16use crate::{BehaviorNode, Context, NodeStatus, TreeStatusSnapshot};
17use async_trait::async_trait;
18use mecha10_core::Receiver;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22/// The topic this node watches by default - the same `/teleop/cmd` topic the CLI TUI's e-stop
23/// key publishes `TeleopInput` (including emergency-stop commands) to; see
24/// `packages/cli/src/ui/teleop.rs`'s `TELEOP_CMD` constant. Configurable via `topic` so a
25/// project that republishes teleop commands elsewhere can point this node there instead.
26fn default_topic() -> String {
27    "/teleop/cmd".to_string()
28}
29
30/// The subset of `mecha10_core::teleop::TeleopInput` this node actually inspects. Every other
31/// field is ignored (and, because this doesn't `deny_unknown_fields`, never even rejected).
32#[derive(Debug, Clone, Serialize, Deserialize)]
33struct EmergencyStopSignal {
34    #[serde(default)]
35    emergency_stop: bool,
36}
37
38#[derive(Debug, Serialize, Deserialize)]
39struct EstopActiveConfig {
40    #[serde(default = "default_topic")]
41    topic: String,
42}
43
44pub struct EstopActiveNode {
45    topic: String,
46    receiver: Option<Receiver<Value>>,
47    /// Whether the latest message seen on `topic` carried `emergency_stop: true`. Defaults to
48    /// `false` before any message has arrived - the absence of a signal is not itself treated as
49    /// an emergency, the same Failure-until-proven-active convention
50    /// `CapabilityTaskNode::has_target` uses for its target-tracking mode.
51    active: bool,
52    last_status: Option<NodeStatus>,
53}
54
55impl std::fmt::Debug for EstopActiveNode {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.debug_struct("EstopActiveNode")
58            .field("topic", &self.topic)
59            .field("subscribed", &self.receiver.is_some())
60            .field("active", &self.active)
61            .finish()
62    }
63}
64
65impl EstopActiveNode {
66    /// Create a new EstopActiveNode from JSON configuration. `topic` is optional and defaults to
67    /// [`default_topic`]. A bare `null`/absent config (e.g. `{"type": "node", "node":
68    /// "estop_active"}` with no `config` key at all) is treated the same as `{}` - the loader
69    /// passes `Value::Null` through for a leaf node with no `config` entry, which
70    /// `serde_json::from_value` can't deserialize directly into a struct.
71    pub fn from_json(config: Value) -> anyhow::Result<Self> {
72        let cfg: EstopActiveConfig = if config.is_null() {
73            EstopActiveConfig { topic: default_topic() }
74        } else {
75            serde_json::from_value(config)?
76        };
77        Ok(Self {
78            topic: cfg.topic,
79            receiver: None,
80            active: false,
81            last_status: None,
82        })
83    }
84
85    /// Construct a node with an already-connected receiver, bypassing live subscription.
86    ///
87    /// Used by unit tests to exercise `poll()`/`tick()` convergence without requiring a real
88    /// `Context`/Redis.
89    #[cfg(test)]
90    fn with_receiver(receiver: Receiver<Value>) -> Self {
91        Self {
92            topic: "test/topic".to_string(),
93            receiver: Some(receiver),
94            active: false,
95            last_status: None,
96        }
97    }
98
99    /// Drain every pending message, updating `active` from the latest one seen (if any) - only
100    /// the most recent message matters, mirroring `SensorCheckNode::evaluate`/
101    /// `CapabilityTaskNode::poll_target`.
102    fn poll(&mut self) {
103        let Some(receiver) = self.receiver.as_mut() else {
104            return;
105        };
106
107        let mut latest = None;
108        while let Ok(value) = receiver.try_recv() {
109            match serde_json::from_value::<EmergencyStopSignal>(value) {
110                Ok(signal) => latest = Some(signal.emergency_stop),
111                Err(e) => tracing::warn!("EstopActiveNode: dropping malformed teleop message: {}", e),
112            }
113        }
114
115        if let Some(active) = latest {
116            self.active = active;
117        }
118    }
119}
120
121#[async_trait]
122impl BehaviorNode for EstopActiveNode {
123    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
124        if self.receiver.is_none() {
125            self.receiver = Some(ctx.subscribe_raw::<Value>(&self.topic).await?);
126        }
127        self.poll();
128        let status = if self.active {
129            NodeStatus::Running
130        } else {
131            NodeStatus::Failure
132        };
133        self.last_status = Some(status);
134        Ok(status)
135    }
136
137    fn name(&self) -> &str {
138        "EstopActiveNode"
139    }
140
141    // Deliberately does NOT override `active_capability()` (default `None`): unlike
142    // `instance-track`'s `capability_task` leaf, this node never hands control to an external
143    // capability node - it only reports a safety condition.
144
145    fn status_snapshot(&self) -> TreeStatusSnapshot {
146        TreeStatusSnapshot {
147            name: self.name().to_string(),
148            status: self.last_status,
149            active_capability: self.active_capability().map(String::from),
150            children: Vec::new(),
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use mecha10_core::teleop::{TeleopCommand, TeleopInput};
159    use tokio::sync::mpsc;
160
161    fn node_with_channel() -> (EstopActiveNode, mpsc::UnboundedSender<Value>) {
162        let (tx, rx) = mpsc::unbounded_channel();
163        (EstopActiveNode::with_receiver(Receiver::from(rx)), tx)
164    }
165
166    #[test]
167    fn no_message_yet_defaults_to_inactive() {
168        let (mut node, _tx) = node_with_channel();
169        node.poll();
170        assert!(!node.active);
171    }
172
173    #[test]
174    fn emergency_stop_true_marks_active() {
175        let (mut node, tx) = node_with_channel();
176        let input = TeleopInput::emergency_stop("safety", 123);
177        tx.send(serde_json::to_value(&input).unwrap()).unwrap();
178
179        node.poll();
180
181        assert!(node.active);
182    }
183
184    #[test]
185    fn emergency_stop_false_marks_inactive() {
186        let (mut node, tx) = node_with_channel();
187        let input = TeleopInput::new("gamepad", 1.0, 0.0, 123);
188        tx.send(serde_json::to_value(&input).unwrap()).unwrap();
189
190        node.poll();
191
192        assert!(!node.active);
193    }
194
195    #[test]
196    fn later_message_overrides_earlier_one() {
197        let (mut node, tx) = node_with_channel();
198        tx.send(serde_json::to_value(TeleopInput::emergency_stop("safety", 1)).unwrap())
199            .unwrap();
200        tx.send(serde_json::to_value(TeleopInput::new("gamepad", 0.0, 0.0, 2)).unwrap())
201            .unwrap();
202
203        node.poll();
204
205        assert!(!node.active);
206    }
207
208    #[test]
209    fn ignores_malformed_messages_and_keeps_last_known_state() {
210        let (mut node, tx) = node_with_channel();
211        tx.send(serde_json::to_value(TeleopInput::emergency_stop("safety", 1)).unwrap())
212            .unwrap();
213        node.poll();
214        assert!(node.active);
215
216        tx.send(serde_json::json!("not an object")).unwrap();
217        node.poll();
218
219        assert!(
220            node.active,
221            "a malformed message must not reset a previously-known state"
222        );
223    }
224
225    /// End-to-end `tick()` (real subscription via `Context`) convergence, mirroring
226    /// `composition.rs`'s Redis-backed tests: `Failure` before any message, `Running` once an
227    /// `emergency_stop: true` message is published.
228    #[tokio::test]
229    #[ignore = "Requires Redis infrastructure"]
230    async fn tick_returns_failure_before_any_message_then_running_once_estop_fires() {
231        let ctx = crate::test_helpers::create_test_context("estop_active_tick").await;
232        let mut node = EstopActiveNode::from_json(serde_json::json!({ "topic": "/teleop/cmd" })).unwrap();
233
234        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
235
236        let input = TeleopInput::emergency_stop("safety", 1);
237        ctx.publish_to_path("/teleop/cmd", &input).await.unwrap();
238        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
239    }
240
241    #[test]
242    fn does_not_override_active_capability() {
243        let (node, _tx) = node_with_channel();
244        assert_eq!(node.active_capability(), None);
245    }
246
247    #[test]
248    fn from_json_defaults_topic() {
249        let node = EstopActiveNode::from_json(serde_json::json!({})).unwrap();
250        assert_eq!(node.topic, "/teleop/cmd");
251    }
252
253    #[test]
254    fn from_json_defaults_topic_for_a_bare_null_config() {
255        // The loader passes `Value::Null` through for a leaf node with no `config` key at all
256        // (e.g. `{"type": "node", "node": "estop_active"}`) - must not error out.
257        let node = EstopActiveNode::from_json(Value::Null).unwrap();
258        assert_eq!(node.topic, "/teleop/cmd");
259    }
260
261    #[test]
262    fn from_json_reads_explicit_topic() {
263        let node = EstopActiveNode::from_json(serde_json::json!({ "topic": "/custom/teleop" })).unwrap();
264        assert_eq!(node.topic, "/custom/teleop");
265    }
266
267    #[test]
268    fn teleop_command_variant_is_irrelevant_to_the_signal() {
269        // Sanity: TeleopCommand::Drivetrain{0,0} with emergency_stop is what
270        // TeleopInput::emergency_stop actually builds - confirm our struct only reads the one
271        // field regardless of command shape.
272        let input = TeleopInput::emergency_stop("safety", 1);
273        assert!(matches!(input.command, TeleopCommand::Drivetrain { .. }));
274        let signal: EmergencyStopSignal = serde_json::from_value(serde_json::to_value(&input).unwrap()).unwrap();
275        assert!(signal.emergency_stop);
276    }
277}