Skip to main content

hap_model/
uuid.rs

1//! HAP type UUIDs.
2//!
3//! HAP service and characteristic types are written on the wire as **short**
4//! hex strings (e.g. `"3E"`, `"43"`) that abbreviate a UUID in the HAP base
5//! range `0000XXXX-0000-1000-8000-0026BB765291`, where `XXXX` is the short
6//! value left-padded to 8 hex digits. Vendor (non-HAP) types use a full
7//! 36-character UUID and are stored verbatim.
8
9use crate::error::{ModelError, Result};
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
12/// The fixed HAP base UUID suffix shared by every HAP-defined type.
13pub(crate) const HAP_BASE_SUFFIX: &str = "-0000-1000-8000-0026BB765291";
14
15/// A 128-bit type UUID, stored as its canonical lowercase 36-char string.
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct Uuid(String);
18
19impl Uuid {
20    /// Parse a HAP `type` string: either a short hex abbreviation (1–8 hex
21    /// digits) of a HAP-base UUID, or a full 36-char UUID.
22    ///
23    /// # Errors
24    /// Returns [`ModelError::MalformedUuid`] if the string is neither form.
25    pub fn parse(s: &str) -> Result<Self> {
26        let t = s.trim();
27        if t.len() == 36 && t.as_bytes()[8] == b'-' {
28            // Full UUID: validate it is hex+dashes, store lowercased.
29            if t.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
30                return Ok(Uuid(t.to_ascii_lowercase()));
31            }
32            return Err(ModelError::MalformedUuid(s.to_string()));
33        }
34        // Short form: 1..=8 hex digits.
35        if (1..=8).contains(&t.len()) && t.chars().all(|c| c.is_ascii_hexdigit()) {
36            let padded = format!("{:0>8}", t.to_ascii_lowercase());
37            return Ok(Uuid(
38                format!("{padded}{HAP_BASE_SUFFIX}").to_ascii_lowercase(),
39            ));
40        }
41        Err(ModelError::MalformedUuid(s.to_string()))
42    }
43
44    /// The canonical full 36-char UUID string.
45    pub fn as_full(&self) -> &str {
46        &self.0
47    }
48
49    /// The short HAP form (`XXXX` with leading zeroes stripped, uppercased)
50    /// if this UUID is in the HAP base range; otherwise `None`.
51    pub fn as_short(&self) -> Option<String> {
52        let suffix = HAP_BASE_SUFFIX.to_ascii_lowercase();
53        let head = self.0.strip_suffix(&suffix)?;
54        // head is the 8-hex-digit first group.
55        let trimmed = head.trim_start_matches('0');
56        let trimmed = if trimmed.is_empty() { "0" } else { trimmed };
57        Some(trimmed.to_ascii_uppercase())
58    }
59
60    /// Construct directly from a known-good full UUID string (used by codegen).
61    pub(crate) fn from_full_unchecked(full: String) -> Self {
62        Uuid(full)
63    }
64}
65
66impl Serialize for Uuid {
67    fn serialize<S: Serializer>(&self, s: S) -> core::result::Result<S::Ok, S::Error> {
68        // Serialize back in short form when possible (HAP convention), else full.
69        match self.as_short() {
70            Some(short) => s.serialize_str(&short),
71            None => s.serialize_str(&self.0),
72        }
73    }
74}
75
76impl<'de> Deserialize<'de> for Uuid {
77    fn deserialize<D: Deserializer<'de>>(d: D) -> core::result::Result<Self, D::Error> {
78        let raw = String::deserialize(d)?;
79        Uuid::parse(&raw).map_err(serde::de::Error::custom)
80    }
81}
82
83#[cfg(test)]
84// Test-code carve-out: unwrap allowed with this documented justification.
85#[allow(clippy::unwrap_used)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn short_3e_expands_to_accessory_information() {
91        let u = Uuid::parse("3E").unwrap();
92        assert_eq!(u.as_full(), "0000003e-0000-1000-8000-0026bb765291");
93    }
94
95    #[test]
96    fn short_43_expands_to_lightbulb() {
97        let u = Uuid::parse("43").unwrap();
98        assert_eq!(u.as_full(), "00000043-0000-1000-8000-0026bb765291");
99    }
100
101    #[test]
102    fn round_trips_short_form() {
103        let u = Uuid::parse("43").unwrap();
104        assert_eq!(u.as_short().as_deref(), Some("43"));
105    }
106
107    #[test]
108    fn full_vendor_uuid_round_trips_verbatim_and_has_no_short() {
109        let v = "00112233-4455-6677-8899-aabbccddeeff";
110        let u = Uuid::parse(v).unwrap();
111        assert_eq!(u.as_full(), v);
112        assert_eq!(u.as_short(), None);
113    }
114
115    #[test]
116    fn rejects_garbage() {
117        assert!(matches!(
118            Uuid::parse("zz"),
119            Err(ModelError::MalformedUuid(_))
120        ));
121        assert!(matches!(Uuid::parse(""), Err(ModelError::MalformedUuid(_))));
122    }
123}