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}