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 explicit_output = flag_symbols(&caps.output);
87        let output = output_symbols(&display, &input, &explicit_output);
88        let links = link_symbols(&caps.transport);
89        let explicit_streams = flag_symbols(&caps.streams);
90        let streams = stream_symbols(&caps.input, &explicit_streams);
91        let rate = RateClass::from_expr(&caps.rate).unwrap_or_else(|_| RateClass::safe_default());
92        Self::new(DeviceProfileParts {
93            kind: Symbol::new(caps.preset_name().to_owned()),
94            display,
95            input,
96            output,
97            links,
98            streams,
99            rate,
100            policy: caps.privacy.clone(),
101        })
102    }
103
104    /// Encodes this profile as a `device/profile` tagged map.
105    pub fn to_expr(&self) -> Expr {
106        build::map(vec![
107            (
108                "kind",
109                Expr::Symbol(Symbol::qualified(
110                    DEVICE_PROFILE_NAMESPACE,
111                    DEVICE_PROFILE_KIND,
112                )),
113            ),
114            ("device-kind", Expr::Symbol(self.kind.clone())),
115            ("tier", Expr::Symbol(self.tier.to_symbol())),
116            ("display", symbol_list(&self.display)),
117            ("input", symbol_list(&self.input)),
118            ("output", symbol_list(&self.output)),
119            ("links", symbol_list(&self.links)),
120            ("streams", symbol_list(&self.streams)),
121            ("rate", self.rate.to_expr()),
122            ("policy", self.policy.clone()),
123        ])
124    }
125
126    /// Parses a `device/profile` tagged map.
127    pub fn from_expr(expr: &Expr) -> Result<Self, DeviceProfileError> {
128        let Expr::Map(entries) = expr else {
129            return Err(DeviceProfileError::NotProfile);
130        };
131        match access::entry_field(entries, "kind") {
132            Some(Expr::Symbol(kind))
133                if kind.namespace.as_deref() == Some(DEVICE_PROFILE_NAMESPACE)
134                    && kind.name.as_ref() == DEVICE_PROFILE_KIND => {}
135            _ => return Err(DeviceProfileError::NotProfile),
136        }
137        let kind = match access::entry_field(entries, "device-kind") {
138            Some(Expr::Symbol(symbol)) => symbol.clone(),
139            Some(_) => return Err(DeviceProfileError::BadField("device-kind")),
140            None => return Err(DeviceProfileError::MissingField("device-kind")),
141        };
142        let tier = match access::entry_field(entries, "tier") {
143            Some(Expr::Symbol(symbol)) => {
144                DeviceTier::from_symbol(symbol).ok_or(DeviceProfileError::BadField("tier"))?
145            }
146            Some(_) => return Err(DeviceProfileError::BadField("tier")),
147            None => return Err(DeviceProfileError::MissingField("tier")),
148        };
149        let display = symbol_vec(entries, "display")?;
150        let input = symbol_vec(entries, "input")?;
151        let output = symbol_vec(entries, "output")?;
152        let links = symbol_vec(entries, "links")?;
153        let streams = symbol_vec(entries, "streams")?;
154        let rate = match access::entry_field(entries, "rate") {
155            Some(value) => RateClass::from_expr(value).map_err(DeviceProfileError::Rate)?,
156            None => return Err(DeviceProfileError::MissingField("rate")),
157        };
158        let policy = match access::entry_field(entries, "policy") {
159            Some(value) => value.clone(),
160            None => return Err(DeviceProfileError::MissingField("policy")),
161        };
162        let profile = Self {
163            kind,
164            tier,
165            display: dedup_symbols(display),
166            input: dedup_symbols(input),
167            output: dedup_symbols(output),
168            links: dedup_symbols(links),
169            streams: dedup_symbols(streams),
170            rate,
171            policy,
172        };
173        let derived = derive_tier(&profile);
174        if tier != derived {
175            return Err(DeviceProfileError::TierMismatch {
176                declared: tier,
177                derived,
178            });
179        }
180        Ok(profile)
181    }
182}
183
184/// Extension methods that view [`SurfaceCaps`] as a device profile.
185pub trait DeviceSurfaceCapsExt {
186    /// Builds the typed profile for these surface capabilities.
187    fn device_profile(&self) -> DeviceProfile;
188
189    /// Reads the timing envelope, using the safe default when missing or
190    /// malformed.
191    fn device_rate(&self) -> RateClass;
192}
193
194impl DeviceSurfaceCapsExt for SurfaceCaps {
195    fn device_profile(&self) -> DeviceProfile {
196        DeviceProfile::from_surface_caps(self)
197    }
198
199    fn device_rate(&self) -> RateClass {
200        RateClass::from_expr(&self.rate).unwrap_or_else(|_| RateClass::safe_default())
201    }
202}
203
204/// Glasses-specific adapter path selected from an already-derived profile.
205#[derive(Clone, Copy, Debug, PartialEq, Eq)]
206pub enum GlassesClass {
207    /// A mono HUD edge such as the Halo.
208    MonoHud,
209    /// A stereo, pose-coupled edge such as the Luma Ultra.
210    Stereo6Dof,
211    /// A mirror/display-only pair with no local spatial adapter.
212    DisplayOnly,
213}
214
215/// Selects the glasses encoder/adapter path without deriving the device tier.
216///
217/// [`derive_tier`] remains the single tier source; this helper only lets
218/// glasses-specific code choose between mono HUD, stereo 6DoF, and mirror paths.
219pub fn glasses_class(profile: &DeviceProfile) -> Option<GlassesClass> {
220    if !is_glasses_profile(profile) {
221        return None;
222    }
223    if has_symbol(&profile.display, "stereo") && has_symbol(&profile.streams, "pose") {
224        Some(GlassesClass::Stereo6Dof)
225    } else if has_symbol(&profile.display, "hud")
226        || (has_symbol(&profile.display, "mono") && has_symbol(&profile.output, "hud"))
227    {
228        Some(GlassesClass::MonoHud)
229    } else {
230        Some(GlassesClass::DisplayOnly)
231    }
232}
233
234/// The single authoritative tier derivation function.
235pub fn derive_tier(profile: &DeviceProfile) -> DeviceTier {
236    if has_symbol(&profile.display, "stereo") && has_symbol(&profile.streams, "pose") {
237        DeviceTier::Rich
238    } else if has_any(
239        &profile.output,
240        &["haptic", "face", "hud", "speaker", "tone"],
241    ) {
242        DeviceTier::Actuator
243    } else if !profile.streams.is_empty() {
244        DeviceTier::Sensor
245    } else {
246        DeviceTier::Display
247    }
248}
249
250/// Returns a compact preset profile for the requested tier.
251pub fn tier_preset(tier: DeviceTier) -> DeviceProfile {
252    match tier {
253        DeviceTier::Display => DeviceProfile::new(DeviceProfileParts {
254            kind: Symbol::new("display"),
255            display: symbols(&["flat"]),
256            input: Vec::new(),
257            output: symbols(&["screen"]),
258            links: symbols(&["usb"]),
259            streams: Vec::new(),
260            rate: RateClass::safe_default(),
261            policy: build::map(Vec::new()),
262        }),
263        DeviceTier::Sensor => DeviceProfile::new(DeviceProfileParts {
264            kind: Symbol::new("sensor"),
265            display: symbols(&["flat"]),
266            input: Vec::new(),
267            output: Vec::new(),
268            links: symbols(&["bluetooth"]),
269            streams: symbols(&["motion"]),
270            rate: RateClass::watch(),
271            policy: build::map(Vec::new()),
272        }),
273        DeviceTier::Actuator => DeviceProfile::new(DeviceProfileParts {
274            kind: Symbol::new("actuator"),
275            display: symbols(&["round"]),
276            input: symbols(&["tap"]),
277            output: symbols(&["haptic"]),
278            links: symbols(&["phone-relay"]),
279            streams: symbols(&["battery"]),
280            rate: RateClass::watch(),
281            policy: build::map(Vec::new()),
282        }),
283        DeviceTier::Rich => DeviceProfile::new(DeviceProfileParts {
284            kind: Symbol::new("rich"),
285            display: symbols(&["stereo", "hud"]),
286            input: symbols(&["voice"]),
287            output: symbols(&["hud", "speaker"]),
288            links: symbols(&["lan-ws"]),
289            streams: symbols(&["pose", "motion"]),
290            rate: RateClass::stereo(),
291            policy: build::map(Vec::new()),
292        }),
293    }
294}
295
296/// A small expression used by the embedded recipe.
297pub fn device_profile_demo() -> Expr {
298    tier_preset(DeviceTier::Rich).to_expr()
299}
300
301/// A reason a device profile could not be parsed.
302#[derive(Clone, Debug, PartialEq, Eq)]
303pub enum DeviceProfileError {
304    /// The value was not a `device/profile` tagged map.
305    NotProfile,
306    /// A required field was missing.
307    MissingField(&'static str),
308    /// A field carried the wrong value shape.
309    BadField(&'static str),
310    /// The rate map was malformed.
311    Rate(RateError),
312    /// The declared tier disagreed with [`derive_tier`].
313    TierMismatch {
314        /// The tier declared in the profile map.
315        declared: DeviceTier,
316        /// The tier derived from the capability fields.
317        derived: DeviceTier,
318    },
319}
320
321impl core::fmt::Display for DeviceProfileError {
322    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
323        match self {
324            DeviceProfileError::NotProfile => write!(f, "value is not a device/profile map"),
325            DeviceProfileError::MissingField(name) => {
326                write!(f, "device profile missing field: {name}")
327            }
328            DeviceProfileError::BadField(name) => {
329                write!(f, "device profile field has wrong shape: {name}")
330            }
331            DeviceProfileError::Rate(err) => write!(f, "device profile rate: {err}"),
332            DeviceProfileError::TierMismatch { declared, derived } => write!(
333                f,
334                "device profile tier mismatch: declared {}, derived {}",
335                declared.token(),
336                derived.token()
337            ),
338        }
339    }
340}
341
342impl std::error::Error for DeviceProfileError {}
343
344fn symbol_vec(
345    entries: &[(Expr, Expr)],
346    name: &'static str,
347) -> Result<Vec<Symbol>, DeviceProfileError> {
348    match access::entry_field(entries, name) {
349        Some(Expr::List(items)) => items
350            .iter()
351            .map(|item| match item {
352                Expr::Symbol(symbol) => Ok(symbol.clone()),
353                _ => Err(DeviceProfileError::BadField(name)),
354            })
355            .collect(),
356        Some(_) => Err(DeviceProfileError::BadField(name)),
357        None => Err(DeviceProfileError::MissingField(name)),
358    }
359}
360
361fn symbol_list(symbols: &[Symbol]) -> Expr {
362    build::list(symbols.iter().cloned().map(Expr::Symbol).collect())
363}
364
365fn display_symbols(display: &Expr) -> Vec<Symbol> {
366    let mut out = Vec::new();
367    push_symbol_field(&mut out, display, "display");
368    if matches!(access::field(display, "stereo"), Some(Expr::Bool(true))) {
369        push_symbol(&mut out, "stereo");
370    }
371    if matches!(access::field(display, "mono"), Some(Expr::Bool(true))) {
372        push_symbol(&mut out, "mono");
373    }
374    if matches!(access::field(display, "none"), Some(Expr::Bool(true))) {
375        push_symbol(&mut out, "none");
376    }
377    if access::field(display, "lines").is_some() {
378        push_symbol(&mut out, "hud");
379    }
380    push_symbol_field(&mut out, display, "shape");
381    push_symbol_field(&mut out, display, "density");
382    push_symbol_field(&mut out, display, "tracking-class");
383    push_symbol_list_field(&mut out, display, "anchor-spaces");
384    if out.is_empty() && matches!(display, Expr::Map(entries) if !entries.is_empty()) {
385        push_symbol(&mut out, "flat");
386    }
387    out
388}
389
390fn flag_symbols(map: &Expr) -> Vec<Symbol> {
391    let Expr::Map(entries) = map else {
392        return Vec::new();
393    };
394    let mut out = Vec::new();
395    for (key, value) in entries {
396        if !matches!(value, Expr::Bool(true)) {
397            continue;
398        }
399        if let Expr::Symbol(symbol) = key
400            && symbol.namespace.is_none()
401        {
402            push_existing(&mut out, symbol.clone());
403        }
404    }
405    out
406}
407
408fn output_symbols(display: &[Symbol], input: &[Symbol], explicit: &[Symbol]) -> Vec<Symbol> {
409    let mut out = explicit.to_vec();
410    if !has_symbol(display, "none") && !display.is_empty() {
411        push_symbol(&mut out, "screen");
412    }
413    if has_symbol(display, "hud") {
414        push_symbol(&mut out, "hud");
415    }
416    if has_symbol(input, "haptic-ack") {
417        push_symbol(&mut out, "haptic");
418    }
419    out
420}
421
422fn link_symbols(transport: &Expr) -> Vec<Symbol> {
423    let mut out = Vec::new();
424    if let Some(Expr::List(items)) = access::field(transport, "links") {
425        for item in items {
426            if let Expr::Symbol(symbol) = item {
427                push_existing(&mut out, symbol.clone());
428            }
429        }
430    }
431    match access::field(transport, "kind") {
432        Some(Expr::Symbol(kind)) if kind.name.as_ref() == "relay" => {
433            push_symbol(&mut out, "phone-relay");
434        }
435        Some(Expr::Symbol(kind)) if kind.name.as_ref() == "websocket" => {
436            push_symbol(&mut out, "lan-ws");
437        }
438        Some(Expr::Symbol(kind)) if matches!(kind.name.as_ref(), "tty" | "local") => {
439            push_symbol(&mut out, "usb");
440        }
441        Some(Expr::Symbol(kind)) => push_existing(&mut out, kind.clone()),
442        _ => {}
443    }
444    out
445}
446
447fn stream_symbols(input: &Expr, explicit: &[Symbol]) -> Vec<Symbol> {
448    let mut out = explicit.to_vec();
449    if matches!(access::field(input, "camera"), Some(Expr::Bool(true))) {
450        push_symbol(&mut out, "motion");
451    }
452    out
453}
454
455fn push_symbol_field(out: &mut Vec<Symbol>, map: &Expr, name: &str) {
456    if let Some(Expr::Symbol(symbol)) = access::field(map, name) {
457        push_existing(out, symbol.clone());
458    }
459}
460
461fn push_symbol_list_field(out: &mut Vec<Symbol>, map: &Expr, name: &str) {
462    if let Some(Expr::List(items)) = access::field(map, name) {
463        for item in items {
464            if let Expr::Symbol(symbol) = item {
465                push_existing(out, symbol.clone());
466            }
467        }
468    }
469}
470
471fn is_glasses_profile(profile: &DeviceProfile) -> bool {
472    let name = profile.kind.name.as_ref();
473    name == "glasses" || name.starts_with("glasses-")
474}
475
476fn symbols(names: &[&str]) -> Vec<Symbol> {
477    names.iter().map(|name| Symbol::new(*name)).collect()
478}
479
480pub(crate) fn has_symbol(symbols: &[Symbol], name: &str) -> bool {
481    symbols
482        .iter()
483        .any(|symbol| symbol.namespace.is_none() && symbol.name.as_ref() == name)
484}
485
486pub(crate) fn has_any(symbols: &[Symbol], names: &[&str]) -> bool {
487    names.iter().any(|name| has_symbol(symbols, name))
488}
489
490pub(crate) fn push_symbol(out: &mut Vec<Symbol>, name: &str) {
491    push_existing(out, Symbol::new(name.to_owned()));
492}
493
494pub(crate) fn push_existing(out: &mut Vec<Symbol>, symbol: Symbol) {
495    if !out.iter().any(|existing| existing == &symbol) {
496        out.push(symbol);
497    }
498}
499
500fn dedup_symbols(symbols: Vec<Symbol>) -> Vec<Symbol> {
501    let mut out = Vec::new();
502    for symbol in symbols {
503        push_existing(&mut out, symbol);
504    }
505    out
506}