Skip to main content

hap_model/
format.rs

1//! Characteristic value formats and decoded values.
2
3use crate::error::{ModelError, Result};
4use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
5use serde::{Deserialize, Serialize};
6
7/// The HAP characteristic value format (the `format` field on a characteristic).
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10#[non_exhaustive]
11pub enum CharFormat {
12    /// `bool`
13    Bool,
14    /// `uint8`
15    Uint8,
16    /// `uint16`
17    Uint16,
18    /// `uint32`
19    Uint32,
20    /// `uint64`
21    Uint64,
22    /// `int` (32-bit signed on the wire; stored as `i64`)
23    Int,
24    /// `float`
25    Float,
26    /// `string`
27    String,
28    /// `tlv8` (opaque base64 on the wire; stored as raw bytes)
29    Tlv8,
30    /// `data` (opaque base64 on the wire; stored as raw bytes)
31    Data,
32}
33
34impl CharFormat {
35    /// The wire spelling used in error messages and serialization.
36    pub fn as_str(self) -> &'static str {
37        match self {
38            CharFormat::Bool => "bool",
39            CharFormat::Uint8 => "uint8",
40            CharFormat::Uint16 => "uint16",
41            CharFormat::Uint32 => "uint32",
42            CharFormat::Uint64 => "uint64",
43            CharFormat::Int => "int",
44            CharFormat::Float => "float",
45            CharFormat::String => "string",
46            CharFormat::Tlv8 => "tlv8",
47            CharFormat::Data => "data",
48        }
49    }
50
51    fn uint_max(self) -> Option<u64> {
52        match self {
53            CharFormat::Uint8 => Some(u64::from(u8::MAX)),
54            CharFormat::Uint16 => Some(u64::from(u16::MAX)),
55            CharFormat::Uint32 => Some(u64::from(u32::MAX)),
56            CharFormat::Uint64 => Some(u64::MAX),
57            _ => None,
58        }
59    }
60
61    /// Map a JSON value to a [`CharValue`] under this format. See the plan's
62    /// "Format → CharValue mapping rules" table for the exact contract.
63    ///
64    /// # Errors
65    /// [`ModelError::ValueType`] for a wrong JSON type, [`ModelError::ValueRange`]
66    /// for an out-of-range number, [`ModelError::Base64`] for bad tlv8/data.
67    pub fn value_from_json(self, v: &serde_json::Value) -> Result<CharValue> {
68        use serde_json::Value as J;
69        match self {
70            CharFormat::Bool => match v {
71                J::Bool(b) => Ok(CharValue::Bool(*b)),
72                J::Number(n) if n.as_u64() == Some(0) => Ok(CharValue::Bool(false)),
73                J::Number(n) if n.as_u64() == Some(1) => Ok(CharValue::Bool(true)),
74                other => Err(self.type_err(other)),
75            },
76            CharFormat::Uint8 | CharFormat::Uint16 | CharFormat::Uint32 | CharFormat::Uint64 => {
77                let n = v.as_u64().ok_or_else(|| self.type_err(v))?;
78                if let Some(max) = self.uint_max() {
79                    if n > max {
80                        return Err(ModelError::ValueRange {
81                            format: self.as_str(),
82                            value: n.to_string(),
83                        });
84                    }
85                }
86                Ok(CharValue::Uint(n))
87            }
88            CharFormat::Int => {
89                let n = v.as_i64().ok_or_else(|| self.type_err(v))?;
90                Ok(CharValue::Int(n))
91            }
92            CharFormat::Float => {
93                let f = v.as_f64().ok_or_else(|| self.type_err(v))?;
94                Ok(CharValue::Float(f))
95            }
96            CharFormat::String => match v {
97                J::String(s) => Ok(CharValue::Str(s.clone())),
98                other => Err(self.type_err(other)),
99            },
100            CharFormat::Tlv8 | CharFormat::Data => match v {
101                J::String(s) => {
102                    let bytes = B64
103                        .decode(s.as_bytes())
104                        .map_err(|e| ModelError::Base64(e.to_string()))?;
105                    Ok(CharValue::Bytes(bytes))
106                }
107                other => Err(self.type_err(other)),
108            },
109        }
110    }
111
112    fn type_err(self, v: &serde_json::Value) -> ModelError {
113        ModelError::ValueType {
114            format: self.as_str(),
115            detail: format!("got JSON {v}"),
116        }
117    }
118}
119
120/// A decoded characteristic value, collapsed across the integer widths.
121#[derive(Debug, Clone, PartialEq)]
122#[non_exhaustive]
123pub enum CharValue {
124    /// A boolean.
125    Bool(bool),
126    /// A signed integer (`int` format).
127    Int(i64),
128    /// An unsigned integer (`uint8`/`uint16`/`uint32`/`uint64`).
129    Uint(u64),
130    /// A float (`float`).
131    Float(f64),
132    /// A UTF-8 string (`string`).
133    Str(String),
134    /// Opaque bytes (`tlv8` / `data`), base64 on the wire.
135    Bytes(Vec<u8>),
136}
137
138impl CharValue {
139    /// Render this value as the JSON `value` it serializes to on the wire.
140    /// `Bytes` becomes a base64 string; integers/floats become JSON numbers.
141    pub fn to_json(&self) -> serde_json::Value {
142        use serde_json::Value as J;
143        match self {
144            CharValue::Bool(b) => J::Bool(*b),
145            CharValue::Int(n) => J::Number((*n).into()),
146            CharValue::Uint(n) => J::Number((*n).into()),
147            CharValue::Float(f) => serde_json::Number::from_f64(*f).map_or(J::Null, J::Number),
148            CharValue::Str(s) => J::String(s.clone()),
149            CharValue::Bytes(b) => J::String(B64.encode(b)),
150        }
151    }
152}
153
154#[cfg(test)]
155// Test-code carve-out: unwrap allowed with this documented justification.
156#[allow(clippy::unwrap_used)]
157mod tests {
158    use super::*;
159    use serde_json::json;
160
161    #[test]
162    fn bool_accepts_json_bool_and_0_1() {
163        assert_eq!(
164            CharFormat::Bool.value_from_json(&json!(true)).unwrap(),
165            CharValue::Bool(true)
166        );
167        assert_eq!(
168            CharFormat::Bool.value_from_json(&json!(0)).unwrap(),
169            CharValue::Bool(false)
170        );
171        assert_eq!(
172            CharFormat::Bool.value_from_json(&json!(1)).unwrap(),
173            CharValue::Bool(true)
174        );
175        assert!(CharFormat::Bool.value_from_json(&json!("x")).is_err());
176    }
177
178    #[test]
179    fn uint8_rejects_over_255() {
180        assert_eq!(
181            CharFormat::Uint8.value_from_json(&json!(255)).unwrap(),
182            CharValue::Uint(255)
183        );
184        assert!(matches!(
185            CharFormat::Uint8.value_from_json(&json!(256)),
186            Err(ModelError::ValueRange { .. })
187        ));
188    }
189
190    #[test]
191    fn float_accepts_integer_json() {
192        assert_eq!(
193            CharFormat::Float.value_from_json(&json!(3)).unwrap(),
194            CharValue::Float(3.0)
195        );
196        assert_eq!(
197            CharFormat::Float.value_from_json(&json!(0.5)).unwrap(),
198            CharValue::Float(0.5)
199        );
200    }
201
202    #[test]
203    fn tlv8_base64_round_trip() {
204        // base64("\x01\x02\x03") == "AQID"
205        let v = CharFormat::Tlv8.value_from_json(&json!("AQID")).unwrap();
206        assert_eq!(v, CharValue::Bytes(vec![1, 2, 3]));
207        assert_eq!(v.to_json(), json!("AQID"));
208        assert!(matches!(
209            CharFormat::Data.value_from_json(&json!("!!!")),
210            Err(ModelError::Base64(_))
211        ));
212    }
213
214    #[test]
215    fn format_serde_round_trip() {
216        let s = serde_json::to_string(&CharFormat::Uint16).unwrap();
217        assert_eq!(s, "\"uint16\"");
218        let f: CharFormat = serde_json::from_str("\"tlv8\"").unwrap();
219        assert_eq!(f, CharFormat::Tlv8);
220    }
221}