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 hdlc,
13 host::{PropertyNotification, PropertyNotificationError, TidAllocator},
14 ids::{
15 INTERFACE_TYPE, MAX_AUTO_ANNOUNCE_INTERVAL_S, MIN_AUTO_ANNOUNCE_INTERVAL_S,
16 PROTOCOL_MAJOR_VERSION, PROTOCOL_MINOR_VERSION, cap, prop, saved,
17 },
18 items::{self, Filter},
19 meta::{BufferedRxMeta, RX_FLAG_ACKED, RX_FLAG_BUFFERED},
20 pui,
21};
22
23use crate::{
24 MobileError,
25 mobile_mesh::{MobileMeshManagementAnswerRecord, MobileMeshPropertyWriteRecord},
26};
27
28#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
30pub struct GattSegmentRecord {
31 pub value: Vec<u8>,
32}
33
34#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
36pub struct UlcpPropertyFrameRecord {
37 pub transaction_id: u8,
38 pub command: u8,
39 pub property_id: u32,
40 pub value: Vec<u8>,
41}
42
43#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
45pub struct UlcpBatteryRecord {
46 pub percentage: Option<u8>,
47 pub voltage_mv: Option<u16>,
49 pub charge_state: Option<UlcpChargeState>,
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
57pub enum UlcpChargeState {
58 Discharging,
60 Charging,
62 Charged,
64}
65
66impl UlcpChargeState {
67 fn from_wire(state: BatteryChargeState) -> Self {
68 match state {
69 BatteryChargeState::Discharging => Self::Discharging,
70 BatteryChargeState::Charging => Self::Charging,
71 BatteryChargeState::Charged => Self::Charged,
72 }
73 }
74}
75
76#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
84pub struct UlcpRepeaterSettingsRecord {
85 pub enabled: bool,
88 pub regions: Vec<String>,
93 pub default_region: Option<Vec<u8>>,
96 pub min_rssi_dbm: Option<i16>,
98 pub min_snr_db: Option<i8>,
100}
101
102#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)]
110pub struct UlcpGnssSettingsRecord {
111 pub enabled: bool,
115 pub ident_update: bool,
118 pub ident_precision: u8,
122 pub time_trust: bool,
126}
127
128#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)]
134pub struct UlcpAdvertSettingsRecord {
135 pub advert_interval_seconds: u32,
139 pub beacon_interval_seconds: u32,
143 pub startup_beacon: bool,
145}
146
147#[derive(Clone, Debug, PartialEq, uniffi::Record)]
155pub struct UlcpIdentPositionRecord {
156 pub location: Vec<u8>,
159 pub latitude_deg: Option<f64>,
161 pub longitude_deg: Option<f64>,
163 pub cell_meters: Option<f64>,
165 pub altitude_m: Option<i32>,
167}
168
169#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, uniffi::Enum)]
171pub enum UlcpFixKind {
172 #[default]
174 None,
175 TwoD,
177 ThreeD,
179}
180
181impl UlcpFixKind {
182 fn from_wire(fix: FixKind) -> Self {
183 match fix {
184 FixKind::None => Self::None,
185 FixKind::TwoD => Self::TwoD,
186 FixKind::ThreeD => Self::ThreeD,
187 }
188 }
189}
190
191#[derive(Clone, Debug, PartialEq, uniffi::Record)]
199pub struct UlcpGnssRecord {
200 pub fix: UlcpFixKind,
201 pub location: Vec<u8>,
206 pub latitude_deg: Option<f64>,
214 pub longitude_deg: Option<f64>,
215 pub location_cell_meters: Option<f64>,
217 pub altitude_m: Option<i32>,
219 pub accuracy_dm: Option<u16>,
223 pub satellites_used: u8,
226 pub satellites_in_view: Option<u8>,
228}
229
230#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)]
237pub struct UlcpTimeRecord {
238 pub epoch_seconds: Option<u32>,
243}
244
245#[derive(Clone, Debug, PartialEq, uniffi::Record)]
251pub struct UlcpSyncRecord {
252 pub capability_count: u32,
253 pub has_host_filtering: bool,
254 pub supports_offline_queue: bool,
255 pub supports_delegated_ack: bool,
256 pub supports_device_name: bool,
257 pub device_name: Option<String>,
260 pub supports_lora: bool,
261 pub supports_duty_cycle_limit: bool,
262 pub supports_battery: bool,
265 pub battery: Option<UlcpBatteryRecord>,
273 pub supports_repeater: bool,
275 pub supports_ident: bool,
278 pub supports_device_identity: bool,
281 pub supports_time: bool,
285 pub supports_gnss: bool,
288 pub supports_advert: bool,
291 pub supports_admin: bool,
295 pub supports_alert: bool,
297 pub supports_reboot: bool,
299 pub alert: Option<UlcpAlertState>,
302 pub phy_enabled: bool,
303 pub frequency_khz: u32,
304 pub transmit_power_dbm: i8,
305 pub bandwidth_hz: Option<u32>,
306 pub spreading_factor: Option<u8>,
307 pub coding_rate_denom: Option<u8>,
308 pub duty_cycle_now: Option<u16>,
309 pub duty_cycle_limit: Option<u16>,
310 pub saved: Option<SavedSnapshotRecord>,
311 pub queued_frames: Option<u16>,
312 pub dropped_frames: Option<u32>,
313 pub filter_count: Option<u32>,
314 pub host_channel_count: Option<u32>,
315 pub host_peer_count: Option<u32>,
316 pub auto_ack: Option<bool>,
317 pub repeater: Option<UlcpRepeaterSettingsRecord>,
320 pub dev_peer_keys: Option<Vec<Vec<u8>>>,
324 pub dev_admin_keys: Option<Vec<Vec<u8>>>,
329 pub dev_channel_ids: Option<Vec<Vec<u8>>>,
335 pub ident_role: Option<u8>,
340 pub ident_mobile: Option<bool>,
343 pub ident_position: Option<UlcpIdentPositionRecord>,
347 pub dev_discoverable: Option<bool>,
351 pub tz_offset_min: Option<i16>,
358 pub gnss: Option<UlcpGnssSettingsRecord>,
361 pub advert: Option<UlcpAdvertSettingsRecord>,
364 pub unreadable_properties: Vec<u32>,
373}
374
375#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
381pub enum SavedSnapshotRecord {
382 None,
384 Current,
386 Fallback,
389 Unreadable,
392}
393
394#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
397pub enum UlcpSessionPhase {
398 Idle,
399 Synchronizing,
400 AwaitingHost,
401 Claiming,
402 Configuring,
403 Attached,
404}
405
406#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
409pub struct UlcpRadioSettingsRecord {
410 pub device_name: Option<String>,
411 pub phy_enabled: bool,
412 pub frequency_khz: u32,
413 pub transmit_power_dbm: i8,
414 pub bandwidth_hz: Option<u32>,
415 pub spreading_factor: Option<u8>,
416 pub coding_rate_denom: Option<u8>,
417 pub duty_cycle_limit: Option<u16>,
418}
419
420#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
429pub struct UlcpDeviceConfigRecord {
430 pub radio: UlcpRadioSettingsRecord,
433 pub ident_role: Option<u8>,
437 pub ident_mobile: Option<bool>,
440 pub dev_discoverable: Option<bool>,
444 pub repeater: Option<UlcpRepeaterSettingsRecord>,
447 pub tz_offset_min: Option<i16>,
454 pub gnss: Option<UlcpGnssSettingsRecord>,
457 pub advert: Option<UlcpAdvertSettingsRecord>,
460}
461
462fn gnss_record(snapshot: &GnssSnapshot) -> UlcpGnssRecord {
464 let bytes = snapshot.location();
465 let placed = (!bytes.is_empty()).then(|| NodeLocation::from_bytes(bytes).center());
466 UlcpGnssRecord {
467 fix: UlcpFixKind::from_wire(snapshot.fix),
468 location: bytes.to_vec(),
469 latitude_deg: placed.map(|(latitude, _)| latitude.into()),
470 longitude_deg: placed.map(|(_, longitude)| longitude.into()),
471 location_cell_meters: (!bytes.is_empty())
472 .then(|| ulcp_location_cell_meters(bytes.len() as u8))
473 .flatten(),
474 altitude_m: snapshot.altitude_m,
475 accuracy_dm: snapshot.accuracy_dm,
476 satellites_used: snapshot.sats_used,
477 satellites_in_view: snapshot.sats_in_view,
478 }
479}
480
481#[uniffi::export]
490pub fn ulcp_location_cell_meters(precision_bytes: u8) -> Option<f64> {
491 (1..=MAX_PRECISION)
493 .contains(&precision_bytes)
494 .then(|| 360.0 * 111_320.0 / 16f64.powi(precision_bytes.into()))
495}
496
497#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
499pub enum UlcpAlertState {
500 None,
502 Locate,
505}
506
507impl UlcpAlertState {
508 fn from_wire(state: AlertState) -> Self {
509 match state {
510 AlertState::None => Self::None,
511 AlertState::Locate => Self::Locate,
512 }
513 }
514
515 fn to_wire(self) -> AlertState {
516 match self {
517 Self::None => AlertState::None,
518 Self::Locate => AlertState::Locate,
519 }
520 }
521}
522
523#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
525pub enum UlcpHostOwnership {
526 Unknown,
527 LocalIdentityUnavailable,
528 Unsupported,
529 Unclaimed,
530 Ours,
531 OtherHost,
532}
533
534#[derive(Clone, Debug, PartialEq, uniffi::Record)]
539pub struct UlcpSessionSnapshotRecord {
540 pub generation: u64,
541 pub phase: UlcpSessionPhase,
542 pub host_ownership: UlcpHostOwnership,
543 pub device_key: Option<Vec<u8>>,
544 pub device_name: Option<String>,
545 pub battery: Option<UlcpBatteryRecord>,
546 pub alert: Option<UlcpAlertState>,
553 pub time: Option<UlcpTimeRecord>,
556 pub gnss: Option<UlcpGnssRecord>,
560 pub provisioning: Option<UlcpSyncRecord>,
561}
562
563#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
565pub enum UlcpRawTransmitDisposition {
566 Sent,
567 Retry,
568 Rejected,
569}
570
571#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
573pub struct UlcpRawTransmitResultRecord {
574 pub transaction_id: u8,
575 pub status_code: u32,
576 pub status_name: String,
577 pub disposition: UlcpRawTransmitDisposition,
578}
579
580#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
584pub struct UlcpOperationErrorRecord {
585 pub operation: String,
586 pub status_code: u32,
587 pub status_name: String,
588}
589
590#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
594pub struct UlcpPropertyPushRecord {
595 pub property_id: u32,
596 pub value: Vec<u8>,
597}
598
599#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
609pub struct UlcpLocalManagementEventRecord {
610 pub answers: Vec<MobileMeshManagementAnswerRecord>,
611 pub status_code: Option<u32>,
614}
615
616#[derive(Clone, Debug, PartialEq, uniffi::Record)]
620pub struct UlcpSessionUpdateRecord {
621 pub outbound_frames: Vec<Vec<u8>>,
622 pub received_frames: Vec<UlcpReceivedFrameRecord>,
623 pub snapshot: UlcpSessionSnapshotRecord,
624 pub waiting_for_responses: bool,
625 pub raw_transmit_pending: bool,
628 pub raw_transmit_started_transaction_id: Option<u8>,
630 pub raw_transmit_result: Option<UlcpRawTransmitResultRecord>,
634 pub operation_error: Option<UlcpOperationErrorRecord>,
637 pub management_event: Option<UlcpLocalManagementEventRecord>,
640 pub pushed_properties: Vec<UlcpPropertyPushRecord>,
644}
645
646#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
648pub struct UlcpReceivedFrameRecord {
649 pub data: Vec<u8>,
650 pub rssi_dbm: Option<i16>,
651 pub lqi: Option<u8>,
652 pub snr_cb: Option<i16>,
653 pub was_buffered: bool,
654 pub was_acknowledged: bool,
655 pub age_seconds: u32,
656}
657
658#[derive(Clone, Copy, Debug, PartialEq, Eq)]
659enum SessionStage {
660 Idle,
661 Initial,
662 Inspection,
663 Refreshing,
664 Claiming,
665 Saving,
666 Configuring,
667 SavingConfiguration,
668 AwaitingHost,
669 Attached,
670}
671
672#[derive(Clone, Debug, PartialEq, Eq)]
673enum ExpectedResponse {
674 Property(u32),
675 Claim,
676 Save,
677 ConfigurationProperty(u32),
681 SaveConfiguration,
682 RawTransmit,
683 DevKeyInsert {
688 property: u32,
689 item: Vec<u8>,
690 },
691 DevKeyRemove {
693 property: u32,
694 item: Vec<u8>,
695 },
696 SaveDevKeys {
698 property: u32,
699 },
700 DevChannelInsert(Vec<u8>),
704 DevChannelRemove(Vec<u8>),
707 SaveDevChannels,
709 HostChannelInsert(VecDeque<Vec<u8>>),
713 HostChannelReplace,
716 ManagementGet(u32),
720 ManagementSet(u32),
723 ManagementSave,
725 ManagementCommand,
729}
730
731impl ExpectedResponse {
732 fn is_management(&self) -> bool {
735 matches!(
736 self,
737 Self::ManagementGet(_)
738 | Self::ManagementSet(_)
739 | Self::ManagementSave
740 | Self::ManagementCommand
741 )
742 }
743}
744
745#[derive(Debug, Default)]
753struct LocalManagement {
754 fetch_queue: VecDeque<u32>,
755 write_queue: VecDeque<(u32, Vec<u8>)>,
756 answers: Vec<MobileMeshManagementAnswerRecord>,
757 save_status: Option<u32>,
758}
759
760struct UlcpSessionState {
761 generation: u64,
762 mode: UlcpAttachMode,
767 lazy_inspection: bool,
770 stage: SessionStage,
771 tids: TidAllocator,
772 expected: HashMap<u8, ExpectedResponse>,
773 selected_host_key: Option<[u8; 32]>,
774 radio_host_key: Option<Vec<u8>>,
775 host_key_unsupported: bool,
776 responses: HashMap<u32, UlcpPropertyFrameRecord>,
777 inspection_queue: VecDeque<u32>,
778 configuration_queue: VecDeque<(u32, Vec<u8>)>,
779 device_key: Option<Vec<u8>>,
780 device_name: Option<String>,
781 battery: Option<UlcpBatteryRecord>,
790 alert: Option<UlcpAlertState>,
794 time: Option<UlcpTimeRecord>,
797 gnss: Option<GnssSnapshot>,
802 provisioning: Option<UlcpSyncRecord>,
803 stage_failure_pending: bool,
804 management: Option<LocalManagement>,
806 management_event: Option<UlcpLocalManagementEventRecord>,
809 pushed_properties: Vec<UlcpPropertyPushRecord>,
811}
812
813impl Default for UlcpSessionState {
814 fn default() -> Self {
815 Self {
816 generation: 0,
817 mode: UlcpAttachMode::Tethered,
818 lazy_inspection: false,
819 stage: SessionStage::Idle,
820 tids: TidAllocator::new(),
821 expected: HashMap::new(),
822 selected_host_key: None,
823 radio_host_key: None,
824 host_key_unsupported: false,
825 responses: HashMap::new(),
826 inspection_queue: VecDeque::new(),
827 configuration_queue: VecDeque::new(),
828 device_key: None,
829 device_name: None,
830 battery: None,
831 alert: None,
832 time: None,
833 gnss: None,
834 provisioning: None,
835 stage_failure_pending: false,
836 management: None,
837 management_event: None,
838 pushed_properties: Vec::new(),
839 }
840 }
841}
842
843#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, uniffi::Enum)]
850pub enum UlcpAttachMode {
851 #[default]
854 Tethered,
855 Administrative,
859}
860
861#[derive(uniffi::Object)]
868pub struct MobileUlcpSession {
869 inner: Mutex<UlcpSessionState>,
870 mode: UlcpAttachMode,
871 lazy_inspection: bool,
872}
873
874#[uniffi::export]
875impl MobileUlcpSession {
876 #[uniffi::constructor]
878 pub fn new() -> Arc<Self> {
879 Arc::new(Self::with_mode(UlcpAttachMode::Tethered))
880 }
881
882 #[uniffi::constructor]
885 pub fn administrative() -> Arc<Self> {
886 Arc::new(Self::with_mode(UlcpAttachMode::Administrative))
887 }
888
889 #[uniffi::constructor]
906 pub fn administrative_lazy() -> Arc<Self> {
907 let mut session = Self::with_mode(UlcpAttachMode::Administrative);
908 session.lazy_inspection = true;
909 Arc::new(session)
910 }
911
912 pub fn attach_mode(&self) -> UlcpAttachMode {
914 self.mode
915 }
916
917 pub fn begin(
919 &self,
920 selected_host_key: Option<Vec<u8>>,
921 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
922 let selected_host_key = selected_host_key
923 .map(|key| {
924 key.try_into()
925 .map_err(|_| MobileError::InvalidPublicKeyLength)
926 })
927 .transpose()?;
928 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
929 let generation = state.generation.wrapping_add(1);
930 *state = UlcpSessionState {
931 generation,
932 mode: self.mode,
933 lazy_inspection: self.lazy_inspection,
934 stage: SessionStage::Initial,
935 selected_host_key,
936 ..UlcpSessionState::default()
937 };
938
939 let mut outbound = Vec::new();
940 for property in [
941 prop::LAST_STATUS,
942 prop::PROTOCOL_VERSION,
943 prop::CAPS,
944 prop::DEV_KEY,
945 prop::DEV_NAME,
946 prop::BATTERY,
947 prop::HOST_KEY,
948 ] {
949 outbound.push(state.get_property(property)?);
950 }
951 Ok(state.update(outbound))
952 }
953
954 pub fn claim(&self, host_key: Vec<u8>) -> Result<UlcpSessionUpdateRecord, MobileError> {
956 if self.mode == UlcpAttachMode::Administrative {
959 return Err(MobileError::AdministrativeSession);
960 }
961 let host_key: [u8; 32] = host_key
962 .try_into()
963 .map_err(|_| MobileError::InvalidPublicKeyLength)?;
964 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
965 if state.stage != SessionStage::AwaitingHost
966 || !matches!(
967 state.ownership(),
968 UlcpHostOwnership::Unclaimed | UlcpHostOwnership::OtherHost
969 )
970 {
971 return Err(MobileError::InvalidUlcpFrame);
972 }
973 state.selected_host_key = Some(host_key);
974 state.stage = SessionStage::Claiming;
975 state.expected.clear();
976 let tid = state.allocate_tid();
977 state.expected.insert(tid, ExpectedResponse::Claim);
978 let frame = ulcp_prop_set(tid, prop::HOST_KEY, host_key.to_vec())?;
979 Ok(state.update(vec![frame]))
980 }
981
982 pub fn factory_reset(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
990 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
991 let tid = state.allocate_tid();
992 let frame = ulcp_factory_reset(tid)?;
996 Ok(state.update(vec![frame]))
997 }
998
999 pub fn reboot(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
1009 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1010 let tid = state.allocate_tid();
1011 let frame = ulcp_reboot(tid)?;
1012 Ok(state.update(vec![frame]))
1013 }
1014
1015 pub fn set_alert(&self, state: UlcpAlertState) -> Result<UlcpSessionUpdateRecord, MobileError> {
1028 let mut session = self.inner.lock().expect("ULCP session mutex poisoned");
1029 if session.stage != SessionStage::Attached {
1030 return Err(MobileError::InvalidUlcpFrame);
1031 }
1032 if !session.has_capability(cap::ALERT)? {
1033 return Err(MobileError::UnsupportedCapability);
1034 }
1035 let value = encode_alert_state(state)?;
1036 let tid = session.allocate_tid();
1037 session
1038 .expected
1039 .insert(tid, ExpectedResponse::Property(prop::ALERT));
1040 let frame = ulcp_prop_set(tid, prop::ALERT, value)?;
1041 Ok(session.update(vec![frame]))
1042 }
1043
1044 pub fn set_time(
1060 &self,
1061 epoch_seconds: Option<u32>,
1062 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1063 let mut session = self.inner.lock().expect("ULCP session mutex poisoned");
1064 if session.stage != SessionStage::Attached {
1065 return Err(MobileError::InvalidUlcpFrame);
1066 }
1067 if !session.has_capability(cap::TIME)? {
1068 return Err(MobileError::UnsupportedCapability);
1069 }
1070 let value = epoch_seconds
1071 .map(|epoch| epoch.to_le_bytes().to_vec())
1072 .unwrap_or_default();
1073 let tid = session.allocate_tid();
1074 session
1075 .expected
1076 .insert(tid, ExpectedResponse::Property(prop::TIME));
1077 let frame = ulcp_prop_set(tid, prop::TIME, value)?;
1078 Ok(session.update(vec![frame]))
1079 }
1080
1081 pub fn configure(
1083 &self,
1084 settings: UlcpRadioSettingsRecord,
1085 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1086 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1087 if state.stage != SessionStage::Attached {
1088 return Err(MobileError::InvalidUlcpFrame);
1089 }
1090 validate_radio_settings(&settings, DeviceCapabilities::read(&state)?)?;
1091
1092 state.expected.clear();
1093 state.configuration_queue = state.writable(configuration_values(settings, Vec::new()));
1094 let mut outbound = Vec::new();
1095 state.start_configuration(&mut outbound)?;
1096 Ok(state.update(outbound))
1097 }
1098
1099 pub fn configure_device(
1108 &self,
1109 configuration: UlcpDeviceConfigRecord,
1110 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1111 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1112 if state.stage != SessionStage::Attached {
1113 return Err(MobileError::InvalidUlcpFrame);
1114 }
1115 let capabilities = DeviceCapabilities::read(&state)?;
1116 validate_radio_settings(&configuration.radio, capabilities)?;
1117 let device_values = validate_device_settings(&configuration, capabilities)?;
1118
1119 state.expected.clear();
1120 state.configuration_queue =
1121 state.writable(configuration_values(configuration.radio, device_values));
1122 let mut outbound = Vec::new();
1123 state.start_configuration(&mut outbound)?;
1124 Ok(state.update(outbound))
1125 }
1126
1127 pub fn configure_positioning(
1143 &self,
1144 gnss: Option<UlcpGnssSettingsRecord>,
1145 tz_offset_min: Option<i16>,
1146 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1147 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1148 if state.stage != SessionStage::Attached {
1149 return Err(MobileError::InvalidUlcpFrame);
1150 }
1151 let values = positioning_values(gnss, tz_offset_min, DeviceCapabilities::read(&state)?)?;
1152 if values.is_empty() {
1155 return Err(MobileError::UnsupportedCapability);
1156 }
1157
1158 state.expected.clear();
1159 state.configuration_queue = state.writable(values);
1160 let mut outbound = Vec::new();
1161 state.start_configuration(&mut outbound)?;
1162 Ok(state.update(outbound))
1163 }
1164
1165 pub fn configure_advertising(
1172 &self,
1173 advert: Option<UlcpAdvertSettingsRecord>,
1174 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1175 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1176 if state.stage != SessionStage::Attached {
1177 return Err(MobileError::InvalidUlcpFrame);
1178 }
1179 let values = advert_values(advert, DeviceCapabilities::read(&state)?)?;
1180 if values.is_empty() {
1183 return Err(MobileError::UnsupportedCapability);
1184 }
1185
1186 state.expected.clear();
1187 state.configuration_queue = state.writable(values);
1188 let mut outbound = Vec::new();
1189 state.start_configuration(&mut outbound)?;
1190 Ok(state.update(outbound))
1191 }
1192
1193 pub fn refresh(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
1198 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1199 if state.stage != SessionStage::Attached || !state.expected.is_empty() {
1200 return Err(MobileError::InvalidUlcpFrame);
1201 }
1202 let capabilities = state
1203 .responses
1204 .get(&prop::CAPS)
1205 .ok_or(MobileError::InvalidUlcpFrame)?
1206 .value
1207 .clone();
1208 state.inspection_queue = ulcp_refresh_properties(capabilities)?.into();
1209 let mut outbound = Vec::new();
1210 state.start_refresh(&mut outbound)?;
1211 Ok(state.update(outbound))
1212 }
1213
1214 pub fn refresh_positioning(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
1226 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1227 if state.stage != SessionStage::Attached || !state.expected.is_empty() {
1228 return Err(MobileError::InvalidUlcpFrame);
1229 }
1230 let capabilities = state
1231 .responses
1232 .get(&prop::CAPS)
1233 .ok_or(MobileError::InvalidUlcpFrame)?
1234 .value
1235 .clone();
1236 if !decode_capabilities(&capabilities)?.contains(&cap::GNSS) {
1239 return Err(MobileError::InvalidUlcpFrame);
1240 }
1241 state.inspection_queue = VecDeque::from(vec![
1242 prop::GNSS_LOCATION,
1243 prop::GNSS_ALTITUDE,
1244 prop::GNSS_FIX,
1245 prop::GNSS_PRECISION,
1246 prop::GNSS_SATELLITES,
1247 ]);
1248 let mut outbound = Vec::new();
1249 state.start_refresh(&mut outbound)?;
1250 Ok(state.update(outbound))
1251 }
1252
1253 pub fn begin_property_fetch(
1266 &self,
1267 property_ids: Vec<u32>,
1268 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1269 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1270 state.begin_local_management()?;
1271 state.management = Some(LocalManagement {
1272 fetch_queue: property_ids.into(),
1273 ..LocalManagement::default()
1274 });
1275 let mut outbound = Vec::new();
1276 state.continue_local_management(&mut outbound)?;
1277 Ok(state.update(outbound))
1278 }
1279
1280 pub fn begin_property_writes(
1292 &self,
1293 writes: Vec<MobileMeshPropertyWriteRecord>,
1294 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1295 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1296 state.begin_local_management()?;
1297 state.management = Some(LocalManagement {
1298 write_queue: writes
1299 .into_iter()
1300 .map(|write| (write.property_id, write.value))
1301 .collect(),
1302 ..LocalManagement::default()
1303 });
1304 let mut outbound = Vec::new();
1305 state.continue_local_management(&mut outbound)?;
1306 Ok(state.update(outbound))
1307 }
1308
1309 pub fn begin_save(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
1316 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1317 state.begin_local_management()?;
1318 if !state.has_capability(cap::SAVE)? {
1319 state.management_event = Some(UlcpLocalManagementEventRecord {
1320 answers: Vec::new(),
1321 status_code: None,
1322 });
1323 return Ok(state.update(Vec::new()));
1324 }
1325 state.management = Some(LocalManagement::default());
1326 let tid = state.allocate_tid();
1327 state.expected.insert(tid, ExpectedResponse::ManagementSave);
1328 let frame = ulcp_save(tid)?;
1329 Ok(state.update(vec![frame]))
1330 }
1331
1332 pub fn begin_ble_clear_bonds(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
1341 self.begin_ble_command(ulcp_ble_clear_bonds)
1342 }
1343
1344 pub fn insert_device_channel_key(
1359 &self,
1360 channel_key: Vec<u8>,
1361 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1362 let id = dev_channel_id(&channel_key)?;
1363 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1364 state.begin_device_domain_operation(cap::DEV_IDENTITY)?;
1365 let tid = state.allocate_tid();
1366 state
1367 .expected
1368 .insert(tid, ExpectedResponse::DevChannelInsert(id));
1369 let frame = ulcp_prop_insert(tid, prop::DEV_CHANNEL_KEYS, &channel_key)?;
1370 Ok(state.update(vec![frame]))
1371 }
1372
1373 pub fn remove_device_channel_key(
1382 &self,
1383 channel_key: Vec<u8>,
1384 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1385 let id = dev_channel_id(&channel_key)?;
1386 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1387 state.begin_device_domain_operation(cap::DEV_IDENTITY)?;
1388 let tid = state.allocate_tid();
1389 state
1390 .expected
1391 .insert(tid, ExpectedResponse::DevChannelRemove(id));
1392 let frame = ulcp_prop_remove(tid, prop::DEV_CHANNEL_KEYS, &channel_key)?;
1393 Ok(state.update(vec![frame]))
1394 }
1395
1396 pub fn reconcile_host_channel_keys(
1414 &self,
1415 keys: Vec<Vec<u8>>,
1416 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1417 let mut desired = VecDeque::with_capacity(keys.len());
1418 let mut desired_ids = Vec::with_capacity(keys.len());
1419 for key in keys {
1420 desired_ids.push(dev_channel_id(&key)?);
1421 desired.push_back(key);
1422 }
1423
1424 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1425 if state.stage != SessionStage::Attached || !state.expected.is_empty() {
1426 return Err(MobileError::InvalidUlcpFrame);
1427 }
1428 if !state.has_capability(cap::HOST_KEYS)? {
1429 return Err(MobileError::UnsupportedCapability);
1430 }
1431
1432 let current = state
1433 .responses
1434 .get(&prop::HOST_CHANNEL_KEYS)
1435 .map(|entry| entry.value.clone())
1436 .unwrap_or_default();
1437 let current_ids: Vec<Vec<u8>> = current
1438 .chunks(items::CHANNEL_ID_LEN)
1439 .map(<[u8]>::to_vec)
1440 .collect();
1441
1442 if current_ids.iter().any(|id| !desired_ids.contains(id)) {
1446 let table: Vec<u8> = desired.iter().flatten().copied().collect();
1447 let tid = state.allocate_tid();
1448 state
1449 .expected
1450 .insert(tid, ExpectedResponse::HostChannelReplace);
1451 state.set_host_channel_ids(&desired_ids);
1452 let frame = ulcp_prop_set(tid, prop::HOST_CHANNEL_KEYS, table)?;
1453 return Ok(state.update(vec![frame]));
1454 }
1455
1456 desired.retain(|key| {
1457 !current_ids
1458 .iter()
1459 .any(|id| dev_channel_id(key).is_ok_and(|derived| &derived == id))
1460 });
1461 match state.next_host_channel_insert(desired) {
1462 Some(frame) => Ok(state.update(vec![frame])),
1463 None => Ok(state.update(Vec::new())),
1464 }
1465 }
1466
1467 pub fn insert_device_peer(
1477 &self,
1478 public_key: Vec<u8>,
1479 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1480 self.insert_device_key(cap::DEV_IDENTITY, prop::DEV_PEERS, public_key)
1481 }
1482
1483 pub fn remove_device_peer(
1491 &self,
1492 public_key: Vec<u8>,
1493 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1494 self.remove_device_key(cap::DEV_IDENTITY, prop::DEV_PEERS, public_key)
1495 }
1496
1497 pub fn insert_device_admin(
1514 &self,
1515 public_key: Vec<u8>,
1516 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1517 self.insert_device_key(cap::ADMIN, prop::DEV_ADMINS, public_key)
1518 }
1519
1520 pub fn remove_device_admin(
1529 &self,
1530 public_key: Vec<u8>,
1531 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1532 self.remove_device_key(cap::ADMIN, prop::DEV_ADMINS, public_key)
1533 }
1534
1535 pub fn transmit_raw(
1543 &self,
1544 data: Vec<u8>,
1545 nocca: bool,
1546 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1547 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1548 let raw_pipeline_active = state
1549 .expected
1550 .values()
1551 .all(|expected| matches!(expected, ExpectedResponse::RawTransmit));
1552 if state.stage != SessionStage::Attached || !raw_pipeline_active || data.is_empty() {
1553 return Err(MobileError::InvalidUlcpFrame);
1554 }
1555 let mut available_tid = None;
1556 for _ in 0..usize::from(frame::TID_MAX) {
1557 let candidate = state.allocate_tid();
1558 if !state.expected.contains_key(&candidate) {
1559 available_tid = Some(candidate);
1560 break;
1561 }
1562 }
1563 let tid = available_tid.ok_or(MobileError::InvalidUlcpFrame)?;
1564 state.expected.insert(tid, ExpectedResponse::RawTransmit);
1565 let mut metadata = [0u8; umsh_ulcp::TxMeta::WIRE_LEN];
1566 let flags = if nocca {
1567 umsh_ulcp::meta::TX_FLAG_NOCCA
1568 } else {
1569 0
1570 };
1571 umsh_ulcp::TxMeta {
1572 flags,
1573 ..umsh_ulcp::TxMeta::default()
1574 }
1575 .encode(&mut metadata)
1576 .map_err(|_| MobileError::InvalidUlcpFrame)?;
1577 let mut frame = vec![0u8; data.len() + 16];
1578 let len = umsh_ulcp::frame::str_send(
1579 &mut frame,
1580 tid,
1581 umsh_ulcp::ids::stream::PHY_RAW,
1582 &data,
1583 &metadata,
1584 )
1585 .map_err(|_| MobileError::InvalidUlcpFrame)?;
1586 frame.truncate(len);
1587 let mut update = state.update(vec![frame]);
1588 update.raw_transmit_started_transaction_id = Some(tid);
1589 Ok(update)
1590 }
1591
1592 pub fn consume(&self, frame: Vec<u8>) -> Result<UlcpSessionUpdateRecord, MobileError> {
1594 let parsed = Frame::parse(&frame).map_err(|_| MobileError::UlcpFrameUnparsable)?;
1595 if parsed.command() == Some(Cmd::StrRecv) {
1596 if parsed.header.tid() != frame::TID_UNSOLICITED {
1597 return Err(MobileError::UlcpUnexpectedFrame);
1598 }
1599 let payload = StreamPayload::parse(parsed.payload)
1600 .map_err(|_| MobileError::UlcpMalformedPayload)?;
1601 if payload.stream != umsh_ulcp::ids::stream::PHY_RAW {
1602 return Err(MobileError::UlcpUnexpectedFrame);
1603 }
1604 let metadata = BufferedRxMeta::decode(payload.metadata)
1605 .map_err(|_| MobileError::UlcpMalformedPayload)?;
1606 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1607 if state.stage == SessionStage::Idle {
1608 return Err(MobileError::UlcpUnexpectedFrame);
1609 }
1610 return Ok(state.update_with_received(vec![UlcpReceivedFrameRecord {
1611 data: payload.data.to_vec(),
1612 rssi_dbm: metadata.rx.rssi_dbm,
1613 lqi: metadata.rx.lqi.map(core::num::NonZeroU8::get),
1614 snr_cb: metadata.rx.snr_cb,
1615 was_buffered: metadata.flags & RX_FLAG_BUFFERED != 0,
1616 was_acknowledged: metadata.flags & RX_FLAG_ACKED != 0,
1617 age_seconds: metadata.age_s,
1618 }]));
1619 }
1620 let response = inspect_ulcp_property_frame(frame)?;
1621 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1622 let mut outbound = Vec::new();
1623 let mut raw_transmit_result = None;
1624 let mut operation_error = None;
1625
1626 if response.transaction_id == frame::TID_UNSOLICITED {
1627 if response.command == Cmd::PropIs as u8 {
1628 state
1629 .responses
1630 .insert(response.property_id, response.clone());
1631 state.pushed_properties.push(UlcpPropertyPushRecord {
1635 property_id: response.property_id,
1636 value: response.value.clone(),
1637 });
1638 }
1639 state.apply_property(&response)?;
1640 state.refresh_attached_snapshot(Some(response.property_id))?;
1641 return Ok(state.update(outbound));
1642 }
1643
1644 let expected = state
1645 .expected
1646 .remove(&response.transaction_id)
1647 .ok_or(MobileError::UlcpUnexpectedFrame)?;
1648 match expected {
1649 ExpectedResponse::Property(property) => {
1650 if response.property_id == prop::LAST_STATUS && property != prop::LAST_STATUS {
1651 let expected_property = matches!(
1656 state.stage,
1657 SessionStage::Inspection | SessionStage::Refreshing
1658 );
1659 if expected_property {
1660 state.responses.remove(&property);
1661 } else {
1662 operation_error = Some(ulcp_operation_error(
1663 format!("read property {property}"),
1664 response.value.as_slice(),
1665 )?);
1666 let optional_initial_property = state.stage == SessionStage::Initial
1667 && matches!(
1668 property,
1669 prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY | prop::HOST_KEY
1670 );
1671 state.stage_failure_pending |= !optional_initial_property;
1672 if state.stage == SessionStage::Initial && property == prop::HOST_KEY {
1673 state.host_key_unsupported = true;
1674 }
1675 }
1676 } else {
1677 if response.property_id != property || response.command != Cmd::PropIs as u8 {
1678 return Err(MobileError::UlcpMismatchedResponse);
1679 }
1680 state.responses.insert(property, response.clone());
1681 state.apply_property(&response)?;
1682 }
1683 }
1684 ExpectedResponse::Claim => {
1685 if response.property_id == prop::LAST_STATUS {
1686 operation_error = Some(ulcp_operation_error(
1687 "claim host identity".to_owned(),
1688 response.value.as_slice(),
1689 )?);
1690 state.stage_failure_pending = true;
1691 } else {
1692 if response.property_id != prop::HOST_KEY
1693 || response.command != Cmd::PropIs as u8
1694 {
1695 return Err(MobileError::UlcpMismatchedResponse);
1696 }
1697 state.radio_host_key = Some(response.value.clone());
1703 state.responses.insert(prop::HOST_KEY, response);
1704 if state.has_capability(cap::SAVE)? {
1705 state.stage = SessionStage::Saving;
1706 let tid = state.allocate_tid();
1707 state.expected.insert(tid, ExpectedResponse::Save);
1708 outbound.push(ulcp_save(tid)?);
1709 } else {
1710 state.start_inspection(&mut outbound)?;
1711 }
1712 }
1713 }
1714 ExpectedResponse::Save => {
1715 if response.property_id != prop::LAST_STATUS
1716 || response.command != Cmd::PropIs as u8
1717 {
1718 return Err(MobileError::UlcpMismatchedResponse);
1719 }
1720 if inspect_ulcp_status(response.value.clone())? != 0 {
1721 operation_error = Some(ulcp_operation_error(
1722 "save claimed host identity".to_owned(),
1723 response.value.as_slice(),
1724 )?);
1725 state.stage_failure_pending = true;
1726 } else {
1727 state.start_inspection(&mut outbound)?;
1728 }
1729 }
1730 ExpectedResponse::ConfigurationProperty(property) => {
1731 if response.property_id == prop::LAST_STATUS {
1732 operation_error = Some(ulcp_operation_error(
1733 format!("set property {property}"),
1734 response.value.as_slice(),
1735 )?);
1736 state.stage_failure_pending = true;
1737 state.responses.remove(&property);
1741 } else if response.property_id != property || response.command != Cmd::PropIs as u8
1742 {
1743 return Err(MobileError::UlcpMismatchedResponse);
1744 } else {
1745 state.responses.insert(property, response.clone());
1754 state.apply_property(&response)?;
1755 }
1756 }
1757 ExpectedResponse::SaveConfiguration => {
1758 if response.property_id != prop::LAST_STATUS
1759 || response.command != Cmd::PropIs as u8
1760 {
1761 return Err(MobileError::UlcpMismatchedResponse);
1762 }
1763 if inspect_ulcp_status(response.value.clone())? != 0 {
1764 operation_error = Some(ulcp_operation_error(
1765 "save radio configuration".to_owned(),
1766 response.value.as_slice(),
1767 )?);
1768 state.stage_failure_pending = true;
1769 } else {
1770 state.finish_configuration()?;
1771 }
1772 }
1773 ExpectedResponse::RawTransmit => {
1774 if response.property_id != prop::LAST_STATUS
1775 || response.command != Cmd::PropIs as u8
1776 {
1777 return Err(MobileError::UlcpMismatchedResponse);
1778 }
1779 let status_code = inspect_ulcp_status(response.value)?;
1780 let status = umsh_ulcp::Status(status_code);
1781 raw_transmit_result = Some(UlcpRawTransmitResultRecord {
1782 transaction_id: response.transaction_id,
1783 status_code,
1784 status_name: format!("{status:?}"),
1785 disposition: if status == umsh_ulcp::Status::OK {
1786 UlcpRawTransmitDisposition::Sent
1787 } else if status == umsh_ulcp::Status::BUSY
1788 || status == umsh_ulcp::Status::CCA_FAILURE
1789 {
1790 UlcpRawTransmitDisposition::Retry
1793 } else {
1794 UlcpRawTransmitDisposition::Rejected
1795 },
1796 });
1797 }
1798 ExpectedResponse::HostChannelInsert(mut remaining) => {
1799 if response.property_id == prop::LAST_STATUS {
1800 let error = ulcp_operation_error(
1801 "provision host channel key".to_owned(),
1802 response.value.as_slice(),
1803 )?;
1804 if error.status_code != umsh_ulcp::Status::ALREADY.0 {
1810 operation_error = Some(error);
1811 state.refresh_attached_snapshot(None)?;
1812 remaining.clear();
1813 }
1814 } else if response.property_id != prop::HOST_CHANNEL_KEYS
1815 || response.command != Cmd::PropInserted as u8
1816 {
1817 return Err(MobileError::UlcpMismatchedResponse);
1818 }
1819 if let Some(frame) = state.next_host_channel_insert(remaining) {
1820 outbound.push(frame);
1821 } else {
1822 state.refresh_attached_snapshot(None)?;
1823 }
1824 }
1825 ExpectedResponse::HostChannelReplace => {
1826 if response.property_id == prop::LAST_STATUS {
1827 operation_error = Some(ulcp_operation_error(
1828 "provision host channel keys".to_owned(),
1829 response.value.as_slice(),
1830 )?);
1831 } else if response.property_id != prop::HOST_CHANNEL_KEYS
1832 || response.command != Cmd::PropIs as u8
1833 {
1834 return Err(MobileError::UlcpMismatchedResponse);
1835 }
1836 state.refresh_attached_snapshot(None)?;
1837 }
1838 ExpectedResponse::DevChannelInsert(id) => {
1839 if response.property_id == prop::LAST_STATUS {
1840 let error = ulcp_operation_error(
1841 "insert device channel key".to_owned(),
1842 response.value.as_slice(),
1843 )?;
1844 if error.status_code == umsh_ulcp::Status::ALREADY.0 {
1846 state.patch_dev_channels(&id, true);
1847 state.refresh_attached_snapshot(None)?;
1848 }
1849 operation_error = Some(error);
1850 } else {
1851 if response.property_id != prop::DEV_CHANNEL_KEYS
1852 || response.command != Cmd::PropInserted as u8
1853 || response.value != id
1854 {
1855 return Err(MobileError::UlcpMismatchedResponse);
1856 }
1857 state.patch_dev_channels(&id, true);
1858 if state.has_capability(cap::SAVE)? {
1859 let tid = state.allocate_tid();
1860 state
1861 .expected
1862 .insert(tid, ExpectedResponse::SaveDevChannels);
1863 outbound.push(ulcp_save(tid)?);
1864 }
1865 state.refresh_attached_snapshot(None)?;
1866 }
1867 }
1868 ExpectedResponse::DevChannelRemove(id) => {
1869 if response.property_id == prop::LAST_STATUS {
1870 let error = ulcp_operation_error(
1871 "remove device channel key".to_owned(),
1872 response.value.as_slice(),
1873 )?;
1874 if error.status_code == umsh_ulcp::Status::ITEM_NOT_FOUND.0 {
1875 state.patch_dev_channels(&id, false);
1876 state.refresh_attached_snapshot(None)?;
1877 }
1878 operation_error = Some(error);
1879 } else {
1880 if response.property_id != prop::DEV_CHANNEL_KEYS
1881 || response.command != Cmd::PropRemoved as u8
1882 || response.value != id
1883 {
1884 return Err(MobileError::UlcpMismatchedResponse);
1885 }
1886 state.patch_dev_channels(&id, false);
1887 if state.has_capability(cap::SAVE)? {
1888 let tid = state.allocate_tid();
1889 state
1890 .expected
1891 .insert(tid, ExpectedResponse::SaveDevChannels);
1892 outbound.push(ulcp_save(tid)?);
1893 }
1894 state.refresh_attached_snapshot(None)?;
1895 }
1896 }
1897 ExpectedResponse::SaveDevChannels => {
1898 if response.property_id != prop::LAST_STATUS
1899 || response.command != Cmd::PropIs as u8
1900 {
1901 return Err(MobileError::UlcpMismatchedResponse);
1902 }
1903 if inspect_ulcp_status(response.value.clone())? != 0 {
1904 operation_error = Some(ulcp_operation_error(
1905 "save device channel keys".to_owned(),
1906 response.value.as_slice(),
1907 )?);
1908 }
1909 }
1910 ExpectedResponse::DevKeyInsert { property, item } => {
1911 let table = dev_key_table_name(property);
1912 if response.property_id == prop::LAST_STATUS {
1913 let error = ulcp_operation_error(
1914 format!("insert device {table}"),
1915 response.value.as_slice(),
1916 )?;
1917 if error.status_code == umsh_ulcp::Status::ALREADY.0 {
1920 state.patch_dev_keys(property, &item, true);
1921 state.refresh_attached_snapshot(None)?;
1922 }
1923 operation_error = Some(error);
1924 } else {
1925 if response.property_id != property
1926 || response.command != Cmd::PropInserted as u8
1927 || response.value != item
1928 {
1929 return Err(MobileError::UlcpMismatchedResponse);
1930 }
1931 state.patch_dev_keys(property, &item, true);
1932 if state.has_capability(cap::SAVE)? {
1933 let tid = state.allocate_tid();
1934 state
1935 .expected
1936 .insert(tid, ExpectedResponse::SaveDevKeys { property });
1937 outbound.push(ulcp_save(tid)?);
1938 }
1939 state.refresh_attached_snapshot(None)?;
1940 }
1941 }
1942 ExpectedResponse::DevKeyRemove { property, item } => {
1943 let table = dev_key_table_name(property);
1944 if response.property_id == prop::LAST_STATUS {
1945 let error = ulcp_operation_error(
1946 format!("remove device {table}"),
1947 response.value.as_slice(),
1948 )?;
1949 if error.status_code == umsh_ulcp::Status::ITEM_NOT_FOUND.0 {
1952 state.patch_dev_keys(property, &item, false);
1953 state.refresh_attached_snapshot(None)?;
1954 }
1955 operation_error = Some(error);
1956 } else {
1957 if response.property_id != property
1958 || response.command != Cmd::PropRemoved as u8
1959 || response.value != item
1960 {
1961 return Err(MobileError::UlcpMismatchedResponse);
1962 }
1963 state.patch_dev_keys(property, &item, false);
1964 if state.has_capability(cap::SAVE)? {
1965 let tid = state.allocate_tid();
1966 state
1967 .expected
1968 .insert(tid, ExpectedResponse::SaveDevKeys { property });
1969 outbound.push(ulcp_save(tid)?);
1970 }
1971 state.refresh_attached_snapshot(None)?;
1972 }
1973 }
1974 ExpectedResponse::SaveDevKeys { property } => {
1975 if response.property_id != prop::LAST_STATUS
1976 || response.command != Cmd::PropIs as u8
1977 {
1978 return Err(MobileError::UlcpMismatchedResponse);
1979 }
1980 if inspect_ulcp_status(response.value.clone())? != 0 {
1981 let table = dev_key_table_name(property);
1985 operation_error = Some(ulcp_operation_error(
1986 format!("save device {table}s"),
1987 response.value.as_slice(),
1988 )?);
1989 }
1990 }
1991 ExpectedResponse::ManagementGet(property) => {
1992 if response.property_id == prop::LAST_STATUS && property != prop::LAST_STATUS {
1993 let status_code = inspect_ulcp_status(response.value.clone())?;
1997 state.responses.remove(&property);
1998 state.record_management_answer(MobileMeshManagementAnswerRecord {
1999 property_id: property,
2000 value: None,
2001 status_code: Some(status_code),
2002 });
2003 } else if response.property_id != property || response.command != Cmd::PropIs as u8
2004 {
2005 return Err(MobileError::UlcpMismatchedResponse);
2006 } else {
2007 state.responses.insert(property, response.clone());
2008 state.apply_property(&response)?;
2009 state.record_management_answer(MobileMeshManagementAnswerRecord {
2010 property_id: property,
2011 value: Some(response.value.clone()),
2012 status_code: None,
2013 });
2014 }
2015 state.continue_local_management(&mut outbound)?;
2016 }
2017 ExpectedResponse::ManagementSet(property) => {
2018 if response.property_id == prop::LAST_STATUS && property != prop::LAST_STATUS {
2019 let status_code = inspect_ulcp_status(response.value.clone())?;
2024 state.record_management_answer(MobileMeshManagementAnswerRecord {
2025 property_id: property,
2026 value: None,
2027 status_code: Some(status_code),
2028 });
2029 } else if response.property_id != property || response.command != Cmd::PropIs as u8
2030 {
2031 return Err(MobileError::UlcpMismatchedResponse);
2032 } else {
2033 state.responses.insert(property, response.clone());
2036 state.apply_property(&response)?;
2037 state.record_management_answer(MobileMeshManagementAnswerRecord {
2038 property_id: property,
2039 value: Some(response.value.clone()),
2040 status_code: None,
2041 });
2042 }
2043 state.continue_local_management(&mut outbound)?;
2044 }
2045 ExpectedResponse::ManagementSave | ExpectedResponse::ManagementCommand => {
2046 if response.property_id != prop::LAST_STATUS
2047 || response.command != Cmd::PropIs as u8
2048 {
2049 return Err(MobileError::UlcpMismatchedResponse);
2050 }
2051 let status_code = inspect_ulcp_status(response.value.clone())?;
2052 if let Some(op) = state.management.as_mut() {
2053 op.save_status = Some(status_code);
2054 }
2055 state.continue_local_management(&mut outbound)?;
2056 }
2057 }
2058
2059 if state.expected.is_empty() {
2060 if state.stage_failure_pending {
2061 state.stage_failure_pending = false;
2062 state.recover_from_operation_failure(&mut outbound)?;
2063 } else {
2064 state.advance_completed_stage(&mut outbound)?;
2065 }
2066 }
2067 Ok(state.update_with(outbound, Vec::new(), raw_transmit_result, operation_error))
2068 }
2069
2070 pub fn reset(&self) -> UlcpSessionUpdateRecord {
2072 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
2073 let generation = state.generation.wrapping_add(1);
2074 *state = UlcpSessionState {
2075 generation,
2076 mode: self.mode,
2077 lazy_inspection: self.lazy_inspection,
2078 ..UlcpSessionState::default()
2079 };
2080 state.update(Vec::new())
2081 }
2082
2083 pub fn abandon_raw_transmits(&self, transaction_ids: Vec<u8>) -> UlcpSessionUpdateRecord {
2087 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
2088 for tid in transaction_ids {
2089 if matches!(
2090 state.expected.get(&tid),
2091 Some(ExpectedResponse::RawTransmit)
2092 ) {
2093 state.expected.remove(&tid);
2094 }
2095 }
2096 state.update(Vec::new())
2097 }
2098}
2099
2100impl MobileUlcpSession {
2101 fn begin_ble_command(
2104 &self,
2105 encode: fn(u8) -> Result<Vec<u8>, MobileError>,
2106 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
2107 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
2108 state.begin_local_management()?;
2109 if !state.has_capability(cap::BLE)? {
2110 return Err(MobileError::UnsupportedCapability);
2111 }
2112 state.management = Some(LocalManagement::default());
2113 let tid = state.allocate_tid();
2114 state
2115 .expected
2116 .insert(tid, ExpectedResponse::ManagementCommand);
2117 let frame = encode(tid)?;
2118 Ok(state.update(vec![frame]))
2119 }
2120
2121 fn with_mode(mode: UlcpAttachMode) -> Self {
2122 Self {
2123 inner: Mutex::new(UlcpSessionState {
2124 mode,
2125 ..UlcpSessionState::default()
2126 }),
2127 mode,
2128 lazy_inspection: false,
2129 }
2130 }
2131
2132 fn insert_device_key(
2134 &self,
2135 capability: u32,
2136 property: u32,
2137 public_key: Vec<u8>,
2138 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
2139 let public_key: [u8; 32] = public_key
2140 .try_into()
2141 .map_err(|_| MobileError::InvalidPublicKeyLength)?;
2142 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
2143 state.begin_device_domain_operation(capability)?;
2144 let tid = state.allocate_tid();
2145 state.expected.insert(
2146 tid,
2147 ExpectedResponse::DevKeyInsert {
2148 property,
2149 item: public_key.to_vec(),
2150 },
2151 );
2152 let frame = ulcp_prop_insert(tid, property, &public_key)?;
2153 Ok(state.update(vec![frame]))
2154 }
2155
2156 fn remove_device_key(
2158 &self,
2159 capability: u32,
2160 property: u32,
2161 public_key: Vec<u8>,
2162 ) -> Result<UlcpSessionUpdateRecord, MobileError> {
2163 let public_key: [u8; 32] = public_key
2164 .try_into()
2165 .map_err(|_| MobileError::InvalidPublicKeyLength)?;
2166 let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
2167 state.begin_device_domain_operation(capability)?;
2168 let tid = state.allocate_tid();
2169 state.expected.insert(
2170 tid,
2171 ExpectedResponse::DevKeyRemove {
2172 property,
2173 item: public_key.to_vec(),
2174 },
2175 );
2176 let frame = ulcp_prop_remove(tid, property, &public_key)?;
2177 Ok(state.update(vec![frame]))
2178 }
2179}
2180
2181fn dev_key_table_name(property: u32) -> &'static str {
2184 match property {
2185 prop::DEV_ADMINS => "administrator",
2186 _ => "peer",
2187 }
2188}
2189
2190impl UlcpSessionState {
2191 fn allocate_tid(&mut self) -> u8 {
2192 self.tids.allocate()
2193 }
2194
2195 fn get_property(&mut self, property: u32) -> Result<Vec<u8>, MobileError> {
2196 let tid = self.allocate_tid();
2197 self.expected
2198 .insert(tid, ExpectedResponse::Property(property));
2199 ulcp_prop_get(tid, property)
2200 }
2201
2202 fn phase(&self) -> UlcpSessionPhase {
2203 match self.stage {
2204 SessionStage::Idle => UlcpSessionPhase::Idle,
2205 SessionStage::Initial | SessionStage::Inspection | SessionStage::Saving => {
2206 UlcpSessionPhase::Synchronizing
2207 }
2208 SessionStage::Refreshing => UlcpSessionPhase::Attached,
2211 SessionStage::AwaitingHost => UlcpSessionPhase::AwaitingHost,
2212 SessionStage::Claiming => UlcpSessionPhase::Claiming,
2213 SessionStage::Configuring | SessionStage::SavingConfiguration => {
2214 UlcpSessionPhase::Configuring
2215 }
2216 SessionStage::Attached => UlcpSessionPhase::Attached,
2217 }
2218 }
2219
2220 fn ownership(&self) -> UlcpHostOwnership {
2221 if self.host_key_unsupported {
2222 return UlcpHostOwnership::Unsupported;
2223 }
2224 let Some(radio_key) = self.radio_host_key.as_deref() else {
2225 return UlcpHostOwnership::Unknown;
2226 };
2227 if radio_key.is_empty() {
2228 return UlcpHostOwnership::Unclaimed;
2229 }
2230 match self.selected_host_key {
2231 None => UlcpHostOwnership::LocalIdentityUnavailable,
2232 Some(selected) if radio_key == selected => UlcpHostOwnership::Ours,
2233 Some(_) => UlcpHostOwnership::OtherHost,
2234 }
2235 }
2236
2237 fn update(&mut self, outbound_frames: Vec<Vec<u8>>) -> UlcpSessionUpdateRecord {
2238 self.update_with(outbound_frames, Vec::new(), None, None)
2239 }
2240
2241 fn update_with_received(
2242 &mut self,
2243 received_frames: Vec<UlcpReceivedFrameRecord>,
2244 ) -> UlcpSessionUpdateRecord {
2245 self.update_with(Vec::new(), received_frames, None, None)
2246 }
2247
2248 fn update_with(
2249 &mut self,
2250 outbound_frames: Vec<Vec<u8>>,
2251 received_frames: Vec<UlcpReceivedFrameRecord>,
2252 raw_transmit_result: Option<UlcpRawTransmitResultRecord>,
2253 operation_error: Option<UlcpOperationErrorRecord>,
2254 ) -> UlcpSessionUpdateRecord {
2255 let raw_transmit_pending = self
2256 .expected
2257 .values()
2258 .any(|expected| matches!(expected, ExpectedResponse::RawTransmit));
2259 UlcpSessionUpdateRecord {
2260 outbound_frames,
2261 received_frames,
2262 snapshot: UlcpSessionSnapshotRecord {
2263 generation: self.generation,
2264 phase: self.phase(),
2265 host_ownership: self.ownership(),
2266 device_key: self.device_key.clone(),
2267 device_name: self.device_name.clone(),
2268 battery: self.battery.take(),
2271 alert: self.alert,
2272 time: self.time.take(),
2273 gnss: self.gnss.as_ref().map(gnss_record),
2274 provisioning: self.provisioning.clone(),
2275 },
2276 waiting_for_responses: !self.expected.is_empty(),
2277 raw_transmit_pending,
2278 raw_transmit_started_transaction_id: None,
2279 raw_transmit_result,
2280 operation_error,
2281 management_event: self.management_event.take(),
2284 pushed_properties: std::mem::take(&mut self.pushed_properties),
2285 }
2286 }
2287
2288 fn apply_property(&mut self, response: &UlcpPropertyFrameRecord) -> Result<(), MobileError> {
2289 if response.command != Cmd::PropIs as u8 {
2290 return Ok(());
2293 }
2294 match response.property_id {
2295 prop::DEV_KEY => {
2296 if response.value.is_empty() {
2297 self.device_key = None;
2298 } else if response.value.len() == items::PUBLIC_KEY_LEN {
2299 self.device_key = Some(response.value.clone());
2300 } else {
2301 return Err(MobileError::InvalidUlcpFrame);
2302 }
2303 }
2304 prop::DEV_NAME => {
2305 let name = core::str::from_utf8(&response.value)
2306 .map_err(|_| MobileError::InvalidUlcpFrame)?;
2307 self.device_name = (!name.is_empty()).then(|| name.to_owned());
2308 }
2309 prop::BATTERY => {
2310 self.battery = Some(inspect_ulcp_battery(response.value.clone())?);
2311 }
2312 prop::ALERT => {
2313 self.alert = Some(inspect_ulcp_alert(response.value.clone())?);
2316 }
2317 prop::TIME => {
2318 self.time = Some(UlcpTimeRecord {
2322 epoch_seconds: decode_optional(&response.value, decode_u32)?,
2323 });
2324 }
2325 key if umsh_ulcp::gnss::is_positioning_property(key) => {
2326 self.gnss
2333 .get_or_insert(GnssSnapshot::SEARCHING)
2334 .absorb(key, &response.value)
2335 .map_err(|_| MobileError::InvalidUlcpFrame)?;
2336 }
2337 prop::HOST_KEY => {
2338 if !response.value.is_empty() && response.value.len() != items::PUBLIC_KEY_LEN {
2339 return Err(MobileError::InvalidUlcpFrame);
2340 }
2341 self.radio_host_key = Some(response.value.clone());
2342 }
2343 _ => {}
2344 }
2345 Ok(())
2346 }
2347
2348 fn attaches_without_host_decision(&self) -> bool {
2357 self.mode == UlcpAttachMode::Administrative
2358 || matches!(
2359 self.ownership(),
2360 UlcpHostOwnership::Ours | UlcpHostOwnership::Unsupported
2361 )
2362 }
2363
2364 fn begin_device_domain_operation(&mut self, capability: u32) -> Result<(), MobileError> {
2368 if self.stage != SessionStage::Attached || !self.expected.is_empty() {
2369 return Err(MobileError::InvalidUlcpFrame);
2370 }
2371 if !self.has_capability(capability)? {
2372 return Err(MobileError::InvalidUlcpFrame);
2373 }
2374 Ok(())
2375 }
2376
2377 fn begin_local_management(&mut self) -> Result<(), MobileError> {
2387 if self.stage != SessionStage::Attached || self.management.is_some() {
2388 return Err(MobileError::InvalidUlcpFrame);
2389 }
2390 let busy = self
2391 .expected
2392 .values()
2393 .any(|expected| !matches!(expected, ExpectedResponse::RawTransmit));
2394 if busy {
2395 return Err(MobileError::InvalidUlcpFrame);
2396 }
2397 Ok(())
2398 }
2399
2400 fn allocate_management_tid(&mut self) -> Result<u8, MobileError> {
2407 for _ in 0..usize::from(frame::TID_MAX) {
2408 let tid = self.tids.allocate();
2409 if !self.expected.contains_key(&tid) {
2410 return Ok(tid);
2411 }
2412 }
2413 Err(MobileError::InvalidUlcpFrame)
2414 }
2415
2416 fn record_management_answer(&mut self, answer: MobileMeshManagementAnswerRecord) {
2418 if let Some(op) = self.management.as_mut() {
2419 op.answers.push(answer);
2420 }
2421 }
2422
2423 fn continue_local_management(
2427 &mut self,
2428 outbound: &mut Vec<Vec<u8>>,
2429 ) -> Result<(), MobileError> {
2430 if self.expected.values().any(ExpectedResponse::is_management) {
2431 return Ok(());
2432 }
2433 let Some(mut op) = self.management.take() else {
2434 return Ok(());
2435 };
2436 if let Some((property, value)) = op.write_queue.pop_front() {
2437 let tid = self.allocate_management_tid()?;
2441 self.expected
2442 .insert(tid, ExpectedResponse::ManagementSet(property));
2443 outbound.push(ulcp_prop_set(tid, property, value)?);
2444 self.management = Some(op);
2445 } else if !op.fetch_queue.is_empty() {
2446 let budget = usize::from(frame::TID_MAX).saturating_sub(self.expected.len());
2447 for _ in 0..budget {
2448 let Some(property) = op.fetch_queue.pop_front() else {
2449 break;
2450 };
2451 let tid = self.allocate_management_tid()?;
2452 self.expected
2453 .insert(tid, ExpectedResponse::ManagementGet(property));
2454 outbound.push(ulcp_prop_get(tid, property)?);
2455 }
2456 self.management = Some(op);
2457 } else {
2458 self.management_event = Some(UlcpLocalManagementEventRecord {
2459 answers: op.answers,
2460 status_code: op.save_status,
2461 });
2462 self.refresh_attached_snapshot(None)?;
2463 }
2464 Ok(())
2465 }
2466
2467 fn next_host_channel_insert(&mut self, mut remaining: VecDeque<Vec<u8>>) -> Option<Vec<u8>> {
2470 let key = remaining.pop_front()?;
2471 let tid = self.allocate_tid();
2472 let frame = ulcp_prop_insert(tid, prop::HOST_CHANNEL_KEYS, &key).ok()?;
2473 self.expected
2474 .insert(tid, ExpectedResponse::HostChannelInsert(remaining));
2475 if let Ok(id) = dev_channel_id(&key) {
2476 self.push_host_channel_id(&id);
2477 }
2478 Some(frame)
2479 }
2480
2481 fn set_host_channel_ids(&mut self, ids: &[Vec<u8>]) {
2483 let value = ids.concat();
2484 self.host_channel_entry().value = value;
2485 }
2486
2487 fn push_host_channel_id(&mut self, id: &[u8]) {
2488 let entry = self.host_channel_entry();
2489 if !entry.value.chunks(items::CHANNEL_ID_LEN).any(|c| c == id) {
2490 entry.value.extend_from_slice(id);
2491 }
2492 }
2493
2494 fn host_channel_entry(&mut self) -> &mut UlcpPropertyFrameRecord {
2495 self.responses
2496 .entry(prop::HOST_CHANNEL_KEYS)
2497 .or_insert_with(|| UlcpPropertyFrameRecord {
2498 transaction_id: frame::TID_UNSOLICITED,
2499 command: Cmd::PropIs as u8,
2500 property_id: prop::HOST_CHANNEL_KEYS,
2501 value: Vec::new(),
2502 })
2503 }
2504
2505 fn patch_dev_channels(&mut self, id: &[u8], present: bool) {
2509 let entry = self
2510 .responses
2511 .entry(prop::DEV_CHANNEL_KEYS)
2512 .or_insert_with(|| UlcpPropertyFrameRecord {
2513 transaction_id: frame::TID_UNSOLICITED,
2514 command: Cmd::PropIs as u8,
2515 property_id: prop::DEV_CHANNEL_KEYS,
2516 value: Vec::new(),
2517 });
2518 let mut value = Vec::with_capacity(entry.value.len() + id.len());
2519 let mut found = false;
2520 for chunk in entry.value.chunks(items::CHANNEL_ID_LEN) {
2521 if chunk == id {
2522 found = true;
2523 if !present {
2524 continue;
2525 }
2526 }
2527 value.extend_from_slice(chunk);
2528 }
2529 if present && !found {
2530 value.extend_from_slice(id);
2531 }
2532 entry.value = value;
2533 }
2534
2535 fn patch_dev_keys(&mut self, property: u32, key: &[u8], present: bool) {
2538 let entry = self
2539 .responses
2540 .entry(property)
2541 .or_insert_with(|| UlcpPropertyFrameRecord {
2542 transaction_id: frame::TID_UNSOLICITED,
2543 command: Cmd::PropIs as u8,
2544 property_id: property,
2545 value: Vec::new(),
2546 });
2547 let mut value = Vec::with_capacity(entry.value.len() + key.len());
2548 let mut found = false;
2549 for chunk in entry.value.chunks(items::PUBLIC_KEY_LEN) {
2550 if chunk == key {
2551 found = true;
2552 if !present {
2553 continue;
2554 }
2555 }
2556 value.extend_from_slice(chunk);
2557 }
2558 if present && !found {
2559 value.extend_from_slice(key);
2560 }
2561 entry.value = value;
2562 }
2563
2564 fn has_capability(&self, capability: u32) -> Result<bool, MobileError> {
2565 let capabilities = self
2566 .responses
2567 .get(&prop::CAPS)
2568 .ok_or(MobileError::InvalidUlcpFrame)?;
2569 Ok(decode_capabilities(&capabilities.value)?.contains(&capability))
2570 }
2571
2572 fn writable(&self, values: Vec<(u32, Vec<u8>)>) -> VecDeque<(u32, Vec<u8>)> {
2573 let unreadable = self
2574 .provisioning
2575 .as_ref()
2576 .map(|sync| sync.unreadable_properties.as_slice())
2577 .unwrap_or_default();
2578 writable(values, unreadable).into()
2579 }
2580
2581 fn advance_completed_stage(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2582 match self.stage {
2583 SessionStage::Initial => {
2584 let version = self
2585 .responses
2586 .get(&prop::PROTOCOL_VERSION)
2587 .ok_or(MobileError::InvalidUlcpFrame)?;
2588 if version.value != [PROTOCOL_MAJOR_VERSION, PROTOCOL_MINOR_VERSION] {
2589 return Err(MobileError::InvalidUlcpFrame);
2590 }
2591 let capabilities = self
2592 .responses
2593 .get(&prop::CAPS)
2594 .ok_or(MobileError::InvalidUlcpFrame)?;
2595 self.inspection_queue = if self.lazy_inspection {
2596 VecDeque::from(vec![
2601 prop::INTERFACE_TYPE,
2602 prop::PHY_ENABLED,
2603 prop::PHY_FREQ,
2604 prop::PHY_TX_POWER,
2605 ])
2606 } else {
2607 ulcp_inspection_properties(capabilities.value.clone())?.into()
2608 };
2609 let advertises_host_filter = self.has_capability(cap::HOST_FILTER)?;
2610 if advertises_host_filter == self.host_key_unsupported {
2611 return Err(MobileError::InvalidUlcpFrame);
2612 }
2613 if self.attaches_without_host_decision() {
2614 self.start_inspection(outbound)?;
2615 } else {
2616 self.stage = SessionStage::AwaitingHost;
2617 }
2618 }
2619 SessionStage::Inspection => self.start_inspection(outbound)?,
2620 SessionStage::Refreshing => self.start_refresh(outbound)?,
2621 SessionStage::Configuring => {
2622 if !self.configuration_queue.is_empty() {
2623 self.start_configuration(outbound)?;
2624 } else if self.has_capability(cap::SAVE)? {
2625 self.stage = SessionStage::SavingConfiguration;
2626 let tid = self.allocate_tid();
2627 self.expected
2628 .insert(tid, ExpectedResponse::SaveConfiguration);
2629 outbound.push(ulcp_save(tid)?);
2630 } else {
2631 self.finish_configuration()?;
2632 }
2633 }
2634 SessionStage::Claiming
2635 | SessionStage::Saving
2636 | SessionStage::AwaitingHost
2637 | SessionStage::Attached
2638 | SessionStage::SavingConfiguration
2639 | SessionStage::Idle => {}
2640 }
2641 Ok(())
2642 }
2643
2644 fn recover_from_operation_failure(
2647 &mut self,
2648 outbound: &mut Vec<Vec<u8>>,
2649 ) -> Result<(), MobileError> {
2650 self.configuration_queue.clear();
2651 self.inspection_queue.clear();
2652 match self.stage {
2653 SessionStage::Claiming => self.stage = SessionStage::AwaitingHost,
2654 SessionStage::Saving => {
2655 self.start_inspection(outbound)?;
2658 }
2659 SessionStage::Refreshing
2660 | SessionStage::Configuring
2661 | SessionStage::SavingConfiguration => {
2662 self.stage = SessionStage::Attached;
2666 }
2667 SessionStage::Inspection if self.provisioning.is_some() => {
2668 self.stage = SessionStage::Attached;
2669 }
2670 SessionStage::Initial | SessionStage::Inspection => {
2671 self.stage = SessionStage::Initial;
2675 }
2676 SessionStage::Attached | SessionStage::AwaitingHost | SessionStage::Idle => {}
2677 }
2678 Ok(())
2679 }
2680
2681 fn finish_configuration(&mut self) -> Result<(), MobileError> {
2682 let responses = self.responses.values().cloned().collect();
2683 self.provisioning = Some(inspect_ulcp_sync(responses)?);
2684 self.stage = SessionStage::Attached;
2685 Ok(())
2686 }
2687
2688 fn start_configuration(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2689 self.stage = SessionStage::Configuring;
2690 for _ in 0..usize::from(frame::TID_MAX) {
2691 let Some((property, value)) = self.configuration_queue.pop_front() else {
2692 break;
2693 };
2694 let tid = self.allocate_tid();
2695 self.expected
2696 .insert(tid, ExpectedResponse::ConfigurationProperty(property));
2697 outbound.push(ulcp_prop_set(tid, property, value)?);
2698 }
2699 Ok(())
2700 }
2701
2702 fn start_inspection(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2703 self.stage = SessionStage::Inspection;
2704 if self.inspection_queue.is_empty() {
2705 let responses = self.responses.values().cloned().collect();
2706 self.provisioning = Some(inspect_ulcp_sync(responses)?);
2707 self.stage = SessionStage::Attached;
2708 return Ok(());
2709 }
2710 for _ in 0..usize::from(frame::TID_MAX) {
2711 let Some(property) = self.inspection_queue.pop_front() else {
2712 break;
2713 };
2714 outbound.push(self.get_property(property)?);
2715 }
2716 Ok(())
2717 }
2718
2719 fn start_refresh(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2720 self.stage = SessionStage::Refreshing;
2721 if self.inspection_queue.is_empty() {
2722 let responses = self.responses.values().cloned().collect();
2723 self.provisioning = Some(inspect_ulcp_sync(responses)?);
2724 self.stage = SessionStage::Attached;
2725 return Ok(());
2726 }
2727 for _ in 0..usize::from(frame::TID_MAX) {
2728 let Some(property) = self.inspection_queue.pop_front() else {
2729 break;
2730 };
2731 outbound.push(self.get_property(property)?);
2732 }
2733 Ok(())
2734 }
2735
2736 fn refresh_attached_snapshot(
2753 &mut self,
2754 changed_property: Option<u32>,
2755 ) -> Result<(), MobileError> {
2756 if self.stage != SessionStage::Attached {
2757 return Ok(());
2758 }
2759 let responses = self.responses.values().cloned().collect();
2760 self.provisioning = Some(inspect_ulcp_sync(responses)?);
2761 if changed_property == Some(prop::HOST_KEY) && !self.attaches_without_host_decision() {
2762 self.stage = SessionStage::AwaitingHost;
2763 }
2764 Ok(())
2765 }
2766}
2767
2768#[uniffi::export]
2771pub fn ulcp_inspection_properties(capabilities: Vec<u8>) -> Result<Vec<u32>, MobileError> {
2772 let capabilities = decode_capabilities(&capabilities)?;
2773 validate_capability_dependencies(&capabilities)?;
2774 let has = |capability| capabilities.contains(&capability);
2775
2776 let mut properties = vec![
2777 prop::INTERFACE_TYPE,
2778 prop::PHY_ENABLED,
2779 prop::PHY_FREQ,
2780 prop::PHY_TX_POWER,
2781 ];
2782 if has(cap::PHY_LORA) {
2783 properties.extend([prop::PHY_LORA_BW, prop::PHY_LORA_SF, prop::PHY_LORA_CR]);
2784 }
2785 if has(cap::PHY_DUTY_LIMIT) {
2786 properties.extend([prop::PHY_DUTY_NOW, prop::PHY_DUTY_LIMIT]);
2787 }
2788 if has(cap::SAVE) {
2789 properties.push(prop::SAVED);
2790 }
2791 if has(cap::HOST_FILTER) {
2792 properties.push(prop::HOST_RX_FILTERS);
2793 }
2794 if has(cap::HOST_KEYS) {
2795 properties.extend([prop::HOST_CHANNEL_KEYS, prop::HOST_PEER_KEYS]);
2796 }
2797 if has(cap::HOST_RX_QUEUE) {
2798 properties.extend([prop::HOST_RX_QUEUE_COUNT, prop::HOST_RX_QUEUE_DROPPED]);
2799 }
2800 if has(cap::HOST_AUTO_ACK) {
2801 properties.push(prop::HOST_AUTO_ACK);
2802 }
2803 if has(cap::REPEATER) {
2804 properties.extend([
2805 prop::MAC_REPEATER_ENABLED,
2806 prop::MAC_REPEATER_REGIONS,
2807 prop::MAC_REPEATER_DEFAULT_REGION,
2808 prop::MAC_REPEATER_MIN_RSSI,
2809 prop::MAC_REPEATER_MIN_SNR,
2810 ]);
2811 }
2812 if has(cap::IDENT) {
2813 properties.extend([
2814 prop::IDENT_ROLE,
2815 prop::IDENT_MOBILE,
2816 prop::IDENT_LOCATION,
2821 prop::IDENT_ALTITUDE,
2822 ]);
2823 }
2824 if has(cap::DEV_IDENTITY) {
2825 properties.extend([
2826 prop::DEV_PEERS,
2827 prop::DEV_CHANNEL_KEYS,
2828 prop::DEV_DISCOVERABLE,
2829 ]);
2830 }
2831 if has(cap::ADMIN) {
2832 properties.push(prop::DEV_ADMINS);
2833 }
2834 if has(cap::ALERT) {
2835 properties.push(prop::ALERT);
2838 }
2839 if has(cap::TIME) {
2840 properties.extend([prop::TIME, prop::TZ_OFFSET]);
2845 }
2846 if has(cap::GNSS) {
2847 properties.extend([
2848 prop::GNSS_ENABLED,
2849 prop::GNSS_LOCATION,
2850 prop::GNSS_ALTITUDE,
2851 prop::GNSS_FIX,
2852 prop::GNSS_PRECISION,
2853 prop::GNSS_SATELLITES,
2854 prop::GNSS_IDENT_UPDATE,
2855 prop::GNSS_IDENT_PRECISION,
2856 prop::GNSS_TIME_TRUST,
2857 ]);
2858 }
2859 if has(cap::ADVERT) {
2860 properties.extend([
2861 prop::ADVERT_INTERVAL,
2862 prop::BEACON_INTERVAL,
2863 prop::STARTUP_BEACON,
2864 ]);
2865 }
2866 Ok(properties)
2867}
2868
2869pub(crate) fn ulcp_refresh_properties(capabilities: Vec<u8>) -> Result<Vec<u32>, MobileError> {
2870 let decoded = decode_capabilities(&capabilities)?;
2871 validate_capability_dependencies(&decoded)?;
2872 let has = |capability| decoded.contains(&capability);
2873 let mut properties = Vec::new();
2874 if has(cap::DEV_IDENTITY) {
2875 properties.push(prop::DEV_KEY);
2876 }
2877 if has(cap::DEV_NAME) {
2878 properties.push(prop::DEV_NAME);
2879 }
2880 if has(cap::BATTERY) {
2881 properties.push(prop::BATTERY);
2882 }
2883 if has(cap::HOST_FILTER) {
2884 properties.push(prop::HOST_KEY);
2885 }
2886 properties.extend(ulcp_inspection_properties(capabilities)?);
2887 Ok(properties)
2888}
2889
2890#[uniffi::export]
2900pub fn inspect_ulcp_sync(
2901 responses: Vec<UlcpPropertyFrameRecord>,
2902) -> Result<UlcpSyncRecord, MobileError> {
2903 let value = |key| property_value(&responses, key);
2904 let capabilities = decode_capabilities(value(prop::CAPS)?)?;
2905 validate_capability_dependencies(&capabilities)?;
2906 let has = |capability| capabilities.contains(&capability);
2907
2908 let interface = decode_exact_pui(value(prop::INTERFACE_TYPE)?)?;
2909 if interface != INTERFACE_TYPE {
2910 return Err(MobileError::InvalidUlcpFrame);
2911 }
2912 let phy_enabled = decode_bool(value(prop::PHY_ENABLED)?)?;
2913 let frequency_khz = decode_u32(value(prop::PHY_FREQ)?)?;
2914 let transmit_power_dbm = decode_i8(value(prop::PHY_TX_POWER)?)?;
2915
2916 let mut expected = ExpectedProperties {
2917 responses: &responses,
2918 unreadable: Vec::new(),
2919 };
2920 let lora = has(cap::PHY_LORA);
2921 let bandwidth_hz = expected.read(lora, prop::PHY_LORA_BW, decode_u32);
2922 let spreading_factor = expected.read(lora, prop::PHY_LORA_SF, decode_u8);
2923 let coding_rate_denom = expected.read(lora, prop::PHY_LORA_CR, decode_u8);
2924 let duty = has(cap::PHY_DUTY_LIMIT);
2925 let duty_cycle_now = expected.read(duty, prop::PHY_DUTY_NOW, decode_u16);
2926 let duty_cycle_limit = expected.read(duty, prop::PHY_DUTY_LIMIT, decode_u16);
2927 let saved = expected.read(has(cap::SAVE), prop::SAVED, decode_saved);
2928 let queue = has(cap::HOST_RX_QUEUE);
2929 let queued_frames = expected.read(queue, prop::HOST_RX_QUEUE_COUNT, decode_u16);
2930 let dropped_frames = expected.read(queue, prop::HOST_RX_QUEUE_DROPPED, decode_u32);
2931 let filter_count = expected.read(
2932 has(cap::HOST_FILTER),
2933 prop::HOST_RX_FILTERS,
2934 decode_filter_count,
2935 );
2936 let host_keys = has(cap::HOST_KEYS);
2937 let host_channel_count = expected.read(
2938 host_keys,
2939 prop::HOST_CHANNEL_KEYS,
2940 decode_fixed_count::<{ items::CHANNEL_ID_LEN }>,
2941 );
2942 let host_peer_count = expected.read(
2943 host_keys,
2944 prop::HOST_PEER_KEYS,
2945 decode_fixed_count::<{ items::PUBLIC_KEY_LEN }>,
2946 );
2947 let auto_ack = expected.read(has(cap::HOST_AUTO_ACK), prop::HOST_AUTO_ACK, decode_bool);
2948 let dev_identity = has(cap::DEV_IDENTITY);
2949 let dev_peer_keys = expected.read(
2950 dev_identity,
2951 prop::DEV_PEERS,
2952 decode_fixed_list::<{ items::PUBLIC_KEY_LEN }>,
2953 );
2954 let dev_channel_ids = expected.read(
2955 dev_identity,
2956 prop::DEV_CHANNEL_KEYS,
2957 decode_fixed_list::<{ items::CHANNEL_ID_LEN }>,
2958 );
2959 let manageable = has(cap::ADMIN);
2960 let dev_admin_keys = expected.read(
2961 manageable,
2962 prop::DEV_ADMINS,
2963 decode_fixed_list::<{ items::PUBLIC_KEY_LEN }>,
2964 );
2965 let device_name = expected
2969 .read(has(cap::DEV_NAME), prop::DEV_NAME, decode_device_name)
2970 .flatten();
2971
2972 let battery = reported_value(&responses, has(cap::BATTERY), prop::BATTERY, |value| {
2979 inspect_ulcp_battery(value.to_vec())
2980 });
2981 let alert = reported_value(&responses, has(cap::ALERT), prop::ALERT, |value| {
2982 inspect_ulcp_alert(value.to_vec())
2983 });
2984
2985 let forwards = has(cap::REPEATER);
2988 let repeater_enabled = expected.read(forwards, prop::MAC_REPEATER_ENABLED, decode_bool);
2989 let regions = expected.read(forwards, prop::MAC_REPEATER_REGIONS, decode_region_list);
2990 let default_region = expected.read(
2991 forwards,
2992 prop::MAC_REPEATER_DEFAULT_REGION,
2993 decode_optional_region,
2994 );
2995 let min_rssi_dbm = expected.read(forwards, prop::MAC_REPEATER_MIN_RSSI, |value| {
2996 decode_optional(value, decode_i16)
2997 });
2998 let min_snr_db = expected.read(forwards, prop::MAC_REPEATER_MIN_SNR, |value| {
2999 decode_optional(value, decode_i8)
3000 });
3001 let repeater = (|| {
3002 Some(UlcpRepeaterSettingsRecord {
3003 enabled: repeater_enabled?,
3004 regions: regions?,
3005 default_region: default_region?,
3006 min_rssi_dbm: min_rssi_dbm?,
3007 min_snr_db: min_snr_db?,
3008 })
3009 })();
3010
3011 let ident = has(cap::IDENT);
3014 let ident_role = expected
3015 .read(ident, prop::IDENT_ROLE, |value| {
3016 decode_optional(value, decode_u8)
3017 })
3018 .flatten();
3019 let ident_mobile = expected.read(ident, prop::IDENT_MOBILE, decode_bool);
3020 let ident_location = expected.read(ident, prop::IDENT_LOCATION, |value| {
3024 Ok::<Vec<u8>, MobileError>(value.to_vec())
3025 });
3026 let ident_altitude = expected.read(ident, prop::IDENT_ALTITUDE, decode_optional_altitude);
3027 let ident_position = (|| {
3028 let location = ident_location?;
3029 let placed = (!location.is_empty()).then(|| NodeLocation::from_bytes(&location).center());
3030 Some(UlcpIdentPositionRecord {
3031 latitude_deg: placed.map(|(latitude, _)| latitude.into()),
3032 longitude_deg: placed.map(|(_, longitude)| longitude.into()),
3033 cell_meters: (!location.is_empty())
3034 .then(|| ulcp_location_cell_meters(location.len() as u8))
3035 .flatten(),
3036 altitude_m: ident_altitude?,
3037 location,
3038 })
3039 })();
3040 let dev_discoverable = expected.read(dev_identity, prop::DEV_DISCOVERABLE, decode_bool);
3041
3042 let tz_offset_min = expected.read(has(cap::TIME), prop::TZ_OFFSET, decode_i16);
3043
3044 let positioning = has(cap::GNSS);
3047 let gnss_enabled = expected.read(positioning, prop::GNSS_ENABLED, decode_bool);
3048 let ident_update = expected.read(positioning, prop::GNSS_IDENT_UPDATE, decode_bool);
3049 let ident_precision = expected.read(positioning, prop::GNSS_IDENT_PRECISION, decode_precision);
3050 let time_trust = expected.read(positioning, prop::GNSS_TIME_TRUST, decode_bool);
3051 let gnss = (|| {
3052 Some(UlcpGnssSettingsRecord {
3053 enabled: gnss_enabled?,
3054 ident_update: ident_update?,
3055 ident_precision: ident_precision?,
3056 time_trust: time_trust?,
3057 })
3058 })();
3059
3060 let announces = has(cap::ADVERT);
3063 let advert_interval = expected.read(announces, prop::ADVERT_INTERVAL, decode_u32);
3064 let beacon_interval = expected.read(announces, prop::BEACON_INTERVAL, decode_u32);
3065 let startup_beacon = expected.read(announces, prop::STARTUP_BEACON, decode_bool);
3066 let advert = (|| {
3067 Some(UlcpAdvertSettingsRecord {
3068 advert_interval_seconds: advert_interval?,
3069 beacon_interval_seconds: beacon_interval?,
3070 startup_beacon: startup_beacon?,
3071 })
3072 })();
3073
3074 let mut unreadable_properties = expected.unreadable;
3075 unreadable_properties.sort_unstable();
3076
3077 Ok(UlcpSyncRecord {
3078 capability_count: capabilities
3079 .len()
3080 .try_into()
3081 .map_err(|_| MobileError::InvalidUlcpFrame)?,
3082 has_host_filtering: has(cap::HOST_FILTER),
3083 supports_offline_queue: has(cap::HOST_RX_QUEUE),
3084 supports_delegated_ack: has(cap::HOST_AUTO_ACK),
3085 supports_device_name: has(cap::DEV_NAME),
3086 device_name,
3087 supports_lora: has(cap::PHY_LORA),
3088 supports_duty_cycle_limit: has(cap::PHY_DUTY_LIMIT),
3089 supports_battery: has(cap::BATTERY),
3090 battery,
3091 supports_repeater: has(cap::REPEATER),
3092 supports_ident: has(cap::IDENT),
3093 supports_device_identity: has(cap::DEV_IDENTITY),
3094 supports_time: has(cap::TIME),
3095 supports_gnss: positioning,
3096 supports_advert: announces,
3097 supports_admin: manageable,
3098 supports_alert: has(cap::ALERT),
3099 supports_reboot: has(cap::REBOOT),
3100 alert,
3101 phy_enabled,
3102 frequency_khz,
3103 transmit_power_dbm,
3104 bandwidth_hz,
3105 spreading_factor,
3106 coding_rate_denom,
3107 duty_cycle_now,
3108 duty_cycle_limit,
3109 saved,
3110 queued_frames,
3111 dropped_frames,
3112 filter_count,
3113 host_channel_count,
3114 host_peer_count,
3115 auto_ack,
3116 repeater,
3117 dev_peer_keys,
3118 dev_admin_keys,
3119 dev_channel_ids,
3120 ident_role,
3121 ident_mobile,
3122 ident_position,
3123 dev_discoverable,
3124 tz_offset_min,
3125 gnss,
3126 advert,
3127 unreadable_properties,
3128 })
3129}
3130
3131#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
3140pub enum UlcpManageCategory {
3141 Power,
3143 Radio,
3145 Statistics,
3147 Identity,
3149 Gnss,
3151 Time,
3154 Bluetooth,
3157 Repeater,
3159 PeerNodes,
3161}
3162
3163#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)]
3171pub struct UlcpManagedPropertyIds {
3172 pub caps: u32,
3173 pub device_version: u32,
3174 pub device_model: u32,
3175 pub device_name: u32,
3176 pub battery: u32,
3177 pub phy_enabled: u32,
3178 pub frequency: u32,
3179 pub transmit_power: u32,
3180 pub lora_bandwidth: u32,
3181 pub lora_spreading_factor: u32,
3182 pub lora_coding_rate: u32,
3183 pub duty_cycle_now: u32,
3184 pub duty_cycle_limit: u32,
3185 pub stat_tx_packets: u32,
3186 pub stat_tx_channel_busy: u32,
3187 pub stat_rx_packets: u32,
3188 pub stat_rx_bad_crc: u32,
3189 pub stat_rx_non_umsh: u32,
3190 pub stat_rx_accepted: u32,
3191 pub stat_forwarded: u32,
3192 pub stat_forward_dropped: u32,
3193 pub stat_forward_cancelled: u32,
3194 pub ident_role: u32,
3195 pub ident_mobile: u32,
3196 pub ident_location: u32,
3197 pub ident_altitude: u32,
3198 pub dev_discoverable: u32,
3199 pub gnss_ident_update: u32,
3200 pub gnss_ident_precision: u32,
3201 pub uptime: u32,
3202 pub advert_interval: u32,
3203 pub beacon_interval: u32,
3204 pub startup_beacon: u32,
3205 pub gnss_enabled: u32,
3206 pub gnss_time_trust: u32,
3207 pub ble_enabled: u32,
3208 pub ble_bond_count: u32,
3209 pub ble_link: u32,
3210 pub ble_pairing: u32,
3211 pub time: u32,
3212 pub tz_offset: u32,
3213 pub alert: u32,
3214 pub repeater_enabled: u32,
3215 pub repeater_regions: u32,
3216 pub repeater_default_region: u32,
3217 pub repeater_min_rssi: u32,
3218 pub repeater_min_snr: u32,
3219 pub dev_peers: u32,
3220 pub dev_admins: u32,
3221}
3222
3223#[uniffi::export]
3225pub fn ulcp_managed_property_ids() -> UlcpManagedPropertyIds {
3226 UlcpManagedPropertyIds {
3227 caps: prop::CAPS,
3228 device_version: prop::DEV_VERSION,
3229 device_model: prop::DEV_MODEL,
3230 device_name: prop::DEV_NAME,
3231 battery: prop::BATTERY,
3232 phy_enabled: prop::PHY_ENABLED,
3233 frequency: prop::PHY_FREQ,
3234 transmit_power: prop::PHY_TX_POWER,
3235 lora_bandwidth: prop::PHY_LORA_BW,
3236 lora_spreading_factor: prop::PHY_LORA_SF,
3237 lora_coding_rate: prop::PHY_LORA_CR,
3238 duty_cycle_now: prop::PHY_DUTY_NOW,
3239 duty_cycle_limit: prop::PHY_DUTY_LIMIT,
3240 stat_tx_packets: prop::STAT_TX_PACKETS,
3241 stat_tx_channel_busy: prop::STAT_TX_CHANNEL_BUSY,
3242 stat_rx_packets: prop::STAT_RX_PACKETS,
3243 stat_rx_bad_crc: prop::STAT_RX_BAD_CRC,
3244 stat_rx_non_umsh: prop::STAT_RX_NON_UMSH,
3245 stat_rx_accepted: prop::STAT_RX_ACCEPTED,
3246 stat_forwarded: prop::STAT_FORWARDED,
3247 stat_forward_dropped: prop::STAT_FORWARD_DROPPED,
3248 stat_forward_cancelled: prop::STAT_FORWARD_CANCELLED,
3249 ident_role: prop::IDENT_ROLE,
3250 ident_mobile: prop::IDENT_MOBILE,
3251 ident_location: prop::IDENT_LOCATION,
3252 ident_altitude: prop::IDENT_ALTITUDE,
3253 dev_discoverable: prop::DEV_DISCOVERABLE,
3254 gnss_ident_update: prop::GNSS_IDENT_UPDATE,
3255 gnss_ident_precision: prop::GNSS_IDENT_PRECISION,
3256 uptime: prop::UPTIME,
3257 advert_interval: prop::ADVERT_INTERVAL,
3258 beacon_interval: prop::BEACON_INTERVAL,
3259 startup_beacon: prop::STARTUP_BEACON,
3260 gnss_enabled: prop::GNSS_ENABLED,
3261 gnss_time_trust: prop::GNSS_TIME_TRUST,
3262 ble_enabled: prop::BLE_ENABLED,
3263 ble_bond_count: prop::BLE_BOND_COUNT,
3264 ble_link: prop::BLE_LINK,
3265 ble_pairing: prop::BLE_PAIRING,
3266 time: prop::TIME,
3267 tz_offset: prop::TZ_OFFSET,
3268 alert: prop::ALERT,
3269 repeater_enabled: prop::MAC_REPEATER_ENABLED,
3270 repeater_regions: prop::MAC_REPEATER_REGIONS,
3271 repeater_default_region: prop::MAC_REPEATER_DEFAULT_REGION,
3272 repeater_min_rssi: prop::MAC_REPEATER_MIN_RSSI,
3273 repeater_min_snr: prop::MAC_REPEATER_MIN_SNR,
3274 dev_peers: prop::DEV_PEERS,
3275 dev_admins: prop::DEV_ADMINS,
3276 }
3277}
3278
3279#[uniffi::export]
3284pub fn ulcp_card_properties() -> Vec<u32> {
3285 vec![
3286 prop::CAPS,
3287 prop::DEV_VERSION,
3288 prop::DEV_MODEL,
3289 prop::DEV_NAME,
3290 ]
3291}
3292
3293#[uniffi::export]
3299pub fn ulcp_category_properties(
3300 category: UlcpManageCategory,
3301 capabilities: Vec<u8>,
3302) -> Result<Vec<u32>, MobileError> {
3303 let capabilities = decode_capabilities(&capabilities)?;
3304 validate_capability_dependencies(&capabilities)?;
3305 let has = |capability| capabilities.contains(&capability);
3306 let mut properties = Vec::new();
3307 let mut when = |gate: bool, keys: &[u32]| {
3308 if gate {
3309 properties.extend_from_slice(keys);
3310 }
3311 };
3312
3313 match category {
3314 UlcpManageCategory::Power => when(has(cap::BATTERY), &[prop::BATTERY]),
3315 UlcpManageCategory::Radio => {
3316 when(
3317 true,
3318 &[prop::PHY_ENABLED, prop::PHY_FREQ, prop::PHY_TX_POWER],
3319 );
3320 when(
3321 has(cap::PHY_LORA),
3322 &[prop::PHY_LORA_BW, prop::PHY_LORA_SF, prop::PHY_LORA_CR],
3323 );
3324 when(
3325 has(cap::PHY_DUTY_LIMIT),
3326 &[prop::PHY_DUTY_NOW, prop::PHY_DUTY_LIMIT],
3327 );
3328 }
3329 UlcpManageCategory::Statistics => {
3330 when(
3331 has(cap::STATS),
3332 &[
3333 prop::STAT_TX_PACKETS,
3334 prop::STAT_TX_CHANNEL_BUSY,
3335 prop::STAT_RX_PACKETS,
3336 prop::STAT_RX_BAD_CRC,
3337 prop::STAT_RX_NON_UMSH,
3338 prop::STAT_RX_ACCEPTED,
3339 prop::PHY_DUTY_NOW,
3340 prop::UPTIME,
3341 ],
3342 );
3343 when(
3344 has(cap::STATS) && has(cap::REPEATER),
3345 &[
3346 prop::STAT_FORWARDED,
3347 prop::STAT_FORWARD_DROPPED,
3348 prop::STAT_FORWARD_CANCELLED,
3349 ],
3350 );
3351 }
3352 UlcpManageCategory::Identity => {
3353 when(has(cap::DEV_NAME), &[prop::DEV_NAME]);
3354 when(
3355 has(cap::IDENT),
3356 &[
3357 prop::IDENT_ROLE,
3358 prop::IDENT_MOBILE,
3359 prop::IDENT_LOCATION,
3360 prop::IDENT_ALTITUDE,
3361 ],
3362 );
3363 when(has(cap::DEV_IDENTITY), &[prop::DEV_DISCOVERABLE]);
3364 when(
3369 has(cap::GNSS),
3370 &[prop::GNSS_IDENT_UPDATE, prop::GNSS_IDENT_PRECISION],
3371 );
3372 when(
3373 has(cap::ADVERT),
3374 &[
3375 prop::ADVERT_INTERVAL,
3376 prop::BEACON_INTERVAL,
3377 prop::STARTUP_BEACON,
3378 ],
3379 );
3380 }
3381 UlcpManageCategory::Gnss => when(
3382 has(cap::GNSS),
3383 &[
3384 prop::GNSS_ENABLED,
3385 prop::GNSS_LOCATION,
3386 prop::GNSS_ALTITUDE,
3387 prop::GNSS_FIX,
3388 prop::GNSS_PRECISION,
3389 prop::GNSS_SATELLITES,
3390 ],
3391 ),
3392 UlcpManageCategory::Time => {
3393 when(true, &[prop::UPTIME]);
3396 when(has(cap::TIME), &[prop::TIME, prop::TZ_OFFSET]);
3397 when(has(cap::GNSS), &[prop::GNSS_TIME_TRUST]);
3400 }
3401 UlcpManageCategory::Bluetooth => {
3402 when(
3408 has(cap::BLE),
3409 &[
3410 prop::BLE_ENABLED,
3411 prop::BLE_BOND_COUNT,
3412 prop::BLE_LINK,
3413 prop::BLE_PAIRING,
3414 ],
3415 );
3416 }
3417 UlcpManageCategory::Repeater => when(
3418 has(cap::REPEATER),
3419 &[
3420 prop::MAC_REPEATER_ENABLED,
3421 prop::MAC_REPEATER_REGIONS,
3422 prop::MAC_REPEATER_DEFAULT_REGION,
3423 prop::MAC_REPEATER_MIN_RSSI,
3424 prop::MAC_REPEATER_MIN_SNR,
3425 ],
3426 ),
3427 UlcpManageCategory::PeerNodes => {
3428 when(has(cap::DEV_IDENTITY), &[prop::DEV_PEERS]);
3429 when(has(cap::ADMIN), &[prop::DEV_ADMINS]);
3430 }
3431 }
3432 properties.retain(|&key| umsh_ulcp::ids::admin_reachable(key));
3433 Ok(properties)
3434}
3435
3436#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
3443pub struct UlcpDeviceCardRecord {
3444 pub capabilities: Vec<u8>,
3447 pub device_version: Option<String>,
3451 pub device_model: Option<String>,
3453 pub device_name: Option<String>,
3454 pub supports_device_name: bool,
3455 pub supports_battery: bool,
3456 pub supports_lora: bool,
3457 pub supports_duty_cycle_limit: bool,
3458 pub supports_repeater: bool,
3459 pub supports_ident: bool,
3460 pub supports_device_identity: bool,
3461 pub supports_gnss: bool,
3462 pub supports_advert: bool,
3463 pub supports_admin: bool,
3464 pub supports_alert: bool,
3465 pub supports_ble: bool,
3468 pub supports_reboot: bool,
3470 pub supports_save: bool,
3471 pub supports_multi: bool,
3475}
3476
3477#[uniffi::export]
3484pub fn inspect_ulcp_device_card(
3485 responses: Vec<UlcpPropertyFrameRecord>,
3486) -> Result<UlcpDeviceCardRecord, MobileError> {
3487 let raw = property_value(&responses, prop::CAPS)?.to_vec();
3488 let capabilities = decode_capabilities(&raw)?;
3489 validate_capability_dependencies(&capabilities)?;
3490 let has = |capability| capabilities.contains(&capability);
3491 let text = |key| {
3492 property_value(&responses, key)
3493 .and_then(decode_device_name)
3494 .ok()
3495 .flatten()
3496 };
3497
3498 Ok(UlcpDeviceCardRecord {
3499 capabilities: raw,
3500 device_version: text(prop::DEV_VERSION),
3501 device_model: text(prop::DEV_MODEL),
3502 device_name: text(prop::DEV_NAME),
3503 supports_device_name: has(cap::DEV_NAME),
3504 supports_battery: has(cap::BATTERY),
3505 supports_lora: has(cap::PHY_LORA),
3506 supports_duty_cycle_limit: has(cap::PHY_DUTY_LIMIT),
3507 supports_repeater: has(cap::REPEATER),
3508 supports_ident: has(cap::IDENT),
3509 supports_device_identity: has(cap::DEV_IDENTITY),
3510 supports_gnss: has(cap::GNSS),
3511 supports_advert: has(cap::ADVERT),
3512 supports_admin: has(cap::ADMIN),
3513 supports_alert: has(cap::ALERT),
3514 supports_ble: has(cap::BLE),
3515 supports_reboot: has(cap::REBOOT),
3516 supports_save: has(cap::SAVE),
3517 supports_multi: has(cap::CMD_MULTI),
3518 })
3519}
3520
3521#[derive(Clone, Debug, Default, PartialEq, uniffi::Record)]
3529pub struct UlcpDevicePropertiesRecord {
3530 pub battery: Option<UlcpBatteryRecord>,
3531 pub phy_enabled: Option<bool>,
3532 pub frequency_khz: Option<u32>,
3533 pub transmit_power_dbm: Option<i8>,
3534 pub bandwidth_hz: Option<u32>,
3535 pub spreading_factor: Option<u8>,
3536 pub coding_rate_denom: Option<u8>,
3537 pub duty_cycle_now: Option<u16>,
3538 pub duty_cycle_limit: Option<u16>,
3539 pub stat_tx_packets: Option<u32>,
3540 pub stat_tx_channel_busy: Option<u32>,
3541 pub stat_rx_packets: Option<u32>,
3542 pub stat_rx_bad_crc: Option<u32>,
3543 pub stat_rx_non_umsh: Option<u32>,
3544 pub stat_rx_accepted: Option<u32>,
3545 pub stat_forwarded: Option<u32>,
3546 pub stat_forward_dropped: Option<u32>,
3547 pub stat_forward_cancelled: Option<u32>,
3548 pub device_name: Option<String>,
3549 pub ident_role: Option<u8>,
3552 pub ident_mobile: Option<bool>,
3553 pub ident_location: Option<Vec<u8>>,
3556 pub ident_latitude_deg: Option<f64>,
3557 pub ident_longitude_deg: Option<f64>,
3558 pub ident_location_cell_meters: Option<f64>,
3561 pub ident_altitude_m: Option<i32>,
3562 pub dev_discoverable: Option<bool>,
3563 pub gnss_ident_update: Option<bool>,
3567 pub gnss_ident_precision: Option<u8>,
3568 pub uptime_seconds: Option<u32>,
3571 pub advert_interval_seconds: Option<u32>,
3572 pub beacon_interval_seconds: Option<u32>,
3573 pub startup_beacon: Option<bool>,
3574 pub gnss_enabled: Option<bool>,
3575 pub gnss: Option<UlcpGnssRecord>,
3578 pub gnss_time_trust: Option<bool>,
3579 pub ble_enabled: Option<bool>,
3581 pub ble_bond_count: Option<u8>,
3584 pub ble_link: Option<u8>,
3589 pub ble_pairing: Option<bool>,
3592 pub time: Option<UlcpTimeRecord>,
3596 pub tz_offset_min: Option<i16>,
3597 pub repeater_enabled: Option<bool>,
3598 pub repeater_regions: Option<Vec<String>>,
3599 pub repeater_default_region: Option<Vec<u8>>,
3600 pub repeater_min_rssi_dbm: Option<i16>,
3601 pub repeater_min_snr_db: Option<i8>,
3602 pub dev_peer_keys: Option<Vec<Vec<u8>>>,
3603 pub dev_admin_keys: Option<Vec<Vec<u8>>>,
3604}
3605
3606#[uniffi::export]
3618pub fn ulcp_encode_location(
3619 latitude_deg: f64,
3620 longitude_deg: f64,
3621 precision: u8,
3622) -> Result<Vec<u8>, MobileError> {
3623 if !(1..=MAX_PRECISION).contains(&precision)
3624 || !(-90.0..=90.0).contains(&latitude_deg)
3625 || !(-180.0..=180.0).contains(&longitude_deg)
3626 {
3627 return Err(MobileError::InvalidUlcpFrame);
3628 }
3629 let e7 = |degrees: f64| (degrees * 1e7).round() as i32;
3633 Ok(
3634 NodeLocation::from_e7(e7(latitude_deg), e7(longitude_deg), precision)
3635 .as_bytes()
3636 .to_vec(),
3637 )
3638}
3639
3640#[uniffi::export]
3647pub fn ulcp_property_record(property_id: u32, value: Vec<u8>) -> UlcpPropertyFrameRecord {
3648 UlcpPropertyFrameRecord {
3649 transaction_id: 0,
3652 command: Cmd::PropIs as u8,
3653 property_id,
3654 value,
3655 }
3656}
3657
3658#[uniffi::export]
3665pub fn inspect_ulcp_properties(
3666 responses: Vec<UlcpPropertyFrameRecord>,
3667) -> UlcpDevicePropertiesRecord {
3668 let at = &responses;
3669 let location = optional_value(at, prop::IDENT_LOCATION, |value| {
3670 Ok::<Vec<u8>, MobileError>(value.to_vec())
3671 });
3672 let placed = location
3673 .as_ref()
3674 .filter(|bytes| !bytes.is_empty())
3675 .map(|bytes| NodeLocation::from_bytes(bytes).center());
3676
3677 UlcpDevicePropertiesRecord {
3678 battery: optional_value(at, prop::BATTERY, |value| {
3679 inspect_ulcp_battery(value.to_vec())
3680 }),
3681 phy_enabled: optional_value(at, prop::PHY_ENABLED, decode_bool),
3682 frequency_khz: optional_value(at, prop::PHY_FREQ, decode_u32),
3683 transmit_power_dbm: optional_value(at, prop::PHY_TX_POWER, decode_i8),
3684 bandwidth_hz: optional_value(at, prop::PHY_LORA_BW, decode_u32),
3685 spreading_factor: optional_value(at, prop::PHY_LORA_SF, decode_u8),
3686 coding_rate_denom: optional_value(at, prop::PHY_LORA_CR, decode_u8),
3687 duty_cycle_now: optional_value(at, prop::PHY_DUTY_NOW, decode_u16),
3688 duty_cycle_limit: optional_value(at, prop::PHY_DUTY_LIMIT, decode_u16),
3689 stat_tx_packets: optional_value(at, prop::STAT_TX_PACKETS, decode_u32),
3690 stat_tx_channel_busy: optional_value(at, prop::STAT_TX_CHANNEL_BUSY, decode_u32),
3691 stat_rx_packets: optional_value(at, prop::STAT_RX_PACKETS, decode_u32),
3692 stat_rx_bad_crc: optional_value(at, prop::STAT_RX_BAD_CRC, decode_u32),
3693 stat_rx_non_umsh: optional_value(at, prop::STAT_RX_NON_UMSH, decode_u32),
3694 stat_rx_accepted: optional_value(at, prop::STAT_RX_ACCEPTED, decode_u32),
3695 stat_forwarded: optional_value(at, prop::STAT_FORWARDED, decode_u32),
3696 stat_forward_dropped: optional_value(at, prop::STAT_FORWARD_DROPPED, decode_u32),
3697 stat_forward_cancelled: optional_value(at, prop::STAT_FORWARD_CANCELLED, decode_u32),
3698 device_name: optional_value(at, prop::DEV_NAME, decode_device_name).flatten(),
3699 ident_role: optional_value(at, prop::IDENT_ROLE, |value| {
3700 decode_optional(value, decode_u8)
3701 })
3702 .flatten(),
3703 ident_mobile: optional_value(at, prop::IDENT_MOBILE, decode_bool),
3704 ident_latitude_deg: placed.map(|(latitude, _)| latitude.into()),
3705 ident_longitude_deg: placed.map(|(_, longitude)| longitude.into()),
3706 ident_location_cell_meters: location
3707 .as_ref()
3708 .filter(|bytes| !bytes.is_empty())
3709 .and_then(|bytes| ulcp_location_cell_meters(bytes.len() as u8)),
3710 ident_location: location,
3711 ident_altitude_m: optional_value(at, prop::IDENT_ALTITUDE, decode_optional_altitude)
3712 .flatten(),
3713 dev_discoverable: optional_value(at, prop::DEV_DISCOVERABLE, decode_bool),
3714 gnss_ident_update: optional_value(at, prop::GNSS_IDENT_UPDATE, decode_bool),
3715 gnss_ident_precision: optional_value(at, prop::GNSS_IDENT_PRECISION, decode_precision),
3716 uptime_seconds: optional_value(at, prop::UPTIME, decode_u32),
3717 advert_interval_seconds: optional_value(at, prop::ADVERT_INTERVAL, decode_u32),
3718 beacon_interval_seconds: optional_value(at, prop::BEACON_INTERVAL, decode_u32),
3719 startup_beacon: optional_value(at, prop::STARTUP_BEACON, decode_bool),
3720 gnss_enabled: optional_value(at, prop::GNSS_ENABLED, decode_bool),
3721 gnss: gnss_readout(at),
3722 gnss_time_trust: optional_value(at, prop::GNSS_TIME_TRUST, decode_bool),
3723 ble_enabled: optional_value(at, prop::BLE_ENABLED, decode_bool),
3724 ble_bond_count: optional_value(at, prop::BLE_BOND_COUNT, decode_u8),
3725 ble_link: optional_value(at, prop::BLE_LINK, decode_u8),
3726 ble_pairing: optional_value(at, prop::BLE_PAIRING, decode_bool),
3727 time: optional_value(at, prop::TIME, |value| {
3728 Ok::<UlcpTimeRecord, MobileError>(UlcpTimeRecord {
3729 epoch_seconds: decode_optional(value, decode_u32)?,
3730 })
3731 }),
3732 tz_offset_min: optional_value(at, prop::TZ_OFFSET, decode_i16),
3733 repeater_enabled: optional_value(at, prop::MAC_REPEATER_ENABLED, decode_bool),
3734 repeater_regions: optional_value(at, prop::MAC_REPEATER_REGIONS, decode_region_list),
3735 repeater_default_region: optional_value(
3736 at,
3737 prop::MAC_REPEATER_DEFAULT_REGION,
3738 decode_optional_region,
3739 )
3740 .flatten(),
3741 repeater_min_rssi_dbm: optional_value(at, prop::MAC_REPEATER_MIN_RSSI, |value| {
3742 decode_optional(value, decode_i16)
3743 })
3744 .flatten(),
3745 repeater_min_snr_db: optional_value(at, prop::MAC_REPEATER_MIN_SNR, |value| {
3746 decode_optional(value, decode_i8)
3747 })
3748 .flatten(),
3749 dev_peer_keys: optional_value(
3750 at,
3751 prop::DEV_PEERS,
3752 decode_fixed_list::<{ items::PUBLIC_KEY_LEN }>,
3753 ),
3754 dev_admin_keys: optional_value(
3755 at,
3756 prop::DEV_ADMINS,
3757 decode_fixed_list::<{ items::PUBLIC_KEY_LEN }>,
3758 ),
3759 }
3760}
3761
3762fn gnss_readout(responses: &[UlcpPropertyFrameRecord]) -> Option<UlcpGnssRecord> {
3767 let mut snapshot = GnssSnapshot::SEARCHING;
3768 property_value(responses, prop::GNSS_FIX)
3769 .ok()
3770 .and_then(|value| snapshot.absorb(prop::GNSS_FIX, value).ok())?;
3771 for key in [
3772 prop::GNSS_LOCATION,
3773 prop::GNSS_ALTITUDE,
3774 prop::GNSS_PRECISION,
3775 prop::GNSS_SATELLITES,
3776 ] {
3777 if let Ok(value) = property_value(responses, key) {
3778 let _ = snapshot.absorb(key, value);
3779 }
3780 }
3781 Some(gnss_record(&snapshot))
3782}
3783
3784fn decode_optional_altitude(value: &[u8]) -> Result<Option<i32>, MobileError> {
3786 match value {
3787 [] => Ok(None),
3788 bytes => umsh_ulcp::sint::decode(bytes)
3789 .map(Some)
3790 .map_err(|_| MobileError::InvalidUlcpFrame),
3791 }
3792}
3793
3794#[uniffi::export]
3806pub fn ulcp_dirty_writes(
3807 desired: UlcpDevicePropertiesRecord,
3808 dirty_property_ids: Vec<u32>,
3809) -> Result<Vec<MobileMeshPropertyWriteRecord>, MobileError> {
3810 let mut dirty: Vec<u32> = dirty_property_ids;
3811 dirty.sort_unstable();
3812 dirty.dedup();
3813
3814 let mut values: Vec<(u32, Vec<u8>)> = Vec::new();
3815 for key in &dirty {
3816 let missing = || MobileError::InvalidUlcpFrame;
3820 let value = match *key {
3821 prop::PHY_ENABLED => vec![desired.phy_enabled.ok_or_else(missing)? as u8],
3822 prop::PHY_FREQ => desired
3823 .frequency_khz
3824 .ok_or_else(missing)?
3825 .to_le_bytes()
3826 .to_vec(),
3827 prop::PHY_TX_POWER => vec![desired.transmit_power_dbm.ok_or_else(missing)? as u8],
3828 prop::PHY_LORA_BW => desired
3829 .bandwidth_hz
3830 .ok_or_else(missing)?
3831 .to_le_bytes()
3832 .to_vec(),
3833 prop::PHY_LORA_SF => vec![desired.spreading_factor.ok_or_else(missing)?],
3834 prop::PHY_LORA_CR => vec![desired.coding_rate_denom.ok_or_else(missing)?],
3835 prop::PHY_DUTY_LIMIT => desired
3836 .duty_cycle_limit
3837 .ok_or_else(missing)?
3838 .to_le_bytes()
3839 .to_vec(),
3840 prop::STAT_TX_PACKETS
3841 | prop::STAT_TX_CHANNEL_BUSY
3842 | prop::STAT_RX_PACKETS
3843 | prop::STAT_RX_BAD_CRC
3844 | prop::STAT_RX_NON_UMSH
3845 | prop::STAT_RX_ACCEPTED
3846 | prop::STAT_FORWARDED
3847 | prop::STAT_FORWARD_DROPPED
3848 | prop::STAT_FORWARD_CANCELLED => 0u32.to_le_bytes().to_vec(),
3849 prop::DEV_NAME => desired
3850 .device_name
3851 .clone()
3852 .ok_or_else(missing)?
3853 .into_bytes(),
3854 prop::IDENT_ROLE => desired
3857 .ident_role
3858 .map(|role| vec![role])
3859 .unwrap_or_default(),
3860 prop::IDENT_MOBILE => vec![desired.ident_mobile.ok_or_else(missing)? as u8],
3861 prop::IDENT_LOCATION => {
3862 let location = desired.ident_location.clone().unwrap_or_default();
3863 if location.len() > MAX_PRECISION as usize {
3864 return Err(MobileError::InvalidUlcpFrame);
3865 }
3866 location
3867 }
3868 prop::IDENT_ALTITUDE => match desired.ident_altitude_m {
3869 Some(meters) => {
3870 let mut buf = [0u8; umsh_ulcp::sint::MAX_LEN];
3871 let len = umsh_ulcp::sint::encode(meters, &mut buf)
3872 .map_err(|_| MobileError::InvalidUlcpFrame)?;
3873 buf[..len].to_vec()
3874 }
3875 None => Vec::new(),
3876 },
3877 prop::DEV_DISCOVERABLE => vec![desired.dev_discoverable.ok_or_else(missing)? as u8],
3878 prop::GNSS_ENABLED => vec![desired.gnss_enabled.ok_or_else(missing)? as u8],
3879 prop::GNSS_IDENT_UPDATE => vec![desired.gnss_ident_update.ok_or_else(missing)? as u8],
3880 prop::GNSS_IDENT_PRECISION => {
3881 let precision = desired.gnss_ident_precision.ok_or_else(missing)?;
3882 if !(1..=MAX_PRECISION).contains(&precision) {
3883 return Err(MobileError::InvalidUlcpFrame);
3884 }
3885 vec![precision]
3886 }
3887 prop::GNSS_TIME_TRUST => vec![desired.gnss_time_trust.ok_or_else(missing)? as u8],
3888 prop::BLE_ENABLED => vec![desired.ble_enabled.ok_or_else(missing)? as u8],
3892 prop::BLE_PAIRING => vec![desired.ble_pairing.ok_or_else(missing)? as u8],
3893 prop::TIME => desired
3896 .time
3897 .ok_or_else(missing)?
3898 .epoch_seconds
3899 .map(|epoch| epoch.to_le_bytes().to_vec())
3900 .unwrap_or_default(),
3901 prop::TZ_OFFSET => desired
3902 .tz_offset_min
3903 .ok_or_else(missing)?
3904 .to_le_bytes()
3905 .to_vec(),
3906 prop::ADVERT_INTERVAL => desired
3907 .advert_interval_seconds
3908 .ok_or_else(missing)?
3909 .to_le_bytes()
3910 .to_vec(),
3911 prop::BEACON_INTERVAL => desired
3912 .beacon_interval_seconds
3913 .ok_or_else(missing)?
3914 .to_le_bytes()
3915 .to_vec(),
3916 prop::STARTUP_BEACON => vec![desired.startup_beacon.ok_or_else(missing)? as u8],
3917 prop::MAC_REPEATER_ENABLED => vec![desired.repeater_enabled.ok_or_else(missing)? as u8],
3918 prop::MAC_REPEATER_REGIONS => encode_region_list(
3919 desired
3920 .repeater_regions
3921 .clone()
3922 .ok_or_else(missing)?
3923 .as_slice(),
3924 )?,
3925 prop::MAC_REPEATER_DEFAULT_REGION => {
3927 let region = desired.repeater_default_region.clone().unwrap_or_default();
3928 if !region.is_empty() && region.len() != items::REGION_CODE_LEN {
3929 return Err(MobileError::InvalidUlcpFrame);
3930 }
3931 region
3932 }
3933 prop::MAC_REPEATER_MIN_RSSI => desired
3934 .repeater_min_rssi_dbm
3935 .map(|rssi| rssi.to_le_bytes().to_vec())
3936 .unwrap_or_default(),
3937 prop::MAC_REPEATER_MIN_SNR => desired
3938 .repeater_min_snr_db
3939 .map(|snr| vec![snr as u8])
3940 .unwrap_or_default(),
3941 _ => return Err(MobileError::InvalidUlcpFrame),
3944 };
3945 values.push((*key, value));
3946 }
3947
3948 const RADIO: [u32; 6] = [
3950 prop::PHY_FREQ,
3951 prop::PHY_TX_POWER,
3952 prop::PHY_LORA_BW,
3953 prop::PHY_LORA_SF,
3954 prop::PHY_LORA_CR,
3955 prop::PHY_DUTY_LIMIT,
3956 ];
3957 if values.iter().any(|(key, _)| RADIO.contains(key)) {
3958 let ends_enabled = match values.iter().find(|(key, _)| *key == prop::PHY_ENABLED) {
3959 Some((_, value)) => value.first() == Some(&1),
3960 None => desired.phy_enabled.unwrap_or(true),
3963 };
3964 values.retain(|(key, _)| *key != prop::PHY_ENABLED);
3965 values.insert(0, (prop::PHY_ENABLED, vec![0]));
3966 values.push((prop::PHY_ENABLED, vec![ends_enabled as u8]));
3967 }
3968
3969 Ok(values
3970 .into_iter()
3971 .map(|(property_id, value)| MobileMeshPropertyWriteRecord { property_id, value })
3972 .collect())
3973}
3974
3975const WHOLE_WRITE_GROUPS: [&[u32]; 4] = [
3980 &[prop::PHY_LORA_BW, prop::PHY_LORA_SF, prop::PHY_LORA_CR],
3981 &[
3982 prop::MAC_REPEATER_ENABLED,
3983 prop::MAC_REPEATER_REGIONS,
3984 prop::MAC_REPEATER_DEFAULT_REGION,
3985 prop::MAC_REPEATER_MIN_RSSI,
3986 prop::MAC_REPEATER_MIN_SNR,
3987 ],
3988 &[
3989 prop::GNSS_ENABLED,
3990 prop::GNSS_IDENT_UPDATE,
3991 prop::GNSS_IDENT_PRECISION,
3992 prop::GNSS_TIME_TRUST,
3993 ],
3994 &[
3995 prop::ADVERT_INTERVAL,
3996 prop::BEACON_INTERVAL,
3997 prop::STARTUP_BEACON,
3998 ],
3999];
4000
4001struct ExpectedProperties<'a> {
4004 responses: &'a [UlcpPropertyFrameRecord],
4005 unreadable: Vec<u32>,
4006}
4007
4008impl ExpectedProperties<'_> {
4009 fn read<T>(
4015 &mut self,
4016 gated_on: bool,
4017 key: u32,
4018 decode: impl FnOnce(&[u8]) -> Result<T, MobileError>,
4019 ) -> Option<T> {
4020 if !gated_on {
4021 return None;
4022 }
4023 match property_value(self.responses, key).and_then(decode) {
4024 Ok(value) => Some(value),
4025 Err(_) => {
4026 self.unreadable.push(key);
4027 None
4028 }
4029 }
4030 }
4031}
4032
4033fn reported_value<T>(
4039 responses: &[UlcpPropertyFrameRecord],
4040 gated_on: bool,
4041 key: u32,
4042 decode: impl FnOnce(&[u8]) -> Result<T, MobileError>,
4043) -> Option<T> {
4044 if !gated_on {
4045 return None;
4046 }
4047 optional_value(responses, key, decode)
4048}
4049
4050fn optional_value<T>(
4057 responses: &[UlcpPropertyFrameRecord],
4058 key: u32,
4059 decode: impl FnOnce(&[u8]) -> Result<T, MobileError>,
4060) -> Option<T> {
4061 property_value(responses, key).and_then(decode).ok()
4062}
4063
4064fn property_value(responses: &[UlcpPropertyFrameRecord], key: u32) -> Result<&[u8], MobileError> {
4065 let mut matching = responses
4066 .iter()
4067 .filter(|response| response.property_id == key);
4068 let response = matching.next().ok_or(MobileError::InvalidUlcpFrame)?;
4069 if matching.next().is_some() || response.command != Cmd::PropIs as u8 {
4070 return Err(MobileError::InvalidUlcpFrame);
4071 }
4072 Ok(&response.value)
4073}
4074
4075pub(crate) fn decode_capabilities(value: &[u8]) -> Result<Vec<u32>, MobileError> {
4076 let mut capabilities = Vec::new();
4077 let mut rest = value;
4078 while !rest.is_empty() {
4079 let (capability, used) = pui::decode(rest).map_err(|_| MobileError::InvalidUlcpFrame)?;
4080 if capabilities.contains(&capability) {
4081 return Err(MobileError::InvalidUlcpFrame);
4082 }
4083 capabilities.push(capability);
4084 rest = &rest[used..];
4085 }
4086 Ok(capabilities)
4087}
4088
4089fn validate_capability_dependencies(capabilities: &[u32]) -> Result<(), MobileError> {
4090 let has = |capability| capabilities.contains(&capability);
4091 if has(cap::HOST_RX_QUEUE) && !has(cap::HOST_FILTER)
4092 || has(cap::HOST_KEYS) && !has(cap::HOST_FILTER)
4093 || has(cap::HOST_AUTO_ACK) && (!has(cap::HOST_KEYS) || !has(cap::HOST_RX_QUEUE))
4094 || has(cap::REPEATER) && !has(cap::DEV_IDENTITY)
4097 || has(cap::IDENT) && !has(cap::DEV_IDENTITY)
4098 || has(cap::ADMIN) && !has(cap::DEV_IDENTITY)
4102 || has(cap::ADVERT) && !has(cap::DEV_IDENTITY)
4104 || has(cap::GNSS) && !has(cap::TIME)
4107 {
4108 return Err(MobileError::InvalidUlcpFrame);
4109 }
4110 Ok(())
4111}
4112
4113fn decode_exact_pui(value: &[u8]) -> Result<u32, MobileError> {
4114 let (decoded, used) = pui::decode(value).map_err(|_| MobileError::InvalidUlcpFrame)?;
4115 (used == value.len())
4116 .then_some(decoded)
4117 .ok_or(MobileError::InvalidUlcpFrame)
4118}
4119
4120fn decode_bool(value: &[u8]) -> Result<bool, MobileError> {
4121 match value {
4122 [0] => Ok(false),
4123 [1] => Ok(true),
4124 _ => Err(MobileError::InvalidUlcpFrame),
4125 }
4126}
4127
4128fn decode_saved(value: &[u8]) -> Result<SavedSnapshotRecord, MobileError> {
4129 match value {
4130 [saved::NONE] => Ok(SavedSnapshotRecord::None),
4131 [saved::CURRENT] => Ok(SavedSnapshotRecord::Current),
4132 [saved::FALLBACK] => Ok(SavedSnapshotRecord::Fallback),
4133 [saved::UNREADABLE] => Ok(SavedSnapshotRecord::Unreadable),
4134 _ => Err(MobileError::InvalidUlcpFrame),
4135 }
4136}
4137
4138fn decode_u16(value: &[u8]) -> Result<u16, MobileError> {
4139 value
4140 .try_into()
4141 .map(u16::from_le_bytes)
4142 .map_err(|_| MobileError::InvalidUlcpFrame)
4143}
4144
4145fn decode_u8(value: &[u8]) -> Result<u8, MobileError> {
4146 value
4147 .first()
4148 .copied()
4149 .filter(|_| value.len() == 1)
4150 .ok_or(MobileError::InvalidUlcpFrame)
4151}
4152
4153fn decode_i8(value: &[u8]) -> Result<i8, MobileError> {
4154 decode_u8(value).map(|value| value as i8)
4155}
4156
4157fn decode_precision(value: &[u8]) -> Result<u8, MobileError> {
4161 decode_u8(value)
4162 .ok()
4163 .filter(|bytes| (1..=MAX_PRECISION).contains(bytes))
4164 .ok_or(MobileError::InvalidUlcpFrame)
4165}
4166
4167fn decode_i16(value: &[u8]) -> Result<i16, MobileError> {
4168 value
4169 .try_into()
4170 .map(i16::from_le_bytes)
4171 .map_err(|_| MobileError::InvalidUlcpFrame)
4172}
4173
4174fn decode_optional<T>(
4176 value: &[u8],
4177 decode: impl Fn(&[u8]) -> Result<T, MobileError>,
4178) -> Result<Option<T>, MobileError> {
4179 if value.is_empty() {
4180 return Ok(None);
4181 }
4182 decode(value).map(Some)
4183}
4184
4185fn decode_region_list(value: &[u8]) -> Result<Vec<String>, MobileError> {
4191 let mut regions = Vec::new();
4192 for item in items::prefixed_items(value) {
4193 let item = item.map_err(|_| MobileError::InvalidUlcpFrame)?;
4194 let text = core::str::from_utf8(item).map_err(|_| MobileError::InvalidUlcpFrame)?;
4195 regions.push(text.to_owned());
4196 }
4197 Ok(regions)
4198}
4199
4200fn encode_region_list(regions: &[String]) -> Result<Vec<u8>, MobileError> {
4206 let mut value = Vec::new();
4207 for region in regions {
4208 if !(1..=items::REGION_STRING_MAX_LEN).contains(®ion.len()) {
4209 return Err(MobileError::InvalidUlcpFrame);
4210 }
4211 let mut item = vec![0u8; region.len() + 4];
4212 let len = items::encode_prefixed_item(region.as_bytes(), &mut item)
4213 .map_err(|_| MobileError::InvalidUlcpFrame)?;
4214 value.extend_from_slice(&item[..len]);
4215 }
4216 Ok(value)
4217}
4218
4219fn decode_optional_region(value: &[u8]) -> Result<Option<Vec<u8>>, MobileError> {
4220 match value.len() {
4221 0 => Ok(None),
4222 items::REGION_CODE_LEN => Ok(Some(value.to_vec())),
4223 _ => Err(MobileError::InvalidUlcpFrame),
4224 }
4225}
4226
4227fn writable(values: Vec<(u32, Vec<u8>)>, unreadable: &[u32]) -> Vec<(u32, Vec<u8>)> {
4235 if unreadable.is_empty() {
4236 return values;
4237 }
4238 let dropped = |property: u32| {
4239 unreadable.contains(&property)
4240 || WHOLE_WRITE_GROUPS.iter().any(|group| {
4241 group.contains(&property) && group.iter().any(|part| unreadable.contains(part))
4242 })
4243 };
4244 values
4245 .into_iter()
4246 .filter(|(property, _)| !dropped(*property))
4247 .collect()
4248}
4249
4250pub(crate) fn device_config_writes(
4259 configuration: UlcpDeviceConfigRecord,
4260 reported: &UlcpSyncRecord,
4261) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
4262 let capabilities = DeviceCapabilities::reported(reported);
4263 validate_radio_settings(&configuration.radio, capabilities)?;
4264 let device_values = validate_device_settings(&configuration, capabilities)?;
4265 Ok(writable(
4266 configuration_values(configuration.radio, device_values),
4267 &reported.unreadable_properties,
4268 ))
4269}
4270
4271#[derive(Clone, Copy)]
4279struct DeviceCapabilities {
4280 device_name: bool,
4281 lora: bool,
4282 duty_cycle_limit: bool,
4283 ident: bool,
4284 dev_identity: bool,
4285 repeater: bool,
4286 time: bool,
4287 gnss: bool,
4288 advert: bool,
4289}
4290
4291impl DeviceCapabilities {
4292 fn read(state: &UlcpSessionState) -> Result<Self, MobileError> {
4294 Ok(Self {
4295 device_name: state.has_capability(cap::DEV_NAME)?,
4296 lora: state.has_capability(cap::PHY_LORA)?,
4297 duty_cycle_limit: state.has_capability(cap::PHY_DUTY_LIMIT)?,
4298 ident: state.has_capability(cap::IDENT)?,
4299 dev_identity: state.has_capability(cap::DEV_IDENTITY)?,
4300 repeater: state.has_capability(cap::REPEATER)?,
4301 time: state.has_capability(cap::TIME)?,
4302 gnss: state.has_capability(cap::GNSS)?,
4303 advert: state.has_capability(cap::ADVERT)?,
4304 })
4305 }
4306
4307 fn reported(sync: &UlcpSyncRecord) -> Self {
4309 Self {
4310 device_name: sync.supports_device_name,
4311 lora: sync.supports_lora,
4312 duty_cycle_limit: sync.supports_duty_cycle_limit,
4313 ident: sync.supports_ident,
4314 dev_identity: sync.supports_device_identity,
4315 repeater: sync.supports_repeater,
4316 time: sync.supports_time,
4317 gnss: sync.supports_gnss,
4318 advert: sync.supports_advert,
4319 }
4320 }
4321}
4322
4323fn configuration_values(
4331 settings: UlcpRadioSettingsRecord,
4332 device_values: Vec<(u32, Vec<u8>)>,
4333) -> Vec<(u32, Vec<u8>)> {
4334 let mut values = Vec::new();
4335 if !settings.phy_enabled {
4336 values.push((prop::PHY_ENABLED, vec![0]));
4337 }
4338 if let Some(name) = settings.device_name {
4339 values.push((prop::DEV_NAME, name.into_bytes()));
4340 }
4341 values.extend([
4342 (
4343 prop::PHY_FREQ,
4344 settings.frequency_khz.to_le_bytes().to_vec(),
4345 ),
4346 (prop::PHY_TX_POWER, vec![settings.transmit_power_dbm as u8]),
4347 ]);
4348 if let (Some(bandwidth), Some(sf), Some(cr)) = (
4349 settings.bandwidth_hz,
4350 settings.spreading_factor,
4351 settings.coding_rate_denom,
4352 ) {
4353 values.extend([
4354 (prop::PHY_LORA_BW, bandwidth.to_le_bytes().to_vec()),
4355 (prop::PHY_LORA_SF, vec![sf]),
4356 (prop::PHY_LORA_CR, vec![cr]),
4357 ]);
4358 }
4359 if let Some(limit) = settings.duty_cycle_limit {
4360 values.push((prop::PHY_DUTY_LIMIT, limit.to_le_bytes().to_vec()));
4361 }
4362 values.extend(device_values);
4363 if settings.phy_enabled {
4364 values.push((prop::PHY_ENABLED, vec![1]));
4365 }
4366 values
4367}
4368
4369fn validate_device_settings(
4377 configuration: &UlcpDeviceConfigRecord,
4378 capabilities: DeviceCapabilities,
4379) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
4380 let mut values = Vec::new();
4381
4382 let supports_ident = capabilities.ident;
4383 if configuration.ident_mobile.is_some() != supports_ident
4384 || (configuration.ident_role.is_some() && !supports_ident)
4385 {
4386 return Err(MobileError::InvalidUlcpFrame);
4387 }
4388 if supports_ident {
4389 values.push((
4391 prop::IDENT_ROLE,
4392 configuration
4393 .ident_role
4394 .map(|role| vec![role])
4395 .unwrap_or_default(),
4396 ));
4397 values.push((
4398 prop::IDENT_MOBILE,
4399 vec![configuration.ident_mobile.unwrap_or(false) as u8],
4400 ));
4401 }
4402
4403 let supports_dev_identity = capabilities.dev_identity;
4404 if configuration.dev_discoverable.is_some() != supports_dev_identity {
4405 return Err(MobileError::InvalidUlcpFrame);
4406 }
4407 if let Some(discoverable) = configuration.dev_discoverable {
4408 values.push((prop::DEV_DISCOVERABLE, vec![discoverable as u8]));
4409 }
4410
4411 let supports_repeater = capabilities.repeater;
4412 if configuration.repeater.is_some() != supports_repeater {
4413 return Err(MobileError::InvalidUlcpFrame);
4414 }
4415 if let Some(repeater) = &configuration.repeater {
4416 let regions = encode_region_list(&repeater.regions)?;
4417 if let Some(default_region) = &repeater.default_region {
4418 if default_region.len() != items::REGION_CODE_LEN {
4419 return Err(MobileError::InvalidUlcpFrame);
4420 }
4421 }
4422 values.extend([
4427 (prop::MAC_REPEATER_REGIONS, regions),
4428 (
4429 prop::MAC_REPEATER_DEFAULT_REGION,
4430 repeater.default_region.clone().unwrap_or_default(),
4431 ),
4432 (
4433 prop::MAC_REPEATER_MIN_RSSI,
4434 repeater
4435 .min_rssi_dbm
4436 .map(|rssi| rssi.to_le_bytes().to_vec())
4437 .unwrap_or_default(),
4438 ),
4439 (
4440 prop::MAC_REPEATER_MIN_SNR,
4441 repeater
4442 .min_snr_db
4443 .map(|snr| vec![snr as u8])
4444 .unwrap_or_default(),
4445 ),
4446 (prop::MAC_REPEATER_ENABLED, vec![repeater.enabled as u8]),
4447 ]);
4448 }
4449
4450 values.extend(positioning_values(
4451 configuration.gnss,
4452 configuration.tz_offset_min,
4453 capabilities,
4454 )?);
4455 values.extend(advert_values(configuration.advert, capabilities)?);
4456 Ok(values)
4457}
4458
4459fn advert_values(
4465 advert: Option<UlcpAdvertSettingsRecord>,
4466 capabilities: DeviceCapabilities,
4467) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
4468 let announces = capabilities.advert;
4469 if advert.is_some() != announces {
4470 return Err(MobileError::InvalidUlcpFrame);
4471 }
4472 let Some(advert) = advert else {
4473 return Ok(Vec::new());
4474 };
4475 for interval in [
4479 advert.advert_interval_seconds,
4480 advert.beacon_interval_seconds,
4481 ] {
4482 if interval != 0
4483 && !(MIN_AUTO_ANNOUNCE_INTERVAL_S..=MAX_AUTO_ANNOUNCE_INTERVAL_S).contains(&interval)
4484 {
4485 return Err(MobileError::InvalidUlcpFrame);
4486 }
4487 }
4488 Ok(vec![
4489 (
4490 prop::ADVERT_INTERVAL,
4491 advert.advert_interval_seconds.to_le_bytes().to_vec(),
4492 ),
4493 (
4494 prop::BEACON_INTERVAL,
4495 advert.beacon_interval_seconds.to_le_bytes().to_vec(),
4496 ),
4497 (prop::STARTUP_BEACON, vec![advert.startup_beacon as u8]),
4498 ])
4499}
4500
4501fn positioning_values(
4513 gnss: Option<UlcpGnssSettingsRecord>,
4514 tz_offset_min: Option<i16>,
4515 capabilities: DeviceCapabilities,
4516) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
4517 let mut values = Vec::new();
4518
4519 let keeps_time = capabilities.time;
4520 if tz_offset_min.is_some() != keeps_time {
4521 return Err(MobileError::InvalidUlcpFrame);
4522 }
4523 if let Some(minutes) = tz_offset_min {
4524 if !(-12 * 60..=14 * 60).contains(&minutes) {
4528 return Err(MobileError::InvalidUlcpFrame);
4529 }
4530 values.push((prop::TZ_OFFSET, minutes.to_le_bytes().to_vec()));
4531 }
4532
4533 let positioning = capabilities.gnss;
4534 if gnss.is_some() != positioning {
4535 return Err(MobileError::InvalidUlcpFrame);
4536 }
4537 if let Some(gnss) = gnss {
4538 if !(1..=MAX_PRECISION).contains(&gnss.ident_precision) {
4539 return Err(MobileError::InvalidUlcpFrame);
4540 }
4541 values.extend([
4545 (prop::GNSS_IDENT_UPDATE, vec![gnss.ident_update as u8]),
4546 (prop::GNSS_IDENT_PRECISION, vec![gnss.ident_precision]),
4547 (prop::GNSS_TIME_TRUST, vec![gnss.time_trust as u8]),
4548 (prop::GNSS_ENABLED, vec![gnss.enabled as u8]),
4549 ]);
4550 }
4551 Ok(values)
4552}
4553
4554fn validate_radio_settings(
4555 settings: &UlcpRadioSettingsRecord,
4556 capabilities: DeviceCapabilities,
4557) -> Result<(), MobileError> {
4558 if settings.frequency_khz == 0 {
4559 return Err(MobileError::InvalidUlcpFrame);
4560 }
4561 if let Some(name) = &settings.device_name {
4562 if !capabilities.device_name
4563 || name.is_empty()
4564 || name.len() > 64
4565 || name.as_bytes().contains(&0)
4566 {
4567 return Err(MobileError::InvalidUlcpFrame);
4568 }
4569 }
4570 let lora = (
4571 settings.bandwidth_hz,
4572 settings.spreading_factor,
4573 settings.coding_rate_denom,
4574 );
4575 match lora {
4576 (None, None, None) if !capabilities.lora => {}
4577 (Some(bandwidth), Some(sf), Some(cr))
4578 if capabilities.lora
4579 && bandwidth > 0
4580 && (5..=12).contains(&sf)
4581 && (5..=8).contains(&cr) => {}
4582 _ => return Err(MobileError::InvalidUlcpFrame),
4583 }
4584 if settings.duty_cycle_limit.is_some() != capabilities.duty_cycle_limit {
4585 return Err(MobileError::InvalidUlcpFrame);
4586 }
4587 Ok(())
4588}
4589
4590fn decode_device_name(value: &[u8]) -> Result<Option<String>, MobileError> {
4593 let name = core::str::from_utf8(value).map_err(|_| MobileError::InvalidUlcpFrame)?;
4594 Ok((!name.is_empty()).then(|| name.to_owned()))
4595}
4596
4597fn decode_u32(value: &[u8]) -> Result<u32, MobileError> {
4598 value
4599 .try_into()
4600 .map(u32::from_le_bytes)
4601 .map_err(|_| MobileError::InvalidUlcpFrame)
4602}
4603
4604fn decode_fixed_list<const N: usize>(value: &[u8]) -> Result<Vec<Vec<u8>>, MobileError> {
4608 items::fixed_items::<N>(value)
4609 .map_err(|_| MobileError::InvalidUlcpFrame)?
4610 .map(|item| Ok(item.to_vec()))
4611 .collect()
4612}
4613
4614fn decode_fixed_count<const N: usize>(value: &[u8]) -> Result<u32, MobileError> {
4615 let count = items::fixed_items::<N>(value)
4616 .map_err(|_| MobileError::InvalidUlcpFrame)?
4617 .count();
4618 count.try_into().map_err(|_| MobileError::InvalidUlcpFrame)
4619}
4620
4621fn decode_filter_count(value: &[u8]) -> Result<u32, MobileError> {
4622 let mut count = 0u32;
4623 for item in items::prefixed_items(value) {
4624 let item = item.map_err(|_| MobileError::InvalidUlcpFrame)?;
4625 Filter::decode(item).map_err(|_| MobileError::InvalidUlcpFrame)?;
4626 count = count.checked_add(1).ok_or(MobileError::InvalidUlcpFrame)?;
4627 }
4628 Ok(count)
4629}
4630
4631#[uniffi::export]
4634pub fn ulcp_gatt_segments(
4635 frame: Vec<u8>,
4636 maximum_value_length: u16,
4637) -> Result<Vec<GattSegmentRecord>, MobileError> {
4638 let segment_payload = usize::from(maximum_value_length)
4639 .checked_sub(1)
4640 .filter(|length| *length > 0)
4641 .ok_or(MobileError::GattMtuTooSmall)?;
4642 if frame.len() > MAX_FRAME {
4643 return Err(MobileError::InvalidUlcpFrame);
4644 }
4645
4646 Ok(gatt::segments(&frame, segment_payload)
4647 .map(|segment| {
4648 let mut value = vec![0; segment.payload().len() + 1];
4649 let length = segment
4650 .write_to(&mut value)
4651 .expect("sized from the segment payload");
4652 value.truncate(length);
4653 GattSegmentRecord { value }
4654 })
4655 .collect())
4656}
4657
4658#[uniffi::export]
4664pub fn ulcp_hdlc_encode(frame: Vec<u8>) -> Result<Vec<u8>, MobileError> {
4665 if frame.len() > MAX_FRAME {
4666 return Err(MobileError::InvalidUlcpFrame);
4667 }
4668 let mut wire = vec![0; hdlc::max_encoded_len(frame.len())];
4669 let length =
4670 hdlc::encode_frame(&frame, &mut wire).map_err(|_| MobileError::InvalidUlcpFrame)?;
4671 wire.truncate(length);
4672 Ok(wire)
4673}
4674
4675#[uniffi::export]
4677pub fn ulcp_prop_get(transaction_id: u8, property_id: u32) -> Result<Vec<u8>, MobileError> {
4678 let mut output = [0; 8];
4679 let length = frame::prop_get(&mut output, transaction_id, property_id)
4680 .map_err(|_| MobileError::InvalidUlcpFrame)?;
4681 Ok(output[..length].to_vec())
4682}
4683
4684#[uniffi::export]
4686pub fn ulcp_prop_set(
4687 transaction_id: u8,
4688 property_id: u32,
4689 value: Vec<u8>,
4690) -> Result<Vec<u8>, MobileError> {
4691 if value.len() > MAX_FRAME {
4692 return Err(MobileError::InvalidUlcpFrame);
4693 }
4694 let mut output = vec![0; MAX_FRAME];
4695 let length = frame::prop_set(&mut output, transaction_id, property_id, &value)
4696 .map_err(|_| MobileError::InvalidUlcpFrame)?;
4697 output.truncate(length);
4698 Ok(output)
4699}
4700
4701fn ulcp_prop_insert(
4704 transaction_id: u8,
4705 property_id: u32,
4706 item: &[u8],
4707) -> Result<Vec<u8>, MobileError> {
4708 let mut output = vec![0; MAX_FRAME];
4709 let length = frame::prop_insert(&mut output, transaction_id, property_id, item)
4710 .map_err(|_| MobileError::InvalidUlcpFrame)?;
4711 output.truncate(length);
4712 Ok(output)
4713}
4714
4715fn ulcp_prop_remove(
4718 transaction_id: u8,
4719 property_id: u32,
4720 selector: &[u8],
4721) -> Result<Vec<u8>, MobileError> {
4722 let mut output = vec![0; MAX_FRAME];
4723 let length = frame::prop_remove(&mut output, transaction_id, property_id, selector)
4724 .map_err(|_| MobileError::InvalidUlcpFrame)?;
4725 output.truncate(length);
4726 Ok(output)
4727}
4728
4729#[uniffi::export]
4734pub fn ulcp_max_dev_peers() -> u8 {
4735 8
4736}
4737
4738#[uniffi::export]
4742pub fn ulcp_max_dev_channels() -> u8 {
4743 8
4744}
4745
4746#[uniffi::export]
4751pub fn ulcp_max_dev_admins() -> u8 {
4752 8
4753}
4754
4755#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
4760pub struct RadioPresetRecord {
4761 pub id: String,
4762 pub name: String,
4763 pub frequency_khz: u32,
4764 pub bandwidth_hz: u32,
4765 pub spreading_factor: u8,
4766 pub coding_rate_denom: u8,
4767 pub transmit_power_dbm: Option<i8>,
4770 pub duty_cycle_limit: u16,
4771 pub sync_word: u16,
4772 pub tx_preamble_symbols: u16,
4773}
4774
4775#[uniffi::export]
4778pub fn ulcp_radio_presets() -> Vec<RadioPresetRecord> {
4779 umsh_ulcp::profiles::VETTED
4780 .iter()
4781 .map(|profile| RadioPresetRecord {
4782 id: profile.id.to_string(),
4783 name: profile.name.to_string(),
4784 frequency_khz: profile.freq_khz,
4785 bandwidth_hz: profile.bw_hz,
4786 spreading_factor: profile.sf,
4787 coding_rate_denom: profile.cr_denom,
4788 transmit_power_dbm: profile.tx_power_dbm,
4789 duty_cycle_limit: profile.duty_limit,
4790 sync_word: profile.sync_word,
4791 tx_preamble_symbols: profile.tx_preamble_symbols,
4792 })
4793 .collect()
4794}
4795
4796#[uniffi::export]
4798pub fn ulcp_supported_bandwidths_hz() -> Vec<u32> {
4799 umsh_ulcp::profiles::SUPPORTED_BANDWIDTHS_HZ.to_vec()
4800}
4801
4802fn dev_channel_id(channel_key: &[u8]) -> Result<Vec<u8>, MobileError> {
4804 let bytes: [u8; items::CHANNEL_KEY_LEN] = channel_key
4805 .try_into()
4806 .map_err(|_| MobileError::InvalidChannelKeyLength)?;
4807 Ok(crate::derive_channel_id(bytes.to_vec())?)
4808}
4809
4810#[uniffi::export]
4812pub fn ulcp_save(transaction_id: u8) -> Result<Vec<u8>, MobileError> {
4813 let mut output = [0; 2];
4814 let length =
4815 frame::save(&mut output, transaction_id).map_err(|_| MobileError::InvalidUlcpFrame)?;
4816 Ok(output[..length].to_vec())
4817}
4818
4819#[uniffi::export]
4821pub fn ulcp_factory_reset(transaction_id: u8) -> Result<Vec<u8>, MobileError> {
4822 let mut output = [0; 2];
4823 let length = frame::factory_reset(&mut output, transaction_id)
4824 .map_err(|_| MobileError::InvalidUlcpFrame)?;
4825 Ok(output[..length].to_vec())
4826}
4827
4828#[uniffi::export]
4830pub fn ulcp_reboot(transaction_id: u8) -> Result<Vec<u8>, MobileError> {
4831 let mut output = [0; 2];
4832 let length =
4833 frame::reboot(&mut output, transaction_id).map_err(|_| MobileError::InvalidUlcpFrame)?;
4834 Ok(output[..length].to_vec())
4835}
4836
4837#[uniffi::export]
4839pub fn ulcp_ble_clear_bonds(transaction_id: u8) -> Result<Vec<u8>, MobileError> {
4840 let mut output = [0; 2];
4841 let length = frame::ble_clear_bonds(&mut output, transaction_id)
4842 .map_err(|_| MobileError::InvalidUlcpFrame)?;
4843 Ok(output[..length].to_vec())
4844}
4845
4846#[uniffi::export]
4854pub fn ulcp_status_name(status: u32) -> String {
4855 format!("{:?}", umsh_ulcp::Status(status))
4856}
4857
4858#[uniffi::export]
4860pub fn inspect_ulcp_status(value: Vec<u8>) -> Result<u32, MobileError> {
4861 decode_exact_pui(&value)
4862}
4863
4864fn ulcp_operation_error(
4865 operation: String,
4866 value: &[u8],
4867) -> Result<UlcpOperationErrorRecord, MobileError> {
4868 let status_code = inspect_ulcp_status(value.to_vec())?;
4869 let status = umsh_ulcp::Status(status_code);
4870 if status == umsh_ulcp::Status::OK {
4871 return Err(MobileError::InvalidUlcpFrame);
4875 }
4876 Ok(UlcpOperationErrorRecord {
4877 operation,
4878 status_code,
4879 status_name: format!("{status:?}"),
4880 })
4881}
4882
4883#[uniffi::export]
4890pub fn describe_ulcp_frame(bytes: Vec<u8>) -> String {
4891 let Ok(parsed) = Frame::parse(&bytes) else {
4892 return format!("unparsable len={}", bytes.len());
4893 };
4894 let command = match parsed.command() {
4895 Some(cmd) => format!("{cmd:?}({})", parsed.cmd),
4896 None => format!("unknown({})", parsed.cmd),
4897 };
4898 let property = match PropertyNotification::parse(&bytes) {
4899 Ok(notification) => format!(
4900 " prop=0x{:04x} value={}B",
4901 notification.key,
4902 notification.value.len()
4903 ),
4904 Err(_) => String::new(),
4905 };
4906 format!(
4907 "tid={} cmd={command}{property} len={}",
4908 parsed.header.tid(),
4909 bytes.len()
4910 )
4911}
4912
4913#[uniffi::export]
4915pub fn inspect_ulcp_property_frame(bytes: Vec<u8>) -> Result<UlcpPropertyFrameRecord, MobileError> {
4916 let parsed = PropertyNotification::parse(&bytes).map_err(|cause| match cause {
4917 PropertyNotificationError::MalformedFrame => MobileError::UlcpFrameUnparsable,
4918 PropertyNotificationError::UnexpectedCommand => MobileError::UlcpUnexpectedCommand,
4919 PropertyNotificationError::MalformedPayload => MobileError::UlcpMalformedPayload,
4920 })?;
4921 Ok(UlcpPropertyFrameRecord {
4922 transaction_id: parsed.tid,
4923 command: parsed.kind.command() as u8,
4924 property_id: parsed.key,
4925 value: parsed.value.to_vec(),
4926 })
4927}
4928
4929#[uniffi::export]
4931pub fn inspect_ulcp_battery(value: Vec<u8>) -> Result<UlcpBatteryRecord, MobileError> {
4932 let battery = BatteryStatus::decode(&value).map_err(|_| MobileError::InvalidUlcpFrame)?;
4933 Ok(UlcpBatteryRecord {
4934 percentage: battery.level_percent,
4935 voltage_mv: battery.voltage_mv,
4936 charge_state: battery.charge_state.map(UlcpChargeState::from_wire),
4937 })
4938}
4939
4940pub(crate) fn encode_alert_state(state: UlcpAlertState) -> Result<Vec<u8>, MobileError> {
4945 let mut value = [0u8; pui::MAX_LEN];
4946 let len = pui::encode(state.to_wire().code(), &mut value)
4947 .map_err(|_| MobileError::InvalidUlcpFrame)?;
4948 Ok(value[..len].to_vec())
4949}
4950
4951#[uniffi::export]
4953pub fn inspect_ulcp_alert(value: Vec<u8>) -> Result<UlcpAlertState, MobileError> {
4954 let (code, consumed) = pui::decode(&value).map_err(|_| MobileError::InvalidUlcpFrame)?;
4955 if consumed != value.len() {
4956 return Err(MobileError::InvalidUlcpFrame);
4957 }
4958 AlertState::from_code(code)
4959 .map(UlcpAlertState::from_wire)
4960 .ok_or(MobileError::InvalidUlcpFrame)
4961}
4962
4963#[uniffi::export]
4972pub fn region_code_from_string(text: String) -> Result<Vec<u8>, MobileError> {
4973 text.parse::<RegionCode>()
4974 .map(|code| code.to_bytes().to_vec())
4975 .map_err(|_| MobileError::InvalidRegionCode)
4976}
4977
4978#[uniffi::export]
4982pub fn region_code_description(code: Vec<u8>) -> Result<String, MobileError> {
4983 let bytes: [u8; items::REGION_CODE_LEN] = code
4984 .try_into()
4985 .map_err(|_| MobileError::InvalidRegionCode)?;
4986 Ok(RegionCode::from_bytes(bytes).to_string())
4987}
4988
4989#[derive(uniffi::Object)]
4991pub struct MobileGattReassembler {
4992 inner: Mutex<Reassembler<MAX_FRAME>>,
4993}
4994
4995#[uniffi::export]
4996impl MobileGattReassembler {
4997 #[uniffi::constructor]
4998 pub fn new() -> Arc<Self> {
4999 Arc::new(Self {
5000 inner: Mutex::new(Reassembler::new()),
5001 })
5002 }
5003
5004 pub fn push(&self, segment: Vec<u8>) -> Result<Option<Vec<u8>>, MobileError> {
5007 let mut reassembler = self.inner.lock().expect("GATT reassembler mutex poisoned");
5008 match reassembler.push(&segment) {
5009 None => Ok(None),
5010 Some(Ok(frame)) => Ok(Some(frame.to_vec())),
5011 Some(Err(cause)) => Err(cause.into()),
5012 }
5013 }
5014
5015 pub fn reset(&self) {
5016 self.inner
5017 .lock()
5018 .expect("GATT reassembler mutex poisoned")
5019 .reset();
5020 }
5021}
5022
5023#[derive(uniffi::Object)]
5029pub struct MobileHdlcDecoder {
5030 inner: Mutex<hdlc::Decoder<{ MAX_FRAME + 2 }>>,
5031}
5032
5033#[uniffi::export]
5034impl MobileHdlcDecoder {
5035 #[uniffi::constructor]
5036 pub fn new() -> Arc<Self> {
5037 Arc::new(Self {
5038 inner: Mutex::new(hdlc::Decoder::new()),
5039 })
5040 }
5041
5042 pub fn push(&self, bytes: Vec<u8>) -> Vec<Vec<u8>> {
5053 let mut decoder = self.inner.lock().expect("HDLC decoder mutex poisoned");
5054 let mut frames = Vec::new();
5055 for byte in bytes {
5056 if let Some(Ok(frame)) = decoder.push(byte) {
5057 frames.push(frame.to_vec());
5058 }
5059 }
5060 frames
5061 }
5062
5063 pub fn reset(&self) {
5067 self.inner
5068 .lock()
5069 .expect("HDLC decoder mutex poisoned")
5070 .reset();
5071 }
5072}
5073
5074#[cfg(test)]
5075mod tests {
5076 use super::*;
5077 use umsh_ulcp::PropPayload;
5078
5079 fn response(property_id: u32, value: &[u8]) -> UlcpPropertyFrameRecord {
5080 UlcpPropertyFrameRecord {
5081 transaction_id: 1,
5082 command: Cmd::PropIs as u8,
5083 property_id,
5084 value: value.to_vec(),
5085 }
5086 }
5087
5088 fn encoded_capabilities(values: &[u32]) -> Vec<u8> {
5089 let mut encoded = Vec::new();
5090 for value in values {
5091 let mut bytes = [0; pui::MAX_LEN];
5092 let len = pui::encode(*value, &mut bytes).unwrap();
5093 encoded.extend_from_slice(&bytes[..len]);
5094 }
5095 encoded
5096 }
5097
5098 fn property_request(bytes: &[u8]) -> (u8, u32) {
5099 let parsed = Frame::parse(bytes).unwrap();
5100 assert_eq!(parsed.command(), Some(Cmd::PropGet));
5101 let (property, used) = pui::decode(parsed.payload).unwrap();
5102 assert_eq!(used, parsed.payload.len());
5103 (parsed.header.tid(), property)
5104 }
5105
5106 fn property_response(tid: u8, property: u32, value: &[u8]) -> Vec<u8> {
5107 let mut bytes = vec![0; MAX_FRAME];
5108 let length = frame::prop_is(&mut bytes, tid, property, value).unwrap();
5109 bytes.truncate(length);
5110 bytes
5111 }
5112
5113 fn answer_requests(
5114 session: &MobileUlcpSession,
5115 requests: Vec<Vec<u8>>,
5116 value: impl Fn(u32) -> (u32, Vec<u8>),
5117 ) -> UlcpSessionUpdateRecord {
5118 let mut last = None;
5119 for request in requests {
5120 let (tid, requested) = property_request(&request);
5121 let (returned, bytes) = value(requested);
5122 last = Some(
5123 session
5124 .consume(property_response(tid, returned, &bytes))
5125 .unwrap(),
5126 );
5127 }
5128 last.unwrap()
5129 }
5130
5131 fn commissionable_capabilities() -> Vec<u32> {
5135 vec![
5136 cap::HOST_FILTER,
5137 cap::SAVE,
5138 cap::DEV_NAME,
5139 cap::DEV_IDENTITY,
5140 cap::REPEATER,
5141 cap::IDENT,
5142 cap::ADMIN,
5143 ]
5144 }
5145
5146 fn commissionable_value(property: u32) -> (u32, Vec<u8>) {
5149 let value = match property {
5150 prop::LAST_STATUS => vec![0],
5151 prop::PROTOCOL_VERSION => vec![6, 0],
5152 prop::CAPS => encoded_capabilities(&commissionable_capabilities()),
5153 prop::DEV_NAME => b"Ridge repeater".to_vec(),
5154 prop::DEV_KEY => vec![0x5A; 32],
5155 prop::BATTERY => Vec::new(),
5156 prop::INTERFACE_TYPE => vec![INTERFACE_TYPE as u8],
5157 prop::PHY_ENABLED => vec![1],
5158 prop::PHY_FREQ => 915_000u32.to_le_bytes().to_vec(),
5159 prop::PHY_TX_POWER => vec![14],
5160 prop::SAVED => vec![saved::CURRENT],
5161 prop::HOST_RX_FILTERS => Vec::new(),
5162 prop::MAC_REPEATER_ENABLED => vec![0],
5163 prop::MAC_REPEATER_REGIONS
5164 | prop::MAC_REPEATER_DEFAULT_REGION
5165 | prop::MAC_REPEATER_MIN_RSSI
5166 | prop::MAC_REPEATER_MIN_SNR
5167 | prop::IDENT_ROLE
5168 | prop::IDENT_LOCATION
5170 | prop::IDENT_ALTITUDE
5171 | prop::DEV_PEERS
5172 | prop::DEV_ADMINS
5173 | prop::DEV_CHANNEL_KEYS => Vec::new(),
5174 prop::IDENT_MOBILE => vec![0],
5175 prop::DEV_DISCOVERABLE => vec![1],
5176 other => unreachable!("unexpected property {other}"),
5177 };
5178 (property, value)
5179 }
5180
5181 fn drive_reads(
5183 session: &MobileUlcpSession,
5184 requests: Vec<Vec<u8>>,
5185 value: impl Fn(u32) -> (u32, Vec<u8>),
5186 ) -> UlcpSessionUpdateRecord {
5187 let mut pending = requests;
5188 let mut last = None;
5189 while !pending.is_empty() {
5190 let update = answer_requests(session, pending, &value);
5191 pending = update.outbound_frames.clone();
5192 last = Some(update);
5193 }
5194 last.expect("at least one batch")
5195 }
5196
5197 fn attach_commissionable(
5200 session: &MobileUlcpSession,
5201 selected_host_key: Option<Vec<u8>>,
5202 host_key: Vec<u8>,
5203 ) -> UlcpSessionUpdateRecord {
5204 let begin = session.begin(selected_host_key).unwrap();
5205 drive_reads(session, begin.outbound_frames, move |property| {
5206 if property == prop::HOST_KEY {
5207 (property, host_key.clone())
5208 } else {
5209 commissionable_value(property)
5210 }
5211 })
5212 }
5213
5214 fn attach_host_keys_capable(session: &MobileUlcpSession) -> UlcpSessionUpdateRecord {
5218 let mut capabilities = commissionable_capabilities();
5219 capabilities.push(cap::HOST_KEYS);
5220 let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
5221 drive_reads(
5222 session,
5223 begin.outbound_frames,
5224 move |property| match property {
5225 prop::CAPS => (property, encoded_capabilities(&capabilities)),
5226 prop::HOST_KEY => (property, vec![0xAA; 32]),
5227 prop::HOST_CHANNEL_KEYS | prop::HOST_PEER_KEYS => (property, Vec::new()),
5228 _ => commissionable_value(property),
5229 },
5230 )
5231 }
5232
5233 fn drive_configuration(
5237 session: &MobileUlcpSession,
5238 first_batch: Vec<Vec<u8>>,
5239 ) -> (HashMap<u32, Vec<u8>>, Vec<u32>, u8) {
5240 let mut pending = VecDeque::from(first_batch);
5241 let mut written = HashMap::new();
5242 let mut order = Vec::new();
5243 loop {
5244 let request = pending.pop_front().expect("configuration ends in a save");
5245 let parsed = Frame::parse(&request).unwrap();
5246 if parsed.command() == Some(Cmd::Save) {
5247 return (written, order, parsed.header.tid());
5248 }
5249 assert_eq!(parsed.command(), Some(Cmd::PropSet));
5250 let payload = PropPayload::parse(parsed.payload).unwrap();
5251 order.push(payload.key);
5252 written.insert(payload.key, payload.value.to_vec());
5253 let update = session
5254 .consume(property_response(
5255 parsed.header.tid(),
5256 payload.key,
5257 payload.value,
5258 ))
5259 .unwrap_or_else(|error| {
5260 panic!("write of property {} failed: {error:?}", payload.key)
5261 });
5262 pending.extend(update.outbound_frames);
5263 }
5264 }
5265
5266 #[test]
5267 fn exported_gatt_round_trip_uses_shared_codec() {
5268 let frame = ulcp_prop_get(3, 4_864).unwrap();
5269 let segments = ulcp_gatt_segments(frame.clone(), 4).unwrap();
5270 let receiver = MobileGattReassembler::new();
5271 let mut completed = None;
5272 for segment in segments {
5273 if let Some(value) = receiver.push(segment.value).unwrap() {
5274 completed = Some(value);
5275 }
5276 }
5277 assert_eq!(completed, Some(frame));
5278 }
5279
5280 #[test]
5281 fn exported_hdlc_round_trip_uses_shared_codec() {
5282 let frame = ulcp_prop_get(3, 4_864).unwrap();
5283 let wire = ulcp_hdlc_encode(frame.clone()).unwrap();
5284
5285 let mut expected = vec![0; hdlc::max_encoded_len(frame.len())];
5288 let length = hdlc::encode_frame(&frame, &mut expected).unwrap();
5289 expected.truncate(length);
5290 assert_eq!(wire, expected);
5291
5292 let decoder = MobileHdlcDecoder::new();
5293 assert_eq!(decoder.push(wire), vec![frame]);
5294 }
5295
5296 #[test]
5297 fn hdlc_decoding_spans_chunk_boundaries_and_batches() {
5298 let first = ulcp_prop_get(1, 4_864).unwrap();
5301 let second = ulcp_prop_get(2, 4_865).unwrap();
5302 let mut wire = ulcp_hdlc_encode(first.clone()).unwrap();
5303 wire.extend(ulcp_hdlc_encode(second.clone()).unwrap());
5304
5305 let decoder = MobileHdlcDecoder::new();
5306 let split = wire.len() / 3;
5307 assert!(decoder.push(wire[..split].to_vec()).is_empty());
5308 assert_eq!(decoder.push(wire[split..].to_vec()), vec![first, second]);
5309 }
5310
5311 #[test]
5312 fn hdlc_decoding_escapes_the_framing_bytes() {
5313 let frame = vec![hdlc::FLAG, hdlc::ESCAPE, 0x11, 0x13, 0x00, 0xFF];
5315 let decoder = MobileHdlcDecoder::new();
5316 assert_eq!(
5317 decoder.push(ulcp_hdlc_encode(frame.clone()).unwrap()),
5318 vec![frame]
5319 );
5320 }
5321
5322 #[test]
5323 fn hdlc_decoding_resynchronizes_past_noise() {
5324 let frame = ulcp_prop_get(7, 4_864).unwrap();
5327 let mut wire = vec![0x01, 0x02, hdlc::FLAG, 0xDE, 0xAD];
5328 wire.extend(ulcp_hdlc_encode(frame.clone()).unwrap());
5329
5330 let decoder = MobileHdlcDecoder::new();
5331 assert_eq!(decoder.push(wire), vec![frame]);
5332 }
5333
5334 #[test]
5335 fn hdlc_encoding_admits_exactly_what_gatt_does() {
5336 let largest = vec![0xA5; MAX_FRAME];
5339 let decoder = MobileHdlcDecoder::new();
5340 assert_eq!(
5341 decoder.push(ulcp_hdlc_encode(largest.clone()).unwrap()),
5342 vec![largest]
5343 );
5344 assert!(matches!(
5345 ulcp_hdlc_encode(vec![0xA5; MAX_FRAME + 1]),
5346 Err(MobileError::InvalidUlcpFrame)
5347 ));
5348 }
5349
5350 #[test]
5351 fn a_reset_decoder_drops_the_partial_frame() {
5352 let frame = ulcp_prop_get(5, 4_864).unwrap();
5353 let wire = ulcp_hdlc_encode(frame.clone()).unwrap();
5354
5355 let decoder = MobileHdlcDecoder::new();
5356 assert!(decoder.push(wire[..wire.len() - 2].to_vec()).is_empty());
5357 decoder.reset();
5359 assert!(decoder.push(wire[wire.len() - 2..].to_vec()).is_empty());
5360 assert_eq!(decoder.push(wire), vec![frame]);
5361 }
5362
5363 #[test]
5364 fn property_response_is_validated_and_typed() {
5365 let mut bytes = [0; 16];
5366 let length = frame::prop_is(&mut bytes, 5, 64, &[1, 2, 3]).unwrap();
5367 assert_eq!(
5368 inspect_ulcp_property_frame(bytes[..length].to_vec()).unwrap(),
5369 UlcpPropertyFrameRecord {
5370 transaction_id: 5,
5371 command: Cmd::PropIs as u8,
5372 property_id: 64,
5373 value: vec![1, 2, 3],
5374 }
5375 );
5376 }
5377
5378 #[test]
5379 fn property_set_uses_shared_frame_codec() {
5380 let encoded = ulcp_prop_set(6, 96, vec![7; 32]).unwrap();
5381 let parsed = Frame::parse(&encoded).unwrap();
5382 assert_eq!(parsed.header.tid(), 6);
5383 assert_eq!(parsed.command(), Some(Cmd::PropSet));
5384 let payload = PropPayload::parse(parsed.payload).unwrap();
5385 assert_eq!(payload.key, 96);
5386 assert_eq!(payload.value, &[7; 32]);
5387 }
5388
5389 #[test]
5390 fn save_and_status_use_shared_frame_codec() {
5391 let encoded = ulcp_save(7).unwrap();
5392 let parsed = Frame::parse(&encoded).unwrap();
5393 assert_eq!(parsed.header.tid(), 7);
5394 assert_eq!(parsed.command(), Some(Cmd::Save));
5395 assert!(parsed.payload.is_empty());
5396
5397 assert_eq!(inspect_ulcp_status(vec![0]).unwrap(), 0);
5398 assert_eq!(
5399 inspect_ulcp_status(vec![0x80]),
5400 Err(MobileError::InvalidUlcpFrame)
5401 );
5402 }
5403
5404 #[test]
5405 fn exported_transport_rejects_invalid_bounds_and_segments() {
5406 assert_eq!(
5407 ulcp_gatt_segments(vec![0; MAX_FRAME + 1], 20),
5408 Err(MobileError::InvalidUlcpFrame)
5409 );
5410 assert_eq!(
5411 ulcp_gatt_segments(vec![1], 1),
5412 Err(MobileError::GattMtuTooSmall)
5413 );
5414 assert_eq!(
5415 MobileGattReassembler::new().push(vec![]),
5416 Err(MobileError::GattSegmentRunt)
5417 );
5418 let receiver = MobileGattReassembler::new();
5421 assert_eq!(
5422 receiver.push(vec![0xC0]),
5423 Err(MobileError::GattSegmentOrphan)
5424 );
5425 assert_eq!(
5426 receiver.push(vec![0x08]),
5427 Err(MobileError::GattSegmentReservedBits)
5428 );
5429 let mut oversized = vec![0u8; MAX_FRAME + 2];
5430 oversized[0] = gatt::SAR_FIRST << 6;
5431 assert_eq!(
5432 receiver.push(oversized),
5433 Err(MobileError::GattSegmentTooLong)
5434 );
5435 }
5436
5437 #[test]
5438 fn battery_reduction_preserves_supported_ui_fields() {
5439 assert_eq!(
5440 inspect_ulcp_battery(vec![0b110, 82, 1]).unwrap(),
5441 UlcpBatteryRecord {
5442 percentage: Some(82),
5443 voltage_mv: None,
5444 charge_state: Some(UlcpChargeState::Charging),
5445 }
5446 );
5447 assert_eq!(
5448 inspect_ulcp_battery(vec![0b111, 0xEC, 0x0E, 82, 0]).unwrap(),
5449 UlcpBatteryRecord {
5450 percentage: Some(82),
5451 voltage_mv: Some(3820),
5452 charge_state: Some(UlcpChargeState::Discharging),
5453 }
5454 );
5455 assert_eq!(
5456 inspect_ulcp_battery(vec![]).unwrap(),
5457 UlcpBatteryRecord {
5458 percentage: None,
5459 voltage_mv: None,
5460 charge_state: None,
5461 }
5462 );
5463 }
5464
5465 #[test]
5466 fn a_reading_is_carried_when_reported_and_missed_quietly_when_not() {
5467 let capabilities = encoded_capabilities(&[cap::BATTERY, cap::ALERT]);
5468 let base = |extra: Vec<UlcpPropertyFrameRecord>| {
5469 let mut responses = vec![
5470 response(prop::CAPS, &capabilities),
5471 response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
5472 response(prop::PHY_ENABLED, &[1]),
5473 response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
5474 response(prop::PHY_TX_POWER, &[14]),
5475 ];
5476 responses.extend(extra);
5477 inspect_ulcp_sync(responses).unwrap()
5478 };
5479
5480 let reported = base(vec![
5481 response(prop::BATTERY, &[0b110, 82, 1]),
5482 response(prop::ALERT, &[AlertState::Locate.code() as u8]),
5483 ]);
5484 assert_eq!(reported.battery.unwrap().percentage, Some(82));
5485 assert_eq!(reported.alert, Some(UlcpAlertState::Locate));
5486
5487 let silent = base(Vec::new());
5492 assert!(silent.supports_battery && silent.supports_alert);
5493 assert_eq!(silent.battery, None);
5494 assert_eq!(silent.alert, None);
5495 assert!(silent.unreadable_properties.is_empty());
5496 }
5497
5498 #[test]
5499 fn minimal_inspection_is_small_and_validated() {
5500 assert_eq!(
5501 ulcp_inspection_properties(vec![cap::WRITABLE_RAW_STREAM as u8]).unwrap(),
5502 [
5503 prop::INTERFACE_TYPE,
5504 prop::PHY_ENABLED,
5505 prop::PHY_FREQ,
5506 prop::PHY_TX_POWER,
5507 ]
5508 );
5509 let sync = inspect_ulcp_sync(vec![
5510 response(prop::CAPS, &[cap::WRITABLE_RAW_STREAM as u8]),
5511 response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
5512 response(prop::PHY_ENABLED, &[1]),
5513 response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
5514 response(prop::PHY_TX_POWER, &[14]),
5515 ])
5516 .unwrap();
5517 assert!(sync.phy_enabled);
5518 assert_eq!(sync.frequency_khz, 915_000);
5519 assert_eq!(sync.transmit_power_dbm, 14);
5520 assert!(!sync.has_host_filtering);
5521 assert_eq!(sync.queued_frames, None);
5522 }
5523
5524 #[test]
5525 fn full_inspection_reports_only_digest_counts() {
5526 let capabilities = (cap::HOST_FILTER..=cap::BATTERY)
5527 .map(|capability| capability as u8)
5528 .collect::<Vec<_>>();
5529 let properties = ulcp_inspection_properties(capabilities.clone()).unwrap();
5530 assert!(properties.contains(&prop::HOST_RX_FILTERS));
5531 assert!(properties.contains(&prop::HOST_RX_QUEUE_COUNT));
5532 assert!(properties.contains(&prop::HOST_AUTO_ACK));
5533
5534 let sync = inspect_ulcp_sync(vec![
5535 response(prop::CAPS, &capabilities),
5536 response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
5537 response(prop::PHY_ENABLED, &[1]),
5538 response(prop::PHY_FREQ, &868_100u32.to_le_bytes()),
5539 response(prop::PHY_TX_POWER, &[22]),
5540 response(prop::SAVED, &[1]),
5541 response(prop::HOST_RX_FILTERS, &[]),
5542 response(prop::HOST_CHANNEL_KEYS, &[1, 2, 3, 4]),
5543 response(prop::HOST_PEER_KEYS, &[7; 32]),
5544 response(prop::HOST_RX_QUEUE_COUNT, &3u16.to_le_bytes()),
5545 response(prop::HOST_RX_QUEUE_DROPPED, &4u32.to_le_bytes()),
5546 response(prop::HOST_AUTO_ACK, &[1]),
5547 response(prop::DEV_PEERS, &[7; 64]),
5548 response(prop::DEV_DISCOVERABLE, &[1]),
5549 ])
5550 .unwrap();
5551 assert_eq!(sync.saved, Some(SavedSnapshotRecord::Current));
5552 assert_eq!(sync.queued_frames, Some(3));
5553 assert_eq!(sync.dropped_frames, Some(4));
5554 assert_eq!(sync.filter_count, Some(0));
5555 assert_eq!(sync.host_channel_count, Some(2));
5556 assert_eq!(sync.host_peer_count, Some(1));
5557 assert_eq!(sync.auto_ack, Some(true));
5558 assert!(sync.supports_device_identity);
5561 assert_eq!(sync.dev_peer_keys, Some(vec![vec![7; 32], vec![7; 32]]));
5562 }
5563
5564 #[test]
5565 fn invalid_capability_dependencies_and_values_fail_closed() {
5566 assert_eq!(
5567 ulcp_inspection_properties(vec![cap::HOST_RX_QUEUE as u8]),
5568 Err(MobileError::InvalidUlcpFrame)
5569 );
5570 assert_eq!(
5571 inspect_ulcp_sync(vec![
5572 response(prop::CAPS, &[]),
5573 response(prop::INTERFACE_TYPE, &[7]),
5574 response(prop::PHY_ENABLED, &[1]),
5575 response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
5576 ]),
5577 Err(MobileError::InvalidUlcpFrame)
5578 );
5579 }
5580
5581 #[test]
5582 fn mobile_session_owns_sync_tids_and_attaches_transparent_radio() {
5583 let session = MobileUlcpSession::new();
5584 let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
5585 assert_eq!(begin.snapshot.phase, UlcpSessionPhase::Synchronizing);
5586 assert_eq!(begin.outbound_frames.len(), 7);
5587 assert_eq!(
5588 begin
5589 .outbound_frames
5590 .iter()
5591 .map(|request| property_request(request).0)
5592 .collect::<Vec<_>>(),
5593 [1, 2, 3, 4, 5, 6, 7]
5594 );
5595
5596 let inspection =
5597 answer_requests(&session, begin.outbound_frames, |property| match property {
5598 prop::LAST_STATUS => (property, vec![0]),
5599 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
5600 prop::CAPS => (property, vec![cap::WRITABLE_RAW_STREAM as u8]),
5601 prop::DEV_KEY => (property, Vec::new()),
5602 prop::DEV_NAME => (property, b"Transparent".to_vec()),
5603 prop::BATTERY => (property, Vec::new()),
5604 prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
5605 _ => unreachable!(),
5606 });
5607 assert_eq!(inspection.outbound_frames.len(), 4);
5608 assert_eq!(
5609 inspection.snapshot.host_ownership,
5610 UlcpHostOwnership::Unsupported
5611 );
5612
5613 let attached =
5614 answer_requests(
5615 &session,
5616 inspection.outbound_frames,
5617 |property| match property {
5618 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
5619 prop::PHY_ENABLED => (property, vec![1]),
5620 prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
5621 prop::PHY_TX_POWER => (property, vec![14]),
5622 _ => unreachable!(),
5623 },
5624 );
5625 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5626 assert_eq!(
5627 attached.snapshot.device_name.as_deref(),
5628 Some("Transparent")
5629 );
5630 assert_eq!(
5631 attached.snapshot.provisioning.unwrap().frequency_khz,
5632 915_000
5633 );
5634 }
5635
5636 #[test]
5637 fn mobile_session_owns_claim_then_save_choreography() {
5638 let host_key = vec![0xAA; 32];
5639 let session = MobileUlcpSession::new();
5640 let begin = session.begin(Some(host_key.clone())).unwrap();
5641 let awaiting =
5642 answer_requests(&session, begin.outbound_frames, |property| match property {
5643 prop::LAST_STATUS => (property, vec![0]),
5644 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
5645 prop::CAPS => (property, vec![cap::HOST_FILTER as u8, cap::SAVE as u8]),
5646 prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY | prop::HOST_KEY => {
5647 (property, Vec::new())
5648 }
5649 _ => unreachable!(),
5650 });
5651 assert_eq!(awaiting.snapshot.phase, UlcpSessionPhase::AwaitingHost);
5652 assert_eq!(
5653 awaiting.snapshot.host_ownership,
5654 UlcpHostOwnership::Unclaimed
5655 );
5656
5657 let claim = session.claim(host_key.clone()).unwrap();
5658 assert_eq!(claim.snapshot.phase, UlcpSessionPhase::Claiming);
5659 assert_eq!(claim.outbound_frames.len(), 1);
5660 let parsed_claim = Frame::parse(&claim.outbound_frames[0]).unwrap();
5661 assert_eq!(parsed_claim.command(), Some(Cmd::PropSet));
5662 let payload = PropPayload::parse(parsed_claim.payload).unwrap();
5663 assert_eq!(payload.key, prop::HOST_KEY);
5664 assert_eq!(payload.value, host_key);
5665
5666 let save = session
5667 .consume(property_response(
5668 parsed_claim.header.tid(),
5669 prop::HOST_KEY,
5670 &host_key,
5671 ))
5672 .unwrap();
5673 assert_eq!(save.outbound_frames.len(), 1);
5674 let parsed_save = Frame::parse(&save.outbound_frames[0]).unwrap();
5675 assert_eq!(parsed_save.command(), Some(Cmd::Save));
5676
5677 let inspection = session
5678 .consume(property_response(
5679 parsed_save.header.tid(),
5680 prop::LAST_STATUS,
5681 &[0],
5682 ))
5683 .unwrap();
5684 assert_eq!(inspection.outbound_frames.len(), 6);
5685 let attached =
5686 answer_requests(
5687 &session,
5688 inspection.outbound_frames,
5689 |property| match property {
5690 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
5691 prop::PHY_ENABLED => (property, vec![1]),
5692 prop::PHY_FREQ => (property, 868_100u32.to_le_bytes().to_vec()),
5693 prop::PHY_TX_POWER => (property, vec![14]),
5694 prop::SAVED => (property, vec![1]),
5695 prop::HOST_RX_FILTERS => (property, Vec::new()),
5696 _ => unreachable!(),
5697 },
5698 );
5699 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5700 assert_eq!(attached.snapshot.host_ownership, UlcpHostOwnership::Ours);
5701 assert_eq!(
5702 attached.snapshot.provisioning.unwrap().saved,
5703 Some(SavedSnapshotRecord::Current)
5704 );
5705
5706 let changed_host = session
5707 .consume(property_response(
5708 frame::TID_UNSOLICITED,
5709 prop::HOST_KEY,
5710 &[0xBB; 32],
5711 ))
5712 .unwrap();
5713 assert_eq!(changed_host.snapshot.phase, UlcpSessionPhase::AwaitingHost);
5714 assert_eq!(
5715 changed_host.snapshot.host_ownership,
5716 UlcpHostOwnership::OtherHost
5717 );
5718 }
5719
5720 #[test]
5721 fn administrative_session_attaches_without_claiming_anyones_radio() {
5722 let phone = vec![0xAA; 32];
5723 let other_phone = vec![0xBB; 32];
5724
5725 let session = MobileUlcpSession::administrative();
5729 let attached = attach_commissionable(&session, Some(phone.clone()), other_phone.clone());
5730 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5731 assert_eq!(
5732 attached.snapshot.host_ownership,
5733 UlcpHostOwnership::OtherHost
5734 );
5735 assert_eq!(
5736 session.claim(phone.clone()),
5737 Err(MobileError::AdministrativeSession)
5738 );
5739
5740 let unclaimed = MobileUlcpSession::administrative();
5743 let attached = attach_commissionable(&unclaimed, Some(phone.clone()), Vec::new());
5744 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5745 assert_eq!(
5746 attached.snapshot.host_ownership,
5747 UlcpHostOwnership::Unclaimed
5748 );
5749
5750 let tethered = MobileUlcpSession::new();
5753 let begin = tethered.begin(Some(phone.clone())).unwrap();
5754 let awaiting = answer_requests(&tethered, begin.outbound_frames, move |property| {
5755 if property == prop::HOST_KEY {
5756 (property, other_phone.clone())
5757 } else {
5758 commissionable_value(property)
5759 }
5760 });
5761 assert_eq!(awaiting.snapshot.phase, UlcpSessionPhase::AwaitingHost);
5762
5763 let pushed = session
5766 .consume(property_response(
5767 frame::TID_UNSOLICITED,
5768 prop::HOST_KEY,
5769 &[0xCC; 32],
5770 ))
5771 .unwrap();
5772 assert_eq!(pushed.snapshot.phase, UlcpSessionPhase::Attached);
5773 assert_eq!(pushed.snapshot.host_ownership, UlcpHostOwnership::OtherHost);
5774 }
5775
5776 #[test]
5777 fn attached_snapshot_reports_the_devices_own_domain() {
5778 let session = MobileUlcpSession::administrative();
5779 let attached = attach_commissionable(&session, None, Vec::new());
5780 let provisioning = attached.snapshot.provisioning.unwrap();
5781 assert!(provisioning.supports_repeater);
5782 assert!(provisioning.supports_ident);
5783 assert_eq!(provisioning.ident_role, None);
5784 assert_eq!(provisioning.ident_mobile, Some(false));
5785 assert_eq!(
5786 provisioning.repeater,
5787 Some(UlcpRepeaterSettingsRecord {
5788 enabled: false,
5789 regions: Vec::new(),
5790 default_region: None,
5791 min_rssi_dbm: None,
5792 min_snr_db: None,
5793 })
5794 );
5795
5796 let plain = inspect_ulcp_sync(vec![
5799 response(prop::CAPS, &[cap::WRITABLE_RAW_STREAM as u8]),
5800 response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
5801 response(prop::PHY_ENABLED, &[1]),
5802 response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
5803 response(prop::PHY_TX_POWER, &[14]),
5804 ])
5805 .unwrap();
5806 assert!(!plain.supports_repeater);
5807 assert!(!plain.supports_ident);
5808 assert_eq!(plain.repeater, None);
5809 assert_eq!(plain.ident_mobile, None);
5810 }
5811
5812 #[test]
5813 fn repeater_policy_round_trips_through_the_sync_reducer() {
5814 let capabilities = encoded_capabilities(&commissionable_capabilities());
5815 let sync = inspect_ulcp_sync(vec![
5816 response(prop::CAPS, &capabilities),
5817 response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
5818 response(prop::PHY_ENABLED, &[1]),
5819 response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
5820 response(prop::PHY_TX_POWER, &[14]),
5821 response(prop::SAVED, &[saved::CURRENT]),
5822 response(prop::HOST_RX_FILTERS, &[]),
5823 response(prop::MAC_REPEATER_ENABLED, &[1]),
5824 response(
5826 prop::MAC_REPEATER_REGIONS,
5827 &[3, b'S', b'J', b'C', 3, b'S', b'F', b'O'],
5828 ),
5829 response(prop::MAC_REPEATER_DEFAULT_REGION, &[0x78, 0x53]),
5830 response(prop::MAC_REPEATER_MIN_RSSI, &(-115i16).to_le_bytes()),
5831 response(prop::MAC_REPEATER_MIN_SNR, &[(-7i8) as u8]),
5832 response(prop::IDENT_ROLE, &[3]),
5833 response(prop::IDENT_MOBILE, &[1]),
5834 response(prop::DEV_PEERS, &[]),
5835 response(prop::DEV_DISCOVERABLE, &[1]),
5836 ])
5837 .unwrap();
5838 assert_eq!(
5839 sync.repeater,
5840 Some(UlcpRepeaterSettingsRecord {
5841 enabled: true,
5842 regions: vec!["SJC".to_owned(), "SFO".to_owned()],
5843 default_region: Some(vec![0x78, 0x53]),
5844 min_rssi_dbm: Some(-115),
5845 min_snr_db: Some(-7),
5846 })
5847 );
5848 assert_eq!(sync.ident_role, Some(3));
5849 assert_eq!(sync.ident_mobile, Some(true));
5850
5851 let malformed = |property, value: &[u8]| {
5856 let mut responses = vec![
5857 response(prop::CAPS, &capabilities),
5858 response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
5859 response(prop::PHY_ENABLED, &[1]),
5860 response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
5861 response(prop::PHY_TX_POWER, &[14]),
5862 response(prop::SAVED, &[saved::CURRENT]),
5863 response(prop::MAC_REPEATER_ENABLED, &[0]),
5864 response(prop::MAC_REPEATER_REGIONS, &[]),
5865 response(prop::MAC_REPEATER_DEFAULT_REGION, &[]),
5866 response(prop::MAC_REPEATER_MIN_RSSI, &[]),
5867 response(prop::MAC_REPEATER_MIN_SNR, &[]),
5868 response(prop::IDENT_ROLE, &[]),
5869 response(prop::IDENT_MOBILE, &[0]),
5870 response(prop::DEV_PEERS, &[]),
5871 response(prop::DEV_DISCOVERABLE, &[1]),
5872 ];
5873 responses.retain(|entry| entry.property_id != property);
5874 responses.push(response(property, value));
5875 inspect_ulcp_sync(responses)
5876 };
5877 for property in [
5878 prop::MAC_REPEATER_REGIONS,
5879 prop::MAC_REPEATER_DEFAULT_REGION,
5880 prop::MAC_REPEATER_MIN_RSSI,
5881 ] {
5882 let sync = malformed(property, &[0x8D, 0x53, 0x7C]).expect("device still described");
5883 assert_eq!(sync.repeater, None, "property {property}");
5884 assert!(sync.supports_repeater, "property {property}");
5885 assert!(
5886 sync.unreadable_properties.contains(&property),
5887 "property {property}"
5888 );
5889 }
5890 assert_eq!(
5891 ulcp_inspection_properties(encoded_capabilities(&[cap::REPEATER])),
5892 Err(MobileError::InvalidUlcpFrame)
5893 );
5894 }
5895
5896 #[test]
5902 fn a_refused_property_still_yields_an_administrable_device() {
5903 let session = MobileUlcpSession::administrative();
5904 let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
5905 let attached = drive_reads(&session, begin.outbound_frames, |property| match property {
5906 prop::DEV_DISCOVERABLE | prop::MAC_REPEATER_MIN_RSSI => (
5909 prop::LAST_STATUS,
5910 vec![umsh_ulcp::Status::PROP_NOT_FOUND.0 as u8],
5911 ),
5912 prop::HOST_KEY => (property, vec![0xBB; 32]),
5913 other => commissionable_value(other),
5914 });
5915
5916 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5917 assert_eq!(attached.operation_error, None);
5921 let sync = attached.snapshot.provisioning.expect("device described");
5922 assert!(sync.supports_device_identity);
5923 assert_eq!(sync.dev_discoverable, None);
5924 assert_eq!(
5925 sync.unreadable_properties,
5926 vec![prop::MAC_REPEATER_MIN_RSSI, prop::DEV_DISCOVERABLE]
5927 );
5928 assert_eq!(sync.dev_peer_keys, Some(Vec::new()));
5930 assert!(sync.supports_repeater);
5933 assert_eq!(sync.repeater, None);
5934
5935 let configured = session
5936 .configure_device(UlcpDeviceConfigRecord {
5937 radio: UlcpRadioSettingsRecord {
5938 device_name: None,
5939 phy_enabled: true,
5940 frequency_khz: 906_875,
5941 transmit_power_dbm: 20,
5942 bandwidth_hz: None,
5943 spreading_factor: None,
5944 coding_rate_denom: None,
5945 duty_cycle_limit: None,
5946 },
5947 ident_role: None,
5948 ident_mobile: Some(true),
5949 dev_discoverable: Some(true),
5950 repeater: Some(UlcpRepeaterSettingsRecord {
5951 enabled: false,
5952 regions: Vec::new(),
5953 default_region: None,
5954 min_rssi_dbm: None,
5955 min_snr_db: None,
5956 }),
5957 tz_offset_min: None,
5958 gnss: None,
5959 advert: None,
5960 })
5961 .unwrap();
5962 let (written, _, _) = drive_configuration(&session, configured.outbound_frames);
5963 assert!(!written.contains_key(&prop::DEV_DISCOVERABLE));
5968 for property in [
5969 prop::MAC_REPEATER_ENABLED,
5970 prop::MAC_REPEATER_REGIONS,
5971 prop::MAC_REPEATER_DEFAULT_REGION,
5972 prop::MAC_REPEATER_MIN_RSSI,
5973 prop::MAC_REPEATER_MIN_SNR,
5974 ] {
5975 assert!(!written.contains_key(&property), "property {property}");
5976 }
5977 assert_eq!(written.get(&prop::IDENT_MOBILE), Some(&vec![1]));
5978 assert_eq!(
5979 written.get(&prop::PHY_FREQ),
5980 Some(&906_875u32.to_le_bytes().to_vec())
5981 );
5982 }
5983
5984 #[test]
5985 fn configuring_a_device_writes_its_whole_domain_as_a_property_map() {
5986 let session = MobileUlcpSession::administrative();
5987 let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xBB; 32]);
5988 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5989
5990 let configured = session
5991 .configure_device(UlcpDeviceConfigRecord {
5992 radio: UlcpRadioSettingsRecord {
5993 device_name: Some("Ridge repeater".into()),
5994 phy_enabled: true,
5995 frequency_khz: 906_875,
5996 transmit_power_dbm: 22,
5997 bandwidth_hz: None,
5998 spreading_factor: None,
5999 coding_rate_denom: None,
6000 duty_cycle_limit: None,
6001 },
6002 ident_role: Some(3),
6003 ident_mobile: Some(false),
6004 dev_discoverable: Some(false),
6005 repeater: Some(UlcpRepeaterSettingsRecord {
6006 enabled: true,
6007 regions: vec!["SJC".to_owned()],
6008 default_region: Some(vec![0x78, 0x53]),
6009 min_rssi_dbm: Some(-115),
6010 min_snr_db: Some(-7),
6011 }),
6012 tz_offset_min: None,
6013 gnss: None,
6014 advert: None,
6015 })
6016 .unwrap();
6017 assert_eq!(configured.snapshot.phase, UlcpSessionPhase::Configuring);
6018
6019 let (written, order, save_tid) = drive_configuration(&session, configured.outbound_frames);
6020
6021 assert_eq!(
6025 written,
6026 HashMap::from([
6027 (prop::DEV_NAME, b"Ridge repeater".to_vec()),
6028 (prop::PHY_FREQ, 906_875u32.to_le_bytes().to_vec()),
6029 (prop::PHY_TX_POWER, vec![22]),
6030 (prop::IDENT_ROLE, vec![3]),
6031 (prop::IDENT_MOBILE, vec![0]),
6032 (prop::DEV_DISCOVERABLE, vec![0]),
6033 (prop::MAC_REPEATER_REGIONS, vec![3, b'S', b'J', b'C']),
6034 (prop::MAC_REPEATER_DEFAULT_REGION, vec![0x78, 0x53]),
6035 (
6036 prop::MAC_REPEATER_MIN_RSSI,
6037 (-115i16).to_le_bytes().to_vec()
6038 ),
6039 (prop::MAC_REPEATER_MIN_SNR, vec![(-7i8) as u8]),
6040 (prop::MAC_REPEATER_ENABLED, vec![1]),
6041 (prop::PHY_ENABLED, vec![1]),
6042 ])
6043 );
6044 assert_eq!(
6047 &order[order.len() - 2..],
6048 &[prop::MAC_REPEATER_ENABLED, prop::PHY_ENABLED]
6049 );
6050
6051 let attached = session
6052 .consume(property_response(save_tid, prop::LAST_STATUS, &[0]))
6053 .unwrap();
6054 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
6055 assert_eq!(
6056 attached.snapshot.host_ownership,
6057 UlcpHostOwnership::OtherHost
6058 );
6059 let provisioning = attached.snapshot.provisioning.unwrap();
6060 assert_eq!(provisioning.frequency_khz, 906_875);
6061 assert_eq!(provisioning.ident_role, Some(3));
6062 assert_eq!(
6063 provisioning.repeater,
6064 Some(UlcpRepeaterSettingsRecord {
6065 enabled: true,
6066 regions: vec!["SJC".to_owned()],
6067 default_region: Some(vec![0x78, 0x53]),
6068 min_rssi_dbm: Some(-115),
6069 min_snr_db: Some(-7),
6070 })
6071 );
6072 }
6073
6074 #[test]
6075 fn device_configuration_must_match_what_the_device_can_do() {
6076 let session = MobileUlcpSession::administrative();
6077 attach_commissionable(&session, None, Vec::new());
6078
6079 let radio = UlcpRadioSettingsRecord {
6080 device_name: None,
6081 phy_enabled: true,
6082 frequency_khz: 915_000,
6083 transmit_power_dbm: 14,
6084 bandwidth_hz: None,
6085 spreading_factor: None,
6086 coding_rate_denom: None,
6087 duty_cycle_limit: None,
6088 };
6089 let repeater = UlcpRepeaterSettingsRecord {
6090 enabled: true,
6091 regions: Vec::new(),
6092 default_region: None,
6093 min_rssi_dbm: None,
6094 min_snr_db: None,
6095 };
6096 let configure = |ident_role, ident_mobile, dev_discoverable, repeater| {
6097 session.configure_device(UlcpDeviceConfigRecord {
6098 radio: radio.clone(),
6099 ident_role,
6100 ident_mobile,
6101 dev_discoverable,
6102 repeater,
6103 tz_offset_min: None,
6104 gnss: None,
6105 advert: None,
6106 })
6107 };
6108
6109 assert_eq!(
6112 configure(Some(3), None, Some(true), Some(repeater.clone())),
6113 Err(MobileError::InvalidUlcpFrame)
6114 );
6115 assert_eq!(
6116 configure(None, Some(false), Some(true), None),
6117 Err(MobileError::InvalidUlcpFrame)
6118 );
6119 assert_eq!(
6120 configure(None, Some(false), None, Some(repeater.clone())),
6121 Err(MobileError::InvalidUlcpFrame)
6122 );
6123 for bad in ["", &"A".repeat(items::REGION_STRING_MAX_LEN + 1)] {
6126 assert_eq!(
6127 configure(
6128 None,
6129 Some(false),
6130 Some(true),
6131 Some(UlcpRepeaterSettingsRecord {
6132 regions: vec![bad.to_owned()],
6133 ..repeater.clone()
6134 })
6135 ),
6136 Err(MobileError::InvalidUlcpFrame)
6137 );
6138 }
6139 assert_eq!(
6140 configure(
6141 None,
6142 Some(false),
6143 Some(true),
6144 Some(UlcpRepeaterSettingsRecord {
6145 default_region: Some(vec![0x78, 0x53, 0x00]),
6146 ..repeater.clone()
6147 })
6148 ),
6149 Err(MobileError::InvalidUlcpFrame)
6150 );
6151
6152 let configured = configure(None, Some(true), Some(true), Some(repeater)).unwrap();
6155 let (written, ..) = drive_configuration(&session, configured.outbound_frames);
6156 assert_eq!(written.get(&prop::IDENT_ROLE), Some(&Vec::new()));
6157 assert_eq!(written.get(&prop::IDENT_MOBILE), Some(&vec![1]));
6158 assert_eq!(written.get(&prop::DEV_DISCOVERABLE), Some(&vec![1]));
6159 assert_eq!(written.get(&prop::MAC_REPEATER_REGIONS), Some(&Vec::new()));
6160 assert_eq!(written.get(&prop::MAC_REPEATER_MIN_RSSI), Some(&Vec::new()));
6161 }
6162
6163 #[test]
6164 fn region_codes_convert_between_text_and_wire_octets() {
6165 assert_eq!(region_code_from_string("SJC".into()).unwrap(), [0x78, 0x53]);
6166 assert_eq!(region_code_description(vec![0x78, 0x53]).unwrap(), "SJC");
6167 assert_eq!(region_code_from_string("WA".into()).unwrap(), [0x8F, 0xE8]);
6169 assert_eq!(region_code_description(vec![0x8F, 0xE8]).unwrap(), "WA");
6170 let named = region_code_from_string("Rogue Valley".into()).unwrap();
6173 assert_eq!(named, [0xC0, 0xF9]);
6174 let described = region_code_description(named.clone()).unwrap();
6175 assert_eq!(described, "0xC0F9");
6176 assert_eq!(region_code_from_string(described).unwrap(), named);
6177
6178 assert_eq!(region_code_from_string("sjc".into()).unwrap(), [0x78, 0x53]);
6180 assert_eq!(
6181 region_code_from_string("rogue valley".into()).unwrap(),
6182 named
6183 );
6184
6185 assert_eq!(
6186 region_code_from_string(" ".into()),
6187 Err(MobileError::InvalidRegionCode)
6188 );
6189 assert_eq!(
6190 region_code_description(vec![0x78]),
6191 Err(MobileError::InvalidRegionCode)
6192 );
6193 }
6194
6195 #[test]
6196 fn mobile_session_rejects_mismatched_transaction_response() {
6197 let session = MobileUlcpSession::new();
6198 let begin = session.begin(None).unwrap();
6199 let (tid, _) = property_request(&begin.outbound_frames[0]);
6200 assert_eq!(
6201 session.consume(property_response(tid, prop::PHY_FREQ, &[0; 4])),
6202 Err(MobileError::UlcpMismatchedResponse)
6203 );
6204 assert_eq!(
6208 session.consume(property_response(tid, prop::PHY_FREQ, &[0; 4])),
6209 Err(MobileError::UlcpUnexpectedFrame)
6210 );
6211 }
6212
6213 #[test]
6214 fn received_frame_causes_are_distinguishable() {
6215 let session = MobileUlcpSession::new();
6216 assert_eq!(
6217 session.consume(vec![0x00, 0x06]),
6218 Err(MobileError::UlcpFrameUnparsable)
6219 );
6220 let mut save = [0u8; 8];
6224 let len = frame::save(&mut save, 1).unwrap();
6225 assert_eq!(
6226 session.consume(save[..len].to_vec()),
6227 Err(MobileError::UlcpUnexpectedCommand)
6228 );
6229 }
6230
6231 #[test]
6232 fn frame_descriptions_name_the_command_without_payload_bytes() {
6233 let mut bytes = [0u8; 16];
6234 let len = frame::prop_is(&mut bytes, 3, 0x1234, &[5, 6]).unwrap();
6235 assert_eq!(
6236 describe_ulcp_frame(bytes[..len].to_vec()),
6237 "tid=3 cmd=PropIs(6) prop=0x1234 value=2B len=6"
6238 );
6239 assert_eq!(describe_ulcp_frame(vec![0x00]), "unparsable len=1");
6240 }
6241
6242 #[test]
6243 fn mobile_session_emits_typed_raw_receive_during_sync() {
6244 let session = MobileUlcpSession::new();
6245 session.begin(None).unwrap();
6246
6247 let metadata = BufferedRxMeta {
6248 rx: umsh_ulcp::RxMeta {
6249 rssi_dbm: Some(-87),
6250 lqi: core::num::NonZeroU8::new(42),
6251 snr_cb: Some(125),
6252 },
6253 flags: RX_FLAG_BUFFERED | RX_FLAG_ACKED,
6254 age_s: 9,
6255 };
6256 let mut metadata_bytes = [0; BufferedRxMeta::WIRE_LEN];
6257 metadata.encode(&mut metadata_bytes).unwrap();
6258 let mut bytes = vec![0; MAX_FRAME];
6259 let len = frame::str_recv(
6260 &mut bytes,
6261 umsh_ulcp::ids::stream::PHY_RAW,
6262 &[1, 2, 3],
6263 &metadata_bytes,
6264 )
6265 .unwrap();
6266 bytes.truncate(len);
6267
6268 let update = session.consume(bytes).unwrap();
6269 assert_eq!(update.received_frames.len(), 1);
6270 assert_eq!(
6271 update.received_frames[0],
6272 UlcpReceivedFrameRecord {
6273 data: vec![1, 2, 3],
6274 rssi_dbm: Some(-87),
6275 lqi: Some(42),
6276 snr_cb: Some(125),
6277 was_buffered: true,
6278 was_acknowledged: true,
6279 age_seconds: 9,
6280 }
6281 );
6282 assert!(update.outbound_frames.is_empty());
6283 assert!(update.waiting_for_responses);
6284 }
6285
6286 #[test]
6287 fn mobile_session_reports_raw_transmit_rejection_without_ending_session() {
6288 let session = MobileUlcpSession::new();
6289 let begin = session.begin(None).unwrap();
6290 let inspection =
6291 answer_requests(&session, begin.outbound_frames, |property| match property {
6292 prop::LAST_STATUS => (property, vec![0]),
6293 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
6294 prop::CAPS => (property, vec![cap::WRITABLE_RAW_STREAM as u8]),
6295 prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY => (property, Vec::new()),
6296 prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
6297 _ => unreachable!(),
6298 });
6299 let attached =
6300 answer_requests(
6301 &session,
6302 inspection.outbound_frames,
6303 |property| match property {
6304 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
6305 prop::PHY_ENABLED => (property, vec![1]),
6306 prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
6307 prop::PHY_TX_POWER => (property, vec![14]),
6308 _ => unreachable!(),
6309 },
6310 );
6311 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
6312
6313 let transmit = session.transmit_raw(vec![1, 2, 3], false).unwrap();
6314 assert!(transmit.raw_transmit_pending);
6315 assert_eq!(transmit.raw_transmit_result, None);
6316 assert_eq!(transmit.outbound_frames.len(), 1);
6317 let second_transmit = session.transmit_raw(vec![4], false).unwrap();
6318 assert_ne!(
6319 transmit.raw_transmit_started_transaction_id,
6320 second_transmit.raw_transmit_started_transaction_id
6321 );
6322
6323 let request = Frame::parse(&transmit.outbound_frames[0]).unwrap();
6324 let rejected = session
6325 .consume(property_response(
6326 request.header.tid(),
6327 prop::LAST_STATUS,
6328 &[umsh_ulcp::Status::INVALID_STATE.0 as u8],
6329 ))
6330 .unwrap();
6331 assert_eq!(rejected.snapshot.phase, UlcpSessionPhase::Attached);
6332 assert!(rejected.raw_transmit_pending);
6333 assert_eq!(
6334 rejected.raw_transmit_result,
6335 Some(UlcpRawTransmitResultRecord {
6336 transaction_id: request.header.tid(),
6337 status_code: umsh_ulcp::Status::INVALID_STATE.0,
6338 status_name: "Status::INVALID_STATE".into(),
6339 disposition: UlcpRawTransmitDisposition::Rejected,
6340 })
6341 );
6342 let second_request = Frame::parse(&second_transmit.outbound_frames[0]).unwrap();
6343 let completed = session
6344 .consume(property_response(
6345 second_request.header.tid(),
6346 prop::LAST_STATUS,
6347 &[umsh_ulcp::Status::OK.0 as u8],
6348 ))
6349 .unwrap();
6350 assert!(!completed.raw_transmit_pending);
6351
6352 let retryable = session.transmit_raw(vec![5], false).unwrap();
6355 let request = Frame::parse(&retryable.outbound_frames[0]).unwrap();
6356 let busy = session
6357 .consume(property_response(
6358 request.header.tid(),
6359 prop::LAST_STATUS,
6360 &[umsh_ulcp::Status::BUSY.0 as u8],
6361 ))
6362 .unwrap();
6363 assert_eq!(
6364 busy.raw_transmit_result.unwrap().disposition,
6365 UlcpRawTransmitDisposition::Retry
6366 );
6367
6368 let abandoned = session.transmit_raw(vec![6], false).unwrap();
6369 let abandoned_request = Frame::parse(&abandoned.outbound_frames[0]).unwrap();
6370 assert!(
6371 !session
6372 .abandon_raw_transmits(vec![abandoned_request.header.tid()])
6373 .raw_transmit_pending
6374 );
6375
6376 let configured = session
6380 .configure(UlcpRadioSettingsRecord {
6381 device_name: None,
6382 phy_enabled: true,
6383 frequency_khz: 915_000,
6384 transmit_power_dbm: 14,
6385 bandwidth_hz: None,
6386 spreading_factor: None,
6387 coding_rate_denom: None,
6388 duty_cycle_limit: None,
6389 })
6390 .unwrap();
6391 let mut final_update = None;
6392 for (index, request) in configured.outbound_frames.into_iter().enumerate() {
6393 let parsed = Frame::parse(&request).unwrap();
6394 let payload = PropPayload::parse(parsed.payload).unwrap();
6395 let response = if index == 0 {
6396 property_response(
6397 parsed.header.tid(),
6398 prop::LAST_STATUS,
6399 &[umsh_ulcp::Status::INVALID_ARGUMENT.0 as u8],
6400 )
6401 } else {
6402 property_response(parsed.header.tid(), payload.key, payload.value)
6403 };
6404 let update = session.consume(response).unwrap();
6405 if index == 0 {
6406 assert_eq!(
6407 update.operation_error,
6408 Some(UlcpOperationErrorRecord {
6409 operation: format!("set property {}", payload.key),
6410 status_code: umsh_ulcp::Status::INVALID_ARGUMENT.0,
6411 status_name: "Status::INVALID_ARGUMENT".into(),
6412 })
6413 );
6414 }
6415 final_update = Some(update);
6416 }
6417 assert_eq!(
6418 final_update.unwrap().snapshot.phase,
6419 UlcpSessionPhase::Attached
6420 );
6421 assert!(session.transmit_raw(vec![6], false).is_ok());
6422 }
6423
6424 #[test]
6425 fn mobile_session_verifies_radio_configuration_then_saves() {
6426 let session = MobileUlcpSession::new();
6427 let begin = session.begin(None).unwrap();
6428 let inspection =
6429 answer_requests(&session, begin.outbound_frames, |property| match property {
6430 prop::LAST_STATUS => (property, vec![0]),
6431 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
6432 prop::CAPS => (
6433 property,
6434 encoded_capabilities(&[
6435 cap::SAVE,
6436 cap::DEV_NAME,
6437 cap::PHY_LORA,
6438 cap::PHY_DUTY_LIMIT,
6439 ]),
6440 ),
6441 prop::DEV_NAME => (property, b"Old name".to_vec()),
6442 prop::DEV_KEY | prop::BATTERY => (property, Vec::new()),
6443 prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
6444 _ => unreachable!(),
6445 });
6446 let partial =
6447 answer_requests(
6448 &session,
6449 inspection.outbound_frames,
6450 |property| match property {
6451 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
6452 prop::PHY_ENABLED => (property, vec![1]),
6453 prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
6454 prop::PHY_TX_POWER => (property, vec![14]),
6455 prop::PHY_LORA_BW => (property, 125_000u32.to_le_bytes().to_vec()),
6456 prop::PHY_LORA_SF => (property, vec![9]),
6457 prop::PHY_LORA_CR => (property, vec![5]),
6458 _ => unreachable!(),
6459 },
6460 );
6461 let attached = answer_requests(
6462 &session,
6463 partial.outbound_frames,
6464 |property| match property {
6465 prop::PHY_DUTY_NOW => (property, 65u16.to_le_bytes().to_vec()),
6466 prop::PHY_DUTY_LIMIT => (property, 655u16.to_le_bytes().to_vec()),
6467 prop::SAVED => (property, vec![1]),
6468 _ => unreachable!(),
6469 },
6470 );
6471 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
6472
6473 let configured = session
6474 .configure(UlcpRadioSettingsRecord {
6475 device_name: Some("Trail radio".into()),
6476 phy_enabled: true,
6477 frequency_khz: 868_100,
6478 transmit_power_dbm: 20,
6479 bandwidth_hz: Some(250_000),
6480 spreading_factor: Some(10),
6481 coding_rate_denom: Some(6),
6482 duty_cycle_limit: Some(6_553),
6483 })
6484 .unwrap();
6485 assert_eq!(configured.snapshot.phase, UlcpSessionPhase::Configuring);
6486 assert_eq!(
6487 configured.outbound_frames.len(),
6488 usize::from(frame::TID_MAX)
6489 );
6490 let mut pending = VecDeque::from(configured.outbound_frames);
6491 let mut configured_properties = Vec::new();
6492 let save_tid = loop {
6493 let request = pending.pop_front().unwrap();
6494 let parsed = Frame::parse(&request).unwrap();
6495 if parsed.command() == Some(Cmd::Save) {
6496 break parsed.header.tid();
6497 }
6498 assert_eq!(parsed.command(), Some(Cmd::PropSet));
6499 let payload = PropPayload::parse(parsed.payload).unwrap();
6500 configured_properties.push(payload.key);
6501 let answer: &[u8] = match payload.key {
6505 prop::PHY_TX_POWER => &[17],
6506 _ => payload.value,
6507 };
6508 let update = session
6509 .consume(property_response(parsed.header.tid(), payload.key, answer))
6510 .unwrap_or_else(|error| {
6511 panic!(
6512 "configuration response for property {} failed: {error:?}",
6513 payload.key
6514 )
6515 });
6516 pending.extend(update.outbound_frames);
6517 };
6518 assert_eq!(configured_properties.last(), Some(&prop::PHY_ENABLED));
6519 let attached = session
6520 .consume(property_response(save_tid, prop::LAST_STATUS, &[0]))
6521 .unwrap();
6522 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
6523 assert_eq!(
6524 attached.snapshot.device_name.as_deref(),
6525 Some("Trail radio")
6526 );
6527 let provisioning = attached.snapshot.provisioning.unwrap();
6528 assert_eq!(provisioning.frequency_khz, 868_100);
6529 assert_eq!(provisioning.transmit_power_dbm, 17);
6532 assert_eq!(provisioning.bandwidth_hz, Some(250_000));
6533 assert_eq!(provisioning.spreading_factor, Some(10));
6534 assert_eq!(provisioning.coding_rate_denom, Some(6));
6535 assert_eq!(provisioning.duty_cycle_now, Some(65));
6536 assert_eq!(provisioning.duty_cycle_limit, Some(6_553));
6537
6538 let pushed = session
6539 .consume(property_response(
6540 frame::TID_UNSOLICITED,
6541 prop::PHY_DUTY_NOW,
6542 &131u16.to_le_bytes(),
6543 ))
6544 .unwrap();
6545 assert_eq!(
6546 pushed.snapshot.provisioning.unwrap().duty_cycle_now,
6547 Some(131)
6548 );
6549
6550 let refresh = session.refresh().unwrap();
6551 assert_eq!(refresh.snapshot.phase, UlcpSessionPhase::Attached);
6552 assert!(refresh.waiting_for_responses);
6553 let refresh_tail =
6554 answer_requests(
6555 &session,
6556 refresh.outbound_frames,
6557 |property| match property {
6558 prop::DEV_NAME => (property, b"Fresh name".to_vec()),
6559 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
6560 prop::PHY_ENABLED => (property, vec![1]),
6561 prop::PHY_FREQ => (property, 910_525u32.to_le_bytes().to_vec()),
6562 prop::PHY_TX_POWER => (property, vec![18]),
6563 prop::PHY_LORA_BW => (property, 62_500u32.to_le_bytes().to_vec()),
6564 prop::PHY_LORA_SF => (property, vec![7]),
6565 _ => unreachable!(),
6566 },
6567 );
6568 let refreshed =
6569 answer_requests(
6570 &session,
6571 refresh_tail.outbound_frames,
6572 |property| match property {
6573 prop::PHY_LORA_CR => (property, vec![5]),
6574 prop::PHY_DUTY_NOW => (property, 262u16.to_le_bytes().to_vec()),
6575 prop::PHY_DUTY_LIMIT => (property, 655u16.to_le_bytes().to_vec()),
6576 prop::SAVED => (property, vec![1]),
6577 _ => unreachable!(),
6578 },
6579 );
6580 assert_eq!(refreshed.snapshot.phase, UlcpSessionPhase::Attached);
6581 assert!(!refreshed.waiting_for_responses);
6582 assert_eq!(
6583 refreshed.snapshot.device_name.as_deref(),
6584 Some("Fresh name")
6585 );
6586 let refreshed = refreshed.snapshot.provisioning.unwrap();
6587 assert_eq!(refreshed.frequency_khz, 910_525);
6588 assert_eq!(refreshed.duty_cycle_now, Some(262));
6589 assert_eq!(refreshed.duty_cycle_limit, Some(655));
6590 }
6591
6592 fn inserted_response(tid: u8, property: u32, item: &[u8]) -> Vec<u8> {
6593 let mut bytes = vec![0; MAX_FRAME];
6594 let length = frame::prop_inserted(&mut bytes, tid, property, item).unwrap();
6595 bytes.truncate(length);
6596 bytes
6597 }
6598
6599 fn removed_response(tid: u8, property: u32, item: &[u8]) -> Vec<u8> {
6600 let mut bytes = vec![0; MAX_FRAME];
6601 let length = frame::prop_removed(&mut bytes, tid, property, item).unwrap();
6602 bytes.truncate(length);
6603 bytes
6604 }
6605
6606 fn dev_peer_keys(update: &UlcpSessionUpdateRecord) -> Vec<Vec<u8>> {
6607 update
6608 .snapshot
6609 .provisioning
6610 .as_ref()
6611 .unwrap()
6612 .dev_peer_keys
6613 .clone()
6614 .unwrap()
6615 }
6616
6617 #[test]
6618 fn device_peer_insert_and_remove_patch_the_table_and_chain_a_save() {
6619 let session = MobileUlcpSession::new();
6620 let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
6621 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
6622 assert_eq!(dev_peer_keys(&attached), Vec::<Vec<u8>>::new());
6623
6624 let insert = session.insert_device_peer(vec![0xC1; 32]).unwrap();
6625 assert_eq!(insert.outbound_frames.len(), 1);
6626 let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
6627 assert_eq!(request.command(), Some(Cmd::PropInsert));
6628
6629 let confirmed = session
6630 .consume(inserted_response(
6631 request.header.tid(),
6632 prop::DEV_PEERS,
6633 &[0xC1; 32],
6634 ))
6635 .unwrap();
6636 assert_eq!(dev_peer_keys(&confirmed), vec![vec![0xC1; 32]]);
6637 assert_eq!(confirmed.operation_error, None);
6638 assert!(confirmed.waiting_for_responses);
6640 assert_eq!(confirmed.outbound_frames.len(), 1);
6641 let save = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
6642 assert_eq!(save.command(), Some(Cmd::Save));
6643
6644 let saved = session
6645 .consume(property_response(
6646 save.header.tid(),
6647 prop::LAST_STATUS,
6648 &[umsh_ulcp::Status::OK.0 as u8],
6649 ))
6650 .unwrap();
6651 assert_eq!(saved.operation_error, None);
6652 assert!(!saved.waiting_for_responses);
6653 assert_eq!(saved.snapshot.phase, UlcpSessionPhase::Attached);
6654
6655 let remove = session.remove_device_peer(vec![0xC1; 32]).unwrap();
6656 let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
6657 assert_eq!(request.command(), Some(Cmd::PropRemove));
6658 let confirmed = session
6659 .consume(removed_response(
6660 request.header.tid(),
6661 prop::DEV_PEERS,
6662 &[0xC1; 32],
6663 ))
6664 .unwrap();
6665 assert_eq!(dev_peer_keys(&confirmed), Vec::<Vec<u8>>::new());
6666 let save = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
6667 assert_eq!(save.command(), Some(Cmd::Save));
6668 let saved = session
6669 .consume(property_response(
6670 save.header.tid(),
6671 prop::LAST_STATUS,
6672 &[umsh_ulcp::Status::OK.0 as u8],
6673 ))
6674 .unwrap();
6675 assert!(!saved.waiting_for_responses);
6676 }
6677
6678 #[test]
6679 fn device_peer_failures_report_status_without_ending_the_session() {
6680 let session = MobileUlcpSession::new();
6681 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
6682
6683 let insert = session.insert_device_peer(vec![0xC2; 32]).unwrap();
6686 let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
6687 let full = session
6688 .consume(property_response(
6689 request.header.tid(),
6690 prop::LAST_STATUS,
6691 &[umsh_ulcp::Status::NOMEM.0 as u8],
6692 ))
6693 .unwrap();
6694 assert_eq!(
6695 full.operation_error,
6696 Some(UlcpOperationErrorRecord {
6697 operation: "insert device peer".into(),
6698 status_code: umsh_ulcp::Status::NOMEM.0,
6699 status_name: "Status::NOMEM".into(),
6700 })
6701 );
6702 assert_eq!(dev_peer_keys(&full), Vec::<Vec<u8>>::new());
6703 assert!(!full.waiting_for_responses);
6704 assert_eq!(full.snapshot.phase, UlcpSessionPhase::Attached);
6705
6706 let insert = session.insert_device_peer(vec![0xC3; 32]).unwrap();
6709 let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
6710 let already = session
6711 .consume(property_response(
6712 request.header.tid(),
6713 prop::LAST_STATUS,
6714 &[umsh_ulcp::Status::ALREADY.0 as u8],
6715 ))
6716 .unwrap();
6717 assert_eq!(
6718 already.operation_error.as_ref().unwrap().status_name,
6719 "Status::ALREADY"
6720 );
6721 assert_eq!(dev_peer_keys(&already), vec![vec![0xC3; 32]]);
6722
6723 let remove = session.remove_device_peer(vec![0xC3; 32]).unwrap();
6726 let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
6727 let missing = session
6728 .consume(property_response(
6729 request.header.tid(),
6730 prop::LAST_STATUS,
6731 &[umsh_ulcp::Status::ITEM_NOT_FOUND.0 as u8],
6732 ))
6733 .unwrap();
6734 assert_eq!(
6735 missing.operation_error.as_ref().unwrap().status_name,
6736 "Status::ITEM_NOT_FOUND"
6737 );
6738 assert_eq!(dev_peer_keys(&missing), Vec::<Vec<u8>>::new());
6739
6740 assert!(session.insert_device_peer(vec![0xC4; 32]).is_ok());
6742 }
6743
6744 fn dev_admin_keys(update: &UlcpSessionUpdateRecord) -> Vec<Vec<u8>> {
6745 update
6746 .snapshot
6747 .provisioning
6748 .as_ref()
6749 .unwrap()
6750 .dev_admin_keys
6751 .clone()
6752 .unwrap()
6753 }
6754
6755 #[test]
6756 fn listing_an_administrator_is_the_bench_half_of_node_management() {
6757 let session = MobileUlcpSession::new();
6758 let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
6759 assert!(
6760 attached
6761 .snapshot
6762 .provisioning
6763 .as_ref()
6764 .unwrap()
6765 .supports_admin
6766 );
6767 assert_eq!(dev_admin_keys(&attached), Vec::<Vec<u8>>::new());
6768
6769 let phone = vec![0x11; 32];
6772 let insert = session.insert_device_admin(phone.clone()).unwrap();
6773 let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
6774 assert_eq!(request.command(), Some(Cmd::PropInsert));
6775
6776 let confirmed = session
6777 .consume(inserted_response(
6778 request.header.tid(),
6779 prop::DEV_ADMINS,
6780 &phone,
6781 ))
6782 .unwrap();
6783 assert_eq!(dev_admin_keys(&confirmed), vec![phone.clone()]);
6784 let save = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
6787 assert_eq!(save.command(), Some(Cmd::Save));
6788 session
6789 .consume(property_response(
6790 save.header.tid(),
6791 prop::LAST_STATUS,
6792 &[umsh_ulcp::Status::OK.0 as u8],
6793 ))
6794 .unwrap();
6795
6796 assert_eq!(dev_peer_keys(&confirmed), Vec::<Vec<u8>>::new());
6798
6799 let remove = session.remove_device_admin(phone.clone()).unwrap();
6800 let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
6801 assert_eq!(request.command(), Some(Cmd::PropRemove));
6802 let confirmed = session
6803 .consume(removed_response(
6804 request.header.tid(),
6805 prop::DEV_ADMINS,
6806 &phone,
6807 ))
6808 .unwrap();
6809 assert_eq!(dev_admin_keys(&confirmed), Vec::<Vec<u8>>::new());
6810 }
6811
6812 #[test]
6816 fn a_configuration_reduces_the_same_way_with_or_without_a_session() {
6817 let configuration = UlcpDeviceConfigRecord {
6818 radio: UlcpRadioSettingsRecord {
6819 device_name: Some("Ridge repeater".into()),
6820 phy_enabled: true,
6821 frequency_khz: 906_875,
6822 transmit_power_dbm: 20,
6823 bandwidth_hz: None,
6824 spreading_factor: None,
6825 coding_rate_denom: None,
6826 duty_cycle_limit: None,
6827 },
6828 ident_role: None,
6829 ident_mobile: Some(false),
6830 dev_discoverable: Some(true),
6831 repeater: Some(UlcpRepeaterSettingsRecord {
6832 enabled: true,
6833 regions: Vec::new(),
6834 default_region: None,
6835 min_rssi_dbm: None,
6836 min_snr_db: None,
6837 }),
6838 tz_offset_min: None,
6839 gnss: None,
6840 advert: None,
6841 };
6842
6843 let session = MobileUlcpSession::new();
6844 let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
6845 let reported = attached.snapshot.provisioning.clone().unwrap();
6846
6847 let configured = session.configure_device(configuration.clone()).unwrap();
6848 let (written, order, _) = drive_configuration(&session, configured.outbound_frames);
6849
6850 let writes = device_config_writes(configuration, &reported).unwrap();
6851 assert_eq!(
6852 writes.iter().map(|(key, _)| *key).collect::<Vec<_>>(),
6853 order
6854 );
6855 for (key, value) in &writes {
6856 assert_eq!(written.get(key), Some(value));
6857 }
6858 assert_eq!(writes.first().unwrap().0, prop::DEV_NAME);
6861 assert_eq!(writes.last().unwrap(), &(prop::PHY_ENABLED, vec![1]));
6862 }
6863
6864 #[test]
6867 fn an_unreadable_property_is_left_out_of_an_administrator_s_write() {
6868 let mut reported = attach_commissionable(
6869 &MobileUlcpSession::new(),
6870 Some(vec![0xAA; 32]),
6871 vec![0xAA; 32],
6872 )
6873 .snapshot
6874 .provisioning
6875 .clone()
6876 .unwrap();
6877 reported.unreadable_properties = vec![prop::DEV_DISCOVERABLE];
6878
6879 let configuration = UlcpDeviceConfigRecord {
6880 radio: UlcpRadioSettingsRecord {
6881 device_name: None,
6882 phy_enabled: true,
6883 frequency_khz: 906_875,
6884 transmit_power_dbm: 20,
6885 bandwidth_hz: None,
6886 spreading_factor: None,
6887 coding_rate_denom: None,
6888 duty_cycle_limit: None,
6889 },
6890 ident_role: None,
6891 ident_mobile: Some(false),
6892 dev_discoverable: Some(true),
6893 repeater: Some(UlcpRepeaterSettingsRecord {
6894 enabled: true,
6895 regions: Vec::new(),
6896 default_region: None,
6897 min_rssi_dbm: None,
6898 min_snr_db: None,
6899 }),
6900 tz_offset_min: None,
6901 gnss: None,
6902 advert: None,
6903 };
6904 let writes = device_config_writes(configuration, &reported).unwrap();
6907 assert!(!writes.iter().any(|(key, _)| *key == prop::DEV_DISCOVERABLE));
6908 }
6909
6910 #[test]
6911 fn an_administrator_failure_names_the_list_it_came_from() {
6912 let session = MobileUlcpSession::new();
6913 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
6914
6915 let insert = session.insert_device_admin(vec![0x22; 32]).unwrap();
6916 let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
6917 let full = session
6918 .consume(property_response(
6919 request.header.tid(),
6920 prop::LAST_STATUS,
6921 &[umsh_ulcp::Status::NOMEM.0 as u8],
6922 ))
6923 .unwrap();
6924 assert_eq!(
6925 full.operation_error,
6926 Some(UlcpOperationErrorRecord {
6927 operation: "insert device administrator".into(),
6928 status_code: umsh_ulcp::Status::NOMEM.0,
6929 status_name: "Status::NOMEM".into(),
6930 })
6931 );
6932 assert_eq!(dev_admin_keys(&full), Vec::<Vec<u8>>::new());
6933 assert_eq!(full.snapshot.phase, UlcpSessionPhase::Attached);
6934 }
6935
6936 #[test]
6937 fn a_radio_that_cannot_be_managed_refuses_the_administrator_table() {
6938 let session = MobileUlcpSession::new();
6939 let capabilities = vec![
6942 cap::HOST_FILTER,
6943 cap::SAVE,
6944 cap::DEV_NAME,
6945 cap::DEV_IDENTITY,
6946 ];
6947 let sync = inspect_ulcp_sync(vec![
6948 response(prop::CAPS, &encoded_capabilities(&capabilities)),
6949 response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
6950 response(prop::PHY_ENABLED, &[1]),
6951 response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
6952 response(prop::PHY_TX_POWER, &[14]),
6953 response(prop::HOST_RX_FILTERS, &[]),
6954 response(prop::SAVED, &[saved::CURRENT]),
6955 response(prop::DEV_PEERS, &[]),
6956 response(prop::DEV_CHANNEL_KEYS, &[]),
6957 response(prop::DEV_DISCOVERABLE, &[1]),
6958 ])
6959 .unwrap();
6960 assert!(!sync.supports_admin);
6961 assert_eq!(sync.dev_admin_keys, None);
6962 assert!(!sync.unreadable_properties.contains(&prop::DEV_ADMINS));
6963 assert!(
6964 !ulcp_inspection_properties(encoded_capabilities(&capabilities))
6965 .unwrap()
6966 .contains(&prop::DEV_ADMINS)
6967 );
6968
6969 assert!(session.insert_device_admin(vec![0x33; 32]).is_err());
6970 }
6971
6972 #[test]
6973 fn an_administrator_list_needs_a_device_identity_to_authorize_against() {
6974 assert!(
6975 ulcp_inspection_properties(encoded_capabilities(&[cap::ADMIN])).is_err(),
6976 "CAP_ADMIN without CAP_DEV_IDENTITY is not a device this phone can describe"
6977 );
6978 }
6979
6980 fn dev_channel_ids(update: &UlcpSessionUpdateRecord) -> Vec<Vec<u8>> {
6981 update
6982 .snapshot
6983 .provisioning
6984 .as_ref()
6985 .unwrap()
6986 .dev_channel_ids
6987 .clone()
6988 .unwrap()
6989 }
6990
6991 #[test]
6992 fn device_channel_insert_and_remove_track_identifiers_not_keys() {
6993 let session = MobileUlcpSession::new();
6994 let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
6995 assert_eq!(dev_channel_ids(&attached), Vec::<Vec<u8>>::new());
6996
6997 let key = vec![0xB7; 32];
7000 let id = crate::derive_channel_id(key.clone()).unwrap();
7001 assert_eq!(id.len(), items::CHANNEL_ID_LEN);
7002
7003 let insert = session.insert_device_channel_key(key.clone()).unwrap();
7004 let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
7005 assert_eq!(request.command(), Some(Cmd::PropInsert));
7006
7007 let confirmed = session
7008 .consume(inserted_response(
7009 request.header.tid(),
7010 prop::DEV_CHANNEL_KEYS,
7011 &id,
7012 ))
7013 .unwrap();
7014 assert_eq!(dev_channel_ids(&confirmed), vec![id.clone()]);
7015 assert_eq!(confirmed.operation_error, None);
7016 let save = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
7017 assert_eq!(save.command(), Some(Cmd::Save));
7018 let saved = session
7019 .consume(property_response(
7020 save.header.tid(),
7021 prop::LAST_STATUS,
7022 &[umsh_ulcp::Status::OK.0 as u8],
7023 ))
7024 .unwrap();
7025 assert!(!saved.waiting_for_responses);
7026
7027 let remove = session.remove_device_channel_key(key).unwrap();
7029 let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
7030 assert_eq!(request.command(), Some(Cmd::PropRemove));
7031 let confirmed = session
7032 .consume(removed_response(
7033 request.header.tid(),
7034 prop::DEV_CHANNEL_KEYS,
7035 &id,
7036 ))
7037 .unwrap();
7038 assert_eq!(dev_channel_ids(&confirmed), Vec::<Vec<u8>>::new());
7039 }
7040
7041 #[test]
7042 fn device_channel_failures_report_status_without_ending_the_session() {
7043 let session = MobileUlcpSession::new();
7044 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
7045
7046 let key = vec![0xC7; 32];
7047 let id = crate::derive_channel_id(key.clone()).unwrap();
7048
7049 let insert = session.insert_device_channel_key(key.clone()).unwrap();
7050 let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
7051 let full = session
7052 .consume(property_response(
7053 request.header.tid(),
7054 prop::LAST_STATUS,
7055 &[umsh_ulcp::Status::NOMEM.0 as u8],
7056 ))
7057 .unwrap();
7058 assert_eq!(
7059 full.operation_error,
7060 Some(UlcpOperationErrorRecord {
7061 operation: "insert device channel key".into(),
7062 status_code: umsh_ulcp::Status::NOMEM.0,
7063 status_name: "Status::NOMEM".into(),
7064 })
7065 );
7066 assert_eq!(dev_channel_ids(&full), Vec::<Vec<u8>>::new());
7067 assert_eq!(full.snapshot.phase, UlcpSessionPhase::Attached);
7068
7069 let insert = session.insert_device_channel_key(key.clone()).unwrap();
7071 let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
7072 let already = session
7073 .consume(property_response(
7074 request.header.tid(),
7075 prop::LAST_STATUS,
7076 &[umsh_ulcp::Status::ALREADY.0 as u8],
7077 ))
7078 .unwrap();
7079 assert_eq!(dev_channel_ids(&already), vec![id]);
7080
7081 let remove = session.remove_device_channel_key(key).unwrap();
7082 let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
7083 let missing = session
7084 .consume(property_response(
7085 request.header.tid(),
7086 prop::LAST_STATUS,
7087 &[umsh_ulcp::Status::ITEM_NOT_FOUND.0 as u8],
7088 ))
7089 .unwrap();
7090 assert_eq!(
7091 missing.operation_error.as_ref().unwrap().status_name,
7092 "Status::ITEM_NOT_FOUND"
7093 );
7094 assert_eq!(dev_channel_ids(&missing), Vec::<Vec<u8>>::new());
7095
7096 assert!(session.insert_device_channel_key(vec![0xC8; 32]).is_ok());
7097 }
7098
7099 fn host_channel_count(update: &UlcpSessionUpdateRecord) -> Option<u32> {
7100 update
7101 .snapshot
7102 .provisioning
7103 .as_ref()
7104 .unwrap()
7105 .host_channel_count
7106 }
7107
7108 #[test]
7109 fn host_channel_reconcile_inserts_what_the_radio_is_missing() {
7110 let session = MobileUlcpSession::new();
7111 let attached = attach_host_keys_capable(&session);
7112 assert_eq!(host_channel_count(&attached), Some(0));
7113
7114 let first = vec![0xD1; 32];
7115 let second = vec![0xD2; 32];
7116 let update = session
7117 .reconcile_host_channel_keys(vec![first.clone(), second.clone()])
7118 .unwrap();
7119
7120 assert_eq!(update.outbound_frames.len(), 1);
7122 let request = Frame::parse(&update.outbound_frames[0]).unwrap();
7123 assert_eq!(request.command(), Some(Cmd::PropInsert));
7124 let confirmed = session
7125 .consume(inserted_response(
7126 request.header.tid(),
7127 prop::HOST_CHANNEL_KEYS,
7128 &crate::derive_channel_id(first).unwrap(),
7129 ))
7130 .unwrap();
7131 assert_eq!(confirmed.outbound_frames.len(), 1);
7132 let request = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
7133 let done = session
7134 .consume(inserted_response(
7135 request.header.tid(),
7136 prop::HOST_CHANNEL_KEYS,
7137 &crate::derive_channel_id(second).unwrap(),
7138 ))
7139 .unwrap();
7140 assert!(done.outbound_frames.is_empty());
7141 assert!(!done.waiting_for_responses);
7142 assert_eq!(done.operation_error, None);
7143 assert_eq!(host_channel_count(&done), Some(2));
7144 }
7145
7146 #[test]
7147 fn host_channel_reconcile_is_a_no_op_when_the_radio_already_matches() {
7148 let session = MobileUlcpSession::new();
7149 attach_host_keys_capable(&session);
7150 let key = vec![0xD3; 32];
7151
7152 let update = session
7153 .reconcile_host_channel_keys(vec![key.clone()])
7154 .unwrap();
7155 let request = Frame::parse(&update.outbound_frames[0]).unwrap();
7156 session
7157 .consume(inserted_response(
7158 request.header.tid(),
7159 prop::HOST_CHANNEL_KEYS,
7160 &crate::derive_channel_id(key.clone()).unwrap(),
7161 ))
7162 .unwrap();
7163
7164 let again = session.reconcile_host_channel_keys(vec![key]).unwrap();
7167 assert!(again.outbound_frames.is_empty());
7168 assert!(!again.waiting_for_responses);
7169 }
7170
7171 #[test]
7172 fn host_channel_reconcile_replaces_the_table_to_shed_an_unknown_channel() {
7173 let session = MobileUlcpSession::new();
7174 attach_host_keys_capable(&session);
7175 let stranger = vec![0xD4; 32];
7176
7177 let update = session
7179 .reconcile_host_channel_keys(vec![stranger.clone()])
7180 .unwrap();
7181 let request = Frame::parse(&update.outbound_frames[0]).unwrap();
7182 session
7183 .consume(inserted_response(
7184 request.header.tid(),
7185 prop::HOST_CHANNEL_KEYS,
7186 &crate::derive_channel_id(stranger).unwrap(),
7187 ))
7188 .unwrap();
7189
7190 let mine = vec![0xD5; 32];
7193 let replace = session
7194 .reconcile_host_channel_keys(vec![mine.clone()])
7195 .unwrap();
7196 let request = Frame::parse(&replace.outbound_frames[0]).unwrap();
7197 assert_eq!(request.command(), Some(Cmd::PropSet));
7198
7199 let done = session
7200 .consume(property_response(
7201 request.header.tid(),
7202 prop::HOST_CHANNEL_KEYS,
7203 &crate::derive_channel_id(mine).unwrap(),
7204 ))
7205 .unwrap();
7206 assert_eq!(host_channel_count(&done), Some(1));
7207 assert!(!done.waiting_for_responses);
7208 }
7209
7210 #[test]
7211 fn a_full_host_channel_table_reports_status_and_stays_attached() {
7212 let session = MobileUlcpSession::new();
7213 attach_host_keys_capable(&session);
7214
7215 let update = session
7216 .reconcile_host_channel_keys(vec![vec![0xD6; 32], vec![0xD7; 32]])
7217 .unwrap();
7218 let request = Frame::parse(&update.outbound_frames[0]).unwrap();
7219 let full = session
7220 .consume(property_response(
7221 request.header.tid(),
7222 prop::LAST_STATUS,
7223 &[umsh_ulcp::Status::NOMEM.0 as u8],
7224 ))
7225 .unwrap();
7226
7227 assert_eq!(
7228 full.operation_error.as_ref().unwrap().status_name,
7229 "Status::NOMEM"
7230 );
7231 assert!(full.outbound_frames.is_empty());
7234 assert!(!full.waiting_for_responses);
7235 assert_eq!(full.snapshot.phase, UlcpSessionPhase::Attached);
7236 }
7237
7238 #[test]
7239 fn an_already_stored_host_channel_key_is_success() {
7240 let session = MobileUlcpSession::new();
7241 attach_host_keys_capable(&session);
7242
7243 let update = session
7244 .reconcile_host_channel_keys(vec![vec![0xD8; 32]])
7245 .unwrap();
7246 let request = Frame::parse(&update.outbound_frames[0]).unwrap();
7247 let already = session
7248 .consume(property_response(
7249 request.header.tid(),
7250 prop::LAST_STATUS,
7251 &[umsh_ulcp::Status::ALREADY.0 as u8],
7252 ))
7253 .unwrap();
7254 assert_eq!(already.operation_error, None);
7255 assert!(!already.waiting_for_responses);
7256 }
7257
7258 #[test]
7259 fn device_channel_keys_must_be_full_length() {
7260 let session = MobileUlcpSession::new();
7261 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
7262 assert_eq!(
7263 session.insert_device_channel_key(vec![0x01; 31]),
7264 Err(MobileError::InvalidChannelKeyLength)
7265 );
7266 assert_eq!(
7267 session.remove_device_channel_key(Vec::new()),
7268 Err(MobileError::InvalidChannelKeyLength)
7269 );
7270 }
7271
7272 #[test]
7273 fn device_peer_operations_require_the_device_identity_capability() {
7274 let session = MobileUlcpSession::new();
7275 let begin = session.begin(None).unwrap();
7276 let inspection =
7277 answer_requests(&session, begin.outbound_frames, |property| match property {
7278 prop::LAST_STATUS => (property, vec![0]),
7279 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
7280 prop::CAPS => (property, vec![cap::WRITABLE_RAW_STREAM as u8]),
7281 prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY => (property, Vec::new()),
7282 prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
7283 _ => unreachable!(),
7284 });
7285 let attached =
7286 answer_requests(
7287 &session,
7288 inspection.outbound_frames,
7289 |property| match property {
7290 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
7291 prop::PHY_ENABLED => (property, vec![1]),
7292 prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
7293 prop::PHY_TX_POWER => (property, vec![14]),
7294 _ => unreachable!(),
7295 },
7296 );
7297 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
7298 assert_eq!(
7299 session.insert_device_peer(vec![0xC1; 32]).unwrap_err(),
7300 MobileError::InvalidUlcpFrame
7301 );
7302 assert_eq!(
7303 session.remove_device_peer(vec![0xC1; 32]).unwrap_err(),
7304 MobileError::InvalidUlcpFrame
7305 );
7306 assert_eq!(
7308 session.insert_device_peer(vec![0xC1; 31]).unwrap_err(),
7309 MobileError::InvalidPublicKeyLength
7310 );
7311 }
7312
7313 fn attached_battery_session() -> std::sync::Arc<MobileUlcpSession> {
7316 let session = MobileUlcpSession::new();
7317 let begin = session.begin(None).unwrap();
7318 let inspection =
7319 answer_requests(&session, begin.outbound_frames, |property| match property {
7320 prop::LAST_STATUS => (property, vec![0]),
7321 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
7322 prop::CAPS => (property, encoded_capabilities(&[cap::BATTERY])),
7323 prop::BATTERY => (property, vec![0b111, 0x74, 0x0E, 60, 0]),
7325 prop::DEV_KEY | prop::DEV_NAME => (property, Vec::new()),
7326 prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
7327 _ => unreachable!(),
7328 });
7329 let attached =
7330 answer_requests(
7331 &session,
7332 inspection.outbound_frames,
7333 |property| match property {
7334 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
7335 prop::PHY_ENABLED => (property, vec![1]),
7336 prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
7337 prop::PHY_TX_POWER => (property, vec![14]),
7338 _ => unreachable!(),
7339 },
7340 );
7341 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
7342 session
7343 }
7344
7345 #[test]
7346 fn battery_is_reported_once_per_measurement_not_on_every_update() {
7347 let session = attached_battery_session();
7348
7349 let pushed = session
7352 .consume(property_response(
7353 frame::TID_UNSOLICITED,
7354 prop::BATTERY,
7355 &[0b111, 0x10, 0x10, 45, 1],
7356 ))
7357 .unwrap();
7358 let battery = pushed.snapshot.battery.expect("push carries the snapshot");
7359 assert_eq!(battery.percentage, Some(45));
7360 assert_eq!(battery.voltage_mv, Some(0x1010));
7361 assert_eq!(battery.charge_state, Some(UlcpChargeState::Charging));
7362
7363 let unrelated = session
7367 .consume(property_response(
7368 frame::TID_UNSOLICITED,
7369 prop::DEV_NAME,
7370 b"Ridge repeater",
7371 ))
7372 .unwrap();
7373 assert!(unrelated.snapshot.battery.is_none());
7374 assert_eq!(
7375 unrelated.snapshot.device_name.as_deref(),
7376 Some("Ridge repeater"),
7377 "unrelated state still propagates"
7378 );
7379
7380 let again = session
7382 .consume(property_response(
7383 frame::TID_UNSOLICITED,
7384 prop::BATTERY,
7385 &[0b111, 0x20, 0x10, 50, 1],
7386 ))
7387 .unwrap();
7388 assert_eq!(
7389 again.snapshot.battery.expect("second push").percentage,
7390 Some(50)
7391 );
7392 }
7393
7394 fn attached_alert_session() -> std::sync::Arc<MobileUlcpSession> {
7396 let session = MobileUlcpSession::new();
7397 let begin = session.begin(None).unwrap();
7398 let inspection =
7399 answer_requests(&session, begin.outbound_frames, |property| match property {
7400 prop::LAST_STATUS => (property, vec![0]),
7401 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
7402 prop::CAPS => (property, encoded_capabilities(&[cap::ALERT])),
7403 prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY => (property, Vec::new()),
7404 prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
7405 _ => unreachable!(),
7406 });
7407 let attached =
7408 answer_requests(
7409 &session,
7410 inspection.outbound_frames,
7411 |property| match property {
7412 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
7413 prop::PHY_ENABLED => (property, vec![1]),
7414 prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
7415 prop::PHY_TX_POWER => (property, vec![14]),
7416 prop::ALERT => (property, vec![0]),
7417 _ => unreachable!(),
7418 },
7419 );
7420 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
7421 assert_eq!(attached.snapshot.alert, Some(UlcpAlertState::None));
7422 session
7423 }
7424
7425 #[test]
7426 fn alert_state_follows_the_radio_not_the_request() {
7427 let session = attached_alert_session();
7428
7429 let request = session.set_alert(UlcpAlertState::Locate).unwrap();
7430 let [frame] = &request.outbound_frames[..] else {
7431 panic!("one CMD_PROP_SET");
7432 };
7433 let parsed = Frame::parse(frame).unwrap();
7434 assert_eq!(parsed.command(), Some(Cmd::PropSet));
7435 let payload = PropPayload::parse(parsed.payload).unwrap();
7436 assert_eq!((payload.key, payload.value), (prop::ALERT, &[1u8][..]));
7437
7438 let started = session
7439 .consume(property_response(parsed.header.tid(), prop::ALERT, &[1]))
7440 .unwrap();
7441 assert_eq!(started.snapshot.alert, Some(UlcpAlertState::Locate));
7442
7443 let unrelated = session
7446 .consume(property_response(
7447 frame::TID_UNSOLICITED,
7448 prop::DEV_NAME,
7449 b"Ridge repeater",
7450 ))
7451 .unwrap();
7452 assert_eq!(unrelated.snapshot.alert, Some(UlcpAlertState::Locate));
7453
7454 let cancelled = session
7457 .consume(property_response(frame::TID_UNSOLICITED, prop::ALERT, &[0]))
7458 .unwrap();
7459 assert_eq!(cancelled.snapshot.alert, Some(UlcpAlertState::None));
7460 assert_eq!(cancelled.snapshot.phase, UlcpSessionPhase::Attached);
7461 }
7462
7463 #[test]
7464 fn alert_needs_the_capability() {
7465 let session = attached_battery_session();
7466 assert_eq!(
7467 session.set_alert(UlcpAlertState::Locate).unwrap_err(),
7468 MobileError::UnsupportedCapability
7469 );
7470 let update = session
7473 .consume(property_response(
7474 frame::TID_UNSOLICITED,
7475 prop::BATTERY,
7476 &[0b111, 0x10, 0x10, 45, 1],
7477 ))
7478 .unwrap();
7479 assert_eq!(update.snapshot.alert, None);
7480 }
7481
7482 #[test]
7483 fn malformed_alert_values_are_rejected() {
7484 assert_eq!(inspect_ulcp_alert(vec![0]).unwrap(), UlcpAlertState::None);
7485 assert_eq!(inspect_ulcp_alert(vec![1]).unwrap(), UlcpAlertState::Locate);
7486 assert!(inspect_ulcp_alert(vec![2]).is_err());
7488 assert!(inspect_ulcp_alert(vec![1, 0]).is_err());
7489 assert!(inspect_ulcp_alert(Vec::new()).is_err());
7490 }
7491
7492 fn attach_positioning(session: &MobileUlcpSession) -> UlcpSessionUpdateRecord {
7495 let mut capabilities = commissionable_capabilities();
7496 capabilities.extend([cap::TIME, cap::GNSS]);
7497 let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
7498 drive_reads(
7499 session,
7500 begin.outbound_frames,
7501 move |property| match property {
7502 prop::CAPS => (property, encoded_capabilities(&capabilities)),
7503 prop::HOST_KEY => (property, vec![0xAA; 32]),
7504 prop::TIME => (property, 1_754_000_000u32.to_le_bytes().to_vec()),
7505 prop::TZ_OFFSET => (property, (-420i16).to_le_bytes().to_vec()),
7508 prop::GNSS_ENABLED | prop::GNSS_IDENT_UPDATE | prop::GNSS_TIME_TRUST => {
7509 (property, vec![1])
7510 }
7511 prop::GNSS_LOCATION => (property, placed_location().as_bytes().to_vec()),
7512 prop::GNSS_ALTITUDE => (property, 71i32.to_le_bytes().to_vec()),
7513 prop::GNSS_FIX => (property, vec![2]),
7514 prop::GNSS_PRECISION => (property, 62u16.to_le_bytes().to_vec()),
7515 prop::GNSS_SATELLITES => (property, vec![9, 14]),
7516 prop::GNSS_IDENT_PRECISION => (property, vec![5]),
7517 _ => commissionable_value(property),
7518 },
7519 )
7520 }
7521
7522 fn attach_advertising(session: &MobileUlcpSession) -> UlcpSessionUpdateRecord {
7523 let mut capabilities = commissionable_capabilities();
7524 capabilities.push(cap::ADVERT);
7525 let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
7526 drive_reads(
7527 session,
7528 begin.outbound_frames,
7529 move |property| match property {
7530 prop::CAPS => (property, encoded_capabilities(&capabilities)),
7531 prop::HOST_KEY => (property, vec![0xAA; 32]),
7532 prop::ADVERT_INTERVAL => (property, 14_400u32.to_le_bytes().to_vec()),
7533 prop::BEACON_INTERVAL => (property, 3_600u32.to_le_bytes().to_vec()),
7534 prop::STARTUP_BEACON => (property, vec![1]),
7535 _ => commissionable_value(property),
7536 },
7537 )
7538 }
7539
7540 #[test]
7541 fn advertisement_policy_folds_into_the_sync_record() {
7542 let session = MobileUlcpSession::administrative();
7543 let update = attach_advertising(&session);
7544 let sync = update.snapshot.provisioning.expect("device described");
7545
7546 assert!(sync.supports_advert);
7547 assert_eq!(
7548 sync.advert,
7549 Some(UlcpAdvertSettingsRecord {
7550 advert_interval_seconds: 14_400,
7551 beacon_interval_seconds: 3_600,
7552 startup_beacon: true,
7553 })
7554 );
7555 }
7556
7557 #[test]
7561 fn the_advertised_position_folds_into_the_sync_record() {
7562 let cell = NodeLocation::from_lat_lon(37.5119, -122.2495, 4);
7563 let session = MobileUlcpSession::administrative();
7564 let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
7565 let bytes = cell.as_bytes().to_vec();
7566 let update = drive_reads(
7567 &session,
7568 begin.outbound_frames,
7569 move |property| match property {
7570 prop::HOST_KEY => (property, vec![0xAA; 32]),
7571 prop::IDENT_LOCATION => (property, bytes.clone()),
7572 prop::IDENT_ALTITUDE => (property, vec![0x64]),
7573 _ => commissionable_value(property),
7574 },
7575 );
7576 let sync = update.snapshot.provisioning.expect("device described");
7577 let position = sync.ident_position.expect("position reported");
7578
7579 assert_eq!(position.location, cell.as_bytes());
7580 assert!((position.latitude_deg.unwrap() - 37.5119).abs() < 0.01);
7581 assert!((position.longitude_deg.unwrap() + 122.2495).abs() < 0.01);
7582 assert!((610.0..613.0).contains(&position.cell_meters.unwrap()));
7583 assert_eq!(position.altitude_m, Some(100));
7584 }
7585
7586 #[test]
7589 fn an_unplaced_device_reports_an_empty_cell() {
7590 let session = MobileUlcpSession::administrative();
7591 let update = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
7592 let sync = update.snapshot.provisioning.expect("device described");
7593 let position = sync.ident_position.expect("position reported");
7594
7595 assert!(position.location.is_empty());
7596 assert_eq!(position.latitude_deg, None);
7597 assert_eq!(position.cell_meters, None);
7598 assert_eq!(position.altitude_m, None);
7599 }
7600
7601 #[test]
7604 fn a_device_without_the_capability_reports_no_advertisement_policy() {
7605 let session = MobileUlcpSession::administrative();
7606 let update = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
7607 let sync = update.snapshot.provisioning.expect("device described");
7608
7609 assert!(!sync.supports_advert);
7610 assert_eq!(sync.advert, None);
7611 }
7612
7613 #[test]
7614 fn configure_advertising_writes_the_whole_schedule() {
7615 let session = MobileUlcpSession::administrative();
7616 attach_advertising(&session);
7617
7618 let configured = session
7619 .configure_advertising(Some(UlcpAdvertSettingsRecord {
7620 advert_interval_seconds: 0,
7621 beacon_interval_seconds: 1_800,
7622 startup_beacon: false,
7623 }))
7624 .unwrap();
7625 let (written, _, _) = drive_configuration(&session, configured.outbound_frames);
7626 assert_eq!(
7627 written.get(&prop::ADVERT_INTERVAL).map(Vec::as_slice),
7628 Some(&0u32.to_le_bytes()[..])
7629 );
7630 assert_eq!(
7631 written.get(&prop::BEACON_INTERVAL).map(Vec::as_slice),
7632 Some(&1_800u32.to_le_bytes()[..])
7633 );
7634 assert_eq!(
7635 written.get(&prop::STARTUP_BEACON).map(Vec::as_slice),
7636 Some(&[0u8][..])
7637 );
7638 }
7639
7640 #[test]
7644 fn an_advertisement_record_must_match_what_the_device_can_do() {
7645 let session = MobileUlcpSession::administrative();
7646 attach_advertising(&session);
7647 let whole = UlcpAdvertSettingsRecord {
7648 advert_interval_seconds: 14_400,
7649 beacon_interval_seconds: 3_600,
7650 startup_beacon: true,
7651 };
7652
7653 assert_eq!(
7655 session.configure_advertising(None),
7656 Err(MobileError::InvalidUlcpFrame)
7657 );
7658 for out_of_range in [
7659 MIN_AUTO_ANNOUNCE_INTERVAL_S - 1,
7660 MAX_AUTO_ANNOUNCE_INTERVAL_S + 1,
7661 ] {
7662 assert_eq!(
7663 session.configure_advertising(Some(UlcpAdvertSettingsRecord {
7664 beacon_interval_seconds: out_of_range,
7665 ..whole
7666 })),
7667 Err(MobileError::InvalidUlcpFrame)
7668 );
7669 }
7670 assert!(
7672 session
7673 .configure_advertising(Some(UlcpAdvertSettingsRecord {
7674 beacon_interval_seconds: 0,
7675 ..whole
7676 }))
7677 .is_ok()
7678 );
7679 }
7680
7681 #[test]
7684 fn an_advertisement_record_is_refused_without_the_capability() {
7685 let session = MobileUlcpSession::administrative();
7686 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
7687 assert_eq!(
7688 session.configure_advertising(Some(UlcpAdvertSettingsRecord {
7689 advert_interval_seconds: 14_400,
7690 beacon_interval_seconds: 3_600,
7691 startup_beacon: true,
7692 })),
7693 Err(MobileError::InvalidUlcpFrame)
7694 );
7695 }
7696
7697 fn placed_location() -> NodeLocation {
7699 NodeLocation::from_e7(377_749_290, -1_224_194_160, 5)
7700 }
7701
7702 #[test]
7703 fn a_positioning_device_reports_its_fix_and_its_policy() {
7704 let session = MobileUlcpSession::new();
7705 let attached = attach_positioning(&session);
7706 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
7707
7708 let sync = attached.snapshot.provisioning.clone().expect("described");
7709 assert!(sync.supports_time && sync.supports_gnss);
7710 assert_eq!(sync.tz_offset_min, Some(-420));
7711 assert_eq!(
7712 sync.gnss,
7713 Some(UlcpGnssSettingsRecord {
7714 enabled: true,
7715 ident_update: true,
7716 ident_precision: 5,
7717 time_trust: true,
7718 })
7719 );
7720 assert!(sync.unreadable_properties.is_empty());
7721
7722 let gnss = attached.snapshot.gnss.expect("a receiver was read");
7723 assert_eq!(gnss.fix, UlcpFixKind::ThreeD);
7724 assert_eq!(gnss.altitude_m, Some(71));
7725 assert_eq!(gnss.accuracy_dm, Some(62));
7726 assert_eq!(
7727 (gnss.satellites_used, gnss.satellites_in_view),
7728 (9, Some(14))
7729 );
7730 assert_eq!(gnss.location, placed_location().as_bytes());
7731 let (latitude, longitude) = (gnss.latitude_deg.unwrap(), gnss.longitude_deg.unwrap());
7734 assert!((latitude - 37.774_929).abs() < 5e-4, "{latitude}");
7735 assert!((longitude + 122.419_416).abs() < 5e-4, "{longitude}");
7736 assert!((38.0..39.0).contains(&gnss.location_cell_meters.unwrap()));
7737 }
7738
7739 #[test]
7740 fn the_clock_is_reported_once_and_the_fix_is_mirrored() {
7741 let session = MobileUlcpSession::new();
7742 attach_positioning(&session);
7743
7744 let unrelated = session
7748 .consume(property_response(
7749 frame::TID_UNSOLICITED,
7750 prop::DEV_NAME,
7751 b"Ridge repeater",
7752 ))
7753 .unwrap();
7754 assert_eq!(unrelated.snapshot.time, None);
7755 assert_eq!(
7756 unrelated.snapshot.gnss.expect("still known").fix,
7757 UlcpFixKind::ThreeD
7758 );
7759
7760 let lost = session
7764 .consume(property_response(
7765 frame::TID_UNSOLICITED,
7766 prop::GNSS_FIX,
7767 &[1],
7768 ))
7769 .unwrap();
7770 let gnss = lost.snapshot.gnss.expect("still known");
7771 assert_eq!(gnss.fix, UlcpFixKind::TwoD);
7772 assert_eq!(gnss.satellites_used, 9);
7773 assert_eq!(gnss.altitude_m, Some(71));
7774
7775 let stepped = session
7778 .consume(property_response(
7779 frame::TID_UNSOLICITED,
7780 prop::TIME,
7781 &1_754_000_600u32.to_le_bytes(),
7782 ))
7783 .unwrap();
7784 assert_eq!(
7785 stepped.snapshot.time,
7786 Some(UlcpTimeRecord {
7787 epoch_seconds: Some(1_754_000_600)
7788 })
7789 );
7790 assert_eq!(stepped.snapshot.phase, UlcpSessionPhase::Attached);
7791 }
7792
7793 #[test]
7794 fn sampling_a_position_asks_for_the_position_and_nothing_else() {
7795 let session = MobileUlcpSession::new();
7796 attach_positioning(&session);
7797
7798 let poll = session.refresh_positioning().unwrap();
7802 let asked: Vec<u32> = poll
7803 .outbound_frames
7804 .iter()
7805 .map(|frame| {
7806 let parsed = Frame::parse(frame).unwrap();
7807 PropPayload::parse(parsed.payload).unwrap().key
7808 })
7809 .collect();
7810 assert_eq!(
7811 asked,
7812 vec![
7813 prop::GNSS_LOCATION,
7814 prop::GNSS_ALTITUDE,
7815 prop::GNSS_FIX,
7816 prop::GNSS_PRECISION,
7817 prop::GNSS_SATELLITES,
7818 ]
7819 );
7820
7821 assert!(!asked.contains(&prop::PHY_FREQ));
7825 assert!(!asked.contains(&prop::DEV_NAME));
7826
7827 let sampled = answer_requests(&session, poll.outbound_frames, |property| match property {
7830 prop::GNSS_LOCATION => (property, placed_location().as_bytes().to_vec()),
7831 prop::GNSS_ALTITUDE => (property, 88i32.to_le_bytes().to_vec()),
7832 prop::GNSS_FIX => (property, vec![2]),
7833 prop::GNSS_PRECISION => (property, 40u16.to_le_bytes().to_vec()),
7834 prop::GNSS_SATELLITES => (property, vec![11, 15]),
7835 _ => unreachable!("{property}"),
7836 });
7837 let gnss = sampled.snapshot.gnss.expect("a receiver was sampled");
7838 assert_eq!(gnss.altitude_m, Some(88));
7839 assert_eq!(gnss.satellites_used, 11);
7840 assert_eq!(sampled.snapshot.phase, UlcpSessionPhase::Attached);
7841 }
7842
7843 #[test]
7844 fn a_radio_without_a_receiver_is_never_asked_where_it_is() {
7845 let session = attached_battery_session();
7848 assert_eq!(
7849 session.refresh_positioning(),
7850 Err(MobileError::InvalidUlcpFrame)
7851 );
7852 }
7853
7854 #[test]
7855 fn setting_the_clock_writes_the_epoch_and_clearing_it_writes_nothing() {
7856 let session = MobileUlcpSession::new();
7857 attach_positioning(&session);
7858
7859 let request = session.set_time(Some(1_754_000_900)).unwrap();
7860 let [frame] = &request.outbound_frames[..] else {
7861 panic!("one CMD_PROP_SET");
7862 };
7863 let parsed = Frame::parse(frame).unwrap();
7864 let payload = PropPayload::parse(parsed.payload).unwrap();
7865 assert_eq!(payload.key, prop::TIME);
7866 assert_eq!(payload.value, &1_754_000_900u32.to_le_bytes()[..]);
7867
7868 let set = session
7872 .consume(property_response(
7873 parsed.header.tid(),
7874 prop::TIME,
7875 &1_754_000_901u32.to_le_bytes(),
7876 ))
7877 .unwrap();
7878 assert_eq!(
7879 set.snapshot.time,
7880 Some(UlcpTimeRecord {
7881 epoch_seconds: Some(1_754_000_901)
7882 })
7883 );
7884
7885 let clearing = session.set_time(None).unwrap();
7887 let parsed = Frame::parse(&clearing.outbound_frames[0]).unwrap();
7888 let payload = PropPayload::parse(parsed.payload).unwrap();
7889 assert_eq!((payload.key, payload.value), (prop::TIME, &[][..]));
7890 let cleared = session
7891 .consume(property_response(parsed.header.tid(), prop::TIME, &[]))
7892 .unwrap();
7893 assert_eq!(
7894 cleared.snapshot.time,
7895 Some(UlcpTimeRecord {
7896 epoch_seconds: None
7897 })
7898 );
7899 }
7900
7901 #[test]
7902 fn the_clock_needs_the_capability() {
7903 let session = MobileUlcpSession::new();
7904 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
7905 assert_eq!(
7906 session.set_time(Some(1_754_000_900)).unwrap_err(),
7907 MobileError::UnsupportedCapability
7908 );
7909 let sync = session
7910 .refresh()
7911 .unwrap()
7912 .snapshot
7913 .provisioning
7914 .expect("described");
7915 assert!(!sync.supports_time && !sync.supports_gnss);
7916 assert_eq!((sync.tz_offset_min, sync.gnss), (None, None));
7917 }
7918
7919 #[test]
7920 fn positioning_settings_are_written_whole_with_the_receiver_last() {
7921 let session = MobileUlcpSession::administrative();
7922 attach_positioning(&session);
7923
7924 let configured = session
7925 .configure_device(UlcpDeviceConfigRecord {
7926 radio: UlcpRadioSettingsRecord {
7927 device_name: None,
7928 phy_enabled: true,
7929 frequency_khz: 915_000,
7930 transmit_power_dbm: 14,
7931 bandwidth_hz: None,
7932 spreading_factor: None,
7933 coding_rate_denom: None,
7934 duty_cycle_limit: None,
7935 },
7936 ident_role: None,
7937 ident_mobile: Some(false),
7938 dev_discoverable: Some(true),
7939 repeater: Some(UlcpRepeaterSettingsRecord {
7940 enabled: false,
7941 regions: Vec::new(),
7942 default_region: None,
7943 min_rssi_dbm: None,
7944 min_snr_db: None,
7945 }),
7946 tz_offset_min: Some(60),
7947 gnss: Some(UlcpGnssSettingsRecord {
7948 enabled: true,
7949 ident_update: false,
7950 ident_precision: 3,
7951 time_trust: false,
7952 }),
7953 advert: None,
7954 })
7955 .unwrap();
7956 let (written, order, _) = drive_configuration(&session, configured.outbound_frames);
7957 assert_eq!(
7958 written.get(&prop::TZ_OFFSET),
7959 Some(&60i16.to_le_bytes().to_vec())
7960 );
7961 assert_eq!(written.get(&prop::GNSS_IDENT_UPDATE), Some(&vec![0]));
7962 assert_eq!(written.get(&prop::GNSS_IDENT_PRECISION), Some(&vec![3]));
7963 assert_eq!(written.get(&prop::GNSS_TIME_TRUST), Some(&vec![0]));
7964 assert_eq!(written.get(&prop::GNSS_ENABLED), Some(&vec![1]));
7965
7966 let switch = order.iter().position(|key| *key == prop::GNSS_ENABLED);
7969 for policy in [
7970 prop::GNSS_IDENT_UPDATE,
7971 prop::GNSS_IDENT_PRECISION,
7972 prop::GNSS_TIME_TRUST,
7973 ] {
7974 assert!(
7975 order.iter().position(|key| *key == policy) < switch,
7976 "{policy}"
7977 );
7978 }
7979 }
7980
7981 #[test]
7982 fn a_tethered_phone_changes_positioning_without_restating_the_domain() {
7983 let session = MobileUlcpSession::new();
7986 attach_positioning(&session);
7987
7988 let configured = session
7989 .configure_positioning(
7990 Some(UlcpGnssSettingsRecord {
7991 enabled: false,
7992 ident_update: false,
7993 ident_precision: 3,
7994 time_trust: false,
7995 }),
7996 Some(0),
7997 )
7998 .unwrap();
7999 let (written, order, save_tid) = drive_configuration(&session, configured.outbound_frames);
8000
8001 assert_eq!(
8002 written,
8003 HashMap::from([
8004 (prop::TZ_OFFSET, 0i16.to_le_bytes().to_vec()),
8005 (prop::GNSS_IDENT_UPDATE, vec![0]),
8006 (prop::GNSS_IDENT_PRECISION, vec![3]),
8007 (prop::GNSS_TIME_TRUST, vec![0]),
8008 (prop::GNSS_ENABLED, vec![0]),
8009 ]),
8010 "only the zone and the positioning policy are written"
8011 );
8012 assert_eq!(order.last(), Some(&prop::GNSS_ENABLED));
8013
8014 let attached = session
8017 .consume(property_response(save_tid, prop::LAST_STATUS, &[0]))
8018 .unwrap();
8019 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
8020 assert_eq!(
8021 attached.snapshot.provisioning.unwrap().gnss,
8022 Some(UlcpGnssSettingsRecord {
8023 enabled: false,
8024 ident_update: false,
8025 ident_precision: 3,
8026 time_trust: false,
8027 })
8028 );
8029 }
8030
8031 #[test]
8032 fn positioning_on_its_own_still_matches_the_capabilities() {
8033 let session = MobileUlcpSession::new();
8034 attach_positioning(&session);
8035 let whole = UlcpGnssSettingsRecord {
8036 enabled: true,
8037 ident_update: false,
8038 ident_precision: 5,
8039 time_trust: true,
8040 };
8041 assert_eq!(
8043 session.configure_positioning(Some(whole), None),
8044 Err(MobileError::InvalidUlcpFrame)
8045 );
8046 assert_eq!(
8047 session.configure_positioning(None, Some(0)),
8048 Err(MobileError::InvalidUlcpFrame)
8049 );
8050
8051 let plain = MobileUlcpSession::new();
8054 attach_commissionable(&plain, Some(vec![0xAA; 32]), vec![0xAA; 32]);
8055 assert_eq!(
8056 plain.configure_positioning(None, None),
8057 Err(MobileError::UnsupportedCapability)
8058 );
8059 }
8060
8061 #[test]
8062 fn a_positioning_record_must_match_what_the_device_can_do() {
8063 let session = MobileUlcpSession::administrative();
8064 attach_positioning(&session);
8065
8066 let whole = UlcpDeviceConfigRecord {
8067 radio: UlcpRadioSettingsRecord {
8068 device_name: None,
8069 phy_enabled: true,
8070 frequency_khz: 915_000,
8071 transmit_power_dbm: 14,
8072 bandwidth_hz: None,
8073 spreading_factor: None,
8074 coding_rate_denom: None,
8075 duty_cycle_limit: None,
8076 },
8077 ident_role: None,
8078 ident_mobile: Some(false),
8079 dev_discoverable: Some(true),
8080 repeater: Some(UlcpRepeaterSettingsRecord {
8081 enabled: false,
8082 regions: Vec::new(),
8083 default_region: None,
8084 min_rssi_dbm: None,
8085 min_snr_db: None,
8086 }),
8087 tz_offset_min: Some(0),
8088 gnss: Some(UlcpGnssSettingsRecord {
8089 enabled: true,
8090 ident_update: false,
8091 ident_precision: 5,
8092 time_trust: true,
8093 }),
8094 advert: None,
8095 };
8096
8097 assert_eq!(
8099 session.configure_device(UlcpDeviceConfigRecord {
8100 tz_offset_min: None,
8101 ..whole.clone()
8102 }),
8103 Err(MobileError::InvalidUlcpFrame)
8104 );
8105 assert_eq!(
8106 session.configure_device(UlcpDeviceConfigRecord {
8107 gnss: None,
8108 ..whole.clone()
8109 }),
8110 Err(MobileError::InvalidUlcpFrame)
8111 );
8112 assert_eq!(
8114 session.configure_device(UlcpDeviceConfigRecord {
8115 gnss: Some(UlcpGnssSettingsRecord {
8116 ident_precision: 8,
8117 ..whole.gnss.unwrap()
8118 }),
8119 ..whole.clone()
8120 }),
8121 Err(MobileError::InvalidUlcpFrame)
8122 );
8123 assert_eq!(
8125 session.configure_device(UlcpDeviceConfigRecord {
8126 tz_offset_min: Some(15 * 60),
8127 ..whole.clone()
8128 }),
8129 Err(MobileError::InvalidUlcpFrame)
8130 );
8131 }
8132
8133 #[test]
8134 fn half_a_positioning_policy_is_withdrawn_whole() {
8135 let session = MobileUlcpSession::administrative();
8136 let mut capabilities = commissionable_capabilities();
8137 capabilities.extend([cap::TIME, cap::GNSS]);
8138 let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
8139 let attached = drive_reads(
8140 &session,
8141 begin.outbound_frames,
8142 move |property| match property {
8143 prop::CAPS => (property, encoded_capabilities(&capabilities)),
8144 prop::HOST_KEY => (property, vec![0xAA; 32]),
8145 prop::GNSS_TIME_TRUST => (
8147 prop::LAST_STATUS,
8148 vec![umsh_ulcp::Status::PROP_NOT_FOUND.0 as u8],
8149 ),
8150 prop::TIME => (property, Vec::new()),
8151 prop::TZ_OFFSET => (property, 0i16.to_le_bytes().to_vec()),
8152 prop::GNSS_ENABLED | prop::GNSS_IDENT_UPDATE => (property, vec![0]),
8153 prop::GNSS_IDENT_PRECISION => (property, vec![5]),
8154 prop::GNSS_LOCATION | prop::GNSS_ALTITUDE | prop::GNSS_PRECISION => {
8155 (property, Vec::new())
8156 }
8157 prop::GNSS_FIX => (property, vec![0]),
8158 prop::GNSS_SATELLITES => (property, vec![0]),
8159 _ => commissionable_value(property),
8160 },
8161 );
8162
8163 let sync = attached.snapshot.provisioning.clone().expect("described");
8164 assert!(sync.supports_gnss);
8165 assert_eq!(sync.gnss, None);
8166 assert_eq!(sync.unreadable_properties, vec![prop::GNSS_TIME_TRUST]);
8167 let gnss = attached.snapshot.gnss.expect("the receiver was read");
8170 assert_eq!(gnss.fix, UlcpFixKind::None);
8171 assert!(gnss.location.is_empty());
8172 assert_eq!(gnss.latitude_deg, None);
8173
8174 let configured = session
8177 .configure_device(UlcpDeviceConfigRecord {
8178 radio: UlcpRadioSettingsRecord {
8179 device_name: None,
8180 phy_enabled: true,
8181 frequency_khz: 915_000,
8182 transmit_power_dbm: 14,
8183 bandwidth_hz: None,
8184 spreading_factor: None,
8185 coding_rate_denom: None,
8186 duty_cycle_limit: None,
8187 },
8188 ident_role: None,
8189 ident_mobile: Some(false),
8190 dev_discoverable: Some(true),
8191 repeater: Some(UlcpRepeaterSettingsRecord {
8192 enabled: false,
8193 regions: Vec::new(),
8194 default_region: None,
8195 min_rssi_dbm: None,
8196 min_snr_db: None,
8197 }),
8198 tz_offset_min: Some(0),
8199 gnss: Some(UlcpGnssSettingsRecord {
8200 enabled: true,
8201 ident_update: true,
8202 ident_precision: 5,
8203 time_trust: true,
8204 }),
8205 advert: None,
8206 })
8207 .unwrap();
8208 let (written, _, _) = drive_configuration(&session, configured.outbound_frames);
8209 for property in [
8210 prop::GNSS_ENABLED,
8211 prop::GNSS_IDENT_UPDATE,
8212 prop::GNSS_IDENT_PRECISION,
8213 prop::GNSS_TIME_TRUST,
8214 ] {
8215 assert!(!written.contains_key(&property), "property {property}");
8216 }
8217 assert_eq!(
8219 written.get(&prop::TZ_OFFSET),
8220 Some(&0i16.to_le_bytes().to_vec())
8221 );
8222 }
8223
8224 #[test]
8225 fn a_receiver_without_a_clock_is_not_a_device_this_phone_believes() {
8226 assert_eq!(
8230 ulcp_inspection_properties(encoded_capabilities(&[cap::GNSS])),
8231 Err(MobileError::InvalidUlcpFrame)
8232 );
8233
8234 let asked =
8238 ulcp_inspection_properties(encoded_capabilities(&[cap::TIME, cap::GNSS])).unwrap();
8239 for property in [
8240 prop::TIME,
8241 prop::TZ_OFFSET,
8242 prop::GNSS_ENABLED,
8243 prop::GNSS_LOCATION,
8244 prop::GNSS_FIX,
8245 prop::GNSS_SATELLITES,
8246 prop::GNSS_TIME_TRUST,
8247 ] {
8248 assert!(asked.contains(&property), "property {property}");
8249 }
8250 let clock_only = ulcp_inspection_properties(encoded_capabilities(&[cap::TIME])).unwrap();
8252 assert!(clock_only.contains(&prop::TIME));
8253 assert!(!clock_only.contains(&prop::GNSS_ENABLED));
8254 }
8255
8256 #[test]
8257 fn a_precision_outside_the_encoding_names_no_cell() {
8258 assert_eq!(ulcp_location_cell_meters(0), None);
8259 assert_eq!(ulcp_location_cell_meters(8), None);
8260 let five = ulcp_location_cell_meters(5).unwrap();
8262 assert!((38.0..39.0).contains(&five), "{five}");
8263 }
8264
8265 #[test]
8266 fn battery_push_does_not_move_an_attached_session_out_of_phase() {
8267 let session = attached_battery_session();
8268 let pushed = session
8269 .consume(property_response(
8270 frame::TID_UNSOLICITED,
8271 prop::BATTERY,
8272 &[0b111, 0x10, 0x10, 45, 1],
8273 ))
8274 .unwrap();
8275 assert_eq!(pushed.snapshot.phase, UlcpSessionPhase::Attached);
8276 assert!(!pushed.waiting_for_responses);
8277 assert!(pushed.outbound_frames.is_empty());
8278 }
8279
8280 #[test]
8281 fn battery_push_to_a_tethered_session_on_another_phones_radio_stays_attached() {
8282 let session = MobileUlcpSession::new();
8290 let ours = vec![0x11; 32];
8291 let theirs = vec![0x22; 32];
8292 let begin = session.begin(Some(ours.clone())).unwrap();
8293 let synchronized =
8294 answer_requests(&session, begin.outbound_frames, |property| match property {
8295 prop::LAST_STATUS => (property, vec![0]),
8296 prop::PROTOCOL_VERSION => (property, vec![6, 0]),
8297 prop::CAPS => (
8300 property,
8301 encoded_capabilities(&[cap::BATTERY, cap::HOST_FILTER]),
8302 ),
8303 prop::BATTERY => (property, vec![0b111, 0x74, 0x0E, 60, 0]),
8304 prop::DEV_KEY | prop::DEV_NAME => (property, Vec::new()),
8305 prop::HOST_KEY => (property, theirs.clone()),
8307 _ => unreachable!(),
8308 });
8309 assert_eq!(
8310 synchronized.snapshot.host_ownership,
8311 UlcpHostOwnership::OtherHost
8312 );
8313 assert_eq!(synchronized.snapshot.phase, UlcpSessionPhase::AwaitingHost);
8314
8315 let claim = session.claim(ours).unwrap();
8319 let claim_tid = Frame::parse(&claim.outbound_frames[0])
8320 .unwrap()
8321 .header
8322 .tid();
8323 let claimed = session
8324 .consume(property_response(claim_tid, prop::HOST_KEY, &theirs))
8325 .unwrap();
8326 let attached = answer_requests(
8327 &session,
8328 claimed.outbound_frames,
8329 |property| match property {
8330 prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
8331 prop::PHY_ENABLED => (property, vec![1]),
8332 prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
8333 prop::PHY_TX_POWER => (property, vec![14]),
8334 prop::HOST_RX_FILTERS => (property, Vec::new()),
8335 _ => unreachable!(),
8336 },
8337 );
8338 assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
8339 assert_eq!(
8340 attached.snapshot.host_ownership,
8341 UlcpHostOwnership::OtherHost,
8342 "the claim did not take"
8343 );
8344
8345 let pushed = session
8346 .consume(property_response(
8347 frame::TID_UNSOLICITED,
8348 prop::BATTERY,
8349 &[0b111, 0x10, 0x10, 45, 1],
8350 ))
8351 .unwrap();
8352 assert_eq!(
8353 pushed.snapshot.battery.expect("push carries it").percentage,
8354 Some(45)
8355 );
8356 assert_eq!(
8357 pushed.snapshot.phase,
8358 UlcpSessionPhase::Attached,
8359 "a battery report is not an attach decision"
8360 );
8361
8362 let reclaimed = session
8365 .consume(property_response(
8366 frame::TID_UNSOLICITED,
8367 prop::HOST_KEY,
8368 &[0x33; 32],
8369 ))
8370 .unwrap();
8371 assert_eq!(
8372 reclaimed.snapshot.phase,
8373 UlcpSessionPhase::AwaitingHost,
8374 "a third phone taking the radio is the user's call"
8375 );
8376 }
8377
8378 fn managed_capabilities() -> Vec<u8> {
8382 encoded_capabilities(&[
8383 cap::DEV_NAME,
8384 cap::BATTERY,
8385 cap::PHY_LORA,
8386 cap::PHY_DUTY_LIMIT,
8387 cap::REPEATER,
8388 cap::IDENT,
8389 cap::DEV_IDENTITY,
8390 cap::GNSS,
8391 cap::TIME,
8392 cap::ADVERT,
8393 cap::ADMIN,
8394 cap::ALERT,
8395 cap::BLE,
8396 cap::STATS,
8397 cap::SAVE,
8398 cap::CMD_MULTI,
8399 ])
8400 }
8401
8402 #[test]
8406 fn the_bluetooth_screen_asks_for_everything_and_lets_the_device_refuse() {
8407 let expected = vec![
8408 prop::BLE_ENABLED,
8409 prop::BLE_BOND_COUNT,
8410 prop::BLE_LINK,
8411 prop::BLE_PAIRING,
8412 ];
8413 assert_eq!(
8414 ulcp_category_properties(UlcpManageCategory::Bluetooth, managed_capabilities())
8415 .unwrap(),
8416 expected
8417 );
8418 assert_eq!(
8419 ulcp_category_properties(
8420 UlcpManageCategory::Bluetooth,
8421 encoded_capabilities(&[cap::BLE])
8422 )
8423 .unwrap(),
8424 expected,
8425 "CAP_BLE alone asks the same questions; the answers differ"
8426 );
8427 assert!(
8428 ulcp_category_properties(UlcpManageCategory::Bluetooth, encoded_capabilities(&[]))
8429 .unwrap()
8430 .is_empty(),
8431 "a device with no Bluetooth has no screen at all"
8432 );
8433 }
8434
8435 #[test]
8439 fn the_bond_count_is_read_but_never_written() {
8440 let record = inspect_ulcp_properties(vec![
8441 response(prop::BLE_ENABLED, &[1]),
8442 response(prop::BLE_BOND_COUNT, &[3]),
8443 response(prop::BLE_LINK, &[2]),
8444 response(prop::BLE_PAIRING, &[1]),
8445 ]);
8446 assert_eq!(record.ble_enabled, Some(true));
8447 assert_eq!(record.ble_bond_count, Some(3));
8448 assert_eq!(record.ble_link, Some(2));
8449 assert_eq!(record.ble_pairing, Some(true));
8450
8451 let partial = inspect_ulcp_properties(vec![response(prop::BLE_ENABLED, &[1])]);
8455 assert_eq!(partial.ble_bond_count, None);
8456 assert_eq!(partial.ble_link, None);
8457
8458 assert_eq!(
8459 dirty(
8460 UlcpDevicePropertiesRecord {
8461 ble_enabled: Some(false),
8462 ble_bond_count: Some(3),
8463 ..Default::default()
8464 },
8465 &[prop::BLE_ENABLED],
8466 )
8467 .unwrap(),
8468 vec![(prop::BLE_ENABLED, vec![0])]
8469 );
8470 assert_eq!(
8471 dirty(
8472 UlcpDevicePropertiesRecord {
8473 ble_pairing: Some(false),
8474 ..Default::default()
8475 },
8476 &[prop::BLE_PAIRING],
8477 )
8478 .unwrap(),
8479 vec![(prop::BLE_PAIRING, vec![0])],
8480 "the window is a toggle, closable from the same switch that opens it"
8481 );
8482 assert!(
8483 dirty(
8484 UlcpDevicePropertiesRecord {
8485 ble_bond_count: Some(0),
8486 ..Default::default()
8487 },
8488 &[prop::BLE_BOND_COUNT],
8489 )
8490 .is_err(),
8491 "the count is not a way to forget a host"
8492 );
8493 }
8494
8495 #[test]
8496 fn a_category_asks_only_for_its_own_screen() {
8497 let caps = managed_capabilities();
8498 let radio = ulcp_category_properties(UlcpManageCategory::Radio, caps.clone()).unwrap();
8499 assert_eq!(
8500 radio,
8501 vec![
8502 prop::PHY_ENABLED,
8503 prop::PHY_FREQ,
8504 prop::PHY_TX_POWER,
8505 prop::PHY_LORA_BW,
8506 prop::PHY_LORA_SF,
8507 prop::PHY_LORA_CR,
8508 prop::PHY_DUTY_NOW,
8509 prop::PHY_DUTY_LIMIT,
8510 ]
8511 );
8512 assert_eq!(
8513 ulcp_category_properties(UlcpManageCategory::Power, caps).unwrap(),
8514 vec![prop::BATTERY],
8515 "one property is the whole of a power screen"
8516 );
8517 }
8518
8519 #[test]
8520 fn the_statistics_screen_is_capability_gated_and_resets_with_zeroes() {
8521 let full = ulcp_category_properties(UlcpManageCategory::Statistics, managed_capabilities())
8522 .unwrap();
8523 assert_eq!(
8524 full,
8525 vec![
8526 prop::STAT_TX_PACKETS,
8527 prop::STAT_TX_CHANNEL_BUSY,
8528 prop::STAT_RX_PACKETS,
8529 prop::STAT_RX_BAD_CRC,
8530 prop::STAT_RX_NON_UMSH,
8531 prop::STAT_RX_ACCEPTED,
8532 prop::PHY_DUTY_NOW,
8533 prop::UPTIME,
8534 prop::STAT_FORWARDED,
8535 prop::STAT_FORWARD_DROPPED,
8536 prop::STAT_FORWARD_CANCELLED,
8537 ]
8538 );
8539 assert!(
8540 ulcp_category_properties(
8541 UlcpManageCategory::Statistics,
8542 encoded_capabilities(&[cap::DEV_IDENTITY, cap::REPEATER])
8543 )
8544 .unwrap()
8545 .is_empty(),
8546 "duty cycle and uptime do not keep the screen visible without CAP_STATS"
8547 );
8548 assert_eq!(
8549 ulcp_category_properties(
8550 UlcpManageCategory::Statistics,
8551 encoded_capabilities(&[cap::STATS])
8552 )
8553 .unwrap(),
8554 full[..8],
8555 "forwarding counters need CAP_REPEATER"
8556 );
8557
8558 let record = inspect_ulcp_properties(vec![
8559 response(prop::STAT_TX_PACKETS, &23u32.to_le_bytes()),
8560 response(prop::STAT_RX_BAD_CRC, &4u32.to_le_bytes()),
8561 response(prop::PHY_DUTY_NOW, &655u16.to_le_bytes()),
8562 response(prop::UPTIME, &3600u32.to_le_bytes()),
8563 ]);
8564 assert_eq!(record.stat_tx_packets, Some(23));
8565 assert_eq!(record.stat_rx_bad_crc, Some(4));
8566 assert_eq!(record.duty_cycle_now, Some(655));
8567 assert_eq!(record.uptime_seconds, Some(3600));
8568
8569 assert_eq!(
8570 dirty(
8571 UlcpDevicePropertiesRecord::default(),
8572 &[prop::STAT_RX_BAD_CRC, prop::STAT_TX_PACKETS],
8573 )
8574 .unwrap(),
8575 vec![
8576 (prop::STAT_TX_PACKETS, vec![0, 0, 0, 0]),
8577 (prop::STAT_RX_BAD_CRC, vec![0, 0, 0, 0]),
8578 ],
8579 "counter reset writes do not require invented desired values"
8580 );
8581 }
8582
8583 #[test]
8589 fn only_what_was_edited_is_written() {
8590 let written = dirty(
8591 UlcpDevicePropertiesRecord {
8592 gnss_enabled: Some(true),
8593 gnss_ident_update: Some(false),
8594 gnss_ident_precision: Some(4),
8595 gnss_time_trust: Some(true),
8596 ..Default::default()
8597 },
8598 &[prop::GNSS_IDENT_UPDATE],
8599 )
8600 .unwrap();
8601 assert_eq!(
8602 written,
8603 vec![(prop::GNSS_IDENT_UPDATE, vec![0])],
8604 "the other three GNSS settings were not edited, so they do not travel"
8605 );
8606 }
8607
8608 #[test]
8609 fn a_category_leaves_out_what_the_device_cannot_do() {
8610 let caps = encoded_capabilities(&[cap::REPEATER, cap::IDENT, cap::DEV_IDENTITY]);
8612 assert_eq!(
8613 ulcp_category_properties(UlcpManageCategory::Radio, caps.clone()).unwrap(),
8614 vec![prop::PHY_ENABLED, prop::PHY_FREQ, prop::PHY_TX_POWER],
8615 "a device without CAP_PHY_LORA is not asked for a modem profile"
8616 );
8617 assert!(
8618 ulcp_category_properties(UlcpManageCategory::Gnss, caps.clone())
8619 .unwrap()
8620 .is_empty(),
8621 "a device with no receiver has no GNSS screen to fill"
8622 );
8623 let identity = ulcp_category_properties(UlcpManageCategory::Identity, caps).unwrap();
8624 assert!(
8625 identity.contains(&prop::IDENT_LOCATION),
8626 "a fixed repeater still has a position to state"
8627 );
8628 assert!(
8629 !identity.contains(&prop::GNSS_IDENT_UPDATE),
8630 "nothing to auto-update from"
8631 );
8632 }
8633
8634 #[test]
8635 fn the_card_is_what_survives_between_openings() {
8636 let card = inspect_ulcp_device_card(vec![
8637 response(prop::CAPS, &managed_capabilities()),
8638 response(prop::DEV_VERSION, b"fw-2026.08.01"),
8639 response(prop::DEV_MODEL, b"T1000-E"),
8640 response(prop::DEV_NAME, b"Ridge"),
8641 ])
8642 .unwrap();
8643 assert_eq!(card.device_version.as_deref(), Some("fw-2026.08.01"));
8644 assert_eq!(card.device_model.as_deref(), Some("T1000-E"));
8645 assert_eq!(card.device_name.as_deref(), Some("Ridge"));
8646 assert!(card.supports_alert, "the find-my-device button is offered");
8647 assert!(card.supports_multi, "batched reads are worth trying");
8648 assert_eq!(
8649 card.capabilities,
8650 managed_capabilities(),
8651 "kept verbatim, to plan later reads against without asking again"
8652 );
8653 }
8654
8655 #[test]
8656 fn a_card_without_capabilities_is_no_card_at_all() {
8657 assert!(
8658 inspect_ulcp_device_card(vec![response(prop::DEV_NAME, b"Ridge")]).is_err(),
8659 "nothing can be planned against a device that would not say what it is"
8660 );
8661 }
8662
8663 #[test]
8664 fn a_card_tolerates_a_device_that_names_neither_firmware_nor_model() {
8665 let card =
8666 inspect_ulcp_device_card(vec![response(prop::CAPS, &managed_capabilities())]).unwrap();
8667 assert_eq!(card.device_version, None);
8668 assert_eq!(card.device_model, None);
8669 assert!(card.supports_gnss, "the capabilities still read");
8670 }
8671
8672 #[test]
8673 fn a_category_read_says_nothing_about_the_categories_it_did_not_ask_for() {
8674 let read = inspect_ulcp_properties(vec![
8675 response(prop::PHY_ENABLED, &[1]),
8676 response(prop::PHY_FREQ, &906_875u32.to_le_bytes()),
8677 response(prop::PHY_TX_POWER, &[22]),
8678 response(prop::PHY_LORA_SF, &[11]),
8679 ]);
8680 assert_eq!(read.phy_enabled, Some(true));
8681 assert_eq!(read.frequency_khz, Some(906_875));
8682 assert_eq!(read.transmit_power_dbm, Some(22));
8683 assert_eq!(read.spreading_factor, Some(11));
8684 assert_eq!(read.device_name, None, "nobody asked");
8685 assert_eq!(read.repeater_enabled, None);
8686 assert_eq!(read.gnss, None);
8687 }
8688
8689 #[test]
8690 fn an_unreadable_answer_leaves_its_field_absent_rather_than_failing_the_read() {
8691 let read = inspect_ulcp_properties(vec![
8692 response(prop::PHY_FREQ, &[0x01, 0x02]),
8693 response(prop::PHY_TX_POWER, &[17]),
8694 ]);
8695 assert_eq!(read.frequency_khz, None, "two octets are not a frequency");
8696 assert_eq!(
8697 read.transmit_power_dbm,
8698 Some(17),
8699 "one bad answer does not cost the screen the rest"
8700 );
8701 }
8702
8703 #[test]
8704 fn an_advertised_position_reads_as_a_place() {
8705 let cell = [0x84, 0x21, 0x9f, 0x40];
8706 let read = inspect_ulcp_properties(vec![
8707 response(prop::IDENT_LOCATION, &cell),
8708 response(prop::IDENT_ALTITUDE, &[0xC8, 0x00]),
8709 ]);
8710 assert_eq!(read.ident_location.as_deref(), Some(&cell[..]));
8711 assert!(read.ident_latitude_deg.is_some());
8712 assert!(read.ident_longitude_deg.is_some());
8713 assert_eq!(
8714 read.ident_location_cell_meters,
8715 ulcp_location_cell_meters(4),
8716 "what the four octets actually disclose"
8717 );
8718 assert_eq!(
8719 read.ident_altitude_m,
8720 Some(200),
8721 "a padded altitude reads the same as a minimal one"
8722 );
8723 }
8724
8725 #[test]
8726 fn a_device_advertising_no_position_is_not_a_device_that_was_never_asked() {
8727 let read = inspect_ulcp_properties(vec![
8728 response(prop::IDENT_LOCATION, &[]),
8729 response(prop::IDENT_ALTITUDE, &[]),
8730 ]);
8731 assert_eq!(read.ident_location, Some(Vec::new()));
8732 assert_eq!(read.ident_latitude_deg, None);
8733 assert_eq!(read.ident_altitude_m, None);
8734 }
8735
8736 #[test]
8737 fn a_place_encodes_to_the_cell_it_reads_back_as() {
8738 let cell = ulcp_encode_location(37.3382, -121.8863, 5).unwrap();
8739 assert_eq!(cell.len(), 5, "the precision is the value's length");
8740 let read = inspect_ulcp_properties(vec![response(prop::IDENT_LOCATION, &cell)]);
8741 assert!(
8742 (read.ident_latitude_deg.unwrap() - 37.3382).abs() < 0.01,
8743 "the cell the point falls in"
8744 );
8745 assert!((read.ident_longitude_deg.unwrap() + 121.8863).abs() < 0.01);
8746 }
8747
8748 #[test]
8749 fn a_coordinate_off_the_globe_is_a_typo_rather_than_a_place() {
8750 assert!(ulcp_encode_location(37.0, 200.0, 5).is_err());
8751 assert!(ulcp_encode_location(91.0, 0.0, 5).is_err());
8752 assert!(ulcp_encode_location(37.0, 0.0, 0).is_err());
8753 assert!(ulcp_encode_location(37.0, 0.0, 8).is_err());
8754 }
8755
8756 #[test]
8757 fn a_negative_altitude_is_an_ordinary_place() {
8758 let read = inspect_ulcp_properties(vec![response(prop::IDENT_ALTITUDE, &[0x9C])]);
8759 assert_eq!(read.ident_altitude_m, Some(-100), "Death Valley reads");
8760 }
8761
8762 #[test]
8763 fn a_receiver_readout_needs_the_fix_to_mean_anything() {
8764 let without = inspect_ulcp_properties(vec![
8765 response(prop::GNSS_ENABLED, &[1]),
8766 response(prop::GNSS_SATELLITES, &[7, 9]),
8767 ]);
8768 assert_eq!(without.gnss_enabled, Some(true));
8769 assert_eq!(
8770 without.gnss, None,
8771 "satellite counts alone do not say whether there is a position"
8772 );
8773
8774 let with = inspect_ulcp_properties(vec![
8775 response(prop::GNSS_FIX, &[FixKind::ThreeD as u8]),
8776 response(prop::GNSS_LOCATION, &[0x84, 0x21, 0x9f, 0x40]),
8777 response(prop::GNSS_ALTITUDE, &1_400i32.to_le_bytes()),
8778 response(prop::GNSS_SATELLITES, &[7, 9]),
8779 ]);
8780 let readout = with.gnss.expect("a fix makes a readout");
8781 assert_eq!(readout.altitude_m, Some(1_400));
8782 assert_eq!(readout.satellites_used, 7);
8783 assert!(readout.latitude_deg.is_some());
8784 }
8785
8786 fn dirty(
8788 desired: UlcpDevicePropertiesRecord,
8789 edited: &[u32],
8790 ) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
8791 Ok(ulcp_dirty_writes(desired, edited.to_vec())?
8792 .into_iter()
8793 .map(|write| (write.property_id, write.value))
8794 .collect())
8795 }
8796
8797 #[test]
8798 fn one_edit_is_one_write() {
8799 let written = dirty(
8800 UlcpDevicePropertiesRecord {
8801 device_name: Some("Saddle".into()),
8802 ident_mobile: Some(true),
8805 repeater_enabled: Some(true),
8806 dev_discoverable: Some(false),
8807 ..Default::default()
8808 },
8809 &[prop::DEV_NAME],
8810 )
8811 .unwrap();
8812 assert_eq!(
8813 written,
8814 vec![(prop::DEV_NAME, b"Saddle".to_vec())],
8815 "the point of the whole design: a rename costs one write"
8816 );
8817 }
8818
8819 #[test]
8820 fn an_edit_with_nothing_to_write_is_a_caller_mistake() {
8821 assert!(
8822 dirty(UlcpDevicePropertiesRecord::default(), &[prop::DEV_NAME]).is_err(),
8823 "reporting success for an edit that carries no value would be a lie"
8824 );
8825 }
8826
8827 #[test]
8828 fn a_modem_knob_travels_alone_inside_the_bracket() {
8829 let written = dirty(
8830 UlcpDevicePropertiesRecord {
8831 phy_enabled: Some(true),
8832 bandwidth_hz: Some(250_000),
8833 spreading_factor: Some(10),
8834 coding_rate_denom: Some(5),
8835 ..Default::default()
8836 },
8837 &[prop::PHY_LORA_SF],
8838 )
8839 .unwrap();
8840 let keys: Vec<u32> = written.iter().map(|(key, _)| *key).collect();
8841 assert_eq!(
8842 keys,
8843 vec![prop::PHY_ENABLED, prop::PHY_LORA_SF, prop::PHY_ENABLED],
8844 "the untouched bandwidth and coding rate stay home"
8845 );
8846 assert_eq!(written.first().unwrap().1, vec![0], "the radio goes down");
8847 assert_eq!(
8848 written.last().unwrap().1,
8849 vec![1],
8850 "and comes back up as it was"
8851 );
8852 }
8853
8854 #[test]
8855 fn a_radio_left_off_stays_off_after_the_change() {
8856 let written = dirty(
8857 UlcpDevicePropertiesRecord {
8858 phy_enabled: Some(false),
8859 frequency_khz: Some(915_000),
8860 ..Default::default()
8861 },
8862 &[prop::PHY_FREQ],
8863 )
8864 .unwrap();
8865 assert_eq!(written.first().unwrap(), &(prop::PHY_ENABLED, vec![0]));
8866 assert_eq!(
8867 written.last().unwrap(),
8868 &(prop::PHY_ENABLED, vec![0]),
8869 "bringing the radio up would be a change nobody asked for"
8870 );
8871 }
8872
8873 #[test]
8874 fn turning_the_radio_off_is_not_bracketed() {
8875 let written = dirty(
8876 UlcpDevicePropertiesRecord {
8877 phy_enabled: Some(false),
8878 ..Default::default()
8879 },
8880 &[prop::PHY_ENABLED],
8881 )
8882 .unwrap();
8883 assert_eq!(
8884 written,
8885 vec![(prop::PHY_ENABLED, vec![0])],
8886 "nothing is transmitting under a parameter that moved"
8887 );
8888 }
8889
8890 #[test]
8891 fn an_altitude_takes_no_more_octets_than_it_needs() {
8892 for (meters, expected) in [
8893 (100i32, vec![0x64]),
8894 (-100, vec![0x9C]),
8895 (200, vec![0xC8, 0x00]),
8896 (-200, vec![0x38, 0xFF]),
8897 (100_000, vec![0xA0, 0x86, 0x01]),
8898 (-100_000, vec![0x60, 0x79, 0xFE]),
8899 ] {
8900 let written = dirty(
8901 UlcpDevicePropertiesRecord {
8902 ident_altitude_m: Some(meters),
8903 ..Default::default()
8904 },
8905 &[prop::IDENT_ALTITUDE],
8906 )
8907 .unwrap();
8908 assert_eq!(
8909 written,
8910 vec![(prop::IDENT_ALTITUDE, expected)],
8911 "{meters} m"
8912 );
8913 }
8914 }
8915
8916 #[test]
8917 fn clearing_a_position_writes_the_clearing() {
8918 let written = dirty(
8919 UlcpDevicePropertiesRecord {
8920 ident_location: None,
8921 ident_altitude_m: None,
8922 ..Default::default()
8923 },
8924 &[prop::IDENT_LOCATION, prop::IDENT_ALTITUDE],
8925 )
8926 .unwrap();
8927 assert_eq!(
8928 written,
8929 vec![
8930 (prop::IDENT_LOCATION, Vec::new()),
8931 (prop::IDENT_ALTITUDE, Vec::new()),
8932 ],
8933 "an empty value is how a device is told it has no position"
8934 );
8935 }
8936
8937 #[test]
8938 fn a_position_finer_than_the_encoding_allows_is_refused_here() {
8939 assert!(
8940 dirty(
8941 UlcpDevicePropertiesRecord {
8942 ident_location: Some(vec![0; 8]),
8943 ..Default::default()
8944 },
8945 &[prop::IDENT_LOCATION],
8946 )
8947 .is_err(),
8948 "the device would refuse it, costing a round trip that could only fail"
8949 );
8950 }
8951
8952 #[test]
8953 fn a_read_only_property_is_not_something_to_apply() {
8954 assert!(
8955 dirty(
8956 UlcpDevicePropertiesRecord {
8957 duty_cycle_now: Some(12),
8958 ..Default::default()
8959 },
8960 &[prop::PHY_DUTY_NOW],
8961 )
8962 .is_err(),
8963 "a device's own report of its past hour is not the phone's to state"
8964 );
8965 }
8966
8967 #[test]
8968 fn a_forwarding_policy_edit_travels_alone() {
8969 let written = dirty(
8970 UlcpDevicePropertiesRecord {
8971 repeater_enabled: Some(true),
8972 repeater_regions: Some(vec!["SJC".into()]),
8973 repeater_default_region: None,
8974 repeater_min_rssi_dbm: Some(-115),
8975 repeater_min_snr_db: None,
8976 ..Default::default()
8977 },
8978 &[prop::MAC_REPEATER_MIN_RSSI, prop::MAC_REPEATER_REGIONS],
8979 )
8980 .unwrap();
8981 assert_eq!(
8982 written.iter().map(|(key, _)| *key).collect::<Vec<_>>(),
8983 vec![prop::MAC_REPEATER_REGIONS, prop::MAC_REPEATER_MIN_RSSI],
8984 "the three untouched policy settings stay home"
8985 );
8986 assert_eq!(
8987 written[0].1,
8988 vec![3, b'S', b'J', b'C'],
8989 "regions pack the same way they do over the local link"
8990 );
8991 }
8992
8993 fn set_request(bytes: &[u8]) -> (u8, u32, Vec<u8>) {
8997 let parsed = Frame::parse(bytes).unwrap();
8998 assert_eq!(parsed.command(), Some(Cmd::PropSet));
8999 let payload = PropPayload::parse(parsed.payload).unwrap();
9000 (parsed.header.tid(), payload.key, payload.value.to_vec())
9001 }
9002
9003 #[test]
9004 fn local_fetch_reports_values_and_refusals_alike() {
9005 let session = MobileUlcpSession::new();
9006 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
9007
9008 let update = session
9009 .begin_property_fetch(vec![prop::DEV_NAME, prop::GNSS_ENABLED])
9010 .unwrap();
9011 assert_eq!(update.outbound_frames.len(), 2);
9012
9013 let (name_tid, name_prop) = property_request(&update.outbound_frames[0]);
9014 assert_eq!(name_prop, prop::DEV_NAME);
9015 let (gnss_tid, gnss_prop) = property_request(&update.outbound_frames[1]);
9016 assert_eq!(gnss_prop, prop::GNSS_ENABLED);
9017
9018 let mid = session
9019 .consume(property_response(name_tid, prop::DEV_NAME, b"Ridge"))
9020 .unwrap();
9021 assert!(
9022 mid.management_event.is_none(),
9023 "half-answered is not answered"
9024 );
9025 let done = session
9027 .consume(property_response(
9028 gnss_tid,
9029 prop::LAST_STATUS,
9030 &[umsh_ulcp::Status::PROP_NOT_FOUND.0 as u8],
9031 ))
9032 .unwrap();
9033 let event = done.management_event.expect("both answers are in");
9034 assert_eq!(
9035 event.answers,
9036 vec![
9037 MobileMeshManagementAnswerRecord {
9038 property_id: prop::DEV_NAME,
9039 value: Some(b"Ridge".to_vec()),
9040 status_code: None,
9041 },
9042 MobileMeshManagementAnswerRecord {
9043 property_id: prop::GNSS_ENABLED,
9044 value: None,
9045 status_code: Some(umsh_ulcp::Status::PROP_NOT_FOUND.0),
9046 },
9047 ],
9048 "a refusal is that property's answer, not the operation's failure"
9049 );
9050 assert_eq!(event.status_code, None);
9051 assert_eq!(
9052 done.snapshot.device_name.as_deref(),
9053 Some("Ridge"),
9054 "what a fetch learns, the session snapshot learns too"
9055 );
9056 }
9057
9058 #[test]
9059 fn local_fetch_asks_in_bounded_batches() {
9060 let session = MobileUlcpSession::new();
9061 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
9062
9063 let properties: Vec<u32> = vec![
9065 prop::DEV_NAME,
9066 prop::PHY_ENABLED,
9067 prop::PHY_FREQ,
9068 prop::PHY_TX_POWER,
9069 prop::IDENT_MOBILE,
9070 prop::DEV_DISCOVERABLE,
9071 prop::MAC_REPEATER_ENABLED,
9072 prop::DEV_PEERS,
9073 prop::DEV_ADMINS,
9074 ];
9075 let update = session.begin_property_fetch(properties.clone()).unwrap();
9076 assert_eq!(
9077 update.outbound_frames.len(),
9078 usize::from(frame::TID_MAX),
9079 "a round asks for no more than the transaction space holds"
9080 );
9081 let second = answer_requests(&session, update.outbound_frames, commissionable_value);
9082 assert_eq!(second.outbound_frames.len(), 2);
9083 assert!(second.management_event.is_none());
9084 let done = answer_requests(&session, second.outbound_frames, commissionable_value);
9085 let event = done.management_event.expect("all nine answered");
9086 assert_eq!(event.answers.len(), properties.len());
9087 }
9088
9089 #[test]
9090 fn local_writes_go_out_one_at_a_time_and_survive_a_refusal() {
9091 let session = MobileUlcpSession::new();
9092 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
9093
9094 let update = session
9095 .begin_property_writes(vec![
9096 MobileMeshPropertyWriteRecord {
9097 property_id: prop::DEV_NAME,
9098 value: b"Saddle".to_vec(),
9099 },
9100 MobileMeshPropertyWriteRecord {
9101 property_id: prop::IDENT_MOBILE,
9102 value: vec![1],
9103 },
9104 ])
9105 .unwrap();
9106 assert_eq!(
9107 update.outbound_frames.len(),
9108 1,
9109 "order is load-bearing, so nothing is pipelined"
9110 );
9111 let (tid, property, value) = set_request(&update.outbound_frames[0]);
9112 assert_eq!(
9113 (property, value.as_slice()),
9114 (prop::DEV_NAME, &b"Saddle"[..])
9115 );
9116
9117 let mid = session
9119 .consume(property_response(
9120 tid,
9121 prop::LAST_STATUS,
9122 &[umsh_ulcp::Status::INVALID_ARGUMENT.0 as u8],
9123 ))
9124 .unwrap();
9125 assert!(mid.management_event.is_none());
9126 assert_eq!(mid.outbound_frames.len(), 1);
9127 let (tid, property, value) = set_request(&mid.outbound_frames[0]);
9128 assert_eq!((property, value), (prop::IDENT_MOBILE, vec![1]));
9129
9130 let done = session
9131 .consume(property_response(tid, prop::IDENT_MOBILE, &[1]))
9132 .unwrap();
9133 let event = done.management_event.expect("both writes answered");
9134 assert_eq!(
9135 event.answers,
9136 vec![
9137 MobileMeshManagementAnswerRecord {
9138 property_id: prop::DEV_NAME,
9139 value: None,
9140 status_code: Some(umsh_ulcp::Status::INVALID_ARGUMENT.0),
9141 },
9142 MobileMeshManagementAnswerRecord {
9143 property_id: prop::IDENT_MOBILE,
9144 value: Some(vec![1]),
9145 status_code: None,
9146 },
9147 ],
9148 );
9149 }
9150
9151 #[test]
9152 fn local_save_reports_the_device_status() {
9153 let session = MobileUlcpSession::new();
9154 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
9155
9156 let update = session.begin_save().unwrap();
9157 assert_eq!(update.outbound_frames.len(), 1);
9158 let parsed = Frame::parse(&update.outbound_frames[0]).unwrap();
9159 assert_eq!(parsed.command(), Some(Cmd::Save));
9160 let done = session
9161 .consume(property_response(
9162 parsed.header.tid(),
9163 prop::LAST_STATUS,
9164 &[0],
9165 ))
9166 .unwrap();
9167 let event = done.management_event.expect("the save answered");
9168 assert!(event.answers.is_empty());
9169 assert_eq!(event.status_code, Some(0));
9170 }
9171
9172 #[test]
9173 fn local_save_without_the_capability_is_already_done() {
9174 let session = MobileUlcpSession::new();
9175 let capabilities: Vec<u32> = commissionable_capabilities()
9176 .into_iter()
9177 .filter(|&capability| capability != cap::SAVE)
9178 .collect();
9179 let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
9180 drive_reads(
9181 &session,
9182 begin.outbound_frames,
9183 move |property| match property {
9184 prop::CAPS => (property, encoded_capabilities(&capabilities)),
9185 prop::HOST_KEY => (property, vec![0xAA; 32]),
9186 _ => commissionable_value(property),
9187 },
9188 );
9189
9190 let update = session.begin_save().unwrap();
9191 assert!(update.outbound_frames.is_empty());
9192 let event = update
9193 .management_event
9194 .expect("nothing to ask, so the operation is already complete");
9195 assert_eq!(event.status_code, None);
9196 }
9197
9198 #[test]
9199 fn one_local_operation_at_a_time() {
9200 let session = MobileUlcpSession::new();
9201 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
9202
9203 let update = session.begin_property_fetch(vec![prop::DEV_NAME]).unwrap();
9204 assert!(
9205 session.begin_property_fetch(vec![prop::DEV_NAME]).is_err(),
9206 "a second operation must wait for the first"
9207 );
9208 assert!(session.refresh().is_err(), "so must a refresh");
9209
9210 let (tid, _) = property_request(&update.outbound_frames[0]);
9211 let done = session
9212 .consume(property_response(tid, prop::DEV_NAME, b"Ridge"))
9213 .unwrap();
9214 assert!(done.management_event.is_some());
9215 assert!(
9216 session.begin_property_fetch(vec![prop::DEV_NAME]).is_ok(),
9217 "and once it completes, the next may run"
9218 );
9219 }
9220
9221 #[test]
9222 fn an_unsolicited_value_is_carried_out_verbatim() {
9223 let session = MobileUlcpSession::new();
9224 attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
9225
9226 let update = session
9227 .consume(property_response(
9228 frame::TID_UNSOLICITED,
9229 prop::IDENT_MOBILE,
9230 &[1],
9231 ))
9232 .unwrap();
9233 assert_eq!(
9234 update.pushed_properties,
9235 vec![UlcpPropertyPushRecord {
9236 property_id: prop::IDENT_MOBILE,
9237 value: vec![1],
9238 }],
9239 "a push reaches whoever caches values by number, not just the snapshot"
9240 );
9241 }
9242
9243 #[test]
9244 fn a_lazy_administrative_attach_reads_only_what_attaching_requires() {
9245 let session = MobileUlcpSession::administrative_lazy();
9246 let begin = session.begin(None).unwrap();
9247 let mut asked = Vec::new();
9248 let mut pending = begin.outbound_frames;
9249 let mut last = None;
9250 while !pending.is_empty() {
9251 let update = answer_requests(&session, pending.clone(), |property| match property {
9252 prop::HOST_KEY => (property, vec![0xBB; 32]),
9255 _ => commissionable_value(property),
9256 });
9257 for request in &pending {
9258 asked.push(property_request(request).1);
9259 }
9260 pending = update.outbound_frames.clone();
9261 last = Some(update);
9262 }
9263 let update = last.unwrap();
9264 assert_eq!(update.snapshot.phase, UlcpSessionPhase::Attached);
9265 assert_eq!(
9266 asked.len(),
9267 11,
9268 "the seven-property preamble and the four the sync reduction insists on"
9269 );
9270 assert!(
9271 !asked.contains(&prop::MAC_REPEATER_ENABLED),
9272 "the device domain is left to be read on demand"
9273 );
9274
9275 let fetch = session
9277 .begin_property_fetch(vec![prop::MAC_REPEATER_ENABLED])
9278 .unwrap();
9279 let (tid, _) = property_request(&fetch.outbound_frames[0]);
9280 let done = session
9281 .consume(property_response(tid, prop::MAC_REPEATER_ENABLED, &[0]))
9282 .unwrap();
9283 assert!(done.management_event.is_some());
9284 }
9285
9286 #[test]
9287 fn radio_presets_cross_the_bindings_whole() {
9288 let presets = ulcp_radio_presets();
9289 assert_eq!(presets.len(), umsh_ulcp::profiles::VETTED.len());
9290 assert_eq!(presets[0].id, umsh_ulcp::profiles::DEFAULT.id);
9291
9292 for (preset, profile) in presets.iter().zip(umsh_ulcp::profiles::VETTED) {
9297 assert_eq!(preset.id, profile.id);
9298 assert_eq!(preset.name, profile.name);
9299 assert_eq!(preset.frequency_khz, profile.freq_khz);
9300 assert_eq!(preset.bandwidth_hz, profile.bw_hz);
9301 assert_eq!(preset.spreading_factor, profile.sf);
9302 assert_eq!(preset.coding_rate_denom, profile.cr_denom);
9303 assert_eq!(preset.transmit_power_dbm, profile.tx_power_dbm);
9304 assert_eq!(preset.duty_cycle_limit, profile.duty_limit);
9305 assert_eq!(preset.sync_word, profile.sync_word);
9306 assert_eq!(preset.tx_preamble_symbols, profile.tx_preamble_symbols);
9307 }
9308
9309 assert!(
9312 presets
9313 .iter()
9314 .any(|preset| preset.transmit_power_dbm.is_none()),
9315 "the optional power is reachable from the app"
9316 );
9317 assert_eq!(
9318 ulcp_supported_bandwidths_hz(),
9319 umsh_ulcp::profiles::SUPPORTED_BANDWIDTHS_HZ
9320 );
9321 }
9322}