Skip to main content

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}