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