pamoja_profile/profile.rs
1//! The profile manifest and its named, ready-to-run presets.
2//!
3//! A profile is data: a [`Profile`] serializes to and from a manifest a community
4//! can write by hand, store in a file, and share. The presets here are convenience
5//! constructors for the same data, not a closed set - any manifest that names a
6//! [`ControlSpec`] and a [`PowerSchedule`] is a valid profile.
7
8use core::time::Duration;
9use std::collections::BTreeMap;
10
11use pamoja_power::PowerPlan;
12use serde::de::{self, Deserializer};
13use serde::ser::{SerializeMap, Serializer};
14use serde::{Deserialize, Serialize};
15
16use crate::format;
17use crate::presentation::refuse;
18use crate::{Controller, ElementSpec, LocalizedText, Param, Params, Presentation, Viz};
19
20/// What a profile reads: the quantity its control decides on, and the unit its numbers
21/// are in.
22///
23/// A manifest names it so a reader knows what to wire before opening the numbers, and so
24/// a runner can pick the right reading from a part that measures several things, such as
25/// the temperature a BME280 reports beside humidity and pressure. In a manifest it is the
26/// `reads` object:
27///
28/// ```json
29/// { "quantity": "temperature", "unit": "celsius" }
30/// ```
31///
32/// Both are lowercase words joined by underscores. The setpoint, bands, and limits of the
33/// profile's control are in the unit named here.
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct Reads {
37 /// The quantity, such as `"temperature"`, `"relative_humidity"`, or `"water_level"`.
38 pub quantity: String,
39 /// The unit the profile's numbers are in, such as `"celsius"`, `"percent"`, or
40 /// `"meter"`.
41 pub unit: String,
42}
43
44impl Reads {
45 /// Names what a profile reads.
46 ///
47 /// # Arguments
48 ///
49 /// * `quantity` - the quantity, such as `"temperature"`.
50 /// * `unit` - the unit the profile's numbers are in, such as `"celsius"`.
51 ///
52 /// # Returns
53 ///
54 /// The declaration.
55 pub fn new(quantity: impl Into<String>, unit: impl Into<String>) -> Self {
56 Self {
57 quantity: quantity.into(),
58 unit: unit.into(),
59 }
60 }
61}
62
63/// How a profile turns each reading into control output and alerts.
64///
65/// This is the policy half of a profile's manifest: the tunable rule a community can
66/// publish and share, with no code to write. [`Profile::controller`] assembles it
67/// into a live [`Controller`]. In a manifest it is tagged by `kind`, with the kind's
68/// parameters beside it:
69///
70/// ```json
71/// { "kind": "setpoint", "setpoint": 5.0, "hysteresis": 0.5, "cooling": true, "safe_band": 3.0 }
72/// ```
73///
74/// A `kind` the library does not know is a [`Custom`](ControlSpec::Custom) policy: it
75/// loads with every other field kept as its [`Params`], and a
76/// [`PolicyRegistry`](crate::PolicyRegistry) resolves it to the code that decides it,
77/// so a manifest for a policy of your own reads exactly like one for a setpoint:
78///
79/// ```json
80/// { "kind": "frost_guard", "warn_below": 2.0 }
81/// ```
82#[derive(Clone, Debug, PartialEq)]
83pub enum ControlSpec {
84 /// Hold a reading near `setpoint` by switching an output on and off.
85 Setpoint {
86 /// The target reading, such as 5 C for a vaccine fridge.
87 setpoint: f32,
88 /// Half the deadband width around the setpoint, which stops the output
89 /// chattering at the threshold.
90 hysteresis: f32,
91 /// Whether the output cools (switches on above the band) or heats (switches
92 /// on below it). An irrigation valve that adds water is a "heater".
93 cooling: bool,
94 /// How far the reading may stray from the setpoint before an
95 /// [`Alert::OutOfRange`](crate::Alert::OutOfRange) fires.
96 safe_band: f32,
97 },
98 /// Watch a falling level and warn before it reaches `empty`.
99 Level {
100 /// The level treated as empty, such as a dry tank.
101 empty: f32,
102 /// Warn once the level is estimated to reach `empty` within this many more
103 /// samples.
104 warn_within: u32,
105 },
106 /// Warn when a reading changes faster than `limit` per sample.
107 Surge {
108 /// Watch a rapid rise (`true`) or a rapid fall (`false`).
109 rising: bool,
110 /// The largest safe change per sample.
111 limit: f32,
112 },
113 /// Report readings only, with no control output and no alerts.
114 Monitor,
115 /// A policy the library does not ship, named by the manifest and decided by code a
116 /// [`PolicyRegistry`](crate::PolicyRegistry) resolves the kind to.
117 Custom {
118 /// The kind as the manifest names it, such as `"frost_guard"`.
119 kind: String,
120 /// Every other field the manifest carried beside the kind.
121 params: Params,
122 },
123}
124
125/// The fields each built-in kind takes, as a manifest names them.
126const SETPOINT_FIELDS: &[&str] = &["kind", "setpoint", "hysteresis", "cooling", "safe_band"];
127const LEVEL_FIELDS: &[&str] = &["kind", "empty", "warn_within"];
128const SURGE_FIELDS: &[&str] = &["kind", "rising", "limit"];
129const MONITOR_FIELDS: &[&str] = &["kind"];
130
131impl ControlSpec {
132 /// The kinds the library ships, as a manifest names them.
133 pub const BUILT_IN: [&'static str; 4] = ["setpoint", "level", "surge", "monitor"];
134
135 /// Returns the kind as a manifest names it.
136 ///
137 /// # Returns
138 ///
139 /// `"setpoint"`, `"level"`, `"surge"`, `"monitor"`, or a custom kind's own name.
140 pub fn kind(&self) -> &str {
141 match self {
142 ControlSpec::Setpoint { .. } => "setpoint",
143 ControlSpec::Level { .. } => "level",
144 ControlSpec::Surge { .. } => "surge",
145 ControlSpec::Monitor => "monitor",
146 ControlSpec::Custom { kind, .. } => kind,
147 }
148 }
149
150 /// Creates a policy of a kind the library does not ship, checked so it survives a
151 /// trip through its manifest.
152 ///
153 /// # Arguments
154 ///
155 /// * `kind` - the kind as the manifest will name it, such as `"frost_guard"`.
156 /// * `params` - every other field the policy needs.
157 ///
158 /// # Returns
159 ///
160 /// The custom policy.
161 ///
162 /// # Errors
163 ///
164 /// Returns [`Error::Codec`](pamoja_core::Error::Codec) if `kind` is empty or names a
165 /// built-in kind, which a manifest would read back as that kind, or if `params`
166 /// holds a field named `kind`, which the manifest keeps for the kind itself.
167 ///
168 /// # Examples
169 ///
170 /// ```
171 /// use pamoja_profile::{ControlSpec, Params};
172 ///
173 /// let guard = ControlSpec::custom("frost_guard", Params::new().with("warn_below", 2.0))?;
174 /// assert_eq!(guard.kind(), "frost_guard");
175 /// assert!(ControlSpec::custom("setpoint", Params::new()).is_err());
176 /// # Ok::<(), pamoja_core::Error>(())
177 /// ```
178 pub fn custom(kind: impl Into<String>, params: Params) -> pamoja_core::Result<Self> {
179 let kind = kind.into();
180 if kind.is_empty() {
181 return Err(pamoja_core::Error::Codec(
182 "a custom control needs a kind to be named by".to_owned(),
183 ));
184 }
185 if matches!(kind.as_str(), "setpoint" | "level" | "surge" | "monitor") {
186 return Err(pamoja_core::Error::Codec(format!(
187 "{kind} is a built-in control kind, so it takes its own fields rather than parameters"
188 )));
189 }
190 if params.get("kind").is_some() {
191 return Err(pamoja_core::Error::Codec(
192 "a custom control cannot have a parameter named kind, which its manifest keeps for the kind itself"
193 .to_owned(),
194 ));
195 }
196 Ok(ControlSpec::Custom { kind, params })
197 }
198}
199
200impl Serialize for ControlSpec {
201 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
202 match self {
203 ControlSpec::Setpoint {
204 setpoint,
205 hysteresis,
206 cooling,
207 safe_band,
208 } => {
209 let mut map = serializer.serialize_map(Some(5))?;
210 map.serialize_entry("kind", "setpoint")?;
211 map.serialize_entry("setpoint", setpoint)?;
212 map.serialize_entry("hysteresis", hysteresis)?;
213 map.serialize_entry("cooling", cooling)?;
214 map.serialize_entry("safe_band", safe_band)?;
215 map.end()
216 }
217 ControlSpec::Level { empty, warn_within } => {
218 let mut map = serializer.serialize_map(Some(3))?;
219 map.serialize_entry("kind", "level")?;
220 map.serialize_entry("empty", empty)?;
221 map.serialize_entry("warn_within", warn_within)?;
222 map.end()
223 }
224 ControlSpec::Surge { rising, limit } => {
225 let mut map = serializer.serialize_map(Some(3))?;
226 map.serialize_entry("kind", "surge")?;
227 map.serialize_entry("rising", rising)?;
228 map.serialize_entry("limit", limit)?;
229 map.end()
230 }
231 ControlSpec::Monitor => {
232 let mut map = serializer.serialize_map(Some(1))?;
233 map.serialize_entry("kind", "monitor")?;
234 map.end()
235 }
236 ControlSpec::Custom { kind, params } => {
237 let mut map = serializer.serialize_map(Some(1 + params.len()))?;
238 map.serialize_entry("kind", kind)?;
239 for (name, value) in params.iter() {
240 map.serialize_entry(name, value)?;
241 }
242 map.end()
243 }
244 }
245 }
246}
247
248impl<'de> Deserialize<'de> for ControlSpec {
249 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
250 let mut fields: BTreeMap<String, Param> = BTreeMap::deserialize(deserializer)?;
251 let kind = match fields.remove("kind") {
252 Some(Param::Text(kind)) => kind,
253 Some(_) => return Err(de::Error::custom("`kind` must be a string")),
254 None => return Err(de::Error::missing_field("kind")),
255 };
256 let number = |name: &'static str| -> Result<f32, D::Error> {
257 match fields.get(name) {
258 Some(Param::Number(value)) => Ok(*value as f32),
259 Some(_) => Err(de::Error::custom(format!("`{name}` must be a number"))),
260 None => Err(de::Error::missing_field(name)),
261 }
262 };
263 let flag = |name: &'static str| -> Result<bool, D::Error> {
264 match fields.get(name) {
265 Some(Param::Flag(value)) => Ok(*value),
266 Some(_) => Err(de::Error::custom(format!("`{name}` must be true or false"))),
267 None => Err(de::Error::missing_field(name)),
268 }
269 };
270 let count = |name: &'static str| -> Result<u32, D::Error> {
271 match fields.get(name) {
272 Some(Param::Number(value))
273 if *value >= 0.0 && value.fract() == 0.0 && *value <= f64::from(u32::MAX) =>
274 {
275 Ok(*value as u32)
276 }
277 Some(_) => Err(de::Error::custom(format!(
278 "`{name}` must be a whole number of samples"
279 ))),
280 None => Err(de::Error::missing_field(name)),
281 }
282 };
283 let allowed = match kind.as_str() {
284 "setpoint" => Some(SETPOINT_FIELDS),
285 "level" => Some(LEVEL_FIELDS),
286 "surge" => Some(SURGE_FIELDS),
287 "monitor" => Some(MONITOR_FIELDS),
288 _ => None,
289 };
290 if let Some(allowed) = allowed {
291 if let Some(extra) = fields.keys().find(|name| !allowed.contains(&name.as_str())) {
292 return Err(de::Error::unknown_field(extra, allowed));
293 }
294 }
295 Ok(match kind.as_str() {
296 "setpoint" => ControlSpec::Setpoint {
297 setpoint: number("setpoint")?,
298 hysteresis: number("hysteresis")?,
299 cooling: flag("cooling")?,
300 safe_band: number("safe_band")?,
301 },
302 "level" => ControlSpec::Level {
303 empty: number("empty")?,
304 warn_within: count("warn_within")?,
305 },
306 "surge" => ControlSpec::Surge {
307 rising: flag("rising")?,
308 limit: number("limit")?,
309 },
310 "monitor" => ControlSpec::Monitor,
311 _ => ControlSpec::Custom {
312 kind,
313 params: Params::from(fields),
314 },
315 })
316 }
317}
318
319/// How often a node samples as its battery drains, in plain seconds.
320///
321/// This is the serializable form of a [`PowerPlan`](pamoja_power::PowerPlan): a
322/// manifest carries the three work intervals as whole seconds and the two
323/// state-of-charge thresholds, and [`plan`](PowerSchedule::plan) assembles the
324/// `pamoja-power` governor from them. The thresholds may be omitted from a manifest,
325/// in which case they default to entering the saver cadence below 50% charge and the
326/// critical cadence below 20%. So may the hysteresis margin, which defaults to five
327/// points: a node that fell below a threshold climbs back once the charge is 5% above
328/// it, so a charge hovering at the threshold does not switch the cadence every cycle.
329#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct PowerSchedule {
332 /// Seconds between samples at a healthy charge.
333 pub active_secs: u64,
334 /// Seconds between samples while conserving.
335 pub saver_secs: u64,
336 /// Seconds between samples when critically low.
337 pub critical_secs: u64,
338 /// Enter the saver cadence below this state of charge.
339 #[serde(default = "PowerSchedule::default_saver_below")]
340 pub saver_below: f32,
341 /// Enter the critical cadence below this state of charge.
342 #[serde(default = "PowerSchedule::default_critical_below")]
343 pub critical_below: f32,
344 /// How far above a threshold the charge must climb to leave the lower cadence.
345 #[serde(default = "PowerSchedule::default_hysteresis")]
346 pub hysteresis: f32,
347}
348
349impl PowerSchedule {
350 fn default_saver_below() -> f32 {
351 0.5
352 }
353
354 fn default_critical_below() -> f32 {
355 0.2
356 }
357
358 fn default_hysteresis() -> f32 {
359 pamoja_power::DEFAULT_HYSTERESIS
360 }
361
362 /// Creates a schedule from its three work intervals, with default thresholds.
363 ///
364 /// # Arguments
365 ///
366 /// * `active_secs` - seconds between samples at a healthy charge.
367 /// * `saver_secs` - seconds between samples while conserving.
368 /// * `critical_secs` - seconds between samples when critically low.
369 ///
370 /// # Returns
371 ///
372 /// A schedule that enters the saver cadence below 50% charge and the critical
373 /// cadence below 20%, and leaves each once the charge is five points above it.
374 pub fn new(active_secs: u64, saver_secs: u64, critical_secs: u64) -> Self {
375 Self {
376 active_secs,
377 saver_secs,
378 critical_secs,
379 saver_below: Self::default_saver_below(),
380 critical_below: Self::default_critical_below(),
381 hysteresis: Self::default_hysteresis(),
382 }
383 }
384
385 /// Sets the state-of-charge thresholds for entering each lower cadence.
386 ///
387 /// # Arguments
388 ///
389 /// * `saver_below` - enter the saver cadence when charge is below this.
390 /// * `critical_below` - enter the critical cadence when charge is below this,
391 /// normally lower than `saver_below`.
392 ///
393 /// # Returns
394 ///
395 /// The updated schedule, for chaining.
396 pub fn with_thresholds(mut self, saver_below: f32, critical_below: f32) -> Self {
397 self.saver_below = saver_below;
398 self.critical_below = critical_below;
399 self
400 }
401
402 /// Sets how far above a threshold the charge must climb to leave the lower cadence.
403 ///
404 /// # Arguments
405 ///
406 /// * `margin` - the state of charge added to each threshold on the way back up; `0.0`
407 /// switches cadence at the thresholds themselves.
408 ///
409 /// # Returns
410 ///
411 /// The updated schedule, for chaining.
412 pub fn with_hysteresis(mut self, margin: f32) -> Self {
413 self.hysteresis = margin;
414 self
415 }
416
417 /// Assembles the `pamoja-power` governor this schedule describes.
418 ///
419 /// # Returns
420 ///
421 /// A [`PowerPlan`](pamoja_power::PowerPlan) with this schedule's intervals,
422 /// thresholds, and hysteresis.
423 pub fn plan(&self) -> PowerPlan {
424 PowerPlan::new(
425 Duration::from_secs(self.active_secs),
426 Duration::from_secs(self.saver_secs),
427 Duration::from_secs(self.critical_secs),
428 )
429 .thresholds(self.saver_below, self.critical_below)
430 .with_hysteresis(self.hysteresis)
431 }
432}
433
434/// A named, pre-wired bundle of control policy, publish topic, and power schedule.
435///
436/// A profile is the unit a builder instantiates instead of wiring pins and tuning
437/// constants, and it is plain data: it serializes to and from a manifest a community
438/// can write, store in a file, and share. Pick a preset such as
439/// [`vaccine_fridge_monitor`](Profile::vaccine_fridge_monitor) or load one with
440/// [`from_json`](Profile::from_json), hand it a sensor, an actuator, a transport, and
441/// a codec, and the resulting [`Node`](crate::Node) reads, decides, drives the
442/// output, and publishes on its own. Every field is public, so a deployment can
443/// adjust the policy, topic, or power schedule in place.
444///
445/// A manifest is written in the format [`FORMAT`](crate::FORMAT) names, and may say so
446/// with a `$schema` naming [`Profile::SCHEMA`], which an editor reads to check the file
447/// as it is typed. A field the format does not have is refused, with the nearest one it
448/// does, so a misspelling never leaves a default in place without a word.
449///
450/// # Examples
451///
452/// ```
453/// use pamoja_profile::{ControlSpec, Profile};
454///
455/// let profile = Profile::vaccine_fridge_monitor();
456/// assert_eq!(profile.name, "vaccine-fridge-monitor");
457/// assert!(matches!(profile.control, ControlSpec::Setpoint { .. }));
458/// ```
459#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
460#[serde(try_from = "ProfileFile", into = "ProfileFile")]
461pub struct Profile {
462 /// A stable, human-readable name, such as `"vaccine-fridge-monitor"`.
463 pub name: String,
464 /// What the profile is for, in a sentence or two: what it watches or holds, and what
465 /// it does when a reading crosses a line. A manifest shared in a catalog explains
466 /// itself with it; a profile built in code may leave it `None`.
467 pub description: Option<String>,
468 /// What the profile reads, the quantity and the unit its numbers are in. A profile
469 /// whose node reads a whole measurement through a policy of its own may leave it
470 /// `None`.
471 pub reads: Option<Reads>,
472 /// The topic each reading is published to.
473 pub topic: String,
474 /// The control policy applied to each reading.
475 pub control: ControlSpec,
476 /// The power schedule that sets how often the node samples as the battery drains.
477 pub power: PowerSchedule,
478 /// How this profile presents itself on the dashboard - its custom sensors, node
479 /// stats, and theme. A profile that introduces no element beyond the dashboard's
480 /// built-in set leaves this `None`.
481 pub presentation: Option<Presentation>,
482}
483
484/// A profile as its manifest writes it, with the `$schema` that names its format.
485#[derive(Serialize, Deserialize)]
486#[serde(deny_unknown_fields)]
487struct ProfileFile {
488 #[serde(rename = "$schema", default, skip_serializing_if = "Option::is_none")]
489 schema: Option<String>,
490 name: String,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
492 description: Option<String>,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
494 reads: Option<Reads>,
495 topic: String,
496 control: ControlSpec,
497 power: PowerSchedule,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
499 presentation: Option<Presentation>,
500}
501
502impl TryFrom<ProfileFile> for Profile {
503 type Error = String;
504
505 fn try_from(file: ProfileFile) -> Result<Self, String> {
506 format::check_schema(file.schema.as_deref(), "profile", Profile::SCHEMA)?;
507 Ok(Profile {
508 name: file.name,
509 description: file.description,
510 reads: file.reads,
511 topic: file.topic,
512 control: file.control,
513 power: file.power,
514 presentation: file.presentation,
515 })
516 }
517}
518
519impl From<Profile> for ProfileFile {
520 fn from(profile: Profile) -> Self {
521 ProfileFile {
522 schema: Some(Profile::SCHEMA.to_owned()),
523 name: profile.name,
524 description: profile.description,
525 reads: profile.reads,
526 topic: profile.topic,
527 control: profile.control,
528 power: profile.power,
529 presentation: profile.presentation,
530 }
531 }
532}
533
534impl Profile {
535 /// The address of the published JSON Schema for the manifest format this build writes,
536 /// which [`to_json`](Profile::to_json) names as the manifest's `$schema`.
537 pub const SCHEMA: &'static str = "https://pamoja.molex.cloud/schema/profile-1.json";
538
539 /// Creates a profile of the caller's own from its parts, with no description, no
540 /// declaration of what it reads, and no presentation.
541 ///
542 /// # Arguments
543 ///
544 /// * `name` - a stable, human-readable name, such as `"raised-bed-drip"`.
545 /// * `topic` - the topic each reading is published to.
546 /// * `control` - the control policy applied to each reading.
547 /// * `power` - how often the node samples as the battery drains.
548 ///
549 /// # Returns
550 ///
551 /// The profile. [`with_description`](Profile::with_description),
552 /// [`with_reads`](Profile::with_reads), and
553 /// [`with_presentation`](Profile::with_presentation) add the rest.
554 ///
555 /// # Examples
556 ///
557 /// ```
558 /// use pamoja_profile::{ControlSpec, PowerSchedule, Profile};
559 ///
560 /// let drip = Profile::new(
561 /// "raised-bed-drip",
562 /// "garden/bed-1/moisture",
563 /// ControlSpec::Setpoint { setpoint: 37.5, hysteresis: 7.5, cooling: false, safe_band: 15.0 },
564 /// PowerSchedule::new(300, 1800, 3600),
565 /// )
566 /// .with_reads("soil_moisture", "percent");
567 /// assert_eq!(drip.name, "raised-bed-drip");
568 /// assert_eq!(Profile::from_json(&drip.to_json()?)?, drip);
569 /// # Ok::<(), pamoja_core::Error>(())
570 /// ```
571 pub fn new(
572 name: impl Into<String>,
573 topic: impl Into<String>,
574 control: ControlSpec,
575 power: PowerSchedule,
576 ) -> Self {
577 Self {
578 name: name.into(),
579 description: None,
580 reads: None,
581 topic: topic.into(),
582 control,
583 power,
584 presentation: None,
585 }
586 }
587
588 /// Declares what the profile reads.
589 ///
590 /// # Arguments
591 ///
592 /// * `quantity` - the quantity its control decides on, such as `"temperature"`.
593 /// * `unit` - the unit its numbers are in, such as `"celsius"`.
594 ///
595 /// # Returns
596 ///
597 /// The profile, for chaining.
598 ///
599 /// # Examples
600 ///
601 /// ```
602 /// use pamoja_profile::Profile;
603 ///
604 /// let fridge = Profile::vaccine_fridge_monitor();
605 /// let reads = fridge.reads.as_ref().expect("every preset says what it reads");
606 /// assert_eq!((reads.quantity.as_str(), reads.unit.as_str()), ("temperature", "celsius"));
607 /// ```
608 pub fn with_reads(mut self, quantity: impl Into<String>, unit: impl Into<String>) -> Self {
609 self.reads = Some(Reads::new(quantity, unit));
610 self
611 }
612
613 /// A cold-chain fridge monitor: hold 5 C and alert on a spoilage excursion.
614 ///
615 /// Switches a cooler to hold the contents near 5 C and raises an
616 /// [`Alert::OutOfRange`](crate::Alert::OutOfRange) the moment the temperature
617 /// leaves the 2-8 C safe range. Data integrity outweighs power here, so it keeps
618 /// sampling often even as the battery drains.
619 ///
620 /// # Returns
621 ///
622 /// The cold-chain monitoring profile.
623 pub fn vaccine_fridge_monitor() -> Self {
624 Self {
625 name: "vaccine-fridge-monitor".to_owned(),
626 description: Some(
627 "Holds a vaccine fridge at 5 C by switching its cooler, and raises an \
628 alert the moment the temperature leaves the 2 to 8 C safe range. Keeps \
629 sampling often as the battery drains, since an unnoticed excursion \
630 costs more than a flat battery."
631 .to_owned(),
632 ),
633 reads: Some(Reads::new("temperature", "celsius")),
634 topic: "cold-chain/fridge/temperature".to_owned(),
635 control: ControlSpec::Setpoint {
636 setpoint: 5.0,
637 hysteresis: 0.5,
638 cooling: true,
639 safe_band: 3.0,
640 },
641 power: PowerSchedule::new(60, 300, 900),
642 presentation: Some(
643 Presentation::new()
644 .with_element(
645 ElementSpec::new(
646 "fridge_temp",
647 "celsius",
648 "Fridge temperature",
649 Viz::Thermometer,
650 )
651 .with_band(2.0, 8.0)
652 .with_locale_label("fr", "Température du réfrigérateur")
653 .with_locale_label("sw", "Joto la friji"),
654 )
655 .with_element(
656 ElementSpec::new("cooler", "state", "Cooler", Viz::Switch)
657 .with_state("state.cooler_off")
658 .with_locale_label("fr", "Groupe froid")
659 .with_locale_label("sw", "Kipoza"),
660 )
661 .with_element(
662 ElementSpec::new("compressor_duty", "percent", "Compressor duty", Viz::Bar)
663 .as_stat()
664 .with_band(0.0, 60.0)
665 .with_locale_label("fr", "Cycle du compresseur")
666 .with_locale_label("sw", "Utendaji wa kompresa"),
667 )
668 .with_message(
669 "state.cooler_off",
670 LocalizedText::per_locale([
671 ("en", "Off"),
672 ("fr", "Arrêt"),
673 ("sw", "Imezimwa"),
674 ]),
675 )
676 .with_message(
677 "state.cooler_on",
678 LocalizedText::per_locale([
679 ("en", "Running"),
680 ("fr", "En marche"),
681 ("sw", "Inafanya kazi"),
682 ]),
683 ),
684 ),
685 }
686 }
687
688 /// An irrigation node: hold soil moisture near a target by opening a valve.
689 ///
690 /// Treats the valve as a "heater" for soil moisture, opening it when the soil
691 /// dries below the band and closing it once it is wet enough, and alerts if the
692 /// soil falls critically dry. Samples less often than the fridge, since soil
693 /// changes slowly and battery life matters more.
694 ///
695 /// # Returns
696 ///
697 /// The irrigation profile.
698 pub fn irrigation_node() -> Self {
699 Self {
700 name: "irrigation-node".to_owned(),
701 description: Some(
702 "Opens an irrigation valve when soil moisture falls below 30 % and \
703 closes it again above 40 %, and alerts when the soil dries below 10 % \
704 or is waterlogged above 60 %. Samples slowly, since soil changes over \
705 hours and the battery has to last."
706 .to_owned(),
707 ),
708 reads: Some(Reads::new("soil_moisture", "percent")),
709 topic: "farm/irrigation/soil-moisture".to_owned(),
710 control: ControlSpec::Setpoint {
711 setpoint: 35.0,
712 hysteresis: 5.0,
713 cooling: false,
714 safe_band: 25.0,
715 },
716 power: PowerSchedule::new(300, 1800, 3600),
717 presentation: Some(
718 Presentation::new()
719 .with_element(
720 ElementSpec::new("soil_moisture", "percent", "Soil moisture", Viz::Droplet)
721 .with_band(10.0, 60.0)
722 .with_locale_label("fr", "Humidité du sol")
723 .with_locale_label("sw", "Unyevu wa udongo"),
724 )
725 .with_element(
726 ElementSpec::new("drip_valve", "state", "Drip valve", Viz::Valve)
727 .with_state("state.closed")
728 .with_locale_label("fr", "Vanne goutte-Ã -goutte")
729 .with_locale_label("sw", "Vali ya matone"),
730 ),
731 ),
732 }
733 }
734
735 /// A well-level monitor: report depth and warn before the well runs dry.
736 ///
737 /// Observes the water level without driving an output and raises an
738 /// [`Alert::RunningOut`](crate::Alert::RunningOut) once the level is on course to
739 /// reach the dry mark within a few more samples.
740 ///
741 /// # Returns
742 ///
743 /// The well-level monitoring profile.
744 pub fn well_level() -> Self {
745 Self {
746 name: "well-level".to_owned(),
747 description: Some(
748 "Reports a well's water level and warns once the level is on course to \
749 reach the dry mark within six more samples, so a pump is stopped \
750 before it runs dry."
751 .to_owned(),
752 ),
753 reads: Some(Reads::new("water_level", "meter")),
754 topic: "water/well/level".to_owned(),
755 control: ControlSpec::Level {
756 empty: 0.5,
757 warn_within: 6,
758 },
759 power: PowerSchedule::new(600, 1800, 3600),
760 presentation: Some(
761 Presentation::new()
762 .with_element(
763 ElementSpec::new("well_level", "meter", "Well level", Viz::Bar)
764 .with_band(1.0, 6.0)
765 .with_locale_label("fr", "Niveau du puits")
766 .with_locale_label("sw", "Kina cha kisima"),
767 )
768 .with_element(
769 ElementSpec::new("battery_voltage", "volt", "Battery", Viz::Battery)
770 .as_stat()
771 .with_band(3.5, 4.3)
772 .with_locale_label("fr", "Batterie")
773 .with_locale_label("sw", "Betri"),
774 ),
775 ),
776 }
777 }
778
779 /// A flash-flood sensor: warn when a river level rises dangerously fast.
780 ///
781 /// Watches a river or stream gauge and raises an
782 /// [`Alert::ChangingFast`](crate::Alert::ChangingFast) when the level rises more
783 /// than 0.3 m in a single sample, the signature of a flash flood. It samples
784 /// often, because a flood gives little warning.
785 ///
786 /// # Returns
787 ///
788 /// The flash-flood monitoring profile.
789 ///
790 /// # Examples
791 ///
792 /// ```
793 /// use pamoja_profile::{Alert, Profile};
794 ///
795 /// let mut control = Profile::flood_sensor().controller()?;
796 /// control.evaluate(1.0); // first fix establishes the level
797 /// let reaction = control.evaluate(1.5); // the river jumped 0.5 m
798 /// assert!(matches!(reaction.alert, Some(Alert::ChangingFast { .. })));
799 /// # Ok::<(), pamoja_core::Error>(())
800 /// ```
801 pub fn flood_sensor() -> Self {
802 Self {
803 name: "flood-sensor".to_owned(),
804 description: Some(
805 "Watches a river gauge and warns when the level rises more than 0.3 m \
806 in one sample, the signature of a flash flood. Samples every minute, \
807 since a flood gives little warning."
808 .to_owned(),
809 ),
810 reads: Some(Reads::new("water_level", "meter")),
811 topic: "water/river/level".to_owned(),
812 control: ControlSpec::Surge {
813 rising: true,
814 limit: 0.3,
815 },
816 power: PowerSchedule::new(60, 300, 900),
817 presentation: Some(
818 Presentation::new()
819 .with_element(
820 ElementSpec::new("river_level", "meter", "River level", Viz::Wave)
821 .with_band(0.2, 2.5)
822 .wide()
823 .with_locale_label("fr", "Niveau de la rivière")
824 .with_locale_label("sw", "Kina cha mto"),
825 )
826 .with_element(
827 ElementSpec::new("rainfall", "millimeter", "Rainfall", Viz::Bar)
828 .with_band(0.0, 25.0)
829 .with_locale_label("fr", "Précipitations")
830 .with_locale_label("sw", "Mvua iliyonyesha"),
831 ),
832 ),
833 }
834 }
835
836 /// Assembles this profile's [`ControlSpec`] into a live [`Controller`].
837 ///
838 /// Each call builds a new controller, so keep the one it returns for the life of the
839 /// node. One built again for each reading forgets whether its output was on and what
840 /// the reading before was, so a heater never holds through its deadband and a level
841 /// or a surge never has a previous reading to measure against.
842 ///
843 /// # Returns
844 ///
845 /// A fresh controller implementing the profile's policy, with its control state
846 /// reset.
847 ///
848 /// # Errors
849 ///
850 /// Returns [`Error::Codec`](pamoja_core::Error::Codec) when the profile names a custom
851 /// kind, which no built-in controller decides: a
852 /// [`PolicyRegistry`](crate::PolicyRegistry) that registers the kind resolves it to
853 /// the code that does. The reason names the kind, and the built-in kind it is
854 /// probably a misspelling of when there is one.
855 ///
856 /// # Examples
857 ///
858 /// ```
859 /// use pamoja_profile::{ControlSpec, Params, PowerSchedule, Profile};
860 ///
861 /// let typo = Profile::new(
862 /// "cellar-heater",
863 /// "home/cellar/temperature",
864 /// ControlSpec::custom("setpiont", Params::new())?,
865 /// PowerSchedule::new(300, 900, 1800),
866 /// );
867 /// let refused = typo.controller().unwrap_err().to_string();
868 /// assert!(refused.contains("did you mean `setpoint`?"), "{refused}");
869 /// # Ok::<(), pamoja_core::Error>(())
870 /// ```
871 pub fn controller(&self) -> pamoja_core::Result<Controller> {
872 Controller::from_spec(&self.control)
873 }
874
875 /// Attaches a dashboard [`Presentation`] declaring this profile's custom elements.
876 ///
877 /// A profile that measures something the dashboard does not draw out of the box - a
878 /// turbidity probe, a custom node stat - carries the graphic, band, and label for it
879 /// here, so the dashboard offers and renders it with no code.
880 ///
881 /// # Arguments
882 ///
883 /// * `presentation` - how this profile presents itself on the dashboard.
884 ///
885 /// # Returns
886 ///
887 /// The profile, for chaining.
888 ///
889 /// # Examples
890 ///
891 /// ```
892 /// use pamoja_profile::{ElementSpec, Presentation, Profile, Viz};
893 ///
894 /// // `with_presentation` sets the whole presentation, so on a shipped preset it
895 /// // replaces the elements the preset came with: a well-level profile drawn this
896 /// // way shows only the turbidity gauge. `with_element` adds one and keeps the rest.
897 /// let replaced = Profile::well_level().with_presentation(
898 /// Presentation::new().with_element(
899 /// ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
900 /// .with_band(0.0, 5.0),
901 /// ),
902 /// );
903 /// let elements = &replaced.presentation.unwrap().elements;
904 /// assert_eq!(elements.len(), 1);
905 /// assert_eq!(elements[0].viz.kind(), "radial");
906 /// ```
907 pub fn with_presentation(mut self, presentation: Presentation) -> Self {
908 self.presentation = Some(presentation);
909 self
910 }
911
912 /// Adds one element to how this profile presents itself, keeping the rest.
913 ///
914 /// [`with_presentation`](Profile::with_presentation) replaces a profile's whole
915 /// presentation, which drops what a preset already declared. This adds to it, so a
916 /// deployment can hang its own gauge off a shipped profile and keep the profile's
917 /// own elements, labels, and theme.
918 ///
919 /// # Arguments
920 ///
921 /// * `element` - the custom sensor or node stat to add.
922 ///
923 /// # Returns
924 ///
925 /// The profile, for chaining.
926 ///
927 /// # Examples
928 ///
929 /// ```
930 /// use pamoja_profile::{ElementSpec, Profile, Viz};
931 ///
932 /// // The shipped irrigation profile, plus the turbidity probe this farm also fitted.
933 /// let profile = Profile::irrigation_node().with_element(
934 /// ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge).with_band(0.0, 5.0),
935 /// );
936 /// let keys: Vec<&str> = profile
937 /// .presentation
938 /// .as_ref()
939 /// .unwrap()
940 /// .elements
941 /// .iter()
942 /// .map(|element| element.key.as_str())
943 /// .collect();
944 /// assert_eq!(keys, ["soil_moisture", "drip_valve", "water_turbidity"]);
945 /// ```
946 pub fn with_element(mut self, element: ElementSpec) -> Self {
947 self.presentation
948 .get_or_insert_with(Presentation::new)
949 .elements
950 .push(element);
951 self
952 }
953
954 /// Adds the wording for one state or event code this profile emits.
955 ///
956 /// The dashboard ships no translation for a code it never knew, so a profile that
957 /// raises its own supplies the words here. Like
958 /// [`with_element`](Profile::with_element) this keeps whatever the profile already
959 /// declared.
960 ///
961 /// # Arguments
962 ///
963 /// * `code` - the message key, a `state.` or `event.` code.
964 /// * `text` - one text for every locale, or per-locale text.
965 ///
966 /// # Returns
967 ///
968 /// The profile, for chaining.
969 ///
970 /// # Examples
971 ///
972 /// ```
973 /// use pamoja_profile::Profile;
974 ///
975 /// let profile = Profile::irrigation_node()
976 /// .with_message("state.flushing", [("en", "Flushing"), ("sw", "Inasafishwa")]);
977 /// assert!(profile.presentation.unwrap().messages.contains_key("state.flushing"));
978 /// ```
979 pub fn with_message(
980 mut self,
981 code: impl Into<String>,
982 text: impl Into<crate::LocalizedText>,
983 ) -> Self {
984 self.presentation
985 .get_or_insert_with(Presentation::new)
986 .messages
987 .insert(code.into(), text.into());
988 self
989 }
990
991 /// Sets what this profile is for, in a sentence or two.
992 ///
993 /// A manifest shared in a catalog carries its purpose with it, so a reader knows what
994 /// the profile watches or holds and what it does about it before opening the file.
995 ///
996 /// # Arguments
997 ///
998 /// * `description` - the purpose, in plain words.
999 ///
1000 /// # Returns
1001 ///
1002 /// The profile, for chaining.
1003 ///
1004 /// # Examples
1005 ///
1006 /// ```
1007 /// use pamoja_profile::Profile;
1008 ///
1009 /// let profile = Profile::well_level()
1010 /// .with_description("Warns before the village borehole runs dry.");
1011 /// assert!(profile.to_json().unwrap().contains("borehole"));
1012 /// ```
1013 pub fn with_description(mut self, description: impl Into<String>) -> Self {
1014 self.description = Some(description.into());
1015 self
1016 }
1017
1018 /// Checks the profile for what a manifest can say that a node or a dashboard could not
1019 /// make sense of.
1020 ///
1021 /// A manifest is written by hand and shared, so [`from_json`](Profile::from_json) runs
1022 /// this on every one it loads, and each language's constructor runs it on the parts it
1023 /// is given. A profile built in Rust can run it too.
1024 ///
1025 /// # Returns
1026 ///
1027 /// Nothing when the profile is usable.
1028 ///
1029 /// # Errors
1030 ///
1031 /// Returns [`Error::Codec`](pamoja_core::Error::Codec) naming the first problem:
1032 ///
1033 /// - an empty name, or a topic that is empty or holds `+` or `#`, which make it a
1034 /// filter rather than a place to publish;
1035 /// - a `reads` whose quantity or unit is not lowercase words joined by underscores;
1036 /// - a control value that is not a finite number, a hysteresis of zero or less, a
1037 /// safe band narrower than the hysteresis, a level that warns within no samples, a
1038 /// surge limit of zero or less, or a custom kind with no name;
1039 /// - a sampling interval of zero, intervals that shorten as the battery drains, a
1040 /// saver threshold outside 0 to 1, or a critical threshold that is not between 0 and
1041 /// the saver threshold;
1042 /// - anything [`Presentation::check`] refuses in the presentation.
1043 ///
1044 /// # Examples
1045 ///
1046 /// ```
1047 /// use pamoja_profile::{ControlSpec, PowerSchedule, Profile};
1048 ///
1049 /// // A deadband of zero switches the output on and off on every reading near the
1050 /// // setpoint, which wears out a relay.
1051 /// let chattering = Profile::new(
1052 /// "cellar-heater",
1053 /// "home/cellar/temperature",
1054 /// ControlSpec::Setpoint { setpoint: 8.0, hysteresis: 0.0, cooling: false, safe_band: 5.0 },
1055 /// PowerSchedule::new(300, 900, 1800),
1056 /// );
1057 /// let refused = chattering.check().unwrap_err().to_string();
1058 /// assert!(refused.contains("the output chatters at the setpoint"));
1059 /// ```
1060 pub fn check(&self) -> pamoja_core::Result<()> {
1061 if self.name.trim().is_empty() {
1062 return refuse("`name` must not be empty".to_owned());
1063 }
1064 let topic = &self.topic;
1065 if topic.is_empty() {
1066 return refuse("the topic is empty".to_owned());
1067 }
1068 if topic.contains(['+', '#']) {
1069 return refuse(format!(
1070 "the topic `{topic}` is a filter; a profile publishes to one topic"
1071 ));
1072 }
1073 if let Some(reads) = &self.reads {
1074 for (field, word) in [("quantity", &reads.quantity), ("unit", &reads.unit)] {
1075 if !is_word(word) {
1076 return refuse(format!(
1077 "the `reads` {field} `{word}` must be lowercase words joined by underscores, such as `relative_humidity`"
1078 ));
1079 }
1080 }
1081 }
1082 self.check_control()?;
1083 self.check_power()?;
1084 match &self.presentation {
1085 Some(presentation) => presentation.check(),
1086 None => Ok(()),
1087 }
1088 }
1089
1090 /// Checks the control policy's values, which the deserializer reads by type alone.
1091 fn check_control(&self) -> pamoja_core::Result<()> {
1092 let finite = |name: &str, value: f32| {
1093 if value.is_finite() {
1094 Ok(())
1095 } else {
1096 refuse(format!("`{name}` must be a finite number, not {value}"))
1097 }
1098 };
1099 match self.control {
1100 ControlSpec::Setpoint {
1101 setpoint,
1102 hysteresis,
1103 safe_band,
1104 ..
1105 } => {
1106 finite("setpoint", setpoint)?;
1107 finite("hysteresis", hysteresis)?;
1108 finite("safe_band", safe_band)?;
1109 if hysteresis <= 0.0 {
1110 return refuse(
1111 "`hysteresis` must be above zero, or the output chatters at the setpoint"
1112 .to_owned(),
1113 );
1114 }
1115 if safe_band < hysteresis {
1116 return refuse(format!(
1117 "`safe_band` ({safe_band}) is narrower than `hysteresis` ({hysteresis}), so an alert would fire inside the deadband"
1118 ));
1119 }
1120 Ok(())
1121 }
1122 ControlSpec::Level { empty, warn_within } => {
1123 finite("empty", empty)?;
1124 if warn_within == 0 {
1125 return refuse(
1126 "`warn_within` must be at least one sample, or the warning never comes"
1127 .to_owned(),
1128 );
1129 }
1130 Ok(())
1131 }
1132 ControlSpec::Surge { limit, .. } => {
1133 finite("limit", limit)?;
1134 if limit <= 0.0 {
1135 return refuse(
1136 "`limit` must be above zero, or every sample is a surge".to_owned(),
1137 );
1138 }
1139 Ok(())
1140 }
1141 ControlSpec::Custom { ref kind, .. } if kind.trim().is_empty() => {
1142 refuse("a custom control needs a kind to be named by".to_owned())
1143 }
1144 ControlSpec::Monitor | ControlSpec::Custom { .. } => Ok(()),
1145 }
1146 }
1147
1148 /// Checks that the schedule slows down as the battery drains.
1149 fn check_power(&self) -> pamoja_core::Result<()> {
1150 let power = &self.power;
1151 if power.active_secs == 0 {
1152 return refuse("`active_secs` must be at least one second".to_owned());
1153 }
1154 if !(power.active_secs <= power.saver_secs && power.saver_secs <= power.critical_secs) {
1155 return refuse(format!(
1156 "the intervals must not shorten as the battery drains: active {} s, saver {} s, critical {} s",
1157 power.active_secs, power.saver_secs, power.critical_secs
1158 ));
1159 }
1160 if !(power.saver_below > 0.0 && power.saver_below <= 1.0) {
1161 return refuse(format!(
1162 "`saver_below` must be a state of charge above 0 and at most 1, not {}",
1163 power.saver_below
1164 ));
1165 }
1166 if !(power.critical_below > 0.0 && power.critical_below < power.saver_below) {
1167 return refuse(format!(
1168 "`critical_below` must sit between 0 and `saver_below` ({}), not {}",
1169 power.saver_below, power.critical_below
1170 ));
1171 }
1172 if !(power.hysteresis >= 0.0 && power.saver_below + power.hysteresis <= 1.0) {
1173 return refuse(format!(
1174 "the power `hysteresis` must be 0 or more and leave `saver_below` ({}) plus it at most 1, not {}",
1175 power.saver_below, power.hysteresis
1176 ));
1177 }
1178 Ok(())
1179 }
1180}
1181
1182/// Whether a name is lowercase words joined by single underscores, such as `soil_moisture`.
1183fn is_word(name: &str) -> bool {
1184 name.split('_').all(|part| {
1185 part.starts_with(|c: char| c.is_ascii_lowercase())
1186 && part
1187 .chars()
1188 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
1189 })
1190}
1191
1192#[cfg(feature = "json")]
1193impl Profile {
1194 /// Loads a profile from a JSON manifest.
1195 ///
1196 /// This is how a shared profile reaches a device: a community publishes a manifest
1197 /// file, and the runtime loads it into a profile to assemble a node from.
1198 ///
1199 /// # Arguments
1200 ///
1201 /// * `manifest` - the JSON text of the profile.
1202 ///
1203 /// # Returns
1204 ///
1205 /// The profile described by `manifest`.
1206 ///
1207 /// # Errors
1208 ///
1209 /// Returns [`Error::Codec`](pamoja_core::Error::Codec) if `manifest` is not valid
1210 /// JSON, does not describe a profile, is written in a format this build does not read,
1211 /// carries a field the format does not have, or describes a profile
1212 /// [`check`](Profile::check) refuses. A misspelled field or value is reported with
1213 /// the name it was probably meant to be.
1214 ///
1215 /// # Examples
1216 ///
1217 /// ```
1218 /// use pamoja_profile::Profile;
1219 ///
1220 /// // A well-level monitor, shared as a manifest. The power thresholds are
1221 /// // optional and default when omitted.
1222 /// let manifest = r#"{
1223 /// "$schema": "https://pamoja.molex.cloud/schema/profile-1.json",
1224 /// "name": "tank-level",
1225 /// "reads": { "quantity": "water_level", "unit": "meter" },
1226 /// "topic": "water/tank/level",
1227 /// "control": { "kind": "level", "empty": 0.0, "warn_within": 5 },
1228 /// "power": { "active_secs": 600, "saver_secs": 1800, "critical_secs": 3600 }
1229 /// }"#;
1230 ///
1231 /// let profile = Profile::from_json(manifest).expect("valid manifest");
1232 /// assert_eq!(profile.name, "tank-level");
1233 ///
1234 /// let mut control = profile.controller().expect("a built-in kind");
1235 /// control.evaluate(10.0); // first reading establishes a level
1236 /// assert!(control.evaluate(2.0).alert.is_some()); // falling fast toward empty
1237 ///
1238 /// // A misspelled field is refused with the one it was meant to be, rather than
1239 /// // leaving the default in its place.
1240 /// let typo = manifest.replace("\"warn_within\"", "\"warn_withn\"");
1241 /// let refused = Profile::from_json(&typo).unwrap_err().to_string();
1242 /// assert!(refused.contains("did you mean `warn_within`?"), "{refused}");
1243 /// ```
1244 pub fn from_json(manifest: &str) -> pamoja_core::Result<Self> {
1245 let profile: Profile = serde_json::from_str(manifest)
1246 .map_err(|error| pamoja_core::Error::Codec(format::explain(&error)))?;
1247 profile.check()?;
1248 Ok(profile)
1249 }
1250
1251 /// Serializes this profile to a JSON manifest a community can share.
1252 ///
1253 /// # Returns
1254 ///
1255 /// The pretty-printed JSON text of the profile.
1256 ///
1257 /// # Errors
1258 ///
1259 /// Returns [`Error::Codec`](pamoja_core::Error::Codec) if the profile cannot be
1260 /// serialized.
1261 pub fn to_json(&self) -> pamoja_core::Result<String> {
1262 serde_json::to_string_pretty(self)
1263 .map_err(|error| pamoja_core::Error::Codec(error.to_string()))
1264 }
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269 use super::*;
1270 use crate::Alert;
1271
1272 #[test]
1273 fn presets_have_stable_names_and_topics() {
1274 assert_eq!(
1275 Profile::vaccine_fridge_monitor().name,
1276 "vaccine-fridge-monitor"
1277 );
1278 for preset in [
1279 Profile::vaccine_fridge_monitor(),
1280 Profile::irrigation_node(),
1281 Profile::well_level(),
1282 Profile::flood_sensor(),
1283 ] {
1284 let description = preset.description.expect("every preset explains itself");
1285 assert!(description.ends_with('.'), "{description}");
1286 }
1287 assert_eq!(
1288 Profile::vaccine_fridge_monitor().topic,
1289 "cold-chain/fridge/temperature"
1290 );
1291 assert_eq!(Profile::irrigation_node().name, "irrigation-node");
1292 assert_eq!(Profile::well_level().name, "well-level");
1293 }
1294
1295 #[test]
1296 fn the_fridge_controller_cools_and_flags_a_spoilage_excursion() {
1297 let mut control = Profile::vaccine_fridge_monitor().controller().unwrap();
1298 let reaction = control.evaluate(9.0);
1299 assert_eq!(reaction.actuator, Some(true));
1300 assert!(matches!(reaction.alert, Some(Alert::OutOfRange { .. })));
1301 }
1302
1303 #[test]
1304 fn the_well_controller_observes_without_an_output() {
1305 let mut control = Profile::well_level().controller().unwrap();
1306 control.evaluate(3.0);
1307 assert_eq!(control.evaluate(2.0).actuator, None);
1308 }
1309
1310 #[test]
1311 fn the_flood_controller_warns_on_a_rapid_rise() {
1312 let mut control = Profile::flood_sensor().controller().unwrap();
1313 control.evaluate(1.0);
1314 let reaction = control.evaluate(1.5); // a 0.5 m jump in one sample
1315 assert!(matches!(reaction.alert, Some(Alert::ChangingFast { .. })));
1316 }
1317
1318 #[test]
1319 fn the_schedule_builds_the_documented_power_plan() {
1320 use pamoja_power::PowerMode;
1321
1322 let plan = Profile::vaccine_fridge_monitor().power.plan();
1323 assert_eq!(plan.mode(0.9), PowerMode::Active);
1324 assert_eq!(plan.mode(0.1), PowerMode::Critical);
1325 assert_eq!(plan.interval(0.9), Duration::from_secs(60));
1326 }
1327
1328 #[cfg(feature = "json")]
1329 #[test]
1330 fn a_profile_round_trips_through_json() {
1331 // Cover a setpoint profile and a surge profile, the two manifest shapes that
1332 // carry the most fields.
1333 for profile in [Profile::irrigation_node(), Profile::flood_sensor()] {
1334 let json = profile.to_json().expect("serialize");
1335 let restored = Profile::from_json(&json).expect("deserialize");
1336 assert_eq!(profile, restored);
1337 }
1338 }
1339
1340 #[cfg(feature = "json")]
1341 #[test]
1342 fn a_kind_the_library_never_shipped_loads_with_its_parameters() {
1343 let manifest = r#"{
1344 "name": "orchard-frost",
1345 "topic": "orchard/air/temperature",
1346 "control": { "kind": "frost_guard", "warn_below": 2.0, "latching": true, "zone": "north" },
1347 "power": { "active_secs": 60, "saver_secs": 300, "critical_secs": 900 }
1348 }"#;
1349 let profile = Profile::from_json(manifest).expect("a custom kind parses");
1350 let ControlSpec::Custom { kind, params } = &profile.control else {
1351 panic!("expected a custom kind, got {:?}", profile.control);
1352 };
1353 assert_eq!(kind, "frost_guard");
1354 assert_eq!(profile.control.kind(), "frost_guard");
1355 assert_eq!(params.number("warn_below"), Some(2.0));
1356 assert_eq!(params.flag("latching"), Some(true));
1357 assert_eq!(params.text("zone"), Some("north"));
1358 assert_eq!(params.len(), 3);
1359
1360 // No built-in controller decides a custom kind, so asking for one is refused
1361 // rather than handing back a monitor that would never drive the output.
1362 assert!(profile.controller().is_err());
1363
1364 // It writes back in the same flat shape, with the kind first.
1365 let shared = profile.to_json().expect("serializes");
1366 assert!(
1367 shared.contains("\"kind\": \"frost_guard\",\n \"latching\": true,\n \"warn_below\": 2.0,\n \"zone\": \"north\""),
1368 "{shared}"
1369 );
1370 assert_eq!(Profile::from_json(&shared).expect("round trip"), profile);
1371 }
1372
1373 #[cfg(feature = "json")]
1374 #[test]
1375 fn a_built_in_kind_with_a_wrong_field_is_refused_rather_than_taken_as_custom() {
1376 let control = |body: &str| {
1377 let manifest = format!(
1378 r#"{{ "name": "t", "topic": "t", "control": {body}, "power": {{ "active_secs": 1, "saver_secs": 2, "critical_secs": 3 }} }}"#
1379 );
1380 Profile::from_json(&manifest).map(|profile| profile.control)
1381 };
1382 let error = control(r#"{ "kind": "setpoint", "setpoint": 5.0 }"#).unwrap_err();
1383 assert!(error.to_string().contains("hysteresis"), "{error}");
1384 let error =
1385 control(r#"{ "kind": "level", "empty": 0.0, "warn_within": 2.5 }"#).unwrap_err();
1386 assert!(error.to_string().contains("whole number"), "{error}");
1387 let error = control(r#"{ "kind": "surge", "rising": "up", "limit": 1.0 }"#).unwrap_err();
1388 assert!(error.to_string().contains("true or false"), "{error}");
1389 let error = control(r#"{ "setpoint": 5.0 }"#).unwrap_err();
1390 assert!(error.to_string().contains("kind"), "{error}");
1391 let error = control(r#"{ "kind": 3 }"#).unwrap_err();
1392 assert!(error.to_string().contains("string"), "{error}");
1393 assert_eq!(
1394 control(r#"{ "kind": "level", "empty": 1, "warn_within": 4 }"#).unwrap(),
1395 ControlSpec::Level {
1396 empty: 1.0,
1397 warn_within: 4
1398 }
1399 );
1400 assert_eq!(
1401 ControlSpec::BUILT_IN,
1402 ["setpoint", "level", "surge", "monitor"]
1403 );
1404 assert_eq!(ControlSpec::Monitor.kind(), "monitor");
1405 }
1406
1407 #[cfg(feature = "json")]
1408 #[test]
1409 fn a_field_the_format_does_not_have_is_refused_with_the_one_it_meant() {
1410 let brooder = Profile::new(
1411 "brooder",
1412 "poultry/brooder/temperature",
1413 ControlSpec::Setpoint {
1414 setpoint: 32.0,
1415 hysteresis: 0.5,
1416 cooling: false,
1417 safe_band: 4.0,
1418 },
1419 PowerSchedule::new(120, 600, 1800),
1420 )
1421 .with_reads("temperature", "celsius")
1422 .with_presentation(
1423 Presentation::new()
1424 .with_element(
1425 ElementSpec::new(
1426 "brooder_temperature",
1427 "celsius",
1428 "Brooder",
1429 Viz::Thermometer,
1430 )
1431 .with_band(28.0, 36.0),
1432 )
1433 .with_theme(crate::Theme {
1434 accent: Some("#c8553d".to_owned()),
1435 ..crate::Theme::default()
1436 }),
1437 );
1438 let shared = brooder.to_json().unwrap();
1439 assert!(
1440 shared.starts_with(
1441 "{\n \"$schema\": \"https://pamoja.molex.cloud/schema/profile-1.json\",\n \"name\": \"brooder\",\n \"reads\": {\n \"quantity\": \"temperature\",\n \"unit\": \"celsius\"\n },\n \"topic\""
1442 ),
1443 "{shared}"
1444 );
1445 assert_eq!(Profile::from_json(&shared).unwrap(), brooder);
1446
1447 let refused = |from: &str, to: &str| {
1448 assert!(shared.contains(from), "{from} is not in {shared}");
1449 match Profile::from_json(&shared.replacen(from, to, 1)) {
1450 Err(pamoja_core::Error::Codec(reason)) => reason,
1451 other => panic!("expected {to} to be refused, got {other:?}"),
1452 }
1453 };
1454 let starts = |from: &str, to: &str, expected: &str| {
1455 let reason = refused(from, to);
1456 assert!(reason.starts_with(expected), "{reason}");
1457 };
1458 starts(
1459 "\"topic\"",
1460 "\"topik\"",
1461 "unknown field `topik`, did you mean `topic`?",
1462 );
1463 starts(
1464 "\"critical_secs\"",
1465 "\"critical_sec\"",
1466 "unknown field `critical_sec`, did you mean `critical_secs`?",
1467 );
1468 starts(
1469 "\"safe_band\"",
1470 "\"safe-band\"",
1471 "unknown field `safe-band`, did you mean `safe_band`?",
1472 );
1473 starts(
1474 "\"unit\": \"celsius\"\n }",
1475 "\"units\": \"celsius\"\n }",
1476 "unknown field `units`, did you mean `unit`?",
1477 );
1478 starts(
1479 "\"label\"",
1480 "\"lable\"",
1481 "unknown field `lable`, did you mean `label`?",
1482 );
1483 starts(
1484 "\"accent\"",
1485 "\"acent\"",
1486 "unknown field `acent`, did you mean `accent`?",
1487 );
1488 starts(
1489 "\"thermometer\"",
1490 "\"thermometr\"",
1491 "unknown variant `thermometr`, did you mean `thermometer`?",
1492 );
1493 starts(
1494 "\"name\"",
1495 "\"colour\": 1, \"name\"",
1496 "unknown field `colour`, expected one of",
1497 );
1498 starts(
1499 "\"hysteresis\": 0.5",
1500 "\"hysteresis\": 0.5, \"deadband\": 1.0",
1501 "unknown field `deadband`, expected one of `kind`, `setpoint`, `hysteresis`, `cooling`, `safe_band`",
1502 );
1503 assert!(refused("profile-1.json", "profile-2.json").contains("profile format 2"));
1504 assert!(refused("\"temperature\"", "\"Temperature\"").contains("lowercase words"));
1505 assert!(refused("\"celsius\"\n }", "\"deg C\"\n }").contains("lowercase words"));
1506
1507 let unmarked = shared.replacen(
1508 " \"$schema\": \"https://pamoja.molex.cloud/schema/profile-1.json\",\n",
1509 "",
1510 1,
1511 );
1512 assert_eq!(Profile::from_json(&unmarked).unwrap(), brooder);
1513 let offline = shared.replacen(Profile::SCHEMA, "./profile-1.json", 1);
1514 assert_eq!(Profile::from_json(&offline).unwrap(), brooder);
1515 }
1516
1517 #[cfg(feature = "json")]
1518 #[test]
1519 fn a_manifest_may_omit_the_power_thresholds() {
1520 let manifest = r#"{
1521 "name": "tank",
1522 "topic": "water/tank/level",
1523 "control": { "kind": "level", "empty": 0.0, "warn_within": 4 },
1524 "power": { "active_secs": 600, "saver_secs": 1800, "critical_secs": 3600 }
1525 }"#;
1526 let profile = Profile::from_json(manifest).expect("valid manifest");
1527 assert_eq!(profile.power.saver_below, 0.5);
1528 assert_eq!(profile.power.critical_below, 0.2);
1529 assert_eq!(profile.power.hysteresis, pamoja_power::DEFAULT_HYSTERESIS);
1530 assert!(matches!(
1531 profile.control,
1532 ControlSpec::Level { warn_within: 4, .. }
1533 ));
1534 assert_eq!(profile.description, None);
1535 assert!(!profile.to_json().unwrap().contains("description"));
1536 let described = profile.with_description("Warns before a rain tank runs dry.");
1537 let shared = described.to_json().unwrap();
1538 assert!(shared.contains("\"description\": \"Warns before a rain tank runs dry.\""));
1539 assert_eq!(Profile::from_json(&shared).unwrap(), described);
1540 }
1541
1542 #[test]
1543 fn every_preset_passes_its_own_check() {
1544 for preset in [
1545 Profile::vaccine_fridge_monitor(),
1546 Profile::irrigation_node(),
1547 Profile::well_level(),
1548 Profile::flood_sensor(),
1549 ] {
1550 preset
1551 .check()
1552 .unwrap_or_else(|error| panic!("{}: {error}", preset.name));
1553 }
1554 }
1555
1556 #[test]
1557 fn a_profile_no_node_could_run_is_refused_with_the_reason() {
1558 let base = || {
1559 Profile::new(
1560 "brooder",
1561 "poultry/brooder/temperature",
1562 ControlSpec::Setpoint {
1563 setpoint: 32.0,
1564 hysteresis: 0.5,
1565 cooling: false,
1566 safe_band: 4.0,
1567 },
1568 PowerSchedule::new(120, 600, 1800),
1569 )
1570 };
1571 base().check().expect("the base profile is usable");
1572 let refused = |profile: Profile| profile.check().unwrap_err().to_string();
1573
1574 let mut unnamed = base();
1575 unnamed.name = " ".to_owned();
1576 assert!(refused(unnamed).contains("`name` must not be empty"));
1577 let mut filter = base();
1578 filter.topic = "poultry/+/temperature".to_owned();
1579 assert!(refused(filter).contains("is a filter"));
1580
1581 let setpoint = |hysteresis: f32, safe_band: f32| {
1582 let mut profile = base();
1583 profile.control = ControlSpec::Setpoint {
1584 setpoint: 32.0,
1585 hysteresis,
1586 cooling: false,
1587 safe_band,
1588 };
1589 profile
1590 };
1591 assert!(refused(setpoint(0.0, 4.0)).contains("chatters"));
1592 assert!(refused(setpoint(2.0, 1.0)).contains("inside the deadband"));
1593 assert!(refused(setpoint(f32::NAN, 4.0)).contains("`hysteresis` must be a finite number"));
1594
1595 let mut never = base();
1596 never.control = ControlSpec::Level {
1597 empty: 0.0,
1598 warn_within: 0,
1599 };
1600 assert!(refused(never).contains("never comes"));
1601 let mut always = base();
1602 always.control = ControlSpec::Surge {
1603 rising: true,
1604 limit: 0.0,
1605 };
1606 assert!(refused(always).contains("every sample is a surge"));
1607
1608 let schedule = |power: PowerSchedule| {
1609 let mut profile = base();
1610 profile.power = power;
1611 profile
1612 };
1613 assert!(refused(schedule(PowerSchedule::new(0, 600, 1800))).contains("at least one second"));
1614 assert!(refused(schedule(PowerSchedule::new(120, 60, 1800))).contains("must not shorten"));
1615 let inverted = PowerSchedule::new(120, 600, 1800).with_thresholds(0.2, 0.5);
1616 assert!(refused(schedule(inverted)).contains("`critical_below` must sit between"));
1617 let over = PowerSchedule::new(120, 600, 1800).with_thresholds(50.0, 0.2);
1618 assert!(refused(schedule(over)).contains("not 50"));
1619 let out_of_reach = PowerSchedule::new(120, 600, 1800).with_thresholds(0.98, 0.2);
1620 assert!(
1621 refused(schedule(out_of_reach)).contains("the power `hysteresis` must be 0 or more")
1622 );
1623 let negative = PowerSchedule::new(120, 600, 1800).with_hysteresis(-0.1);
1624 assert!(refused(schedule(negative)).contains("not -0.1"));
1625 let unknown = PowerSchedule::new(120, 600, 1800).with_hysteresis(f32::NAN);
1626 assert!(refused(schedule(unknown)).contains("not NaN"));
1627 let flat = PowerSchedule::new(120, 600, 1800).with_hysteresis(0.0);
1628 schedule(flat).check().expect("no margin is allowed");
1629
1630 let drawn = base().with_element(
1631 ElementSpec::new(
1632 "brooder_temperature",
1633 "celsius",
1634 "Brooder",
1635 Viz::Thermometer,
1636 )
1637 .with_band(36.0, 28.0),
1638 );
1639 assert!(refused(drawn).contains("the low end comes first"));
1640 }
1641
1642 #[cfg(feature = "json")]
1643 #[test]
1644 fn a_manifest_is_checked_as_it_loads() {
1645 let manifest = r#"{
1646 "name": "brooder",
1647 "topic": "poultry/brooder/temperature",
1648 "control": { "kind": "setpoint", "setpoint": 32.0, "hysteresis": 0.5, "cooling": false, "safe_band": -4.0 },
1649 "power": { "active_secs": 120, "saver_secs": 600, "critical_secs": 1800 }
1650 }"#;
1651 let refused = Profile::from_json(manifest).unwrap_err().to_string();
1652 assert!(
1653 refused.contains("`safe_band` (-4) is narrower than `hysteresis` (0.5)"),
1654 "{refused}"
1655 );
1656 }
1657}