Skip to main content

mecha10_behavior_runtime/actions/
sensing.rs

1//! Action nodes that inspect sensor state.
2
3use crate::{BehaviorNode, Context, NodeStatus};
4use async_trait::async_trait;
5use mecha10_core::Receiver;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9/// SensorCheckNode - Checks sensor state
10///
11/// Subscribes to a sensor topic and checks if a condition is met.
12///
13/// # Configuration
14///
15/// ```json
16/// {
17///   "type": "sensor_check",
18///   "config": {
19///     "topic": "/sensors/obstacle",
20///     "field": "distance",
21///     "operator": "less_than",
22///     "threshold": 0.5
23///   }
24/// }
25/// ```
26///
27/// # Behavior
28///
29/// - Lazily subscribes to `topic` on the first tick.
30/// - Each tick, drains any pending sensor messages and keeps the most recent one. If no new
31///   message has arrived since the last tick, the previously received reading is reused (a
32///   publisher is not required to publish on every tick).
33/// - If no sensor message has ever been received, the node returns `Running` - it is still
34///   waiting for a first reading, per the documented semantics of `NodeStatus::Running`.
35/// - If a message has been received but `field` is missing or not numeric, the node returns
36///   `Failure` - the condition cannot be evaluated.
37/// - Otherwise the node compares `field`'s value against `threshold` using `operator` and
38///   returns `Success` if the comparison holds, `Failure` otherwise.
39pub 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    /// Evaluate `actual <op> threshold`.
71    fn evaluate(&self, actual: f64, threshold: f64) -> bool {
72        match self {
73            ComparisonOperator::LessThan => actual < threshold,
74            ComparisonOperator::GreaterThan => actual > threshold,
75            // Floating point sensor readings are rarely bit-for-bit identical; use a small
76            // tolerance rather than exact equality.
77            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    /// Create a new SensorCheckNode from JSON configuration
92    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    /// Drain any pending sensor messages (keeping only the most recent) and evaluate the
105    /// configured condition against the latest known reading.
106    ///
107    /// Pure with respect to messaging infrastructure - it only touches the already-subscribed
108    /// `Receiver`, which makes it directly unit-testable without a live `Context`/Redis.
109    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    /// Construct a node with an already-connected receiver, bypassing live subscription.
152    ///
153    /// Used by unit tests to exercise `evaluate()` without requiring a real `Context`/Redis.
154    #[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        // A publisher that stops sending updates shouldn't invalidate a previously received
268        // reading - the node should keep evaluating against the last known value.
269        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        // No new message published before the next tick - still Success from cached reading.
275        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        // Both messages are drained in a single evaluate() call; the most recent wins.
287        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}