mecha10_behavior_runtime/actions/
sensing.rs1use crate::{BehaviorNode, Context, NodeStatus};
4use async_trait::async_trait;
5use mecha10_core::Receiver;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9pub struct SensorCheckNode {
40 topic: String,
41 field: String,
42 operator: ComparisonOperator,
43 threshold: f64,
44 receiver: Option<Receiver<Value>>,
45 latest: Option<Value>,
46}
47
48impl std::fmt::Debug for SensorCheckNode {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct("SensorCheckNode")
51 .field("topic", &self.topic)
52 .field("field", &self.field)
53 .field("operator", &self.operator)
54 .field("threshold", &self.threshold)
55 .field("subscribed", &self.receiver.is_some())
56 .field("latest", &self.latest)
57 .finish()
58 }
59}
60
61#[derive(Debug, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63enum ComparisonOperator {
64 LessThan,
65 GreaterThan,
66 Equal,
67}
68
69impl ComparisonOperator {
70 fn evaluate(&self, actual: f64, threshold: f64) -> bool {
72 match self {
73 ComparisonOperator::LessThan => actual < threshold,
74 ComparisonOperator::GreaterThan => actual > threshold,
75 ComparisonOperator::Equal => (actual - threshold).abs() < f64::EPSILON,
76 }
77 }
78}
79
80#[derive(Debug, Serialize, Deserialize)]
81struct SensorCheckConfig {
82 topic: String,
83 field: String,
84 operator: ComparisonOperator,
85 threshold: f64,
86}
87
88impl SensorCheckNode {
89 pub fn from_json(config: Value) -> anyhow::Result<Self> {
91 let cfg: SensorCheckConfig = serde_json::from_value(config)?;
92 Ok(Self {
93 topic: cfg.topic,
94 field: cfg.field,
95 operator: cfg.operator,
96 threshold: cfg.threshold,
97 receiver: None,
98 latest: None,
99 })
100 }
101
102 fn evaluate(&mut self) -> NodeStatus {
108 if let Some(receiver) = self.receiver.as_mut() {
109 while let Ok(msg) = receiver.try_recv() {
110 self.latest = Some(msg);
111 }
112 }
113
114 let Some(latest) = self.latest.as_ref() else {
115 tracing::debug!(topic = %self.topic, "SensorCheckNode: waiting for first sensor reading");
116 return NodeStatus::Running;
117 };
118
119 let Some(actual) = latest.get(&self.field).and_then(Value::as_f64) else {
120 tracing::warn!(
121 topic = %self.topic,
122 field = %self.field,
123 message = %latest,
124 "SensorCheckNode: field missing or not numeric in sensor message"
125 );
126 return NodeStatus::Failure;
127 };
128
129 let met = self.operator.evaluate(actual, self.threshold);
130 tracing::debug!(
131 "SensorCheckNode: checking {} {} {} -> {}",
132 actual,
133 match self.operator {
134 ComparisonOperator::LessThan => "<",
135 ComparisonOperator::GreaterThan => ">",
136 ComparisonOperator::Equal => "==",
137 },
138 self.threshold,
139 met
140 );
141
142 if met {
143 NodeStatus::Success
144 } else {
145 NodeStatus::Failure
146 }
147 }
148
149 #[cfg(test)]
153 fn with_receiver(field: &str, operator: ComparisonOperator, threshold: f64, receiver: Receiver<Value>) -> Self {
154 Self {
155 topic: "test/topic".to_string(),
156 field: field.to_string(),
157 operator,
158 threshold,
159 receiver: Some(receiver),
160 latest: None,
161 }
162 }
163}
164
165#[async_trait]
166impl BehaviorNode for SensorCheckNode {
167 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
168 if self.receiver.is_none() {
169 self.receiver = Some(ctx.subscribe_raw::<Value>(&self.topic).await?);
170 }
171 Ok(self.evaluate())
172 }
173
174 fn name(&self) -> &str {
175 "SensorCheckNode"
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use serde_json::json;
183 use tokio::sync::mpsc;
184
185 fn node_with_channel(
186 field: &str,
187 operator: ComparisonOperator,
188 threshold: f64,
189 ) -> (SensorCheckNode, mpsc::UnboundedSender<Value>) {
190 let (tx, rx) = mpsc::unbounded_channel();
191 let node = SensorCheckNode::with_receiver(field, operator, threshold, Receiver::from(rx));
192 (node, tx)
193 }
194
195 #[test]
196 fn no_message_received_returns_running() {
197 let (mut node, _tx) = node_with_channel("distance", ComparisonOperator::LessThan, 0.5);
198 assert_eq!(node.evaluate(), NodeStatus::Running);
199 }
200
201 #[test]
202 fn missing_field_returns_failure() {
203 let (mut node, tx) = node_with_channel("distance", ComparisonOperator::LessThan, 0.5);
204 tx.send(json!({ "other_field": 1.0 })).unwrap();
205 assert_eq!(node.evaluate(), NodeStatus::Failure);
206 }
207
208 #[test]
209 fn non_numeric_field_returns_failure() {
210 let (mut node, tx) = node_with_channel("distance", ComparisonOperator::LessThan, 0.5);
211 tx.send(json!({ "distance": "close" })).unwrap();
212 assert_eq!(node.evaluate(), NodeStatus::Failure);
213 }
214
215 #[test]
216 fn less_than_success_when_below_threshold() {
217 let (mut node, tx) = node_with_channel("distance", ComparisonOperator::LessThan, 0.5);
218 tx.send(json!({ "distance": 0.2 })).unwrap();
219 assert_eq!(node.evaluate(), NodeStatus::Success);
220 }
221
222 #[test]
223 fn less_than_failure_when_at_or_above_threshold() {
224 let (mut node, tx) = node_with_channel("distance", ComparisonOperator::LessThan, 0.5);
225 tx.send(json!({ "distance": 0.5 })).unwrap();
226 assert_eq!(node.evaluate(), NodeStatus::Failure);
227
228 tx.send(json!({ "distance": 1.0 })).unwrap();
229 assert_eq!(node.evaluate(), NodeStatus::Failure);
230 }
231
232 #[test]
233 fn greater_than_success_when_above_threshold() {
234 let (mut node, tx) = node_with_channel("battery", ComparisonOperator::GreaterThan, 20.0);
235 tx.send(json!({ "battery": 55.0 })).unwrap();
236 assert_eq!(node.evaluate(), NodeStatus::Success);
237 }
238
239 #[test]
240 fn greater_than_failure_when_at_or_below_threshold() {
241 let (mut node, tx) = node_with_channel("battery", ComparisonOperator::GreaterThan, 20.0);
242 tx.send(json!({ "battery": 20.0 })).unwrap();
243 assert_eq!(node.evaluate(), NodeStatus::Failure);
244
245 tx.send(json!({ "battery": 5.0 })).unwrap();
246 assert_eq!(node.evaluate(), NodeStatus::Failure);
247 }
248
249 #[test]
250 fn equal_success_when_matching() {
251 let (mut node, tx) = node_with_channel("state", ComparisonOperator::Equal, 1.0);
252 tx.send(json!({ "state": 1.0 })).unwrap();
253 assert_eq!(node.evaluate(), NodeStatus::Success);
254 }
255
256 #[test]
257 fn equal_failure_when_not_matching() {
258 let (mut node, tx) = node_with_channel("state", ComparisonOperator::Equal, 1.0);
259 tx.send(json!({ "state": 0.0 })).unwrap();
260 assert_eq!(node.evaluate(), NodeStatus::Failure);
261 }
262
263 #[test]
264 fn stale_topic_reuses_last_known_reading() {
265 let (mut node, tx) = node_with_channel("distance", ComparisonOperator::LessThan, 0.5);
266
267 tx.send(json!({ "distance": 0.1 })).unwrap();
268 assert_eq!(node.evaluate(), NodeStatus::Success);
269
270 assert_eq!(node.evaluate(), NodeStatus::Success);
271 assert_eq!(node.evaluate(), NodeStatus::Success);
272 }
273
274 #[test]
275 fn later_message_overrides_earlier_one() {
276 let (mut node, tx) = node_with_channel("distance", ComparisonOperator::LessThan, 0.5);
277
278 tx.send(json!({ "distance": 0.1 })).unwrap();
279 tx.send(json!({ "distance": 1.0 })).unwrap();
280
281 assert_eq!(node.evaluate(), NodeStatus::Failure);
282 }
283
284 #[test]
285 fn from_json_parses_all_operators() {
286 for operator in ["less_than", "greater_than", "equal"] {
287 let config = json!({
288 "topic": "/sensors/distance",
289 "field": "value",
290 "operator": operator,
291 "threshold": 0.5
292 });
293 assert!(SensorCheckNode::from_json(config).is_ok());
294 }
295 }
296
297 #[test]
298 fn from_json_rejects_unknown_operator() {
299 let config = json!({
300 "topic": "/sensors/distance",
301 "field": "value",
302 "operator": "not_a_real_operator",
303 "threshold": 0.5
304 });
305 assert!(SensorCheckNode::from_json(config).is_err());
306 }
307}