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