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