Skip to main content

pamoja_profile/
presentation.rs

1//! How a profile presents its custom elements on the local-first dashboard.
2//!
3//! The dashboard renders a language-neutral fleet snapshot and, by default, picks a
4//! graphic for each reading from its key and unit. That covers the common quantities,
5//! but a community often measures something we never anticipated - a water turbidity
6//! probe, a pH meter, a custom node stat. A [`Presentation`] lets a profile *declare*
7//! those elements as plain data: the graphic to draw them with, their safe band, a
8//! label, which groups they are offered on, and a small theme. It is part of the same
9//! shareable manifest a community already authors, so a new sensor type needs no code
10//! and no change to the dashboard.
11//!
12//! The declaration is presentation only. Values still travel in the snapshot as raw
13//! numbers and stable keys; this names how to *show* them.
14
15use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18
19/// The graphic a reading is drawn with on the dashboard.
20///
21/// The names are the instrument, not the quantity, so a profile chooses the shape that
22/// reads best for its data: a 270-degree arch [`Gauge`](Viz::Gauge) for a fraction, a
23/// [`Bar`](Viz::Bar) for a tank, a [`Switch`](Viz::Switch) for an on/off state. Each
24/// maps to one of the dashboard's hand-drawn visualizations through
25/// [`kind`](Viz::kind).
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Viz {
29    /// A rolling sparkline of recent values. The default for an unfamiliar quantity.
30    Spark,
31    /// A 270-degree arch gauge, for a fraction or percentage.
32    Gauge,
33    /// A half-dial with a needle, for a pressure or flow reading.
34    Dial,
35    /// A horizontal bar with a safe-band tick, for a level or stock.
36    Bar,
37    /// A thermometer, for a temperature.
38    Thermometer,
39    /// A liquid-filled droplet, for humidity or moisture.
40    Droplet,
41    /// A segmented battery cell, for a state of charge or voltage.
42    Battery,
43    /// An anemometer, for wind speed.
44    Wind,
45    /// A sun whose corona grows with the reading, for illuminance.
46    Sun,
47    /// An acoustic waveform, for sound level or an acoustic event.
48    Wave,
49    /// A labelled state chip, lit when the state reads as "on". For a discrete state.
50    Switch,
51    /// A pipe valve, open along the flow or closed across it. For a controllable valve.
52    Valve,
53    /// A row of hash-chained blocks, for a tamper-evident record count.
54    Chain,
55    /// A neighbour-mesh topology map, for a mesh node's peers.
56    Mesh,
57    /// A plain numeric counter, for a node or network stat.
58    Count,
59}
60
61impl Viz {
62    /// Every graphic, in a stable order, so a caller can enumerate the available
63    /// visualizations (and a build can check each one still renders).
64    pub const ALL: [Viz; 15] = [
65        Viz::Spark,
66        Viz::Gauge,
67        Viz::Dial,
68        Viz::Bar,
69        Viz::Thermometer,
70        Viz::Droplet,
71        Viz::Battery,
72        Viz::Wind,
73        Viz::Sun,
74        Viz::Wave,
75        Viz::Switch,
76        Viz::Valve,
77        Viz::Chain,
78        Viz::Mesh,
79        Viz::Count,
80    ];
81
82    /// Returns the dashboard visualization kind this graphic renders as.
83    ///
84    /// The dashboard's renderer dispatches on a small set of internal kind strings; a
85    /// few friendly names differ from them ([`Gauge`](Viz::Gauge) draws the `radial`
86    /// arch, [`Thermometer`](Viz::Thermometer) the `therm` instrument,
87    /// [`Switch`](Viz::Switch) the `chip`). This is the value carried on the wire so the
88    /// page needs no lookup of its own.
89    ///
90    /// # Returns
91    ///
92    /// The stable visualization kind, such as `"radial"` or `"bar"`.
93    pub fn kind(self) -> &'static str {
94        match self {
95            Viz::Spark => "spark",
96            Viz::Gauge => "radial",
97            Viz::Dial => "dial",
98            Viz::Bar => "bar",
99            Viz::Thermometer => "therm",
100            Viz::Droplet => "droplet",
101            Viz::Battery => "battery",
102            Viz::Wind => "wind",
103            Viz::Sun => "sun",
104            Viz::Wave => "wave",
105            Viz::Switch => "chip",
106            Viz::Valve => "valve",
107            Viz::Chain => "chain",
108            Viz::Mesh => "mesh",
109            Viz::Count => "count",
110        }
111    }
112}
113
114/// Which groups a declared element is offered on when a user adds a sensor.
115///
116/// A custom element rarely makes sense everywhere: a mesh-routing stat belongs only on
117/// a mesh node, while a quality-of-life detector a community wants on every node is
118/// [`Always`](Scope::Always). This gates the add-sensor dialog so a profile's element
119/// appears only where it applies.
120#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum Scope {
123    /// Offered on every group, whatever its link.
124    #[default]
125    Always,
126    /// Offered only on groups whose link kind is one of these, such as `["mesh"]`.
127    Links(Vec<String>),
128}
129
130/// A custom sensor or node stat a profile contributes to the dashboard.
131///
132/// This is the unit of a [`Presentation`]: one element keyed by a stable, language-
133/// neutral key, drawn with a chosen [`Viz`], scoped to the groups it belongs on, and
134/// labelled for people who do not read the key. The snapshot still carries the raw
135/// value under `key`; this names how to show it.
136#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
137pub struct ElementSpec {
138    /// The stable, language-neutral element key, such as `"water_turbidity"`.
139    pub key: String,
140    /// The canonical unit name, such as `"ntu"`, `"ph"`, or `"count"`.
141    pub unit: String,
142    /// A human-readable fallback label, shown when no localized label is available.
143    pub label: String,
144    /// Optional per-locale labels, keyed by locale tag (`"en"`, `"sw"`, ...). A locale
145    /// present here is shown in that locale; otherwise the page falls back to
146    /// [`label`](ElementSpec::label).
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub labels: Option<BTreeMap<String, String>>,
149    /// The graphic this element is drawn with.
150    pub viz: Viz,
151    /// The safe band `[low, high]` in the element's unit, drawn as the gauge's safe zone.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub band: Option<[f32; 2]>,
154    /// Whether this is a node or network stat rather than a measurement of the world.
155    /// Stats are counted and rendered apart from sensors. Defaults `false`.
156    #[serde(default)]
157    pub stat: bool,
158    /// Which groups this element is offered on. Defaults to [`Scope::Always`].
159    #[serde(default)]
160    pub scope: Scope,
161    /// Whether the element's tile spans two columns, for a wide graphic. Defaults `false`.
162    #[serde(default)]
163    pub span: bool,
164    /// A starting numeric value for the add-sensor dialog, before a real sample arrives.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub value: Option<f32>,
167    /// A starting discrete state code, such as `"state.closed"`, for a non-numeric element.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub state: Option<String>,
170}
171
172impl ElementSpec {
173    /// Declares a numeric element drawn with the given graphic.
174    ///
175    /// # Arguments
176    ///
177    /// * `key` - the stable, language-neutral element key.
178    /// * `unit` - the canonical unit name.
179    /// * `label` - a human-readable fallback label.
180    /// * `viz` - the graphic to draw it with.
181    ///
182    /// # Returns
183    ///
184    /// A measurement element offered on every group, with no band yet.
185    pub fn new(
186        key: impl Into<String>,
187        unit: impl Into<String>,
188        label: impl Into<String>,
189        viz: Viz,
190    ) -> Self {
191        Self {
192            key: key.into(),
193            unit: unit.into(),
194            label: label.into(),
195            labels: None,
196            viz,
197            band: None,
198            stat: false,
199            scope: Scope::Always,
200            span: false,
201            value: None,
202            state: None,
203        }
204    }
205
206    /// Sets the safe band drawn as the graphic's safe zone.
207    ///
208    /// # Arguments
209    ///
210    /// * `low` - the bottom of the safe band.
211    /// * `high` - the top of the safe band.
212    ///
213    /// # Returns
214    ///
215    /// The element, for chaining.
216    pub fn with_band(mut self, low: f32, high: f32) -> Self {
217        self.band = Some([low, high]);
218        self
219    }
220
221    /// Restricts the groups this element is offered on.
222    ///
223    /// # Arguments
224    ///
225    /// * `scope` - the groups the add-sensor dialog offers this element on.
226    ///
227    /// # Returns
228    ///
229    /// The element, for chaining.
230    pub fn on(mut self, scope: Scope) -> Self {
231        self.scope = scope;
232        self
233    }
234
235    /// Marks the element as a node or network stat rather than a measurement.
236    ///
237    /// # Returns
238    ///
239    /// The element, for chaining.
240    pub fn as_stat(mut self) -> Self {
241        self.stat = true;
242        self
243    }
244
245    /// Sets a starting value shown until the first real sample arrives.
246    ///
247    /// # Arguments
248    ///
249    /// * `value` - the starting numeric value.
250    ///
251    /// # Returns
252    ///
253    /// The element, for chaining.
254    pub fn with_value(mut self, value: f32) -> Self {
255        self.value = Some(value);
256        self
257    }
258
259    /// Sets a starting discrete state code for a non-numeric element.
260    ///
261    /// # Arguments
262    ///
263    /// * `state` - the starting state code, such as `"state.closed"`.
264    ///
265    /// # Returns
266    ///
267    /// The element, for chaining.
268    pub fn with_state(mut self, state: impl Into<String>) -> Self {
269        self.state = Some(state.into());
270        self
271    }
272
273    /// Spans the element's tile across two columns, for a wide graphic.
274    ///
275    /// # Returns
276    ///
277    /// The element, for chaining.
278    pub fn wide(mut self) -> Self {
279        self.span = true;
280        self
281    }
282
283    /// Adds a localized label for one locale.
284    ///
285    /// # Arguments
286    ///
287    /// * `locale` - the locale tag, such as `"sw"`.
288    /// * `label` - the element's label in that locale.
289    ///
290    /// # Returns
291    ///
292    /// The element, for chaining.
293    pub fn with_locale_label(
294        mut self,
295        locale: impl Into<String>,
296        label: impl Into<String>,
297    ) -> Self {
298        self.labels
299            .get_or_insert_with(BTreeMap::new)
300            .insert(locale.into(), label.into());
301        self
302    }
303}
304
305/// A small set of theme tokens a profile can set on the dashboard.
306///
307/// Each token, when present, tints one of the page's CSS custom properties, so a
308/// deployment can carry its own brand accent and status palette. Modest by design: it
309/// tints the existing console rather than restyling it. Colors are any CSS color.
310#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "camelCase")]
312pub struct Theme {
313    /// The brand/interaction accent (links, focus glow, brand mark), such as `"#3fb1c8"`.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub accent: Option<String>,
316    /// The healthy/ok status color, which also tints an in-band gauge.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub ok: Option<String>,
319    /// The warning status color.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub warn: Option<String>,
322    /// The alarm status color.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub alarm: Option<String>,
325    /// The unfilled track/rail color behind gauges and progress bars.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub track: Option<String>,
328}
329
330/// A piece of human-facing text a profile supplies, either one string for every locale or
331/// a per-locale map.
332///
333/// Used for the messages a profile localizes for the dashboard - its custom discrete state
334/// codes and event codes. In a manifest it is a bare string for the simple case or an
335/// object keyed by locale tag:
336///
337/// ```json
338/// { "state.flushing": "Flushing", "event.filter_clog": { "en": "Filter clogged", "sw": "Kichujio kimeziba" } }
339/// ```
340#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
341#[serde(untagged)]
342pub enum LocalizedText {
343    /// One text shown in every locale that has no specific translation.
344    Plain(String),
345    /// Per-locale text, keyed by locale tag (`"en"`, `"sw"`, ...).
346    PerLocale(BTreeMap<String, String>),
347}
348
349impl From<String> for LocalizedText {
350    fn from(text: String) -> Self {
351        LocalizedText::Plain(text)
352    }
353}
354
355impl From<&str> for LocalizedText {
356    fn from(text: &str) -> Self {
357        LocalizedText::Plain(text.to_owned())
358    }
359}
360
361impl From<BTreeMap<String, String>> for LocalizedText {
362    fn from(map: BTreeMap<String, String>) -> Self {
363        LocalizedText::PerLocale(map)
364    }
365}
366
367/// How a profile presents itself on the dashboard: its custom elements and theme.
368///
369/// A [`Profile`](crate::Profile) carries an optional `presentation`, so a deployment's
370/// dashboard offers exactly the sensor types its profiles introduce and renders them
371/// the way the profile intends. The dashboard turns these declarations into the catalog
372/// it serves to the page.
373#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
374pub struct Presentation {
375    /// The custom sensors and node stats this profile contributes.
376    #[serde(default)]
377    pub elements: Vec<ElementSpec>,
378    /// An optional theme that tints the dashboard.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub theme: Option<Theme>,
381    /// Localized text for the stable codes this profile introduces, keyed by the page's
382    /// message key (a discrete state such as `"state.flushing"` or an event such as
383    /// `"event.filter_clog"`). The dashboard ships no translation for a code it never knew,
384    /// so a profile that emits a custom state or event supplies its wording here.
385    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
386    pub messages: BTreeMap<String, LocalizedText>,
387}
388
389impl Presentation {
390    /// Starts an empty presentation.
391    ///
392    /// # Returns
393    ///
394    /// A presentation with no elements and no theme.
395    pub fn new() -> Self {
396        Self::default()
397    }
398
399    /// Adds a custom element.
400    ///
401    /// # Arguments
402    ///
403    /// * `element` - the sensor or stat to contribute.
404    ///
405    /// # Returns
406    ///
407    /// The presentation, for chaining.
408    pub fn with_element(mut self, element: ElementSpec) -> Self {
409        self.elements.push(element);
410        self
411    }
412
413    /// Sets the theme that tints the dashboard.
414    ///
415    /// # Arguments
416    ///
417    /// * `theme` - the theme tokens to apply.
418    ///
419    /// # Returns
420    ///
421    /// The presentation, for chaining.
422    pub fn with_theme(mut self, theme: Theme) -> Self {
423        self.theme = Some(theme);
424        self
425    }
426
427    /// Supplies localized text for a stable code this profile introduces.
428    ///
429    /// Use this for a custom discrete state or event the dashboard ships no wording for, so
430    /// the page renders it as words rather than the raw code.
431    ///
432    /// # Arguments
433    ///
434    /// * `key` - the page message key, such as `"state.flushing"` or `"event.filter_clog"`.
435    /// * `text` - the text, one string for every locale or a per-locale map.
436    ///
437    /// # Returns
438    ///
439    /// The presentation, for chaining.
440    pub fn with_message(mut self, key: impl Into<String>, text: impl Into<LocalizedText>) -> Self {
441        self.messages.insert(key.into(), text.into());
442        self
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn every_viz_maps_to_its_documented_render_kind() {
452        // The full set of graphics and the render kind each draws as. ALL and this table must
453        // agree, and every kind is distinct, so a new graphic cannot silently collide or be
454        // left out of the enumerated set.
455        let table = [
456            (Viz::Spark, "spark"),
457            (Viz::Gauge, "radial"),
458            (Viz::Dial, "dial"),
459            (Viz::Bar, "bar"),
460            (Viz::Thermometer, "therm"),
461            (Viz::Droplet, "droplet"),
462            (Viz::Battery, "battery"),
463            (Viz::Wind, "wind"),
464            (Viz::Sun, "sun"),
465            (Viz::Wave, "wave"),
466            (Viz::Switch, "chip"),
467            (Viz::Valve, "valve"),
468            (Viz::Chain, "chain"),
469            (Viz::Mesh, "mesh"),
470            (Viz::Count, "count"),
471        ];
472        assert_eq!(
473            table.len(),
474            Viz::ALL.len(),
475            "the table covers every variant in ALL"
476        );
477        let mut kinds = std::collections::HashSet::new();
478        for (viz, kind) in table {
479            assert_eq!(viz.kind(), kind);
480            assert!(Viz::ALL.contains(&viz), "{kind} is in ALL");
481            assert!(kinds.insert(kind), "{kind} is unique");
482        }
483    }
484
485    #[test]
486    fn an_element_builds_with_band_and_scope() {
487        let element = ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
488            .with_band(0.0, 5.0)
489            .on(Scope::Links(vec!["mesh".into()]));
490        assert_eq!(element.band, Some([0.0, 5.0]));
491        assert!(matches!(element.scope, Scope::Links(_)));
492        assert!(!element.stat);
493    }
494
495    #[cfg(feature = "json")]
496    #[test]
497    fn viz_serializes_to_its_friendly_name() {
498        assert_eq!(serde_json::to_string(&Viz::Gauge).unwrap(), "\"gauge\"");
499        assert_eq!(serde_json::to_string(&Viz::Switch).unwrap(), "\"switch\"");
500    }
501
502    #[cfg(feature = "json")]
503    #[test]
504    fn scope_round_trips_in_both_forms() {
505        assert_eq!(serde_json::to_string(&Scope::Always).unwrap(), "\"always\"");
506        let links = Scope::Links(vec!["mesh".into()]);
507        let json = serde_json::to_string(&links).unwrap();
508        assert_eq!(json, r#"{"links":["mesh"]}"#);
509        assert_eq!(serde_json::from_str::<Scope>(&json).unwrap(), links);
510    }
511
512    #[cfg(feature = "json")]
513    #[test]
514    fn a_presentation_round_trips_through_json() {
515        let presentation = Presentation::new()
516            .with_element(
517                ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
518                    .with_band(0.0, 5.0)
519                    .with_locale_label("sw", "Utiririko"),
520            )
521            .with_element(
522                ElementSpec::new("packets_dropped", "count", "Packets dropped", Viz::Count)
523                    .as_stat()
524                    .on(Scope::Links(vec!["mesh".into()])),
525            )
526            .with_theme(Theme {
527                accent: Some("#3fb1c8".into()),
528                ..Theme::default()
529            })
530            .with_message("state.flushing", "Flushing")
531            .with_message(
532                "event.filter_clog",
533                BTreeMap::from([
534                    ("en".to_owned(), "Filter clogged".to_owned()),
535                    ("sw".to_owned(), "Kichujio kimeziba".to_owned()),
536                ]),
537            );
538        let json = serde_json::to_string(&presentation).unwrap();
539        let restored: Presentation = serde_json::from_str(&json).unwrap();
540        assert_eq!(presentation, restored);
541    }
542
543    #[cfg(feature = "json")]
544    #[test]
545    fn a_message_is_a_bare_string_or_a_locale_map_on_the_wire() {
546        let presentation = Presentation::new()
547            .with_message("state.flushing", "Flushing")
548            .with_message(
549                "event.filter_clog",
550                BTreeMap::from([("en".to_owned(), "Filter clogged".to_owned())]),
551            );
552        let json = serde_json::to_string(&presentation.messages).unwrap();
553        assert_eq!(
554            json,
555            r#"{"event.filter_clog":{"en":"Filter clogged"},"state.flushing":"Flushing"}"#
556        );
557    }
558}