Skip to main content

plato_engine_block/
lib.rs

1//! # Plato Engine Block
2//!
3//! Atomic room runtime for the Plato Matrix — the universal agent-space interface.
4//!
5//! A "room" is a self-contained unit of sensor/actuator interaction that ticks at a
6//! configurable rate, maintains rolling history, and evaluates alarm rules. The text
7//! protocol allows external agents to query state, control actuators, and subscribe
8//! to live updates.
9//!
10//! ## Feature flags
11//!
12//! - `std` (default): Enables `std` support (File, println, etc.)
13//! - `server`: Enables the tokio-based TCP multi-client server
14
15#![cfg_attr(not(feature = "std"), no_std)]
16
17#[cfg(not(feature = "std"))]
18extern crate alloc;
19
20#[cfg(not(feature = "std"))]
21use alloc::{string::String, string::ToString, vec::Vec, format, boxed::Box, collections::BTreeMap};
22
23
24
25pub mod engine;
26pub mod tick;
27pub mod sensor;
28pub mod actuator;
29pub mod alarm;
30pub mod history;
31pub mod protocol;
32
33#[cfg(feature = "server")]
34pub mod server;
35
36pub use engine::{PlatoEngine, PlatoEngineBuilder};
37pub use tick::Tick;
38pub use sensor::{Sensor, SensorFn, SensorSpec};
39pub use actuator::{Actuator, ActuatorFn, ActuatorSpec};
40pub use alarm::{AlarmRule, AlarmState, AlarmCondition};
41pub use history::HistoryBuffer;
42pub use protocol::ProtocolHandler;
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47    use std::sync::atomic::{AtomicU64, Ordering};
48    use std::sync::Arc;
49
50    fn make_engine() -> PlatoEngine {
51        PlatoEngine::builder()
52            .sensor("temp", Box::new(|| 22.5))
53            .sensor("humidity", Box::new(|| 45.0))
54            .actuator("heater", Box::new(|v| v > 0.0 && v < 100.0))
55            .actuator("fan", Box::new(|_| true))
56            .tick_hz(10.0)
57            .history_capacity(50)
58            .build()
59    }
60
61    #[test]
62    fn test_engine_creation_with_builder() {
63        let engine = make_engine();
64        assert_eq!(engine.sensors.len(), 2);
65        assert_eq!(engine.actuators.len(), 2);
66        assert_eq!(engine.tick_hz, 10.0);
67        assert_eq!(engine.history.capacity(), 50);
68    }
69
70    #[test]
71    fn test_sensor_reading_and_tick() {
72        let mut engine = make_engine();
73        let tick = engine.tick();
74        assert_eq!(tick.index, 0);
75        assert_eq!(tick.data.len(), 2);
76        assert_eq!(tick.get("temp"), Some(22.5));
77        assert_eq!(tick.get("humidity"), Some(45.0));
78    }
79
80    #[test]
81    fn test_history_buffer_overflow() {
82        let mut engine = PlatoEngine::builder()
83            .sensor("x", Box::new(|| 1.0))
84            .tick_hz(1.0)
85            .history_capacity(3)
86            .build();
87        engine.tick(); // 0
88        engine.tick(); // 1
89        engine.tick(); // 2
90        engine.tick(); // 3 — should push out tick 0
91        assert_eq!(engine.history.len(), 3);
92        let ticks = engine.history(3);
93        assert_eq!(ticks[0].index, 1);
94        assert_eq!(ticks[2].index, 3);
95    }
96
97    #[test]
98    fn test_actuator_command_parsing() {
99        let mut engine = make_engine();
100        let resp = engine.handle_command("heater 50.0");
101        assert!(resp.contains("heater"));
102        assert!(resp.contains("50"));
103    }
104
105    #[test]
106    fn test_actuator_execution() {
107        let mut engine = make_engine();
108        assert_eq!(engine.set_actuator("heater", 50.0), Ok(true));
109        assert_eq!(engine.set_actuator("heater", -1.0), Ok(false));
110        assert!(engine.set_actuator("nonexistent", 1.0).is_err());
111    }
112
113    #[test]
114    fn test_alarm_triggering() {
115        let mut engine = PlatoEngine::builder()
116            .sensor("temp", Box::new(|| 100.0))
117            .alarm(
118                "overheat",
119                Box::new(|data| {
120                    data.iter().any(|(n, v)| n == "temp" && *v > 80.0)
121                }),
122                5,
123            )
124            .tick_hz(1.0)
125            .history_capacity(10)
126            .build();
127        let _ = engine.tick();
128        assert_eq!(engine.alarm_fires.len(), 1);
129        assert_eq!(engine.alarm_fires[0], "overheat");
130    }
131
132    #[test]
133    fn test_alarm_cooldown() {
134        let mut engine = PlatoEngine::builder()
135            .sensor("temp", Box::new(|| 100.0))
136            .alarm(
137                "overheat",
138                Box::new(|data| {
139                    data.iter().any(|(n, v)| n == "temp" && *v > 80.0)
140                }),
141                5,
142            )
143            .tick_hz(1.0)
144            .history_capacity(10)
145            .build();
146        // First tick fires
147        let _ = engine.tick();
148        assert_eq!(engine.alarm_fires.len(), 1);
149        engine.alarm_fires.clear();
150        // Subsequent ticks should NOT re-fire immediately (condition still true, but alarm is Active)
151        let _ = engine.tick();
152        assert_eq!(engine.alarm_fires.len(), 0);
153    }
154
155    #[test]
156    fn test_alarm_not_retriggering_in_cooldown() {
157        let mut engine = PlatoEngine::builder()
158            .sensor("temp", Box::new(|| 100.0))
159            .alarm(
160                "overheat",
161                Box::new(|_| true),
162                10,
163            )
164            .tick_hz(1.0)
165            .history_capacity(100)
166            .build();
167        let _ = engine.tick(); // fires (tick 0)
168        assert_eq!(engine.alarm_fires.len(), 1);
169        engine.alarm_fires.clear();
170
171        // Now we need the condition to go false then true again.
172        // Since our sensor always returns 100 and condition always returns true,
173        // the alarm stays Active. Let's test with a different approach.
174        assert_eq!(engine.alarms[0].state, crate::alarm::AlarmState::Active { since_tick: 0 });
175    }
176
177    #[test]
178    fn test_protocol_tick() {
179        let mut engine = make_engine();
180        let resp = engine.handle_command("tick");
181        assert!(resp.contains("tick 0"));
182        assert!(resp.contains("temp = 22.5"));
183    }
184
185    #[test]
186    fn test_protocol_history() {
187        let mut engine = make_engine();
188        engine.handle_command("tick");
189        engine.handle_command("tick");
190        let resp = engine.handle_command("history 2");
191        assert!(resp.contains("tick 0"));
192        assert!(resp.contains("tick 1"));
193    }
194
195    #[test]
196    fn test_protocol_history_with_limit() {
197        let mut engine = make_engine();
198        for _ in 0..5 {
199            engine.tick();
200        }
201        let resp = engine.handle_command("history 3");
202        // Should show only last 3 ticks
203        assert!(resp.contains("tick 2"));
204        assert!(resp.contains("tick 4"));
205        assert!(!resp.contains("tick 0"));
206        assert!(!resp.contains("tick 1"));
207    }
208
209    #[test]
210    fn test_protocol_actuator() {
211        let mut engine = make_engine();
212        let resp = engine.handle_command("fan 75.0");
213        assert!(resp.contains("fan"));
214        assert!(resp.contains("75"));
215    }
216
217    #[test]
218    fn test_protocol_subscribe() {
219        let mut engine = make_engine();
220        let resp = engine.handle_command("subscribe");
221        assert_eq!(resp, "subscribed");
222        assert!(engine.streaming);
223    }
224
225    #[test]
226    fn test_protocol_unsubscribe() {
227        let mut engine = make_engine();
228        engine.subscribe();
229        let resp = engine.handle_command("unsubscribe");
230        assert_eq!(resp, "unsubscribed");
231        assert!(!engine.streaming);
232    }
233
234    #[test]
235    fn test_protocol_help() {
236        let mut engine = make_engine();
237        let resp = engine.handle_command("help");
238        assert!(resp.contains("tick"));
239        assert!(resp.contains("history"));
240        assert!(resp.contains("subscribe"));
241    }
242
243    #[test]
244    fn test_protocol_unknown() {
245        let mut engine = make_engine();
246        let resp = engine.handle_command("foobar");
247        assert!(resp.contains("unknown"));
248    }
249
250    #[test]
251    fn test_subscribe_unsubscribe_state() {
252        let mut engine = make_engine();
253        assert!(!engine.streaming);
254        engine.subscribe();
255        assert!(engine.streaming);
256        engine.unsubscribe();
257        assert!(!engine.streaming);
258    }
259
260    #[test]
261    fn test_multiple_ticks_in_history() {
262        let mut engine = make_engine();
263        for _ in 0..10 {
264            engine.tick();
265        }
266        assert_eq!(engine.history.len(), 10);
267        let ticks = engine.history(5);
268        assert_eq!(ticks.len(), 5);
269    }
270
271    #[test]
272    fn test_tick_data_contains_all_sensors() {
273        let mut engine = make_engine();
274        let tick = engine.tick();
275        assert_eq!(tick.data.len(), 2);
276        let names: Vec<&str> = tick.data.iter().map(|(n, _)| n.as_str()).collect();
277        assert!(names.contains(&"temp"));
278        assert!(names.contains(&"humidity"));
279    }
280
281    #[test]
282    fn test_history_no_data_returns_empty() {
283        let engine = make_engine();
284        let ticks = engine.history(10);
285        assert!(ticks.is_empty());
286    }
287
288    #[test]
289    fn test_builder_defaults() {
290        let engine = PlatoEngine::builder().build();
291        assert_eq!(engine.tick_hz, 1.0);
292        assert_eq!(engine.history.capacity(), 100);
293    }
294
295    #[test]
296    fn test_integration_full() {
297        let counter = Arc::new(AtomicU64::new(0));
298        let counter_clone = counter.clone();
299        let mut engine = PlatoEngine::builder()
300            .sensor(
301                "counter",
302                Box::new(move || {
303                    let v = counter_clone.fetch_add(1, Ordering::SeqCst);
304                    v as f64
305                }),
306            )
307            .actuator("reset", Box::new(|v| v == 0.0))
308            .alarm(
309                "high",
310                Box::new(|data| data.iter().any(|(_, v)| *v > 5.0)),
311                3,
312            )
313            .tick_hz(10.0)
314            .history_capacity(100)
315            .build();
316
317        // Take several ticks
318        for _ in 0..7 {
319            engine.tick();
320        }
321
322        // History should have all 7
323        assert_eq!(engine.history.len(), 7);
324
325        // Query history
326        let h = engine.history(3);
327        assert_eq!(h.len(), 3);
328
329        // Latest should be tick 6
330        let latest = engine.latest().unwrap();
331        assert_eq!(latest.index, 6);
332
333        // Alarm should have fired when counter exceeded 5
334        // (the alarm fires when the condition is first met)
335        // Since we consumed alarm_fires each tick via tick(), let's check alarm state
336        assert_ne!(engine.alarms[0].state, crate::alarm::AlarmState::Idle);
337
338        // Use protocol
339        let resp = engine.handle_command("history 3");
340        assert!(resp.contains("tick 4"));
341
342        // Set actuator
343        let resp = engine.handle_command("reset 0.0");
344        assert!(resp.contains("reset"));
345
346        // Unknown actuator
347        let resp = engine.handle_command("reset 1.0");
348        assert!(resp.contains("rejected"));
349    }
350}