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 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#[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 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#[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 #[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#[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#[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#[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#[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 #[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 pub activate_before: i64,
553}
554
555#[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 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 #[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}