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::{ElementSpec, 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 labeled 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    /// The numeric range `[low, high]` this reading can be commanded to, in its unit,
92    /// present only on a numeric actuator such as a pump speed or a setpoint. The page
93    /// shows a control for it, and a [`Command::Set`](crate::Command::Set) inside the
94    /// range moves the value.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub range: Option<[f32; 2]>,
97    /// A display name for a key the page's own bundles and the served catalog do not
98    /// carry, such as a sensor added from the page itself. The page prefers a translated
99    /// label when it has one, then this, then a reading of the key.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub label: Option<String>,
102    /// Whether this is a node or network stat (neighbors, hops, link or relay status, a
103    /// tamper-log record count) rather than a measurement of the world. The page counts and
104    /// renders stats apart from sensors. Defaults `false`.
105    #[serde(default, skip_serializing_if = "is_false")]
106    pub stat: bool,
107}
108
109impl Reading {
110    /// Creates a reading in good standing with no band or trend.
111    ///
112    /// # Arguments
113    ///
114    /// * `key` - the stable element key.
115    /// * `value` - the raw measured value.
116    /// * `unit` - the canonical unit name.
117    ///
118    /// # Returns
119    ///
120    /// A [`Status::Ok`] reading carrying just the value and unit.
121    pub fn new(key: impl Into<String>, value: f32, unit: impl Into<String>) -> Self {
122        Self {
123            key: key.into(),
124            value,
125            unit: unit.into(),
126            status: Status::Ok,
127            band: None,
128            viz: None,
129            trend: None,
130            state: None,
131            actions: None,
132            range: None,
133            label: None,
134            stat: false,
135        }
136    }
137
138    /// Creates a reading from an element a profile declares.
139    ///
140    /// A profile says how a reading should be drawn: its key, unit, graphic, safe band,
141    /// and the value or discrete state it starts in. This is that declaration turned
142    /// into the first reading a gateway reports, so a fleet can be stood up straight
143    /// from the profiles it runs. Later samples replace the value and keep the rest.
144    ///
145    /// # Arguments
146    ///
147    /// * `element` - the element the profile declares.
148    ///
149    /// # Returns
150    ///
151    /// A [`Status::Ok`] reading carrying the element's key, unit, graphic, band, and
152    /// starting value or state.
153    ///
154    /// # Examples
155    ///
156    /// ```
157    /// use pamoja_dashboard::{ElementSpec, Reading, Viz};
158    ///
159    /// let element = ElementSpec::new("store_humidity", "percent", "Store humidity", Viz::Droplet)
160    ///     .with_band(30.0, 65.0)
161    ///     .with_value(48.0);
162    /// let reading = Reading::from_element(&element);
163    /// assert_eq!(reading.key, "store_humidity");
164    /// assert_eq!(reading.band, Some([30.0, 65.0]));
165    /// assert_eq!(reading.viz.as_deref(), Some("droplet"));
166    /// ```
167    pub fn from_element(element: &ElementSpec) -> Self {
168        let mut reading = Reading::new(
169            &element.key,
170            element.value.unwrap_or_default(),
171            &element.unit,
172        )
173        .with_viz(element.viz);
174        if let Some([low, high]) = element.band {
175            reading = reading.with_band(low, high);
176        }
177        if let Some(state) = &element.state {
178            reading = reading.with_state(state);
179        }
180        if element.stat {
181            reading = reading.as_stat();
182        }
183        reading
184    }
185
186    /// Sets the reading's health.
187    ///
188    /// # Arguments
189    ///
190    /// * `status` - the health to record.
191    ///
192    /// # Returns
193    ///
194    /// The reading, for chaining.
195    pub fn with_status(mut self, status: Status) -> Self {
196        self.status = status;
197        self
198    }
199
200    /// Sets the safe band drawn as the gauge's safe zone.
201    ///
202    /// # Arguments
203    ///
204    /// * `low` - the bottom of the safe band.
205    /// * `high` - the top of the safe band.
206    ///
207    /// # Returns
208    ///
209    /// The reading, for chaining.
210    pub fn with_band(mut self, low: f32, high: f32) -> Self {
211        self.band = Some([low, high]);
212        self
213    }
214
215    /// Pins the graphic this reading is drawn with, overriding the page's heuristic.
216    ///
217    /// Use this when a profile declares a custom element with [`Viz`] and you want the
218    /// live reading drawn the same way, regardless of its key and unit.
219    ///
220    /// # Arguments
221    ///
222    /// * `viz` - the graphic to draw the reading with.
223    ///
224    /// # Returns
225    ///
226    /// The reading, for chaining.
227    pub fn with_viz(mut self, viz: Viz) -> Self {
228        self.viz = Some(viz.kind().to_owned());
229        self
230    }
231
232    /// Sets the reading's trend arrow.
233    ///
234    /// # Arguments
235    ///
236    /// * `trend` - which way the reading is moving.
237    ///
238    /// # Returns
239    ///
240    /// The reading, for chaining.
241    pub fn with_trend(mut self, trend: Trend) -> Self {
242        self.trend = Some(trend);
243        self
244    }
245
246    /// Sets a discrete state code for a non-numeric reading, rendered as a chip.
247    ///
248    /// # Arguments
249    ///
250    /// * `state` - the stable state code, such as `"state.open"`.
251    ///
252    /// # Returns
253    ///
254    /// The reading, for chaining.
255    pub fn with_state(mut self, state: impl Into<String>) -> Self {
256        self.state = Some(state.into());
257        self
258    }
259
260    /// Marks the reading as a controllable actuator with the given discrete actions.
261    ///
262    /// # Arguments
263    ///
264    /// * `actions` - the action codes a client may command, such as `["open", "closed"]`.
265    ///
266    /// # Returns
267    ///
268    /// The reading, for chaining.
269    pub fn with_actions(mut self, actions: impl IntoIterator<Item = impl Into<String>>) -> Self {
270        self.actions = Some(actions.into_iter().map(Into::into).collect());
271        self
272    }
273
274    /// Marks the reading as a numeric actuator that accepts a value inside a range.
275    ///
276    /// # Arguments
277    ///
278    /// * `low` - the lowest value a client may command, in the reading's unit.
279    /// * `high` - the highest value a client may command.
280    ///
281    /// # Returns
282    ///
283    /// The reading, for chaining.
284    pub fn with_range(mut self, low: f32, high: f32) -> Self {
285        self.range = Some([low, high]);
286        self
287    }
288
289    /// Gives the reading a display name of its own, for a key no bundle or catalog knows.
290    ///
291    /// # Arguments
292    ///
293    /// * `label` - the name the page shows when it has no translation for the key.
294    ///
295    /// # Returns
296    ///
297    /// The reading, for chaining.
298    pub fn with_label(mut self, label: impl Into<String>) -> Self {
299        self.label = Some(label.into());
300        self
301    }
302
303    /// Marks the reading as a node or network stat rather than a measurement, so the page
304    /// counts and renders it apart from sensors.
305    ///
306    /// # Returns
307    ///
308    /// The reading, for chaining.
309    pub fn as_stat(mut self) -> Self {
310        self.stat = true;
311        self
312    }
313}
314
315/// The severity of a telemetry event, mirrored onto the wire as a stable string.
316#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(rename_all = "lowercase")]
318pub enum EventLevel {
319    /// Fine-grained detail.
320    Trace,
321    /// Diagnostic detail.
322    Debug,
323    /// A normal, noteworthy event.
324    Info,
325    /// Something unexpected the node recovered from.
326    Warn,
327    /// A failure that needs attention.
328    Error,
329}
330
331impl From<Level> for EventLevel {
332    fn from(level: Level) -> Self {
333        match level {
334            Level::Trace => EventLevel::Trace,
335            Level::Debug => EventLevel::Debug,
336            Level::Info => EventLevel::Info,
337            Level::Warn => EventLevel::Warn,
338            Level::Error => EventLevel::Error,
339        }
340    }
341}
342
343/// One recent telemetry event, carried as a stable code the page localizes.
344#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
345#[serde(rename_all = "camelCase")]
346pub struct EventRecord {
347    /// The event's severity.
348    pub level: EventLevel,
349    /// The stable, short event code, such as `"battery.low"` or `"link.lost"`.
350    pub code: String,
351    /// An optional measurement that came with the event.
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub value: Option<f32>,
354    /// How many seconds ago the event happened, for a relative "x ago" display.
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub age_secs: Option<u64>,
357}
358
359impl EventRecord {
360    /// Builds a record from a telemetry [`Event`] and how long ago it happened.
361    ///
362    /// # Arguments
363    ///
364    /// * `event` - the telemetry event to mirror onto the wire.
365    /// * `age_secs` - how many seconds ago it happened, or `None` if unknown.
366    ///
367    /// # Returns
368    ///
369    /// The serializable event record.
370    pub fn from_event(event: &Event, age_secs: Option<u64>) -> Self {
371        Self {
372            level: event.level.into(),
373            code: event.code.to_owned(),
374            value: event.value,
375            age_secs,
376        }
377    }
378}
379
380/// The work cadence a node is running at, mirrored from [`PowerMode`].
381#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(rename_all = "lowercase")]
383pub enum Mode {
384    /// Healthy charge: the normal cadence.
385    Active,
386    /// Low charge: a stretched cadence to conserve.
387    Saver,
388    /// Critically low charge: the bare minimum to survive.
389    Critical,
390}
391
392impl From<PowerMode> for Mode {
393    fn from(mode: PowerMode) -> Self {
394        match mode {
395            PowerMode::Active => Mode::Active,
396            PowerMode::Saver => Mode::Saver,
397            PowerMode::Critical => Mode::Critical,
398        }
399    }
400}
401
402/// The kind of link a group reports over, shown as a labeled service before the bars.
403#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
404#[serde(rename_all = "lowercase")]
405pub enum LinkKind {
406    /// Long-range, low-power radio.
407    Lora,
408    /// Local WiFi.
409    Wifi,
410    /// A cellular modem (LTE-M, 2G/4G, or similar).
411    Cellular,
412    /// A narrowband-IoT cellular link, common for low-power field clinics.
413    NbIot,
414    /// A satellite uplink.
415    Satellite,
416    /// Wired Ethernet.
417    Ethernet,
418    /// A multi-hop radio mesh.
419    Mesh,
420}
421
422/// A group's connectivity: what it talks over, how strong it is, and whether it is up.
423#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
424pub struct Link {
425    /// The kind of link.
426    pub kind: LinkKind,
427    /// Signal strength as a bar count in `0..=4`.
428    pub strength: u8,
429    /// Whether the group currently has any uplink at all.
430    pub online: bool,
431}
432
433/// A single sensor: its current reading, recent history, power, and recent events.
434#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
435#[serde(rename_all = "camelCase")]
436pub struct Sensor {
437    /// A stable, human-readable sensor identifier, such as `"fridge-1"`.
438    pub id: String,
439    /// The sensor's current reading.
440    pub reading: Reading,
441    /// The sensor's battery state of charge in `[0.0, 1.0]`, if it has one.
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub battery: Option<f32>,
444    /// The work cadence the sensor's node is running at.
445    pub mode: Mode,
446    /// Recent values of the reading, oldest first, for a sparkline and min/max.
447    pub history: Vec<f32>,
448    /// The most recent telemetry events for this sensor, newest first.
449    pub events: Vec<EventRecord>,
450    /// The mesh peer (node or station) that hosts this sensor, if any. Sensors sharing a
451    /// peer name are drawn on one node in the mesh map; an empty peer is the node itself.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub peer: Option<String>,
454    /// The sensor's (or its hosting peer's) latitude in decimal degrees, if known. When set
455    /// with [`lon`](Sensor::lon) the mesh map can place the peer by real position.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub lat: Option<f64>,
458    /// The sensor's (or its hosting peer's) longitude in decimal degrees, if known.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub lon: Option<f64>,
461}
462
463impl Sensor {
464    /// Creates a sensor with an id and current reading, no battery, history, or events yet.
465    ///
466    /// # Arguments
467    ///
468    /// * `id` - the stable sensor identifier.
469    /// * `reading` - the sensor's current reading.
470    ///
471    /// # Returns
472    ///
473    /// An [`Mode::Active`] sensor carrying just the reading.
474    pub fn new(id: impl Into<String>, reading: Reading) -> Self {
475        Self {
476            id: id.into(),
477            reading,
478            battery: None,
479            mode: Mode::Active,
480            history: Vec::new(),
481            events: Vec::new(),
482            peer: None,
483            lat: None,
484            lon: None,
485        }
486    }
487
488    /// Sets the mesh peer (node or station) that hosts this sensor.
489    ///
490    /// # Arguments
491    ///
492    /// * `peer` - the host peer's name.
493    ///
494    /// # Returns
495    ///
496    /// The sensor, for chaining.
497    pub fn on_peer(mut self, peer: impl Into<String>) -> Self {
498        self.peer = Some(peer.into());
499        self
500    }
501
502    /// Sets the sensor's (or its hosting peer's) geographic position.
503    ///
504    /// # Arguments
505    ///
506    /// * `lat` - latitude in decimal degrees.
507    /// * `lon` - longitude in decimal degrees.
508    ///
509    /// # Returns
510    ///
511    /// The sensor, for chaining.
512    pub fn at(mut self, lat: f64, lon: f64) -> Self {
513        self.lat = Some(lat);
514        self.lon = Some(lon);
515        self
516    }
517}
518
519/// A group of sensors sharing one node and one link, such as a clinic's fridges.
520#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
521#[serde(rename_all = "camelCase")]
522pub struct Group {
523    /// A stable group identifier.
524    pub id: String,
525    /// A human-readable group name, such as `"Kano cold chain"`.
526    pub name: String,
527    /// The group's link.
528    pub link: Link,
529    /// The group's overall health, the worst of its sensors.
530    pub status: Status,
531    /// The sensors in the group.
532    pub sensors: Vec<Sensor>,
533    /// The node's latitude in decimal degrees, if known. With [`lon`](Group::lon) it places
534    /// the node on a geographic map.
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub lat: Option<f64>,
537    /// The node's longitude in decimal degrees, if known.
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub lon: Option<f64>,
540}
541
542impl Group {
543    /// Sets the node's geographic position.
544    ///
545    /// # Arguments
546    ///
547    /// * `lat` - latitude in decimal degrees.
548    /// * `lon` - longitude in decimal degrees.
549    ///
550    /// # Returns
551    ///
552    /// The group, for chaining.
553    pub fn at(mut self, lat: f64, lon: f64) -> Self {
554        self.lat = Some(lat);
555        self.lon = Some(lon);
556        self
557    }
558
559    /// Recomputes the group's [`status`](Group::status) from its sensors and events.
560    ///
561    /// # Returns
562    ///
563    /// The group's overall status, also stored back into [`status`](Group::status).
564    pub fn recompute_status(&mut self) -> Status {
565        let mut overall = if self.link.online {
566            Status::Ok
567        } else {
568            Status::Warn
569        };
570        for sensor in &self.sensors {
571            overall = overall.worst(sensor.reading.status);
572            for event in &sensor.events {
573                overall = overall.worst(match event.level {
574                    EventLevel::Error => Status::Alarm,
575                    EventLevel::Warn => Status::Warn,
576                    _ => Status::Ok,
577                });
578            }
579        }
580        self.status = overall;
581        overall
582    }
583}
584
585/// An organization, such as a health authority or a farming co-op.
586#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
587#[serde(rename_all = "camelCase")]
588pub struct Org {
589    /// A stable organization identifier.
590    pub id: String,
591    /// A human-readable organization name.
592    pub name: String,
593    /// The sensor groups belonging to the organization.
594    pub groups: Vec<Group>,
595}
596
597/// The complete language-neutral fleet snapshot served at `GET /state`.
598///
599/// This is the single source the dashboard renders from. It is byte-identical in
600/// every locale; the page supplies all words and formatting.
601#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
602#[serde(rename_all = "camelCase")]
603pub struct State {
604    /// The organizations in the fleet.
605    pub orgs: Vec<Org>,
606    /// The fleet's overall health, the worst across every group.
607    pub status: Status,
608    /// Seconds the gateway has been running, if tracked.
609    #[serde(skip_serializing_if = "Option::is_none")]
610    pub uptime_secs: Option<u64>,
611    /// Whether this snapshot comes from the hardware-free demo, not a real device. The page
612    /// shows demo-only affordances (the scenario switcher) only when this is set; a real
613    /// device omits it.
614    #[serde(default, skip_serializing_if = "is_false")]
615    pub demo: bool,
616}
617
618fn is_false(value: &bool) -> bool {
619    !*value
620}
621
622impl State {
623    /// Recomputes every group's status and the fleet's overall status.
624    ///
625    /// # Returns
626    ///
627    /// The fleet's overall status, also stored back into [`status`](State::status).
628    pub fn recompute_status(&mut self) -> Status {
629        let mut overall = Status::Ok;
630        for org in &mut self.orgs {
631            for group in &mut org.groups {
632                overall = overall.worst(group.recompute_status());
633            }
634        }
635        self.status = overall;
636        overall
637    }
638
639    /// Serializes the snapshot to the compact JSON served at `GET /state`.
640    ///
641    /// # Returns
642    ///
643    /// The JSON text of the snapshot.
644    ///
645    /// # Errors
646    ///
647    /// Returns a [`serde_json::Error`] if the snapshot cannot be serialized, which in
648    /// practice only happens on a non-finite float.
649    pub fn to_json(&self) -> Result<String, serde_json::Error> {
650        serde_json::to_string(self)
651    }
652
653    /// Parses a snapshot from its JSON form, for restoring a persisted fleet on boot.
654    ///
655    /// # Arguments
656    ///
657    /// * `json` - the JSON text of a previously serialized snapshot.
658    ///
659    /// # Returns
660    ///
661    /// The parsed [`State`].
662    ///
663    /// # Errors
664    ///
665    /// Returns a [`serde_json::Error`] if the JSON is malformed or does not match the shape.
666    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
667        serde_json::from_str(json)
668    }
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    fn sensor(key: &str, value: f32, status: Status) -> Sensor {
676        Sensor {
677            id: key.to_owned(),
678            reading: Reading::new(key, value, "celsius")
679                .with_status(status)
680                .with_band(2.0, 8.0),
681            battery: Some(0.8),
682            mode: Mode::Active,
683            history: vec![value],
684            events: Vec::new(),
685            peer: None,
686            lat: None,
687            lon: None,
688        }
689    }
690
691    fn fleet(sensor_status: Status, online: bool) -> State {
692        State {
693            orgs: vec![Org {
694                id: "org-1".to_owned(),
695                name: "Org One".to_owned(),
696                groups: vec![Group {
697                    id: "g1".to_owned(),
698                    name: "Group One".to_owned(),
699                    link: Link {
700                        kind: LinkKind::Lora,
701                        strength: 3,
702                        online,
703                    },
704                    status: Status::Ok,
705                    sensors: vec![sensor("temperature", 5.0, sensor_status)],
706                    lat: None,
707                    lon: None,
708                }],
709            }],
710            status: Status::Ok,
711            uptime_secs: Some(3600),
712            demo: false,
713        }
714    }
715
716    #[test]
717    fn status_worst_picks_the_most_urgent() {
718        assert_eq!(Status::Ok.worst(Status::Warn), Status::Warn);
719        assert_eq!(Status::Warn.worst(Status::Alarm), Status::Alarm);
720    }
721
722    #[test]
723    fn recompute_rolls_sensor_status_up_to_group_and_fleet() {
724        let mut state = fleet(Status::Alarm, true);
725        assert_eq!(state.recompute_status(), Status::Alarm);
726        assert_eq!(state.orgs[0].groups[0].status, Status::Alarm);
727        assert_eq!(state.status, Status::Alarm);
728    }
729
730    #[test]
731    fn an_offline_group_is_at_least_a_warning() {
732        let mut state = fleet(Status::Ok, false);
733        assert_eq!(state.recompute_status(), Status::Warn);
734    }
735
736    #[test]
737    fn the_fleet_round_trips_through_json() {
738        let mut state = fleet(Status::Warn, true);
739        state.recompute_status();
740        let json = state.to_json().expect("serialize");
741        let restored: State = serde_json::from_str(&json).expect("deserialize");
742        assert_eq!(state, restored);
743    }
744
745    #[test]
746    fn the_wire_uses_stable_lowercase_tags() {
747        assert_eq!(serde_json::to_string(&Status::Alarm).unwrap(), "\"alarm\"");
748        assert_eq!(serde_json::to_string(&LinkKind::Lora).unwrap(), "\"lora\"");
749        assert_eq!(serde_json::to_string(&Mode::Saver).unwrap(), "\"saver\"");
750    }
751}