mecha10_behavior_runtime/actions/
estop.rs1use crate::{BehaviorNode, Context, NodeStatus, TreeStatusSnapshot};
17use async_trait::async_trait;
18use mecha10_core::Receiver;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22fn default_topic() -> String {
27 "/teleop/cmd".to_string()
28}
29
30#[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 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 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 #[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 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 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 #[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 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 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}