Skip to main content

pamoja_profile/
node.rs

1//! The ready-to-run node a profile assembles around real components.
2
3use core::time::Duration;
4
5use pamoja_codec::Codec;
6use pamoja_core::{Actuator, Result, Sensor, Transport};
7use pamoja_power::PowerMode;
8
9use crate::{Controller, Profile, Reaction};
10
11/// An actuator that accepts and ignores commands.
12///
13/// Profiles that only observe - such as a well-level monitor - have no output to
14/// drive. [`Node::monitor`] wires this in their place so a node has one uniform
15/// shape whether or not it switches an actuator.
16#[derive(Clone, Copy, Debug, Default)]
17pub struct NoActuator;
18
19impl Actuator for NoActuator {
20    type Command = bool;
21
22    async fn apply(&mut self, _command: bool) -> Result<()> {
23        Ok(())
24    }
25}
26
27/// A profile assembled around the components that make it run.
28///
29/// The node is the thin shell that ties a [`Profile`]'s decision logic to real I/O.
30/// Each [`tick`](Node::tick) reads the sensor, runs the profile's
31/// [`Controller`](crate::Controller), drives the actuator when the controller calls
32/// for it, and publishes the reading over the transport with the supplied codec. The
33/// control math lives in `pamoja-kit`, the power schedule in `pamoja-power`, and the
34/// wire format in the codec, so the node adds composition, not behavior.
35///
36/// Readings are real-world `f32` units (degrees, percent, litres), the form the
37/// `pamoja-kit` controllers expect; a driver is responsible for calibrating raw
38/// counts into those units before the node sees them.
39///
40/// # Examples
41///
42/// ```
43/// use pamoja_codec::CborCodec;
44/// use pamoja_core::{Actuator, Result, Sensor, Transport};
45/// use pamoja_loopback::{LoopbackBroker, LoopbackTransport};
46/// use pamoja_profile::{Node, Profile};
47///
48/// struct Probe(f32);
49/// impl Sensor for Probe {
50///     type Reading = f32;
51///     async fn read(&mut self) -> Result<f32> {
52///         Ok(self.0)
53///     }
54/// }
55///
56/// struct Cooler;
57/// impl Actuator for Cooler {
58///     type Command = bool;
59///     async fn apply(&mut self, _on: bool) -> Result<()> {
60///         Ok(())
61///     }
62/// }
63///
64/// # async fn run() -> Result<()> {
65/// let broker = LoopbackBroker::new();
66/// let mut link = LoopbackTransport::new(broker);
67/// link.connect().await?;
68///
69/// // A warm fridge, assembled straight from its profile.
70/// let mut node = Node::new(Profile::vaccine_fridge_monitor(), Probe(9.0), Cooler, link, CborCodec);
71/// let reaction = node.tick().await?;
72/// assert_eq!(reaction.actuator, Some(true)); // the cooler runs
73/// assert!(reaction.alert.is_some()); // and 9 C is a spoilage excursion
74/// # Ok(())
75/// # }
76/// ```
77pub struct Node<S, A, T, C> {
78    profile: Profile,
79    controller: Controller,
80    sensor: S,
81    actuator: A,
82    transport: T,
83    codec: C,
84}
85
86impl<S, A, T, C> Node<S, A, T, C> {
87    /// Assembles a node from a profile and the components that drive it.
88    ///
89    /// # Arguments
90    ///
91    /// * `profile` - the profile to assemble; its policy becomes the node's controller.
92    /// * `sensor` - the source of readings.
93    /// * `actuator` - the output the controller switches.
94    /// * `transport` - the link readings are published over; expected to be connected.
95    /// * `codec` - the wire format readings are encoded with.
96    ///
97    /// # Returns
98    ///
99    /// A node ready to [`tick`](Node::tick).
100    pub fn new(profile: Profile, sensor: S, actuator: A, transport: T, codec: C) -> Self {
101        let controller = profile.controller();
102        Self {
103            profile,
104            controller,
105            sensor,
106            actuator,
107            transport,
108            codec,
109        }
110    }
111
112    /// Returns the profile this node was assembled from.
113    ///
114    /// # Returns
115    ///
116    /// A reference to the node's [`Profile`].
117    pub fn profile(&self) -> &Profile {
118        &self.profile
119    }
120
121    /// Returns the power mode and wait interval for the next cycle.
122    ///
123    /// This assembles the profile's [`PowerSchedule`](crate::PowerSchedule) into a
124    /// `pamoja-power` governor: as the battery drains the interval stretches, and a
125    /// charging panel eases the node back toward its active cadence. The node never
126    /// sleeps; the caller waits the
127    /// returned [`Duration`] before the next [`tick`](Node::tick), so timing stays
128    /// outside the node and the decision logic remains synchronous and testable.
129    ///
130    /// # Arguments
131    ///
132    /// * `soc` - the battery state of charge in `[0.0, 1.0]`.
133    /// * `charging` - whether the panel is currently delivering charge.
134    ///
135    /// # Returns
136    ///
137    /// The [`PowerMode`] to run in and how long to wait before the next cycle.
138    pub fn schedule(&self, soc: f32, charging: bool) -> (PowerMode, Duration) {
139        let plan = self.profile.power.plan();
140        let mode = plan.mode_while_charging(soc, charging);
141        (mode, plan.interval_for(mode))
142    }
143}
144
145impl<S, T, C> Node<S, NoActuator, T, C> {
146    /// Assembles a node for a profile that observes without driving an output.
147    ///
148    /// Wires a [`NoActuator`] in place of a real output, so a monitoring profile such
149    /// as [`well_level`](Profile::well_level) reads and publishes with the same shape
150    /// as a controlling one.
151    ///
152    /// # Arguments
153    ///
154    /// * `profile` - the profile to assemble.
155    /// * `sensor` - the source of readings.
156    /// * `transport` - the link readings are published over; expected to be connected.
157    /// * `codec` - the wire format readings are encoded with.
158    ///
159    /// # Returns
160    ///
161    /// A node ready to [`tick`](Node::tick), with no output to switch.
162    pub fn monitor(profile: Profile, sensor: S, transport: T, codec: C) -> Self {
163        Node::new(profile, sensor, NoActuator, transport, codec)
164    }
165}
166
167impl<S, A, T, C> Node<S, A, T, C>
168where
169    S: Sensor<Reading = f32>,
170    A: Actuator<Command = bool>,
171    T: Transport,
172    C: Codec<f32>,
173{
174    /// Runs one read-decide-act-publish cycle.
175    ///
176    /// Reads the sensor, evaluates the profile's controller, applies the resulting
177    /// command to the actuator when the controller calls for one, and publishes the
178    /// reading to the profile's topic.
179    ///
180    /// # Returns
181    ///
182    /// The [`Reaction`] the controller produced: the actuator setting that was
183    /// applied (if any) and any alert the reading raised.
184    ///
185    /// # Errors
186    ///
187    /// Returns [`Error::Io`](pamoja_core::Error::Io) or
188    /// [`Error::Closed`](pamoja_core::Error::Closed) if the sensor read or the
189    /// actuator command fails, [`Error::Codec`](pamoja_core::Error::Codec) if the
190    /// reading cannot be encoded, and [`Error::Transport`](pamoja_core::Error::Transport)
191    /// or [`Error::Closed`](pamoja_core::Error::Closed) if the publish fails.
192    pub async fn tick(&mut self) -> Result<Reaction> {
193        let reading = self.sensor.read().await?;
194        let reaction = self.controller.evaluate(reading);
195        if let Some(on) = reaction.actuator {
196            self.actuator.apply(on).await?;
197        }
198        let payload = self.codec.encode(&reading)?;
199        self.transport.send(&self.profile.topic, &payload).await?;
200        Ok(reaction)
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use std::sync::{Arc, Mutex};
207
208    use pamoja_codec::CborCodec;
209    use pamoja_core::Error;
210    use pamoja_loopback::{LoopbackBroker, LoopbackTransport};
211    use pamoja_power::PowerMode;
212
213    use super::*;
214
215    // A sensor that plays back a fixed list of readings, then reports closed.
216    struct ScriptedSensor {
217        readings: std::vec::IntoIter<f32>,
218    }
219
220    impl ScriptedSensor {
221        fn new(readings: Vec<f32>) -> Self {
222            Self {
223                readings: readings.into_iter(),
224            }
225        }
226    }
227
228    impl Sensor for ScriptedSensor {
229        type Reading = f32;
230
231        async fn read(&mut self) -> Result<f32> {
232            self.readings.next().ok_or(Error::Closed)
233        }
234    }
235
236    // An actuator that records every command it is given.
237    #[derive(Clone)]
238    struct Recording(Arc<Mutex<Vec<bool>>>);
239
240    impl Actuator for Recording {
241        type Command = bool;
242
243        async fn apply(&mut self, on: bool) -> Result<()> {
244            self.0.lock().expect("commands lock").push(on);
245            Ok(())
246        }
247    }
248
249    async fn connected_pair(filter: &str) -> (LoopbackTransport, LoopbackTransport) {
250        let broker = LoopbackBroker::new();
251        let mut gateway = LoopbackTransport::new(broker.clone());
252        let mut link = LoopbackTransport::new(broker);
253        gateway.connect().await.expect("gateway connect");
254        link.connect().await.expect("link connect");
255        gateway.subscribe(filter).await.expect("subscribe");
256        (gateway, link)
257    }
258
259    #[tokio::test]
260    async fn tick_reads_actuates_and_publishes() {
261        let (mut gateway, link) = connected_pair("cold-chain/#").await;
262        let commands = Arc::new(Mutex::new(Vec::new()));
263        let mut node = Node::new(
264            Profile::vaccine_fridge_monitor(),
265            ScriptedSensor::new(vec![9.0]),
266            Recording(commands.clone()),
267            link,
268            CborCodec,
269        );
270
271        let reaction = node.tick().await.expect("tick");
272        assert_eq!(reaction.actuator, Some(true));
273        assert!(reaction.alert.is_some());
274        assert_eq!(*commands.lock().expect("commands"), vec![true]);
275
276        let message = gateway.recv().await.expect("recv").expect("a reading");
277        assert_eq!(message.topic, "cold-chain/fridge/temperature");
278        let reading: f32 = CborCodec.decode(&message.payload).expect("decode");
279        assert_eq!(reading, 9.0);
280    }
281
282    #[tokio::test]
283    async fn monitor_publishes_without_an_actuator() {
284        let (mut gateway, link) = connected_pair("water/#").await;
285        let mut node = Node::monitor(
286            Profile::well_level(),
287            ScriptedSensor::new(vec![3.2]),
288            link,
289            CborCodec,
290        );
291
292        let reaction = node.tick().await.expect("tick");
293        assert_eq!(reaction.actuator, None);
294
295        let message = gateway.recv().await.expect("recv").expect("a reading");
296        assert_eq!(message.topic, "water/well/level");
297        let reading: f32 = CborCodec.decode(&message.payload).expect("decode");
298        assert_eq!(reading, 3.2);
299    }
300
301    #[test]
302    fn schedule_follows_state_of_charge() {
303        // The schedule reads only the profile, so the components can be placeholders.
304        let node = Node::monitor(Profile::vaccine_fridge_monitor(), (), (), ());
305        assert_eq!(node.schedule(0.9, false).0, PowerMode::Active);
306        assert_eq!(node.schedule(0.1, false).0, PowerMode::Critical);
307        // A charging panel eases off by one mode.
308        assert_eq!(node.schedule(0.1, true).0, PowerMode::Saver);
309        assert_eq!(node.schedule(0.9, false).1, Duration::from_secs(60));
310    }
311}