Skip to main content

sim_lib_server/
device_edge.rs

1//! Route-transparent device edge sessions.
2
3use std::collections::VecDeque;
4
5use sim_kernel::{Error, EventKind, EventLedger, Expr, Ref, Result, Symbol};
6
7/// Stable reference to the ledger owned by a device edge session.
8pub type LedgerRef = Ref;
9
10/// Transport route currently carrying a device edge session.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub enum LinkKind {
13    /// Direct local link.
14    Direct,
15    /// Relayed link through another host.
16    Relay,
17    /// Host-local in-process link.
18    Local,
19    /// Any other route token carried as open data.
20    Custom(Symbol),
21}
22
23impl LinkKind {
24    /// Builds the preferred CI-safe import route.
25    pub fn import() -> Self {
26        Self::Custom(link_symbol("import"))
27    }
28
29    /// Builds the preferred CI-safe modeled route.
30    pub fn modeled() -> Self {
31        Self::Custom(link_symbol("modeled"))
32    }
33
34    /// Builds the standard BLE host route.
35    pub fn ble_host() -> Self {
36        Self::Custom(link_symbol("ble-host"))
37    }
38
39    /// Builds the phone relay route.
40    pub const fn relay() -> Self {
41        Self::Relay
42    }
43
44    /// Builds the Zepp Mini Program bridge route.
45    pub fn zepp_bridge() -> Self {
46        Self::Custom(link_symbol("zepp-bridge"))
47    }
48
49    /// Builds the Wi-Fi route that may only be used after bring-up verification.
50    pub fn wifi_if_verified() -> Self {
51        Self::Custom(link_symbol("wifi-if-verified"))
52    }
53
54    /// Returns the stable route symbol.
55    pub fn as_symbol(&self) -> Symbol {
56        match self {
57            Self::Direct => link_symbol("direct"),
58            Self::Relay => link_symbol("relay"),
59            Self::Local => link_symbol("local"),
60            Self::Custom(symbol) => symbol.clone(),
61        }
62    }
63
64    /// Builds a route kind from a symbol.
65    pub fn from_symbol(symbol: Symbol) -> Self {
66        if symbol.namespace.as_deref() == Some("device/link") {
67            match symbol.name.as_ref() {
68                "direct" => Self::Direct,
69                "relay" => Self::Relay,
70                "local" => Self::Local,
71                _ => Self::Custom(symbol),
72            }
73        } else {
74            Self::Custom(symbol)
75        }
76    }
77
78    /// Encodes this route as expression data.
79    pub fn to_expr(&self) -> Expr {
80        Expr::Symbol(self.as_symbol())
81    }
82}
83
84/// Device profile and provider metadata bound to an edge session.
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct DeviceEdgeProfile {
87    device: Symbol,
88    provider: Symbol,
89}
90
91impl DeviceEdgeProfile {
92    /// Builds edge-session profile metadata.
93    pub fn new(device: Symbol, provider: Symbol) -> Self {
94        Self { device, provider }
95    }
96
97    /// Returns the stable device symbol.
98    pub fn device(&self) -> &Symbol {
99        &self.device
100    }
101
102    /// Returns the provider symbol that supplies the session.
103    pub fn provider(&self) -> &Symbol {
104        &self.provider
105    }
106
107    /// Encodes the profile metadata as expression data.
108    pub fn to_expr(&self) -> Expr {
109        sim_value::build::map(vec![
110            ("device", Expr::Symbol(self.device.clone())),
111            ("provider", Expr::Symbol(self.provider.clone())),
112        ])
113    }
114}
115
116/// A device edge session whose identity survives transport route changes.
117///
118/// The session owns a stable id, a stable ledger reference, a mutable current
119/// link, an optional device profile binding, an event ledger, a route order, a
120/// command queue, and optional visible consent data. Rebinding changes only the
121/// route; the id, ledger reference, event history, queued commands, profile, and
122/// bound consent remain attached to the same session.
123#[derive(Clone, Debug)]
124pub struct DeviceEdgeSession {
125    id: Symbol,
126    link: LinkKind,
127    ledger: LedgerRef,
128    profile: Option<DeviceEdgeProfile>,
129    route_order: Vec<LinkKind>,
130    queued_commands: VecDeque<Expr>,
131    flushed_commands: Vec<Expr>,
132    events: EventLedger,
133    consent: Option<Expr>,
134}
135
136impl DeviceEdgeSession {
137    /// Creates a device edge session using the id as the ledger reference.
138    pub fn new(id: Symbol, link: LinkKind) -> Result<Self> {
139        let ledger = Ref::Symbol(id.clone());
140        Self::with_ledger(id, link, ledger)
141    }
142
143    /// Creates a device edge session with an explicit ledger reference.
144    pub fn with_ledger(id: Symbol, link: LinkKind, ledger: LedgerRef) -> Result<Self> {
145        let mut session = Self {
146            id,
147            link,
148            ledger,
149            profile: None,
150            route_order: Vec::new(),
151            queued_commands: VecDeque::new(),
152            flushed_commands: Vec::new(),
153            events: EventLedger::new(),
154            consent: None,
155        };
156        session.record_event("open")?;
157        Ok(session)
158    }
159
160    /// Returns the stable session id.
161    pub fn id(&self) -> &Symbol {
162        &self.id
163    }
164
165    /// Returns the current route carrying this session.
166    pub fn link(&self) -> &LinkKind {
167        &self.link
168    }
169
170    /// Returns the stable ledger reference.
171    pub fn ledger_ref(&self) -> &LedgerRef {
172        &self.ledger
173    }
174
175    /// Returns the profile bound to this session, if one was registered.
176    pub fn device_profile(&self) -> Option<&DeviceEdgeProfile> {
177        self.profile.as_ref()
178    }
179
180    /// Binds provider/profile metadata to the session.
181    pub fn bind_profile(&mut self, profile: DeviceEdgeProfile) -> Result<()> {
182        self.profile = Some(profile);
183        self.record_event("profile-bound")
184    }
185
186    /// Returns the preferred route order for this session.
187    pub fn route_order(&self) -> &[LinkKind] {
188        &self.route_order
189    }
190
191    /// Replaces the preferred route order.
192    pub fn set_route_order(&mut self, route_order: Vec<LinkKind>) -> Result<()> {
193        if route_order.is_empty() {
194            return Err(Error::HostError(
195                "device edge route order cannot be empty".to_owned(),
196            ));
197        }
198        self.route_order = route_order;
199        self.record_event("route-order")
200    }
201
202    /// Queues an actuator command while the current route is unavailable.
203    pub fn queue_command(&mut self, command: Expr) -> Result<()> {
204        self.queued_commands.push_back(command);
205        self.record_event("command-queued")
206    }
207
208    /// Returns the number of queued commands waiting for a route.
209    pub fn queued_command_count(&self) -> usize {
210        self.queued_commands.len()
211    }
212
213    /// Returns the commands flushed after route reconnects.
214    pub fn flushed_commands(&self) -> &[Expr] {
215        &self.flushed_commands
216    }
217
218    /// Flushes queued actuator commands after a route reconnects.
219    pub fn flush_queued_commands(&mut self) -> Result<Vec<Expr>> {
220        let flushed = self.queued_commands.drain(..).collect::<Vec<_>>();
221        if !flushed.is_empty() {
222            self.flushed_commands.extend(flushed.clone());
223            self.record_event("command-flushed")?;
224        }
225        Ok(flushed)
226    }
227
228    /// Returns the session event ledger.
229    pub fn event_ledger(&self) -> &EventLedger {
230        &self.events
231    }
232
233    /// Returns the visible consent data bound to the session, if present.
234    pub fn bound_consent(&self) -> Option<&Expr> {
235        self.consent.as_ref()
236    }
237
238    /// Returns the session symbol embedded in the bound consent data, if any.
239    pub fn bound_consent_session(&self) -> Option<Symbol> {
240        self.consent
241            .as_ref()
242            .and_then(|consent| sim_value::access::field_sym(consent, "session"))
243    }
244
245    /// Binds visible consent data to the session and records the ledger event.
246    pub fn bind_consent(&mut self, consent: Expr) -> Result<()> {
247        match sim_value::access::field_sym(&consent, "session") {
248            Some(session) if session == self.id => {}
249            Some(session) => {
250                return Err(Error::HostError(format!(
251                    "consent session '{session}' does not match device edge session '{}'",
252                    self.id
253                )));
254            }
255            None => {
256                return Err(Error::HostError(
257                    "device edge consent is missing session".to_owned(),
258                ));
259            }
260        }
261        self.consent = Some(consent);
262        self.record_event("consent-bound")
263    }
264
265    /// Swaps the transport route without changing the session identity.
266    pub fn rebind(&mut self, link: LinkKind) -> Result<()> {
267        self.link = link;
268        self.record_event("rebind")
269    }
270
271    /// Swaps the route and flushes queued commands after the reconnect.
272    pub fn rebind_and_flush(&mut self, link: LinkKind) -> Result<Vec<Expr>> {
273        self.rebind(link)?;
274        self.flush_queued_commands()
275    }
276
277    fn record_event(&mut self, name: &str) -> Result<()> {
278        self.events.push(
279            self.ledger.clone(),
280            EventKind::Trace(Ref::Symbol(Symbol::qualified("device/edge", name))),
281        )?;
282        Ok(())
283    }
284}
285
286/// Returns the standard preferred route order for watch edge sessions.
287pub fn watch_route_order() -> Vec<LinkKind> {
288    vec![
289        LinkKind::import(),
290        LinkKind::modeled(),
291        LinkKind::ble_host(),
292        LinkKind::relay(),
293        LinkKind::zepp_bridge(),
294        LinkKind::wifi_if_verified(),
295    ]
296}
297
298/// Registers a watch profile on a route-transparent edge session.
299pub fn register_watch_edge_session(
300    id: Symbol,
301    profile: DeviceEdgeProfile,
302    ledger: LedgerRef,
303    initial_link: LinkKind,
304) -> Result<DeviceEdgeSession> {
305    let mut session = DeviceEdgeSession::with_ledger(id, initial_link, ledger)?;
306    session.bind_profile(profile)?;
307    session.set_route_order(watch_route_order())?;
308    Ok(session)
309}
310
311fn link_symbol(name: &str) -> Symbol {
312    Symbol::qualified("device/link", name.to_owned())
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    use sim_value::build;
320
321    #[test]
322    fn device_session_survives_route_swap() {
323        let id = Symbol::qualified("device/session", "route-swap");
324        let consent = build::map(vec![
325            ("kind", build::qsym("device", "consent-receipt")),
326            ("session", Expr::Symbol(id.clone())),
327            ("seq", build::uint(7)),
328        ]);
329        let mut session = DeviceEdgeSession::new(id.clone(), LinkKind::Direct).unwrap();
330        session.bind_consent(consent.clone()).unwrap();
331
332        let ledger = session.ledger_ref().clone();
333        let events_before = session.event_ledger().len_for_run(&ledger);
334        session.rebind(LinkKind::Relay).unwrap();
335
336        assert_eq!(session.id(), &id);
337        assert_eq!(session.ledger_ref(), &ledger);
338        assert_eq!(session.link(), &LinkKind::Relay);
339        assert_eq!(session.bound_consent(), Some(&consent));
340        assert_eq!(session.bound_consent_session(), Some(id));
341        assert_eq!(
342            session.event_ledger().len_for_run(&ledger),
343            events_before + 1
344        );
345    }
346
347    #[test]
348    fn consent_must_match_device_session() {
349        let id = Symbol::qualified("device/session", "route-swap");
350        let other = Symbol::qualified("device/session", "other");
351        let consent = build::map(vec![
352            ("kind", build::qsym("device", "consent-receipt")),
353            ("session", Expr::Symbol(other)),
354            ("seq", build::uint(7)),
355        ]);
356        let mut session = DeviceEdgeSession::new(id, LinkKind::Direct).unwrap();
357
358        let err = session.bind_consent(consent).unwrap_err();
359
360        assert!(matches!(
361            err,
362            Error::HostError(message) if message.contains("does not match")
363        ));
364        assert!(session.bound_consent().is_none());
365    }
366
367    #[test]
368    fn watch_session_flushes_queue_after_reconnect() {
369        let id = Symbol::qualified("device/session", "watch-trex3pro-48");
370        let ledger = Ref::Symbol(Symbol::qualified("device/ledger", "watch-trex3pro-48"));
371        let profile = DeviceEdgeProfile::new(
372            Symbol::qualified("device", "amazfit-t-rex-3-pro"),
373            Symbol::qualified("device/provider", "watch-wristbridge"),
374        );
375        let consent = build::map(vec![
376            ("kind", build::qsym("device", "consent-receipt")),
377            ("session", Expr::Symbol(id.clone())),
378            ("seq", build::uint(9)),
379        ]);
380        let command = build::map(vec![
381            ("kind", build::qsym("view-wrist", "command")),
382            ("command", build::qsym("watch/command", "notify")),
383            ("title", build::text("route")),
384        ]);
385
386        let mut session = register_watch_edge_session(
387            id.clone(),
388            profile.clone(),
389            ledger.clone(),
390            LinkKind::Direct,
391        )
392        .unwrap();
393        session.bind_consent(consent.clone()).unwrap();
394        session.queue_command(command.clone()).unwrap();
395
396        let events_before = session.event_ledger().len_for_run(&ledger);
397        let flushed = session.rebind_and_flush(LinkKind::relay()).unwrap();
398
399        assert_eq!(session.id(), &id);
400        assert_eq!(session.ledger_ref(), &ledger);
401        assert_eq!(session.link(), &LinkKind::Relay);
402        assert_eq!(session.device_profile(), Some(&profile));
403        assert_eq!(session.bound_consent(), Some(&consent));
404        assert_eq!(session.bound_consent_session(), Some(id));
405        assert_eq!(session.route_order(), watch_route_order().as_slice());
406        assert_eq!(flushed, vec![command.clone()]);
407        assert_eq!(session.flushed_commands(), &[command]);
408        assert_eq!(session.queued_command_count(), 0);
409        assert_eq!(
410            session.event_ledger().len_for_run(&ledger),
411            events_before + 2
412        );
413    }
414}