1use std::{
2 collections::{HashMap, VecDeque},
3 sync::{Arc, Mutex},
4};
5
6use umsh_core::RegionCode;
7use umsh_node::location::{MAX_PRECISION, NodeLocation};
8use umsh_ulcp::{
9 AlertState, BatteryChargeState, BatteryStatus, Cmd, Frame, StreamPayload, frame,
10 gatt::{self, MAX_FRAME, Reassembler},
11 gnss::{FixKind, GnssSnapshot},
12 host::{PropertyNotification, PropertyNotificationKind, TidAllocator},
13 ids::{
14 INTERFACE_TYPE, MAX_AUTO_ANNOUNCE_INTERVAL_S, MIN_AUTO_ANNOUNCE_INTERVAL_S,
15 PROTOCOL_MAJOR_VERSION, PROTOCOL_MINOR_VERSION, cap, prop, saved,
16 },
17 items::{self, Filter},
18 meta::{BufferedRxMeta, RX_FLAG_ACKED, RX_FLAG_BUFFERED},
19 pui,
20};
21
22use crate::MobileError;
23
24#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
26pub struct GattSegmentRecord {
27 pub value: Vec<u8>,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
32pub struct UlcpPropertyFrameRecord {
33 pub transaction_id: u8,
34 pub command: u8,
35 pub property_id: u32,
36 pub value: Vec<u8>,
37}
38
39#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
41pub struct UlcpBatteryRecord {
42 pub percentage: Option<u8>,
43 pub voltage_mv: Option<u16>,
45 pub charge_state: Option<UlcpChargeState>,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
53pub enum UlcpChargeState {
54 Discharging,
56 Charging,
58 Charged,
60}
61
62impl UlcpChargeState {
63 fn from_wire(state: BatteryChargeState) -> Self {
64 match state {
65 BatteryChargeState::Discharging => Self::Discharging,
66 BatteryChargeState::Charging => Self::Charging,
67 BatteryChargeState::Charged => Self::Charged,
68 }
69 }
70}
71
72#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
79pub struct UlcpRepeaterSettingsRecord {
80 pub enabled: bool,
83 pub regions: Vec<Vec<u8>>,
87 pub default_region: Option<Vec<u8>>,
90 pub min_rssi_dbm: Option<i16>,
92 pub min_snr_db: Option<i8>,
94}
95
96#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)]
104pub struct UlcpGnssSettingsRecord {
105 pub enabled: bool,
109 pub ident_update: bool,
112 pub ident_precision: u8,
116 pub time_trust: bool,
120}
121
122#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)]
128pub struct UlcpAdvertSettingsRecord {
129 pub advert_interval_seconds: u32,
133 pub beacon_interval_seconds: u32,
137 pub startup_beacon: bool,
139}
140
141#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, uniffi::Enum)]
143pub enum UlcpFixKind {
144 #[default]
146 None,
147 TwoD,
149 ThreeD,
151}
152
153impl UlcpFixKind {
154 fn from_wire(fix: FixKind) -> Self {
155 match fix {
156 FixKind::None => Self::None,
157 FixKind::TwoD => Self::TwoD,
158 FixKind::ThreeD => Self::ThreeD,
159 }
160 }
161}
162
163#[derive(Clone, Debug, PartialEq, uniffi::Record)]
171pub struct UlcpGnssRecord {
172 pub fix: UlcpFixKind,
173 pub location: Vec<u8>,
178 pub latitude_deg: Option<f64>,
186 pub longitude_deg: Option<f64>,
187 pub location_cell_meters: Option<f64>,
189 pub altitude_m: Option<i32>,
191 pub accuracy_dm: Option<u16>,
195 pub satellites_used: u8,
198 pub satellites_in_view: Option<u8>,
200}
201
202#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)]
209pub struct UlcpTimeRecord {
210 pub epoch_seconds: Option<u32>,
215}
216
217#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
220pub struct UlcpSyncRecord {
221 pub capability_count: u32,
222 pub has_host_filtering: bool,
223 pub supports_offline_queue: bool,
224 pub supports_delegated_ack: bool,
225 pub supports_device_name: bool,
226 pub supports_lora: bool,
227 pub supports_duty_cycle_limit: bool,
228 pub supports_battery: bool,
231 pub supports_repeater: bool,
233 pub supports_ident: bool,
236 pub supports_device_identity: bool,
239 pub supports_time: bool,
243 pub supports_gnss: bool,
246 pub supports_advert: bool,
249 pub phy_enabled: bool,
250 pub frequency_khz: u32,
251 pub transmit_power_dbm: i8,
252 pub bandwidth_hz: Option<u32>,
253 pub spreading_factor: Option<u8>,
254 pub coding_rate_denom: Option<u8>,
255 pub duty_cycle_now: Option<u16>,
256 pub duty_cycle_limit: Option<u16>,
257 pub saved: Option<SavedSnapshotRecord>,
258 pub queued_frames: Option<u16>,
259 pub dropped_frames: Option<u32>,
260 pub filter_count: Option<u32>,
261 pub host_channel_count: Option<u32>,
262 pub host_peer_count: Option<u32>,
263 pub auto_ack: Option<bool>,
264 pub repeater: Option<UlcpRepeaterSettingsRecord>,
267 pub dev_peer_keys: Option<Vec<Vec<u8>>>,
271 pub dev_channel_ids: Option<Vec<Vec<u8>>>,
277 pub ident_role: Option<u8>,
282 pub ident_mobile: Option<bool>,
285 pub dev_discoverable: Option<bool>,
289 pub tz_offset_min: Option<i16>,
296 pub gnss: Option<UlcpGnssSettingsRecord>,
299 pub advert: Option<UlcpAdvertSettingsRecord>,
302 pub unreadable_properties: Vec<u32>,
311}
312
313#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
319pub enum SavedSnapshotRecord {
320 None,
322 Current,
324 Fallback,
327 Unreadable,
330}
331
332#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
335pub enum UlcpSessionPhase {
336 Idle,
337 Synchronizing,
338 AwaitingHost,
339 Claiming,
340 Configuring,
341 Attached,
342}
343
344#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
347pub struct UlcpRadioSettingsRecord {
348 pub device_name: Option<String>,
349 pub phy_enabled: bool,
350 pub frequency_khz: u32,
351 pub transmit_power_dbm: i8,
352 pub bandwidth_hz: Option<u32>,
353 pub spreading_factor: Option<u8>,
354 pub coding_rate_denom: Option<u8>,
355 pub duty_cycle_limit: Option<u16>,
356}
357
358#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
367pub struct UlcpDeviceConfigRecord {
368 pub radio: UlcpRadioSettingsRecord,
371 pub ident_role: Option<u8>,
375 pub ident_mobile: Option<bool>,
378 pub dev_discoverable: Option<bool>,
382 pub repeater: Option<UlcpRepeaterSettingsRecord>,
385 pub tz_offset_min: Option<i16>,
392 pub gnss: Option<UlcpGnssSettingsRecord>,
395 pub advert: Option<UlcpAdvertSettingsRecord>,
398}
399
400fn gnss_record(snapshot: &GnssSnapshot) -> UlcpGnssRecord {
402 let bytes = snapshot.location();
403 let placed = (!bytes.is_empty()).then(|| NodeLocation::from_bytes(bytes).center());
404 UlcpGnssRecord {
405 fix: UlcpFixKind::from_wire(snapshot.fix),
406 location: bytes.to_vec(),
407 latitude_deg: placed.map(|(latitude, _)| latitude.into()),
408 longitude_deg: placed.map(|(_, longitude)| longitude.into()),
409 location_cell_meters: (!bytes.is_empty())
410 .then(|| ulcp_location_cell_meters(bytes.len() as u8))
411 .flatten(),
412 altitude_m: snapshot.altitude_m,
413 accuracy_dm: snapshot.accuracy_dm,
414 satellites_used: snapshot.sats_used,
415 satellites_in_view: snapshot.sats_in_view,
416 }
417}
418
419#[uniffi::export]
428pub fn ulcp_location_cell_meters(precision_bytes: u8) -> Option<f64> {
429 (1..=MAX_PRECISION)
431 .contains(&precision_bytes)
432 .then(|| 360.0 * 111_320.0 / 16f64.powi(precision_bytes.into()))
433}
434
435#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
437pub enum UlcpAlertState {
438 None,
440 Locate,
443}
444
445impl UlcpAlertState {
446 fn from_wire(state: AlertState) -> Self {
447 match state {
448 AlertState::None => Self::None,
449 AlertState::Locate => Self::Locate,
450 }
451 }
452
453 fn to_wire(self) -> AlertState {
454 match self {
455 Self::None => AlertState::None,
456 Self::Locate => AlertState::Locate,
457 }
458 }
459}
460
461#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
463pub enum UlcpHostOwnership {
464 Unknown,
465 LocalIdentityUnavailable,
466 Unsupported,
467 Unclaimed,
468 Ours,
469 OtherHost,
470}
471
472#[derive(Clone, Debug, PartialEq, uniffi::Record)]
477pub struct UlcpSessionSnapshotRecord {
478 pub generation: u64,
479 pub phase: UlcpSessionPhase,
480 pub host_ownership: UlcpHostOwnership,
481 pub device_key: Option<Vec<u8>>,
482 pub device_name: Option<String>,
483 pub battery: Option<UlcpBatteryRecord>,
484 pub alert: Option<UlcpAlertState>,
491 pub time: Option<UlcpTimeRecord>,
494 pub gnss: Option<UlcpGnssRecord>,
498 pub provisioning: Option<UlcpSyncRecord>,
499}
500
501#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
503pub enum UlcpRawTransmitDisposition {
504 Sent,
505 Retry,
506 Rejected,
507}
508
509#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
511pub struct UlcpRawTransmitResultRecord {
512 pub transaction_id: u8,
513 pub status_code: u32,
514 pub status_name: String,
515 pub disposition: UlcpRawTransmitDisposition,
516}
517
518#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
522pub struct UlcpOperationErrorRecord {
523 pub operation: String,
524 pub status_code: u32,
525 pub status_name: String,
526}
527
528#[derive(Clone, Debug, PartialEq, uniffi::Record)]
532pub struct UlcpSessionUpdateRecord {
533 pub outbound_frames: Vec<Vec<u8>>,
534 pub received_frames: Vec<UlcpReceivedFrameRecord>,
535 pub snapshot: UlcpSessionSnapshotRecord,
536 pub waiting_for_responses: bool,
537 pub raw_transmit_pending: bool,
540 pub raw_transmit_started_transaction_id: Option<u8>,
542 pub raw_transmit_result: Option<UlcpRawTransmitResultRecord>,
546 pub operation_error: Option<UlcpOperationErrorRecord>,
549}
550
551#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
553pub struct UlcpReceivedFrameRecord {
554 pub data: Vec<u8>,
555 pub rssi_dbm: Option<i16>,
556 pub lqi: Option<u8>,
557 pub snr_cb: Option<i16>,
558 pub was_buffered: bool,
559 pub was_acknowledged: bool,
560 pub age_seconds: u32,
561}
562
563#[derive(Clone, Copy, Debug, PartialEq, Eq)]
564enum SessionStage {
565 Idle,
566 Initial,
567 Inspection,
568 Refreshing,
569 Claiming,
570 Saving,
571 Configuring,
572 SavingConfiguration,
573 AwaitingHost,
574 Attached,
575}
576
577#[derive(Clone, Debug, PartialEq, Eq)]
578enum ExpectedResponse {
579 Property(u32),
580 Claim,
581 Save,
582 ConfigurationProperty(u32),
586 SaveConfiguration,
587 RawTransmit,
588 DevPeerInsert(Vec<u8>),
590 DevPeerRemove(Vec<u8>),
592 SaveDevPeers,
594 DevChannelInsert(Vec<u8>),
598 DevChannelRemove(Vec<u8>),
601 SaveDevChannels,
603 HostChannelInsert(VecDeque<Vec<u8>>),
607 HostChannelReplace,
610}
611
612struct UlcpSessionState {
613 generation: u64,
614 mode: UlcpAttachMode,
619 stage: SessionStage,
620 tids: TidAllocator,
621 expected: HashMap<u8, ExpectedResponse>,
622 selected_host_key: Option<[u8; 32]>,
623 radio_host_key: Option<Vec<u8>>,
624 host_key_unsupported: bool,
625 responses: HashMap<u32, UlcpPropertyFrameRecord>,
626 inspection_queue: VecDeque<u32>,
627 configuration_queue: VecDeque<(u32, Vec<u8>)>,
628 device_key: Option<Vec<u8>>,
629 device_name: Option<String>,
630 battery: Option<UlcpBatteryRecord>,
639 alert: Option<UlcpAlertState>,
643 time: Option<UlcpTimeRecord>,
646 gnss: Option<GnssSnapshot>,
651 provisioning: Option<UlcpSyncRecord>,
652 stage_failure_pending: bool,
653}
654
655impl Default for UlcpSessionState {
656 fn default() -> Self {
657 Self {
658 generation: 0,
659 mode: UlcpAttachMode::Tethered,
660 stage: SessionStage::Idle,
661 tids: TidAllocator::new(),
662 expected: HashMap::new(),
663 selected_host_key: None,
664 radio_host_key: None,
665 host_key_unsupported: false,
666 responses: HashMap::new(),
667 inspection_queue: VecDeque::new(),
668 configuration_queue: VecDeque::new(),
669 device_key: None,
670 device_name: None,
671 battery: None,
672 alert: None,
673 time: None,
674 gnss: None,
675 provisioning: None,
676 stage_failure_pending: false,
677 }
678 }
679}
680
681#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, uniffi::Enum)]
688pub enum UlcpAttachMode {
689 #[default]
692 Tethered,
693 Administrative,
697}
698
699#[derive(uniffi::Object)]
706pub struct MobileUlcpSession {
707 inner: Mutex<UlcpSessionState>,
708 mode: UlcpAttachMode,
709}
710
711#[uniffi::export]
712impl MobileUlcpSession {
713 #[uniffi::constructor]
715 pub fn new() -> Arc<Self> {
716 Arc::new(Self::with_mode(UlcpAttachMode::Tethered))
717 }
718
719 #[uniffi::constructor]
722 pub fn administrative() -> Arc<Self> {
723 Arc::new(Self::with_mode(UlcpAttachMode::Administrative))
724 }
725
726 pub fn attach_mode(&self) -> UlcpAttachMode {
728 self.mode
729 }
730
731 pub fn begin(
733 &self,
734 selected_host_key: Option<Vec<u8>>,
735 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
736 let selected_host_key = selected_host_key
737 .map(|key| {
738 key.try_into()
739 .map_err(|_| MobileError::InvalidPublicKeyLength)
740 })
741 .transpose()?;
742 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
743 let generation = state.generation.wrapping_add(1);
744 *state = UlcpSessionState {
745 generation,
746 mode: self.mode,
747 stage: SessionStage::Initial,
748 selected_host_key,
749 ..UlcpSessionState::default()
750 };
751
752 let mut outbound = Vec::new();
753 for property in [
754 prop::LAST_STATUS,
755 prop::PROTOCOL_VERSION,
756 prop::CAPS,
757 prop::DEV_KEY,
758 prop::DEV_NAME,
759 prop::BATTERY,
760 prop::HOST_KEY,
761 ] {
762 outbound.push(state.get_property(property)?);
763 }
764 Ok(state.update(outbound))
765 }
766
767 pub fn claim(&self, host_key: Vec<u8>) -> Result<UlcpSessionUpdateRecord, MobileError> {
769 if self.mode == UlcpAttachMode::Administrative {
772 return Err(MobileError::AdministrativeSession);
773 }
774 let host_key: [u8; 32] = host_key
775 .try_into()
776 .map_err(|_| MobileError::InvalidPublicKeyLength)?;
777 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
778 if state.stage != SessionStage::AwaitingHost
779 || !matches!(
780 state.ownership(),
781 UlcpHostOwnership::Unclaimed | UlcpHostOwnership::OtherHost
782 )
783 {
784 return Err(MobileError::InvalidUlcpFrame);
785 }
786 state.selected_host_key = Some(host_key);
787 state.stage = SessionStage::Claiming;
788 state.expected.clear();
789 let tid = state.allocate_tid();
790 state.expected.insert(tid, ExpectedResponse::Claim);
791 let frame = ulcp_prop_set(tid, prop::HOST_KEY, host_key.to_vec())?;
792 Ok(state.update(vec![frame]))
793 }
794
795 pub fn factory_reset(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
803 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
804 let tid = state.allocate_tid();
805 let frame = ulcp_factory_reset(tid)?;
809 Ok(state.update(vec![frame]))
810 }
811
812 pub fn set_alert(&self, state: UlcpAlertState) -> Result<UlcpSessionUpdateRecord, MobileError> {
825 let mut session = self.inner.lock().expect("ULCP session mutex poisoned");
826 if session.stage != SessionStage::Attached {
827 return Err(MobileError::InvalidUlcpFrame);
828 }
829 if !session.has_capability(cap::ALERT)? {
830 return Err(MobileError::UnsupportedCapability);
831 }
832 let mut value = [0u8; pui::MAX_LEN];
833 let len = pui::encode(state.to_wire().code(), &mut value)
834 .map_err(|_| MobileError::InvalidUlcpFrame)?;
835 let tid = session.allocate_tid();
836 session
837 .expected
838 .insert(tid, ExpectedResponse::Property(prop::ALERT));
839 let frame = ulcp_prop_set(tid, prop::ALERT, value[..len].to_vec())?;
840 Ok(session.update(vec![frame]))
841 }
842
843 pub fn set_time(
859 &self,
860 epoch_seconds: Option<u32>,
861 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
862 let mut session = self.inner.lock().expect("ULCP session mutex poisoned");
863 if session.stage != SessionStage::Attached {
864 return Err(MobileError::InvalidUlcpFrame);
865 }
866 if !session.has_capability(cap::TIME)? {
867 return Err(MobileError::UnsupportedCapability);
868 }
869 let value = epoch_seconds
870 .map(|epoch| epoch.to_le_bytes().to_vec())
871 .unwrap_or_default();
872 let tid = session.allocate_tid();
873 session
874 .expected
875 .insert(tid, ExpectedResponse::Property(prop::TIME));
876 let frame = ulcp_prop_set(tid, prop::TIME, value)?;
877 Ok(session.update(vec![frame]))
878 }
879
880 pub fn configure(
882 &self,
883 settings: UlcpRadioSettingsRecord,
884 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
885 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
886 if state.stage != SessionStage::Attached {
887 return Err(MobileError::InvalidUlcpFrame);
888 }
889 validate_radio_settings(&settings, &state)?;
890
891 state.expected.clear();
892 state.configuration_queue = state.writable(configuration_values(settings, Vec::new()));
893 let mut outbound = Vec::new();
894 state.start_configuration(&mut outbound)?;
895 Ok(state.update(outbound))
896 }
897
898 pub fn configure_device(
907 &self,
908 configuration: UlcpDeviceConfigRecord,
909 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
910 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
911 if state.stage != SessionStage::Attached {
912 return Err(MobileError::InvalidUlcpFrame);
913 }
914 validate_radio_settings(&configuration.radio, &state)?;
915 let device_values = validate_device_settings(&configuration, &state)?;
916
917 state.expected.clear();
918 state.configuration_queue =
919 state.writable(configuration_values(configuration.radio, device_values));
920 let mut outbound = Vec::new();
921 state.start_configuration(&mut outbound)?;
922 Ok(state.update(outbound))
923 }
924
925 pub fn configure_positioning(
941 &self,
942 gnss: Option<UlcpGnssSettingsRecord>,
943 tz_offset_min: Option<i16>,
944 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
945 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
946 if state.stage != SessionStage::Attached {
947 return Err(MobileError::InvalidUlcpFrame);
948 }
949 let values = positioning_values(gnss, tz_offset_min, &state)?;
950 if values.is_empty() {
953 return Err(MobileError::UnsupportedCapability);
954 }
955
956 state.expected.clear();
957 state.configuration_queue = state.writable(values);
958 let mut outbound = Vec::new();
959 state.start_configuration(&mut outbound)?;
960 Ok(state.update(outbound))
961 }
962
963 pub fn configure_advertising(
970 &self,
971 advert: Option<UlcpAdvertSettingsRecord>,
972 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
973 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
974 if state.stage != SessionStage::Attached {
975 return Err(MobileError::InvalidUlcpFrame);
976 }
977 let values = advert_values(advert, &state)?;
978 if values.is_empty() {
981 return Err(MobileError::UnsupportedCapability);
982 }
983
984 state.expected.clear();
985 state.configuration_queue = state.writable(values);
986 let mut outbound = Vec::new();
987 state.start_configuration(&mut outbound)?;
988 Ok(state.update(outbound))
989 }
990
991 pub fn refresh(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
996 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
997 if state.stage != SessionStage::Attached || !state.expected.is_empty() {
998 return Err(MobileError::InvalidUlcpFrame);
999 }
1000 let capabilities = state
1001 .responses
1002 .get(&prop::CAPS)
1003 .ok_or(MobileError::InvalidUlcpFrame)?
1004 .value
1005 .clone();
1006 state.inspection_queue = ulcp_refresh_properties(capabilities)?.into();
1007 let mut outbound = Vec::new();
1008 state.start_refresh(&mut outbound)?;
1009 Ok(state.update(outbound))
1010 }
1011
1012 pub fn refresh_positioning(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
1024 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1025 if state.stage != SessionStage::Attached || !state.expected.is_empty() {
1026 return Err(MobileError::InvalidUlcpFrame);
1027 }
1028 let capabilities = state
1029 .responses
1030 .get(&prop::CAPS)
1031 .ok_or(MobileError::InvalidUlcpFrame)?
1032 .value
1033 .clone();
1034 if !decode_capabilities(&capabilities)?.contains(&cap::GNSS) {
1037 return Err(MobileError::InvalidUlcpFrame);
1038 }
1039 state.inspection_queue = VecDeque::from(vec![
1040 prop::GNSS_LOCATION,
1041 prop::GNSS_ALTITUDE,
1042 prop::GNSS_FIX,
1043 prop::GNSS_PRECISION,
1044 prop::GNSS_SATELLITES,
1045 ]);
1046 let mut outbound = Vec::new();
1047 state.start_refresh(&mut outbound)?;
1048 Ok(state.update(outbound))
1049 }
1050
1051 pub fn insert_device_channel_key(
1066 &self,
1067 channel_key: Vec<u8>,
1068 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1069 let id = dev_channel_id(&channel_key)?;
1070 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1071 state.begin_dev_peer_operation()?;
1072 let tid = state.allocate_tid();
1073 state
1074 .expected
1075 .insert(tid, ExpectedResponse::DevChannelInsert(id));
1076 let frame = ulcp_prop_insert(tid, prop::DEV_CHANNEL_KEYS, &channel_key)?;
1077 Ok(state.update(vec![frame]))
1078 }
1079
1080 pub fn remove_device_channel_key(
1089 &self,
1090 channel_key: Vec<u8>,
1091 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1092 let id = dev_channel_id(&channel_key)?;
1093 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1094 state.begin_dev_peer_operation()?;
1095 let tid = state.allocate_tid();
1096 state
1097 .expected
1098 .insert(tid, ExpectedResponse::DevChannelRemove(id));
1099 let frame = ulcp_prop_remove(tid, prop::DEV_CHANNEL_KEYS, &channel_key)?;
1100 Ok(state.update(vec![frame]))
1101 }
1102
1103 pub fn reconcile_host_channel_keys(
1121 &self,
1122 keys: Vec<Vec<u8>>,
1123 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1124 let mut desired = VecDeque::with_capacity(keys.len());
1125 let mut desired_ids = Vec::with_capacity(keys.len());
1126 for key in keys {
1127 desired_ids.push(dev_channel_id(&key)?);
1128 desired.push_back(key);
1129 }
1130
1131 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1132 if state.stage != SessionStage::Attached || !state.expected.is_empty() {
1133 return Err(MobileError::InvalidUlcpFrame);
1134 }
1135 if !state.has_capability(cap::HOST_KEYS)? {
1136 return Err(MobileError::UnsupportedCapability);
1137 }
1138
1139 let current = state
1140 .responses
1141 .get(&prop::HOST_CHANNEL_KEYS)
1142 .map(|entry| entry.value.clone())
1143 .unwrap_or_default();
1144 let current_ids: Vec<Vec<u8>> = current
1145 .chunks(items::CHANNEL_ID_LEN)
1146 .map(<[u8]>::to_vec)
1147 .collect();
1148
1149 if current_ids.iter().any(|id| !desired_ids.contains(id)) {
1153 let table: Vec<u8> = desired.iter().flatten().copied().collect();
1154 let tid = state.allocate_tid();
1155 state
1156 .expected
1157 .insert(tid, ExpectedResponse::HostChannelReplace);
1158 state.set_host_channel_ids(&desired_ids);
1159 let frame = ulcp_prop_set(tid, prop::HOST_CHANNEL_KEYS, table)?;
1160 return Ok(state.update(vec![frame]));
1161 }
1162
1163 desired.retain(|key| {
1164 !current_ids
1165 .iter()
1166 .any(|id| dev_channel_id(key).is_ok_and(|derived| &derived == id))
1167 });
1168 match state.next_host_channel_insert(desired) {
1169 Some(frame) => Ok(state.update(vec![frame])),
1170 None => Ok(state.update(Vec::new())),
1171 }
1172 }
1173
1174 pub fn insert_device_peer(
1184 &self,
1185 public_key: Vec<u8>,
1186 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1187 let public_key: [u8; 32] = public_key
1188 .try_into()
1189 .map_err(|_| MobileError::InvalidPublicKeyLength)?;
1190 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1191 state.begin_dev_peer_operation()?;
1192 let tid = state.allocate_tid();
1193 state
1194 .expected
1195 .insert(tid, ExpectedResponse::DevPeerInsert(public_key.to_vec()));
1196 let frame = ulcp_prop_insert(tid, prop::DEV_PEERS, &public_key)?;
1197 Ok(state.update(vec![frame]))
1198 }
1199
1200 pub fn remove_device_peer(
1208 &self,
1209 public_key: Vec<u8>,
1210 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1211 let public_key: [u8; 32] = public_key
1212 .try_into()
1213 .map_err(|_| MobileError::InvalidPublicKeyLength)?;
1214 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1215 state.begin_dev_peer_operation()?;
1216 let tid = state.allocate_tid();
1217 state
1218 .expected
1219 .insert(tid, ExpectedResponse::DevPeerRemove(public_key.to_vec()));
1220 let frame = ulcp_prop_remove(tid, prop::DEV_PEERS, &public_key)?;
1221 Ok(state.update(vec![frame]))
1222 }
1223
1224 pub fn transmit_raw(
1232 &self,
1233 data: Vec<u8>,
1234 nocca: bool,
1235 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1236 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1237 let raw_pipeline_active = state
1238 .expected
1239 .values()
1240 .all(|expected| matches!(expected, ExpectedResponse::RawTransmit));
1241 if state.stage != SessionStage::Attached || !raw_pipeline_active || data.is_empty() {
1242 return Err(MobileError::InvalidUlcpFrame);
1243 }
1244 let mut available_tid = None;
1245 for _ in 0..usize::from(frame::TID_MAX) {
1246 let candidate = state.allocate_tid();
1247 if !state.expected.contains_key(&candidate) {
1248 available_tid = Some(candidate);
1249 break;
1250 }
1251 }
1252 let tid = available_tid.ok_or(MobileError::InvalidUlcpFrame)?;
1253 state.expected.insert(tid, ExpectedResponse::RawTransmit);
1254 let mut metadata = [0u8; umsh_ulcp::TxMeta::WIRE_LEN];
1255 let flags = if nocca {
1256 umsh_ulcp::meta::TX_FLAG_NOCCA
1257 } else {
1258 0
1259 };
1260 umsh_ulcp::TxMeta {
1261 flags,
1262 ..umsh_ulcp::TxMeta::default()
1263 }
1264 .encode(&mut metadata)
1265 .map_err(|_| MobileError::InvalidUlcpFrame)?;
1266 let mut frame = vec![0u8; data.len() + 16];
1267 let len = umsh_ulcp::frame::str_send(
1268 &mut frame,
1269 tid,
1270 umsh_ulcp::ids::stream::PHY_RAW,
1271 &data,
1272 &metadata,
1273 )
1274 .map_err(|_| MobileError::InvalidUlcpFrame)?;
1275 frame.truncate(len);
1276 let mut update = state.update(vec![frame]);
1277 update.raw_transmit_started_transaction_id = Some(tid);
1278 Ok(update)
1279 }
1280
1281 pub fn consume(&self, frame: Vec<u8>) -> Result<UlcpSessionUpdateRecord, MobileError> {
1283 let parsed = Frame::parse(&frame).map_err(|_| MobileError::InvalidUlcpFrame)?;
1284 if parsed.command() == Some(Cmd::StrRecv) {
1285 if parsed.header.tid() != frame::TID_UNSOLICITED {
1286 return Err(MobileError::InvalidUlcpFrame);
1287 }
1288 let payload =
1289 StreamPayload::parse(parsed.payload).map_err(|_| MobileError::InvalidUlcpFrame)?;
1290 if payload.stream != umsh_ulcp::ids::stream::PHY_RAW {
1291 return Err(MobileError::InvalidUlcpFrame);
1292 }
1293 let metadata = BufferedRxMeta::decode(payload.metadata)
1294 .map_err(|_| MobileError::InvalidUlcpFrame)?;
1295 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1296 if state.stage == SessionStage::Idle {
1297 return Err(MobileError::InvalidUlcpFrame);
1298 }
1299 return Ok(state.update_with_received(vec![UlcpReceivedFrameRecord {
1300 data: payload.data.to_vec(),
1301 rssi_dbm: metadata.rx.rssi_dbm,
1302 lqi: metadata.rx.lqi.map(core::num::NonZeroU8::get),
1303 snr_cb: metadata.rx.snr_cb,
1304 was_buffered: metadata.flags & RX_FLAG_BUFFERED != 0,
1305 was_acknowledged: metadata.flags & RX_FLAG_ACKED != 0,
1306 age_seconds: metadata.age_s,
1307 }]));
1308 }
1309 let response = inspect_ulcp_property_frame(frame)?;
1310 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1311 let mut outbound = Vec::new();
1312 let mut raw_transmit_result = None;
1313 let mut operation_error = None;
1314
1315 if response.transaction_id == frame::TID_UNSOLICITED {
1316 if response.command == Cmd::PropIs as u8 {
1317 state
1318 .responses
1319 .insert(response.property_id, response.clone());
1320 }
1321 state.apply_property(&response)?;
1322 state.refresh_attached_snapshot(Some(response.property_id))?;
1323 return Ok(state.update(outbound));
1324 }
1325
1326 let expected = state
1327 .expected
1328 .remove(&response.transaction_id)
1329 .ok_or(MobileError::InvalidUlcpFrame)?;
1330 match expected {
1331 ExpectedResponse::Property(property) => {
1332 if response.property_id == prop::LAST_STATUS && property != prop::LAST_STATUS {
1333 let expected_property = matches!(
1338 state.stage,
1339 SessionStage::Inspection | SessionStage::Refreshing
1340 );
1341 if expected_property {
1342 state.responses.remove(&property);
1343 } else {
1344 operation_error = Some(ulcp_operation_error(
1345 format!("read property {property}"),
1346 response.value.as_slice(),
1347 )?);
1348 let optional_initial_property = state.stage == SessionStage::Initial
1349 && matches!(
1350 property,
1351 prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY | prop::HOST_KEY
1352 );
1353 state.stage_failure_pending |= !optional_initial_property;
1354 if state.stage == SessionStage::Initial && property == prop::HOST_KEY {
1355 state.host_key_unsupported = true;
1356 }
1357 }
1358 } else {
1359 if response.property_id != property || response.command != Cmd::PropIs as u8 {
1360 return Err(MobileError::InvalidUlcpFrame);
1361 }
1362 state.responses.insert(property, response.clone());
1363 state.apply_property(&response)?;
1364 }
1365 }
1366 ExpectedResponse::Claim => {
1367 if response.property_id == prop::LAST_STATUS {
1368 operation_error = Some(ulcp_operation_error(
1369 "claim host identity".to_owned(),
1370 response.value.as_slice(),
1371 )?);
1372 state.stage_failure_pending = true;
1373 } else {
1374 if response.property_id != prop::HOST_KEY
1375 || response.command != Cmd::PropIs as u8
1376 {
1377 return Err(MobileError::InvalidUlcpFrame);
1378 }
1379 state.radio_host_key = Some(response.value.clone());
1385 state.responses.insert(prop::HOST_KEY, response);
1386 if state.has_capability(cap::SAVE)? {
1387 state.stage = SessionStage::Saving;
1388 let tid = state.allocate_tid();
1389 state.expected.insert(tid, ExpectedResponse::Save);
1390 outbound.push(ulcp_save(tid)?);
1391 } else {
1392 state.start_inspection(&mut outbound)?;
1393 }
1394 }
1395 }
1396 ExpectedResponse::Save => {
1397 if response.property_id != prop::LAST_STATUS
1398 || response.command != Cmd::PropIs as u8
1399 {
1400 return Err(MobileError::InvalidUlcpFrame);
1401 }
1402 if inspect_ulcp_status(response.value.clone())? != 0 {
1403 operation_error = Some(ulcp_operation_error(
1404 "save claimed host identity".to_owned(),
1405 response.value.as_slice(),
1406 )?);
1407 state.stage_failure_pending = true;
1408 } else {
1409 state.start_inspection(&mut outbound)?;
1410 }
1411 }
1412 ExpectedResponse::ConfigurationProperty(property) => {
1413 if response.property_id == prop::LAST_STATUS {
1414 operation_error = Some(ulcp_operation_error(
1415 format!("set property {property}"),
1416 response.value.as_slice(),
1417 )?);
1418 state.stage_failure_pending = true;
1419 state.responses.remove(&property);
1423 } else if response.property_id != property || response.command != Cmd::PropIs as u8
1424 {
1425 return Err(MobileError::InvalidUlcpFrame);
1426 } else {
1427 state.responses.insert(property, response.clone());
1436 state.apply_property(&response)?;
1437 }
1438 }
1439 ExpectedResponse::SaveConfiguration => {
1440 if response.property_id != prop::LAST_STATUS
1441 || response.command != Cmd::PropIs as u8
1442 {
1443 return Err(MobileError::InvalidUlcpFrame);
1444 }
1445 if inspect_ulcp_status(response.value.clone())? != 0 {
1446 operation_error = Some(ulcp_operation_error(
1447 "save radio configuration".to_owned(),
1448 response.value.as_slice(),
1449 )?);
1450 state.stage_failure_pending = true;
1451 } else {
1452 state.finish_configuration()?;
1453 }
1454 }
1455 ExpectedResponse::RawTransmit => {
1456 if response.property_id != prop::LAST_STATUS
1457 || response.command != Cmd::PropIs as u8
1458 {
1459 return Err(MobileError::InvalidUlcpFrame);
1460 }
1461 let status_code = inspect_ulcp_status(response.value)?;
1462 let status = umsh_ulcp::Status(status_code);
1463 raw_transmit_result = Some(UlcpRawTransmitResultRecord {
1464 transaction_id: response.transaction_id,
1465 status_code,
1466 status_name: format!("{status:?}"),
1467 disposition: if status == umsh_ulcp::Status::OK {
1468 UlcpRawTransmitDisposition::Sent
1469 } else if status == umsh_ulcp::Status::BUSY
1470 || status == umsh_ulcp::Status::CCA_FAILURE
1471 {
1472 UlcpRawTransmitDisposition::Retry
1475 } else {
1476 UlcpRawTransmitDisposition::Rejected
1477 },
1478 });
1479 }
1480 ExpectedResponse::HostChannelInsert(mut remaining) => {
1481 if response.property_id == prop::LAST_STATUS {
1482 let error = ulcp_operation_error(
1483 "provision host channel key".to_owned(),
1484 response.value.as_slice(),
1485 )?;
1486 if error.status_code != umsh_ulcp::Status::ALREADY.0 {
1492 operation_error = Some(error);
1493 state.refresh_attached_snapshot(None)?;
1494 remaining.clear();
1495 }
1496 } else if response.property_id != prop::HOST_CHANNEL_KEYS
1497 || response.command != Cmd::PropInserted as u8
1498 {
1499 return Err(MobileError::InvalidUlcpFrame);
1500 }
1501 if let Some(frame) = state.next_host_channel_insert(remaining) {
1502 outbound.push(frame);
1503 } else {
1504 state.refresh_attached_snapshot(None)?;
1505 }
1506 }
1507 ExpectedResponse::HostChannelReplace => {
1508 if response.property_id == prop::LAST_STATUS {
1509 operation_error = Some(ulcp_operation_error(
1510 "provision host channel keys".to_owned(),
1511 response.value.as_slice(),
1512 )?);
1513 } else if response.property_id != prop::HOST_CHANNEL_KEYS
1514 || response.command != Cmd::PropIs as u8
1515 {
1516 return Err(MobileError::InvalidUlcpFrame);
1517 }
1518 state.refresh_attached_snapshot(None)?;
1519 }
1520 ExpectedResponse::DevChannelInsert(id) => {
1521 if response.property_id == prop::LAST_STATUS {
1522 let error = ulcp_operation_error(
1523 "insert device channel key".to_owned(),
1524 response.value.as_slice(),
1525 )?;
1526 if error.status_code == umsh_ulcp::Status::ALREADY.0 {
1528 state.patch_dev_channels(&id, true);
1529 state.refresh_attached_snapshot(None)?;
1530 }
1531 operation_error = Some(error);
1532 } else {
1533 if response.property_id != prop::DEV_CHANNEL_KEYS
1534 || response.command != Cmd::PropInserted as u8
1535 || response.value != id
1536 {
1537 return Err(MobileError::InvalidUlcpFrame);
1538 }
1539 state.patch_dev_channels(&id, true);
1540 if state.has_capability(cap::SAVE)? {
1541 let tid = state.allocate_tid();
1542 state
1543 .expected
1544 .insert(tid, ExpectedResponse::SaveDevChannels);
1545 outbound.push(ulcp_save(tid)?);
1546 }
1547 state.refresh_attached_snapshot(None)?;
1548 }
1549 }
1550 ExpectedResponse::DevChannelRemove(id) => {
1551 if response.property_id == prop::LAST_STATUS {
1552 let error = ulcp_operation_error(
1553 "remove device channel key".to_owned(),
1554 response.value.as_slice(),
1555 )?;
1556 if error.status_code == umsh_ulcp::Status::ITEM_NOT_FOUND.0 {
1557 state.patch_dev_channels(&id, false);
1558 state.refresh_attached_snapshot(None)?;
1559 }
1560 operation_error = Some(error);
1561 } else {
1562 if response.property_id != prop::DEV_CHANNEL_KEYS
1563 || response.command != Cmd::PropRemoved as u8
1564 || response.value != id
1565 {
1566 return Err(MobileError::InvalidUlcpFrame);
1567 }
1568 state.patch_dev_channels(&id, false);
1569 if state.has_capability(cap::SAVE)? {
1570 let tid = state.allocate_tid();
1571 state
1572 .expected
1573 .insert(tid, ExpectedResponse::SaveDevChannels);
1574 outbound.push(ulcp_save(tid)?);
1575 }
1576 state.refresh_attached_snapshot(None)?;
1577 }
1578 }
1579 ExpectedResponse::SaveDevChannels => {
1580 if response.property_id != prop::LAST_STATUS
1581 || response.command != Cmd::PropIs as u8
1582 {
1583 return Err(MobileError::InvalidUlcpFrame);
1584 }
1585 if inspect_ulcp_status(response.value.clone())? != 0 {
1586 operation_error = Some(ulcp_operation_error(
1587 "save device channel keys".to_owned(),
1588 response.value.as_slice(),
1589 )?);
1590 }
1591 }
1592 ExpectedResponse::DevPeerInsert(item) => {
1593 if response.property_id == prop::LAST_STATUS {
1594 let error = ulcp_operation_error(
1595 "insert device peer".to_owned(),
1596 response.value.as_slice(),
1597 )?;
1598 if error.status_code == umsh_ulcp::Status::ALREADY.0 {
1601 state.patch_dev_peers(&item, true);
1602 state.refresh_attached_snapshot(None)?;
1603 }
1604 operation_error = Some(error);
1605 } else {
1606 if response.property_id != prop::DEV_PEERS
1607 || response.command != Cmd::PropInserted as u8
1608 || response.value != item
1609 {
1610 return Err(MobileError::InvalidUlcpFrame);
1611 }
1612 state.patch_dev_peers(&item, true);
1613 if state.has_capability(cap::SAVE)? {
1614 let tid = state.allocate_tid();
1615 state.expected.insert(tid, ExpectedResponse::SaveDevPeers);
1616 outbound.push(ulcp_save(tid)?);
1617 }
1618 state.refresh_attached_snapshot(None)?;
1619 }
1620 }
1621 ExpectedResponse::DevPeerRemove(item) => {
1622 if response.property_id == prop::LAST_STATUS {
1623 let error = ulcp_operation_error(
1624 "remove device peer".to_owned(),
1625 response.value.as_slice(),
1626 )?;
1627 if error.status_code == umsh_ulcp::Status::ITEM_NOT_FOUND.0 {
1630 state.patch_dev_peers(&item, false);
1631 state.refresh_attached_snapshot(None)?;
1632 }
1633 operation_error = Some(error);
1634 } else {
1635 if response.property_id != prop::DEV_PEERS
1636 || response.command != Cmd::PropRemoved as u8
1637 || response.value != item
1638 {
1639 return Err(MobileError::InvalidUlcpFrame);
1640 }
1641 state.patch_dev_peers(&item, false);
1642 if state.has_capability(cap::SAVE)? {
1643 let tid = state.allocate_tid();
1644 state.expected.insert(tid, ExpectedResponse::SaveDevPeers);
1645 outbound.push(ulcp_save(tid)?);
1646 }
1647 state.refresh_attached_snapshot(None)?;
1648 }
1649 }
1650 ExpectedResponse::SaveDevPeers => {
1651 if response.property_id != prop::LAST_STATUS
1652 || response.command != Cmd::PropIs as u8
1653 {
1654 return Err(MobileError::InvalidUlcpFrame);
1655 }
1656 if inspect_ulcp_status(response.value.clone())? != 0 {
1657 operation_error = Some(ulcp_operation_error(
1661 "save device peers".to_owned(),
1662 response.value.as_slice(),
1663 )?);
1664 }
1665 }
1666 }
1667
1668 if state.expected.is_empty() {
1669 if state.stage_failure_pending {
1670 state.stage_failure_pending = false;
1671 state.recover_from_operation_failure(&mut outbound)?;
1672 } else {
1673 state.advance_completed_stage(&mut outbound)?;
1674 }
1675 }
1676 Ok(state.update_with(outbound, Vec::new(), raw_transmit_result, operation_error))
1677 }
1678
1679 pub fn reset(&self) -> UlcpSessionUpdateRecord {
1681 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1682 let generation = state.generation.wrapping_add(1);
1683 *state = UlcpSessionState {
1684 generation,
1685 mode: self.mode,
1686 ..UlcpSessionState::default()
1687 };
1688 state.update(Vec::new())
1689 }
1690
1691 pub fn abandon_raw_transmits(&self, transaction_ids: Vec<u8>) -> UlcpSessionUpdateRecord {
1695 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1696 for tid in transaction_ids {
1697 if matches!(
1698 state.expected.get(&tid),
1699 Some(ExpectedResponse::RawTransmit)
1700 ) {
1701 state.expected.remove(&tid);
1702 }
1703 }
1704 state.update(Vec::new())
1705 }
1706}
1707
1708impl MobileUlcpSession {
1709 fn with_mode(mode: UlcpAttachMode) -> Self {
1710 Self {
1711 inner: Mutex::new(UlcpSessionState {
1712 mode,
1713 ..UlcpSessionState::default()
1714 }),
1715 mode,
1716 }
1717 }
1718}
1719
1720impl UlcpSessionState {
1721 fn allocate_tid(&mut self) -> u8 {
1722 self.tids.allocate()
1723 }
1724
1725 fn get_property(&mut self, property: u32) -> Result<Vec<u8>, MobileError> {
1726 let tid = self.allocate_tid();
1727 self.expected
1728 .insert(tid, ExpectedResponse::Property(property));
1729 ulcp_prop_get(tid, property)
1730 }
1731
1732 fn phase(&self) -> UlcpSessionPhase {
1733 match self.stage {
1734 SessionStage::Idle => UlcpSessionPhase::Idle,
1735 SessionStage::Initial | SessionStage::Inspection | SessionStage::Saving => {
1736 UlcpSessionPhase::Synchronizing
1737 }
1738 SessionStage::Refreshing => UlcpSessionPhase::Attached,
1741 SessionStage::AwaitingHost => UlcpSessionPhase::AwaitingHost,
1742 SessionStage::Claiming => UlcpSessionPhase::Claiming,
1743 SessionStage::Configuring | SessionStage::SavingConfiguration => {
1744 UlcpSessionPhase::Configuring
1745 }
1746 SessionStage::Attached => UlcpSessionPhase::Attached,
1747 }
1748 }
1749
1750 fn ownership(&self) -> UlcpHostOwnership {
1751 if self.host_key_unsupported {
1752 return UlcpHostOwnership::Unsupported;
1753 }
1754 let Some(radio_key) = self.radio_host_key.as_deref() else {
1755 return UlcpHostOwnership::Unknown;
1756 };
1757 if radio_key.is_empty() {
1758 return UlcpHostOwnership::Unclaimed;
1759 }
1760 match self.selected_host_key {
1761 None => UlcpHostOwnership::LocalIdentityUnavailable,
1762 Some(selected) if radio_key == selected => UlcpHostOwnership::Ours,
1763 Some(_) => UlcpHostOwnership::OtherHost,
1764 }
1765 }
1766
1767 fn update(&mut self, outbound_frames: Vec<Vec<u8>>) -> UlcpSessionUpdateRecord {
1768 self.update_with(outbound_frames, Vec::new(), None, None)
1769 }
1770
1771 fn update_with_received(
1772 &mut self,
1773 received_frames: Vec<UlcpReceivedFrameRecord>,
1774 ) -> UlcpSessionUpdateRecord {
1775 self.update_with(Vec::new(), received_frames, None, None)
1776 }
1777
1778 fn update_with(
1779 &mut self,
1780 outbound_frames: Vec<Vec<u8>>,
1781 received_frames: Vec<UlcpReceivedFrameRecord>,
1782 raw_transmit_result: Option<UlcpRawTransmitResultRecord>,
1783 operation_error: Option<UlcpOperationErrorRecord>,
1784 ) -> UlcpSessionUpdateRecord {
1785 let raw_transmit_pending = self
1786 .expected
1787 .values()
1788 .any(|expected| matches!(expected, ExpectedResponse::RawTransmit));
1789 UlcpSessionUpdateRecord {
1790 outbound_frames,
1791 received_frames,
1792 snapshot: UlcpSessionSnapshotRecord {
1793 generation: self.generation,
1794 phase: self.phase(),
1795 host_ownership: self.ownership(),
1796 device_key: self.device_key.clone(),
1797 device_name: self.device_name.clone(),
1798 battery: self.battery.take(),
1801 alert: self.alert,
1802 time: self.time.take(),
1803 gnss: self.gnss.as_ref().map(gnss_record),
1804 provisioning: self.provisioning.clone(),
1805 },
1806 waiting_for_responses: !self.expected.is_empty(),
1807 raw_transmit_pending,
1808 raw_transmit_started_transaction_id: None,
1809 raw_transmit_result,
1810 operation_error,
1811 }
1812 }
1813
1814 fn apply_property(&mut self, response: &UlcpPropertyFrameRecord) -> Result<(), MobileError> {
1815 if response.command != Cmd::PropIs as u8 {
1816 return Ok(());
1819 }
1820 match response.property_id {
1821 prop::DEV_KEY => {
1822 if response.value.is_empty() {
1823 self.device_key = None;
1824 } else if response.value.len() == items::PUBLIC_KEY_LEN {
1825 self.device_key = Some(response.value.clone());
1826 } else {
1827 return Err(MobileError::InvalidUlcpFrame);
1828 }
1829 }
1830 prop::DEV_NAME => {
1831 let name = core::str::from_utf8(&response.value)
1832 .map_err(|_| MobileError::InvalidUlcpFrame)?;
1833 self.device_name = (!name.is_empty()).then(|| name.to_owned());
1834 }
1835 prop::BATTERY => {
1836 self.battery = Some(inspect_ulcp_battery(response.value.clone())?);
1837 }
1838 prop::ALERT => {
1839 self.alert = Some(inspect_ulcp_alert(response.value.clone())?);
1842 }
1843 prop::TIME => {
1844 self.time = Some(UlcpTimeRecord {
1848 epoch_seconds: decode_optional(&response.value, decode_u32)?,
1849 });
1850 }
1851 key if umsh_ulcp::gnss::is_positioning_property(key) => {
1852 self.gnss
1859 .get_or_insert(GnssSnapshot::SEARCHING)
1860 .absorb(key, &response.value)
1861 .map_err(|_| MobileError::InvalidUlcpFrame)?;
1862 }
1863 prop::HOST_KEY => {
1864 if !response.value.is_empty() && response.value.len() != items::PUBLIC_KEY_LEN {
1865 return Err(MobileError::InvalidUlcpFrame);
1866 }
1867 self.radio_host_key = Some(response.value.clone());
1868 }
1869 _ => {}
1870 }
1871 Ok(())
1872 }
1873
1874 fn attaches_without_host_decision(&self) -> bool {
1883 self.mode == UlcpAttachMode::Administrative
1884 || matches!(
1885 self.ownership(),
1886 UlcpHostOwnership::Ours | UlcpHostOwnership::Unsupported
1887 )
1888 }
1889
1890 fn begin_dev_peer_operation(&mut self) -> Result<(), MobileError> {
1893 if self.stage != SessionStage::Attached || !self.expected.is_empty() {
1894 return Err(MobileError::InvalidUlcpFrame);
1895 }
1896 if !self.has_capability(cap::DEV_IDENTITY)? {
1897 return Err(MobileError::InvalidUlcpFrame);
1898 }
1899 Ok(())
1900 }
1901
1902 fn next_host_channel_insert(&mut self, mut remaining: VecDeque<Vec<u8>>) -> Option<Vec<u8>> {
1905 let key = remaining.pop_front()?;
1906 let tid = self.allocate_tid();
1907 let frame = ulcp_prop_insert(tid, prop::HOST_CHANNEL_KEYS, &key).ok()?;
1908 self.expected
1909 .insert(tid, ExpectedResponse::HostChannelInsert(remaining));
1910 if let Ok(id) = dev_channel_id(&key) {
1911 self.push_host_channel_id(&id);
1912 }
1913 Some(frame)
1914 }
1915
1916 fn set_host_channel_ids(&mut self, ids: &[Vec<u8>]) {
1918 let value = ids.concat();
1919 self.host_channel_entry().value = value;
1920 }
1921
1922 fn push_host_channel_id(&mut self, id: &[u8]) {
1923 let entry = self.host_channel_entry();
1924 if !entry.value.chunks(items::CHANNEL_ID_LEN).any(|c| c == id) {
1925 entry.value.extend_from_slice(id);
1926 }
1927 }
1928
1929 fn host_channel_entry(&mut self) -> &mut UlcpPropertyFrameRecord {
1930 self.responses
1931 .entry(prop::HOST_CHANNEL_KEYS)
1932 .or_insert_with(|| UlcpPropertyFrameRecord {
1933 transaction_id: frame::TID_UNSOLICITED,
1934 command: Cmd::PropIs as u8,
1935 property_id: prop::HOST_CHANNEL_KEYS,
1936 value: Vec::new(),
1937 })
1938 }
1939
1940 fn patch_dev_channels(&mut self, id: &[u8], present: bool) {
1944 let entry = self
1945 .responses
1946 .entry(prop::DEV_CHANNEL_KEYS)
1947 .or_insert_with(|| UlcpPropertyFrameRecord {
1948 transaction_id: frame::TID_UNSOLICITED,
1949 command: Cmd::PropIs as u8,
1950 property_id: prop::DEV_CHANNEL_KEYS,
1951 value: Vec::new(),
1952 });
1953 let mut value = Vec::with_capacity(entry.value.len() + id.len());
1954 let mut found = false;
1955 for chunk in entry.value.chunks(items::CHANNEL_ID_LEN) {
1956 if chunk == id {
1957 found = true;
1958 if !present {
1959 continue;
1960 }
1961 }
1962 value.extend_from_slice(chunk);
1963 }
1964 if present && !found {
1965 value.extend_from_slice(id);
1966 }
1967 entry.value = value;
1968 }
1969
1970 fn patch_dev_peers(&mut self, key: &[u8], present: bool) {
1973 let entry =
1974 self.responses
1975 .entry(prop::DEV_PEERS)
1976 .or_insert_with(|| UlcpPropertyFrameRecord {
1977 transaction_id: frame::TID_UNSOLICITED,
1978 command: Cmd::PropIs as u8,
1979 property_id: prop::DEV_PEERS,
1980 value: Vec::new(),
1981 });
1982 let mut value = Vec::with_capacity(entry.value.len() + key.len());
1983 let mut found = false;
1984 for chunk in entry.value.chunks(items::PUBLIC_KEY_LEN) {
1985 if chunk == key {
1986 found = true;
1987 if !present {
1988 continue;
1989 }
1990 }
1991 value.extend_from_slice(chunk);
1992 }
1993 if present && !found {
1994 value.extend_from_slice(key);
1995 }
1996 entry.value = value;
1997 }
1998
1999 fn has_capability(&self, capability: u32) -> Result<bool, MobileError> {
2000 let capabilities = self
2001 .responses
2002 .get(&prop::CAPS)
2003 .ok_or(MobileError::InvalidUlcpFrame)?;
2004 Ok(decode_capabilities(&capabilities.value)?.contains(&capability))
2005 }
2006
2007 fn writable(&self, values: Vec<(u32, Vec<u8>)>) -> VecDeque<(u32, Vec<u8>)> {
2015 let Some(unreadable) = self
2016 .provisioning
2017 .as_ref()
2018 .map(|sync| sync.unreadable_properties.as_slice())
2019 .filter(|unreadable| !unreadable.is_empty())
2020 else {
2021 return values.into();
2022 };
2023 let dropped = |property: u32| {
2024 unreadable.contains(&property)
2025 || WHOLE_WRITE_GROUPS.iter().any(|group| {
2026 group.contains(&property) && group.iter().any(|part| unreadable.contains(part))
2027 })
2028 };
2029 values
2030 .into_iter()
2031 .filter(|(property, _)| !dropped(*property))
2032 .collect()
2033 }
2034
2035 fn advance_completed_stage(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2036 match self.stage {
2037 SessionStage::Initial => {
2038 let version = self
2039 .responses
2040 .get(&prop::PROTOCOL_VERSION)
2041 .ok_or(MobileError::InvalidUlcpFrame)?;
2042 if version.value != [PROTOCOL_MAJOR_VERSION, PROTOCOL_MINOR_VERSION] {
2043 return Err(MobileError::InvalidUlcpFrame);
2044 }
2045 let capabilities = self
2046 .responses
2047 .get(&prop::CAPS)
2048 .ok_or(MobileError::InvalidUlcpFrame)?;
2049 self.inspection_queue =
2050 ulcp_inspection_properties(capabilities.value.clone())?.into();
2051 let advertises_host_filter = self.has_capability(cap::HOST_FILTER)?;
2052 if advertises_host_filter == self.host_key_unsupported {
2053 return Err(MobileError::InvalidUlcpFrame);
2054 }
2055 if self.attaches_without_host_decision() {
2056 self.start_inspection(outbound)?;
2057 } else {
2058 self.stage = SessionStage::AwaitingHost;
2059 }
2060 }
2061 SessionStage::Inspection => self.start_inspection(outbound)?,
2062 SessionStage::Refreshing => self.start_refresh(outbound)?,
2063 SessionStage::Configuring => {
2064 if !self.configuration_queue.is_empty() {
2065 self.start_configuration(outbound)?;
2066 } else if self.has_capability(cap::SAVE)? {
2067 self.stage = SessionStage::SavingConfiguration;
2068 let tid = self.allocate_tid();
2069 self.expected
2070 .insert(tid, ExpectedResponse::SaveConfiguration);
2071 outbound.push(ulcp_save(tid)?);
2072 } else {
2073 self.finish_configuration()?;
2074 }
2075 }
2076 SessionStage::Claiming
2077 | SessionStage::Saving
2078 | SessionStage::AwaitingHost
2079 | SessionStage::Attached
2080 | SessionStage::SavingConfiguration
2081 | SessionStage::Idle => {}
2082 }
2083 Ok(())
2084 }
2085
2086 fn recover_from_operation_failure(
2089 &mut self,
2090 outbound: &mut Vec<Vec<u8>>,
2091 ) -> Result<(), MobileError> {
2092 self.configuration_queue.clear();
2093 self.inspection_queue.clear();
2094 match self.stage {
2095 SessionStage::Claiming => self.stage = SessionStage::AwaitingHost,
2096 SessionStage::Saving => {
2097 self.start_inspection(outbound)?;
2100 }
2101 SessionStage::Refreshing
2102 | SessionStage::Configuring
2103 | SessionStage::SavingConfiguration => {
2104 self.stage = SessionStage::Attached;
2108 }
2109 SessionStage::Inspection if self.provisioning.is_some() => {
2110 self.stage = SessionStage::Attached;
2111 }
2112 SessionStage::Initial | SessionStage::Inspection => {
2113 self.stage = SessionStage::Initial;
2117 }
2118 SessionStage::Attached | SessionStage::AwaitingHost | SessionStage::Idle => {}
2119 }
2120 Ok(())
2121 }
2122
2123 fn finish_configuration(&mut self) -> Result<(), MobileError> {
2124 let responses = self.responses.values().cloned().collect();
2125 self.provisioning = Some(inspect_ulcp_sync(responses)?);
2126 self.stage = SessionStage::Attached;
2127 Ok(())
2128 }
2129
2130 fn start_configuration(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2131 self.stage = SessionStage::Configuring;
2132 for _ in 0..usize::from(frame::TID_MAX) {
2133 let Some((property, value)) = self.configuration_queue.pop_front() else {
2134 break;
2135 };
2136 let tid = self.allocate_tid();
2137 self.expected
2138 .insert(tid, ExpectedResponse::ConfigurationProperty(property));
2139 outbound.push(ulcp_prop_set(tid, property, value)?);
2140 }
2141 Ok(())
2142 }
2143
2144 fn start_inspection(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2145 self.stage = SessionStage::Inspection;
2146 if self.inspection_queue.is_empty() {
2147 let responses = self.responses.values().cloned().collect();
2148 self.provisioning = Some(inspect_ulcp_sync(responses)?);
2149 self.stage = SessionStage::Attached;
2150 return Ok(());
2151 }
2152 for _ in 0..usize::from(frame::TID_MAX) {
2153 let Some(property) = self.inspection_queue.pop_front() else {
2154 break;
2155 };
2156 outbound.push(self.get_property(property)?);
2157 }
2158 Ok(())
2159 }
2160
2161 fn start_refresh(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2162 self.stage = SessionStage::Refreshing;
2163 if self.inspection_queue.is_empty() {
2164 let responses = self.responses.values().cloned().collect();
2165 self.provisioning = Some(inspect_ulcp_sync(responses)?);
2166 self.stage = SessionStage::Attached;
2167 return Ok(());
2168 }
2169 for _ in 0..usize::from(frame::TID_MAX) {
2170 let Some(property) = self.inspection_queue.pop_front() else {
2171 break;
2172 };
2173 outbound.push(self.get_property(property)?);
2174 }
2175 Ok(())
2176 }
2177
2178 fn refresh_attached_snapshot(
2195 &mut self,
2196 changed_property: Option<u32>,
2197 ) -> Result<(), MobileError> {
2198 if self.stage != SessionStage::Attached {
2199 return Ok(());
2200 }
2201 let responses = self.responses.values().cloned().collect();
2202 self.provisioning = Some(inspect_ulcp_sync(responses)?);
2203 if changed_property == Some(prop::HOST_KEY) && !self.attaches_without_host_decision() {
2204 self.stage = SessionStage::AwaitingHost;
2205 }
2206 Ok(())
2207 }
2208}
2209
2210#[uniffi::export]
2213pub fn ulcp_inspection_properties(capabilities: Vec<u8>) -> Result<Vec<u32>, MobileError> {
2214 let capabilities = decode_capabilities(&capabilities)?;
2215 validate_capability_dependencies(&capabilities)?;
2216 let has = |capability| capabilities.contains(&capability);
2217
2218 let mut properties = vec![
2219 prop::INTERFACE_TYPE,
2220 prop::PHY_ENABLED,
2221 prop::PHY_FREQ,
2222 prop::PHY_TX_POWER,
2223 ];
2224 if has(cap::PHY_LORA) {
2225 properties.extend([prop::PHY_LORA_BW, prop::PHY_LORA_SF, prop::PHY_LORA_CR]);
2226 }
2227 if has(cap::PHY_DUTY_LIMIT) {
2228 properties.extend([prop::PHY_DUTY_NOW, prop::PHY_DUTY_LIMIT]);
2229 }
2230 if has(cap::SAVE) {
2231 properties.push(prop::SAVED);
2232 }
2233 if has(cap::HOST_FILTER) {
2234 properties.push(prop::HOST_RX_FILTERS);
2235 }
2236 if has(cap::HOST_KEYS) {
2237 properties.extend([prop::HOST_CHANNEL_KEYS, prop::HOST_PEER_KEYS]);
2238 }
2239 if has(cap::HOST_RX_QUEUE) {
2240 properties.extend([prop::HOST_RX_QUEUE_COUNT, prop::HOST_RX_QUEUE_DROPPED]);
2241 }
2242 if has(cap::HOST_AUTO_ACK) {
2243 properties.push(prop::HOST_AUTO_ACK);
2244 }
2245 if has(cap::REPEATER) {
2246 properties.extend([
2247 prop::MAC_REPEATER_ENABLED,
2248 prop::MAC_REPEATER_REGIONS,
2249 prop::MAC_REPEATER_DEFAULT_REGION,
2250 prop::MAC_REPEATER_MIN_RSSI,
2251 prop::MAC_REPEATER_MIN_SNR,
2252 ]);
2253 }
2254 if has(cap::IDENT) {
2255 properties.extend([prop::IDENT_ROLE, prop::IDENT_MOBILE]);
2256 }
2257 if has(cap::DEV_IDENTITY) {
2258 properties.extend([
2259 prop::DEV_PEERS,
2260 prop::DEV_CHANNEL_KEYS,
2261 prop::DEV_DISCOVERABLE,
2262 ]);
2263 }
2264 if has(cap::ALERT) {
2265 properties.push(prop::ALERT);
2268 }
2269 if has(cap::TIME) {
2270 properties.extend([prop::TIME, prop::TZ_OFFSET]);
2275 }
2276 if has(cap::GNSS) {
2277 properties.extend([
2278 prop::GNSS_ENABLED,
2279 prop::GNSS_LOCATION,
2280 prop::GNSS_ALTITUDE,
2281 prop::GNSS_FIX,
2282 prop::GNSS_PRECISION,
2283 prop::GNSS_SATELLITES,
2284 prop::GNSS_IDENT_UPDATE,
2285 prop::GNSS_IDENT_PRECISION,
2286 prop::GNSS_TIME_TRUST,
2287 ]);
2288 }
2289 if has(cap::ADVERT) {
2290 properties.extend([
2291 prop::ADVERT_INTERVAL,
2292 prop::BEACON_INTERVAL,
2293 prop::STARTUP_BEACON,
2294 ]);
2295 }
2296 Ok(properties)
2297}
2298
2299fn ulcp_refresh_properties(capabilities: Vec<u8>) -> Result<Vec<u32>, MobileError> {
2300 let decoded = decode_capabilities(&capabilities)?;
2301 validate_capability_dependencies(&decoded)?;
2302 let has = |capability| decoded.contains(&capability);
2303 let mut properties = Vec::new();
2304 if has(cap::DEV_IDENTITY) {
2305 properties.push(prop::DEV_KEY);
2306 }
2307 if has(cap::DEV_NAME) {
2308 properties.push(prop::DEV_NAME);
2309 }
2310 if has(cap::BATTERY) {
2311 properties.push(prop::BATTERY);
2312 }
2313 if has(cap::HOST_FILTER) {
2314 properties.push(prop::HOST_KEY);
2315 }
2316 properties.extend(ulcp_inspection_properties(capabilities)?);
2317 Ok(properties)
2318}
2319
2320#[uniffi::export]
2330pub fn inspect_ulcp_sync(
2331 responses: Vec<UlcpPropertyFrameRecord>,
2332) -> Result<UlcpSyncRecord, MobileError> {
2333 let value = |key| property_value(&responses, key);
2334 let capabilities = decode_capabilities(value(prop::CAPS)?)?;
2335 validate_capability_dependencies(&capabilities)?;
2336 let has = |capability| capabilities.contains(&capability);
2337
2338 let interface = decode_exact_pui(value(prop::INTERFACE_TYPE)?)?;
2339 if interface != INTERFACE_TYPE {
2340 return Err(MobileError::InvalidUlcpFrame);
2341 }
2342 let phy_enabled = decode_bool(value(prop::PHY_ENABLED)?)?;
2343 let frequency_khz = decode_u32(value(prop::PHY_FREQ)?)?;
2344 let transmit_power_dbm = decode_i8(value(prop::PHY_TX_POWER)?)?;
2345
2346 let mut expected = ExpectedProperties {
2347 responses: &responses,
2348 unreadable: Vec::new(),
2349 };
2350 let lora = has(cap::PHY_LORA);
2351 let bandwidth_hz = expected.read(lora, prop::PHY_LORA_BW, decode_u32);
2352 let spreading_factor = expected.read(lora, prop::PHY_LORA_SF, decode_u8);
2353 let coding_rate_denom = expected.read(lora, prop::PHY_LORA_CR, decode_u8);
2354 let duty = has(cap::PHY_DUTY_LIMIT);
2355 let duty_cycle_now = expected.read(duty, prop::PHY_DUTY_NOW, decode_u16);
2356 let duty_cycle_limit = expected.read(duty, prop::PHY_DUTY_LIMIT, decode_u16);
2357 let saved = expected.read(has(cap::SAVE), prop::SAVED, decode_saved);
2358 let queue = has(cap::HOST_RX_QUEUE);
2359 let queued_frames = expected.read(queue, prop::HOST_RX_QUEUE_COUNT, decode_u16);
2360 let dropped_frames = expected.read(queue, prop::HOST_RX_QUEUE_DROPPED, decode_u32);
2361 let filter_count = expected.read(
2362 has(cap::HOST_FILTER),
2363 prop::HOST_RX_FILTERS,
2364 decode_filter_count,
2365 );
2366 let host_keys = has(cap::HOST_KEYS);
2367 let host_channel_count = expected.read(
2368 host_keys,
2369 prop::HOST_CHANNEL_KEYS,
2370 decode_fixed_count::<{ items::CHANNEL_ID_LEN }>,
2371 );
2372 let host_peer_count = expected.read(
2373 host_keys,
2374 prop::HOST_PEER_KEYS,
2375 decode_fixed_count::<{ items::PUBLIC_KEY_LEN }>,
2376 );
2377 let auto_ack = expected.read(has(cap::HOST_AUTO_ACK), prop::HOST_AUTO_ACK, decode_bool);
2378 let dev_identity = has(cap::DEV_IDENTITY);
2379 let dev_peer_keys = expected.read(
2380 dev_identity,
2381 prop::DEV_PEERS,
2382 decode_fixed_list::<{ items::PUBLIC_KEY_LEN }>,
2383 );
2384 let dev_channel_ids = expected.read(
2385 dev_identity,
2386 prop::DEV_CHANNEL_KEYS,
2387 decode_fixed_list::<{ items::CHANNEL_ID_LEN }>,
2388 );
2389
2390 let forwards = has(cap::REPEATER);
2393 let repeater_enabled = expected.read(forwards, prop::MAC_REPEATER_ENABLED, decode_bool);
2394 let regions = expected.read(forwards, prop::MAC_REPEATER_REGIONS, decode_region_list);
2395 let default_region = expected.read(
2396 forwards,
2397 prop::MAC_REPEATER_DEFAULT_REGION,
2398 decode_optional_region,
2399 );
2400 let min_rssi_dbm = expected.read(forwards, prop::MAC_REPEATER_MIN_RSSI, |value| {
2401 decode_optional(value, decode_i16)
2402 });
2403 let min_snr_db = expected.read(forwards, prop::MAC_REPEATER_MIN_SNR, |value| {
2404 decode_optional(value, decode_i8)
2405 });
2406 let repeater = (|| {
2407 Some(UlcpRepeaterSettingsRecord {
2408 enabled: repeater_enabled?,
2409 regions: regions?,
2410 default_region: default_region?,
2411 min_rssi_dbm: min_rssi_dbm?,
2412 min_snr_db: min_snr_db?,
2413 })
2414 })();
2415
2416 let ident = has(cap::IDENT);
2419 let ident_role = expected
2420 .read(ident, prop::IDENT_ROLE, |value| {
2421 decode_optional(value, decode_u8)
2422 })
2423 .flatten();
2424 let ident_mobile = expected.read(ident, prop::IDENT_MOBILE, decode_bool);
2425 let dev_discoverable = expected.read(dev_identity, prop::DEV_DISCOVERABLE, decode_bool);
2426
2427 let tz_offset_min = expected.read(has(cap::TIME), prop::TZ_OFFSET, decode_i16);
2428
2429 let positioning = has(cap::GNSS);
2432 let gnss_enabled = expected.read(positioning, prop::GNSS_ENABLED, decode_bool);
2433 let ident_update = expected.read(positioning, prop::GNSS_IDENT_UPDATE, decode_bool);
2434 let ident_precision = expected.read(positioning, prop::GNSS_IDENT_PRECISION, decode_precision);
2435 let time_trust = expected.read(positioning, prop::GNSS_TIME_TRUST, decode_bool);
2436 let gnss = (|| {
2437 Some(UlcpGnssSettingsRecord {
2438 enabled: gnss_enabled?,
2439 ident_update: ident_update?,
2440 ident_precision: ident_precision?,
2441 time_trust: time_trust?,
2442 })
2443 })();
2444
2445 let announces = has(cap::ADVERT);
2448 let advert_interval = expected.read(announces, prop::ADVERT_INTERVAL, decode_u32);
2449 let beacon_interval = expected.read(announces, prop::BEACON_INTERVAL, decode_u32);
2450 let startup_beacon = expected.read(announces, prop::STARTUP_BEACON, decode_bool);
2451 let advert = (|| {
2452 Some(UlcpAdvertSettingsRecord {
2453 advert_interval_seconds: advert_interval?,
2454 beacon_interval_seconds: beacon_interval?,
2455 startup_beacon: startup_beacon?,
2456 })
2457 })();
2458
2459 let mut unreadable_properties = expected.unreadable;
2460 unreadable_properties.sort_unstable();
2461
2462 Ok(UlcpSyncRecord {
2463 capability_count: capabilities
2464 .len()
2465 .try_into()
2466 .map_err(|_| MobileError::InvalidUlcpFrame)?,
2467 has_host_filtering: has(cap::HOST_FILTER),
2468 supports_offline_queue: has(cap::HOST_RX_QUEUE),
2469 supports_delegated_ack: has(cap::HOST_AUTO_ACK),
2470 supports_device_name: has(cap::DEV_NAME),
2471 supports_lora: has(cap::PHY_LORA),
2472 supports_duty_cycle_limit: has(cap::PHY_DUTY_LIMIT),
2473 supports_battery: has(cap::BATTERY),
2474 supports_repeater: has(cap::REPEATER),
2475 supports_ident: has(cap::IDENT),
2476 supports_device_identity: has(cap::DEV_IDENTITY),
2477 supports_time: has(cap::TIME),
2478 supports_gnss: positioning,
2479 supports_advert: announces,
2480 phy_enabled,
2481 frequency_khz,
2482 transmit_power_dbm,
2483 bandwidth_hz,
2484 spreading_factor,
2485 coding_rate_denom,
2486 duty_cycle_now,
2487 duty_cycle_limit,
2488 saved,
2489 queued_frames,
2490 dropped_frames,
2491 filter_count,
2492 host_channel_count,
2493 host_peer_count,
2494 auto_ack,
2495 repeater,
2496 dev_peer_keys,
2497 dev_channel_ids,
2498 ident_role,
2499 ident_mobile,
2500 dev_discoverable,
2501 tz_offset_min,
2502 gnss,
2503 advert,
2504 unreadable_properties,
2505 })
2506}
2507
2508const WHOLE_WRITE_GROUPS: [&[u32]; 4] = [
2513 &[prop::PHY_LORA_BW, prop::PHY_LORA_SF, prop::PHY_LORA_CR],
2514 &[
2515 prop::MAC_REPEATER_ENABLED,
2516 prop::MAC_REPEATER_REGIONS,
2517 prop::MAC_REPEATER_DEFAULT_REGION,
2518 prop::MAC_REPEATER_MIN_RSSI,
2519 prop::MAC_REPEATER_MIN_SNR,
2520 ],
2521 &[
2522 prop::GNSS_ENABLED,
2523 prop::GNSS_IDENT_UPDATE,
2524 prop::GNSS_IDENT_PRECISION,
2525 prop::GNSS_TIME_TRUST,
2526 ],
2527 &[
2528 prop::ADVERT_INTERVAL,
2529 prop::BEACON_INTERVAL,
2530 prop::STARTUP_BEACON,
2531 ],
2532];
2533
2534struct ExpectedProperties<'a> {
2537 responses: &'a [UlcpPropertyFrameRecord],
2538 unreadable: Vec<u32>,
2539}
2540
2541impl ExpectedProperties<'_> {
2542 fn read<T>(
2548 &mut self,
2549 gated_on: bool,
2550 key: u32,
2551 decode: impl FnOnce(&[u8]) -> Result<T, MobileError>,
2552 ) -> Option<T> {
2553 if !gated_on {
2554 return None;
2555 }
2556 match property_value(self.responses, key).and_then(decode) {
2557 Ok(value) => Some(value),
2558 Err(_) => {
2559 self.unreadable.push(key);
2560 None
2561 }
2562 }
2563 }
2564}
2565
2566fn property_value(responses: &[UlcpPropertyFrameRecord], key: u32) -> Result<&[u8], MobileError> {
2567 let mut matching = responses
2568 .iter()
2569 .filter(|response| response.property_id == key);
2570 let response = matching.next().ok_or(MobileError::InvalidUlcpFrame)?;
2571 if matching.next().is_some() || response.command != Cmd::PropIs as u8 {
2572 return Err(MobileError::InvalidUlcpFrame);
2573 }
2574 Ok(&response.value)
2575}
2576
2577fn decode_capabilities(value: &[u8]) -> Result<Vec<u32>, MobileError> {
2578 let mut capabilities = Vec::new();
2579 let mut rest = value;
2580 while !rest.is_empty() {
2581 let (capability, used) = pui::decode(rest).map_err(|_| MobileError::InvalidUlcpFrame)?;
2582 if capabilities.contains(&capability) {
2583 return Err(MobileError::InvalidUlcpFrame);
2584 }
2585 capabilities.push(capability);
2586 rest = &rest[used..];
2587 }
2588 Ok(capabilities)
2589}
2590
2591fn validate_capability_dependencies(capabilities: &[u32]) -> Result<(), MobileError> {
2592 let has = |capability| capabilities.contains(&capability);
2593 if has(cap::HOST_RX_QUEUE) && !has(cap::HOST_FILTER)
2594 || has(cap::HOST_KEYS) && !has(cap::HOST_FILTER)
2595 || has(cap::HOST_AUTO_ACK) && (!has(cap::HOST_KEYS) || !has(cap::HOST_RX_QUEUE))
2596 || has(cap::REPEATER) && !has(cap::DEV_IDENTITY)
2599 || has(cap::IDENT) && !has(cap::DEV_IDENTITY)
2600 || has(cap::ADVERT) && !has(cap::DEV_IDENTITY)
2602 || has(cap::GNSS) && !has(cap::TIME)
2605 {
2606 return Err(MobileError::InvalidUlcpFrame);
2607 }
2608 Ok(())
2609}
2610
2611fn decode_exact_pui(value: &[u8]) -> Result<u32, MobileError> {
2612 let (decoded, used) = pui::decode(value).map_err(|_| MobileError::InvalidUlcpFrame)?;
2613 (used == value.len())
2614 .then_some(decoded)
2615 .ok_or(MobileError::InvalidUlcpFrame)
2616}
2617
2618fn decode_bool(value: &[u8]) -> Result<bool, MobileError> {
2619 match value {
2620 [0] => Ok(false),
2621 [1] => Ok(true),
2622 _ => Err(MobileError::InvalidUlcpFrame),
2623 }
2624}
2625
2626fn decode_saved(value: &[u8]) -> Result<SavedSnapshotRecord, MobileError> {
2627 match value {
2628 [saved::NONE] => Ok(SavedSnapshotRecord::None),
2629 [saved::CURRENT] => Ok(SavedSnapshotRecord::Current),
2630 [saved::FALLBACK] => Ok(SavedSnapshotRecord::Fallback),
2631 [saved::UNREADABLE] => Ok(SavedSnapshotRecord::Unreadable),
2632 _ => Err(MobileError::InvalidUlcpFrame),
2633 }
2634}
2635
2636fn decode_u16(value: &[u8]) -> Result<u16, MobileError> {
2637 value
2638 .try_into()
2639 .map(u16::from_le_bytes)
2640 .map_err(|_| MobileError::InvalidUlcpFrame)
2641}
2642
2643fn decode_u8(value: &[u8]) -> Result<u8, MobileError> {
2644 value
2645 .first()
2646 .copied()
2647 .filter(|_| value.len() == 1)
2648 .ok_or(MobileError::InvalidUlcpFrame)
2649}
2650
2651fn decode_i8(value: &[u8]) -> Result<i8, MobileError> {
2652 decode_u8(value).map(|value| value as i8)
2653}
2654
2655fn decode_precision(value: &[u8]) -> Result<u8, MobileError> {
2659 decode_u8(value)
2660 .ok()
2661 .filter(|bytes| (1..=MAX_PRECISION).contains(bytes))
2662 .ok_or(MobileError::InvalidUlcpFrame)
2663}
2664
2665fn decode_i16(value: &[u8]) -> Result<i16, MobileError> {
2666 value
2667 .try_into()
2668 .map(i16::from_le_bytes)
2669 .map_err(|_| MobileError::InvalidUlcpFrame)
2670}
2671
2672fn decode_optional<T>(
2674 value: &[u8],
2675 decode: impl Fn(&[u8]) -> Result<T, MobileError>,
2676) -> Result<Option<T>, MobileError> {
2677 if value.is_empty() {
2678 return Ok(None);
2679 }
2680 decode(value).map(Some)
2681}
2682
2683fn decode_region_list(value: &[u8]) -> Result<Vec<Vec<u8>>, MobileError> {
2689 if !value.len().is_multiple_of(items::REGION_CODE_LEN) {
2690 return Err(MobileError::InvalidUlcpFrame);
2691 }
2692 Ok(value
2693 .chunks(items::REGION_CODE_LEN)
2694 .map(<[u8]>::to_vec)
2695 .collect())
2696}
2697
2698fn decode_optional_region(value: &[u8]) -> Result<Option<Vec<u8>>, MobileError> {
2699 match value.len() {
2700 0 => Ok(None),
2701 items::REGION_CODE_LEN => Ok(Some(value.to_vec())),
2702 _ => Err(MobileError::InvalidUlcpFrame),
2703 }
2704}
2705
2706fn configuration_values(
2714 settings: UlcpRadioSettingsRecord,
2715 device_values: Vec<(u32, Vec<u8>)>,
2716) -> Vec<(u32, Vec<u8>)> {
2717 let mut values = Vec::new();
2718 if !settings.phy_enabled {
2719 values.push((prop::PHY_ENABLED, vec![0]));
2720 }
2721 if let Some(name) = settings.device_name {
2722 values.push((prop::DEV_NAME, name.into_bytes()));
2723 }
2724 values.extend([
2725 (
2726 prop::PHY_FREQ,
2727 settings.frequency_khz.to_le_bytes().to_vec(),
2728 ),
2729 (prop::PHY_TX_POWER, vec![settings.transmit_power_dbm as u8]),
2730 ]);
2731 if let (Some(bandwidth), Some(sf), Some(cr)) = (
2732 settings.bandwidth_hz,
2733 settings.spreading_factor,
2734 settings.coding_rate_denom,
2735 ) {
2736 values.extend([
2737 (prop::PHY_LORA_BW, bandwidth.to_le_bytes().to_vec()),
2738 (prop::PHY_LORA_SF, vec![sf]),
2739 (prop::PHY_LORA_CR, vec![cr]),
2740 ]);
2741 }
2742 if let Some(limit) = settings.duty_cycle_limit {
2743 values.push((prop::PHY_DUTY_LIMIT, limit.to_le_bytes().to_vec()));
2744 }
2745 values.extend(device_values);
2746 if settings.phy_enabled {
2747 values.push((prop::PHY_ENABLED, vec![1]));
2748 }
2749 values
2750}
2751
2752fn validate_device_settings(
2760 configuration: &UlcpDeviceConfigRecord,
2761 state: &UlcpSessionState,
2762) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
2763 let mut values = Vec::new();
2764
2765 let supports_ident = state.has_capability(cap::IDENT)?;
2766 if configuration.ident_mobile.is_some() != supports_ident
2767 || (configuration.ident_role.is_some() && !supports_ident)
2768 {
2769 return Err(MobileError::InvalidUlcpFrame);
2770 }
2771 if supports_ident {
2772 values.push((
2774 prop::IDENT_ROLE,
2775 configuration
2776 .ident_role
2777 .map(|role| vec![role])
2778 .unwrap_or_default(),
2779 ));
2780 values.push((
2781 prop::IDENT_MOBILE,
2782 vec![configuration.ident_mobile.unwrap_or(false) as u8],
2783 ));
2784 }
2785
2786 let supports_dev_identity = state.has_capability(cap::DEV_IDENTITY)?;
2787 if configuration.dev_discoverable.is_some() != supports_dev_identity {
2788 return Err(MobileError::InvalidUlcpFrame);
2789 }
2790 if let Some(discoverable) = configuration.dev_discoverable {
2791 values.push((prop::DEV_DISCOVERABLE, vec![discoverable as u8]));
2792 }
2793
2794 let supports_repeater = state.has_capability(cap::REPEATER)?;
2795 if configuration.repeater.is_some() != supports_repeater {
2796 return Err(MobileError::InvalidUlcpFrame);
2797 }
2798 if let Some(repeater) = &configuration.repeater {
2799 let mut regions = Vec::with_capacity(repeater.regions.len() * items::REGION_CODE_LEN);
2800 for region in &repeater.regions {
2801 if region.len() != items::REGION_CODE_LEN {
2802 return Err(MobileError::InvalidUlcpFrame);
2803 }
2804 regions.extend_from_slice(region);
2805 }
2806 if let Some(default_region) = &repeater.default_region {
2807 if default_region.len() != items::REGION_CODE_LEN {
2808 return Err(MobileError::InvalidUlcpFrame);
2809 }
2810 }
2811 values.extend([
2816 (prop::MAC_REPEATER_REGIONS, regions),
2817 (
2818 prop::MAC_REPEATER_DEFAULT_REGION,
2819 repeater.default_region.clone().unwrap_or_default(),
2820 ),
2821 (
2822 prop::MAC_REPEATER_MIN_RSSI,
2823 repeater
2824 .min_rssi_dbm
2825 .map(|rssi| rssi.to_le_bytes().to_vec())
2826 .unwrap_or_default(),
2827 ),
2828 (
2829 prop::MAC_REPEATER_MIN_SNR,
2830 repeater
2831 .min_snr_db
2832 .map(|snr| vec![snr as u8])
2833 .unwrap_or_default(),
2834 ),
2835 (prop::MAC_REPEATER_ENABLED, vec![repeater.enabled as u8]),
2836 ]);
2837 }
2838
2839 values.extend(positioning_values(
2840 configuration.gnss,
2841 configuration.tz_offset_min,
2842 state,
2843 )?);
2844 values.extend(advert_values(configuration.advert, state)?);
2845 Ok(values)
2846}
2847
2848fn advert_values(
2854 advert: Option<UlcpAdvertSettingsRecord>,
2855 state: &UlcpSessionState,
2856) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
2857 let announces = state.has_capability(cap::ADVERT)?;
2858 if advert.is_some() != announces {
2859 return Err(MobileError::InvalidUlcpFrame);
2860 }
2861 let Some(advert) = advert else {
2862 return Ok(Vec::new());
2863 };
2864 for interval in [
2868 advert.advert_interval_seconds,
2869 advert.beacon_interval_seconds,
2870 ] {
2871 if interval != 0
2872 && !(MIN_AUTO_ANNOUNCE_INTERVAL_S..=MAX_AUTO_ANNOUNCE_INTERVAL_S).contains(&interval)
2873 {
2874 return Err(MobileError::InvalidUlcpFrame);
2875 }
2876 }
2877 Ok(vec![
2878 (
2879 prop::ADVERT_INTERVAL,
2880 advert.advert_interval_seconds.to_le_bytes().to_vec(),
2881 ),
2882 (
2883 prop::BEACON_INTERVAL,
2884 advert.beacon_interval_seconds.to_le_bytes().to_vec(),
2885 ),
2886 (prop::STARTUP_BEACON, vec![advert.startup_beacon as u8]),
2887 ])
2888}
2889
2890fn positioning_values(
2902 gnss: Option<UlcpGnssSettingsRecord>,
2903 tz_offset_min: Option<i16>,
2904 state: &UlcpSessionState,
2905) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
2906 let mut values = Vec::new();
2907
2908 let keeps_time = state.has_capability(cap::TIME)?;
2909 if tz_offset_min.is_some() != keeps_time {
2910 return Err(MobileError::InvalidUlcpFrame);
2911 }
2912 if let Some(minutes) = tz_offset_min {
2913 if !(-12 * 60..=14 * 60).contains(&minutes) {
2917 return Err(MobileError::InvalidUlcpFrame);
2918 }
2919 values.push((prop::TZ_OFFSET, minutes.to_le_bytes().to_vec()));
2920 }
2921
2922 let positioning = state.has_capability(cap::GNSS)?;
2923 if gnss.is_some() != positioning {
2924 return Err(MobileError::InvalidUlcpFrame);
2925 }
2926 if let Some(gnss) = gnss {
2927 if !(1..=MAX_PRECISION).contains(&gnss.ident_precision) {
2928 return Err(MobileError::InvalidUlcpFrame);
2929 }
2930 values.extend([
2934 (prop::GNSS_IDENT_UPDATE, vec![gnss.ident_update as u8]),
2935 (prop::GNSS_IDENT_PRECISION, vec![gnss.ident_precision]),
2936 (prop::GNSS_TIME_TRUST, vec![gnss.time_trust as u8]),
2937 (prop::GNSS_ENABLED, vec![gnss.enabled as u8]),
2938 ]);
2939 }
2940 Ok(values)
2941}
2942
2943fn validate_radio_settings(
2944 settings: &UlcpRadioSettingsRecord,
2945 state: &UlcpSessionState,
2946) -> Result<(), MobileError> {
2947 if settings.frequency_khz == 0 {
2948 return Err(MobileError::InvalidUlcpFrame);
2949 }
2950 if let Some(name) = &settings.device_name {
2951 if !state.has_capability(cap::DEV_NAME)?
2952 || name.is_empty()
2953 || name.len() > 64
2954 || name.as_bytes().contains(&0)
2955 {
2956 return Err(MobileError::InvalidUlcpFrame);
2957 }
2958 }
2959 let lora = (
2960 settings.bandwidth_hz,
2961 settings.spreading_factor,
2962 settings.coding_rate_denom,
2963 );
2964 match lora {
2965 (None, None, None) if !state.has_capability(cap::PHY_LORA)? => {}
2966 (Some(bandwidth), Some(sf), Some(cr))
2967 if state.has_capability(cap::PHY_LORA)?
2968 && bandwidth > 0
2969 && (5..=12).contains(&sf)
2970 && (5..=8).contains(&cr) => {}
2971 _ => return Err(MobileError::InvalidUlcpFrame),
2972 }
2973 if settings.duty_cycle_limit.is_some() != state.has_capability(cap::PHY_DUTY_LIMIT)? {
2974 return Err(MobileError::InvalidUlcpFrame);
2975 }
2976 Ok(())
2977}
2978
2979fn decode_u32(value: &[u8]) -> Result<u32, MobileError> {
2980 value
2981 .try_into()
2982 .map(u32::from_le_bytes)
2983 .map_err(|_| MobileError::InvalidUlcpFrame)
2984}
2985
2986fn decode_fixed_list<const N: usize>(value: &[u8]) -> Result<Vec<Vec<u8>>, MobileError> {
2990 items::fixed_items::<N>(value)
2991 .map_err(|_| MobileError::InvalidUlcpFrame)?
2992 .map(|item| Ok(item.to_vec()))
2993 .collect()
2994}
2995
2996fn decode_fixed_count<const N: usize>(value: &[u8]) -> Result<u32, MobileError> {
2997 let count = items::fixed_items::<N>(value)
2998 .map_err(|_| MobileError::InvalidUlcpFrame)?
2999 .count();
3000 count.try_into().map_err(|_| MobileError::InvalidUlcpFrame)
3001}
3002
3003fn decode_filter_count(value: &[u8]) -> Result<u32, MobileError> {
3004 let mut count = 0u32;
3005 for item in items::prefixed_items(value) {
3006 let item = item.map_err(|_| MobileError::InvalidUlcpFrame)?;
3007 Filter::decode(item).map_err(|_| MobileError::InvalidUlcpFrame)?;
3008 count = count.checked_add(1).ok_or(MobileError::InvalidUlcpFrame)?;
3009 }
3010 Ok(count)
3011}
3012
3013#[uniffi::export]
3016pub fn ulcp_gatt_segments(
3017 frame: Vec<u8>,
3018 maximum_value_length: u16,
3019) -> Result<Vec<GattSegmentRecord>, MobileError> {
3020 let segment_payload = usize::from(maximum_value_length)
3021 .checked_sub(1)
3022 .filter(|length| *length > 0)
3023 .ok_or(MobileError::InvalidGattSegment)?;
3024 if frame.len() > MAX_FRAME {
3025 return Err(MobileError::InvalidUlcpFrame);
3026 }
3027
3028 Ok(gatt::segments(&frame, segment_payload)
3029 .map(|segment| {
3030 let mut value = vec![0; segment.payload().len() + 1];
3031 let length = segment
3032 .write_to(&mut value)
3033 .expect("sized from the segment payload");
3034 value.truncate(length);
3035 GattSegmentRecord { value }
3036 })
3037 .collect())
3038}
3039
3040#[uniffi::export]
3042pub fn ulcp_prop_get(transaction_id: u8, property_id: u32) -> Result<Vec<u8>, MobileError> {
3043 let mut output = [0; 8];
3044 let length = frame::prop_get(&mut output, transaction_id, property_id)
3045 .map_err(|_| MobileError::InvalidUlcpFrame)?;
3046 Ok(output[..length].to_vec())
3047}
3048
3049#[uniffi::export]
3051pub fn ulcp_prop_set(
3052 transaction_id: u8,
3053 property_id: u32,
3054 value: Vec<u8>,
3055) -> Result<Vec<u8>, MobileError> {
3056 if value.len() > MAX_FRAME {
3057 return Err(MobileError::InvalidUlcpFrame);
3058 }
3059 let mut output = vec![0; MAX_FRAME];
3060 let length = frame::prop_set(&mut output, transaction_id, property_id, &value)
3061 .map_err(|_| MobileError::InvalidUlcpFrame)?;
3062 output.truncate(length);
3063 Ok(output)
3064}
3065
3066fn ulcp_prop_insert(
3069 transaction_id: u8,
3070 property_id: u32,
3071 item: &[u8],
3072) -> Result<Vec<u8>, MobileError> {
3073 let mut output = vec![0; MAX_FRAME];
3074 let length = frame::prop_insert(&mut output, transaction_id, property_id, item)
3075 .map_err(|_| MobileError::InvalidUlcpFrame)?;
3076 output.truncate(length);
3077 Ok(output)
3078}
3079
3080fn ulcp_prop_remove(
3083 transaction_id: u8,
3084 property_id: u32,
3085 selector: &[u8],
3086) -> Result<Vec<u8>, MobileError> {
3087 let mut output = vec![0; MAX_FRAME];
3088 let length = frame::prop_remove(&mut output, transaction_id, property_id, selector)
3089 .map_err(|_| MobileError::InvalidUlcpFrame)?;
3090 output.truncate(length);
3091 Ok(output)
3092}
3093
3094#[uniffi::export]
3099pub fn ulcp_max_dev_peers() -> u8 {
3100 8
3101}
3102
3103#[uniffi::export]
3107pub fn ulcp_max_dev_channels() -> u8 {
3108 8
3109}
3110
3111fn dev_channel_id(channel_key: &[u8]) -> Result<Vec<u8>, MobileError> {
3113 let bytes: [u8; items::CHANNEL_KEY_LEN] = channel_key
3114 .try_into()
3115 .map_err(|_| MobileError::InvalidChannelKeyLength)?;
3116 Ok(crate::derive_channel_id(bytes.to_vec())?)
3117}
3118
3119#[uniffi::export]
3121pub fn ulcp_save(transaction_id: u8) -> Result<Vec<u8>, MobileError> {
3122 let mut output = [0; 2];
3123 let length =
3124 frame::save(&mut output, transaction_id).map_err(|_| MobileError::InvalidUlcpFrame)?;
3125 Ok(output[..length].to_vec())
3126}
3127
3128#[uniffi::export]
3130pub fn ulcp_factory_reset(transaction_id: u8) -> Result<Vec<u8>, MobileError> {
3131 let mut output = [0; 2];
3132 let length = frame::factory_reset(&mut output, transaction_id)
3133 .map_err(|_| MobileError::InvalidUlcpFrame)?;
3134 Ok(output[..length].to_vec())
3135}
3136
3137#[uniffi::export]
3139pub fn inspect_ulcp_status(value: Vec<u8>) -> Result<u32, MobileError> {
3140 decode_exact_pui(&value)
3141}
3142
3143fn ulcp_operation_error(
3144 operation: String,
3145 value: &[u8],
3146) -> Result<UlcpOperationErrorRecord, MobileError> {
3147 let status_code = inspect_ulcp_status(value.to_vec())?;
3148 let status = umsh_ulcp::Status(status_code);
3149 if status == umsh_ulcp::Status::OK {
3150 return Err(MobileError::InvalidUlcpFrame);
3154 }
3155 Ok(UlcpOperationErrorRecord {
3156 operation,
3157 status_code,
3158 status_name: format!("{status:?}"),
3159 })
3160}
3161
3162#[uniffi::export]
3164pub fn inspect_ulcp_property_frame(bytes: Vec<u8>) -> Result<UlcpPropertyFrameRecord, MobileError> {
3165 let parsed = PropertyNotification::parse(&bytes).map_err(|_| MobileError::InvalidUlcpFrame)?;
3166 let command = match parsed.kind {
3167 PropertyNotificationKind::Is => Cmd::PropIs,
3168 PropertyNotificationKind::Inserted => Cmd::PropInserted,
3169 PropertyNotificationKind::Removed => Cmd::PropRemoved,
3170 };
3171 Ok(UlcpPropertyFrameRecord {
3172 transaction_id: parsed.tid,
3173 command: command as u8,
3174 property_id: parsed.key,
3175 value: parsed.value.to_vec(),
3176 })
3177}
3178
3179#[uniffi::export]
3181pub fn inspect_ulcp_battery(value: Vec<u8>) -> Result<UlcpBatteryRecord, MobileError> {
3182 let battery = BatteryStatus::decode(&value).map_err(|_| MobileError::InvalidUlcpFrame)?;
3183 Ok(UlcpBatteryRecord {
3184 percentage: battery.level_percent,
3185 voltage_mv: battery.voltage_mv,
3186 charge_state: battery.charge_state.map(UlcpChargeState::from_wire),
3187 })
3188}
3189
3190#[uniffi::export]
3192pub fn inspect_ulcp_alert(value: Vec<u8>) -> Result<UlcpAlertState, MobileError> {
3193 let (code, consumed) = pui::decode(&value).map_err(|_| MobileError::InvalidUlcpFrame)?;
3194 if consumed != value.len() {
3195 return Err(MobileError::InvalidUlcpFrame);
3196 }
3197 AlertState::from_code(code)
3198 .map(UlcpAlertState::from_wire)
3199 .ok_or(MobileError::InvalidUlcpFrame)
3200}
3201
3202#[uniffi::export]
3211pub fn region_code_from_string(text: String) -> Result<Vec<u8>, MobileError> {
3212 text.parse::<RegionCode>()
3213 .map(|code| code.to_bytes().to_vec())
3214 .map_err(|_| MobileError::InvalidRegionCode)
3215}
3216
3217#[uniffi::export]
3221pub fn region_code_description(code: Vec<u8>) -> Result<String, MobileError> {
3222 let bytes: [u8; items::REGION_CODE_LEN] = code
3223 .try_into()
3224 .map_err(|_| MobileError::InvalidRegionCode)?;
3225 Ok(RegionCode::from_bytes(bytes).to_string())
3226}
3227
3228#[derive(uniffi::Object)]
3230pub struct MobileGattReassembler {
3231 inner: Mutex<Reassembler<MAX_FRAME>>,
3232}
3233
3234#[uniffi::export]
3235impl MobileGattReassembler {
3236 #[uniffi::constructor]
3237 pub fn new() -> Arc<Self> {
3238 Arc::new(Self {
3239 inner: Mutex::new(Reassembler::new()),
3240 })
3241 }
3242
3243 pub fn push(&self, segment: Vec<u8>) -> Result<Option<Vec<u8>>, MobileError> {
3246 let mut reassembler = self.inner.lock().expect("GATT reassembler mutex poisoned");
3247 match reassembler.push(&segment) {
3248 None => Ok(None),
3249 Some(Ok(frame)) => Ok(Some(frame.to_vec())),
3250 Some(Err(_)) => Err(MobileError::InvalidGattSegment),
3251 }
3252 }
3253
3254 pub fn reset(&self) {
3255 self.inner
3256 .lock()
3257 .expect("GATT reassembler mutex poisoned")
3258 .reset();
3259 }
3260}
3261
3262#[cfg(test)]
3263mod tests {
3264 use super::*;
3265 use umsh_ulcp::PropPayload;
3266
3267 fn response(property_id: u32, value: &[u8]) -> UlcpPropertyFrameRecord {
3268 UlcpPropertyFrameRecord {
3269 transaction_id: 1,
3270 command: Cmd::PropIs as u8,
3271 property_id,
3272 value: value.to_vec(),
3273 }
3274 }
3275
3276 fn encoded_capabilities(values: &[u32]) -> Vec<u8> {
3277 let mut encoded = Vec::new();
3278 for value in values {
3279 let mut bytes = [0; pui::MAX_LEN];
3280 let len = pui::encode(*value, &mut bytes).unwrap();
3281 encoded.extend_from_slice(&bytes[..len]);
3282 }
3283 encoded
3284 }
3285
3286 fn property_request(bytes: &[u8]) -> (u8, u32) {
3287 let parsed = Frame::parse(bytes).unwrap();
3288 assert_eq!(parsed.command(), Some(Cmd::PropGet));
3289 let (property, used) = pui::decode(parsed.payload).unwrap();
3290 assert_eq!(used, parsed.payload.len());
3291 (parsed.header.tid(), property)
3292 }
3293
3294 fn property_response(tid: u8, property: u32, value: &[u8]) -> Vec<u8> {
3295 let mut bytes = vec![0; MAX_FRAME];
3296 let length = frame::prop_is(&mut bytes, tid, property, value).unwrap();
3297 bytes.truncate(length);
3298 bytes
3299 }
3300
3301 fn answer_requests(
3302 session: &MobileUlcpSession,
3303 requests: Vec<Vec<u8>>,
3304 value: impl Fn(u32) -> (u32, Vec<u8>),
3305 ) -> UlcpSessionUpdateRecord {
3306 let mut last = None;
3307 for request in requests {
3308 let (tid, requested) = property_request(&request);
3309 let (returned, bytes) = value(requested);
3310 last = Some(
3311 session
3312 .consume(property_response(tid, returned, &bytes))
3313 .unwrap(),
3314 );
3315 }
3316 last.unwrap()
3317 }
3318
3319 fn commissionable_capabilities() -> Vec<u32> {
3322 vec![
3323 cap::HOST_FILTER,
3324 cap::SAVE,
3325 cap::DEV_NAME,
3326 cap::DEV_IDENTITY,
3327 cap::REPEATER,
3328 cap::IDENT,
3329 ]
3330 }
3331
3332 fn commissionable_value(property: u32) -> (u32, Vec<u8>) {
3335 let value = match property {
3336 prop::LAST_STATUS => vec![0],
3337 prop::PROTOCOL_VERSION => vec![6, 0],
3338 prop::CAPS => encoded_capabilities(&commissionable_capabilities()),
3339 prop::DEV_NAME => b"Ridge repeater".to_vec(),
3340 prop::DEV_KEY => vec![0x5A; 32],
3341 prop::BATTERY => Vec::new(),
3342 prop::INTERFACE_TYPE => vec![INTERFACE_TYPE as u8],
3343 prop::PHY_ENABLED => vec![1],
3344 prop::PHY_FREQ => 915_000u32.to_le_bytes().to_vec(),
3345 prop::PHY_TX_POWER => vec![14],
3346 prop::SAVED => vec![saved::CURRENT],
3347 prop::HOST_RX_FILTERS => Vec::new(),
3348 prop::MAC_REPEATER_ENABLED => vec![0],
3349 prop::MAC_REPEATER_REGIONS
3350 | prop::MAC_REPEATER_DEFAULT_REGION
3351 | prop::MAC_REPEATER_MIN_RSSI
3352 | prop::MAC_REPEATER_MIN_SNR
3353 | prop::IDENT_ROLE
3354 | prop::DEV_PEERS
3355 | prop::DEV_CHANNEL_KEYS => Vec::new(),
3356 prop::IDENT_MOBILE => vec![0],
3357 prop::DEV_DISCOVERABLE => vec![1],
3358 other => unreachable!("unexpected property {other}"),
3359 };
3360 (property, value)
3361 }
3362
3363 fn drive_reads(
3365 session: &MobileUlcpSession,
3366 requests: Vec<Vec<u8>>,
3367 value: impl Fn(u32) -> (u32, Vec<u8>),
3368 ) -> UlcpSessionUpdateRecord {
3369 let mut pending = requests;
3370 let mut last = None;
3371 while !pending.is_empty() {
3372 let update = answer_requests(session, pending, &value);
3373 pending = update.outbound_frames.clone();
3374 last = Some(update);
3375 }
3376 last.expect("at least one batch")
3377 }
3378
3379 fn attach_commissionable(
3382 session: &MobileUlcpSession,
3383 selected_host_key: Option<Vec<u8>>,
3384 host_key: Vec<u8>,
3385 ) -> UlcpSessionUpdateRecord {
3386 let begin = session.begin(selected_host_key).unwrap();
3387 drive_reads(session, begin.outbound_frames, move |property| {
3388 if property == prop::HOST_KEY {
3389 (property, host_key.clone())
3390 } else {
3391 commissionable_value(property)
3392 }
3393 })
3394 }
3395
3396 fn attach_host_keys_capable(session: &MobileUlcpSession) -> UlcpSessionUpdateRecord {
3400 let mut capabilities = commissionable_capabilities();
3401 capabilities.push(cap::HOST_KEYS);
3402 let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
3403 drive_reads(
3404 session,
3405 begin.outbound_frames,
3406 move |property| match property {
3407 prop::CAPS => (property, encoded_capabilities(&capabilities)),
3408 prop::HOST_KEY => (property, vec![0xAA; 32]),
3409 prop::HOST_CHANNEL_KEYS | prop::HOST_PEER_KEYS => (property, Vec::new()),
3410 _ => commissionable_value(property),
3411 },
3412 )
3413 }
3414
3415 fn drive_configuration(
3419 session: &MobileUlcpSession,
3420 first_batch: Vec<Vec<u8>>,
3421 ) -> (HashMap<u32, Vec<u8>>, Vec<u32>, u8) {
3422 let mut pending = VecDeque::from(first_batch);
3423 let mut written = HashMap::new();
3424 let mut order = Vec::new();
3425 loop {
3426 let request = pending.pop_front().expect("configuration ends in a save");
3427 let parsed = Frame::parse(&request).unwrap();
3428 if parsed.command() == Some(Cmd::Save) {
3429 return (written, order, parsed.header.tid());
3430 }
3431 assert_eq!(parsed.command(), Some(Cmd::PropSet));
3432 let payload = PropPayload::parse(parsed.payload).unwrap();
3433 order.push(payload.key);
3434 written.insert(payload.key, payload.value.to_vec());
3435 let update = session
3436 .consume(property_response(
3437 parsed.header.tid(),
3438 payload.key,
3439 payload.value,
3440 ))
3441 .unwrap_or_else(|error| {
3442 panic!("write of property {} failed: {error:?}", payload.key)
3443 });
3444 pending.extend(update.outbound_frames);
3445 }
3446 }
3447
3448 #[test]
3449 fn exported_gatt_round_trip_uses_shared_codec() {
3450 let frame = ulcp_prop_get(3, 4_864).unwrap();
3451 let segments = ulcp_gatt_segments(frame.clone(), 4).unwrap();
3452 let receiver = MobileGattReassembler::new();
3453 let mut completed = None;
3454 for segment in segments {
3455 if let Some(value) = receiver.push(segment.value).unwrap() {
3456 completed = Some(value);
3457 }
3458 }
3459 assert_eq!(completed, Some(frame));
3460 }
3461
3462 #[test]
3463 fn property_response_is_validated_and_typed() {
3464 let mut bytes = [0; 16];
3465 let length = frame::prop_is(&mut bytes, 5, 64, &[1, 2, 3]).unwrap();
3466 assert_eq!(
3467 inspect_ulcp_property_frame(bytes[..length].to_vec()).unwrap(),
3468 UlcpPropertyFrameRecord {
3469 transaction_id: 5,
3470 command: Cmd::PropIs as u8,
3471 property_id: 64,
3472 value: vec![1, 2, 3],
3473 }
3474 );
3475 }
3476
3477 #[test]
3478 fn property_set_uses_shared_frame_codec() {
3479 let encoded = ulcp_prop_set(6, 96, vec![7; 32]).unwrap();
3480 let parsed = Frame::parse(&encoded).unwrap();
3481 assert_eq!(parsed.header.tid(), 6);
3482 assert_eq!(parsed.command(), Some(Cmd::PropSet));
3483 let payload = PropPayload::parse(parsed.payload).unwrap();
3484 assert_eq!(payload.key, 96);
3485 assert_eq!(payload.value, &[7; 32]);
3486 }
3487
3488 #[test]
3489 fn save_and_status_use_shared_frame_codec() {
3490 let encoded = ulcp_save(7).unwrap();
3491 let parsed = Frame::parse(&encoded).unwrap();
3492 assert_eq!(parsed.header.tid(), 7);
3493 assert_eq!(parsed.command(), Some(Cmd::Save));
3494 assert!(parsed.payload.is_empty());
3495
3496 assert_eq!(inspect_ulcp_status(vec![0]).unwrap(), 0);
3497 assert_eq!(
3498 inspect_ulcp_status(vec![0x80]),
3499 Err(MobileError::InvalidUlcpFrame)
3500 );
3501 }
3502
3503 #[test]
3504 fn exported_transport_rejects_invalid_bounds_and_segments() {
3505 assert_eq!(
3506 ulcp_gatt_segments(vec![0; MAX_FRAME + 1], 20),
3507 Err(MobileError::InvalidUlcpFrame)
3508 );
3509 assert_eq!(
3510 ulcp_gatt_segments(vec![1], 1),
3511 Err(MobileError::InvalidGattSegment)
3512 );
3513 assert_eq!(
3514 MobileGattReassembler::new().push(vec![]),
3515 Err(MobileError::InvalidGattSegment)
3516 );
3517 }
3518
3519 #[test]
3520 fn battery_reduction_preserves_supported_ui_fields() {
3521 assert_eq!(
3522 inspect_ulcp_battery(vec![0b110, 82, 1]).unwrap(),
3523 UlcpBatteryRecord {
3524 percentage: Some(82),
3525 voltage_mv: None,
3526 charge_state: Some(UlcpChargeState::Charging),
3527 }
3528 );
3529 assert_eq!(
3530 inspect_ulcp_battery(vec![0b111, 0xEC, 0x0E, 82, 0]).unwrap(),
3531 UlcpBatteryRecord {
3532 percentage: Some(82),
3533 voltage_mv: Some(3820),
3534 charge_state: Some(UlcpChargeState::Discharging),
3535 }
3536 );
3537 assert_eq!(
3538 inspect_ulcp_battery(vec![]).unwrap(),
3539 UlcpBatteryRecord {
3540 percentage: None,
3541 voltage_mv: None,
3542 charge_state: None,
3543 }
3544 );
3545 }
3546
3547 #[test]
3548 fn minimal_inspection_is_small_and_validated() {
3549 assert_eq!(
3550 ulcp_inspection_properties(vec![cap::WRITABLE_RAW_STREAM as u8]).unwrap(),
3551 [
3552 prop::INTERFACE_TYPE,
3553 prop::PHY_ENABLED,
3554 prop::PHY_FREQ,
3555 prop::PHY_TX_POWER,
3556 ]
3557 );
3558 let sync = inspect_ulcp_sync(vec![
3559 response(prop::CAPS, &[cap::WRITABLE_RAW_STREAM as u8]),
3560 response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
3561 response(prop::PHY_ENABLED, &[1]),
3562 response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
3563 response(prop::PHY_TX_POWER, &[14]),
3564 ])
3565 .unwrap();
3566 assert!(sync.phy_enabled);
3567 assert_eq!(sync.frequency_khz, 915_000);
3568 assert_eq!(sync.transmit_power_dbm, 14);
3569 assert!(!sync.has_host_filtering);
3570 assert_eq!(sync.queued_frames, None);
3571 }
3572
3573 #[test]
3574 fn full_inspection_reports_only_digest_counts() {
3575 let capabilities = (cap::HOST_FILTER..=cap::BATTERY)
3576 .map(|capability| capability as u8)
3577 .collect::<Vec<_>>();
3578 let properties = ulcp_inspection_properties(capabilities.clone()).unwrap();
3579 assert!(properties.contains(&prop::HOST_RX_FILTERS));
3580 assert!(properties.contains(&prop::HOST_RX_QUEUE_COUNT));
3581 assert!(properties.contains(&prop::HOST_AUTO_ACK));
3582
3583 let sync = inspect_ulcp_sync(vec![
3584 response(prop::CAPS, &capabilities),
3585 response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
3586 response(prop::PHY_ENABLED, &[1]),
3587 response(prop::PHY_FREQ, &868_100u32.to_le_bytes()),
3588 response(prop::PHY_TX_POWER, &[22]),
3589 response(prop::SAVED, &[1]),
3590 response(prop::HOST_RX_FILTERS, &[]),
3591 response(prop::HOST_CHANNEL_KEYS, &[1, 2, 3, 4]),
3592 response(prop::HOST_PEER_KEYS, &[7; 32]),
3593 response(prop::HOST_RX_QUEUE_COUNT, &3u16.to_le_bytes()),
3594 response(prop::HOST_RX_QUEUE_DROPPED, &4u32.to_le_bytes()),
3595 response(prop::HOST_AUTO_ACK, &[1]),
3596 response(prop::DEV_PEERS, &[7; 64]),
3597 response(prop::DEV_DISCOVERABLE, &[1]),
3598 ])
3599 .unwrap();
3600 assert_eq!(sync.saved, Some(SavedSnapshotRecord::Current));
3601 assert_eq!(sync.queued_frames, Some(3));
3602 assert_eq!(sync.dropped_frames, Some(4));
3603 assert_eq!(sync.filter_count, Some(0));
3604 assert_eq!(sync.host_channel_count, Some(2));
3605 assert_eq!(sync.host_peer_count, Some(1));
3606 assert_eq!(sync.auto_ack, Some(true));
3607 assert!(sync.supports_device_identity);
3610 assert_eq!(sync.dev_peer_keys, Some(vec![vec![7; 32], vec![7; 32]]));
3611 }
3612
3613 #[test]
3614 fn invalid_capability_dependencies_and_values_fail_closed() {
3615 assert_eq!(
3616 ulcp_inspection_properties(vec![cap::HOST_RX_QUEUE as u8]),
3617 Err(MobileError::InvalidUlcpFrame)
3618 );
3619 assert_eq!(
3620 inspect_ulcp_sync(vec![
3621 response(prop::CAPS, &[]),
3622 response(prop::INTERFACE_TYPE, &[7]),
3623 response(prop::PHY_ENABLED, &[1]),
3624 response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
3625 ]),
3626 Err(MobileError::InvalidUlcpFrame)
3627 );
3628 }
3629
3630 #[test]
3631 fn mobile_session_owns_sync_tids_and_attaches_transparent_radio() {
3632 let session = MobileUlcpSession::new();
3633 let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
3634 assert_eq!(begin.snapshot.phase, UlcpSessionPhase::Synchronizing);
3635 assert_eq!(begin.outbound_frames.len(), 7);
3636 assert_eq!(
3637 begin
3638 .outbound_frames
3639 .iter()
3640 .map(|request| property_request(request).0)
3641 .collect::<Vec<_>>(),
3642 [1, 2, 3, 4, 5, 6, 7]
3643 );
3644
3645 let inspection =
3646 answer_requests(&session, begin.outbound_frames, |property| match property {
3647 prop::LAST_STATUS => (property, vec![0]),
3648 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
3649 prop::CAPS => (property, vec![cap::WRITABLE_RAW_STREAM as u8]),
3650 prop::DEV_KEY => (property, Vec::new()),
3651 prop::DEV_NAME => (property, b"Transparent".to_vec()),
3652 prop::BATTERY => (property, Vec::new()),
3653 prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
3654 _ => unreachable!(),
3655 });
3656 assert_eq!(inspection.outbound_frames.len(), 4);
3657 assert_eq!(
3658 inspection.snapshot.host_ownership,
3659 UlcpHostOwnership::Unsupported
3660 );
3661
3662 let attached =
3663 answer_requests(
3664 &session,
3665 inspection.outbound_frames,
3666 |property| match property {
3667 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
3668 prop::PHY_ENABLED => (property, vec![1]),
3669 prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
3670 prop::PHY_TX_POWER => (property, vec![14]),
3671 _ => unreachable!(),
3672 },
3673 );
3674 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
3675 assert_eq!(
3676 attached.snapshot.device_name.as_deref(),
3677 Some("Transparent")
3678 );
3679 assert_eq!(
3680 attached.snapshot.provisioning.unwrap().frequency_khz,
3681 915_000
3682 );
3683 }
3684
3685 #[test]
3686 fn mobile_session_owns_claim_then_save_choreography() {
3687 let host_key = vec![0xAA; 32];
3688 let session = MobileUlcpSession::new();
3689 let begin = session.begin(Some(host_key.clone())).unwrap();
3690 let awaiting =
3691 answer_requests(&session, begin.outbound_frames, |property| match property {
3692 prop::LAST_STATUS => (property, vec![0]),
3693 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
3694 prop::CAPS => (property, vec![cap::HOST_FILTER as u8, cap::SAVE as u8]),
3695 prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY | prop::HOST_KEY => {
3696 (property, Vec::new())
3697 }
3698 _ => unreachable!(),
3699 });
3700 assert_eq!(awaiting.snapshot.phase, UlcpSessionPhase::AwaitingHost);
3701 assert_eq!(
3702 awaiting.snapshot.host_ownership,
3703 UlcpHostOwnership::Unclaimed
3704 );
3705
3706 let claim = session.claim(host_key.clone()).unwrap();
3707 assert_eq!(claim.snapshot.phase, UlcpSessionPhase::Claiming);
3708 assert_eq!(claim.outbound_frames.len(), 1);
3709 let parsed_claim = Frame::parse(&claim.outbound_frames[0]).unwrap();
3710 assert_eq!(parsed_claim.command(), Some(Cmd::PropSet));
3711 let payload = PropPayload::parse(parsed_claim.payload).unwrap();
3712 assert_eq!(payload.key, prop::HOST_KEY);
3713 assert_eq!(payload.value, host_key);
3714
3715 let save = session
3716 .consume(property_response(
3717 parsed_claim.header.tid(),
3718 prop::HOST_KEY,
3719 &host_key,
3720 ))
3721 .unwrap();
3722 assert_eq!(save.outbound_frames.len(), 1);
3723 let parsed_save = Frame::parse(&save.outbound_frames[0]).unwrap();
3724 assert_eq!(parsed_save.command(), Some(Cmd::Save));
3725
3726 let inspection = session
3727 .consume(property_response(
3728 parsed_save.header.tid(),
3729 prop::LAST_STATUS,
3730 &[0],
3731 ))
3732 .unwrap();
3733 assert_eq!(inspection.outbound_frames.len(), 6);
3734 let attached =
3735 answer_requests(
3736 &session,
3737 inspection.outbound_frames,
3738 |property| match property {
3739 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
3740 prop::PHY_ENABLED => (property, vec![1]),
3741 prop::PHY_FREQ => (property, 868_100u32.to_le_bytes().to_vec()),
3742 prop::PHY_TX_POWER => (property, vec![14]),
3743 prop::SAVED => (property, vec![1]),
3744 prop::HOST_RX_FILTERS => (property, Vec::new()),
3745 _ => unreachable!(),
3746 },
3747 );
3748 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
3749 assert_eq!(attached.snapshot.host_ownership, UlcpHostOwnership::Ours);
3750 assert_eq!(
3751 attached.snapshot.provisioning.unwrap().saved,
3752 Some(SavedSnapshotRecord::Current)
3753 );
3754
3755 let changed_host = session
3756 .consume(property_response(
3757 frame::TID_UNSOLICITED,
3758 prop::HOST_KEY,
3759 &[0xBB; 32],
3760 ))
3761 .unwrap();
3762 assert_eq!(changed_host.snapshot.phase, UlcpSessionPhase::AwaitingHost);
3763 assert_eq!(
3764 changed_host.snapshot.host_ownership,
3765 UlcpHostOwnership::OtherHost
3766 );
3767 }
3768
3769 #[test]
3770 fn administrative_session_attaches_without_claiming_anyones_radio() {
3771 let phone = vec![0xAA; 32];
3772 let other_phone = vec![0xBB; 32];
3773
3774 let session = MobileUlcpSession::administrative();
3778 let attached = attach_commissionable(&session, Some(phone.clone()), other_phone.clone());
3779 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
3780 assert_eq!(
3781 attached.snapshot.host_ownership,
3782 UlcpHostOwnership::OtherHost
3783 );
3784 assert_eq!(
3785 session.claim(phone.clone()),
3786 Err(MobileError::AdministrativeSession)
3787 );
3788
3789 let unclaimed = MobileUlcpSession::administrative();
3792 let attached = attach_commissionable(&unclaimed, Some(phone.clone()), Vec::new());
3793 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
3794 assert_eq!(
3795 attached.snapshot.host_ownership,
3796 UlcpHostOwnership::Unclaimed
3797 );
3798
3799 let tethered = MobileUlcpSession::new();
3802 let begin = tethered.begin(Some(phone.clone())).unwrap();
3803 let awaiting = answer_requests(&tethered, begin.outbound_frames, move |property| {
3804 if property == prop::HOST_KEY {
3805 (property, other_phone.clone())
3806 } else {
3807 commissionable_value(property)
3808 }
3809 });
3810 assert_eq!(awaiting.snapshot.phase, UlcpSessionPhase::AwaitingHost);
3811
3812 let pushed = session
3815 .consume(property_response(
3816 frame::TID_UNSOLICITED,
3817 prop::HOST_KEY,
3818 &[0xCC; 32],
3819 ))
3820 .unwrap();
3821 assert_eq!(pushed.snapshot.phase, UlcpSessionPhase::Attached);
3822 assert_eq!(pushed.snapshot.host_ownership, UlcpHostOwnership::OtherHost);
3823 }
3824
3825 #[test]
3826 fn attached_snapshot_reports_the_devices_own_domain() {
3827 let session = MobileUlcpSession::administrative();
3828 let attached = attach_commissionable(&session, None, Vec::new());
3829 let provisioning = attached.snapshot.provisioning.unwrap();
3830 assert!(provisioning.supports_repeater);
3831 assert!(provisioning.supports_ident);
3832 assert_eq!(provisioning.ident_role, None);
3833 assert_eq!(provisioning.ident_mobile, Some(false));
3834 assert_eq!(
3835 provisioning.repeater,
3836 Some(UlcpRepeaterSettingsRecord {
3837 enabled: false,
3838 regions: Vec::new(),
3839 default_region: None,
3840 min_rssi_dbm: None,
3841 min_snr_db: None,
3842 })
3843 );
3844
3845 let plain = inspect_ulcp_sync(vec![
3848 response(prop::CAPS, &[cap::WRITABLE_RAW_STREAM as u8]),
3849 response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
3850 response(prop::PHY_ENABLED, &[1]),
3851 response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
3852 response(prop::PHY_TX_POWER, &[14]),
3853 ])
3854 .unwrap();
3855 assert!(!plain.supports_repeater);
3856 assert!(!plain.supports_ident);
3857 assert_eq!(plain.repeater, None);
3858 assert_eq!(plain.ident_mobile, None);
3859 }
3860
3861 #[test]
3862 fn repeater_policy_round_trips_through_the_sync_reducer() {
3863 let capabilities = encoded_capabilities(&commissionable_capabilities());
3864 let sync = inspect_ulcp_sync(vec![
3865 response(prop::CAPS, &capabilities),
3866 response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
3867 response(prop::PHY_ENABLED, &[1]),
3868 response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
3869 response(prop::PHY_TX_POWER, &[14]),
3870 response(prop::SAVED, &[saved::CURRENT]),
3871 response(prop::HOST_RX_FILTERS, &[]),
3872 response(prop::MAC_REPEATER_ENABLED, &[1]),
3873 response(prop::MAC_REPEATER_REGIONS, &[0x78, 0x53, 0x7C, 0x0F]),
3875 response(prop::MAC_REPEATER_DEFAULT_REGION, &[0x78, 0x53]),
3876 response(prop::MAC_REPEATER_MIN_RSSI, &(-115i16).to_le_bytes()),
3877 response(prop::MAC_REPEATER_MIN_SNR, &[(-7i8) as u8]),
3878 response(prop::IDENT_ROLE, &[3]),
3879 response(prop::IDENT_MOBILE, &[1]),
3880 response(prop::DEV_PEERS, &[]),
3881 response(prop::DEV_DISCOVERABLE, &[1]),
3882 ])
3883 .unwrap();
3884 assert_eq!(
3885 sync.repeater,
3886 Some(UlcpRepeaterSettingsRecord {
3887 enabled: true,
3888 regions: vec![vec![0x78, 0x53], vec![0x7C, 0x0F]],
3889 default_region: Some(vec![0x78, 0x53]),
3890 min_rssi_dbm: Some(-115),
3891 min_snr_db: Some(-7),
3892 })
3893 );
3894 assert_eq!(sync.ident_role, Some(3));
3895 assert_eq!(sync.ident_mobile, Some(true));
3896
3897 let malformed = |property, value: &[u8]| {
3902 let mut responses = vec![
3903 response(prop::CAPS, &capabilities),
3904 response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
3905 response(prop::PHY_ENABLED, &[1]),
3906 response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
3907 response(prop::PHY_TX_POWER, &[14]),
3908 response(prop::SAVED, &[saved::CURRENT]),
3909 response(prop::MAC_REPEATER_ENABLED, &[0]),
3910 response(prop::MAC_REPEATER_REGIONS, &[]),
3911 response(prop::MAC_REPEATER_DEFAULT_REGION, &[]),
3912 response(prop::MAC_REPEATER_MIN_RSSI, &[]),
3913 response(prop::MAC_REPEATER_MIN_SNR, &[]),
3914 response(prop::IDENT_ROLE, &[]),
3915 response(prop::IDENT_MOBILE, &[0]),
3916 response(prop::DEV_PEERS, &[]),
3917 response(prop::DEV_DISCOVERABLE, &[1]),
3918 ];
3919 responses.retain(|entry| entry.property_id != property);
3920 responses.push(response(property, value));
3921 inspect_ulcp_sync(responses)
3922 };
3923 for property in [
3924 prop::MAC_REPEATER_REGIONS,
3925 prop::MAC_REPEATER_DEFAULT_REGION,
3926 prop::MAC_REPEATER_MIN_RSSI,
3927 ] {
3928 let sync = malformed(property, &[0x8D, 0x53, 0x7C]).expect("device still described");
3929 assert_eq!(sync.repeater, None, "property {property}");
3930 assert!(sync.supports_repeater, "property {property}");
3931 assert!(
3932 sync.unreadable_properties.contains(&property),
3933 "property {property}"
3934 );
3935 }
3936 assert_eq!(
3937 ulcp_inspection_properties(encoded_capabilities(&[cap::REPEATER])),
3938 Err(MobileError::InvalidUlcpFrame)
3939 );
3940 }
3941
3942 #[test]
3948 fn a_refused_property_still_yields_an_administrable_device() {
3949 let session = MobileUlcpSession::administrative();
3950 let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
3951 let attached = drive_reads(&session, begin.outbound_frames, |property| match property {
3952 prop::DEV_DISCOVERABLE | prop::MAC_REPEATER_MIN_RSSI => (
3955 prop::LAST_STATUS,
3956 vec![umsh_ulcp::Status::PROP_NOT_FOUND.0 as u8],
3957 ),
3958 prop::HOST_KEY => (property, vec![0xBB; 32]),
3959 other => commissionable_value(other),
3960 });
3961
3962 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
3963 assert_eq!(attached.operation_error, None);
3967 let sync = attached.snapshot.provisioning.expect("device described");
3968 assert!(sync.supports_device_identity);
3969 assert_eq!(sync.dev_discoverable, None);
3970 assert_eq!(
3971 sync.unreadable_properties,
3972 vec![prop::MAC_REPEATER_MIN_RSSI, prop::DEV_DISCOVERABLE]
3973 );
3974 assert_eq!(sync.dev_peer_keys, Some(Vec::new()));
3976 assert!(sync.supports_repeater);
3979 assert_eq!(sync.repeater, None);
3980
3981 let configured = session
3982 .configure_device(UlcpDeviceConfigRecord {
3983 radio: UlcpRadioSettingsRecord {
3984 device_name: None,
3985 phy_enabled: true,
3986 frequency_khz: 906_875,
3987 transmit_power_dbm: 20,
3988 bandwidth_hz: None,
3989 spreading_factor: None,
3990 coding_rate_denom: None,
3991 duty_cycle_limit: None,
3992 },
3993 ident_role: None,
3994 ident_mobile: Some(true),
3995 dev_discoverable: Some(true),
3996 repeater: Some(UlcpRepeaterSettingsRecord {
3997 enabled: false,
3998 regions: Vec::new(),
3999 default_region: None,
4000 min_rssi_dbm: None,
4001 min_snr_db: None,
4002 }),
4003 tz_offset_min: None,
4004 gnss: None,
4005 advert: None,
4006 })
4007 .unwrap();
4008 let (written, _, _) = drive_configuration(&session, configured.outbound_frames);
4009 assert!(!written.contains_key(&prop::DEV_DISCOVERABLE));
4014 for property in [
4015 prop::MAC_REPEATER_ENABLED,
4016 prop::MAC_REPEATER_REGIONS,
4017 prop::MAC_REPEATER_DEFAULT_REGION,
4018 prop::MAC_REPEATER_MIN_RSSI,
4019 prop::MAC_REPEATER_MIN_SNR,
4020 ] {
4021 assert!(!written.contains_key(&property), "property {property}");
4022 }
4023 assert_eq!(written.get(&prop::IDENT_MOBILE), Some(&vec![1]));
4024 assert_eq!(
4025 written.get(&prop::PHY_FREQ),
4026 Some(&906_875u32.to_le_bytes().to_vec())
4027 );
4028 }
4029
4030 #[test]
4031 fn configuring_a_device_writes_its_whole_domain_as_a_property_map() {
4032 let session = MobileUlcpSession::administrative();
4033 let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xBB; 32]);
4034 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
4035
4036 let configured = session
4037 .configure_device(UlcpDeviceConfigRecord {
4038 radio: UlcpRadioSettingsRecord {
4039 device_name: Some("Ridge repeater".into()),
4040 phy_enabled: true,
4041 frequency_khz: 906_875,
4042 transmit_power_dbm: 22,
4043 bandwidth_hz: None,
4044 spreading_factor: None,
4045 coding_rate_denom: None,
4046 duty_cycle_limit: None,
4047 },
4048 ident_role: Some(3),
4049 ident_mobile: Some(false),
4050 dev_discoverable: Some(false),
4051 repeater: Some(UlcpRepeaterSettingsRecord {
4052 enabled: true,
4053 regions: vec![vec![0x78, 0x53]],
4054 default_region: Some(vec![0x78, 0x53]),
4055 min_rssi_dbm: Some(-115),
4056 min_snr_db: Some(-7),
4057 }),
4058 tz_offset_min: None,
4059 gnss: None,
4060 advert: None,
4061 })
4062 .unwrap();
4063 assert_eq!(configured.snapshot.phase, UlcpSessionPhase::Configuring);
4064
4065 let (written, order, save_tid) = drive_configuration(&session, configured.outbound_frames);
4066
4067 assert_eq!(
4071 written,
4072 HashMap::from([
4073 (prop::DEV_NAME, b"Ridge repeater".to_vec()),
4074 (prop::PHY_FREQ, 906_875u32.to_le_bytes().to_vec()),
4075 (prop::PHY_TX_POWER, vec![22]),
4076 (prop::IDENT_ROLE, vec![3]),
4077 (prop::IDENT_MOBILE, vec![0]),
4078 (prop::DEV_DISCOVERABLE, vec![0]),
4079 (prop::MAC_REPEATER_REGIONS, vec![0x78, 0x53]),
4080 (prop::MAC_REPEATER_DEFAULT_REGION, vec![0x78, 0x53]),
4081 (
4082 prop::MAC_REPEATER_MIN_RSSI,
4083 (-115i16).to_le_bytes().to_vec()
4084 ),
4085 (prop::MAC_REPEATER_MIN_SNR, vec![(-7i8) as u8]),
4086 (prop::MAC_REPEATER_ENABLED, vec![1]),
4087 (prop::PHY_ENABLED, vec![1]),
4088 ])
4089 );
4090 assert_eq!(
4093 &order[order.len() - 2..],
4094 &[prop::MAC_REPEATER_ENABLED, prop::PHY_ENABLED]
4095 );
4096
4097 let attached = session
4098 .consume(property_response(save_tid, prop::LAST_STATUS, &[0]))
4099 .unwrap();
4100 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
4101 assert_eq!(
4102 attached.snapshot.host_ownership,
4103 UlcpHostOwnership::OtherHost
4104 );
4105 let provisioning = attached.snapshot.provisioning.unwrap();
4106 assert_eq!(provisioning.frequency_khz, 906_875);
4107 assert_eq!(provisioning.ident_role, Some(3));
4108 assert_eq!(
4109 provisioning.repeater,
4110 Some(UlcpRepeaterSettingsRecord {
4111 enabled: true,
4112 regions: vec![vec![0x78, 0x53]],
4113 default_region: Some(vec![0x78, 0x53]),
4114 min_rssi_dbm: Some(-115),
4115 min_snr_db: Some(-7),
4116 })
4117 );
4118 }
4119
4120 #[test]
4121 fn device_configuration_must_match_what_the_device_can_do() {
4122 let session = MobileUlcpSession::administrative();
4123 attach_commissionable(&session, None, Vec::new());
4124
4125 let radio = UlcpRadioSettingsRecord {
4126 device_name: None,
4127 phy_enabled: true,
4128 frequency_khz: 915_000,
4129 transmit_power_dbm: 14,
4130 bandwidth_hz: None,
4131 spreading_factor: None,
4132 coding_rate_denom: None,
4133 duty_cycle_limit: None,
4134 };
4135 let repeater = UlcpRepeaterSettingsRecord {
4136 enabled: true,
4137 regions: Vec::new(),
4138 default_region: None,
4139 min_rssi_dbm: None,
4140 min_snr_db: None,
4141 };
4142 let configure = |ident_role, ident_mobile, dev_discoverable, repeater| {
4143 session.configure_device(UlcpDeviceConfigRecord {
4144 radio: radio.clone(),
4145 ident_role,
4146 ident_mobile,
4147 dev_discoverable,
4148 repeater,
4149 tz_offset_min: None,
4150 gnss: None,
4151 advert: None,
4152 })
4153 };
4154
4155 assert_eq!(
4158 configure(Some(3), None, Some(true), Some(repeater.clone())),
4159 Err(MobileError::InvalidUlcpFrame)
4160 );
4161 assert_eq!(
4162 configure(None, Some(false), Some(true), None),
4163 Err(MobileError::InvalidUlcpFrame)
4164 );
4165 assert_eq!(
4166 configure(None, Some(false), None, Some(repeater.clone())),
4167 Err(MobileError::InvalidUlcpFrame)
4168 );
4169 assert_eq!(
4171 configure(
4172 None,
4173 Some(false),
4174 Some(true),
4175 Some(UlcpRepeaterSettingsRecord {
4176 regions: vec![vec![0x78]],
4177 ..repeater.clone()
4178 })
4179 ),
4180 Err(MobileError::InvalidUlcpFrame)
4181 );
4182 assert_eq!(
4183 configure(
4184 None,
4185 Some(false),
4186 Some(true),
4187 Some(UlcpRepeaterSettingsRecord {
4188 default_region: Some(vec![0x78, 0x53, 0x00]),
4189 ..repeater.clone()
4190 })
4191 ),
4192 Err(MobileError::InvalidUlcpFrame)
4193 );
4194
4195 let configured = configure(None, Some(true), Some(true), Some(repeater)).unwrap();
4198 let (written, ..) = drive_configuration(&session, configured.outbound_frames);
4199 assert_eq!(written.get(&prop::IDENT_ROLE), Some(&Vec::new()));
4200 assert_eq!(written.get(&prop::IDENT_MOBILE), Some(&vec![1]));
4201 assert_eq!(written.get(&prop::DEV_DISCOVERABLE), Some(&vec![1]));
4202 assert_eq!(written.get(&prop::MAC_REPEATER_REGIONS), Some(&Vec::new()));
4203 assert_eq!(written.get(&prop::MAC_REPEATER_MIN_RSSI), Some(&Vec::new()));
4204 }
4205
4206 #[test]
4207 fn region_codes_convert_between_text_and_wire_octets() {
4208 assert_eq!(region_code_from_string("SJC".into()).unwrap(), [0x78, 0x53]);
4209 assert_eq!(region_code_description(vec![0x78, 0x53]).unwrap(), "SJC");
4210 let named = region_code_from_string("Rogue Valley".into()).unwrap();
4213 assert_eq!(named, [0xDF, 0x6F]);
4214 let described = region_code_description(named.clone()).unwrap();
4215 assert_eq!(described, "0xDF6F");
4216 assert_eq!(region_code_from_string(described).unwrap(), named);
4217
4218 assert_eq!(
4219 region_code_from_string(" ".into()),
4220 Err(MobileError::InvalidRegionCode)
4221 );
4222 assert_eq!(
4223 region_code_description(vec![0x78]),
4224 Err(MobileError::InvalidRegionCode)
4225 );
4226 }
4227
4228 #[test]
4229 fn mobile_session_rejects_mismatched_transaction_response() {
4230 let session = MobileUlcpSession::new();
4231 let begin = session.begin(None).unwrap();
4232 let (tid, _) = property_request(&begin.outbound_frames[0]);
4233 assert_eq!(
4234 session.consume(property_response(tid, prop::PHY_FREQ, &[0; 4])),
4235 Err(MobileError::InvalidUlcpFrame)
4236 );
4237 }
4238
4239 #[test]
4240 fn mobile_session_emits_typed_raw_receive_during_sync() {
4241 let session = MobileUlcpSession::new();
4242 session.begin(None).unwrap();
4243
4244 let metadata = BufferedRxMeta {
4245 rx: umsh_ulcp::RxMeta {
4246 rssi_dbm: Some(-87),
4247 lqi: core::num::NonZeroU8::new(42),
4248 snr_cb: Some(125),
4249 },
4250 flags: RX_FLAG_BUFFERED | RX_FLAG_ACKED,
4251 age_s: 9,
4252 };
4253 let mut metadata_bytes = [0; BufferedRxMeta::WIRE_LEN];
4254 metadata.encode(&mut metadata_bytes).unwrap();
4255 let mut bytes = vec![0; MAX_FRAME];
4256 let len = frame::str_recv(
4257 &mut bytes,
4258 umsh_ulcp::ids::stream::PHY_RAW,
4259 &[1, 2, 3],
4260 &metadata_bytes,
4261 )
4262 .unwrap();
4263 bytes.truncate(len);
4264
4265 let update = session.consume(bytes).unwrap();
4266 assert_eq!(update.received_frames.len(), 1);
4267 assert_eq!(
4268 update.received_frames[0],
4269 UlcpReceivedFrameRecord {
4270 data: vec![1, 2, 3],
4271 rssi_dbm: Some(-87),
4272 lqi: Some(42),
4273 snr_cb: Some(125),
4274 was_buffered: true,
4275 was_acknowledged: true,
4276 age_seconds: 9,
4277 }
4278 );
4279 assert!(update.outbound_frames.is_empty());
4280 assert!(update.waiting_for_responses);
4281 }
4282
4283 #[test]
4284 fn mobile_session_reports_raw_transmit_rejection_without_ending_session() {
4285 let session = MobileUlcpSession::new();
4286 let begin = session.begin(None).unwrap();
4287 let inspection =
4288 answer_requests(&session, begin.outbound_frames, |property| match property {
4289 prop::LAST_STATUS => (property, vec![0]),
4290 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
4291 prop::CAPS => (property, vec![cap::WRITABLE_RAW_STREAM as u8]),
4292 prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY => (property, Vec::new()),
4293 prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
4294 _ => unreachable!(),
4295 });
4296 let attached =
4297 answer_requests(
4298 &session,
4299 inspection.outbound_frames,
4300 |property| match property {
4301 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
4302 prop::PHY_ENABLED => (property, vec![1]),
4303 prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
4304 prop::PHY_TX_POWER => (property, vec![14]),
4305 _ => unreachable!(),
4306 },
4307 );
4308 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
4309
4310 let transmit = session.transmit_raw(vec![1, 2, 3], false).unwrap();
4311 assert!(transmit.raw_transmit_pending);
4312 assert_eq!(transmit.raw_transmit_result, None);
4313 assert_eq!(transmit.outbound_frames.len(), 1);
4314 let second_transmit = session.transmit_raw(vec![4], false).unwrap();
4315 assert_ne!(
4316 transmit.raw_transmit_started_transaction_id,
4317 second_transmit.raw_transmit_started_transaction_id
4318 );
4319
4320 let request = Frame::parse(&transmit.outbound_frames[0]).unwrap();
4321 let rejected = session
4322 .consume(property_response(
4323 request.header.tid(),
4324 prop::LAST_STATUS,
4325 &[umsh_ulcp::Status::INVALID_STATE.0 as u8],
4326 ))
4327 .unwrap();
4328 assert_eq!(rejected.snapshot.phase, UlcpSessionPhase::Attached);
4329 assert!(rejected.raw_transmit_pending);
4330 assert_eq!(
4331 rejected.raw_transmit_result,
4332 Some(UlcpRawTransmitResultRecord {
4333 transaction_id: request.header.tid(),
4334 status_code: umsh_ulcp::Status::INVALID_STATE.0,
4335 status_name: "Status::INVALID_STATE".into(),
4336 disposition: UlcpRawTransmitDisposition::Rejected,
4337 })
4338 );
4339 let second_request = Frame::parse(&second_transmit.outbound_frames[0]).unwrap();
4340 let completed = session
4341 .consume(property_response(
4342 second_request.header.tid(),
4343 prop::LAST_STATUS,
4344 &[umsh_ulcp::Status::OK.0 as u8],
4345 ))
4346 .unwrap();
4347 assert!(!completed.raw_transmit_pending);
4348
4349 let retryable = session.transmit_raw(vec![5], false).unwrap();
4352 let request = Frame::parse(&retryable.outbound_frames[0]).unwrap();
4353 let busy = session
4354 .consume(property_response(
4355 request.header.tid(),
4356 prop::LAST_STATUS,
4357 &[umsh_ulcp::Status::BUSY.0 as u8],
4358 ))
4359 .unwrap();
4360 assert_eq!(
4361 busy.raw_transmit_result.unwrap().disposition,
4362 UlcpRawTransmitDisposition::Retry
4363 );
4364
4365 let abandoned = session.transmit_raw(vec![6], false).unwrap();
4366 let abandoned_request = Frame::parse(&abandoned.outbound_frames[0]).unwrap();
4367 assert!(
4368 !session
4369 .abandon_raw_transmits(vec![abandoned_request.header.tid()])
4370 .raw_transmit_pending
4371 );
4372
4373 let configured = session
4377 .configure(UlcpRadioSettingsRecord {
4378 device_name: None,
4379 phy_enabled: true,
4380 frequency_khz: 915_000,
4381 transmit_power_dbm: 14,
4382 bandwidth_hz: None,
4383 spreading_factor: None,
4384 coding_rate_denom: None,
4385 duty_cycle_limit: None,
4386 })
4387 .unwrap();
4388 let mut final_update = None;
4389 for (index, request) in configured.outbound_frames.into_iter().enumerate() {
4390 let parsed = Frame::parse(&request).unwrap();
4391 let payload = PropPayload::parse(parsed.payload).unwrap();
4392 let response = if index == 0 {
4393 property_response(
4394 parsed.header.tid(),
4395 prop::LAST_STATUS,
4396 &[umsh_ulcp::Status::INVALID_ARGUMENT.0 as u8],
4397 )
4398 } else {
4399 property_response(parsed.header.tid(), payload.key, payload.value)
4400 };
4401 let update = session.consume(response).unwrap();
4402 if index == 0 {
4403 assert_eq!(
4404 update.operation_error,
4405 Some(UlcpOperationErrorRecord {
4406 operation: format!("set property {}", payload.key),
4407 status_code: umsh_ulcp::Status::INVALID_ARGUMENT.0,
4408 status_name: "Status::INVALID_ARGUMENT".into(),
4409 })
4410 );
4411 }
4412 final_update = Some(update);
4413 }
4414 assert_eq!(
4415 final_update.unwrap().snapshot.phase,
4416 UlcpSessionPhase::Attached
4417 );
4418 assert!(session.transmit_raw(vec![6], false).is_ok());
4419 }
4420
4421 #[test]
4422 fn mobile_session_verifies_radio_configuration_then_saves() {
4423 let session = MobileUlcpSession::new();
4424 let begin = session.begin(None).unwrap();
4425 let inspection =
4426 answer_requests(&session, begin.outbound_frames, |property| match property {
4427 prop::LAST_STATUS => (property, vec![0]),
4428 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
4429 prop::CAPS => (
4430 property,
4431 encoded_capabilities(&[
4432 cap::SAVE,
4433 cap::DEV_NAME,
4434 cap::PHY_LORA,
4435 cap::PHY_DUTY_LIMIT,
4436 ]),
4437 ),
4438 prop::DEV_NAME => (property, b"Old name".to_vec()),
4439 prop::DEV_KEY | prop::BATTERY => (property, Vec::new()),
4440 prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
4441 _ => unreachable!(),
4442 });
4443 let partial =
4444 answer_requests(
4445 &session,
4446 inspection.outbound_frames,
4447 |property| match property {
4448 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
4449 prop::PHY_ENABLED => (property, vec![1]),
4450 prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
4451 prop::PHY_TX_POWER => (property, vec![14]),
4452 prop::PHY_LORA_BW => (property, 125_000u32.to_le_bytes().to_vec()),
4453 prop::PHY_LORA_SF => (property, vec![9]),
4454 prop::PHY_LORA_CR => (property, vec![5]),
4455 _ => unreachable!(),
4456 },
4457 );
4458 let attached = answer_requests(
4459 &session,
4460 partial.outbound_frames,
4461 |property| match property {
4462 prop::PHY_DUTY_NOW => (property, 65u16.to_le_bytes().to_vec()),
4463 prop::PHY_DUTY_LIMIT => (property, 655u16.to_le_bytes().to_vec()),
4464 prop::SAVED => (property, vec![1]),
4465 _ => unreachable!(),
4466 },
4467 );
4468 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
4469
4470 let configured = session
4471 .configure(UlcpRadioSettingsRecord {
4472 device_name: Some("Trail radio".into()),
4473 phy_enabled: true,
4474 frequency_khz: 868_100,
4475 transmit_power_dbm: 20,
4476 bandwidth_hz: Some(250_000),
4477 spreading_factor: Some(10),
4478 coding_rate_denom: Some(6),
4479 duty_cycle_limit: Some(6_553),
4480 })
4481 .unwrap();
4482 assert_eq!(configured.snapshot.phase, UlcpSessionPhase::Configuring);
4483 assert_eq!(
4484 configured.outbound_frames.len(),
4485 usize::from(frame::TID_MAX)
4486 );
4487 let mut pending = VecDeque::from(configured.outbound_frames);
4488 let mut configured_properties = Vec::new();
4489 let save_tid = loop {
4490 let request = pending.pop_front().unwrap();
4491 let parsed = Frame::parse(&request).unwrap();
4492 if parsed.command() == Some(Cmd::Save) {
4493 break parsed.header.tid();
4494 }
4495 assert_eq!(parsed.command(), Some(Cmd::PropSet));
4496 let payload = PropPayload::parse(parsed.payload).unwrap();
4497 configured_properties.push(payload.key);
4498 let answer: &[u8] = match payload.key {
4502 prop::PHY_TX_POWER => &[17],
4503 _ => payload.value,
4504 };
4505 let update = session
4506 .consume(property_response(parsed.header.tid(), payload.key, answer))
4507 .unwrap_or_else(|error| {
4508 panic!(
4509 "configuration response for property {} failed: {error:?}",
4510 payload.key
4511 )
4512 });
4513 pending.extend(update.outbound_frames);
4514 };
4515 assert_eq!(configured_properties.last(), Some(&prop::PHY_ENABLED));
4516 let attached = session
4517 .consume(property_response(save_tid, prop::LAST_STATUS, &[0]))
4518 .unwrap();
4519 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
4520 assert_eq!(
4521 attached.snapshot.device_name.as_deref(),
4522 Some("Trail radio")
4523 );
4524 let provisioning = attached.snapshot.provisioning.unwrap();
4525 assert_eq!(provisioning.frequency_khz, 868_100);
4526 assert_eq!(provisioning.transmit_power_dbm, 17);
4529 assert_eq!(provisioning.bandwidth_hz, Some(250_000));
4530 assert_eq!(provisioning.spreading_factor, Some(10));
4531 assert_eq!(provisioning.coding_rate_denom, Some(6));
4532 assert_eq!(provisioning.duty_cycle_now, Some(65));
4533 assert_eq!(provisioning.duty_cycle_limit, Some(6_553));
4534
4535 let pushed = session
4536 .consume(property_response(
4537 frame::TID_UNSOLICITED,
4538 prop::PHY_DUTY_NOW,
4539 &131u16.to_le_bytes(),
4540 ))
4541 .unwrap();
4542 assert_eq!(
4543 pushed.snapshot.provisioning.unwrap().duty_cycle_now,
4544 Some(131)
4545 );
4546
4547 let refresh = session.refresh().unwrap();
4548 assert_eq!(refresh.snapshot.phase, UlcpSessionPhase::Attached);
4549 assert!(refresh.waiting_for_responses);
4550 let refresh_tail =
4551 answer_requests(
4552 &session,
4553 refresh.outbound_frames,
4554 |property| match property {
4555 prop::DEV_NAME => (property, b"Fresh name".to_vec()),
4556 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
4557 prop::PHY_ENABLED => (property, vec![1]),
4558 prop::PHY_FREQ => (property, 910_525u32.to_le_bytes().to_vec()),
4559 prop::PHY_TX_POWER => (property, vec![18]),
4560 prop::PHY_LORA_BW => (property, 62_500u32.to_le_bytes().to_vec()),
4561 prop::PHY_LORA_SF => (property, vec![7]),
4562 _ => unreachable!(),
4563 },
4564 );
4565 let refreshed =
4566 answer_requests(
4567 &session,
4568 refresh_tail.outbound_frames,
4569 |property| match property {
4570 prop::PHY_LORA_CR => (property, vec![5]),
4571 prop::PHY_DUTY_NOW => (property, 262u16.to_le_bytes().to_vec()),
4572 prop::PHY_DUTY_LIMIT => (property, 655u16.to_le_bytes().to_vec()),
4573 prop::SAVED => (property, vec![1]),
4574 _ => unreachable!(),
4575 },
4576 );
4577 assert_eq!(refreshed.snapshot.phase, UlcpSessionPhase::Attached);
4578 assert!(!refreshed.waiting_for_responses);
4579 assert_eq!(
4580 refreshed.snapshot.device_name.as_deref(),
4581 Some("Fresh name")
4582 );
4583 let refreshed = refreshed.snapshot.provisioning.unwrap();
4584 assert_eq!(refreshed.frequency_khz, 910_525);
4585 assert_eq!(refreshed.duty_cycle_now, Some(262));
4586 assert_eq!(refreshed.duty_cycle_limit, Some(655));
4587 }
4588
4589 fn inserted_response(tid: u8, property: u32, item: &[u8]) -> Vec<u8> {
4590 let mut bytes = vec![0; MAX_FRAME];
4591 let length = frame::prop_inserted(&mut bytes, tid, property, item).unwrap();
4592 bytes.truncate(length);
4593 bytes
4594 }
4595
4596 fn removed_response(tid: u8, property: u32, item: &[u8]) -> Vec<u8> {
4597 let mut bytes = vec![0; MAX_FRAME];
4598 let length = frame::prop_removed(&mut bytes, tid, property, item).unwrap();
4599 bytes.truncate(length);
4600 bytes
4601 }
4602
4603 fn dev_peer_keys(update: &UlcpSessionUpdateRecord) -> Vec<Vec<u8>> {
4604 update
4605 .snapshot
4606 .provisioning
4607 .as_ref()
4608 .unwrap()
4609 .dev_peer_keys
4610 .clone()
4611 .unwrap()
4612 }
4613
4614 #[test]
4615 fn device_peer_insert_and_remove_patch_the_table_and_chain_a_save() {
4616 let session = MobileUlcpSession::new();
4617 let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
4618 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
4619 assert_eq!(dev_peer_keys(&attached), Vec::<Vec<u8>>::new());
4620
4621 let insert = session.insert_device_peer(vec![0xC1; 32]).unwrap();
4622 assert_eq!(insert.outbound_frames.len(), 1);
4623 let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
4624 assert_eq!(request.command(), Some(Cmd::PropInsert));
4625
4626 let confirmed = session
4627 .consume(inserted_response(
4628 request.header.tid(),
4629 prop::DEV_PEERS,
4630 &[0xC1; 32],
4631 ))
4632 .unwrap();
4633 assert_eq!(dev_peer_keys(&confirmed), vec![vec![0xC1; 32]]);
4634 assert_eq!(confirmed.operation_error, None);
4635 assert!(confirmed.waiting_for_responses);
4637 assert_eq!(confirmed.outbound_frames.len(), 1);
4638 let save = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
4639 assert_eq!(save.command(), Some(Cmd::Save));
4640
4641 let saved = session
4642 .consume(property_response(
4643 save.header.tid(),
4644 prop::LAST_STATUS,
4645 &[umsh_ulcp::Status::OK.0 as u8],
4646 ))
4647 .unwrap();
4648 assert_eq!(saved.operation_error, None);
4649 assert!(!saved.waiting_for_responses);
4650 assert_eq!(saved.snapshot.phase, UlcpSessionPhase::Attached);
4651
4652 let remove = session.remove_device_peer(vec![0xC1; 32]).unwrap();
4653 let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
4654 assert_eq!(request.command(), Some(Cmd::PropRemove));
4655 let confirmed = session
4656 .consume(removed_response(
4657 request.header.tid(),
4658 prop::DEV_PEERS,
4659 &[0xC1; 32],
4660 ))
4661 .unwrap();
4662 assert_eq!(dev_peer_keys(&confirmed), Vec::<Vec<u8>>::new());
4663 let save = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
4664 assert_eq!(save.command(), Some(Cmd::Save));
4665 let saved = session
4666 .consume(property_response(
4667 save.header.tid(),
4668 prop::LAST_STATUS,
4669 &[umsh_ulcp::Status::OK.0 as u8],
4670 ))
4671 .unwrap();
4672 assert!(!saved.waiting_for_responses);
4673 }
4674
4675 #[test]
4676 fn device_peer_failures_report_status_without_ending_the_session() {
4677 let session = MobileUlcpSession::new();
4678 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
4679
4680 let insert = session.insert_device_peer(vec![0xC2; 32]).unwrap();
4683 let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
4684 let full = session
4685 .consume(property_response(
4686 request.header.tid(),
4687 prop::LAST_STATUS,
4688 &[umsh_ulcp::Status::NOMEM.0 as u8],
4689 ))
4690 .unwrap();
4691 assert_eq!(
4692 full.operation_error,
4693 Some(UlcpOperationErrorRecord {
4694 operation: "insert device peer".into(),
4695 status_code: umsh_ulcp::Status::NOMEM.0,
4696 status_name: "Status::NOMEM".into(),
4697 })
4698 );
4699 assert_eq!(dev_peer_keys(&full), Vec::<Vec<u8>>::new());
4700 assert!(!full.waiting_for_responses);
4701 assert_eq!(full.snapshot.phase, UlcpSessionPhase::Attached);
4702
4703 let insert = session.insert_device_peer(vec![0xC3; 32]).unwrap();
4706 let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
4707 let already = session
4708 .consume(property_response(
4709 request.header.tid(),
4710 prop::LAST_STATUS,
4711 &[umsh_ulcp::Status::ALREADY.0 as u8],
4712 ))
4713 .unwrap();
4714 assert_eq!(
4715 already.operation_error.as_ref().unwrap().status_name,
4716 "Status::ALREADY"
4717 );
4718 assert_eq!(dev_peer_keys(&already), vec![vec![0xC3; 32]]);
4719
4720 let remove = session.remove_device_peer(vec![0xC3; 32]).unwrap();
4723 let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
4724 let missing = session
4725 .consume(property_response(
4726 request.header.tid(),
4727 prop::LAST_STATUS,
4728 &[umsh_ulcp::Status::ITEM_NOT_FOUND.0 as u8],
4729 ))
4730 .unwrap();
4731 assert_eq!(
4732 missing.operation_error.as_ref().unwrap().status_name,
4733 "Status::ITEM_NOT_FOUND"
4734 );
4735 assert_eq!(dev_peer_keys(&missing), Vec::<Vec<u8>>::new());
4736
4737 assert!(session.insert_device_peer(vec![0xC4; 32]).is_ok());
4739 }
4740
4741 fn dev_channel_ids(update: &UlcpSessionUpdateRecord) -> Vec<Vec<u8>> {
4742 update
4743 .snapshot
4744 .provisioning
4745 .as_ref()
4746 .unwrap()
4747 .dev_channel_ids
4748 .clone()
4749 .unwrap()
4750 }
4751
4752 #[test]
4753 fn device_channel_insert_and_remove_track_identifiers_not_keys() {
4754 let session = MobileUlcpSession::new();
4755 let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
4756 assert_eq!(dev_channel_ids(&attached), Vec::<Vec<u8>>::new());
4757
4758 let key = vec![0xB7; 32];
4761 let id = crate::derive_channel_id(key.clone()).unwrap();
4762 assert_eq!(id.len(), items::CHANNEL_ID_LEN);
4763
4764 let insert = session.insert_device_channel_key(key.clone()).unwrap();
4765 let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
4766 assert_eq!(request.command(), Some(Cmd::PropInsert));
4767
4768 let confirmed = session
4769 .consume(inserted_response(
4770 request.header.tid(),
4771 prop::DEV_CHANNEL_KEYS,
4772 &id,
4773 ))
4774 .unwrap();
4775 assert_eq!(dev_channel_ids(&confirmed), vec![id.clone()]);
4776 assert_eq!(confirmed.operation_error, None);
4777 let save = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
4778 assert_eq!(save.command(), Some(Cmd::Save));
4779 let saved = session
4780 .consume(property_response(
4781 save.header.tid(),
4782 prop::LAST_STATUS,
4783 &[umsh_ulcp::Status::OK.0 as u8],
4784 ))
4785 .unwrap();
4786 assert!(!saved.waiting_for_responses);
4787
4788 let remove = session.remove_device_channel_key(key).unwrap();
4790 let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
4791 assert_eq!(request.command(), Some(Cmd::PropRemove));
4792 let confirmed = session
4793 .consume(removed_response(
4794 request.header.tid(),
4795 prop::DEV_CHANNEL_KEYS,
4796 &id,
4797 ))
4798 .unwrap();
4799 assert_eq!(dev_channel_ids(&confirmed), Vec::<Vec<u8>>::new());
4800 }
4801
4802 #[test]
4803 fn device_channel_failures_report_status_without_ending_the_session() {
4804 let session = MobileUlcpSession::new();
4805 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
4806
4807 let key = vec![0xC7; 32];
4808 let id = crate::derive_channel_id(key.clone()).unwrap();
4809
4810 let insert = session.insert_device_channel_key(key.clone()).unwrap();
4811 let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
4812 let full = session
4813 .consume(property_response(
4814 request.header.tid(),
4815 prop::LAST_STATUS,
4816 &[umsh_ulcp::Status::NOMEM.0 as u8],
4817 ))
4818 .unwrap();
4819 assert_eq!(
4820 full.operation_error,
4821 Some(UlcpOperationErrorRecord {
4822 operation: "insert device channel key".into(),
4823 status_code: umsh_ulcp::Status::NOMEM.0,
4824 status_name: "Status::NOMEM".into(),
4825 })
4826 );
4827 assert_eq!(dev_channel_ids(&full), Vec::<Vec<u8>>::new());
4828 assert_eq!(full.snapshot.phase, UlcpSessionPhase::Attached);
4829
4830 let insert = session.insert_device_channel_key(key.clone()).unwrap();
4832 let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
4833 let already = session
4834 .consume(property_response(
4835 request.header.tid(),
4836 prop::LAST_STATUS,
4837 &[umsh_ulcp::Status::ALREADY.0 as u8],
4838 ))
4839 .unwrap();
4840 assert_eq!(dev_channel_ids(&already), vec![id]);
4841
4842 let remove = session.remove_device_channel_key(key).unwrap();
4843 let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
4844 let missing = session
4845 .consume(property_response(
4846 request.header.tid(),
4847 prop::LAST_STATUS,
4848 &[umsh_ulcp::Status::ITEM_NOT_FOUND.0 as u8],
4849 ))
4850 .unwrap();
4851 assert_eq!(
4852 missing.operation_error.as_ref().unwrap().status_name,
4853 "Status::ITEM_NOT_FOUND"
4854 );
4855 assert_eq!(dev_channel_ids(&missing), Vec::<Vec<u8>>::new());
4856
4857 assert!(session.insert_device_channel_key(vec![0xC8; 32]).is_ok());
4858 }
4859
4860 fn host_channel_count(update: &UlcpSessionUpdateRecord) -> Option<u32> {
4861 update
4862 .snapshot
4863 .provisioning
4864 .as_ref()
4865 .unwrap()
4866 .host_channel_count
4867 }
4868
4869 #[test]
4870 fn host_channel_reconcile_inserts_what_the_radio_is_missing() {
4871 let session = MobileUlcpSession::new();
4872 let attached = attach_host_keys_capable(&session);
4873 assert_eq!(host_channel_count(&attached), Some(0));
4874
4875 let first = vec![0xD1; 32];
4876 let second = vec![0xD2; 32];
4877 let update = session
4878 .reconcile_host_channel_keys(vec![first.clone(), second.clone()])
4879 .unwrap();
4880
4881 assert_eq!(update.outbound_frames.len(), 1);
4883 let request = Frame::parse(&update.outbound_frames[0]).unwrap();
4884 assert_eq!(request.command(), Some(Cmd::PropInsert));
4885 let confirmed = session
4886 .consume(inserted_response(
4887 request.header.tid(),
4888 prop::HOST_CHANNEL_KEYS,
4889 &crate::derive_channel_id(first).unwrap(),
4890 ))
4891 .unwrap();
4892 assert_eq!(confirmed.outbound_frames.len(), 1);
4893 let request = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
4894 let done = session
4895 .consume(inserted_response(
4896 request.header.tid(),
4897 prop::HOST_CHANNEL_KEYS,
4898 &crate::derive_channel_id(second).unwrap(),
4899 ))
4900 .unwrap();
4901 assert!(done.outbound_frames.is_empty());
4902 assert!(!done.waiting_for_responses);
4903 assert_eq!(done.operation_error, None);
4904 assert_eq!(host_channel_count(&done), Some(2));
4905 }
4906
4907 #[test]
4908 fn host_channel_reconcile_is_a_no_op_when_the_radio_already_matches() {
4909 let session = MobileUlcpSession::new();
4910 attach_host_keys_capable(&session);
4911 let key = vec![0xD3; 32];
4912
4913 let update = session
4914 .reconcile_host_channel_keys(vec![key.clone()])
4915 .unwrap();
4916 let request = Frame::parse(&update.outbound_frames[0]).unwrap();
4917 session
4918 .consume(inserted_response(
4919 request.header.tid(),
4920 prop::HOST_CHANNEL_KEYS,
4921 &crate::derive_channel_id(key.clone()).unwrap(),
4922 ))
4923 .unwrap();
4924
4925 let again = session.reconcile_host_channel_keys(vec![key]).unwrap();
4928 assert!(again.outbound_frames.is_empty());
4929 assert!(!again.waiting_for_responses);
4930 }
4931
4932 #[test]
4933 fn host_channel_reconcile_replaces_the_table_to_shed_an_unknown_channel() {
4934 let session = MobileUlcpSession::new();
4935 attach_host_keys_capable(&session);
4936 let stranger = vec![0xD4; 32];
4937
4938 let update = session
4940 .reconcile_host_channel_keys(vec![stranger.clone()])
4941 .unwrap();
4942 let request = Frame::parse(&update.outbound_frames[0]).unwrap();
4943 session
4944 .consume(inserted_response(
4945 request.header.tid(),
4946 prop::HOST_CHANNEL_KEYS,
4947 &crate::derive_channel_id(stranger).unwrap(),
4948 ))
4949 .unwrap();
4950
4951 let mine = vec![0xD5; 32];
4954 let replace = session
4955 .reconcile_host_channel_keys(vec![mine.clone()])
4956 .unwrap();
4957 let request = Frame::parse(&replace.outbound_frames[0]).unwrap();
4958 assert_eq!(request.command(), Some(Cmd::PropSet));
4959
4960 let done = session
4961 .consume(property_response(
4962 request.header.tid(),
4963 prop::HOST_CHANNEL_KEYS,
4964 &crate::derive_channel_id(mine).unwrap(),
4965 ))
4966 .unwrap();
4967 assert_eq!(host_channel_count(&done), Some(1));
4968 assert!(!done.waiting_for_responses);
4969 }
4970
4971 #[test]
4972 fn a_full_host_channel_table_reports_status_and_stays_attached() {
4973 let session = MobileUlcpSession::new();
4974 attach_host_keys_capable(&session);
4975
4976 let update = session
4977 .reconcile_host_channel_keys(vec![vec![0xD6; 32], vec![0xD7; 32]])
4978 .unwrap();
4979 let request = Frame::parse(&update.outbound_frames[0]).unwrap();
4980 let full = session
4981 .consume(property_response(
4982 request.header.tid(),
4983 prop::LAST_STATUS,
4984 &[umsh_ulcp::Status::NOMEM.0 as u8],
4985 ))
4986 .unwrap();
4987
4988 assert_eq!(
4989 full.operation_error.as_ref().unwrap().status_name,
4990 "Status::NOMEM"
4991 );
4992 assert!(full.outbound_frames.is_empty());
4995 assert!(!full.waiting_for_responses);
4996 assert_eq!(full.snapshot.phase, UlcpSessionPhase::Attached);
4997 }
4998
4999 #[test]
5000 fn an_already_stored_host_channel_key_is_success() {
5001 let session = MobileUlcpSession::new();
5002 attach_host_keys_capable(&session);
5003
5004 let update = session
5005 .reconcile_host_channel_keys(vec![vec![0xD8; 32]])
5006 .unwrap();
5007 let request = Frame::parse(&update.outbound_frames[0]).unwrap();
5008 let already = session
5009 .consume(property_response(
5010 request.header.tid(),
5011 prop::LAST_STATUS,
5012 &[umsh_ulcp::Status::ALREADY.0 as u8],
5013 ))
5014 .unwrap();
5015 assert_eq!(already.operation_error, None);
5016 assert!(!already.waiting_for_responses);
5017 }
5018
5019 #[test]
5020 fn device_channel_keys_must_be_full_length() {
5021 let session = MobileUlcpSession::new();
5022 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
5023 assert_eq!(
5024 session.insert_device_channel_key(vec![0x01; 31]),
5025 Err(MobileError::InvalidChannelKeyLength)
5026 );
5027 assert_eq!(
5028 session.remove_device_channel_key(Vec::new()),
5029 Err(MobileError::InvalidChannelKeyLength)
5030 );
5031 }
5032
5033 #[test]
5034 fn device_peer_operations_require_the_device_identity_capability() {
5035 let session = MobileUlcpSession::new();
5036 let begin = session.begin(None).unwrap();
5037 let inspection =
5038 answer_requests(&session, begin.outbound_frames, |property| match property {
5039 prop::LAST_STATUS => (property, vec![0]),
5040 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
5041 prop::CAPS => (property, vec![cap::WRITABLE_RAW_STREAM as u8]),
5042 prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY => (property, Vec::new()),
5043 prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
5044 _ => unreachable!(),
5045 });
5046 let attached =
5047 answer_requests(
5048 &session,
5049 inspection.outbound_frames,
5050 |property| match property {
5051 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
5052 prop::PHY_ENABLED => (property, vec![1]),
5053 prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
5054 prop::PHY_TX_POWER => (property, vec![14]),
5055 _ => unreachable!(),
5056 },
5057 );
5058 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5059 assert_eq!(
5060 session.insert_device_peer(vec![0xC1; 32]).unwrap_err(),
5061 MobileError::InvalidUlcpFrame
5062 );
5063 assert_eq!(
5064 session.remove_device_peer(vec![0xC1; 32]).unwrap_err(),
5065 MobileError::InvalidUlcpFrame
5066 );
5067 assert_eq!(
5069 session.insert_device_peer(vec![0xC1; 31]).unwrap_err(),
5070 MobileError::InvalidPublicKeyLength
5071 );
5072 }
5073
5074 fn attached_battery_session() -> std::sync::Arc<MobileUlcpSession> {
5077 let session = MobileUlcpSession::new();
5078 let begin = session.begin(None).unwrap();
5079 let inspection =
5080 answer_requests(&session, begin.outbound_frames, |property| match property {
5081 prop::LAST_STATUS => (property, vec![0]),
5082 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
5083 prop::CAPS => (property, encoded_capabilities(&[cap::BATTERY])),
5084 prop::BATTERY => (property, vec![0b111, 0x74, 0x0E, 60, 0]),
5086 prop::DEV_KEY | prop::DEV_NAME => (property, Vec::new()),
5087 prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
5088 _ => unreachable!(),
5089 });
5090 let attached =
5091 answer_requests(
5092 &session,
5093 inspection.outbound_frames,
5094 |property| match property {
5095 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
5096 prop::PHY_ENABLED => (property, vec![1]),
5097 prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
5098 prop::PHY_TX_POWER => (property, vec![14]),
5099 _ => unreachable!(),
5100 },
5101 );
5102 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5103 session
5104 }
5105
5106 #[test]
5107 fn battery_is_reported_once_per_measurement_not_on_every_update() {
5108 let session = attached_battery_session();
5109
5110 let pushed = session
5113 .consume(property_response(
5114 frame::TID_UNSOLICITED,
5115 prop::BATTERY,
5116 &[0b111, 0x10, 0x10, 45, 1],
5117 ))
5118 .unwrap();
5119 let battery = pushed.snapshot.battery.expect("push carries the snapshot");
5120 assert_eq!(battery.percentage, Some(45));
5121 assert_eq!(battery.voltage_mv, Some(0x1010));
5122 assert_eq!(battery.charge_state, Some(UlcpChargeState::Charging));
5123
5124 let unrelated = session
5128 .consume(property_response(
5129 frame::TID_UNSOLICITED,
5130 prop::DEV_NAME,
5131 b"Ridge repeater",
5132 ))
5133 .unwrap();
5134 assert!(unrelated.snapshot.battery.is_none());
5135 assert_eq!(
5136 unrelated.snapshot.device_name.as_deref(),
5137 Some("Ridge repeater"),
5138 "unrelated state still propagates"
5139 );
5140
5141 let again = session
5143 .consume(property_response(
5144 frame::TID_UNSOLICITED,
5145 prop::BATTERY,
5146 &[0b111, 0x20, 0x10, 50, 1],
5147 ))
5148 .unwrap();
5149 assert_eq!(
5150 again.snapshot.battery.expect("second push").percentage,
5151 Some(50)
5152 );
5153 }
5154
5155 fn attached_alert_session() -> std::sync::Arc<MobileUlcpSession> {
5157 let session = MobileUlcpSession::new();
5158 let begin = session.begin(None).unwrap();
5159 let inspection =
5160 answer_requests(&session, begin.outbound_frames, |property| match property {
5161 prop::LAST_STATUS => (property, vec![0]),
5162 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
5163 prop::CAPS => (property, encoded_capabilities(&[cap::ALERT])),
5164 prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY => (property, Vec::new()),
5165 prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
5166 _ => unreachable!(),
5167 });
5168 let attached =
5169 answer_requests(
5170 &session,
5171 inspection.outbound_frames,
5172 |property| match property {
5173 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
5174 prop::PHY_ENABLED => (property, vec![1]),
5175 prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
5176 prop::PHY_TX_POWER => (property, vec![14]),
5177 prop::ALERT => (property, vec![0]),
5178 _ => unreachable!(),
5179 },
5180 );
5181 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5182 assert_eq!(attached.snapshot.alert, Some(UlcpAlertState::None));
5183 session
5184 }
5185
5186 #[test]
5187 fn alert_state_follows_the_radio_not_the_request() {
5188 let session = attached_alert_session();
5189
5190 let request = session.set_alert(UlcpAlertState::Locate).unwrap();
5191 let [frame] = &request.outbound_frames[..] else {
5192 panic!("one CMD_PROP_SET");
5193 };
5194 let parsed = Frame::parse(frame).unwrap();
5195 assert_eq!(parsed.command(), Some(Cmd::PropSet));
5196 let payload = PropPayload::parse(parsed.payload).unwrap();
5197 assert_eq!((payload.key, payload.value), (prop::ALERT, &[1u8][..]));
5198
5199 let started = session
5200 .consume(property_response(parsed.header.tid(), prop::ALERT, &[1]))
5201 .unwrap();
5202 assert_eq!(started.snapshot.alert, Some(UlcpAlertState::Locate));
5203
5204 let unrelated = session
5207 .consume(property_response(
5208 frame::TID_UNSOLICITED,
5209 prop::DEV_NAME,
5210 b"Ridge repeater",
5211 ))
5212 .unwrap();
5213 assert_eq!(unrelated.snapshot.alert, Some(UlcpAlertState::Locate));
5214
5215 let cancelled = session
5218 .consume(property_response(frame::TID_UNSOLICITED, prop::ALERT, &[0]))
5219 .unwrap();
5220 assert_eq!(cancelled.snapshot.alert, Some(UlcpAlertState::None));
5221 assert_eq!(cancelled.snapshot.phase, UlcpSessionPhase::Attached);
5222 }
5223
5224 #[test]
5225 fn alert_needs_the_capability() {
5226 let session = attached_battery_session();
5227 assert_eq!(
5228 session.set_alert(UlcpAlertState::Locate).unwrap_err(),
5229 MobileError::UnsupportedCapability
5230 );
5231 let update = session
5234 .consume(property_response(
5235 frame::TID_UNSOLICITED,
5236 prop::BATTERY,
5237 &[0b111, 0x10, 0x10, 45, 1],
5238 ))
5239 .unwrap();
5240 assert_eq!(update.snapshot.alert, None);
5241 }
5242
5243 #[test]
5244 fn malformed_alert_values_are_rejected() {
5245 assert_eq!(inspect_ulcp_alert(vec![0]).unwrap(), UlcpAlertState::None);
5246 assert_eq!(inspect_ulcp_alert(vec![1]).unwrap(), UlcpAlertState::Locate);
5247 assert!(inspect_ulcp_alert(vec![2]).is_err());
5249 assert!(inspect_ulcp_alert(vec![1, 0]).is_err());
5250 assert!(inspect_ulcp_alert(Vec::new()).is_err());
5251 }
5252
5253 fn attach_positioning(session: &MobileUlcpSession) -> UlcpSessionUpdateRecord {
5256 let mut capabilities = commissionable_capabilities();
5257 capabilities.extend([cap::TIME, cap::GNSS]);
5258 let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
5259 drive_reads(
5260 session,
5261 begin.outbound_frames,
5262 move |property| match property {
5263 prop::CAPS => (property, encoded_capabilities(&capabilities)),
5264 prop::HOST_KEY => (property, vec![0xAA; 32]),
5265 prop::TIME => (property, 1_754_000_000u32.to_le_bytes().to_vec()),
5266 prop::TZ_OFFSET => (property, (-420i16).to_le_bytes().to_vec()),
5269 prop::GNSS_ENABLED | prop::GNSS_IDENT_UPDATE | prop::GNSS_TIME_TRUST => {
5270 (property, vec![1])
5271 }
5272 prop::GNSS_LOCATION => (property, placed_location().as_bytes().to_vec()),
5273 prop::GNSS_ALTITUDE => (property, 71i32.to_le_bytes().to_vec()),
5274 prop::GNSS_FIX => (property, vec![2]),
5275 prop::GNSS_PRECISION => (property, 62u16.to_le_bytes().to_vec()),
5276 prop::GNSS_SATELLITES => (property, vec![9, 14]),
5277 prop::GNSS_IDENT_PRECISION => (property, vec![5]),
5278 _ => commissionable_value(property),
5279 },
5280 )
5281 }
5282
5283 fn attach_advertising(session: &MobileUlcpSession) -> UlcpSessionUpdateRecord {
5284 let mut capabilities = commissionable_capabilities();
5285 capabilities.push(cap::ADVERT);
5286 let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
5287 drive_reads(
5288 session,
5289 begin.outbound_frames,
5290 move |property| match property {
5291 prop::CAPS => (property, encoded_capabilities(&capabilities)),
5292 prop::HOST_KEY => (property, vec![0xAA; 32]),
5293 prop::ADVERT_INTERVAL => (property, 14_400u32.to_le_bytes().to_vec()),
5294 prop::BEACON_INTERVAL => (property, 3_600u32.to_le_bytes().to_vec()),
5295 prop::STARTUP_BEACON => (property, vec![1]),
5296 _ => commissionable_value(property),
5297 },
5298 )
5299 }
5300
5301 #[test]
5302 fn advertisement_policy_folds_into_the_sync_record() {
5303 let session = MobileUlcpSession::administrative();
5304 let update = attach_advertising(&session);
5305 let sync = update.snapshot.provisioning.expect("device described");
5306
5307 assert!(sync.supports_advert);
5308 assert_eq!(
5309 sync.advert,
5310 Some(UlcpAdvertSettingsRecord {
5311 advert_interval_seconds: 14_400,
5312 beacon_interval_seconds: 3_600,
5313 startup_beacon: true,
5314 })
5315 );
5316 }
5317
5318 #[test]
5321 fn a_device_without_the_capability_reports_no_advertisement_policy() {
5322 let session = MobileUlcpSession::administrative();
5323 let update = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
5324 let sync = update.snapshot.provisioning.expect("device described");
5325
5326 assert!(!sync.supports_advert);
5327 assert_eq!(sync.advert, None);
5328 }
5329
5330 #[test]
5331 fn configure_advertising_writes_the_whole_schedule() {
5332 let session = MobileUlcpSession::administrative();
5333 attach_advertising(&session);
5334
5335 let configured = session
5336 .configure_advertising(Some(UlcpAdvertSettingsRecord {
5337 advert_interval_seconds: 0,
5338 beacon_interval_seconds: 1_800,
5339 startup_beacon: false,
5340 }))
5341 .unwrap();
5342 let (written, _, _) = drive_configuration(&session, configured.outbound_frames);
5343 assert_eq!(
5344 written.get(&prop::ADVERT_INTERVAL).map(Vec::as_slice),
5345 Some(&0u32.to_le_bytes()[..])
5346 );
5347 assert_eq!(
5348 written.get(&prop::BEACON_INTERVAL).map(Vec::as_slice),
5349 Some(&1_800u32.to_le_bytes()[..])
5350 );
5351 assert_eq!(
5352 written.get(&prop::STARTUP_BEACON).map(Vec::as_slice),
5353 Some(&[0u8][..])
5354 );
5355 }
5356
5357 #[test]
5361 fn an_advertisement_record_must_match_what_the_device_can_do() {
5362 let session = MobileUlcpSession::administrative();
5363 attach_advertising(&session);
5364 let whole = UlcpAdvertSettingsRecord {
5365 advert_interval_seconds: 14_400,
5366 beacon_interval_seconds: 3_600,
5367 startup_beacon: true,
5368 };
5369
5370 assert_eq!(
5372 session.configure_advertising(None),
5373 Err(MobileError::InvalidUlcpFrame)
5374 );
5375 for out_of_range in [
5376 MIN_AUTO_ANNOUNCE_INTERVAL_S - 1,
5377 MAX_AUTO_ANNOUNCE_INTERVAL_S + 1,
5378 ] {
5379 assert_eq!(
5380 session.configure_advertising(Some(UlcpAdvertSettingsRecord {
5381 beacon_interval_seconds: out_of_range,
5382 ..whole
5383 })),
5384 Err(MobileError::InvalidUlcpFrame)
5385 );
5386 }
5387 assert!(
5389 session
5390 .configure_advertising(Some(UlcpAdvertSettingsRecord {
5391 beacon_interval_seconds: 0,
5392 ..whole
5393 }))
5394 .is_ok()
5395 );
5396 }
5397
5398 #[test]
5401 fn an_advertisement_record_is_refused_without_the_capability() {
5402 let session = MobileUlcpSession::administrative();
5403 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
5404 assert_eq!(
5405 session.configure_advertising(Some(UlcpAdvertSettingsRecord {
5406 advert_interval_seconds: 14_400,
5407 beacon_interval_seconds: 3_600,
5408 startup_beacon: true,
5409 })),
5410 Err(MobileError::InvalidUlcpFrame)
5411 );
5412 }
5413
5414 fn placed_location() -> NodeLocation {
5416 NodeLocation::from_e7(377_749_290, -1_224_194_160, 5)
5417 }
5418
5419 #[test]
5420 fn a_positioning_device_reports_its_fix_and_its_policy() {
5421 let session = MobileUlcpSession::new();
5422 let attached = attach_positioning(&session);
5423 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5424
5425 let sync = attached.snapshot.provisioning.clone().expect("described");
5426 assert!(sync.supports_time && sync.supports_gnss);
5427 assert_eq!(sync.tz_offset_min, Some(-420));
5428 assert_eq!(
5429 sync.gnss,
5430 Some(UlcpGnssSettingsRecord {
5431 enabled: true,
5432 ident_update: true,
5433 ident_precision: 5,
5434 time_trust: true,
5435 })
5436 );
5437 assert!(sync.unreadable_properties.is_empty());
5438
5439 let gnss = attached.snapshot.gnss.expect("a receiver was read");
5440 assert_eq!(gnss.fix, UlcpFixKind::ThreeD);
5441 assert_eq!(gnss.altitude_m, Some(71));
5442 assert_eq!(gnss.accuracy_dm, Some(62));
5443 assert_eq!(
5444 (gnss.satellites_used, gnss.satellites_in_view),
5445 (9, Some(14))
5446 );
5447 assert_eq!(gnss.location, placed_location().as_bytes());
5448 let (latitude, longitude) = (gnss.latitude_deg.unwrap(), gnss.longitude_deg.unwrap());
5451 assert!((latitude - 37.774_929).abs() < 5e-4, "{latitude}");
5452 assert!((longitude + 122.419_416).abs() < 5e-4, "{longitude}");
5453 assert!((38.0..39.0).contains(&gnss.location_cell_meters.unwrap()));
5454 }
5455
5456 #[test]
5457 fn the_clock_is_reported_once_and_the_fix_is_mirrored() {
5458 let session = MobileUlcpSession::new();
5459 attach_positioning(&session);
5460
5461 let unrelated = session
5465 .consume(property_response(
5466 frame::TID_UNSOLICITED,
5467 prop::DEV_NAME,
5468 b"Ridge repeater",
5469 ))
5470 .unwrap();
5471 assert_eq!(unrelated.snapshot.time, None);
5472 assert_eq!(
5473 unrelated.snapshot.gnss.expect("still known").fix,
5474 UlcpFixKind::ThreeD
5475 );
5476
5477 let lost = session
5481 .consume(property_response(
5482 frame::TID_UNSOLICITED,
5483 prop::GNSS_FIX,
5484 &[1],
5485 ))
5486 .unwrap();
5487 let gnss = lost.snapshot.gnss.expect("still known");
5488 assert_eq!(gnss.fix, UlcpFixKind::TwoD);
5489 assert_eq!(gnss.satellites_used, 9);
5490 assert_eq!(gnss.altitude_m, Some(71));
5491
5492 let stepped = session
5495 .consume(property_response(
5496 frame::TID_UNSOLICITED,
5497 prop::TIME,
5498 &1_754_000_600u32.to_le_bytes(),
5499 ))
5500 .unwrap();
5501 assert_eq!(
5502 stepped.snapshot.time,
5503 Some(UlcpTimeRecord {
5504 epoch_seconds: Some(1_754_000_600)
5505 })
5506 );
5507 assert_eq!(stepped.snapshot.phase, UlcpSessionPhase::Attached);
5508 }
5509
5510 #[test]
5511 fn sampling_a_position_asks_for_the_position_and_nothing_else() {
5512 let session = MobileUlcpSession::new();
5513 attach_positioning(&session);
5514
5515 let poll = session.refresh_positioning().unwrap();
5519 let asked: Vec<u32> = poll
5520 .outbound_frames
5521 .iter()
5522 .map(|frame| {
5523 let parsed = Frame::parse(frame).unwrap();
5524 PropPayload::parse(parsed.payload).unwrap().key
5525 })
5526 .collect();
5527 assert_eq!(
5528 asked,
5529 vec![
5530 prop::GNSS_LOCATION,
5531 prop::GNSS_ALTITUDE,
5532 prop::GNSS_FIX,
5533 prop::GNSS_PRECISION,
5534 prop::GNSS_SATELLITES,
5535 ]
5536 );
5537
5538 assert!(!asked.contains(&prop::PHY_FREQ));
5542 assert!(!asked.contains(&prop::DEV_NAME));
5543
5544 let sampled = answer_requests(&session, poll.outbound_frames, |property| match property {
5547 prop::GNSS_LOCATION => (property, placed_location().as_bytes().to_vec()),
5548 prop::GNSS_ALTITUDE => (property, 88i32.to_le_bytes().to_vec()),
5549 prop::GNSS_FIX => (property, vec![2]),
5550 prop::GNSS_PRECISION => (property, 40u16.to_le_bytes().to_vec()),
5551 prop::GNSS_SATELLITES => (property, vec![11, 15]),
5552 _ => unreachable!("{property}"),
5553 });
5554 let gnss = sampled.snapshot.gnss.expect("a receiver was sampled");
5555 assert_eq!(gnss.altitude_m, Some(88));
5556 assert_eq!(gnss.satellites_used, 11);
5557 assert_eq!(sampled.snapshot.phase, UlcpSessionPhase::Attached);
5558 }
5559
5560 #[test]
5561 fn a_radio_without_a_receiver_is_never_asked_where_it_is() {
5562 let session = attached_battery_session();
5565 assert_eq!(
5566 session.refresh_positioning(),
5567 Err(MobileError::InvalidUlcpFrame)
5568 );
5569 }
5570
5571 #[test]
5572 fn setting_the_clock_writes_the_epoch_and_clearing_it_writes_nothing() {
5573 let session = MobileUlcpSession::new();
5574 attach_positioning(&session);
5575
5576 let request = session.set_time(Some(1_754_000_900)).unwrap();
5577 let [frame] = &request.outbound_frames[..] else {
5578 panic!("one CMD_PROP_SET");
5579 };
5580 let parsed = Frame::parse(frame).unwrap();
5581 let payload = PropPayload::parse(parsed.payload).unwrap();
5582 assert_eq!(payload.key, prop::TIME);
5583 assert_eq!(payload.value, &1_754_000_900u32.to_le_bytes()[..]);
5584
5585 let set = session
5589 .consume(property_response(
5590 parsed.header.tid(),
5591 prop::TIME,
5592 &1_754_000_901u32.to_le_bytes(),
5593 ))
5594 .unwrap();
5595 assert_eq!(
5596 set.snapshot.time,
5597 Some(UlcpTimeRecord {
5598 epoch_seconds: Some(1_754_000_901)
5599 })
5600 );
5601
5602 let clearing = session.set_time(None).unwrap();
5604 let parsed = Frame::parse(&clearing.outbound_frames[0]).unwrap();
5605 let payload = PropPayload::parse(parsed.payload).unwrap();
5606 assert_eq!((payload.key, payload.value), (prop::TIME, &[][..]));
5607 let cleared = session
5608 .consume(property_response(parsed.header.tid(), prop::TIME, &[]))
5609 .unwrap();
5610 assert_eq!(
5611 cleared.snapshot.time,
5612 Some(UlcpTimeRecord {
5613 epoch_seconds: None
5614 })
5615 );
5616 }
5617
5618 #[test]
5619 fn the_clock_needs_the_capability() {
5620 let session = MobileUlcpSession::new();
5621 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
5622 assert_eq!(
5623 session.set_time(Some(1_754_000_900)).unwrap_err(),
5624 MobileError::UnsupportedCapability
5625 );
5626 let sync = session
5627 .refresh()
5628 .unwrap()
5629 .snapshot
5630 .provisioning
5631 .expect("described");
5632 assert!(!sync.supports_time && !sync.supports_gnss);
5633 assert_eq!((sync.tz_offset_min, sync.gnss), (None, None));
5634 }
5635
5636 #[test]
5637 fn positioning_settings_are_written_whole_with_the_receiver_last() {
5638 let session = MobileUlcpSession::administrative();
5639 attach_positioning(&session);
5640
5641 let configured = session
5642 .configure_device(UlcpDeviceConfigRecord {
5643 radio: UlcpRadioSettingsRecord {
5644 device_name: None,
5645 phy_enabled: true,
5646 frequency_khz: 915_000,
5647 transmit_power_dbm: 14,
5648 bandwidth_hz: None,
5649 spreading_factor: None,
5650 coding_rate_denom: None,
5651 duty_cycle_limit: None,
5652 },
5653 ident_role: None,
5654 ident_mobile: Some(false),
5655 dev_discoverable: Some(true),
5656 repeater: Some(UlcpRepeaterSettingsRecord {
5657 enabled: false,
5658 regions: Vec::new(),
5659 default_region: None,
5660 min_rssi_dbm: None,
5661 min_snr_db: None,
5662 }),
5663 tz_offset_min: Some(60),
5664 gnss: Some(UlcpGnssSettingsRecord {
5665 enabled: true,
5666 ident_update: false,
5667 ident_precision: 3,
5668 time_trust: false,
5669 }),
5670 advert: None,
5671 })
5672 .unwrap();
5673 let (written, order, _) = drive_configuration(&session, configured.outbound_frames);
5674 assert_eq!(
5675 written.get(&prop::TZ_OFFSET),
5676 Some(&60i16.to_le_bytes().to_vec())
5677 );
5678 assert_eq!(written.get(&prop::GNSS_IDENT_UPDATE), Some(&vec![0]));
5679 assert_eq!(written.get(&prop::GNSS_IDENT_PRECISION), Some(&vec![3]));
5680 assert_eq!(written.get(&prop::GNSS_TIME_TRUST), Some(&vec![0]));
5681 assert_eq!(written.get(&prop::GNSS_ENABLED), Some(&vec![1]));
5682
5683 let switch = order.iter().position(|key| *key == prop::GNSS_ENABLED);
5686 for policy in [
5687 prop::GNSS_IDENT_UPDATE,
5688 prop::GNSS_IDENT_PRECISION,
5689 prop::GNSS_TIME_TRUST,
5690 ] {
5691 assert!(
5692 order.iter().position(|key| *key == policy) < switch,
5693 "{policy}"
5694 );
5695 }
5696 }
5697
5698 #[test]
5699 fn a_tethered_phone_changes_positioning_without_restating_the_domain() {
5700 let session = MobileUlcpSession::new();
5703 attach_positioning(&session);
5704
5705 let configured = session
5706 .configure_positioning(
5707 Some(UlcpGnssSettingsRecord {
5708 enabled: false,
5709 ident_update: false,
5710 ident_precision: 3,
5711 time_trust: false,
5712 }),
5713 Some(0),
5714 )
5715 .unwrap();
5716 let (written, order, save_tid) = drive_configuration(&session, configured.outbound_frames);
5717
5718 assert_eq!(
5719 written,
5720 HashMap::from([
5721 (prop::TZ_OFFSET, 0i16.to_le_bytes().to_vec()),
5722 (prop::GNSS_IDENT_UPDATE, vec![0]),
5723 (prop::GNSS_IDENT_PRECISION, vec![3]),
5724 (prop::GNSS_TIME_TRUST, vec![0]),
5725 (prop::GNSS_ENABLED, vec![0]),
5726 ]),
5727 "only the zone and the positioning policy are written"
5728 );
5729 assert_eq!(order.last(), Some(&prop::GNSS_ENABLED));
5730
5731 let attached = session
5734 .consume(property_response(save_tid, prop::LAST_STATUS, &[0]))
5735 .unwrap();
5736 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5737 assert_eq!(
5738 attached.snapshot.provisioning.unwrap().gnss,
5739 Some(UlcpGnssSettingsRecord {
5740 enabled: false,
5741 ident_update: false,
5742 ident_precision: 3,
5743 time_trust: false,
5744 })
5745 );
5746 }
5747
5748 #[test]
5749 fn positioning_on_its_own_still_matches_the_capabilities() {
5750 let session = MobileUlcpSession::new();
5751 attach_positioning(&session);
5752 let whole = UlcpGnssSettingsRecord {
5753 enabled: true,
5754 ident_update: false,
5755 ident_precision: 5,
5756 time_trust: true,
5757 };
5758 assert_eq!(
5760 session.configure_positioning(Some(whole), None),
5761 Err(MobileError::InvalidUlcpFrame)
5762 );
5763 assert_eq!(
5764 session.configure_positioning(None, Some(0)),
5765 Err(MobileError::InvalidUlcpFrame)
5766 );
5767
5768 let plain = MobileUlcpSession::new();
5771 attach_commissionable(&plain, Some(vec![0xAA; 32]), vec![0xAA; 32]);
5772 assert_eq!(
5773 plain.configure_positioning(None, None),
5774 Err(MobileError::UnsupportedCapability)
5775 );
5776 }
5777
5778 #[test]
5779 fn a_positioning_record_must_match_what_the_device_can_do() {
5780 let session = MobileUlcpSession::administrative();
5781 attach_positioning(&session);
5782
5783 let whole = UlcpDeviceConfigRecord {
5784 radio: UlcpRadioSettingsRecord {
5785 device_name: None,
5786 phy_enabled: true,
5787 frequency_khz: 915_000,
5788 transmit_power_dbm: 14,
5789 bandwidth_hz: None,
5790 spreading_factor: None,
5791 coding_rate_denom: None,
5792 duty_cycle_limit: None,
5793 },
5794 ident_role: None,
5795 ident_mobile: Some(false),
5796 dev_discoverable: Some(true),
5797 repeater: Some(UlcpRepeaterSettingsRecord {
5798 enabled: false,
5799 regions: Vec::new(),
5800 default_region: None,
5801 min_rssi_dbm: None,
5802 min_snr_db: None,
5803 }),
5804 tz_offset_min: Some(0),
5805 gnss: Some(UlcpGnssSettingsRecord {
5806 enabled: true,
5807 ident_update: false,
5808 ident_precision: 5,
5809 time_trust: true,
5810 }),
5811 advert: None,
5812 };
5813
5814 assert_eq!(
5816 session.configure_device(UlcpDeviceConfigRecord {
5817 tz_offset_min: None,
5818 ..whole.clone()
5819 }),
5820 Err(MobileError::InvalidUlcpFrame)
5821 );
5822 assert_eq!(
5823 session.configure_device(UlcpDeviceConfigRecord {
5824 gnss: None,
5825 ..whole.clone()
5826 }),
5827 Err(MobileError::InvalidUlcpFrame)
5828 );
5829 assert_eq!(
5831 session.configure_device(UlcpDeviceConfigRecord {
5832 gnss: Some(UlcpGnssSettingsRecord {
5833 ident_precision: 8,
5834 ..whole.gnss.unwrap()
5835 }),
5836 ..whole.clone()
5837 }),
5838 Err(MobileError::InvalidUlcpFrame)
5839 );
5840 assert_eq!(
5842 session.configure_device(UlcpDeviceConfigRecord {
5843 tz_offset_min: Some(15 * 60),
5844 ..whole.clone()
5845 }),
5846 Err(MobileError::InvalidUlcpFrame)
5847 );
5848 }
5849
5850 #[test]
5851 fn half_a_positioning_policy_is_withdrawn_whole() {
5852 let session = MobileUlcpSession::administrative();
5853 let mut capabilities = commissionable_capabilities();
5854 capabilities.extend([cap::TIME, cap::GNSS]);
5855 let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
5856 let attached = drive_reads(
5857 &session,
5858 begin.outbound_frames,
5859 move |property| match property {
5860 prop::CAPS => (property, encoded_capabilities(&capabilities)),
5861 prop::HOST_KEY => (property, vec![0xAA; 32]),
5862 prop::GNSS_TIME_TRUST => (
5864 prop::LAST_STATUS,
5865 vec![umsh_ulcp::Status::PROP_NOT_FOUND.0 as u8],
5866 ),
5867 prop::TIME => (property, Vec::new()),
5868 prop::TZ_OFFSET => (property, 0i16.to_le_bytes().to_vec()),
5869 prop::GNSS_ENABLED | prop::GNSS_IDENT_UPDATE => (property, vec![0]),
5870 prop::GNSS_IDENT_PRECISION => (property, vec![5]),
5871 prop::GNSS_LOCATION | prop::GNSS_ALTITUDE | prop::GNSS_PRECISION => {
5872 (property, Vec::new())
5873 }
5874 prop::GNSS_FIX => (property, vec![0]),
5875 prop::GNSS_SATELLITES => (property, vec![0]),
5876 _ => commissionable_value(property),
5877 },
5878 );
5879
5880 let sync = attached.snapshot.provisioning.clone().expect("described");
5881 assert!(sync.supports_gnss);
5882 assert_eq!(sync.gnss, None);
5883 assert_eq!(sync.unreadable_properties, vec![prop::GNSS_TIME_TRUST]);
5884 let gnss = attached.snapshot.gnss.expect("the receiver was read");
5887 assert_eq!(gnss.fix, UlcpFixKind::None);
5888 assert!(gnss.location.is_empty());
5889 assert_eq!(gnss.latitude_deg, None);
5890
5891 let configured = session
5894 .configure_device(UlcpDeviceConfigRecord {
5895 radio: UlcpRadioSettingsRecord {
5896 device_name: None,
5897 phy_enabled: true,
5898 frequency_khz: 915_000,
5899 transmit_power_dbm: 14,
5900 bandwidth_hz: None,
5901 spreading_factor: None,
5902 coding_rate_denom: None,
5903 duty_cycle_limit: None,
5904 },
5905 ident_role: None,
5906 ident_mobile: Some(false),
5907 dev_discoverable: Some(true),
5908 repeater: Some(UlcpRepeaterSettingsRecord {
5909 enabled: false,
5910 regions: Vec::new(),
5911 default_region: None,
5912 min_rssi_dbm: None,
5913 min_snr_db: None,
5914 }),
5915 tz_offset_min: Some(0),
5916 gnss: Some(UlcpGnssSettingsRecord {
5917 enabled: true,
5918 ident_update: true,
5919 ident_precision: 5,
5920 time_trust: true,
5921 }),
5922 advert: None,
5923 })
5924 .unwrap();
5925 let (written, _, _) = drive_configuration(&session, configured.outbound_frames);
5926 for property in [
5927 prop::GNSS_ENABLED,
5928 prop::GNSS_IDENT_UPDATE,
5929 prop::GNSS_IDENT_PRECISION,
5930 prop::GNSS_TIME_TRUST,
5931 ] {
5932 assert!(!written.contains_key(&property), "property {property}");
5933 }
5934 assert_eq!(
5936 written.get(&prop::TZ_OFFSET),
5937 Some(&0i16.to_le_bytes().to_vec())
5938 );
5939 }
5940
5941 #[test]
5942 fn a_receiver_without_a_clock_is_not_a_device_this_phone_believes() {
5943 assert_eq!(
5947 ulcp_inspection_properties(encoded_capabilities(&[cap::GNSS])),
5948 Err(MobileError::InvalidUlcpFrame)
5949 );
5950
5951 let asked =
5955 ulcp_inspection_properties(encoded_capabilities(&[cap::TIME, cap::GNSS])).unwrap();
5956 for property in [
5957 prop::TIME,
5958 prop::TZ_OFFSET,
5959 prop::GNSS_ENABLED,
5960 prop::GNSS_LOCATION,
5961 prop::GNSS_FIX,
5962 prop::GNSS_SATELLITES,
5963 prop::GNSS_TIME_TRUST,
5964 ] {
5965 assert!(asked.contains(&property), "property {property}");
5966 }
5967 let clock_only = ulcp_inspection_properties(encoded_capabilities(&[cap::TIME])).unwrap();
5969 assert!(clock_only.contains(&prop::TIME));
5970 assert!(!clock_only.contains(&prop::GNSS_ENABLED));
5971 }
5972
5973 #[test]
5974 fn a_precision_outside_the_encoding_names_no_cell() {
5975 assert_eq!(ulcp_location_cell_meters(0), None);
5976 assert_eq!(ulcp_location_cell_meters(8), None);
5977 let five = ulcp_location_cell_meters(5).unwrap();
5979 assert!((38.0..39.0).contains(&five), "{five}");
5980 }
5981
5982 #[test]
5983 fn battery_push_does_not_move_an_attached_session_out_of_phase() {
5984 let session = attached_battery_session();
5985 let pushed = session
5986 .consume(property_response(
5987 frame::TID_UNSOLICITED,
5988 prop::BATTERY,
5989 &[0b111, 0x10, 0x10, 45, 1],
5990 ))
5991 .unwrap();
5992 assert_eq!(pushed.snapshot.phase, UlcpSessionPhase::Attached);
5993 assert!(!pushed.waiting_for_responses);
5994 assert!(pushed.outbound_frames.is_empty());
5995 }
5996
5997 #[test]
5998 fn battery_push_to_a_tethered_session_on_another_phones_radio_stays_attached() {
5999 let session = MobileUlcpSession::new();
6007 let ours = vec![0x11; 32];
6008 let theirs = vec![0x22; 32];
6009 let begin = session.begin(Some(ours.clone())).unwrap();
6010 let synchronized =
6011 answer_requests(&session, begin.outbound_frames, |property| match property {
6012 prop::LAST_STATUS => (property, vec![0]),
6013 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
6014 prop::CAPS => (
6017 property,
6018 encoded_capabilities(&[cap::BATTERY, cap::HOST_FILTER]),
6019 ),
6020 prop::BATTERY => (property, vec![0b111, 0x74, 0x0E, 60, 0]),
6021 prop::DEV_KEY | prop::DEV_NAME => (property, Vec::new()),
6022 prop::HOST_KEY => (property, theirs.clone()),
6024 _ => unreachable!(),
6025 });
6026 assert_eq!(
6027 synchronized.snapshot.host_ownership,
6028 UlcpHostOwnership::OtherHost
6029 );
6030 assert_eq!(synchronized.snapshot.phase, UlcpSessionPhase::AwaitingHost);
6031
6032 let claim = session.claim(ours).unwrap();
6036 let claim_tid = Frame::parse(&claim.outbound_frames[0])
6037 .unwrap()
6038 .header
6039 .tid();
6040 let claimed = session
6041 .consume(property_response(claim_tid, prop::HOST_KEY, &theirs))
6042 .unwrap();
6043 let attached = answer_requests(
6044 &session,
6045 claimed.outbound_frames,
6046 |property| match property {
6047 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
6048 prop::PHY_ENABLED => (property, vec![1]),
6049 prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
6050 prop::PHY_TX_POWER => (property, vec![14]),
6051 prop::HOST_RX_FILTERS => (property, Vec::new()),
6052 _ => unreachable!(),
6053 },
6054 );
6055 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
6056 assert_eq!(
6057 attached.snapshot.host_ownership,
6058 UlcpHostOwnership::OtherHost,
6059 "the claim did not take"
6060 );
6061
6062 let pushed = session
6063 .consume(property_response(
6064 frame::TID_UNSOLICITED,
6065 prop::BATTERY,
6066 &[0b111, 0x10, 0x10, 45, 1],
6067 ))
6068 .unwrap();
6069 assert_eq!(
6070 pushed.snapshot.battery.expect("push carries it").percentage,
6071 Some(45)
6072 );
6073 assert_eq!(
6074 pushed.snapshot.phase,
6075 UlcpSessionPhase::Attached,
6076 "a battery report is not an attach decision"
6077 );
6078
6079 let reclaimed = session
6082 .consume(property_response(
6083 frame::TID_UNSOLICITED,
6084 prop::HOST_KEY,
6085 &[0x33; 32],
6086 ))
6087 .unwrap();
6088 assert_eq!(
6089 reclaimed.snapshot.phase,
6090 UlcpSessionPhase::AwaitingHost,
6091 "a third phone taking the radio is the user's call"
6092 );
6093 }
6094}