Skip to main content

pamoja_dashboard/
state.rs

1//! The language-neutral fleet snapshot a gateway serves to its dashboard.
2//!
3//! A dashboard often watches more than one node: a clinic with several cold-chain
4//! fridges, a co-op with many silos, a watershed of river gauges. So the snapshot is a
5//! fleet - organizations, each with sensor groups, each group on its own link and
6//! holding its own sensors. Everything human-facing travels as stable keys, stable
7//! codes, raw values, and canonical units, identical in every locale; the page renders
8//! the words and the formatting at the surface.
9
10use serde::{Deserialize, Serialize};
11
12use pamoja_power::PowerMode;
13use pamoja_profile::Viz;
14use pamoja_telemetry::{Event, Level};
15
16/// The health of a sensor, group, or the whole fleet, the basis of the glance-first UI.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Status {
20    /// Everything is within its safe band. Ordered least urgent, so the derived
21    /// ordering makes [`Status::worst`] a simple `max`.
22    #[default]
23    Ok,
24    /// Something needs attention but is not yet critical.
25    Warn,
26    /// A safety threshold has been crossed and action is needed now.
27    Alarm,
28}
29
30impl Status {
31    /// Returns the more urgent of two statuses.
32    ///
33    /// # Arguments
34    ///
35    /// * `other` - the status to compare against.
36    ///
37    /// # Returns
38    ///
39    /// The most urgent of the two.
40    pub fn worst(self, other: Status) -> Status {
41        self.max(other)
42    }
43}
44
45/// The direction a reading is moving, drawn as a trend arrow.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum Trend {
49    /// The reading is rising.
50    Rising,
51    /// The reading is steady.
52    Steady,
53    /// The reading is falling.
54    Falling,
55}
56
57/// A single measured value, named by a stable key and a canonical unit.
58#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct Reading {
61    /// A stable, language-neutral element key, such as `"soil_moisture"`.
62    pub key: String,
63    /// The raw measured value, in the canonical unit.
64    pub value: f32,
65    /// The canonical unit name, such as `"percent"`, `"celsius"`, or `"volt"`.
66    pub unit: String,
67    /// The health of this reading on its own.
68    pub status: Status,
69    /// The safe band `[low, high]` in the same unit, drawn as the gauge's safe zone.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub band: Option<[f32; 2]>,
72    /// An explicit visualization kind that overrides the page's key/unit heuristic, such
73    /// as `"radial"` or `"bar"`. Set from a profile's [`Viz`](pamoja_profile::Viz) with
74    /// [`with_viz`](Reading::with_viz) so a custom reading is drawn the way its profile
75    /// intends; `None` lets the page pick from the key and unit.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub viz: Option<String>,
78    /// Which way the reading is moving, if known.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub trend: Option<Trend>,
81    /// A discrete state code for a non-numeric reading, such as `"state.open"` for a
82    /// valve or `"pump.nominal"` for a pump, which the page renders as a labelled chip.
83    /// Numeric readings leave this `None`.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub state: Option<String>,
86    /// The discrete actions this reading can be commanded to, such as
87    /// `["open", "closed"]` for a valve. Present only on a controllable actuator; a
88    /// read-only sensor leaves this `None`, and the page shows control only when it is set.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub actions: Option<Vec<String>>,
91    /// Whether this is a node or network stat (neighbours, hops, link or relay status, a
92    /// tamper-log record count) rather than a measurement of the world. The page counts and
93    /// renders stats apart from sensors. Defaults `false`.
94    #[serde(default, skip_serializing_if = "is_false")]
95    pub stat: bool,
96}
97
98impl Reading {
99    /// Creates a reading in good standing with no band or trend.
100    ///
101    /// # Arguments
102    ///
103    /// * `key` - the stable element key.
104    /// * `value` - the raw measured value.
105    /// * `unit` - the canonical unit name.
106    ///
107    /// # Returns
108    ///
109    /// A [`Status::Ok`] reading carrying just the value and unit.
110    pub fn new(key: impl Into<String>, value: f32, unit: impl Into<String>) -> Self {
111        Self {
112            key: key.into(),
113            value,
114            unit: unit.into(),
115            status: Status::Ok,
116            band: None,
117            viz: None,
118            trend: None,
119            state: None,
120            actions: None,
121            stat: false,
122        }
123    }
124
125    /// Sets the reading's health.
126    ///
127    /// # Arguments
128    ///
129    /// * `status` - the health to record.
130    ///
131    /// # Returns
132    ///
133    /// The reading, for chaining.
134    pub fn with_status(mut self, status: Status) -> Self {
135        self.status = status;
136        self
137    }
138
139    /// Sets the safe band drawn as the gauge's safe zone.
140    ///
141    /// # Arguments
142    ///
143    /// * `low` - the bottom of the safe band.
144    /// * `high` - the top of the safe band.
145    ///
146    /// # Returns
147    ///
148    /// The reading, for chaining.
149    pub fn with_band(mut self, low: f32, high: f32) -> Self {
150        self.band = Some([low, high]);
151        self
152    }
153
154    /// Pins the graphic this reading is drawn with, overriding the page's heuristic.
155    ///
156    /// Use this when a profile declares a custom element with [`Viz`] and you want the
157    /// live reading drawn the same way, regardless of its key and unit.
158    ///
159    /// # Arguments
160    ///
161    /// * `viz` - the graphic to draw the reading with.
162    ///
163    /// # Returns
164    ///
165    /// The reading, for chaining.
166    pub fn with_viz(mut self, viz: Viz) -> Self {
167        self.viz = Some(viz.kind().to_owned());
168        self
169    }
170
171    /// Sets the reading's trend arrow.
172    ///
173    /// # Arguments
174    ///
175    /// * `trend` - which way the reading is moving.
176    ///
177    /// # Returns
178    ///
179    /// The reading, for chaining.
180    pub fn with_trend(mut self, trend: Trend) -> Self {
181        self.trend = Some(trend);
182        self
183    }
184
185    /// Sets a discrete state code for a non-numeric reading, rendered as a chip.
186    ///
187    /// # Arguments
188    ///
189    /// * `state` - the stable state code, such as `"state.open"`.
190    ///
191    /// # Returns
192    ///
193    /// The reading, for chaining.
194    pub fn with_state(mut self, state: impl Into<String>) -> Self {
195        self.state = Some(state.into());
196        self
197    }
198
199    /// Marks the reading as a controllable actuator with the given discrete actions.
200    ///
201    /// # Arguments
202    ///
203    /// * `actions` - the action codes a client may command, such as `["open", "closed"]`.
204    ///
205    /// # Returns
206    ///
207    /// The reading, for chaining.
208    pub fn with_actions(mut self, actions: impl IntoIterator<Item = impl Into<String>>) -> Self {
209        self.actions = Some(actions.into_iter().map(Into::into).collect());
210        self
211    }
212
213    /// Marks the reading as a node or network stat rather than a measurement, so the page
214    /// counts and renders it apart from sensors.
215    ///
216    /// # Returns
217    ///
218    /// The reading, for chaining.
219    pub fn as_stat(mut self) -> Self {
220        self.stat = true;
221        self
222    }
223}
224
225/// The severity of a telemetry event, mirrored onto the wire as a stable string.
226#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "lowercase")]
228pub enum EventLevel {
229    /// Fine-grained detail.
230    Trace,
231    /// Diagnostic detail.
232    Debug,
233    /// A normal, noteworthy event.
234    Info,
235    /// Something unexpected the node recovered from.
236    Warn,
237    /// A failure that needs attention.
238    Error,
239}
240
241impl From<Level> for EventLevel {
242    fn from(level: Level) -> Self {
243        match level {
244            Level::Trace => EventLevel::Trace,
245            Level::Debug => EventLevel::Debug,
246            Level::Info => EventLevel::Info,
247            Level::Warn => EventLevel::Warn,
248            Level::Error => EventLevel::Error,
249        }
250    }
251}
252
253/// One recent telemetry event, carried as a stable code the page localizes.
254#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct EventRecord {
257    /// The event's severity.
258    pub level: EventLevel,
259    /// The stable, short event code, such as `"battery.low"` or `"link.lost"`.
260    pub code: String,
261    /// An optional measurement that came with the event.
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub value: Option<f32>,
264    /// How many seconds ago the event happened, for a relative "x ago" display.
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub age_secs: Option<u64>,
267}
268
269impl EventRecord {
270    /// Builds a record from a telemetry [`Event`] and how long ago it happened.
271    ///
272    /// # Arguments
273    ///
274    /// * `event` - the telemetry event to mirror onto the wire.
275    /// * `age_secs` - how many seconds ago it happened, or `None` if unknown.
276    ///
277    /// # Returns
278    ///
279    /// The serializable event record.
280    pub fn from_event(event: &Event, age_secs: Option<u64>) -> Self {
281        Self {
282            level: event.level.into(),
283            code: event.code.to_owned(),
284            value: event.value,
285            age_secs,
286        }
287    }
288}
289
290/// The work cadence a node is running at, mirrored from [`PowerMode`].
291#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(rename_all = "lowercase")]
293pub enum Mode {
294    /// Healthy charge: the normal cadence.
295    Active,
296    /// Low charge: a stretched cadence to conserve.
297    Saver,
298    /// Critically low charge: the bare minimum to survive.
299    Critical,
300}
301
302impl From<PowerMode> for Mode {
303    fn from(mode: PowerMode) -> Self {
304        match mode {
305            PowerMode::Active => Mode::Active,
306            PowerMode::Saver => Mode::Saver,
307            PowerMode::Critical => Mode::Critical,
308        }
309    }
310}
311
312/// The kind of link a group reports over, shown as a labelled service before the bars.
313#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(rename_all = "lowercase")]
315pub enum LinkKind {
316    /// Long-range, low-power radio.
317    Lora,
318    /// Local WiFi.
319    Wifi,
320    /// A cellular modem (LTE-M, 2G/4G, or similar).
321    Cellular,
322    /// A narrowband-IoT cellular link, common for low-power field clinics.
323    NbIot,
324    /// A satellite uplink.
325    Satellite,
326    /// Wired Ethernet.
327    Ethernet,
328    /// A multi-hop radio mesh.
329    Mesh,
330}
331
332/// A group's connectivity: what it talks over, how strong it is, and whether it is up.
333#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
334pub struct Link {
335    /// The kind of link.
336    pub kind: LinkKind,
337    /// Signal strength as a bar count in `0..=4`.
338    pub strength: u8,
339    /// Whether the group currently has any uplink at all.
340    pub online: bool,
341}
342
343/// A single sensor: its current reading, recent history, power, and recent events.
344#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
345#[serde(rename_all = "camelCase")]
346pub struct Sensor {
347    /// A stable, human-readable sensor identifier, such as `"fridge-1"`.
348    pub id: String,
349    /// The sensor's current reading.
350    pub reading: Reading,
351    /// The sensor's battery state of charge in `[0.0, 1.0]`, if it has one.
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub battery: Option<f32>,
354    /// The work cadence the sensor's node is running at.
355    pub mode: Mode,
356    /// Recent values of the reading, oldest first, for a sparkline and min/max.
357    pub history: Vec<f32>,
358    /// The most recent telemetry events for this sensor, newest first.
359    pub events: Vec<EventRecord>,
360    /// The mesh peer (node or station) that hosts this sensor, if any. Sensors sharing a
361    /// peer name are drawn on one node in the mesh map; an empty peer is the node itself.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub peer: Option<String>,
364    /// The sensor's (or its hosting peer's) latitude in decimal degrees, if known. When set
365    /// with [`lon`](Sensor::lon) the mesh map can place the peer by real position.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub lat: Option<f64>,
368    /// The sensor's (or its hosting peer's) longitude in decimal degrees, if known.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub lon: Option<f64>,
371}
372
373impl Sensor {
374    /// Creates a sensor with an id and current reading, no battery, history, or events yet.
375    ///
376    /// # Arguments
377    ///
378    /// * `id` - the stable sensor identifier.
379    /// * `reading` - the sensor's current reading.
380    ///
381    /// # Returns
382    ///
383    /// An [`Mode::Active`] sensor carrying just the reading.
384    pub fn new(id: impl Into<String>, reading: Reading) -> Self {
385        Self {
386            id: id.into(),
387            reading,
388            battery: None,
389            mode: Mode::Active,
390            history: Vec::new(),
391            events: Vec::new(),
392            peer: None,
393            lat: None,
394            lon: None,
395        }
396    }
397
398    /// Sets the mesh peer (node or station) that hosts this sensor.
399    ///
400    /// # Arguments
401    ///
402    /// * `peer` - the host peer's name.
403    ///
404    /// # Returns
405    ///
406    /// The sensor, for chaining.
407    pub fn on_peer(mut self, peer: impl Into<String>) -> Self {
408        self.peer = Some(peer.into());
409        self
410    }
411
412    /// Sets the sensor's (or its hosting peer's) geographic position.
413    ///
414    /// # Arguments
415    ///
416    /// * `lat` - latitude in decimal degrees.
417    /// * `lon` - longitude in decimal degrees.
418    ///
419    /// # Returns
420    ///
421    /// The sensor, for chaining.
422    pub fn at(mut self, lat: f64, lon: f64) -> Self {
423        self.lat = Some(lat);
424        self.lon = Some(lon);
425        self
426    }
427}
428
429/// A group of sensors sharing one node and one link, such as a clinic's fridges.
430#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
431#[serde(rename_all = "camelCase")]
432pub struct Group {
433    /// A stable group identifier.
434    pub id: String,
435    /// A human-readable group name, such as `"Kano cold chain"`.
436    pub name: String,
437    /// The group's link.
438    pub link: Link,
439    /// The group's overall health, the worst of its sensors.
440    pub status: Status,
441    /// The sensors in the group.
442    pub sensors: Vec<Sensor>,
443    /// The node's latitude in decimal degrees, if known. With [`lon`](Group::lon) it places
444    /// the node on a geographic map.
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub lat: Option<f64>,
447    /// The node's longitude in decimal degrees, if known.
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub lon: Option<f64>,
450}
451
452impl Group {
453    /// Sets the node's geographic position.
454    ///
455    /// # Arguments
456    ///
457    /// * `lat` - latitude in decimal degrees.
458    /// * `lon` - longitude in decimal degrees.
459    ///
460    /// # Returns
461    ///
462    /// The group, for chaining.
463    pub fn at(mut self, lat: f64, lon: f64) -> Self {
464        self.lat = Some(lat);
465        self.lon = Some(lon);
466        self
467    }
468
469    /// Recomputes the group's [`status`](Group::status) from its sensors and events.
470    ///
471    /// # Returns
472    ///
473    /// The group's overall status, also stored back into [`status`](Group::status).
474    pub fn recompute_status(&mut self) -> Status {
475        let mut overall = if self.link.online {
476            Status::Ok
477        } else {
478            Status::Warn
479        };
480        for sensor in &self.sensors {
481            overall = overall.worst(sensor.reading.status);
482            for event in &sensor.events {
483                overall = overall.worst(match event.level {
484                    EventLevel::Error => Status::Alarm,
485                    EventLevel::Warn => Status::Warn,
486                    _ => Status::Ok,
487                });
488            }
489        }
490        self.status = overall;
491        overall
492    }
493}
494
495/// An organization, such as a health authority or a farming co-op.
496#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
497#[serde(rename_all = "camelCase")]
498pub struct Org {
499    /// A stable organization identifier.
500    pub id: String,
501    /// A human-readable organization name.
502    pub name: String,
503    /// The sensor groups belonging to the organization.
504    pub groups: Vec<Group>,
505}
506
507/// The complete language-neutral fleet snapshot served at `GET /state`.
508///
509/// This is the single source the dashboard renders from. It is byte-identical in
510/// every locale; the page supplies all words and formatting.
511#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
512#[serde(rename_all = "camelCase")]
513pub struct State {
514    /// The organizations in the fleet.
515    pub orgs: Vec<Org>,
516    /// The fleet's overall health, the worst across every group.
517    pub status: Status,
518    /// Seconds the gateway has been running, if tracked.
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub uptime_secs: Option<u64>,
521    /// Whether this snapshot comes from the hardware-free demo, not a real device. The page
522    /// shows demo-only affordances (the scenario switcher) only when this is set; a real
523    /// device omits it.
524    #[serde(default, skip_serializing_if = "is_false")]
525    pub demo: bool,
526}
527
528fn is_false(value: &bool) -> bool {
529    !*value
530}
531
532impl State {
533    /// Recomputes every group's status and the fleet's overall status.
534    ///
535    /// # Returns
536    ///
537    /// The fleet's overall status, also stored back into [`status`](State::status).
538    pub fn recompute_status(&mut self) -> Status {
539        let mut overall = Status::Ok;
540        for org in &mut self.orgs {
541            for group in &mut org.groups {
542                overall = overall.worst(group.recompute_status());
543            }
544        }
545        self.status = overall;
546        overall
547    }
548
549    /// Serializes the snapshot to the compact JSON served at `GET /state`.
550    ///
551    /// # Returns
552    ///
553    /// The JSON text of the snapshot.
554    ///
555    /// # Errors
556    ///
557    /// Returns a [`serde_json::Error`] if the snapshot cannot be serialized, which in
558    /// practice only happens on a non-finite float.
559    pub fn to_json(&self) -> Result<String, serde_json::Error> {
560        serde_json::to_string(self)
561    }
562
563    /// Parses a snapshot from its JSON form, for restoring a persisted fleet on boot.
564    ///
565    /// # Arguments
566    ///
567    /// * `json` - the JSON text of a previously serialized snapshot.
568    ///
569    /// # Returns
570    ///
571    /// The parsed [`State`].
572    ///
573    /// # Errors
574    ///
575    /// Returns a [`serde_json::Error`] if the JSON is malformed or does not match the shape.
576    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
577        serde_json::from_str(json)
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    fn sensor(key: &str, value: f32, status: Status) -> Sensor {
586        Sensor {
587            id: key.to_owned(),
588            reading: Reading::new(key, value, "celsius")
589                .with_status(status)
590                .with_band(2.0, 8.0),
591            battery: Some(0.8),
592            mode: Mode::Active,
593            history: vec![value],
594            events: Vec::new(),
595            peer: None,
596            lat: None,
597            lon: None,
598        }
599    }
600
601    fn fleet(sensor_status: Status, online: bool) -> State {
602        State {
603            orgs: vec![Org {
604                id: "org-1".to_owned(),
605                name: "Org One".to_owned(),
606                groups: vec![Group {
607                    id: "g1".to_owned(),
608                    name: "Group One".to_owned(),
609                    link: Link {
610                        kind: LinkKind::Lora,
611                        strength: 3,
612                        online,
613                    },
614                    status: Status::Ok,
615                    sensors: vec![sensor("temperature", 5.0, sensor_status)],
616                    lat: None,
617                    lon: None,
618                }],
619            }],
620            status: Status::Ok,
621            uptime_secs: Some(3600),
622            demo: false,
623        }
624    }
625
626    #[test]
627    fn status_worst_picks_the_most_urgent() {
628        assert_eq!(Status::Ok.worst(Status::Warn), Status::Warn);
629        assert_eq!(Status::Warn.worst(Status::Alarm), Status::Alarm);
630    }
631
632    #[test]
633    fn recompute_rolls_sensor_status_up_to_group_and_fleet() {
634        let mut state = fleet(Status::Alarm, true);
635        assert_eq!(state.recompute_status(), Status::Alarm);
636        assert_eq!(state.orgs[0].groups[0].status, Status::Alarm);
637        assert_eq!(state.status, Status::Alarm);
638    }
639
640    #[test]
641    fn an_offline_group_is_at_least_a_warning() {
642        let mut state = fleet(Status::Ok, false);
643        assert_eq!(state.recompute_status(), Status::Warn);
644    }
645
646    #[test]
647    fn the_fleet_round_trips_through_json() {
648        let mut state = fleet(Status::Warn, true);
649        state.recompute_status();
650        let json = state.to_json().expect("serialize");
651        let restored: State = serde_json::from_str(&json).expect("deserialize");
652        assert_eq!(state, restored);
653    }
654
655    #[test]
656    fn the_wire_uses_stable_lowercase_tags() {
657        assert_eq!(serde_json::to_string(&Status::Alarm).unwrap(), "\"alarm\"");
658        assert_eq!(serde_json::to_string(&LinkKind::Lora).unwrap(), "\"lora\"");
659        assert_eq!(serde_json::to_string(&Mode::Saver).unwrap(), "\"saver\"");
660    }
661}