1use 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
49const HISTORY: usize = 32;
51
52struct Inner {
53 state: State,
54 commands: Vec<Command>,
55 started: Instant,
56 allowed: Option<HashSet<String>>,
60}
61
62#[derive(Clone)]
65pub struct Fleet {
66 inner: Arc<Mutex<Inner>>,
67}
68
69impl Fleet {
70 pub fn builder() -> FleetBuilder {
76 FleetBuilder { orgs: Vec::new() }
77 }
78
79 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 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 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 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 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 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 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 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 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 pub fn remove_group(&self, id: &str) {
233 self.mutate(Command::RemoveGroup { id: id.to_owned() });
234 }
235
236 pub fn remove_sensor(&self, target: &str) {
242 self.mutate(Command::RemoveSensor {
243 target: target.to_owned(),
244 });
245 }
246
247 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 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
284fn 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
381pub struct FleetBuilder {
385 orgs: Vec<Org>,
386}
387
388impl FleetBuilder {
389 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 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 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 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 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 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 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 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}