Skip to main content

sim_lib_view_device/
profile.rs

1//! Typed device profiles over open surface capability maps.
2
3use sim_kernel::{Expr, Symbol};
4use sim_lib_view::SurfaceCaps;
5use sim_value::{access, build};
6
7use crate::ladder::DeviceTier;
8use crate::rate::{RateClass, RateError};
9
10/// The namespace for serialized device profile records.
11pub const DEVICE_PROFILE_NAMESPACE: &str = "device";
12
13/// The `kind` tag of a serialized [`DeviceProfile`] map.
14pub const DEVICE_PROFILE_KIND: &str = "profile";
15
16/// A typed device envelope derived from [`SurfaceCaps`].
17///
18/// This is capability data, not a concrete device enum. Instances advertise
19/// maps; this profile reads them into stable lanes so routing and degradation
20/// decisions share one interpretation.
21#[derive(Clone, Debug, PartialEq)]
22pub struct DeviceProfile {
23    /// Device family token, for example `watch`, `glasses`, or `desktop`.
24    pub kind: Symbol,
25    /// Tier computed by [`derive_tier`].
26    pub tier: DeviceTier,
27    /// Display capability tokens such as `flat`, `stereo`, `round`, or `hud`.
28    pub display: Vec<Symbol>,
29    /// Input capability tokens such as `touch`, `tap`, `voice`, or `crown`.
30    pub input: Vec<Symbol>,
31    /// Output capability tokens such as `screen`, `haptic`, `speaker`, or `hud`.
32    pub output: Vec<Symbol>,
33    /// Link tokens such as `usb`, `phone-relay`, or `lan-ws`.
34    pub links: Vec<Symbol>,
35    /// Stream tokens such as `pose`, `heart-rate`, `motion`, or `battery`.
36    pub streams: Vec<Symbol>,
37    /// Declared timing envelope.
38    pub rate: RateClass,
39    /// Consent, retention, and redaction hints.
40    pub policy: Expr,
41}
42
43/// Field bundle for constructing a [`DeviceProfile`].
44#[derive(Clone, Debug, PartialEq)]
45pub struct DeviceProfileParts {
46    /// Device family token, for example `watch`, `glasses`, or `desktop`.
47    pub kind: Symbol,
48    /// Display capability tokens.
49    pub display: Vec<Symbol>,
50    /// Input capability tokens.
51    pub input: Vec<Symbol>,
52    /// Output capability tokens.
53    pub output: Vec<Symbol>,
54    /// Link tokens.
55    pub links: Vec<Symbol>,
56    /// Stream tokens.
57    pub streams: Vec<Symbol>,
58    /// Declared timing envelope.
59    pub rate: RateClass,
60    /// Consent, retention, and redaction hints.
61    pub policy: Expr,
62}
63
64impl DeviceProfile {
65    /// Builds a profile and computes the tier from the advertised fields.
66    pub fn new(parts: DeviceProfileParts) -> Self {
67        let mut profile = Self {
68            kind: parts.kind,
69            tier: DeviceTier::Display,
70            display: dedup_symbols(parts.display),
71            input: dedup_symbols(parts.input),
72            output: dedup_symbols(parts.output),
73            links: dedup_symbols(parts.links),
74            streams: dedup_symbols(parts.streams),
75            rate: parts.rate,
76            policy: parts.policy,
77        };
78        profile.tier = derive_tier(&profile);
79        profile
80    }
81
82    /// Derives a device profile from open [`SurfaceCaps`] metadata.
83    pub fn from_surface_caps(caps: &SurfaceCaps) -> Self {
84        let display = display_symbols(&caps.display);
85        let input = flag_symbols(&caps.input);
86        let output = output_symbols(&display, &input);
87        let links = link_symbols(&caps.transport);
88        let streams = stream_symbols(&caps.input);
89        let rate = RateClass::from_expr(&caps.rate).unwrap_or_else(|_| RateClass::safe_default());
90        Self::new(DeviceProfileParts {
91            kind: Symbol::new(caps.preset_name().to_owned()),
92            display,
93            input,
94            output,
95            links,
96            streams,
97            rate,
98            policy: caps.privacy.clone(),
99        })
100    }
101
102    /// Encodes this profile as a `device/profile` tagged map.
103    pub fn to_expr(&self) -> Expr {
104        build::map(vec![
105            (
106                "kind",
107                Expr::Symbol(Symbol::qualified(
108                    DEVICE_PROFILE_NAMESPACE,
109                    DEVICE_PROFILE_KIND,
110                )),
111            ),
112            ("device-kind", Expr::Symbol(self.kind.clone())),
113            ("tier", Expr::Symbol(self.tier.to_symbol())),
114            ("display", symbol_list(&self.display)),
115            ("input", symbol_list(&self.input)),
116            ("output", symbol_list(&self.output)),
117            ("links", symbol_list(&self.links)),
118            ("streams", symbol_list(&self.streams)),
119            ("rate", self.rate.to_expr()),
120            ("policy", self.policy.clone()),
121        ])
122    }
123
124    /// Parses a `device/profile` tagged map.
125    pub fn from_expr(expr: &Expr) -> Result<Self, DeviceProfileError> {
126        let Expr::Map(entries) = expr else {
127            return Err(DeviceProfileError::NotProfile);
128        };
129        match access::entry_field(entries, "kind") {
130            Some(Expr::Symbol(kind))
131                if kind.namespace.as_deref() == Some(DEVICE_PROFILE_NAMESPACE)
132                    && kind.name.as_ref() == DEVICE_PROFILE_KIND => {}
133            _ => return Err(DeviceProfileError::NotProfile),
134        }
135        let kind = match access::entry_field(entries, "device-kind") {
136            Some(Expr::Symbol(symbol)) => symbol.clone(),
137            Some(_) => return Err(DeviceProfileError::BadField("device-kind")),
138            None => return Err(DeviceProfileError::MissingField("device-kind")),
139        };
140        let tier = match access::entry_field(entries, "tier") {
141            Some(Expr::Symbol(symbol)) => {
142                DeviceTier::from_symbol(symbol).ok_or(DeviceProfileError::BadField("tier"))?
143            }
144            Some(_) => return Err(DeviceProfileError::BadField("tier")),
145            None => return Err(DeviceProfileError::MissingField("tier")),
146        };
147        let display = symbol_vec(entries, "display")?;
148        let input = symbol_vec(entries, "input")?;
149        let output = symbol_vec(entries, "output")?;
150        let links = symbol_vec(entries, "links")?;
151        let streams = symbol_vec(entries, "streams")?;
152        let rate = match access::entry_field(entries, "rate") {
153            Some(value) => RateClass::from_expr(value).map_err(DeviceProfileError::Rate)?,
154            None => return Err(DeviceProfileError::MissingField("rate")),
155        };
156        let policy = match access::entry_field(entries, "policy") {
157            Some(value) => value.clone(),
158            None => return Err(DeviceProfileError::MissingField("policy")),
159        };
160        let profile = Self {
161            kind,
162            tier,
163            display: dedup_symbols(display),
164            input: dedup_symbols(input),
165            output: dedup_symbols(output),
166            links: dedup_symbols(links),
167            streams: dedup_symbols(streams),
168            rate,
169            policy,
170        };
171        let derived = derive_tier(&profile);
172        if tier != derived {
173            return Err(DeviceProfileError::TierMismatch {
174                declared: tier,
175                derived,
176            });
177        }
178        Ok(profile)
179    }
180}
181
182/// Extension methods that view [`SurfaceCaps`] as a device profile.
183pub trait DeviceSurfaceCapsExt {
184    /// Builds the typed profile for these surface capabilities.
185    fn device_profile(&self) -> DeviceProfile;
186
187    /// Reads the timing envelope, using the safe default when missing or
188    /// malformed.
189    fn device_rate(&self) -> RateClass;
190}
191
192impl DeviceSurfaceCapsExt for SurfaceCaps {
193    fn device_profile(&self) -> DeviceProfile {
194        DeviceProfile::from_surface_caps(self)
195    }
196
197    fn device_rate(&self) -> RateClass {
198        RateClass::from_expr(&self.rate).unwrap_or_else(|_| RateClass::safe_default())
199    }
200}
201
202/// The single authoritative tier derivation function.
203pub fn derive_tier(profile: &DeviceProfile) -> DeviceTier {
204    if has_symbol(&profile.display, "stereo") && has_symbol(&profile.streams, "pose") {
205        DeviceTier::Rich
206    } else if has_any(
207        &profile.output,
208        &["haptic", "face", "hud", "speaker", "tone"],
209    ) {
210        DeviceTier::Actuator
211    } else if !profile.streams.is_empty() {
212        DeviceTier::Sensor
213    } else {
214        DeviceTier::Display
215    }
216}
217
218/// Returns a compact preset profile for the requested tier.
219pub fn tier_preset(tier: DeviceTier) -> DeviceProfile {
220    match tier {
221        DeviceTier::Display => DeviceProfile::new(DeviceProfileParts {
222            kind: Symbol::new("display"),
223            display: symbols(&["flat"]),
224            input: Vec::new(),
225            output: symbols(&["screen"]),
226            links: symbols(&["usb"]),
227            streams: Vec::new(),
228            rate: RateClass::safe_default(),
229            policy: build::map(Vec::new()),
230        }),
231        DeviceTier::Sensor => DeviceProfile::new(DeviceProfileParts {
232            kind: Symbol::new("sensor"),
233            display: symbols(&["flat"]),
234            input: Vec::new(),
235            output: Vec::new(),
236            links: symbols(&["bluetooth"]),
237            streams: symbols(&["motion"]),
238            rate: RateClass::watch(),
239            policy: build::map(Vec::new()),
240        }),
241        DeviceTier::Actuator => DeviceProfile::new(DeviceProfileParts {
242            kind: Symbol::new("actuator"),
243            display: symbols(&["round"]),
244            input: symbols(&["tap"]),
245            output: symbols(&["haptic"]),
246            links: symbols(&["phone-relay"]),
247            streams: symbols(&["battery"]),
248            rate: RateClass::watch(),
249            policy: build::map(Vec::new()),
250        }),
251        DeviceTier::Rich => DeviceProfile::new(DeviceProfileParts {
252            kind: Symbol::new("rich"),
253            display: symbols(&["stereo", "hud"]),
254            input: symbols(&["voice"]),
255            output: symbols(&["hud", "speaker"]),
256            links: symbols(&["lan-ws"]),
257            streams: symbols(&["pose", "motion"]),
258            rate: RateClass::stereo(),
259            policy: build::map(Vec::new()),
260        }),
261    }
262}
263
264/// A small expression used by the embedded recipe.
265pub fn device_profile_demo() -> Expr {
266    tier_preset(DeviceTier::Rich).to_expr()
267}
268
269/// A reason a device profile could not be parsed.
270#[derive(Clone, Debug, PartialEq, Eq)]
271pub enum DeviceProfileError {
272    /// The value was not a `device/profile` tagged map.
273    NotProfile,
274    /// A required field was missing.
275    MissingField(&'static str),
276    /// A field carried the wrong value shape.
277    BadField(&'static str),
278    /// The rate map was malformed.
279    Rate(RateError),
280    /// The declared tier disagreed with [`derive_tier`].
281    TierMismatch {
282        /// The tier declared in the profile map.
283        declared: DeviceTier,
284        /// The tier derived from the capability fields.
285        derived: DeviceTier,
286    },
287}
288
289impl core::fmt::Display for DeviceProfileError {
290    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
291        match self {
292            DeviceProfileError::NotProfile => write!(f, "value is not a device/profile map"),
293            DeviceProfileError::MissingField(name) => {
294                write!(f, "device profile missing field: {name}")
295            }
296            DeviceProfileError::BadField(name) => {
297                write!(f, "device profile field has wrong shape: {name}")
298            }
299            DeviceProfileError::Rate(err) => write!(f, "device profile rate: {err}"),
300            DeviceProfileError::TierMismatch { declared, derived } => write!(
301                f,
302                "device profile tier mismatch: declared {}, derived {}",
303                declared.token(),
304                derived.token()
305            ),
306        }
307    }
308}
309
310impl std::error::Error for DeviceProfileError {}
311
312fn symbol_vec(
313    entries: &[(Expr, Expr)],
314    name: &'static str,
315) -> Result<Vec<Symbol>, DeviceProfileError> {
316    match access::entry_field(entries, name) {
317        Some(Expr::List(items)) => items
318            .iter()
319            .map(|item| match item {
320                Expr::Symbol(symbol) => Ok(symbol.clone()),
321                _ => Err(DeviceProfileError::BadField(name)),
322            })
323            .collect(),
324        Some(_) => Err(DeviceProfileError::BadField(name)),
325        None => Err(DeviceProfileError::MissingField(name)),
326    }
327}
328
329fn symbol_list(symbols: &[Symbol]) -> Expr {
330    build::list(symbols.iter().cloned().map(Expr::Symbol).collect())
331}
332
333fn display_symbols(display: &Expr) -> Vec<Symbol> {
334    let mut out = Vec::new();
335    if matches!(access::field(display, "stereo"), Some(Expr::Bool(true))) {
336        push_symbol(&mut out, "stereo");
337    }
338    if access::field(display, "lines").is_some() {
339        push_symbol(&mut out, "hud");
340    }
341    push_symbol_field(&mut out, display, "shape");
342    push_symbol_field(&mut out, display, "density");
343    if out.is_empty() && matches!(display, Expr::Map(entries) if !entries.is_empty()) {
344        push_symbol(&mut out, "flat");
345    }
346    out
347}
348
349fn flag_symbols(map: &Expr) -> Vec<Symbol> {
350    let Expr::Map(entries) = map else {
351        return Vec::new();
352    };
353    let mut out = Vec::new();
354    for (key, value) in entries {
355        if !matches!(value, Expr::Bool(true)) {
356            continue;
357        }
358        if let Expr::Symbol(symbol) = key
359            && symbol.namespace.is_none()
360        {
361            push_existing(&mut out, symbol.clone());
362        }
363    }
364    out
365}
366
367fn output_symbols(display: &[Symbol], input: &[Symbol]) -> Vec<Symbol> {
368    let mut out = Vec::new();
369    if !has_symbol(display, "none") && !display.is_empty() {
370        push_symbol(&mut out, "screen");
371    }
372    if has_symbol(display, "hud") || has_symbol(display, "stereo") {
373        push_symbol(&mut out, "hud");
374    }
375    if has_symbol(input, "haptic-ack") {
376        push_symbol(&mut out, "haptic");
377    }
378    out
379}
380
381fn link_symbols(transport: &Expr) -> Vec<Symbol> {
382    let mut out = Vec::new();
383    match access::field(transport, "kind") {
384        Some(Expr::Symbol(kind)) if kind.name.as_ref() == "relay" => {
385            push_symbol(&mut out, "phone-relay");
386        }
387        Some(Expr::Symbol(kind)) if kind.name.as_ref() == "websocket" => {
388            push_symbol(&mut out, "lan-ws");
389        }
390        Some(Expr::Symbol(kind)) if matches!(kind.name.as_ref(), "tty" | "local") => {
391            push_symbol(&mut out, "usb");
392        }
393        Some(Expr::Symbol(kind)) => push_existing(&mut out, kind.clone()),
394        _ => {}
395    }
396    out
397}
398
399fn stream_symbols(input: &Expr) -> Vec<Symbol> {
400    let mut out = Vec::new();
401    if matches!(access::field(input, "camera"), Some(Expr::Bool(true))) {
402        push_symbol(&mut out, "motion");
403    }
404    out
405}
406
407fn push_symbol_field(out: &mut Vec<Symbol>, map: &Expr, name: &str) {
408    if let Some(Expr::Symbol(symbol)) = access::field(map, name) {
409        push_existing(out, symbol.clone());
410    }
411}
412
413fn symbols(names: &[&str]) -> Vec<Symbol> {
414    names.iter().map(|name| Symbol::new(*name)).collect()
415}
416
417pub(crate) fn has_symbol(symbols: &[Symbol], name: &str) -> bool {
418    symbols
419        .iter()
420        .any(|symbol| symbol.namespace.is_none() && symbol.name.as_ref() == name)
421}
422
423pub(crate) fn has_any(symbols: &[Symbol], names: &[&str]) -> bool {
424    names.iter().any(|name| has_symbol(symbols, name))
425}
426
427pub(crate) fn push_symbol(out: &mut Vec<Symbol>, name: &str) {
428    push_existing(out, Symbol::new(name.to_owned()));
429}
430
431pub(crate) fn push_existing(out: &mut Vec<Symbol>, symbol: Symbol) {
432    if !out.iter().any(|existing| existing == &symbol) {
433        out.push(symbol);
434    }
435}
436
437fn dedup_symbols(symbols: Vec<Symbol>) -> Vec<Symbol> {
438    let mut out = Vec::new();
439    for symbol in symbols {
440        push_existing(&mut out, symbol);
441    }
442    out
443}