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 LAN_CREDENTIAL_MAX_BYTES: usize = 8192;
54pub const SECURITY_VERSION: u64 = 2;
55pub const SRP_USERNAME: &str = "meow";
56pub const APP_INFO_LABEL: &str = "meow";
58pub const APP_CAPABILITY: &str = "pod";
59pub const APP_INFO_LOCKED: &str = "locked";
62pub const APP_INFO_LOCKED_FOR_MS: &str = "lockedForMs";
65pub const CODE_ATTEMPTS: u32 = 5;
67
68pub const SESSION_LIFETIME_MS: i64 = 600_000;
70pub const ACTIVATION_GRACE_MS: i64 = 30_000;
72pub const CREDENTIAL_STATUS_POLL_MS: i64 = 5_000;
73
74pub 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
79pub 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];
88pub const CREDENTIAL_REFUSAL_REASONS: &[&str] = &["invalid_payload", "wrong_state"];
90pub const POD_MODE_REPROVISION: &str = "reprovision";
92pub const NOT_ACTIVATED_DETAIL: &str = "not_activated";
95pub const POD_INFO_ENDPOINT: &str = "pod-info";
96pub const POD_CREDENTIAL_ENDPOINT: &str = "pod-credential";
97pub 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}
106pub 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#[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 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#[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 #[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 #[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#[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#[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#[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#[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 #[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 pub activate_before: i64,
590}
591
592#[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#[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 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 #[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}