use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Viz {
Spark,
Gauge,
Dial,
Bar,
Thermometer,
Droplet,
Battery,
Wind,
Sun,
Wave,
Switch,
Valve,
Chain,
Mesh,
Count,
}
impl Viz {
pub const ALL: [Viz; 15] = [
Viz::Spark,
Viz::Gauge,
Viz::Dial,
Viz::Bar,
Viz::Thermometer,
Viz::Droplet,
Viz::Battery,
Viz::Wind,
Viz::Sun,
Viz::Wave,
Viz::Switch,
Viz::Valve,
Viz::Chain,
Viz::Mesh,
Viz::Count,
];
pub fn kind(self) -> &'static str {
match self {
Viz::Spark => "spark",
Viz::Gauge => "radial",
Viz::Dial => "dial",
Viz::Bar => "bar",
Viz::Thermometer => "therm",
Viz::Droplet => "droplet",
Viz::Battery => "battery",
Viz::Wind => "wind",
Viz::Sun => "sun",
Viz::Wave => "wave",
Viz::Switch => "chip",
Viz::Valve => "valve",
Viz::Chain => "chain",
Viz::Mesh => "mesh",
Viz::Count => "count",
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
#[default]
Always,
Links(Vec<String>),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ElementSpec {
pub key: String,
pub unit: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub labels: Option<BTreeMap<String, String>>,
pub viz: Viz,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub band: Option<[f32; 2]>,
#[serde(default)]
pub stat: bool,
#[serde(default)]
pub scope: Scope,
#[serde(default)]
pub span: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
impl ElementSpec {
pub fn new(
key: impl Into<String>,
unit: impl Into<String>,
label: impl Into<String>,
viz: Viz,
) -> Self {
Self {
key: key.into(),
unit: unit.into(),
label: label.into(),
labels: None,
viz,
band: None,
stat: false,
scope: Scope::Always,
span: false,
value: None,
state: None,
}
}
pub fn with_band(mut self, low: f32, high: f32) -> Self {
self.band = Some([low, high]);
self
}
pub fn on(mut self, scope: Scope) -> Self {
self.scope = scope;
self
}
pub fn as_stat(mut self) -> Self {
self.stat = true;
self
}
pub fn with_value(mut self, value: f32) -> Self {
self.value = Some(value);
self
}
pub fn with_state(mut self, state: impl Into<String>) -> Self {
self.state = Some(state.into());
self
}
pub fn wide(mut self) -> Self {
self.span = true;
self
}
pub fn with_locale_label(
mut self,
locale: impl Into<String>,
label: impl Into<String>,
) -> Self {
self.labels
.get_or_insert_with(BTreeMap::new)
.insert(locale.into(), label.into());
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Theme {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ok: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warn: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alarm: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub track: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum LocalizedText {
Plain(String),
PerLocale(BTreeMap<String, String>),
}
impl From<String> for LocalizedText {
fn from(text: String) -> Self {
LocalizedText::Plain(text)
}
}
impl From<&str> for LocalizedText {
fn from(text: &str) -> Self {
LocalizedText::Plain(text.to_owned())
}
}
impl From<BTreeMap<String, String>> for LocalizedText {
fn from(map: BTreeMap<String, String>) -> Self {
LocalizedText::PerLocale(map)
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Presentation {
#[serde(default)]
pub elements: Vec<ElementSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<Theme>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub messages: BTreeMap<String, LocalizedText>,
}
impl Presentation {
pub fn new() -> Self {
Self::default()
}
pub fn with_element(mut self, element: ElementSpec) -> Self {
self.elements.push(element);
self
}
pub fn with_theme(mut self, theme: Theme) -> Self {
self.theme = Some(theme);
self
}
pub fn with_message(mut self, key: impl Into<String>, text: impl Into<LocalizedText>) -> Self {
self.messages.insert(key.into(), text.into());
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_viz_maps_to_its_documented_render_kind() {
let table = [
(Viz::Spark, "spark"),
(Viz::Gauge, "radial"),
(Viz::Dial, "dial"),
(Viz::Bar, "bar"),
(Viz::Thermometer, "therm"),
(Viz::Droplet, "droplet"),
(Viz::Battery, "battery"),
(Viz::Wind, "wind"),
(Viz::Sun, "sun"),
(Viz::Wave, "wave"),
(Viz::Switch, "chip"),
(Viz::Valve, "valve"),
(Viz::Chain, "chain"),
(Viz::Mesh, "mesh"),
(Viz::Count, "count"),
];
assert_eq!(
table.len(),
Viz::ALL.len(),
"the table covers every variant in ALL"
);
let mut kinds = std::collections::HashSet::new();
for (viz, kind) in table {
assert_eq!(viz.kind(), kind);
assert!(Viz::ALL.contains(&viz), "{kind} is in ALL");
assert!(kinds.insert(kind), "{kind} is unique");
}
}
#[test]
fn an_element_builds_with_band_and_scope() {
let element = ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
.with_band(0.0, 5.0)
.on(Scope::Links(vec!["mesh".into()]));
assert_eq!(element.band, Some([0.0, 5.0]));
assert!(matches!(element.scope, Scope::Links(_)));
assert!(!element.stat);
}
#[cfg(feature = "json")]
#[test]
fn viz_serializes_to_its_friendly_name() {
assert_eq!(serde_json::to_string(&Viz::Gauge).unwrap(), "\"gauge\"");
assert_eq!(serde_json::to_string(&Viz::Switch).unwrap(), "\"switch\"");
}
#[cfg(feature = "json")]
#[test]
fn scope_round_trips_in_both_forms() {
assert_eq!(serde_json::to_string(&Scope::Always).unwrap(), "\"always\"");
let links = Scope::Links(vec!["mesh".into()]);
let json = serde_json::to_string(&links).unwrap();
assert_eq!(json, r#"{"links":["mesh"]}"#);
assert_eq!(serde_json::from_str::<Scope>(&json).unwrap(), links);
}
#[cfg(feature = "json")]
#[test]
fn a_presentation_round_trips_through_json() {
let presentation = Presentation::new()
.with_element(
ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
.with_band(0.0, 5.0)
.with_locale_label("sw", "Utiririko"),
)
.with_element(
ElementSpec::new("packets_dropped", "count", "Packets dropped", Viz::Count)
.as_stat()
.on(Scope::Links(vec!["mesh".into()])),
)
.with_theme(Theme {
accent: Some("#3fb1c8".into()),
..Theme::default()
})
.with_message("state.flushing", "Flushing")
.with_message(
"event.filter_clog",
BTreeMap::from([
("en".to_owned(), "Filter clogged".to_owned()),
("sw".to_owned(), "Kichujio kimeziba".to_owned()),
]),
);
let json = serde_json::to_string(&presentation).unwrap();
let restored: Presentation = serde_json::from_str(&json).unwrap();
assert_eq!(presentation, restored);
}
#[cfg(feature = "json")]
#[test]
fn a_message_is_a_bare_string_or_a_locale_map_on_the_wire() {
let presentation = Presentation::new()
.with_message("state.flushing", "Flushing")
.with_message(
"event.filter_clog",
BTreeMap::from([("en".to_owned(), "Filter clogged".to_owned())]),
);
let json = serde_json::to_string(&presentation.messages).unwrap();
assert_eq!(
json,
r#"{"event.filter_clog":{"en":"Filter clogged"},"state.flushing":"Flushing"}"#
);
}
}