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}