Skip to main content

pamoja_dashboard/
catalog.rs

1//! The presentation catalog a gateway serves so the dashboard can show custom elements.
2//!
3//! The page ships a built-in set of sensor types it knows how to draw and offer. A
4//! deployment usually measures something beyond that set, and a [`Profile`] declares
5//! those extras in its [`Presentation`](pamoja_profile::Presentation). This turns those
6//! declarations into the small JSON catalog served at `GET /catalog`: the page appends
7//! the custom presets to its own and applies the theme, so a new sensor type needs no
8//! page change.
9//!
10//! The catalog is presentation only - which graphic, which band, which label, and which
11//! groups an element is offered on, plus where the page's network map places each group.
12//! Live values still travel in the [`State`](crate::State) snapshot.
13
14use std::collections::BTreeMap;
15
16use serde::Serialize;
17
18use pamoja_profile::{LocalizedText, Presentation, Profile, Scope, Theme};
19
20/// One custom sensor or stat the page should add to its built-in catalog.
21///
22/// Serialized to the same shape the page's catalog uses, so a served preset merges in
23/// by `id` next to the defaults.
24#[derive(Clone, Debug, Serialize)]
25#[serde(rename_all = "camelCase")]
26struct Preset {
27    id: String,
28    key: String,
29    unit: String,
30    viz: String,
31    label: String,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    labels: Option<BTreeMap<String, String>>,
34    scope: Scope,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    band: Option<[f32; 2]>,
37    #[serde(skip_serializing_if = "is_false")]
38    stat: bool,
39    #[serde(skip_serializing_if = "is_false")]
40    span: bool,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    value: Option<f32>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    state: Option<String>,
45}
46
47/// The presentation catalog served at `GET /catalog`.
48///
49/// Build one from the profiles a deployment runs with [`from_profiles`](Catalog::from_profiles),
50/// or from presentations directly with [`from_presentations`](Catalog::from_presentations).
51/// The page fetches it on boot, appends its custom presets to the built-in ones, applies
52/// the theme, and places groups on its network map where
53/// [`with_site_position`](Catalog::with_site_position) says. A gateway with no custom
54/// elements need not serve a catalog at all; the page then keeps its defaults.
55#[derive(Clone, Debug, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct Catalog {
58    sensor_presets: Vec<Preset>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    theme: Option<Theme>,
61    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
62    messages: BTreeMap<String, LocalizedText>,
63    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
64    site_positions: BTreeMap<String, [f32; 2]>,
65}
66
67impl Catalog {
68    /// Builds a catalog from the presentation of each profile a deployment runs.
69    ///
70    /// Every [`ElementSpec`](pamoja_profile::ElementSpec) across the profiles becomes one
71    /// preset, keyed and de-duplicated by its element key (the first wins). The theme is
72    /// the first one a profile declares.
73    ///
74    /// # Arguments
75    ///
76    /// * `profiles` - the profiles whose presentation to gather.
77    ///
78    /// # Returns
79    ///
80    /// A catalog carrying the custom presets and theme.
81    pub fn from_profiles(profiles: &[&Profile]) -> Self {
82        let presentations: Vec<&Presentation> = profiles
83            .iter()
84            .filter_map(|profile| profile.presentation.as_ref())
85            .collect();
86        Self::from_presentations(&presentations)
87    }
88
89    /// Builds a catalog from presentations, for a deployment that declares its elements
90    /// without a profile.
91    ///
92    /// The de-duplication and theme rules are those of
93    /// [`from_profiles`](Catalog::from_profiles).
94    ///
95    /// # Arguments
96    ///
97    /// * `presentations` - the presentations whose elements to gather.
98    ///
99    /// # Returns
100    ///
101    /// A catalog carrying the custom presets and theme.
102    pub fn from_presentations(presentations: &[&Presentation]) -> Self {
103        let mut sensor_presets: Vec<Preset> = Vec::new();
104        let mut theme: Option<Theme> = None;
105        let mut messages: BTreeMap<String, LocalizedText> = BTreeMap::new();
106        for presentation in presentations {
107            if theme.is_none() {
108                theme = presentation.theme.clone();
109            }
110            for (key, text) in &presentation.messages {
111                messages.entry(key.clone()).or_insert_with(|| text.clone());
112            }
113            for element in &presentation.elements {
114                if sensor_presets.iter().any(|p| p.key == element.key) {
115                    continue;
116                }
117                sensor_presets.push(Preset {
118                    id: element.key.clone(),
119                    key: element.key.clone(),
120                    unit: element.unit.clone(),
121                    viz: element.viz.kind().to_owned(),
122                    label: element.label.clone(),
123                    labels: element.labels.clone(),
124                    scope: element.scope.clone(),
125                    band: element.band,
126                    stat: element.stat,
127                    span: element.span,
128                    value: element.value,
129                    state: element.state.clone(),
130                });
131            }
132        }
133        Self {
134            sensor_presets,
135            theme,
136            messages,
137            site_positions: BTreeMap::new(),
138        }
139    }
140
141    /// Places a group on the page's network map.
142    ///
143    /// # Arguments
144    ///
145    /// * `group` - the group's id, or `__gateway` for the gateway itself.
146    /// * `x` - the position across the map, `0.0` at the left edge to `1.0` at the right.
147    /// * `y` - the position down the map, `0.0` at the top to `1.0` at the bottom.
148    ///
149    /// # Returns
150    ///
151    /// The catalog, for chaining.
152    pub fn with_site_position(mut self, group: impl Into<String>, x: f32, y: f32) -> Self {
153        self.site_positions.insert(group.into(), [x, y]);
154        self
155    }
156
157    /// Whether the catalog carries nothing the page does not already have.
158    ///
159    /// # Returns
160    ///
161    /// `true` when there are no custom presets, theme, messages, or map positions, so a
162    /// gateway can skip serving it.
163    pub fn is_empty(&self) -> bool {
164        self.sensor_presets.is_empty()
165            && self.theme.is_none()
166            && self.messages.is_empty()
167            && self.site_positions.is_empty()
168    }
169
170    /// Serializes the catalog to the JSON served at `GET /catalog`.
171    ///
172    /// # Returns
173    ///
174    /// The JSON text of the catalog.
175    ///
176    /// # Errors
177    ///
178    /// Returns a [`serde_json::Error`] if the catalog cannot be serialized, which in
179    /// practice only happens on a non-finite band value.
180    pub fn to_json(&self) -> Result<String, serde_json::Error> {
181        serde_json::to_string(self)
182    }
183}
184
185fn is_false(value: &bool) -> bool {
186    !*value
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use pamoja_profile::{ElementSpec, Presentation, Viz};
193
194    fn water_profile() -> Profile {
195        Profile::well_level().with_presentation(
196            Presentation::new()
197                .with_element(
198                    ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
199                        .with_band(0.0, 5.0)
200                        .on(Scope::Links(vec!["mesh".into()])),
201                )
202                .with_element(
203                    ElementSpec::new("packets_dropped", "count", "Packets dropped", Viz::Count)
204                        .as_stat(),
205                )
206                .with_message("event.filter_clog", "Filter clogged"),
207        )
208    }
209
210    #[test]
211    fn from_profiles_flattens_every_element_to_a_preset() {
212        let profile = water_profile();
213        let catalog = Catalog::from_profiles(&[&profile]);
214        assert_eq!(catalog.sensor_presets.len(), 2);
215        let turbidity = &catalog.sensor_presets[0];
216        assert_eq!(turbidity.viz, "radial");
217        assert_eq!(turbidity.band, Some([0.0, 5.0]));
218        assert!(!turbidity.stat);
219    }
220
221    #[test]
222    fn duplicate_keys_across_profiles_are_kept_once() {
223        let profile = water_profile();
224        let catalog = Catalog::from_profiles(&[&profile, &profile]);
225        assert_eq!(
226            catalog.sensor_presets.len(),
227            2,
228            "the second copy is skipped"
229        );
230    }
231
232    #[test]
233    fn a_profile_without_presentation_yields_an_empty_catalog() {
234        // Every shipped profile presents itself, so this is a profile of one's own that
235        // draws with the built-in elements alone.
236        let mut plain = Profile::well_level();
237        plain.presentation = None;
238        assert!(Catalog::from_profiles(&[&plain]).is_empty());
239    }
240
241    #[test]
242    fn a_presentation_alone_and_map_positions_make_a_catalog() {
243        let presentation = Presentation::new().with_element(ElementSpec::new(
244            "pump_speed",
245            "percent",
246            "Pump speed",
247            Viz::Bar,
248        ));
249        let catalog = Catalog::from_presentations(&[&presentation])
250            .with_site_position("farm-node", 0.3, 0.7)
251            .with_site_position("__gateway", 0.5, 0.5);
252        assert!(!catalog.is_empty());
253        let json = catalog.to_json().expect("serialize");
254        assert!(
255            json.contains("\"sitePositions\":{\"__gateway\":[0.5,0.5],\"farm-node\":[0.3,0.7]}")
256        );
257        assert!(Catalog::from_presentations(&[])
258            .with_site_position("hub", 0.1, 0.1)
259            .to_json()
260            .expect("serialize")
261            .contains("sitePositions"));
262        assert!(Catalog::from_presentations(&[]).is_empty());
263    }
264
265    #[test]
266    fn the_json_uses_the_page_catalog_shape() {
267        let profile = water_profile();
268        let json = Catalog::from_profiles(&[&profile])
269            .to_json()
270            .expect("serialize");
271        assert!(json.contains("\"sensorPresets\""));
272        assert!(json.contains("\"viz\":\"count\""));
273        // A scoped element carries its links; an always element omits the form entirely.
274        assert!(json.contains("\"scope\":{\"links\":[\"mesh\"]}"));
275        assert!(json.contains("\"scope\":\"always\""));
276        // A profile-supplied message for a custom code rides along for the page to localize.
277        assert!(json.contains("\"messages\":{\"event.filter_clog\":\"Filter clogged\"}"));
278    }
279}