1use 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
35pub const BLE_SERVICE_UUID: &str = "a17a7ad5-9b2f-4010-9cf0-aa15ac54c40e";
37pub 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;
48pub 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";
53pub const APP_INFO_LABEL: &str = "meow";
55pub const APP_CAPABILITY: &str = "pod";
56pub const APP_INFO_LOCKED: &str = "locked";
59pub const APP_INFO_LOCKED_FOR_MS: &str = "lockedForMs";
62pub const CODE_ATTEMPTS: u32 = 5;
64
65pub const SESSION_LIFETIME_MS: i64 = 600_000;
67pub const ACTIVATION_GRACE_MS: i64 = 30_000;
69pub const CREDENTIAL_STATUS_POLL_MS: i64 = 5_000;
70
71pub const CREDENTIAL_ISSUE_PATH: &str = "pod/credential/issue";
73pub const CREDENTIAL_STATUS_PATH: &str = "pod/credential/status";
74
75pub const ACTIVATION_ERROR_CODES: &[&str] = &[
77 "time_sync_failed",
78 "cloud_unreachable",
79 "refresh_rejected",
80 "auth_rejected",
81 "storage_failed",
82];
83pub const CREDENTIAL_REFUSAL_REASONS: &[&str] = &["invalid_payload", "wrong_state"];
85pub const POD_MODE_REPROVISION: &str = "reprovision";
87pub const NOT_ACTIVATED_DETAIL: &str = "not_activated";
90pub const POD_INFO_ENDPOINT: &str = "pod-info";
91pub const POD_CREDENTIAL_ENDPOINT: &str = "pod-credential";
92pub 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}
101pub 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#[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 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#[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 #[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 #[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#[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#[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#[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#[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 #[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 pub activate_before: i64,
576}
577
578#[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 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 #[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}