Skip to main content

pamoja_dashboard/
fleet.rs

1//! A real fleet source: a project pushes readings in, the dashboard reads them out, and
2//! control commands queue back for the project to apply.
3//!
4//! The dashboard renders whatever implements [`StateSource`]. [`Mock`](crate::Mock) is the
5//! hardware-free demo; `Fleet` is the real one. Sensing in this SDK is async and the server
6//! is synchronous, so the project owns its own sampling loop - ticking its profiles and
7//! nodes on their power schedule - and pushes each result into a `Fleet` with the report
8//! methods. The dashboard reads the latest with [`snapshot`](StateSource::snapshot), and
9//! authenticated control [`Command`]s queue for the project to drain with
10//! [`take_commands`](Fleet::take_commands) and apply to real hardware, then reflect the
11//! result back through the report methods. The device stays authoritative; provisioning and
12//! actuation are also applied optimistically so the UI updates at once.
13//!
14//! A `Fleet` is cheap to [`Clone`] (it shares one inner state), so one handle drives the
15//! [`Server`](crate::Server) while another stays with the sampling loop.
16//!
17//! # Examples
18//!
19//! ```
20//! use pamoja_dashboard::{Fleet, LinkKind, Reading, Sensor, StateSource, Status};
21//!
22//! let fleet = Fleet::builder()
23//!     .org("clinic", "Kano clinic")
24//!     .group("clinic", "fridges", "Cold chain", LinkKind::Cellular)
25//!     .sensor(
26//!         "fridges",
27//!         Sensor::new("fridge-1", Reading::new("fridge_temp", 4.5, "celsius").with_band(2.0, 8.0)),
28//!     )
29//!     .build();
30//!
31//! // The sampling loop pushes a fresh reading; the dashboard sees it.
32//! fleet.report_reading(
33//!     "fridges",
34//!     "fridge-1",
35//!     Reading::new("fridge_temp", 9.2, "celsius").with_band(2.0, 8.0).with_status(Status::Alarm),
36//! );
37//! let mut handle = fleet.clone();
38//! assert_eq!(handle.snapshot().status, Status::Alarm);
39//! ```
40
41use std::collections::HashSet;
42use std::sync::{Arc, Mutex};
43use std::time::Instant;
44
45use crate::command::{Command, CommandError};
46use crate::source::StateSource;
47use crate::state::{EventRecord, Group, Link, LinkKind, Mode, Org, Reading, Sensor, State, Status};
48
49// How many recent values each sensor keeps for its sparkline.
50const HISTORY: usize = 32;
51
52struct Inner {
53    state: State,
54    commands: Vec<Command>,
55    started: Instant,
56    // The sensor element keys a client may add. `None` accepts any (the default); `Some`
57    // rejects a client `AddSensor` whose key is not listed, so a real device only takes the
58    // sensor types it can actually bind. Gateway-initiated discovery is never gated.
59    allowed: Option<HashSet<String>>,
60}
61
62/// A real fleet a project fills and the dashboard renders. Clone to share one between the
63/// serving layer and the sampling loop.
64#[derive(Clone)]
65pub struct Fleet {
66    inner: Arc<Mutex<Inner>>,
67}
68
69impl Fleet {
70    /// Starts building a fleet's initial structure.
71    ///
72    /// # Returns
73    ///
74    /// An empty [`FleetBuilder`].
75    pub fn builder() -> FleetBuilder {
76        FleetBuilder { orgs: Vec::new() }
77    }
78
79    /// Restores a fleet from a previously saved [`State`], for a gateway that persists its
80    /// fleet across restarts (save what [`snapshot`](StateSource::snapshot) returns, reload
81    /// it here on boot).
82    ///
83    /// # Arguments
84    ///
85    /// * `state` - the fleet structure and last readings to restore.
86    ///
87    /// # Returns
88    ///
89    /// A fleet holding the restored state.
90    pub fn from_state(state: State) -> Self {
91        Self {
92            inner: Arc::new(Mutex::new(Inner {
93                state,
94                commands: Vec::new(),
95                started: Instant::now(),
96                allowed: None,
97            })),
98        }
99    }
100
101    /// Restricts which sensor types a client may add, so a real device only accepts the
102    /// sensors it can bind a driver to.
103    ///
104    /// Without this, an `AddSensor` from the dashboard is always accepted (which suits the
105    /// hardware-free demo). With it, a client `AddSensor` whose element key is not listed is
106    /// rejected with [`CommandError::UnknownSensor`], and the dashboard reports that the
107    /// device does not support that sensor. Discovery through [`add_sensor`](Fleet::add_sensor)
108    /// is the device's own and stays unrestricted. Set this after building or restoring the
109    /// fleet; the keys are the element keys a deployment supports, the same ones its
110    /// presentation declares.
111    ///
112    /// # Arguments
113    ///
114    /// * `keys` - the sensor element keys a client may add, such as `"soil_moisture"`.
115    pub fn allow_sensors(&self, keys: impl IntoIterator<Item = impl Into<String>>) {
116        let mut inner = self.inner.lock().expect("fleet lock");
117        inner.allowed = Some(keys.into_iter().map(Into::into).collect());
118    }
119
120    /// Pushes a fresh reading for a sensor, appending it to the sensor's history.
121    ///
122    /// # Arguments
123    ///
124    /// * `group` - the sensor's group id.
125    /// * `sensor` - the sensor id.
126    /// * `reading` - the new reading (the caller sets its status and band).
127    pub fn report_reading(&self, group: &str, sensor: &str, reading: Reading) {
128        let mut inner = self.inner.lock().expect("fleet lock");
129        if let Some(target) = sensor_mut(&mut inner.state, group, sensor) {
130            let value = reading.value;
131            target.reading = reading;
132            target.history.push(value);
133            let len = target.history.len();
134            if len > HISTORY {
135                target.history.drain(0..len - HISTORY);
136            }
137        }
138        recompute(&mut inner.state);
139    }
140
141    /// Records a recent event for a sensor, newest first.
142    ///
143    /// # Arguments
144    ///
145    /// * `group` - the sensor's group id.
146    /// * `sensor` - the sensor id.
147    /// * `event` - the event to record.
148    pub fn report_event(&self, group: &str, sensor: &str, event: EventRecord) {
149        let mut inner = self.inner.lock().expect("fleet lock");
150        if let Some(target) = sensor_mut(&mut inner.state, group, sensor) {
151            target.events.insert(0, event);
152            target.events.truncate(8);
153        }
154        recompute(&mut inner.state);
155    }
156
157    /// Updates a group's link status (kind, signal strength, online).
158    ///
159    /// # Arguments
160    ///
161    /// * `group` - the group id.
162    /// * `link` - the new link status.
163    pub fn report_link(&self, group: &str, link: Link) {
164        let mut inner = self.inner.lock().expect("fleet lock");
165        if let Some(target) = group_mut(&mut inner.state, group) {
166            target.link = link;
167        }
168        recompute(&mut inner.state);
169    }
170
171    /// Updates a sensor's power mode and battery state of charge.
172    ///
173    /// # Arguments
174    ///
175    /// * `group` - the sensor's group id.
176    /// * `sensor` - the sensor id.
177    /// * `mode` - the work cadence the sensor's node is running at.
178    /// * `battery` - the state of charge in `[0.0, 1.0]`, or `None` if it has no battery.
179    pub fn report_power(&self, group: &str, sensor: &str, mode: Mode, battery: Option<f32>) {
180        let mut inner = self.inner.lock().expect("fleet lock");
181        if let Some(target) = sensor_mut(&mut inner.state, group, sensor) {
182            target.mode = mode;
183            target.battery = battery;
184        }
185    }
186
187    /// Drains the control commands queued since the last call, for the project to apply to
188    /// real hardware and persist, then reflect back through the report methods.
189    ///
190    /// # Returns
191    ///
192    /// The commands accepted since the last drain, in order.
193    pub fn take_commands(&self) -> Vec<Command> {
194        let mut inner = self.inner.lock().expect("fleet lock");
195        std::mem::take(&mut inner.commands)
196    }
197
198    /// Adds a group to an organization at runtime, so a gateway can surface a node the moment
199    /// it is discovered (a LoRa join, a new mesh neighbor). A no-op if the org is unknown.
200    ///
201    /// # Arguments
202    ///
203    /// * `org` - the organization id to add the group to.
204    /// * `group` - the group to add.
205    pub fn add_group(&self, org: &str, group: Group) {
206        self.mutate(Command::AddGroup {
207            org: org.to_owned(),
208            group,
209        });
210    }
211
212    /// Adds a sensor to a group at runtime, for a newly discovered sensor. A no-op if the
213    /// group is unknown.
214    ///
215    /// # Arguments
216    ///
217    /// * `group` - the group id to add the sensor to.
218    /// * `sensor` - the sensor to add.
219    pub fn add_sensor(&self, group: &str, sensor: Sensor) {
220        self.mutate(Command::AddSensor {
221            group: group.to_owned(),
222            sensor,
223            binding: None,
224        });
225    }
226
227    /// Removes a group by id at runtime, for a node that has gone away.
228    ///
229    /// # Arguments
230    ///
231    /// * `id` - the group id to remove.
232    pub fn remove_group(&self, id: &str) {
233        self.mutate(Command::RemoveGroup { id: id.to_owned() });
234    }
235
236    /// Removes a sensor by its `"groupId/sensorId"` path at runtime.
237    ///
238    /// # Arguments
239    ///
240    /// * `target` - the `"groupId/sensorId"` path to remove.
241    pub fn remove_sensor(&self, target: &str) {
242        self.mutate(Command::RemoveSensor {
243            target: target.to_owned(),
244        });
245    }
246
247    // Applies a structural change to the held state, the gateway-initiated counterpart of a
248    // dashboard command; it is not queued back to the gateway.
249    fn mutate(&self, command: Command) {
250        let mut inner = self.inner.lock().expect("fleet lock");
251        let _ = apply(&mut inner.state, &command);
252        recompute(&mut inner.state);
253    }
254}
255
256impl StateSource for Fleet {
257    fn snapshot(&mut self) -> State {
258        let mut inner = self.inner.lock().expect("fleet lock");
259        let uptime = inner.started.elapsed().as_secs();
260        inner.state.uptime_secs = Some(uptime);
261        inner.state.clone()
262    }
263
264    fn command(&mut self, command: &Command) -> Result<(), CommandError> {
265        let mut inner = self.inner.lock().expect("fleet lock");
266        // A real device only takes the sensor types it can bind; reject an add of anything else
267        // so the dashboard says so instead of showing a sensor that will never report.
268        if let Command::AddSensor { sensor, .. } = command {
269            if let Some(allowed) = &inner.allowed {
270                if !allowed.contains(&sensor.reading.key) {
271                    return Err(CommandError::UnknownSensor);
272                }
273            }
274        }
275        let outcome = apply(&mut inner.state, command);
276        if outcome.is_ok() {
277            inner.commands.push(command.clone());
278            recompute(&mut inner.state);
279        }
280        outcome
281    }
282}
283
284// Applies a command optimistically to the held state, mirroring the device's eventual
285// effect so the UI updates at once; the queued copy lets the project make it real.
286fn apply(state: &mut State, command: &Command) -> Result<(), CommandError> {
287    match command {
288        Command::Actuate { target, action } => {
289            let (group, sensor) = target.split_once('/').ok_or(CommandError::UnknownTarget)?;
290            let reading = sensor_mut(state, group, sensor)
291                .map(|s| &mut s.reading)
292                .ok_or(CommandError::UnknownTarget)?;
293            match &reading.actions {
294                Some(actions) if actions.iter().any(|a| a == action) => {
295                    reading.state = Some(format!("state.{action}"));
296                    Ok(())
297                }
298                Some(_) => Err(CommandError::InvalidAction),
299                None => Err(CommandError::Unsupported),
300            }
301        }
302        Command::Set { target, value } => {
303            let (group, sensor) = target.split_once('/').ok_or(CommandError::UnknownTarget)?;
304            let reading = sensor_mut(state, group, sensor)
305                .map(|s| &mut s.reading)
306                .ok_or(CommandError::UnknownTarget)?;
307            match reading.range {
308                Some([low, high]) if (low..=high).contains(value) => {
309                    reading.value = *value;
310                    Ok(())
311                }
312                Some(_) => Err(CommandError::OutOfRange),
313                None => Err(CommandError::Unsupported),
314            }
315        }
316        Command::AddGroup { org, group } => match org_mut(state, org) {
317            Some(target) => {
318                target.groups.push(group.clone());
319                Ok(())
320            }
321            None => Err(CommandError::UnknownTarget),
322        },
323        Command::RemoveGroup { id } => {
324            for org in &mut state.orgs {
325                org.groups.retain(|g| g.id != *id);
326            }
327            Ok(())
328        }
329        Command::AddSensor { group, sensor, .. } => match group_mut(state, group) {
330            Some(target) => {
331                target.sensors.push(sensor.clone());
332                Ok(())
333            }
334            None => Err(CommandError::UnknownTarget),
335        },
336        Command::RemoveSensor { target } => {
337            let (group_id, sensor_id) = target.split_once('/').unwrap_or(("", target));
338            for org in &mut state.orgs {
339                for group in &mut org.groups {
340                    if group.id == group_id {
341                        group.sensors.retain(|s| s.id != sensor_id);
342                    }
343                }
344            }
345            Ok(())
346        }
347    }
348}
349
350fn recompute(state: &mut State) {
351    for org in &mut state.orgs {
352        for group in &mut org.groups {
353            group.recompute_status();
354        }
355    }
356    state.recompute_status();
357}
358
359fn org_mut<'a>(state: &'a mut State, org: &str) -> Option<&'a mut Org> {
360    state.orgs.iter_mut().find(|o| o.id == org)
361}
362
363fn group_mut<'a>(state: &'a mut State, group: &str) -> Option<&'a mut Group> {
364    state
365        .orgs
366        .iter_mut()
367        .flat_map(|o| &mut o.groups)
368        .find(|g| g.id == group)
369}
370
371fn sensor_mut<'a>(state: &'a mut State, group: &str, sensor: &str) -> Option<&'a mut Sensor> {
372    state
373        .orgs
374        .iter_mut()
375        .flat_map(|o| &mut o.groups)
376        .filter(|g| g.id == group)
377        .flat_map(|g| &mut g.sensors)
378        .find(|s| s.id == sensor)
379}
380
381/// Builds a fleet's initial structure: organizations, their groups, and each group's
382/// sensors. Parents are referenced by id, so add an org before its groups and a group
383/// before its sensors.
384pub struct FleetBuilder {
385    orgs: Vec<Org>,
386}
387
388impl FleetBuilder {
389    /// Adds an organization.
390    ///
391    /// # Arguments
392    ///
393    /// * `id` - the stable organization id.
394    /// * `name` - the human-readable name.
395    ///
396    /// # Returns
397    ///
398    /// The builder, for chaining.
399    pub fn org(mut self, id: impl Into<String>, name: impl Into<String>) -> Self {
400        self.orgs.push(Org {
401            id: id.into(),
402            name: name.into(),
403            groups: Vec::new(),
404        });
405        self
406    }
407
408    /// Adds a group to an organization.
409    ///
410    /// # Arguments
411    ///
412    /// * `org` - the id of the organization to add the group to.
413    /// * `id` - the stable group id.
414    /// * `name` - the human-readable name.
415    /// * `kind` - the link the group reports over.
416    ///
417    /// # Returns
418    ///
419    /// The builder, for chaining.
420    pub fn group(
421        mut self,
422        org: &str,
423        id: impl Into<String>,
424        name: impl Into<String>,
425        kind: LinkKind,
426    ) -> Self {
427        if let Some(target) = self.orgs.iter_mut().find(|o| o.id == org) {
428            target.groups.push(Group {
429                id: id.into(),
430                name: name.into(),
431                link: Link {
432                    kind,
433                    strength: 4,
434                    online: true,
435                },
436                status: Status::Ok,
437                sensors: Vec::new(),
438                lat: None,
439                lon: None,
440            });
441        }
442        self
443    }
444
445    /// Adds a sensor to a group.
446    ///
447    /// # Arguments
448    ///
449    /// * `group` - the id of the group to add the sensor to.
450    /// * `sensor` - the sensor (build its reading with [`Reading`] and [`Sensor::new`]).
451    ///
452    /// # Returns
453    ///
454    /// The builder, for chaining.
455    pub fn sensor(mut self, group: &str, sensor: Sensor) -> Self {
456        for org in &mut self.orgs {
457            if let Some(target) = org.groups.iter_mut().find(|g| g.id == group) {
458                target.sensors.push(sensor);
459                break;
460            }
461        }
462        self
463    }
464
465    /// Finishes building, returning a [`Fleet`] ready to serve and report into.
466    ///
467    /// # Returns
468    ///
469    /// The assembled fleet.
470    pub fn build(mut self) -> Fleet {
471        let mut state = State {
472            orgs: std::mem::take(&mut self.orgs),
473            status: Status::Ok,
474            uptime_secs: None,
475            demo: false,
476        };
477        recompute(&mut state);
478        Fleet::from_state(state)
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    fn fleet() -> Fleet {
487        Fleet::builder()
488            .org("clinic", "Kano clinic")
489            .group("clinic", "fridges", "Cold chain", LinkKind::Cellular)
490            .sensor(
491                "fridges",
492                Sensor::new("fridge-1", Reading::new("fridge_temp", 4.5, "celsius")),
493            )
494            .sensor(
495                "fridges",
496                Sensor::new(
497                    "valve",
498                    Reading::new("drip_valve", 0.0, "state")
499                        .with_state("state.closed")
500                        .with_actions(["open", "closed"]),
501                ),
502            )
503            .sensor(
504                "fridges",
505                Sensor::new(
506                    "compressor",
507                    Reading::new("compressor_duty", 40.0, "percent").with_range(0.0, 100.0),
508                ),
509            )
510            .build()
511    }
512
513    #[test]
514    fn a_numeric_actuator_takes_a_value_inside_its_range() {
515        let mut fleet = fleet();
516        fleet
517            .command(&Command::Set {
518                target: "fridges/compressor".to_owned(),
519                value: 65.0,
520            })
521            .expect("the duty is inside the range");
522        assert_eq!(fleet.take_commands().len(), 1, "queued for the project");
523        let compressor = sensor_after(&fleet, "fridges", "compressor");
524        assert_eq!(compressor.reading.value, 65.0);
525        let json = fleet.clone().snapshot().to_json().expect("serialize");
526        assert!(json.contains("\"range\":[0.0,100.0]"));
527    }
528
529    #[test]
530    fn a_value_outside_the_range_or_on_a_plain_reading_is_refused() {
531        let mut fleet = fleet();
532        assert_eq!(
533            fleet.command(&Command::Set {
534                target: "fridges/compressor".to_owned(),
535                value: 120.0,
536            }),
537            Err(CommandError::OutOfRange)
538        );
539        assert_eq!(
540            fleet.command(&Command::Set {
541                target: "fridges/fridge-1".to_owned(),
542                value: 5.0,
543            }),
544            Err(CommandError::Unsupported)
545        );
546        assert!(fleet.take_commands().is_empty());
547        let compressor = sensor_after(&fleet, "fridges", "compressor");
548        assert_eq!(
549            compressor.reading.value, 40.0,
550            "the refused value did not land"
551        );
552    }
553
554    #[test]
555    fn a_sensor_added_with_its_own_name_keeps_it() {
556        let mut fleet = fleet();
557        fleet
558            .command(&Command::AddSensor {
559                group: "fridges".to_owned(),
560                sensor: Sensor::new(
561                    "turbidity",
562                    Reading::new("water_turbidity", 1.2, "ntu").with_label("Turbidity"),
563                ),
564                binding: Some("i2c:0x29".to_owned()),
565            })
566            .expect("add a sensor the page never shipped");
567        let json = fleet.clone().snapshot().to_json().expect("serialize");
568        assert!(json.contains("\"label\":\"Turbidity\""));
569    }
570
571    #[test]
572    fn a_reported_reading_shows_in_the_snapshot_with_history() {
573        let fleet = fleet();
574        fleet.report_reading(
575            "fridges",
576            "fridge-1",
577            Reading::new("fridge_temp", 9.0, "celsius").with_status(Status::Alarm),
578        );
579        let mut handle = fleet.clone();
580        let state = handle.snapshot();
581        let sensor = &state.orgs[0].groups[0].sensors[0];
582        assert_eq!(sensor.reading.value, 9.0);
583        assert_eq!(sensor.history, vec![9.0]);
584        assert_eq!(
585            state.status,
586            Status::Alarm,
587            "the alarm reading lifts fleet status"
588        );
589    }
590
591    #[test]
592    fn an_actuate_command_updates_state_and_queues_for_the_project() {
593        let mut fleet = fleet();
594        fleet
595            .command(&Command::Actuate {
596                target: "fridges/valve".to_owned(),
597                action: "open".to_owned(),
598            })
599            .expect("valve accepts open");
600        let queued = fleet.take_commands();
601        assert_eq!(
602            queued.len(),
603            1,
604            "the command is queued for the project to apply"
605        );
606        let valve = sensor_after(&fleet, "fridges", "valve");
607        assert_eq!(valve.reading.state.as_deref(), Some("state.open"));
608        // Draining empties the queue.
609        assert!(fleet.take_commands().is_empty());
610    }
611
612    #[test]
613    fn an_invalid_actuate_is_refused_and_not_queued() {
614        let mut fleet = fleet();
615        assert_eq!(
616            fleet.command(&Command::Actuate {
617                target: "fridges/fridge-1".to_owned(),
618                action: "open".to_owned(),
619            }),
620            Err(CommandError::Unsupported)
621        );
622        assert!(fleet.take_commands().is_empty());
623    }
624
625    #[test]
626    fn provisioning_commands_change_the_structure() {
627        let mut fleet = fleet();
628        fleet
629            .command(&Command::AddSensor {
630                group: "fridges".to_owned(),
631                sensor: Sensor::new("fridge-2", Reading::new("fridge_temp", 5.0, "celsius")),
632                binding: None,
633            })
634            .expect("add sensor to a known group");
635        assert!(sensor_present(&fleet, "fridges", "fridge-2"));
636
637        fleet
638            .command(&Command::RemoveSensor {
639                target: "fridges/fridge-2".to_owned(),
640            })
641            .expect("remove the sensor");
642        assert!(!sensor_present(&fleet, "fridges", "fridge-2"));
643    }
644
645    #[test]
646    fn runtime_mutators_add_and_remove_for_discovery() {
647        let fleet = fleet();
648        fleet.add_group(
649            "clinic",
650            Group {
651                id: "ward".to_owned(),
652                name: "Ward".to_owned(),
653                link: Link {
654                    kind: LinkKind::Wifi,
655                    strength: 4,
656                    online: true,
657                },
658                status: Status::Ok,
659                sensors: Vec::new(),
660                lat: None,
661                lon: None,
662            },
663        );
664        fleet.add_sensor(
665            "ward",
666            Sensor::new("o2", Reading::new("oxygen_stock", 80.0, "percent")),
667        );
668        assert!(
669            sensor_present(&fleet, "ward", "o2"),
670            "discovered sensor shows"
671        );
672        fleet.remove_group("ward");
673        let mut handle = fleet.clone();
674        assert!(
675            !handle
676                .snapshot()
677                .orgs
678                .iter()
679                .flat_map(|o| &o.groups)
680                .any(|g| g.id == "ward"),
681            "removed group is gone"
682        );
683    }
684
685    #[test]
686    fn an_allow_list_rejects_unsupported_client_adds_but_not_discovery() {
687        let mut fleet = fleet();
688        fleet.allow_sensors(["fridge_temp"]);
689
690        // A supported type is accepted.
691        fleet
692            .command(&Command::AddSensor {
693                group: "fridges".to_owned(),
694                sensor: Sensor::new("f2", Reading::new("fridge_temp", 5.0, "celsius")),
695                binding: None,
696            })
697            .expect("a supported sensor is added");
698
699        // An unsupported type is rejected and not added.
700        assert_eq!(
701            fleet.command(&Command::AddSensor {
702                group: "fridges".to_owned(),
703                sensor: Sensor::new("w", Reading::new("wind_speed", 3.0, "meter_per_second")),
704                binding: None,
705            }),
706            Err(CommandError::UnknownSensor)
707        );
708        assert!(!sensor_present(&fleet, "fridges", "w"));
709
710        // Gateway-initiated discovery is the device's own and stays unrestricted.
711        fleet.add_sensor(
712            "fridges",
713            Sensor::new("disc", Reading::new("wind_speed", 3.0, "meter_per_second")),
714        );
715        assert!(sensor_present(&fleet, "fridges", "disc"));
716    }
717
718    #[test]
719    fn from_state_restores_a_saved_fleet() {
720        let mut original = fleet();
721        let saved = original.snapshot();
722        let mut restored = Fleet::from_state(saved.clone());
723        assert_eq!(restored.snapshot().orgs.len(), saved.orgs.len());
724    }
725
726    fn sensor_after(fleet: &Fleet, group: &str, sensor: &str) -> Sensor {
727        let mut handle = fleet.clone();
728        let state = handle.snapshot();
729        state
730            .orgs
731            .iter()
732            .flat_map(|o| &o.groups)
733            .filter(|g| g.id == group)
734            .flat_map(|g| &g.sensors)
735            .find(|s| s.id == sensor)
736            .expect("sensor")
737            .clone()
738    }
739
740    fn sensor_present(fleet: &Fleet, group: &str, sensor: &str) -> bool {
741        let mut handle = fleet.clone();
742        handle
743            .snapshot()
744            .orgs
745            .iter()
746            .flat_map(|o| &o.groups)
747            .filter(|g| g.id == group)
748            .flat_map(|g| &g.sensors)
749            .any(|s| s.id == sensor)
750    }
751}