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}