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            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    /// Create a new SensorCheckNode from JSON configuration
90    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    /// Drain any pending sensor messages (keeping only the most recent) and evaluate the
103    /// configured condition against the latest known reading.
104    ///
105    /// Pure with respect to messaging infrastructure - it only touches the already-subscribed
106    /// `Receiver`, which makes it directly unit-testable without a live `Context`/Redis.
107    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    /// Construct a node with an already-connected receiver, bypassing live subscription.
150    ///
151    /// Used by unit tests to exercise `evaluate()` without requiring a real `Context`/Redis.
152    #[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}