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