Skip to main content

app_facade_api/
pod.rs

1//! Native-Client pod discovery and commissioning. See
2//! `contract/pod-commissioning-v1.md`.
3use serde::{Deserialize, Serialize};
4
5pub const DISCOVERY_START_TARGET: &str = "/local/pod/discovery/start";
6pub const DISCOVERY_RENEW_TARGET: &str = "/local/pod/discovery/renew";
7pub const DISCOVERY_STOP_TARGET: &str = "/local/pod/discovery/stop";
8pub const DISCOVERY_LIST_TARGET: &str = "/local/pod/discovery/list";
9pub const COMMISSION_START_TARGET: &str = "/local/pod/commission/start";
10pub const COMMISSION_CODE_TARGET: &str = "/local/pod/commission/code";
11pub const COMMISSION_WIFI_TARGET: &str = "/local/pod/commission/wifi";
12pub const COMMISSION_WIFI_SCAN_TARGET: &str = "/local/pod/commission/wifi/scan";
13pub const COMMISSION_AUTHORIZE_TARGET: &str = "/local/pod/commission/authorize";
14pub const COMMISSION_STATUS_TARGET: &str = "/local/pod/commission/status";
15pub const BLUETOOTH_SETTINGS_TARGET: &str = "/local/pod/bluetooth/settings";
16
17pub const DISCOVERY_CHANGED_EVENT: &str = "/local/pod/discovery/changed";
18pub const COMMISSION_CHANGED_EVENT: &str = "/local/pod/commission/changed";
19
20pub const LOCAL_TARGETS: &[&str] = &[
21    DISCOVERY_START_TARGET,
22    DISCOVERY_RENEW_TARGET,
23    DISCOVERY_STOP_TARGET,
24    DISCOVERY_LIST_TARGET,
25    COMMISSION_START_TARGET,
26    COMMISSION_CODE_TARGET,
27    COMMISSION_WIFI_TARGET,
28    COMMISSION_WIFI_SCAN_TARGET,
29    COMMISSION_AUTHORIZE_TARGET,
30    COMMISSION_STATUS_TARGET,
31    BLUETOOTH_SETTINGS_TARGET,
32];
33pub const EVENT_TARGETS: &[&str] = &[DISCOVERY_CHANGED_EVENT, COMMISSION_CHANGED_EVENT];
34
35/// Shared by every commissionable MeowLink device kind.
36pub const BLE_SERVICE_UUID: &str = "a17a7ad5-9b2f-4010-9cf0-aa15ac54c40e";
37/// Bluetooth SIG test value; replace with an assigned company ID before release.
38pub const BLE_COMPANY_ID: u16 = 0xFFFF;
39pub const BLE_MANUFACTURER_MAGIC: [u8; 2] = *b"MW";
40pub const BLE_ADVERTISING_VERSION: u8 = 1;
41pub const BLE_KIND_POD: u8 = 1;
42pub const BLE_KIND_HOST: u8 = 2;
43pub const BLE_FLAG_COMMISSIONABLE: u8 = 0x01;
44pub const BLE_FLAG_WIFI_CONFIGURED: u8 = 0x02;
45
46pub const DISCOVERY_LEASE_MS: i64 = 30_000;
47pub const CANDIDATE_TTL_MS: i64 = 10_000;
48/// Discovery change events are published at most this often.
49pub const DISCOVERY_EVENT_INTERVAL_MS: i64 = 1_000;
50pub const CUSTOM_ENDPOINT_MAX_BYTES: usize = 480;
51pub const SECURITY_VERSION: u64 = 2;
52pub const SRP_USERNAME: &str = "meow";
53/// App info label and capability in the pod's `proto-ver` response.
54pub const APP_INFO_LABEL: &str = "meow";
55pub const APP_CAPABILITY: &str = "pod";
56/// `proto-ver` app info flag, `true` while the device refuses handshakes after
57/// repeated wrong codes.
58pub const APP_INFO_LOCKED: &str = "locked";
59/// `proto-ver` app info field with the remaining lockout in milliseconds,
60/// present only while the device refuses handshakes.
61pub const APP_INFO_LOCKED_FOR_MS: &str = "lockedForMs";
62/// A session fails with `code_locked` after this many rejected codes.
63pub const CODE_ATTEMPTS: u32 = 5;
64
65/// Session deadline before credential delivery starts.
66pub const SESSION_LIFETIME_MS: i64 = 600_000;
67/// After `activateBefore` the commissioner waits this long before giving up.
68pub const ACTIVATION_GRACE_MS: i64 = 30_000;
69pub const CREDENTIAL_STATUS_POLL_MS: i64 = 5_000;
70
71/// Lion paths, relative to `cloudApi` (`https://…/api/v1`).
72pub const CREDENTIAL_ISSUE_PATH: &str = "pod/credential/issue";
73pub const CREDENTIAL_STATUS_PATH: &str = "pod/credential/status";
74
75/// Activation error codes a pod reports in `failed`.
76pub const ACTIVATION_ERROR_CODES: &[&str] = &[
77    "time_sync_failed",
78    "cloud_unreachable",
79    "refresh_rejected",
80    "auth_rejected",
81    "storage_failed",
82];
83/// `reason` of a refused `deliver` or `finish` reply.
84pub const CREDENTIAL_REFUSAL_REASONS: &[&str] = &["invalid_payload", "wrong_state"];
85/// `pod-info` `mode` inside a Wi-Fi change window.
86pub const POD_MODE_REPROVISION: &str = "reprovision";
87/// `activation_failed` detail when the commissioner lost the pod and Lion
88/// never saw the credential activate.
89pub const NOT_ACTIVATED_DETAIL: &str = "not_activated";
90pub const POD_INFO_ENDPOINT: &str = "pod-info";
91pub const POD_CREDENTIAL_ENDPOINT: &str = "pod-credential";
92/// Characteristic UUIDs used when a device exposes no `0x2901` descriptor: the
93/// service UUID with bytes 2..4 replaced by this value. Custom endpoints take
94/// the values after `proto-ver` in contract order.
95pub const STANDARD_ENDPOINT_UUIDS: &[(&str, u16)] = &[
96    ("prov-ctrl", 0xFF4F),
97    ("prov-scan", 0xFF50),
98    ("prov-session", 0xFF51),
99    ("prov-config", 0xFF52),
100    ("proto-ver", 0xFF53),
101];
102pub const POD_ENDPOINT_UUIDS: &[(&str, u16)] = &[
103    (POD_INFO_ENDPOINT, 0xFF54),
104    (POD_CREDENTIAL_ENDPOINT, 0xFF55),
105];
106
107/// Manufacturer-data payload after the 2-byte company ID.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct BleAdvertisement {
110    pub kind: u8,
111    pub flags: u8,
112    pub short_id: [u8; 4],
113}
114
115impl BleAdvertisement {
116    pub const LEN: usize = 9;
117
118    pub fn encode(&self) -> [u8; Self::LEN] {
119        let [m0, m1] = BLE_MANUFACTURER_MAGIC;
120        let [s0, s1, s2, s3] = self.short_id;
121        [
122            m0,
123            m1,
124            BLE_ADVERTISING_VERSION,
125            self.kind,
126            self.flags,
127            s0,
128            s1,
129            s2,
130            s3,
131        ]
132    }
133
134    /// `data` excludes the company ID. Unknown magic or version yields `None`.
135    pub fn decode(data: &[u8]) -> Option<Self> {
136        if data.len() < Self::LEN
137            || data[..2] != BLE_MANUFACTURER_MAGIC
138            || data[2] != BLE_ADVERTISING_VERSION
139        {
140            return None;
141        }
142        Some(Self {
143            kind: data[3],
144            flags: data[4],
145            short_id: [data[5], data[6], data[7], data[8]],
146        })
147    }
148
149    pub fn commissionable(&self) -> bool {
150        self.flags & BLE_FLAG_COMMISSIONABLE != 0
151    }
152
153    pub fn wifi_configured(&self) -> bool {
154        self.flags & BLE_FLAG_WIFI_CONFIGURED != 0
155    }
156
157    pub fn short_id_hex(&self) -> String {
158        self.short_id
159            .iter()
160            .map(|byte| format!("{byte:02X}"))
161            .collect()
162    }
163}
164
165#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
166#[serde(rename_all = "camelCase", deny_unknown_fields)]
167pub struct EmptyRequest {}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
170#[serde(rename_all = "camelCase", deny_unknown_fields)]
171pub struct LeaseRequest {
172    pub lease_id: String,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
176#[serde(rename_all = "camelCase", deny_unknown_fields)]
177pub struct DiscoveryLease {
178    pub lease_id: String,
179    pub expires_at_ms: i64,
180}
181
182#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
183#[serde(rename_all = "camelCase")]
184pub enum AdapterState {
185    Ready,
186    PoweredOff,
187    Denied,
188    Unavailable,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
192#[serde(rename_all = "camelCase", deny_unknown_fields)]
193pub struct AdapterStatus {
194    pub state: AdapterState,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub message: Option<String>,
197}
198
199#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
200#[serde(rename_all = "snake_case")]
201pub enum DeviceKind {
202    Pod,
203    Host,
204}
205
206impl DeviceKind {
207    pub fn from_advertised(kind: u8) -> Option<Self> {
208        match kind {
209            BLE_KIND_POD => Some(Self::Pod),
210            BLE_KIND_HOST => Some(Self::Host),
211            _ => None,
212        }
213    }
214}
215
216/// One nearby commissionable device. Pod commissioning accepts only `pod`;
217/// Host provisioning only `host`.
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
219#[serde(rename_all = "camelCase", deny_unknown_fields)]
220pub struct PodCandidate {
221    pub candidate_id: String,
222    pub kind: DeviceKind,
223    pub name: String,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub model: Option<String>,
226    pub short_id: String,
227    pub rssi: i16,
228    pub commissionable: bool,
229    pub wifi_configured: bool,
230    pub last_seen_ms: i64,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
234#[serde(rename_all = "camelCase", deny_unknown_fields)]
235pub struct DiscoverySnapshot {
236    pub revision: u64,
237    pub scanning: bool,
238    pub adapter: AdapterStatus,
239    pub candidates: Vec<PodCandidate>,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
243#[serde(rename_all = "camelCase", deny_unknown_fields)]
244pub struct CommissionStartRequest {
245    pub candidate_id: String,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
249#[serde(rename_all = "camelCase", deny_unknown_fields)]
250pub struct CommissionCodeRequest {
251    pub session_id: String,
252    pub code: String,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
256#[serde(rename_all = "camelCase", deny_unknown_fields)]
257pub struct CommissionWifiRequest {
258    pub session_id: String,
259    pub ssid: String,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub password: Option<String>,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
265#[serde(rename_all = "camelCase", deny_unknown_fields)]
266pub struct CommissionSessionRequest {
267    pub session_id: String,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
271#[serde(rename_all = "camelCase", deny_unknown_fields)]
272pub struct CommissionAuthorizeRequest {
273    pub session_id: String,
274    pub scope_id: String,
275}
276
277#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
278#[serde(rename_all = "snake_case")]
279pub enum CommissionState {
280    Connecting,
281    AwaitingCode,
282    Securing,
283    ReadingInfo,
284    AwaitingWifi,
285    JoiningWifi,
286    AwaitingAuthorization,
287    Issuing,
288    Delivering,
289    Activating,
290    Completed,
291    Failed,
292    TimedOut,
293}
294
295impl CommissionState {
296    pub fn is_terminal(self) -> bool {
297        matches!(self, Self::Completed | Self::Failed | Self::TimedOut)
298    }
299}
300
301#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
302#[serde(rename_all = "snake_case")]
303pub enum CommissionErrorCode {
304    BluetoothUnavailable,
305    DeviceBusy,
306    Disconnected,
307    CodeRejected,
308    CodeLocked,
309    WifiAuthFailed,
310    WifiNotFound,
311    WifiFailed,
312    IssueFailed,
313    DeliveryFailed,
314    ActivationFailed,
315    SessionTimeout,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
319#[serde(rename_all = "camelCase", deny_unknown_fields)]
320pub struct CommissionError {
321    pub code: CommissionErrorCode,
322    pub message: String,
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub detail: Option<String>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
328#[serde(rename_all = "camelCase", deny_unknown_fields)]
329pub struct PodDeviceInfo {
330    pub device_id: String,
331    pub model: String,
332    pub firmware_version: String,
333    pub wifi_configured: bool,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
337#[serde(rename_all = "camelCase", deny_unknown_fields)]
338pub struct WifiNetwork {
339    pub ssid: String,
340    pub rssi: i16,
341    pub secured: bool,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
345#[serde(rename_all = "camelCase", deny_unknown_fields)]
346pub struct AuthorizationScope {
347    pub scope_id: String,
348    pub name: String,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
352#[serde(rename_all = "camelCase", deny_unknown_fields)]
353pub struct AuthorizationPrompt {
354    pub user_id: String,
355    pub user_name: String,
356    pub scopes: Vec<AuthorizationScope>,
357    pub default_scope_id: String,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
361#[serde(rename_all = "camelCase", deny_unknown_fields)]
362pub struct CommissionSession {
363    pub session_id: String,
364    pub revision: u64,
365    pub state: CommissionState,
366    pub candidate: PodCandidate,
367    pub expires_at_ms: i64,
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub device: Option<PodDeviceInfo>,
370    #[serde(default, skip_serializing_if = "Vec::is_empty")]
371    pub networks: Vec<WifiNetwork>,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub authorization: Option<AuthorizationPrompt>,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub pod_id: Option<String>,
376    /// Set once the pod reports a Wi-Fi change window: the session replaces
377    /// the pod's network and issues no credential.
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub mode: Option<SessionMode>,
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub error: Option<CommissionError>,
382}
383
384#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
385#[serde(rename_all = "snake_case")]
386pub enum SessionMode {
387    Reprovision,
388}
389
390impl CommissionSession {
391    pub fn validate(&self) -> Result<(), &'static str> {
392        match (self.state, self.error.as_ref().map(|error| error.code)) {
393            (CommissionState::Failed | CommissionState::TimedOut, None) => {
394                return Err("failed and timed_out sessions carry an error");
395            }
396            (CommissionState::Failed | CommissionState::TimedOut, Some(_))
397            | (_, None)
398            | (CommissionState::AwaitingCode, Some(CommissionErrorCode::CodeRejected))
399            | (
400                CommissionState::AwaitingWifi,
401                Some(
402                    CommissionErrorCode::WifiAuthFailed
403                    | CommissionErrorCode::WifiNotFound
404                    | CommissionErrorCode::WifiFailed,
405                ),
406            ) => {}
407            _ => {
408                return Err(
409                    "only failed, timed_out and retryable code or Wi-Fi states carry an error",
410                );
411            }
412        }
413        if self.state == CommissionState::AwaitingAuthorization && self.authorization.is_none() {
414            return Err("awaiting_authorization requires an authorization prompt");
415        }
416        if self.mode == Some(SessionMode::Reprovision) {
417            if self.authorization.is_some() || self.pod_id.is_some() {
418                return Err("reprovision sessions issue no credential");
419            }
420        } else if self.state == CommissionState::Completed && self.pod_id.is_none() {
421            return Err("completed sessions carry the issued podId");
422        }
423        Ok(())
424    }
425}
426
427/// `/local/pod/bluetooth/settings` response: whether the platform opened the
428/// settings that fix the current adapter state.
429#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
430#[serde(rename_all = "camelCase", deny_unknown_fields)]
431pub struct BluetoothSettingsResponse {
432    pub opened: bool,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
436#[serde(rename_all = "camelCase", deny_unknown_fields)]
437pub struct CommissionStatus {
438    pub session: Option<CommissionSession>,
439}
440
441/// Stable cause of a rejected `/local/pod/*` or `/local/host/provision/*`
442/// request, carried as `details.reason` of the error envelope.
443#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
444#[serde(rename_all = "snake_case")]
445pub enum RequestErrorReason {
446    SessionActive,
447    DeviceGone,
448    NotCommissionable,
449    WrongKind,
450    UnknownSession,
451    WrongState,
452    InvalidCode,
453    InvalidWifi,
454    BluetoothUnavailable,
455    NotSignedIn,
456    ScopeNotOffered,
457    CloudUnreachableForDevice,
458    Busy,
459}
460
461impl RequestErrorReason {
462    pub const ALL: &'static [Self] = &[
463        Self::SessionActive,
464        Self::DeviceGone,
465        Self::NotCommissionable,
466        Self::WrongKind,
467        Self::UnknownSession,
468        Self::WrongState,
469        Self::InvalidCode,
470        Self::InvalidWifi,
471        Self::BluetoothUnavailable,
472        Self::NotSignedIn,
473        Self::ScopeNotOffered,
474        Self::CloudUnreachableForDevice,
475        Self::Busy,
476    ];
477
478    pub fn as_str(self) -> &'static str {
479        match self {
480            Self::SessionActive => "session_active",
481            Self::DeviceGone => "device_gone",
482            Self::NotCommissionable => "not_commissionable",
483            Self::WrongKind => "wrong_kind",
484            Self::UnknownSession => "unknown_session",
485            Self::WrongState => "wrong_state",
486            Self::InvalidCode => "invalid_code",
487            Self::InvalidWifi => "invalid_wifi",
488            Self::BluetoothUnavailable => "bluetooth_unavailable",
489            Self::NotSignedIn => "not_signed_in",
490            Self::ScopeNotOffered => "scope_not_offered",
491            Self::CloudUnreachableForDevice => "cloud_unreachable_for_device",
492            Self::Busy => "busy",
493        }
494    }
495}
496
497/// The pod's identity as reported by `pod-info` and sent to Lion on issue.
498#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
499#[serde(rename_all = "camelCase")]
500pub struct PodIdentity {
501    pub key_id: String,
502    pub alg: String,
503    pub x: String,
504    pub y: String,
505    pub fingerprint: String,
506}
507
508/// `pod-info` response.
509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
510#[serde(rename_all = "camelCase")]
511pub struct PodInfo {
512    pub protocol: u32,
513    pub device_id: String,
514    pub model: String,
515    #[serde(default)]
516    pub platform: String,
517    pub firmware_version: String,
518    pub identity: PodIdentity,
519    pub wifi_configured: bool,
520    #[serde(default)]
521    pub state: String,
522    /// `reprovision` inside a Wi-Fi change window; absent otherwise.
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub mode: Option<String>,
525}
526
527impl PodInfo {
528    pub fn reprovision(&self) -> bool {
529        self.mode.as_deref() == Some(POD_MODE_REPROVISION)
530    }
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
534#[serde(rename_all = "camelCase")]
535pub struct CredentialIssueRequest {
536    pub device_id: String,
537    pub model: String,
538    pub platform: String,
539    pub firmware_version: String,
540    pub name: String,
541    pub identity: PodIdentity,
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub client_id: Option<String>,
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
547#[serde(rename_all = "camelCase")]
548pub struct CredentialIssueResponse {
549    pub pod_id: String,
550    pub refresh_token: String,
551    /// Epoch milliseconds.
552    pub activate_before: i64,
553}
554
555/// Body of the owner's `credential/status` call.
556#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
557#[serde(rename_all = "camelCase", deny_unknown_fields)]
558pub struct CredentialPodRequest {
559    pub pod_id: String,
560}
561
562#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
563#[serde(rename_all = "snake_case")]
564pub enum CredentialStatus {
565    Pending,
566    Active,
567    /// No such credential for this user: never issued, cleaned up, expired while
568    /// pending, or owned by someone else.
569    Absent,
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
573#[serde(rename_all = "camelCase")]
574pub struct CredentialStatusResponse {
575    pub pod_id: String,
576    pub status: CredentialStatus,
577    /// Epoch milliseconds, present when `active`.
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub activated_at: Option<i64>,
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    #[test]
587    fn advertisement_round_trips_and_rejects_unknown_versions() {
588        let ad = BleAdvertisement {
589            kind: BLE_KIND_POD,
590            flags: BLE_FLAG_COMMISSIONABLE,
591            short_id: [0x1a, 0x2b, 0x3c, 0x4d],
592        };
593        let bytes = ad.encode();
594        assert_eq!(BleAdvertisement::decode(&bytes), Some(ad));
595        assert_eq!(ad.short_id_hex(), "1A2B3C4D");
596        assert!(ad.commissionable() && !ad.wifi_configured());
597
598        let mut other = bytes;
599        other[2] = 2;
600        assert_eq!(BleAdvertisement::decode(&other), None);
601        assert_eq!(BleAdvertisement::decode(&bytes[..8]), None);
602    }
603
604    #[test]
605    fn request_reasons_serialize_as_their_names() {
606        for reason in RequestErrorReason::ALL {
607            assert_eq!(
608                serde_json::to_value(reason).unwrap(),
609                serde_json::Value::from(reason.as_str())
610            );
611        }
612    }
613}