Skip to main content

hems_core/
ids.rs

1//! Identifiers.
2//!
3//! A site is identified by a UUID because it is created by software and has to
4//! be unique across a fleet. Everything inside a site — assets, circuits, driver
5//! templates — is identified by a short human-written string, because those
6//! names appear in a TOML file an installer edits, in MQTT topics, in the local
7//! UI and in support tickets.
8
9use core::fmt;
10use core::str::FromStr;
11
12use uuid::Uuid;
13
14use crate::error::IdError;
15
16/// The identity of one installation, stable across reconfiguration.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19#[cfg_attr(feature = "serde", serde(transparent))]
20pub struct SiteId(Uuid);
21
22impl SiteId {
23    /// A fresh, time-ordered identity.
24    #[must_use]
25    pub fn new() -> Self {
26        Self(Uuid::now_v7())
27    }
28
29    /// Wrap an existing UUID.
30    #[must_use]
31    pub const fn from_uuid(uuid: Uuid) -> Self {
32        Self(uuid)
33    }
34
35    /// The underlying UUID.
36    #[must_use]
37    pub const fn as_uuid(&self) -> &Uuid {
38        &self.0
39    }
40}
41
42impl Default for SiteId {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl fmt::Display for SiteId {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        self.0.fmt(f)
51    }
52}
53
54impl FromStr for SiteId {
55    type Err = uuid::Error;
56    fn from_str(s: &str) -> Result<Self, Self::Err> {
57        Uuid::parse_str(s).map(Self)
58    }
59}
60
61/// The identity of a plan produced by the optimiser.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
63#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
64#[cfg_attr(feature = "serde", serde(transparent))]
65pub struct PlanId(Uuid);
66
67impl PlanId {
68    /// A fresh, time-ordered identity.
69    #[must_use]
70    pub fn new() -> Self {
71        Self(Uuid::now_v7())
72    }
73
74    /// The underlying UUID.
75    #[must_use]
76    pub const fn as_uuid(&self) -> &Uuid {
77        &self.0
78    }
79}
80
81impl Default for PlanId {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl fmt::Display for PlanId {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        self.0.fmt(f)
90    }
91}
92
93macro_rules! slug_id {
94    ($(#[$meta:meta])* $name:ident) => {
95        $(#[$meta])*
96        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
97        #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
98        #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
99        pub struct $name(String);
100
101        impl $name {
102            /// Validate and wrap a name.
103            ///
104            /// Accepts 1–64 characters from `a-z`, `0-9`, `.`, `_` and `-`.
105            /// Upper case is folded to lower case, so `Wallbox` and `wallbox`
106            /// are the same identifier and cannot both appear in one site.
107            ///
108            /// # Errors
109            /// [`IdError`] when the name is empty, too long, or contains an
110            /// unsupported character.
111            pub fn new(name: impl AsRef<str>) -> Result<Self, IdError> {
112                let name = name.as_ref().trim().to_ascii_lowercase();
113                if name.is_empty() {
114                    return Err(IdError::Empty);
115                }
116                if name.chars().count() > 64 {
117                    return Err(IdError::TooLong(name.chars().count()));
118                }
119                if let Some(bad) = name
120                    .chars()
121                    .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
122                {
123                    return Err(IdError::BadCharacter(bad));
124                }
125                Ok(Self(name))
126            }
127
128            /// The identifier as a string slice.
129            #[must_use]
130            pub fn as_str(&self) -> &str {
131                &self.0
132            }
133        }
134
135        impl fmt::Display for $name {
136            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137                f.write_str(&self.0)
138            }
139        }
140
141        impl FromStr for $name {
142            type Err = IdError;
143            fn from_str(s: &str) -> Result<Self, Self::Err> {
144                Self::new(s)
145            }
146        }
147
148        impl TryFrom<String> for $name {
149            type Error = IdError;
150            fn try_from(value: String) -> Result<Self, Self::Error> {
151                Self::new(value)
152            }
153        }
154
155        impl From<$name> for String {
156            fn from(value: $name) -> Self {
157                value.0
158            }
159        }
160
161        impl AsRef<str> for $name {
162            fn as_ref(&self) -> &str {
163                &self.0
164            }
165        }
166    };
167}
168
169slug_id!(
170    /// The name of one asset within a site — `wallbox-garage`, `battery`, `wp`.
171    AssetId
172);
173slug_id!(
174    /// The name of one circuit within a site — `main`, `sub-garage`.
175    CircuitId
176);
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn asset_ids_are_case_folded_and_validated() {
184        assert_eq!(
185            AssetId::new("Wallbox-Garage").unwrap().as_str(),
186            "wallbox-garage"
187        );
188        assert_eq!(AssetId::new("  battery  ").unwrap().as_str(), "battery");
189        assert_eq!(AssetId::new(""), Err(IdError::Empty));
190        assert_eq!(AssetId::new("wall box"), Err(IdError::BadCharacter(' ')));
191        assert_eq!(AssetId::new("a".repeat(65)), Err(IdError::TooLong(65)));
192    }
193
194    #[test]
195    fn site_ids_are_time_ordered() {
196        let a = SiteId::new();
197        let b = SiteId::new();
198        assert!(a < b, "v7 UUIDs sort by creation time");
199    }
200}