umsh_mobile_core/
ulcp.rs

1use std::{
2    collections::{HashMap, VecDeque},
3    sync::{Arc, Mutex},
4};
5
6use umsh_core::RegionCode;
7use umsh_node::location::{MAX_PRECISION, NodeLocation};
8use umsh_ulcp::{
9    AlertState, BatteryChargeState, BatteryStatus, Cmd, Frame, StreamPayload, frame,
10    gatt::{self, MAX_FRAME, Reassembler},
11    gnss::{FixKind, GnssSnapshot},
12    host::{PropertyNotification, PropertyNotificationKind, TidAllocator},
13    ids::{
14        INTERFACE_TYPE, MAX_AUTO_ANNOUNCE_INTERVAL_S, MIN_AUTO_ANNOUNCE_INTERVAL_S,
15        PROTOCOL_MAJOR_VERSION, PROTOCOL_MINOR_VERSION, cap, prop, saved,
16    },
17    items::{self, Filter},
18    meta::{BufferedRxMeta, RX_FLAG_ACKED, RX_FLAG_BUFFERED},
19    pui,
20};
21
22use crate::MobileError;
23
24/// One header-prefixed ATT value produced by ULCP GATT segmentation.
25#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
26pub struct GattSegmentRecord {
27    pub value: Vec<u8>,
28}
29
30/// A validated property-bearing ULCP frame.
31#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
32pub struct UlcpPropertyFrameRecord {
33    pub transaction_id: u8,
34    pub command: u8,
35    pub property_id: u32,
36    pub value: Vec<u8>,
37}
38
39/// UI-relevant fields from a validated `PROP_BATTERY` value.
40#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
41pub struct UlcpBatteryRecord {
42    pub percentage: Option<u8>,
43    /// Measured terminal voltage in millivolts, when the device reports it.
44    pub voltage_mv: Option<u16>,
45    /// What the charging system is doing, when the device reports it.
46    /// Whether the radio is on external power follows from this rather
47    /// than being carried separately.
48    pub charge_state: Option<UlcpChargeState>,
49}
50
51/// The charge state a device reports in `PROP_BATTERY`.
52#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
53pub enum UlcpChargeState {
54    /// Running off the battery.
55    Discharging,
56    /// On external power, taking charge.
57    Charging,
58    /// On external power, charge complete.
59    Charged,
60}
61
62impl UlcpChargeState {
63    fn from_wire(state: BatteryChargeState) -> Self {
64        match state {
65            BatteryChargeState::Discharging => Self::Discharging,
66            BatteryChargeState::Charging => Self::Charging,
67            BatteryChargeState::Charged => Self::Charged,
68        }
69    }
70}
71
72/// The device identity's autonomous flood-forwarding policy.
73///
74/// Region codes travel as the opaque 2-octet wire values they are on the
75/// air: the same bytes the device advertises as its Supported Regions
76/// identity option. Text forms are a presentation concern —
77/// [`region_code_from_string`] and [`region_code_description`] convert.
78#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
79pub struct UlcpRepeaterSettingsRecord {
80    /// `PROP_MAC_REPEATER_ENABLED`. The remaining fields are inert while
81    /// this is false, but are still read and written.
82    pub enabled: bool,
83    /// `PROP_MAC_REPEATER_REGIONS`: which region-tagged floods to
84    /// forward, each entry exactly two octets. Empty imposes no regional
85    /// restriction rather than blocking every flood.
86    pub regions: Vec<Vec<u8>>,
87    /// `PROP_MAC_REPEATER_DEFAULT_REGION`: the tag inserted into an
88    /// untagged flood before forwarding it. `None` forwards untagged.
89    pub default_region: Option<Vec<u8>>,
90    /// `PROP_MAC_REPEATER_MIN_RSSI` in dBm. `None` accepts any.
91    pub min_rssi_dbm: Option<i16>,
92    /// `PROP_MAC_REPEATER_MIN_SNR` in whole dB. `None` accepts any.
93    pub min_snr_db: Option<i8>,
94}
95
96/// The device's positioning policy: whether the receiver runs, and what
97/// is done with what it finds.
98///
99/// Read and written as a whole, like [`UlcpRepeaterSettingsRecord`] and
100/// for the same reason — a receiver switched on under half a policy
101/// starts advertising a position nobody just agreed to. `enabled` is
102/// written last so the rest is already in force when it does.
103#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)]
104pub struct UlcpGnssSettingsRecord {
105    /// `PROP_GNSS_ENABLED`: whether the receiver is powered. Off is the
106    /// lowest power state the board can reach, and on most of them the
107    /// receiver is the largest continuous load there is.
108    pub enabled: bool,
109    /// `PROP_GNSS_IDENT_UPDATE`: whether fixes refresh the location the
110    /// node advertises in its identity.
111    pub ident_update: bool,
112    /// `PROP_GNSS_IDENT_PRECISION`: how many location bytes that
113    /// advertised position is clamped to, 1 (coarsest) through 7. This is
114    /// a disclosure control — see [`ulcp_location_cell_meters`].
115    pub ident_precision: u8,
116    /// `PROP_GNSS_TIME_TRUST`: whether receiver-derived time may set the
117    /// wall clock. Cleared, a hand-set clock is safe from a jammed or
118    /// spoofed sky; position reporting is unaffected.
119    pub time_trust: bool,
120}
121
122/// What the device announces without being asked.
123///
124/// Read and written as a whole, like [`UlcpGnssSettingsRecord`], because
125/// the two schedules are how much of the mesh's airtime this device
126/// claims and an operator sets that as one decision.
127#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)]
128pub struct UlcpAdvertSettingsRecord {
129    /// `PROP_ADVERT_INTERVAL`: seconds between signed identity
130    /// advertisements, 0 for none. An advertisement reaches only the
131    /// device's own neighbours.
132    pub advert_interval_seconds: u32,
133    /// `PROP_BEACON_INTERVAL`: seconds between empty beacons, 0 for none.
134    /// A beacon floods, collecting the path back to the device as it
135    /// goes, and costs a fraction of an advertisement.
136    pub beacon_interval_seconds: u32,
137    /// `PROP_STARTUP_BEACON`: whether one beacon goes out at bring-up.
138    pub startup_beacon: bool,
139}
140
141/// `PROP_GNSS_FIX`: what kind of position solution the receiver has.
142#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, uniffi::Enum)]
143pub enum UlcpFixKind {
144    /// No solution — the receiver is off, or on and still searching.
145    #[default]
146    None,
147    /// Position without altitude.
148    TwoD,
149    /// Position and altitude.
150    ThreeD,
151}
152
153impl UlcpFixKind {
154    fn from_wire(fix: FixKind) -> Self {
155        match fix {
156            FixKind::None => Self::None,
157            FixKind::TwoD => Self::TwoD,
158            FixKind::ThreeD => Self::ThreeD,
159        }
160    }
161}
162
163/// What the receiver currently reports, folded from the five positioning
164/// telemetry properties.
165///
166/// Unlike a battery reading this is carried on *every* snapshot rather
167/// than reported once: it is state the UI mirrors — a map pin does not
168/// disappear because an unrelated property arrived — and the receiver
169/// announces position and fix changes on its own schedule.
170#[derive(Clone, Debug, PartialEq, uniffi::Record)]
171pub struct UlcpGnssRecord {
172    pub fix: UlcpFixKind,
173    /// `PROP_GNSS_LOCATION` as it travels: the interleaved
174    /// variable-precision grid code, empty without a fix. Carried
175    /// verbatim so a caller can compare or forward the cell itself
176    /// rather than re-encoding degrees.
177    pub location: Vec<u8>,
178    /// Center of the encoded cell, in degrees. `None` without a fix.
179    ///
180    /// A location names a cell rather than a point; `location_cell_meters`
181    /// says how large that cell is, and rendering a pin without it claims
182    /// a precision the device did not report. Widened from the f32 the
183    /// decoder works in, because that is the shape every consumer of a
184    /// coordinate wants.
185    pub latitude_deg: Option<f64>,
186    pub longitude_deg: Option<f64>,
187    /// Approximate width of the encoded cell at the equator, in meters.
188    pub location_cell_meters: Option<f64>,
189    /// `PROP_GNSS_ALTITUDE` in meters above the WGS-84 ellipsoid.
190    pub altitude_m: Option<i32>,
191    /// `PROP_GNSS_PRECISION`: estimated horizontal accuracy in
192    /// decimeters. An estimate scaled from dilution of precision, not a
193    /// measured error bound.
194    pub accuracy_dm: Option<u16>,
195    /// Satellites contributing to the solution. Reads 0 while the
196    /// receiver is off.
197    pub satellites_used: u8,
198    /// Satellites in view, when the receiver reports them.
199    pub satellites_in_view: Option<u8>,
200}
201
202/// `PROP_TIME`: what the device's wall clock read when it last reported.
203///
204/// Take-once, like a battery reading and for the same reason: a clock
205/// value means nothing without the instant it was received, so a consumer
206/// stamps what arrives. Republishing it on unrelated updates would
207/// restamp a stale reading as a fresh one.
208#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)]
209pub struct UlcpTimeRecord {
210    /// Seconds since the Unix epoch, or `None` when the device does not
211    /// know what time it is. A device that has never had a fix, a manual
212    /// set, or a retained RTC is in that state, and says so rather than
213    /// reporting zero.
214    pub epoch_seconds: Option<u32>,
215}
216
217/// Read-only, capability-gated device state gathered after host ownership
218/// has been resolved. Counts describe digest forms and contain no key material.
219#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
220pub struct UlcpSyncRecord {
221    pub capability_count: u32,
222    pub has_host_filtering: bool,
223    pub supports_offline_queue: bool,
224    pub supports_delegated_ack: bool,
225    pub supports_device_name: bool,
226    pub supports_lora: bool,
227    pub supports_duty_cycle_limit: bool,
228    /// The device measures its own power state (`CAP_BATTERY`). A device
229    /// without it never reports a battery, so callers have nothing to show.
230    pub supports_battery: bool,
231    /// The device can forward for the mesh on its own (`CAP_REPEATER`).
232    pub supports_repeater: bool,
233    /// The device serves and configures its own advertised node identity
234    /// (`CAP_IDENT`).
235    pub supports_ident: bool,
236    /// The device has an identity domain of its own (`CAP_DEV_IDENTITY`),
237    /// including the `PROP_DEV_PEERS` list.
238    pub supports_device_identity: bool,
239    /// The device keeps a wall clock (`CAP_TIME`). It says nothing about
240    /// where the time comes from, or whether the device currently knows
241    /// it — an unset clock is a device with `CAP_TIME` and no epoch.
242    pub supports_time: bool,
243    /// A GNSS receiver is fitted (`CAP_GNSS`), so the positioning
244    /// properties exist and the device can locate itself.
245    pub supports_gnss: bool,
246    /// The device announces itself on a schedule of its own
247    /// (`CAP_ADVERT`).
248    pub supports_advert: bool,
249    pub phy_enabled: bool,
250    pub frequency_khz: u32,
251    pub transmit_power_dbm: i8,
252    pub bandwidth_hz: Option<u32>,
253    pub spreading_factor: Option<u8>,
254    pub coding_rate_denom: Option<u8>,
255    pub duty_cycle_now: Option<u16>,
256    pub duty_cycle_limit: Option<u16>,
257    pub saved: Option<SavedSnapshotRecord>,
258    pub queued_frames: Option<u16>,
259    pub dropped_frames: Option<u32>,
260    pub filter_count: Option<u32>,
261    pub host_channel_count: Option<u32>,
262    pub host_peer_count: Option<u32>,
263    pub auto_ack: Option<bool>,
264    /// Present when `supports_repeater` and the device reported the whole
265    /// policy.
266    pub repeater: Option<UlcpRepeaterSettingsRecord>,
267    /// `PROP_DEV_PEERS`: the peer public keys stored on the device
268    /// identity, read back losslessly. Present when
269    /// `supports_device_identity` and the device reported the list.
270    pub dev_peer_keys: Option<Vec<Vec<u8>>>,
271    /// `PROP_DEV_CHANNEL_KEYS`: the two-octet identifiers of the channels the
272    /// device identity has joined. Key material is never read back, so a
273    /// caller names these by deriving identifiers from the keys it holds; one
274    /// that matches nothing locally is a channel the device knows and this
275    /// phone does not.
276    pub dev_channel_ids: Option<Vec<Vec<u8>>>,
277    /// `PROP_IDENT_ROLE`. `None` covers "the device derives its role from
278    /// what it is actually doing", "no `CAP_IDENT`", and "the device would
279    /// not report it" — `supports_ident` and `unreadable_properties`
280    /// distinguish them.
281    pub ident_role: Option<u8>,
282    /// `PROP_IDENT_MOBILE`. Present when `supports_ident` and the device
283    /// reported it.
284    pub ident_mobile: Option<bool>,
285    /// `PROP_DEV_DISCOVERABLE`: whether the device identity answers
286    /// Identity Requests. Present when `supports_device_identity` and the
287    /// device reported it.
288    pub dev_discoverable: Option<bool>,
289    /// `PROP_TZ_OFFSET` in minutes east of UTC. Present when
290    /// `supports_time` and the device reported it.
291    ///
292    /// The zone is configuration and the epoch is not: where a device is
293    /// meant to be is known even when what time it is is not, which is
294    /// why this is here and the clock reading is on the session snapshot.
295    pub tz_offset_min: Option<i16>,
296    /// The positioning policy. Present when `supports_gnss` and the
297    /// device reported the whole of it.
298    pub gnss: Option<UlcpGnssSettingsRecord>,
299    /// The advertisement policy. Present when `supports_advert` and the
300    /// device reported the whole of it.
301    pub advert: Option<UlcpAdvertSettingsRecord>,
302    /// Capability-gated properties the device advertised but would not
303    /// report, in ascending order.
304    ///
305    /// A device that refuses a property — old firmware behind a newer
306    /// capability, a property it never implemented — is a device with an
307    /// unknown setting, not one this phone cannot administer. Their values
308    /// are absent above, they are left out of configuration writes, and
309    /// nothing about them can be verified after a save.
310    pub unreadable_properties: Vec<u32>,
311}
312
313/// `PROP_SAVED`: what the radio reports about its stored snapshot.
314///
315/// `Fallback` and `Unreadable` are the values worth surfacing: the radio
316/// is running on configuration older than the one last saved, or on none
317/// at all, and looks healthy otherwise.
318#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
319pub enum SavedSnapshotRecord {
320    /// Nothing is saved.
321    None,
322    /// The newest saved generation is in effect.
323    Current,
324    /// A newer generation was rejected at boot; an older one is in
325    /// effect. Saving again clears it.
326    Fallback,
327    /// A snapshot exists but could not be read; the radio booted with
328    /// factory defaults.
329    Unreadable,
330}
331
332/// Long-lived host-session phase. Swift maps this value to UI link state but
333/// does not implement ULCP transitions itself.
334#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
335pub enum UlcpSessionPhase {
336    Idle,
337    Synchronizing,
338    AwaitingHost,
339    Claiming,
340    Configuring,
341    Attached,
342}
343
344/// Complete desired live radio configuration. Capability-gated fields must be
345/// omitted when the device does not advertise their associated capability.
346#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
347pub struct UlcpRadioSettingsRecord {
348    pub device_name: Option<String>,
349    pub phy_enabled: bool,
350    pub frequency_khz: u32,
351    pub transmit_power_dbm: i8,
352    pub bandwidth_hz: Option<u32>,
353    pub spreading_factor: Option<u8>,
354    pub coding_rate_denom: Option<u8>,
355    pub duty_cycle_limit: Option<u16>,
356}
357
358/// Complete desired configuration of a device's *own* domain: what it is
359/// and what it does when no phone is attached.
360///
361/// This is the commissioning counterpart to [`UlcpRadioSettingsRecord`],
362/// which describes only the radio. Every capability-gated field must be
363/// present exactly when the device advertises the matching capability, so
364/// the record always states a whole desired configuration rather than a
365/// patch — a property that a future template feature can lean on.
366#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
367pub struct UlcpDeviceConfigRecord {
368    /// The live radio profile, applied with the same disable-first,
369    /// enable-last ordering [`MobileUlcpSession::configure`] uses.
370    pub radio: UlcpRadioSettingsRecord,
371    /// `PROP_IDENT_ROLE`, or `None` to let the device derive its
372    /// advertised role from what it is actually doing. Requires
373    /// `CAP_IDENT`.
374    pub ident_role: Option<u8>,
375    /// `PROP_IDENT_MOBILE`. Present exactly when the device advertises
376    /// `CAP_IDENT`.
377    pub ident_mobile: Option<bool>,
378    /// `PROP_DEV_DISCOVERABLE`: whether the device identity answers
379    /// Identity Requests. Present exactly when the device advertises
380    /// `CAP_DEV_IDENTITY`.
381    pub dev_discoverable: Option<bool>,
382    /// The flood-forwarding policy. Present exactly when the device
383    /// advertises `CAP_REPEATER`.
384    pub repeater: Option<UlcpRepeaterSettingsRecord>,
385    /// `PROP_TZ_OFFSET` in minutes east of UTC. Present exactly when the
386    /// device advertises `CAP_TIME`.
387    ///
388    /// The clock itself is not here: it is live state rather than
389    /// configuration, is never saved, and is set with
390    /// [`MobileUlcpSession::set_time`].
391    pub tz_offset_min: Option<i16>,
392    /// The positioning policy. Present exactly when the device advertises
393    /// `CAP_GNSS`.
394    pub gnss: Option<UlcpGnssSettingsRecord>,
395    /// The advertisement policy. Present exactly when the device
396    /// advertises `CAP_ADVERT`.
397    pub advert: Option<UlcpAdvertSettingsRecord>,
398}
399
400/// Present one folded [`GnssSnapshot`] as the record Swift sees.
401fn gnss_record(snapshot: &GnssSnapshot) -> UlcpGnssRecord {
402    let bytes = snapshot.location();
403    let placed = (!bytes.is_empty()).then(|| NodeLocation::from_bytes(bytes).center());
404    UlcpGnssRecord {
405        fix: UlcpFixKind::from_wire(snapshot.fix),
406        location: bytes.to_vec(),
407        latitude_deg: placed.map(|(latitude, _)| latitude.into()),
408        longitude_deg: placed.map(|(_, longitude)| longitude.into()),
409        location_cell_meters: (!bytes.is_empty())
410            .then(|| ulcp_location_cell_meters(bytes.len() as u8))
411            .flatten(),
412        altitude_m: snapshot.altitude_m,
413        accuracy_dm: snapshot.accuracy_dm,
414        satellites_used: snapshot.sats_used,
415        satellites_in_view: snapshot.sats_in_view,
416    }
417}
418
419/// Approximate width, at the equator, of the cell one location precision
420/// names — 2,500 km at one byte down to 15 cm at seven. `None` outside
421/// 1–7.
422///
423/// This is what makes a precision mean something to a person: the setting
424/// is a disclosure control, and how much it discloses is an area, not a
425/// byte count. Fractional because the finest two cells are smaller than a
426/// meter, which a whole number could only report as zero.
427#[uniffi::export]
428pub fn ulcp_location_cell_meters(precision_bytes: u8) -> Option<f64> {
429    // 360° of longitude divided into 16^N cells, at 111,320 m per degree.
430    (1..=MAX_PRECISION)
431        .contains(&precision_bytes)
432        .then(|| 360.0 * 111_320.0 / 16f64.powi(precision_bytes.into()))
433}
434
435/// `PROP_ALERT`: what the radio is doing to make itself findable.
436#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
437pub enum UlcpAlertState {
438    /// Nothing; the nominal state.
439    None,
440    /// The radio is making itself as conspicuous as its hardware allows
441    /// — beeping, flashing, or both, depending on the board.
442    Locate,
443}
444
445impl UlcpAlertState {
446    fn from_wire(state: AlertState) -> Self {
447        match state {
448            AlertState::None => Self::None,
449            AlertState::Locate => Self::Locate,
450        }
451    }
452
453    fn to_wire(self) -> AlertState {
454        match self {
455            Self::None => AlertState::None,
456            Self::Locate => AlertState::Locate,
457        }
458    }
459}
460
461/// Authoritative comparison of `PROP_HOST_KEY` with the selected phone identity.
462#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
463pub enum UlcpHostOwnership {
464    Unknown,
465    LocalIdentityUnavailable,
466    Unsupported,
467    Unclaimed,
468    Ours,
469    OtherHost,
470}
471
472/// Typed state published after each bounded ULCP-session transition.
473///
474/// Not `Eq`: a position is degrees, and floating point has no total
475/// equality to offer.
476#[derive(Clone, Debug, PartialEq, uniffi::Record)]
477pub struct UlcpSessionSnapshotRecord {
478    pub generation: u64,
479    pub phase: UlcpSessionPhase,
480    pub host_ownership: UlcpHostOwnership,
481    pub device_key: Option<Vec<u8>>,
482    pub device_name: Option<String>,
483    pub battery: Option<UlcpBatteryRecord>,
484    /// `PROP_ALERT`, or `None` on a radio without `CAP_ALERT`.
485    ///
486    /// Unlike `battery`, this is carried on *every* snapshot rather than
487    /// reported once: it is state the UI mirrors, and the radio ends an
488    /// alert on its own — a button press or its deadline — so the button
489    /// must follow the radio rather than what the phone last asked for.
490    pub alert: Option<UlcpAlertState>,
491    /// A clock reading that arrived with this update, on a `CAP_TIME`
492    /// device. Reported once — see [`UlcpTimeRecord`].
493    pub time: Option<UlcpTimeRecord>,
494    /// What the receiver reports, on a `CAP_GNSS` device, or `None` until
495    /// the first positioning property is read. Mirrored like `alert`
496    /// rather than taken like `battery`.
497    pub gnss: Option<UlcpGnssRecord>,
498    pub provisioning: Option<UlcpSyncRecord>,
499}
500
501/// What the platform adapter should do after a completed raw PHY request.
502#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
503pub enum UlcpRawTransmitDisposition {
504    Sent,
505    Retry,
506    Rejected,
507}
508
509/// Typed completion of one host-requested raw PHY transmission.
510#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
511pub struct UlcpRawTransmitResultRecord {
512    pub transaction_id: u8,
513    pub status_code: u32,
514    pub status_name: String,
515    pub disposition: UlcpRawTransmitDisposition,
516}
517
518/// A correlated CRP operation completed with a non-OK `PROP_LAST_STATUS`.
519/// This is an operation failure, never evidence that the transport framing is
520/// corrupt or that the BLE connection should be closed.
521#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
522pub struct UlcpOperationErrorRecord {
523    pub operation: String,
524    pub status_code: u32,
525    pub status_name: String,
526}
527
528/// Work produced by the Rust ULCP session. Frames are complete ULCP
529/// frames; the platform adapter remains responsible for GATT segmentation and
530/// write backpressure.
531#[derive(Clone, Debug, PartialEq, uniffi::Record)]
532pub struct UlcpSessionUpdateRecord {
533    pub outbound_frames: Vec<Vec<u8>>,
534    pub received_frames: Vec<UlcpReceivedFrameRecord>,
535    pub snapshot: UlcpSessionSnapshotRecord,
536    pub waiting_for_responses: bool,
537    /// True while one host-requested raw PHY transmission is awaiting the
538    /// radio's `PROP_LAST_STATUS` completion.
539    pub raw_transmit_pending: bool,
540    /// Transaction allocated by `transmit_raw` in this update, if any.
541    pub raw_transmit_started_transaction_id: Option<u8>,
542    /// Completion for the raw PHY transmission consumed by this update.
543    /// Rejections are ordinary radio-level send failures, not malformed
544    /// ULCP frames.
545    pub raw_transmit_result: Option<UlcpRawTransmitResultRecord>,
546    /// Non-transmit operation error consumed by this update. The ULCP
547    /// session has already recovered to a stable stage and remains usable.
548    pub operation_error: Option<UlcpOperationErrorRecord>,
549}
550
551/// One validated raw mesh frame delivered by the companion radio.
552#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
553pub struct UlcpReceivedFrameRecord {
554    pub data: Vec<u8>,
555    pub rssi_dbm: Option<i16>,
556    pub lqi: Option<u8>,
557    pub snr_cb: Option<i16>,
558    pub was_buffered: bool,
559    pub was_acknowledged: bool,
560    pub age_seconds: u32,
561}
562
563#[derive(Clone, Copy, Debug, PartialEq, Eq)]
564enum SessionStage {
565    Idle,
566    Initial,
567    Inspection,
568    Refreshing,
569    Claiming,
570    Saving,
571    Configuring,
572    SavingConfiguration,
573    AwaitingHost,
574    Attached,
575}
576
577#[derive(Clone, Debug, PartialEq, Eq)]
578enum ExpectedResponse {
579    Property(u32),
580    Claim,
581    Save,
582    /// A `CMD_PROP_SET` of this property. The value written is not kept:
583    /// the device's `CMD_PROP_IS` is what the property is worth, so there
584    /// is nothing to compare it against.
585    ConfigurationProperty(u32),
586    SaveConfiguration,
587    RawTransmit,
588    /// A `CMD_PROP_INSERT` of this key into `PROP_DEV_PEERS`.
589    DevPeerInsert(Vec<u8>),
590    /// A `CMD_PROP_REMOVE` of this key from `PROP_DEV_PEERS`.
591    DevPeerRemove(Vec<u8>),
592    /// The `CMD_SAVE` chained behind a device-peer mutation.
593    SaveDevPeers,
594    /// A `CMD_PROP_INSERT` into `PROP_DEV_CHANNEL_KEYS`. Carries the derived
595    /// identifier rather than the key, because the device confirms a channel
596    /// mutation by echoing the identifier — key material is never read back.
597    DevChannelInsert(Vec<u8>),
598    /// A `CMD_PROP_REMOVE` from `PROP_DEV_CHANNEL_KEYS`, selected by key and
599    /// confirmed by identifier.
600    DevChannelRemove(Vec<u8>),
601    /// The `CMD_SAVE` chained behind a device-channel mutation.
602    SaveDevChannels,
603    /// One `CMD_PROP_INSERT` in the host channel-key reconciliation, carrying
604    /// the keys still to be sent. `ALREADY` is success here: a channel key is
605    /// its own item, so a duplicate insert asserts a state that already holds.
606    HostChannelInsert(VecDeque<Vec<u8>>),
607    /// The whole-table `CMD_PROP_SET` used when the device holds a channel
608    /// this phone cannot name, and so cannot select for removal.
609    HostChannelReplace,
610}
611
612struct UlcpSessionState {
613    generation: u64,
614    /// Which relationship this session represents. Held here, not only on
615    /// the object, because ownership resolution is what it changes: an
616    /// administrative session reports foreign ownership truthfully but
617    /// never waits for a host decision it will not make.
618    mode: UlcpAttachMode,
619    stage: SessionStage,
620    tids: TidAllocator,
621    expected: HashMap<u8, ExpectedResponse>,
622    selected_host_key: Option<[u8; 32]>,
623    radio_host_key: Option<Vec<u8>>,
624    host_key_unsupported: bool,
625    responses: HashMap<u32, UlcpPropertyFrameRecord>,
626    inspection_queue: VecDeque<u32>,
627    configuration_queue: VecDeque<(u32, Vec<u8>)>,
628    device_key: Option<Vec<u8>>,
629    device_name: Option<String>,
630    /// A battery snapshot this session has received and not yet reported.
631    ///
632    /// Deliberately *not* a cache: it is taken when an update record is
633    /// built, so `UlcpSessionSnapshotRecord::battery` means "a fresh
634    /// measurement arrived with this update" rather than "the last
635    /// measurement ever seen". Battery is live telemetry — a consumer that
636    /// timestamps what it receives would otherwise restamp a minutes-old
637    /// reading on every unrelated update and report it as current.
638    battery: Option<UlcpBatteryRecord>,
639    /// The radio's live `PROP_ALERT`, or `None` until one is read (and
640    /// permanently on a radio without `CAP_ALERT`). Held rather than
641    /// taken: it is a state to mirror, not an event to report once.
642    alert: Option<UlcpAlertState>,
643    /// A `PROP_TIME` reading not yet reported. Taken, for the reason
644    /// [`UlcpTimeRecord`] gives.
645    time: Option<UlcpTimeRecord>,
646    /// The receiver's view of the world, folded from whichever
647    /// positioning properties have arrived. Held: the properties are
648    /// announced separately, so taking it would report a fix without the
649    /// satellite count that came a frame earlier.
650    gnss: Option<GnssSnapshot>,
651    provisioning: Option<UlcpSyncRecord>,
652    stage_failure_pending: bool,
653}
654
655impl Default for UlcpSessionState {
656    fn default() -> Self {
657        Self {
658            generation: 0,
659            mode: UlcpAttachMode::Tethered,
660            stage: SessionStage::Idle,
661            tids: TidAllocator::new(),
662            expected: HashMap::new(),
663            selected_host_key: None,
664            radio_host_key: None,
665            host_key_unsupported: false,
666            responses: HashMap::new(),
667            inspection_queue: VecDeque::new(),
668            configuration_queue: VecDeque::new(),
669            device_key: None,
670            device_name: None,
671            battery: None,
672            alert: None,
673            time: None,
674            gnss: None,
675            provisioning: None,
676            stage_failure_pending: false,
677        }
678    }
679}
680
681/// The two relationships a phone can have with a radio.
682///
683/// They are different things with different lifecycles, and Swift should
684/// model them as different objects: "my radio" is exactly one, tethered,
685/// and re-provisioned on every attach; "radios I administer" is any
686/// number, configured but never claimed. One list must not serve both.
687#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, uniffi::Enum)]
688pub enum UlcpAttachMode {
689    /// This phone is the radio's tethered host: it claims the radio and
690    /// the radio filters, queues and acknowledges on its behalf.
691    #[default]
692    Tethered,
693    /// This phone is administering the radio without claiming it. A
694    /// phone commissioning ten repeaters must not write its host key on
695    /// any of them.
696    Administrative,
697}
698
699/// Stateful mobile host session for the ULCP.
700///
701/// This is the protocol boundary: it consumes complete reassembled ULCP
702/// frames and owns TIDs, response matching, capability-driven synchronization,
703/// host ownership, and claim/save choreography. Platform code owns only the
704/// transport lifecycle, byte shuttling, and timers.
705#[derive(uniffi::Object)]
706pub struct MobileUlcpSession {
707    inner: Mutex<UlcpSessionState>,
708    mode: UlcpAttachMode,
709}
710
711#[uniffi::export]
712impl MobileUlcpSession {
713    /// A session for the phone's own radio: the one it tethers to.
714    #[uniffi::constructor]
715    pub fn new() -> Arc<Self> {
716        Arc::new(Self::with_mode(UlcpAttachMode::Tethered))
717    }
718
719    /// A session for a radio this phone administers but does not claim.
720    /// [`Self::claim`] is refused; everything else behaves identically.
721    #[uniffi::constructor]
722    pub fn administrative() -> Arc<Self> {
723        Arc::new(Self::with_mode(UlcpAttachMode::Administrative))
724    }
725
726    /// Which relationship this session represents.
727    pub fn attach_mode(&self) -> UlcpAttachMode {
728        self.mode
729    }
730
731    /// Begin post-attach synchronization for a new transport generation.
732    pub fn begin(
733        &self,
734        selected_host_key: Option<Vec<u8>>,
735    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
736        let selected_host_key = selected_host_key
737            .map(|key| {
738                key.try_into()
739                    .map_err(|_| MobileError::InvalidPublicKeyLength)
740            })
741            .transpose()?;
742        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
743        let generation = state.generation.wrapping_add(1);
744        *state = UlcpSessionState {
745            generation,
746            mode: self.mode,
747            stage: SessionStage::Initial,
748            selected_host_key,
749            ..UlcpSessionState::default()
750        };
751
752        let mut outbound = Vec::new();
753        for property in [
754            prop::LAST_STATUS,
755            prop::PROTOCOL_VERSION,
756            prop::CAPS,
757            prop::DEV_KEY,
758            prop::DEV_NAME,
759            prop::BATTERY,
760            prop::HOST_KEY,
761        ] {
762            outbound.push(state.get_property(property)?);
763        }
764        Ok(state.update(outbound))
765    }
766
767    /// Replace an unclaimed or other-host configuration with this phone's key.
768    pub fn claim(&self, host_key: Vec<u8>) -> Result<UlcpSessionUpdateRecord, MobileError> {
769        // Commissioning is not tethering: an administrative session
770        // configures the radio's own domain and never writes a host key.
771        if self.mode == UlcpAttachMode::Administrative {
772            return Err(MobileError::AdministrativeSession);
773        }
774        let host_key: [u8; 32] = host_key
775            .try_into()
776            .map_err(|_| MobileError::InvalidPublicKeyLength)?;
777        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
778        if state.stage != SessionStage::AwaitingHost
779            || !matches!(
780                state.ownership(),
781                UlcpHostOwnership::Unclaimed | UlcpHostOwnership::OtherHost
782            )
783        {
784            return Err(MobileError::InvalidUlcpFrame);
785        }
786        state.selected_host_key = Some(host_key);
787        state.stage = SessionStage::Claiming;
788        state.expected.clear();
789        let tid = state.allocate_tid();
790        state.expected.insert(tid, ExpectedResponse::Claim);
791        let frame = ulcp_prop_set(tid, prop::HOST_KEY, host_key.to_vec())?;
792        Ok(state.update(vec![frame]))
793    }
794
795    /// Erase ALL mutable state on the radio (saved provisioning, device
796    /// identity, BLE bonds, pairing PIN, every persisted journal) and
797    /// reboot it. The radio does not reply — the reset drops the link —
798    /// so this is fire-and-forget: send the frame, then treat the ensuing
799    /// disconnect as completion. Permitted from any stage so a misbehaving
800    /// radio can always be wiped; unlike `claim`/`configure` it makes no
801    /// stage or ownership demands.
802    pub fn factory_reset(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
803        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
804        let tid = state.allocate_tid();
805        // Deliberately no ExpectedResponse: the device wipes storage and
806        // reboots without answering, so `update` reports
807        // waiting_for_responses = false and the caller does not block.
808        let frame = ulcp_factory_reset(tid)?;
809        Ok(state.update(vec![frame]))
810    }
811
812    /// Start or stop the radio's locate alert (`PROP_ALERT`) so a
813    /// misplaced radio can be found.
814    ///
815    /// Not part of `configure_device`, and never saved: this is live
816    /// behavior rather than configuration, and it deliberately survives
817    /// the phone walking out of BLE range — which is precisely when a
818    /// search needs it. What ends it is this call, a button press at the
819    /// radio, or the radio's own deadline; the latter two arrive as an
820    /// unsolicited `PROP_ALERT` carried on the session snapshot.
821    ///
822    /// Re-sending `Locate` while an alert is running restarts that
823    /// deadline, which is how a longer search keeps the alert alive.
824    pub fn set_alert(&self, state: UlcpAlertState) -> Result<UlcpSessionUpdateRecord, MobileError> {
825        let mut session = self.inner.lock().expect("ULCP session mutex poisoned");
826        if session.stage != SessionStage::Attached {
827            return Err(MobileError::InvalidUlcpFrame);
828        }
829        if !session.has_capability(cap::ALERT)? {
830            return Err(MobileError::UnsupportedCapability);
831        }
832        let mut value = [0u8; pui::MAX_LEN];
833        let len = pui::encode(state.to_wire().code(), &mut value)
834            .map_err(|_| MobileError::InvalidUlcpFrame)?;
835        let tid = session.allocate_tid();
836        session
837            .expected
838            .insert(tid, ExpectedResponse::Property(prop::ALERT));
839        let frame = ulcp_prop_set(tid, prop::ALERT, value[..len].to_vec())?;
840        Ok(session.update(vec![frame]))
841    }
842
843    /// Set — or clear — the device's wall clock (`PROP_TIME`).
844    ///
845    /// Live state rather than configuration, and never saved: an epoch
846    /// written to flash would come back arbitrarily wrong, since nothing
847    /// bounds how long a device spends powered off. So this is not part
848    /// of [`Self::configure_device`], which carries the time *zone* —
849    /// where the device is meant to be is worth persisting even when what
850    /// time it is is not.
851    ///
852    /// `None` clears the clock back to unknown, which is what a device
853    /// reports before its first fix. On a device whose receiver is
854    /// trusted for time, a fix will overwrite whatever is set here.
855    ///
856    /// The device answers with the epoch it now holds; that answer, not
857    /// the value written, is what the session snapshot reports.
858    pub fn set_time(
859        &self,
860        epoch_seconds: Option<u32>,
861    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
862        let mut session = self.inner.lock().expect("ULCP session mutex poisoned");
863        if session.stage != SessionStage::Attached {
864            return Err(MobileError::InvalidUlcpFrame);
865        }
866        if !session.has_capability(cap::TIME)? {
867            return Err(MobileError::UnsupportedCapability);
868        }
869        let value = epoch_seconds
870            .map(|epoch| epoch.to_le_bytes().to_vec())
871            .unwrap_or_default();
872        let tid = session.allocate_tid();
873        session
874            .expected
875            .insert(tid, ExpectedResponse::Property(prop::TIME));
876        let frame = ulcp_prop_set(tid, prop::TIME, value)?;
877        Ok(session.update(vec![frame]))
878    }
879
880    /// Apply, verify, and persist a complete radio-settings snapshot.
881    pub fn configure(
882        &self,
883        settings: UlcpRadioSettingsRecord,
884    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
885        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
886        if state.stage != SessionStage::Attached {
887            return Err(MobileError::InvalidUlcpFrame);
888        }
889        validate_radio_settings(&settings, &state)?;
890
891        state.expected.clear();
892        state.configuration_queue = state.writable(configuration_values(settings, Vec::new()));
893        let mut outbound = Vec::new();
894        state.start_configuration(&mut outbound)?;
895        Ok(state.update(outbound))
896    }
897
898    /// Apply, verify, and persist a complete configuration of the device's
899    /// own domain: its radio, the role it advertises, and whether and how
900    /// it forwards for the mesh on its own.
901    ///
902    /// This is what commissioning writes. It touches nothing in the host
903    /// domain — no host key, no filters, no queues — so it is equally
904    /// valid from an administrative session on someone else's radio and
905    /// from a tethered session on this phone's own.
906    pub fn configure_device(
907        &self,
908        configuration: UlcpDeviceConfigRecord,
909    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
910        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
911        if state.stage != SessionStage::Attached {
912            return Err(MobileError::InvalidUlcpFrame);
913        }
914        validate_radio_settings(&configuration.radio, &state)?;
915        let device_values = validate_device_settings(&configuration, &state)?;
916
917        state.expected.clear();
918        state.configuration_queue =
919            state.writable(configuration_values(configuration.radio, device_values));
920        let mut outbound = Vec::new();
921        state.start_configuration(&mut outbound)?;
922        Ok(state.update(outbound))
923    }
924
925    /// Apply and persist the time zone and the positioning policy, and
926    /// nothing else.
927    ///
928    /// [`Self::configure_device`] can write these too, as part of a whole
929    /// device domain — that is what commissioning does. This exists for
930    /// the case commissioning does not cover: a phone changing the
931    /// positioning settings of the radio it is *tethered* to, which has
932    /// no reason to restate that radio's role, discoverability, or
933    /// forwarding policy in order to switch a receiver on.
934    ///
935    /// Each argument must be present exactly when the device advertises
936    /// the matching capability, and the four positioning properties
937    /// travel together for the reason [`UlcpGnssSettingsRecord`] gives.
938    /// The write is echo-verified property by property and closed with a
939    /// save, like any other configuration pass.
940    pub fn configure_positioning(
941        &self,
942        gnss: Option<UlcpGnssSettingsRecord>,
943        tz_offset_min: Option<i16>,
944    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
945        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
946        if state.stage != SessionStage::Attached {
947            return Err(MobileError::InvalidUlcpFrame);
948        }
949        let values = positioning_values(gnss, tz_offset_min, &state)?;
950        // A radio with neither capability has nothing here to configure,
951        // which is a caller mistake rather than an empty success.
952        if values.is_empty() {
953            return Err(MobileError::UnsupportedCapability);
954        }
955
956        state.expected.clear();
957        state.configuration_queue = state.writable(values);
958        let mut outbound = Vec::new();
959        state.start_configuration(&mut outbound)?;
960        Ok(state.update(outbound))
961    }
962
963    /// Apply and persist the advertisement policy, and nothing else.
964    ///
965    /// The tethered-radio counterpart of [`Self::configure_positioning`]:
966    /// a phone changing how often its own radio announces itself has no
967    /// reason to restate that radio's role, forwarding policy, or
968    /// receiver settings to do it.
969    pub fn configure_advertising(
970        &self,
971        advert: Option<UlcpAdvertSettingsRecord>,
972    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
973        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
974        if state.stage != SessionStage::Attached {
975            return Err(MobileError::InvalidUlcpFrame);
976        }
977        let values = advert_values(advert, &state)?;
978        // A radio that announces nothing on its own has nothing here to
979        // configure, which is a caller mistake rather than a no-op.
980        if values.is_empty() {
981            return Err(MobileError::UnsupportedCapability);
982        }
983
984        state.expected.clear();
985        state.configuration_queue = state.writable(values);
986        let mut outbound = Vec::new();
987        state.start_configuration(&mut outbound)?;
988        Ok(state.update(outbound))
989    }
990
991    /// Re-read every capability-gated property represented by the mobile
992    /// snapshot. The existing snapshot remains usable while the bounded
993    /// refresh is in flight; authoritative provisioning is published when
994    /// the full capability-gated read completes.
995    pub fn refresh(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
996        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
997        if state.stage != SessionStage::Attached || !state.expected.is_empty() {
998            return Err(MobileError::InvalidUlcpFrame);
999        }
1000        let capabilities = state
1001            .responses
1002            .get(&prop::CAPS)
1003            .ok_or(MobileError::InvalidUlcpFrame)?
1004            .value
1005            .clone();
1006        state.inspection_queue = ulcp_refresh_properties(capabilities)?.into();
1007        let mut outbound = Vec::new();
1008        state.start_refresh(&mut outbound)?;
1009        Ok(state.update(outbound))
1010    }
1011
1012    /// Sample where the device is, and how well it knows.
1013    ///
1014    /// The device announces a fix indicator and nothing else about a
1015    /// position — a receiver reports about a fix a second and ordinary
1016    /// noise moves the reading, so announcing any of this would keep the
1017    /// radio transmitting for a host that may not be looking. A host that
1018    /// *is* looking asks, at whatever rate it can use the answer.
1019    ///
1020    /// Deliberately narrower than [`refresh`](Self::refresh): the five
1021    /// positioning properties and nothing else, so a screen watching a
1022    /// position does not re-read the PHY triple every time it looks.
1023    pub fn refresh_positioning(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
1024        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1025        if state.stage != SessionStage::Attached || !state.expected.is_empty() {
1026            return Err(MobileError::InvalidUlcpFrame);
1027        }
1028        let capabilities = state
1029            .responses
1030            .get(&prop::CAPS)
1031            .ok_or(MobileError::InvalidUlcpFrame)?
1032            .value
1033            .clone();
1034        // A device without the capability has nothing to sample, and
1035        // asking anyway would earn a refusal per property.
1036        if !decode_capabilities(&capabilities)?.contains(&cap::GNSS) {
1037            return Err(MobileError::InvalidUlcpFrame);
1038        }
1039        state.inspection_queue = VecDeque::from(vec![
1040            prop::GNSS_LOCATION,
1041            prop::GNSS_ALTITUDE,
1042            prop::GNSS_FIX,
1043            prop::GNSS_PRECISION,
1044            prop::GNSS_SATELLITES,
1045        ]);
1046        let mut outbound = Vec::new();
1047        state.start_refresh(&mut outbound)?;
1048        Ok(state.update(outbound))
1049    }
1050
1051    /// Store one channel key on the radio's device identity
1052    /// (`PROP_DEV_CHANNEL_KEYS`), then persist with a chained `CMD_SAVE` when
1053    /// the device can.
1054    ///
1055    /// This is the device's own channel membership, independent of the phone's:
1056    /// it is what the device uses for its own advertisements, blind-unicast
1057    /// addressing, and repeater filtering, and it survives host replacement.
1058    ///
1059    /// Requires an attached, otherwise-idle session on a device advertising
1060    /// `CAP_DEV_IDENTITY`, and the device additionally requires an encrypted
1061    /// link before it will accept key material. Failures surface as
1062    /// `operation_error` with the device's status name — `NOMEM` when the list
1063    /// is full (capacity [`ulcp_max_dev_channels`]), `ALREADY` when the key is
1064    /// already stored, which callers should treat as success.
1065    pub fn insert_device_channel_key(
1066        &self,
1067        channel_key: Vec<u8>,
1068    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1069        let id = dev_channel_id(&channel_key)?;
1070        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1071        state.begin_dev_peer_operation()?;
1072        let tid = state.allocate_tid();
1073        state
1074            .expected
1075            .insert(tid, ExpectedResponse::DevChannelInsert(id));
1076        let frame = ulcp_prop_insert(tid, prop::DEV_CHANNEL_KEYS, &channel_key)?;
1077        Ok(state.update(vec![frame]))
1078    }
1079
1080    /// Remove one channel key from the radio's device identity
1081    /// (`PROP_DEV_CHANNEL_KEYS`), then persist with a chained `CMD_SAVE` when
1082    /// the device can.
1083    ///
1084    /// The remove selector is the key itself, so only a channel the caller
1085    /// still holds the key for can be removed this way. Same preconditions as
1086    /// [`Self::insert_device_channel_key`]; `ITEM_NOT_FOUND` surfaces as
1087    /// `operation_error` and callers should treat it as success.
1088    pub fn remove_device_channel_key(
1089        &self,
1090        channel_key: Vec<u8>,
1091    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1092        let id = dev_channel_id(&channel_key)?;
1093        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1094        state.begin_dev_peer_operation()?;
1095        let tid = state.allocate_tid();
1096        state
1097            .expected
1098            .insert(tid, ExpectedResponse::DevChannelRemove(id));
1099        let frame = ulcp_prop_remove(tid, prop::DEV_CHANNEL_KEYS, &channel_key)?;
1100        Ok(state.update(vec![frame]))
1101    }
1102
1103    /// Make the radio's host channel-key table (`PROP_HOST_CHANNEL_KEYS`)
1104    /// match the phone identity's joined channels.
1105    ///
1106    /// The radio needs these keys to recognize multicast and blind-unicast
1107    /// traffic addressed to channels this phone has joined, and to queue it
1108    /// while the phone is away. That is bookkeeping between the app and its
1109    /// own radio, not a user-facing setting: callers reconcile on attach and
1110    /// after every join or leave, and never surface it.
1111    ///
1112    /// The host domain is volatile — the device does not persist it — so no
1113    /// `CMD_SAVE` is chained and reconciling on attach is what makes it stick.
1114    /// Requires an attached, idle session on a device advertising
1115    /// `CAP_HOST_KEYS`; otherwise the table is not this session's to manage
1116    /// and the call reports that the capability is missing.
1117    ///
1118    /// Returns without any frames when the device already holds exactly the
1119    /// requested set.
1120    pub fn reconcile_host_channel_keys(
1121        &self,
1122        keys: Vec<Vec<u8>>,
1123    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1124        let mut desired = VecDeque::with_capacity(keys.len());
1125        let mut desired_ids = Vec::with_capacity(keys.len());
1126        for key in keys {
1127            desired_ids.push(dev_channel_id(&key)?);
1128            desired.push_back(key);
1129        }
1130
1131        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1132        if state.stage != SessionStage::Attached || !state.expected.is_empty() {
1133            return Err(MobileError::InvalidUlcpFrame);
1134        }
1135        if !state.has_capability(cap::HOST_KEYS)? {
1136            return Err(MobileError::UnsupportedCapability);
1137        }
1138
1139        let current = state
1140            .responses
1141            .get(&prop::HOST_CHANNEL_KEYS)
1142            .map(|entry| entry.value.clone())
1143            .unwrap_or_default();
1144        let current_ids: Vec<Vec<u8>> = current
1145            .chunks(items::CHANNEL_ID_LEN)
1146            .map(<[u8]>::to_vec)
1147            .collect();
1148
1149        // Shedding a channel needs its key as the remove selector, and an
1150        // identifier this phone cannot derive is one whose key it does not
1151        // hold. The table is small, so one whole-table write says everything.
1152        if current_ids.iter().any(|id| !desired_ids.contains(id)) {
1153            let table: Vec<u8> = desired.iter().flatten().copied().collect();
1154            let tid = state.allocate_tid();
1155            state
1156                .expected
1157                .insert(tid, ExpectedResponse::HostChannelReplace);
1158            state.set_host_channel_ids(&desired_ids);
1159            let frame = ulcp_prop_set(tid, prop::HOST_CHANNEL_KEYS, table)?;
1160            return Ok(state.update(vec![frame]));
1161        }
1162
1163        desired.retain(|key| {
1164            !current_ids
1165                .iter()
1166                .any(|id| dev_channel_id(key).is_ok_and(|derived| &derived == id))
1167        });
1168        match state.next_host_channel_insert(desired) {
1169            Some(frame) => Ok(state.update(vec![frame])),
1170            None => Ok(state.update(Vec::new())),
1171        }
1172    }
1173
1174    /// Store one peer public key on the radio's device identity
1175    /// (`PROP_DEV_PEERS`), then persist with a chained `CMD_SAVE` when the
1176    /// device can.
1177    ///
1178    /// Requires an attached, otherwise-idle session on a device advertising
1179    /// `CAP_DEV_IDENTITY`. Failures surface as `operation_error` with the
1180    /// device's status name — `NOMEM` when the list is full (capacity
1181    /// [`ulcp_max_dev_peers`]), `ALREADY` when the key is already stored,
1182    /// which callers should treat as success.
1183    pub fn insert_device_peer(
1184        &self,
1185        public_key: Vec<u8>,
1186    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1187        let public_key: [u8; 32] = public_key
1188            .try_into()
1189            .map_err(|_| MobileError::InvalidPublicKeyLength)?;
1190        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1191        state.begin_dev_peer_operation()?;
1192        let tid = state.allocate_tid();
1193        state
1194            .expected
1195            .insert(tid, ExpectedResponse::DevPeerInsert(public_key.to_vec()));
1196        let frame = ulcp_prop_insert(tid, prop::DEV_PEERS, &public_key)?;
1197        Ok(state.update(vec![frame]))
1198    }
1199
1200    /// Remove one peer public key from the radio's device identity
1201    /// (`PROP_DEV_PEERS`), then persist with a chained `CMD_SAVE` when the
1202    /// device can.
1203    ///
1204    /// Same preconditions as [`Self::insert_device_peer`]. `ITEM_NOT_FOUND`
1205    /// surfaces as `operation_error` and callers should treat it as success —
1206    /// the key is not on the device either way.
1207    pub fn remove_device_peer(
1208        &self,
1209        public_key: Vec<u8>,
1210    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1211        let public_key: [u8; 32] = public_key
1212            .try_into()
1213            .map_err(|_| MobileError::InvalidPublicKeyLength)?;
1214        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1215        state.begin_dev_peer_operation()?;
1216        let tid = state.allocate_tid();
1217        state
1218            .expected
1219            .insert(tid, ExpectedResponse::DevPeerRemove(public_key.to_vec()));
1220        let frame = ulcp_prop_remove(tid, prop::DEV_PEERS, &public_key)?;
1221        Ok(state.update(vec![frame]))
1222    }
1223
1224    /// Queue one complete raw UMSH frame on `STR_PHY_RAW`.
1225    ///
1226    /// The platform adapter supplies only opaque bytes from `MobileMeshSession`;
1227    /// Rust owns the ULCP command, stream identifier, metadata, TID, and
1228    /// confirmation matching. `nocca` sets `TX_FLAG_NOCCA` so the device
1229    /// transmits without its pre-transmit channel-activity check — used for
1230    /// immediate MAC acks (see [`MobileMeshOutboundFrameRecord::nocca`]).
1231    pub fn transmit_raw(
1232        &self,
1233        data: Vec<u8>,
1234        nocca: bool,
1235    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1236        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1237        let raw_pipeline_active = state
1238            .expected
1239            .values()
1240            .all(|expected| matches!(expected, ExpectedResponse::RawTransmit));
1241        if state.stage != SessionStage::Attached || !raw_pipeline_active || data.is_empty() {
1242            return Err(MobileError::InvalidUlcpFrame);
1243        }
1244        let mut available_tid = None;
1245        for _ in 0..usize::from(frame::TID_MAX) {
1246            let candidate = state.allocate_tid();
1247            if !state.expected.contains_key(&candidate) {
1248                available_tid = Some(candidate);
1249                break;
1250            }
1251        }
1252        let tid = available_tid.ok_or(MobileError::InvalidUlcpFrame)?;
1253        state.expected.insert(tid, ExpectedResponse::RawTransmit);
1254        let mut metadata = [0u8; umsh_ulcp::TxMeta::WIRE_LEN];
1255        let flags = if nocca {
1256            umsh_ulcp::meta::TX_FLAG_NOCCA
1257        } else {
1258            0
1259        };
1260        umsh_ulcp::TxMeta {
1261            flags,
1262            ..umsh_ulcp::TxMeta::default()
1263        }
1264        .encode(&mut metadata)
1265        .map_err(|_| MobileError::InvalidUlcpFrame)?;
1266        let mut frame = vec![0u8; data.len() + 16];
1267        let len = umsh_ulcp::frame::str_send(
1268            &mut frame,
1269            tid,
1270            umsh_ulcp::ids::stream::PHY_RAW,
1271            &data,
1272            &metadata,
1273        )
1274        .map_err(|_| MobileError::InvalidUlcpFrame)?;
1275        frame.truncate(len);
1276        let mut update = state.update(vec![frame]);
1277        update.raw_transmit_started_transaction_id = Some(tid);
1278        Ok(update)
1279    }
1280
1281    /// Consume one complete ULCP frame and advance the session reducer.
1282    pub fn consume(&self, frame: Vec<u8>) -> Result<UlcpSessionUpdateRecord, MobileError> {
1283        let parsed = Frame::parse(&frame).map_err(|_| MobileError::InvalidUlcpFrame)?;
1284        if parsed.command() == Some(Cmd::StrRecv) {
1285            if parsed.header.tid() != frame::TID_UNSOLICITED {
1286                return Err(MobileError::InvalidUlcpFrame);
1287            }
1288            let payload =
1289                StreamPayload::parse(parsed.payload).map_err(|_| MobileError::InvalidUlcpFrame)?;
1290            if payload.stream != umsh_ulcp::ids::stream::PHY_RAW {
1291                return Err(MobileError::InvalidUlcpFrame);
1292            }
1293            let metadata = BufferedRxMeta::decode(payload.metadata)
1294                .map_err(|_| MobileError::InvalidUlcpFrame)?;
1295            let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1296            if state.stage == SessionStage::Idle {
1297                return Err(MobileError::InvalidUlcpFrame);
1298            }
1299            return Ok(state.update_with_received(vec![UlcpReceivedFrameRecord {
1300                data: payload.data.to_vec(),
1301                rssi_dbm: metadata.rx.rssi_dbm,
1302                lqi: metadata.rx.lqi.map(core::num::NonZeroU8::get),
1303                snr_cb: metadata.rx.snr_cb,
1304                was_buffered: metadata.flags & RX_FLAG_BUFFERED != 0,
1305                was_acknowledged: metadata.flags & RX_FLAG_ACKED != 0,
1306                age_seconds: metadata.age_s,
1307            }]));
1308        }
1309        let response = inspect_ulcp_property_frame(frame)?;
1310        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1311        let mut outbound = Vec::new();
1312        let mut raw_transmit_result = None;
1313        let mut operation_error = None;
1314
1315        if response.transaction_id == frame::TID_UNSOLICITED {
1316            if response.command == Cmd::PropIs as u8 {
1317                state
1318                    .responses
1319                    .insert(response.property_id, response.clone());
1320            }
1321            state.apply_property(&response)?;
1322            state.refresh_attached_snapshot(Some(response.property_id))?;
1323            return Ok(state.update(outbound));
1324        }
1325
1326        let expected = state
1327            .expected
1328            .remove(&response.transaction_id)
1329            .ok_or(MobileError::InvalidUlcpFrame)?;
1330        match expected {
1331            ExpectedResponse::Property(property) => {
1332                if response.property_id == prop::LAST_STATUS && property != prop::LAST_STATUS {
1333                    // A capability-gated property the device declines is one
1334                    // unknown setting, not an unusable device: drop any value
1335                    // cached from an earlier read so the reduction reports it
1336                    // as unreadable, and carry on with the rest of the queue.
1337                    let expected_property = matches!(
1338                        state.stage,
1339                        SessionStage::Inspection | SessionStage::Refreshing
1340                    );
1341                    if expected_property {
1342                        state.responses.remove(&property);
1343                    } else {
1344                        operation_error = Some(ulcp_operation_error(
1345                            format!("read property {property}"),
1346                            response.value.as_slice(),
1347                        )?);
1348                        let optional_initial_property = state.stage == SessionStage::Initial
1349                            && matches!(
1350                                property,
1351                                prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY | prop::HOST_KEY
1352                            );
1353                        state.stage_failure_pending |= !optional_initial_property;
1354                        if state.stage == SessionStage::Initial && property == prop::HOST_KEY {
1355                            state.host_key_unsupported = true;
1356                        }
1357                    }
1358                } else {
1359                    if response.property_id != property || response.command != Cmd::PropIs as u8 {
1360                        return Err(MobileError::InvalidUlcpFrame);
1361                    }
1362                    state.responses.insert(property, response.clone());
1363                    state.apply_property(&response)?;
1364                }
1365            }
1366            ExpectedResponse::Claim => {
1367                if response.property_id == prop::LAST_STATUS {
1368                    operation_error = Some(ulcp_operation_error(
1369                        "claim host identity".to_owned(),
1370                        response.value.as_slice(),
1371                    )?);
1372                    state.stage_failure_pending = true;
1373                } else {
1374                    if response.property_id != prop::HOST_KEY
1375                        || response.command != Cmd::PropIs as u8
1376                    {
1377                        return Err(MobileError::InvalidUlcpFrame);
1378                    }
1379                    // Whatever the device reports is its host key, even if
1380                    // it is not the one just written — a claim that did not
1381                    // take means this radio belongs to someone else, which
1382                    // `ownership()` reads off this value and reports as
1383                    // `OtherHost`. That is an answer, not a broken session.
1384                    state.radio_host_key = Some(response.value.clone());
1385                    state.responses.insert(prop::HOST_KEY, response);
1386                    if state.has_capability(cap::SAVE)? {
1387                        state.stage = SessionStage::Saving;
1388                        let tid = state.allocate_tid();
1389                        state.expected.insert(tid, ExpectedResponse::Save);
1390                        outbound.push(ulcp_save(tid)?);
1391                    } else {
1392                        state.start_inspection(&mut outbound)?;
1393                    }
1394                }
1395            }
1396            ExpectedResponse::Save => {
1397                if response.property_id != prop::LAST_STATUS
1398                    || response.command != Cmd::PropIs as u8
1399                {
1400                    return Err(MobileError::InvalidUlcpFrame);
1401                }
1402                if inspect_ulcp_status(response.value.clone())? != 0 {
1403                    operation_error = Some(ulcp_operation_error(
1404                        "save claimed host identity".to_owned(),
1405                        response.value.as_slice(),
1406                    )?);
1407                    state.stage_failure_pending = true;
1408                } else {
1409                    state.start_inspection(&mut outbound)?;
1410                }
1411            }
1412            ExpectedResponse::ConfigurationProperty(property) => {
1413                if response.property_id == prop::LAST_STATUS {
1414                    operation_error = Some(ulcp_operation_error(
1415                        format!("set property {property}"),
1416                        response.value.as_slice(),
1417                    )?);
1418                    state.stage_failure_pending = true;
1419                    // The status frame describes the write, not the property.
1420                    // Filing it under the property would leave the snapshot
1421                    // reducing a status code as that property's value.
1422                    state.responses.remove(&property);
1423                } else if response.property_id != property || response.command != Cmd::PropIs as u8
1424                {
1425                    return Err(MobileError::InvalidUlcpFrame);
1426                } else {
1427                    // A `CMD_PROP_IS` is the device's authoritative value,
1428                    // whatever was written. It reports what the device holds
1429                    // — clamped to hardware, reduced to what it supports,
1430                    // changed for a reason this host has no view of — and a
1431                    // value differing from the write is that report, not a
1432                    // fault. The snapshot published to the UI is what the
1433                    // device says, never what was asked for. Failure is a
1434                    // `PROP_LAST_STATUS`, handled above.
1435                    state.responses.insert(property, response.clone());
1436                    state.apply_property(&response)?;
1437                }
1438            }
1439            ExpectedResponse::SaveConfiguration => {
1440                if response.property_id != prop::LAST_STATUS
1441                    || response.command != Cmd::PropIs as u8
1442                {
1443                    return Err(MobileError::InvalidUlcpFrame);
1444                }
1445                if inspect_ulcp_status(response.value.clone())? != 0 {
1446                    operation_error = Some(ulcp_operation_error(
1447                        "save radio configuration".to_owned(),
1448                        response.value.as_slice(),
1449                    )?);
1450                    state.stage_failure_pending = true;
1451                } else {
1452                    state.finish_configuration()?;
1453                }
1454            }
1455            ExpectedResponse::RawTransmit => {
1456                if response.property_id != prop::LAST_STATUS
1457                    || response.command != Cmd::PropIs as u8
1458                {
1459                    return Err(MobileError::InvalidUlcpFrame);
1460                }
1461                let status_code = inspect_ulcp_status(response.value)?;
1462                let status = umsh_ulcp::Status(status_code);
1463                raw_transmit_result = Some(UlcpRawTransmitResultRecord {
1464                    transaction_id: response.transaction_id,
1465                    status_code,
1466                    status_name: format!("{status:?}"),
1467                    disposition: if status == umsh_ulcp::Status::OK {
1468                        UlcpRawTransmitDisposition::Sent
1469                    } else if status == umsh_ulcp::Status::BUSY
1470                        || status == umsh_ulcp::Status::CCA_FAILURE
1471                    {
1472                        // Both are transient channel-contention refusals: the
1473                        // frame never left the radio, so retry with backoff.
1474                        UlcpRawTransmitDisposition::Retry
1475                    } else {
1476                        UlcpRawTransmitDisposition::Rejected
1477                    },
1478                });
1479            }
1480            ExpectedResponse::HostChannelInsert(mut remaining) => {
1481                if response.property_id == prop::LAST_STATUS {
1482                    let error = ulcp_operation_error(
1483                        "provision host channel key".to_owned(),
1484                        response.value.as_slice(),
1485                    )?;
1486                    // A channel key is its own item, so ALREADY asserts the
1487                    // state that was asked for. Anything else — NOMEM above
1488                    // all — stops the pass; the phone still runs its own MAC
1489                    // while attached, so this degrades radio-side filtering
1490                    // rather than the user's ability to use the channel.
1491                    if error.status_code != umsh_ulcp::Status::ALREADY.0 {
1492                        operation_error = Some(error);
1493                        state.refresh_attached_snapshot(None)?;
1494                        remaining.clear();
1495                    }
1496                } else if response.property_id != prop::HOST_CHANNEL_KEYS
1497                    || response.command != Cmd::PropInserted as u8
1498                {
1499                    return Err(MobileError::InvalidUlcpFrame);
1500                }
1501                if let Some(frame) = state.next_host_channel_insert(remaining) {
1502                    outbound.push(frame);
1503                } else {
1504                    state.refresh_attached_snapshot(None)?;
1505                }
1506            }
1507            ExpectedResponse::HostChannelReplace => {
1508                if response.property_id == prop::LAST_STATUS {
1509                    operation_error = Some(ulcp_operation_error(
1510                        "provision host channel keys".to_owned(),
1511                        response.value.as_slice(),
1512                    )?);
1513                } else if response.property_id != prop::HOST_CHANNEL_KEYS
1514                    || response.command != Cmd::PropIs as u8
1515                {
1516                    return Err(MobileError::InvalidUlcpFrame);
1517                }
1518                state.refresh_attached_snapshot(None)?;
1519            }
1520            ExpectedResponse::DevChannelInsert(id) => {
1521                if response.property_id == prop::LAST_STATUS {
1522                    let error = ulcp_operation_error(
1523                        "insert device channel key".to_owned(),
1524                        response.value.as_slice(),
1525                    )?;
1526                    // ALREADY is the device saying the channel is stored.
1527                    if error.status_code == umsh_ulcp::Status::ALREADY.0 {
1528                        state.patch_dev_channels(&id, true);
1529                        state.refresh_attached_snapshot(None)?;
1530                    }
1531                    operation_error = Some(error);
1532                } else {
1533                    if response.property_id != prop::DEV_CHANNEL_KEYS
1534                        || response.command != Cmd::PropInserted as u8
1535                        || response.value != id
1536                    {
1537                        return Err(MobileError::InvalidUlcpFrame);
1538                    }
1539                    state.patch_dev_channels(&id, true);
1540                    if state.has_capability(cap::SAVE)? {
1541                        let tid = state.allocate_tid();
1542                        state
1543                            .expected
1544                            .insert(tid, ExpectedResponse::SaveDevChannels);
1545                        outbound.push(ulcp_save(tid)?);
1546                    }
1547                    state.refresh_attached_snapshot(None)?;
1548                }
1549            }
1550            ExpectedResponse::DevChannelRemove(id) => {
1551                if response.property_id == prop::LAST_STATUS {
1552                    let error = ulcp_operation_error(
1553                        "remove device channel key".to_owned(),
1554                        response.value.as_slice(),
1555                    )?;
1556                    if error.status_code == umsh_ulcp::Status::ITEM_NOT_FOUND.0 {
1557                        state.patch_dev_channels(&id, false);
1558                        state.refresh_attached_snapshot(None)?;
1559                    }
1560                    operation_error = Some(error);
1561                } else {
1562                    if response.property_id != prop::DEV_CHANNEL_KEYS
1563                        || response.command != Cmd::PropRemoved as u8
1564                        || response.value != id
1565                    {
1566                        return Err(MobileError::InvalidUlcpFrame);
1567                    }
1568                    state.patch_dev_channels(&id, false);
1569                    if state.has_capability(cap::SAVE)? {
1570                        let tid = state.allocate_tid();
1571                        state
1572                            .expected
1573                            .insert(tid, ExpectedResponse::SaveDevChannels);
1574                        outbound.push(ulcp_save(tid)?);
1575                    }
1576                    state.refresh_attached_snapshot(None)?;
1577                }
1578            }
1579            ExpectedResponse::SaveDevChannels => {
1580                if response.property_id != prop::LAST_STATUS
1581                    || response.command != Cmd::PropIs as u8
1582                {
1583                    return Err(MobileError::InvalidUlcpFrame);
1584                }
1585                if inspect_ulcp_status(response.value.clone())? != 0 {
1586                    operation_error = Some(ulcp_operation_error(
1587                        "save device channel keys".to_owned(),
1588                        response.value.as_slice(),
1589                    )?);
1590                }
1591            }
1592            ExpectedResponse::DevPeerInsert(item) => {
1593                if response.property_id == prop::LAST_STATUS {
1594                    let error = ulcp_operation_error(
1595                        "insert device peer".to_owned(),
1596                        response.value.as_slice(),
1597                    )?;
1598                    // ALREADY is the device saying the key is stored; keep
1599                    // the cache truthful even though the operation "failed".
1600                    if error.status_code == umsh_ulcp::Status::ALREADY.0 {
1601                        state.patch_dev_peers(&item, true);
1602                        state.refresh_attached_snapshot(None)?;
1603                    }
1604                    operation_error = Some(error);
1605                } else {
1606                    if response.property_id != prop::DEV_PEERS
1607                        || response.command != Cmd::PropInserted as u8
1608                        || response.value != item
1609                    {
1610                        return Err(MobileError::InvalidUlcpFrame);
1611                    }
1612                    state.patch_dev_peers(&item, true);
1613                    if state.has_capability(cap::SAVE)? {
1614                        let tid = state.allocate_tid();
1615                        state.expected.insert(tid, ExpectedResponse::SaveDevPeers);
1616                        outbound.push(ulcp_save(tid)?);
1617                    }
1618                    state.refresh_attached_snapshot(None)?;
1619                }
1620            }
1621            ExpectedResponse::DevPeerRemove(item) => {
1622                if response.property_id == prop::LAST_STATUS {
1623                    let error = ulcp_operation_error(
1624                        "remove device peer".to_owned(),
1625                        response.value.as_slice(),
1626                    )?;
1627                    // ITEM_NOT_FOUND means the key is not on the device,
1628                    // which is the state the caller asked for.
1629                    if error.status_code == umsh_ulcp::Status::ITEM_NOT_FOUND.0 {
1630                        state.patch_dev_peers(&item, false);
1631                        state.refresh_attached_snapshot(None)?;
1632                    }
1633                    operation_error = Some(error);
1634                } else {
1635                    if response.property_id != prop::DEV_PEERS
1636                        || response.command != Cmd::PropRemoved as u8
1637                        || response.value != item
1638                    {
1639                        return Err(MobileError::InvalidUlcpFrame);
1640                    }
1641                    state.patch_dev_peers(&item, false);
1642                    if state.has_capability(cap::SAVE)? {
1643                        let tid = state.allocate_tid();
1644                        state.expected.insert(tid, ExpectedResponse::SaveDevPeers);
1645                        outbound.push(ulcp_save(tid)?);
1646                    }
1647                    state.refresh_attached_snapshot(None)?;
1648                }
1649            }
1650            ExpectedResponse::SaveDevPeers => {
1651                if response.property_id != prop::LAST_STATUS
1652                    || response.command != Cmd::PropIs as u8
1653                {
1654                    return Err(MobileError::InvalidUlcpFrame);
1655                }
1656                if inspect_ulcp_status(response.value.clone())? != 0 {
1657                    // The live mutation stuck; only persistence failed. The
1658                    // session stays attached and the caller sees the same
1659                    // `saved` warning path a failed configuration save uses.
1660                    operation_error = Some(ulcp_operation_error(
1661                        "save device peers".to_owned(),
1662                        response.value.as_slice(),
1663                    )?);
1664                }
1665            }
1666        }
1667
1668        if state.expected.is_empty() {
1669            if state.stage_failure_pending {
1670                state.stage_failure_pending = false;
1671                state.recover_from_operation_failure(&mut outbound)?;
1672            } else {
1673                state.advance_completed_stage(&mut outbound)?;
1674            }
1675        }
1676        Ok(state.update_with(outbound, Vec::new(), raw_transmit_result, operation_error))
1677    }
1678
1679    /// Invalidate all outstanding transactions for a disconnected transport.
1680    pub fn reset(&self) -> UlcpSessionUpdateRecord {
1681        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1682        let generation = state.generation.wrapping_add(1);
1683        *state = UlcpSessionState {
1684            generation,
1685            mode: self.mode,
1686            ..UlcpSessionState::default()
1687        };
1688        state.update(Vec::new())
1689    }
1690
1691    /// Abandon raw transactions whose GATT writes were rejected locally.
1692    /// Their late correlated responses are ignored once; the attachment and
1693    /// all non-raw session state remain intact.
1694    pub fn abandon_raw_transmits(&self, transaction_ids: Vec<u8>) -> UlcpSessionUpdateRecord {
1695        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1696        for tid in transaction_ids {
1697            if matches!(
1698                state.expected.get(&tid),
1699                Some(ExpectedResponse::RawTransmit)
1700            ) {
1701                state.expected.remove(&tid);
1702            }
1703        }
1704        state.update(Vec::new())
1705    }
1706}
1707
1708impl MobileUlcpSession {
1709    fn with_mode(mode: UlcpAttachMode) -> Self {
1710        Self {
1711            inner: Mutex::new(UlcpSessionState {
1712                mode,
1713                ..UlcpSessionState::default()
1714            }),
1715            mode,
1716        }
1717    }
1718}
1719
1720impl UlcpSessionState {
1721    fn allocate_tid(&mut self) -> u8 {
1722        self.tids.allocate()
1723    }
1724
1725    fn get_property(&mut self, property: u32) -> Result<Vec<u8>, MobileError> {
1726        let tid = self.allocate_tid();
1727        self.expected
1728            .insert(tid, ExpectedResponse::Property(property));
1729        ulcp_prop_get(tid, property)
1730    }
1731
1732    fn phase(&self) -> UlcpSessionPhase {
1733        match self.stage {
1734            SessionStage::Idle => UlcpSessionPhase::Idle,
1735            SessionStage::Initial | SessionStage::Inspection | SessionStage::Saving => {
1736                UlcpSessionPhase::Synchronizing
1737            }
1738            // A refresh deliberately preserves the attached phase so live UI
1739            // does not disappear while fresh authoritative values are read.
1740            SessionStage::Refreshing => UlcpSessionPhase::Attached,
1741            SessionStage::AwaitingHost => UlcpSessionPhase::AwaitingHost,
1742            SessionStage::Claiming => UlcpSessionPhase::Claiming,
1743            SessionStage::Configuring | SessionStage::SavingConfiguration => {
1744                UlcpSessionPhase::Configuring
1745            }
1746            SessionStage::Attached => UlcpSessionPhase::Attached,
1747        }
1748    }
1749
1750    fn ownership(&self) -> UlcpHostOwnership {
1751        if self.host_key_unsupported {
1752            return UlcpHostOwnership::Unsupported;
1753        }
1754        let Some(radio_key) = self.radio_host_key.as_deref() else {
1755            return UlcpHostOwnership::Unknown;
1756        };
1757        if radio_key.is_empty() {
1758            return UlcpHostOwnership::Unclaimed;
1759        }
1760        match self.selected_host_key {
1761            None => UlcpHostOwnership::LocalIdentityUnavailable,
1762            Some(selected) if radio_key == selected => UlcpHostOwnership::Ours,
1763            Some(_) => UlcpHostOwnership::OtherHost,
1764        }
1765    }
1766
1767    fn update(&mut self, outbound_frames: Vec<Vec<u8>>) -> UlcpSessionUpdateRecord {
1768        self.update_with(outbound_frames, Vec::new(), None, None)
1769    }
1770
1771    fn update_with_received(
1772        &mut self,
1773        received_frames: Vec<UlcpReceivedFrameRecord>,
1774    ) -> UlcpSessionUpdateRecord {
1775        self.update_with(Vec::new(), received_frames, None, None)
1776    }
1777
1778    fn update_with(
1779        &mut self,
1780        outbound_frames: Vec<Vec<u8>>,
1781        received_frames: Vec<UlcpReceivedFrameRecord>,
1782        raw_transmit_result: Option<UlcpRawTransmitResultRecord>,
1783        operation_error: Option<UlcpOperationErrorRecord>,
1784    ) -> UlcpSessionUpdateRecord {
1785        let raw_transmit_pending = self
1786            .expected
1787            .values()
1788            .any(|expected| matches!(expected, ExpectedResponse::RawTransmit));
1789        UlcpSessionUpdateRecord {
1790            outbound_frames,
1791            received_frames,
1792            snapshot: UlcpSessionSnapshotRecord {
1793                generation: self.generation,
1794                phase: self.phase(),
1795                host_ownership: self.ownership(),
1796                device_key: self.device_key.clone(),
1797                device_name: self.device_name.clone(),
1798                // Taken, not cloned: reported once, on the update that
1799                // actually carries a new measurement.
1800                battery: self.battery.take(),
1801                alert: self.alert,
1802                time: self.time.take(),
1803                gnss: self.gnss.as_ref().map(gnss_record),
1804                provisioning: self.provisioning.clone(),
1805            },
1806            waiting_for_responses: !self.expected.is_empty(),
1807            raw_transmit_pending,
1808            raw_transmit_started_transaction_id: None,
1809            raw_transmit_result,
1810            operation_error,
1811        }
1812    }
1813
1814    fn apply_property(&mut self, response: &UlcpPropertyFrameRecord) -> Result<(), MobileError> {
1815        if response.command != Cmd::PropIs as u8 {
1816            // Insert/remove notifications are valid protocol frames, but none
1817            // of the mobile snapshot fields are multi-value payloads.
1818            return Ok(());
1819        }
1820        match response.property_id {
1821            prop::DEV_KEY => {
1822                if response.value.is_empty() {
1823                    self.device_key = None;
1824                } else if response.value.len() == items::PUBLIC_KEY_LEN {
1825                    self.device_key = Some(response.value.clone());
1826                } else {
1827                    return Err(MobileError::InvalidUlcpFrame);
1828                }
1829            }
1830            prop::DEV_NAME => {
1831                let name = core::str::from_utf8(&response.value)
1832                    .map_err(|_| MobileError::InvalidUlcpFrame)?;
1833                self.device_name = (!name.is_empty()).then(|| name.to_owned());
1834            }
1835            prop::BATTERY => {
1836                self.battery = Some(inspect_ulcp_battery(response.value.clone())?);
1837            }
1838            prop::ALERT => {
1839                // Arrives both as the answer to a write and unsolicited,
1840                // when the radio ends the alert itself.
1841                self.alert = Some(inspect_ulcp_alert(response.value.clone())?);
1842            }
1843            prop::TIME => {
1844                // Announced when the clock goes from unknown to known and
1845                // whenever it steps, which is how a phone learns the
1846                // device found the time on its own.
1847                self.time = Some(UlcpTimeRecord {
1848                    epoch_seconds: decode_optional(&response.value, decode_u32)?,
1849                });
1850            }
1851            key if umsh_ulcp::gnss::is_positioning_property(key) => {
1852                // Read or announced, indifferently: a device is meant to
1853                // announce only the fix indicator and leave a position to
1854                // be sampled, but one that volunteers a position anyway is
1855                // carrying the value a read would have returned, so this
1856                // takes it. Folding rather than replacing is what lets a
1857                // single property arrive without erasing the others.
1858                self.gnss
1859                    .get_or_insert(GnssSnapshot::SEARCHING)
1860                    .absorb(key, &response.value)
1861                    .map_err(|_| MobileError::InvalidUlcpFrame)?;
1862            }
1863            prop::HOST_KEY => {
1864                if !response.value.is_empty() && response.value.len() != items::PUBLIC_KEY_LEN {
1865                    return Err(MobileError::InvalidUlcpFrame);
1866                }
1867                self.radio_host_key = Some(response.value.clone());
1868            }
1869            _ => {}
1870        }
1871        Ok(())
1872    }
1873
1874    /// Whether synchronization may proceed straight to inspection without
1875    /// pausing for the user to decide about host ownership.
1876    ///
1877    /// A tethered session must pause: it is about to become the radio's
1878    /// host, and taking a radio from another phone is a decision only the
1879    /// user can make. An administrative session never claims anything, so
1880    /// there is no decision to pause for — whose radio this is stays worth
1881    /// reporting, but only as information.
1882    fn attaches_without_host_decision(&self) -> bool {
1883        self.mode == UlcpAttachMode::Administrative
1884            || matches!(
1885                self.ownership(),
1886                UlcpHostOwnership::Ours | UlcpHostOwnership::Unsupported
1887            )
1888    }
1889
1890    /// Gate a device-peer mutation: attached, no other operation in
1891    /// flight, and the device actually has a device identity domain.
1892    fn begin_dev_peer_operation(&mut self) -> Result<(), MobileError> {
1893        if self.stage != SessionStage::Attached || !self.expected.is_empty() {
1894            return Err(MobileError::InvalidUlcpFrame);
1895        }
1896        if !self.has_capability(cap::DEV_IDENTITY)? {
1897            return Err(MobileError::InvalidUlcpFrame);
1898        }
1899        Ok(())
1900    }
1901
1902    /// Queue the next host channel-key insert, if any remain. The cached
1903    /// digest is updated as each key is accepted.
1904    fn next_host_channel_insert(&mut self, mut remaining: VecDeque<Vec<u8>>) -> Option<Vec<u8>> {
1905        let key = remaining.pop_front()?;
1906        let tid = self.allocate_tid();
1907        let frame = ulcp_prop_insert(tid, prop::HOST_CHANNEL_KEYS, &key).ok()?;
1908        self.expected
1909            .insert(tid, ExpectedResponse::HostChannelInsert(remaining));
1910        if let Ok(id) = dev_channel_id(&key) {
1911            self.push_host_channel_id(&id);
1912        }
1913        Some(frame)
1914    }
1915
1916    /// Replace the cached `PROP_HOST_CHANNEL_KEYS` digest wholesale.
1917    fn set_host_channel_ids(&mut self, ids: &[Vec<u8>]) {
1918        let value = ids.concat();
1919        self.host_channel_entry().value = value;
1920    }
1921
1922    fn push_host_channel_id(&mut self, id: &[u8]) {
1923        let entry = self.host_channel_entry();
1924        if !entry.value.chunks(items::CHANNEL_ID_LEN).any(|c| c == id) {
1925            entry.value.extend_from_slice(id);
1926        }
1927    }
1928
1929    fn host_channel_entry(&mut self) -> &mut UlcpPropertyFrameRecord {
1930        self.responses
1931            .entry(prop::HOST_CHANNEL_KEYS)
1932            .or_insert_with(|| UlcpPropertyFrameRecord {
1933                transaction_id: frame::TID_UNSOLICITED,
1934                command: Cmd::PropIs as u8,
1935                property_id: prop::HOST_CHANNEL_KEYS,
1936                value: Vec::new(),
1937            })
1938    }
1939
1940    /// Patch the cached `PROP_DEV_CHANNEL_KEYS` digest after a confirmed
1941    /// mutation. The cached value is a list of derived identifiers, so this
1942    /// tracks identifiers rather than key material.
1943    fn patch_dev_channels(&mut self, id: &[u8], present: bool) {
1944        let entry = self
1945            .responses
1946            .entry(prop::DEV_CHANNEL_KEYS)
1947            .or_insert_with(|| UlcpPropertyFrameRecord {
1948                transaction_id: frame::TID_UNSOLICITED,
1949                command: Cmd::PropIs as u8,
1950                property_id: prop::DEV_CHANNEL_KEYS,
1951                value: Vec::new(),
1952            });
1953        let mut value = Vec::with_capacity(entry.value.len() + id.len());
1954        let mut found = false;
1955        for chunk in entry.value.chunks(items::CHANNEL_ID_LEN) {
1956            if chunk == id {
1957                found = true;
1958                if !present {
1959                    continue;
1960                }
1961            }
1962            value.extend_from_slice(chunk);
1963        }
1964        if present && !found {
1965            value.extend_from_slice(id);
1966        }
1967        entry.value = value;
1968    }
1969
1970    /// Patch the cached `PROP_DEV_PEERS` table after a confirmed mutation,
1971    /// keeping it lossless without a round-trip re-read.
1972    fn patch_dev_peers(&mut self, key: &[u8], present: bool) {
1973        let entry =
1974            self.responses
1975                .entry(prop::DEV_PEERS)
1976                .or_insert_with(|| UlcpPropertyFrameRecord {
1977                    transaction_id: frame::TID_UNSOLICITED,
1978                    command: Cmd::PropIs as u8,
1979                    property_id: prop::DEV_PEERS,
1980                    value: Vec::new(),
1981                });
1982        let mut value = Vec::with_capacity(entry.value.len() + key.len());
1983        let mut found = false;
1984        for chunk in entry.value.chunks(items::PUBLIC_KEY_LEN) {
1985            if chunk == key {
1986                found = true;
1987                if !present {
1988                    continue;
1989                }
1990            }
1991            value.extend_from_slice(chunk);
1992        }
1993        if present && !found {
1994            value.extend_from_slice(key);
1995        }
1996        entry.value = value;
1997    }
1998
1999    fn has_capability(&self, capability: u32) -> Result<bool, MobileError> {
2000        let capabilities = self
2001            .responses
2002            .get(&prop::CAPS)
2003            .ok_or(MobileError::InvalidUlcpFrame)?;
2004        Ok(decode_capabilities(&capabilities.value)?.contains(&capability))
2005    }
2006
2007    /// Drop the writes the device has already refused to answer for.
2008    ///
2009    /// A capability-gated property that would not read is one the device
2010    /// does not implement, so writing it fails — and one rejected write
2011    /// abandons the whole configuration pass. The caller still states a
2012    /// complete configuration; what cannot land is left out here, where the
2013    /// device's own answers are known, rather than in the form.
2014    fn writable(&self, values: Vec<(u32, Vec<u8>)>) -> VecDeque<(u32, Vec<u8>)> {
2015        let Some(unreadable) = self
2016            .provisioning
2017            .as_ref()
2018            .map(|sync| sync.unreadable_properties.as_slice())
2019            .filter(|unreadable| !unreadable.is_empty())
2020        else {
2021            return values.into();
2022        };
2023        let dropped = |property: u32| {
2024            unreadable.contains(&property)
2025                || WHOLE_WRITE_GROUPS.iter().any(|group| {
2026                    group.contains(&property) && group.iter().any(|part| unreadable.contains(part))
2027                })
2028        };
2029        values
2030            .into_iter()
2031            .filter(|(property, _)| !dropped(*property))
2032            .collect()
2033    }
2034
2035    fn advance_completed_stage(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2036        match self.stage {
2037            SessionStage::Initial => {
2038                let version = self
2039                    .responses
2040                    .get(&prop::PROTOCOL_VERSION)
2041                    .ok_or(MobileError::InvalidUlcpFrame)?;
2042                if version.value != [PROTOCOL_MAJOR_VERSION, PROTOCOL_MINOR_VERSION] {
2043                    return Err(MobileError::InvalidUlcpFrame);
2044                }
2045                let capabilities = self
2046                    .responses
2047                    .get(&prop::CAPS)
2048                    .ok_or(MobileError::InvalidUlcpFrame)?;
2049                self.inspection_queue =
2050                    ulcp_inspection_properties(capabilities.value.clone())?.into();
2051                let advertises_host_filter = self.has_capability(cap::HOST_FILTER)?;
2052                if advertises_host_filter == self.host_key_unsupported {
2053                    return Err(MobileError::InvalidUlcpFrame);
2054                }
2055                if self.attaches_without_host_decision() {
2056                    self.start_inspection(outbound)?;
2057                } else {
2058                    self.stage = SessionStage::AwaitingHost;
2059                }
2060            }
2061            SessionStage::Inspection => self.start_inspection(outbound)?,
2062            SessionStage::Refreshing => self.start_refresh(outbound)?,
2063            SessionStage::Configuring => {
2064                if !self.configuration_queue.is_empty() {
2065                    self.start_configuration(outbound)?;
2066                } else if self.has_capability(cap::SAVE)? {
2067                    self.stage = SessionStage::SavingConfiguration;
2068                    let tid = self.allocate_tid();
2069                    self.expected
2070                        .insert(tid, ExpectedResponse::SaveConfiguration);
2071                    outbound.push(ulcp_save(tid)?);
2072                } else {
2073                    self.finish_configuration()?;
2074                }
2075            }
2076            SessionStage::Claiming
2077            | SessionStage::Saving
2078            | SessionStage::AwaitingHost
2079            | SessionStage::Attached
2080            | SessionStage::SavingConfiguration
2081            | SessionStage::Idle => {}
2082        }
2083        Ok(())
2084    }
2085
2086    /// Abort only the failed operation stage. A correlated CRP status error
2087    /// never invalidates GATT framing and therefore never resets the session.
2088    fn recover_from_operation_failure(
2089        &mut self,
2090        outbound: &mut Vec<Vec<u8>>,
2091    ) -> Result<(), MobileError> {
2092        self.configuration_queue.clear();
2093        self.inspection_queue.clear();
2094        match self.stage {
2095            SessionStage::Claiming => self.stage = SessionStage::AwaitingHost,
2096            SessionStage::Saving => {
2097                // The host-key write succeeded even if persistence did not.
2098                // Continue attaching while reporting that SAVE failed.
2099                self.start_inspection(outbound)?;
2100            }
2101            SessionStage::Refreshing
2102            | SessionStage::Configuring
2103            | SessionStage::SavingConfiguration => {
2104                // Retain the last authoritative snapshot. Property echoes that
2105                // completed before the failed operation remain available for
2106                // the next explicit refresh.
2107                self.stage = SessionStage::Attached;
2108            }
2109            SessionStage::Inspection if self.provisioning.is_some() => {
2110                self.stage = SessionStage::Attached;
2111            }
2112            SessionStage::Initial | SessionStage::Inspection => {
2113                // The transport is healthy but the initial snapshot is not
2114                // trustworthy enough to attach. Stay connected and report the
2115                // operation error; reconnect/refresh may retry synchronization.
2116                self.stage = SessionStage::Initial;
2117            }
2118            SessionStage::Attached | SessionStage::AwaitingHost | SessionStage::Idle => {}
2119        }
2120        Ok(())
2121    }
2122
2123    fn finish_configuration(&mut self) -> Result<(), MobileError> {
2124        let responses = self.responses.values().cloned().collect();
2125        self.provisioning = Some(inspect_ulcp_sync(responses)?);
2126        self.stage = SessionStage::Attached;
2127        Ok(())
2128    }
2129
2130    fn start_configuration(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2131        self.stage = SessionStage::Configuring;
2132        for _ in 0..usize::from(frame::TID_MAX) {
2133            let Some((property, value)) = self.configuration_queue.pop_front() else {
2134                break;
2135            };
2136            let tid = self.allocate_tid();
2137            self.expected
2138                .insert(tid, ExpectedResponse::ConfigurationProperty(property));
2139            outbound.push(ulcp_prop_set(tid, property, value)?);
2140        }
2141        Ok(())
2142    }
2143
2144    fn start_inspection(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2145        self.stage = SessionStage::Inspection;
2146        if self.inspection_queue.is_empty() {
2147            let responses = self.responses.values().cloned().collect();
2148            self.provisioning = Some(inspect_ulcp_sync(responses)?);
2149            self.stage = SessionStage::Attached;
2150            return Ok(());
2151        }
2152        for _ in 0..usize::from(frame::TID_MAX) {
2153            let Some(property) = self.inspection_queue.pop_front() else {
2154                break;
2155            };
2156            outbound.push(self.get_property(property)?);
2157        }
2158        Ok(())
2159    }
2160
2161    fn start_refresh(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2162        self.stage = SessionStage::Refreshing;
2163        if self.inspection_queue.is_empty() {
2164            let responses = self.responses.values().cloned().collect();
2165            self.provisioning = Some(inspect_ulcp_sync(responses)?);
2166            self.stage = SessionStage::Attached;
2167            return Ok(());
2168        }
2169        for _ in 0..usize::from(frame::TID_MAX) {
2170            let Some(property) = self.inspection_queue.pop_front() else {
2171                break;
2172            };
2173            outbound.push(self.get_property(property)?);
2174        }
2175        Ok(())
2176    }
2177
2178    /// Recompute the attached provisioning snapshot after device state
2179    /// changed under an established session.
2180    ///
2181    /// `changed_property` is the property the triggering notification
2182    /// carried, or `None` for a change this session made itself.
2183    ///
2184    /// Re-opening the host decision is deliberately limited to a
2185    /// `PROP_HOST_KEY` change. Another phone claiming the radio out from
2186    /// under an attached session is a question only the user can answer,
2187    /// so that case still returns to the host prompt. Every *other*
2188    /// published value is news, not a decision: a session attached to a
2189    /// radio owned by another identity (a tethered claim that did not
2190    /// take, which attaches deliberately — see the `Claim` arm) would
2191    /// otherwise be thrown back to the prompt by any unsolicited update at
2192    /// all. `PROP_BATTERY` makes that concrete, being the one notification
2193    /// that arrives on its own schedule for the life of the session.
2194    fn refresh_attached_snapshot(
2195        &mut self,
2196        changed_property: Option<u32>,
2197    ) -> Result<(), MobileError> {
2198        if self.stage != SessionStage::Attached {
2199            return Ok(());
2200        }
2201        let responses = self.responses.values().cloned().collect();
2202        self.provisioning = Some(inspect_ulcp_sync(responses)?);
2203        if changed_property == Some(prop::HOST_KEY) && !self.attaches_without_host_decision() {
2204            self.stage = SessionStage::AwaitingHost;
2205        }
2206        Ok(())
2207    }
2208}
2209
2210/// Return the authoritative properties needed for the read-only post-attach
2211/// inspection, gated by the supplied `PROP_CAPS` value.
2212#[uniffi::export]
2213pub fn ulcp_inspection_properties(capabilities: Vec<u8>) -> Result<Vec<u32>, MobileError> {
2214    let capabilities = decode_capabilities(&capabilities)?;
2215    validate_capability_dependencies(&capabilities)?;
2216    let has = |capability| capabilities.contains(&capability);
2217
2218    let mut properties = vec![
2219        prop::INTERFACE_TYPE,
2220        prop::PHY_ENABLED,
2221        prop::PHY_FREQ,
2222        prop::PHY_TX_POWER,
2223    ];
2224    if has(cap::PHY_LORA) {
2225        properties.extend([prop::PHY_LORA_BW, prop::PHY_LORA_SF, prop::PHY_LORA_CR]);
2226    }
2227    if has(cap::PHY_DUTY_LIMIT) {
2228        properties.extend([prop::PHY_DUTY_NOW, prop::PHY_DUTY_LIMIT]);
2229    }
2230    if has(cap::SAVE) {
2231        properties.push(prop::SAVED);
2232    }
2233    if has(cap::HOST_FILTER) {
2234        properties.push(prop::HOST_RX_FILTERS);
2235    }
2236    if has(cap::HOST_KEYS) {
2237        properties.extend([prop::HOST_CHANNEL_KEYS, prop::HOST_PEER_KEYS]);
2238    }
2239    if has(cap::HOST_RX_QUEUE) {
2240        properties.extend([prop::HOST_RX_QUEUE_COUNT, prop::HOST_RX_QUEUE_DROPPED]);
2241    }
2242    if has(cap::HOST_AUTO_ACK) {
2243        properties.push(prop::HOST_AUTO_ACK);
2244    }
2245    if has(cap::REPEATER) {
2246        properties.extend([
2247            prop::MAC_REPEATER_ENABLED,
2248            prop::MAC_REPEATER_REGIONS,
2249            prop::MAC_REPEATER_DEFAULT_REGION,
2250            prop::MAC_REPEATER_MIN_RSSI,
2251            prop::MAC_REPEATER_MIN_SNR,
2252        ]);
2253    }
2254    if has(cap::IDENT) {
2255        properties.extend([prop::IDENT_ROLE, prop::IDENT_MOBILE]);
2256    }
2257    if has(cap::DEV_IDENTITY) {
2258        properties.extend([
2259            prop::DEV_PEERS,
2260            prop::DEV_CHANNEL_KEYS,
2261            prop::DEV_DISCOVERABLE,
2262        ]);
2263    }
2264    if has(cap::ALERT) {
2265        // Read at attach so a phone reconnecting mid-search finds the
2266        // alert it left running rather than a stale "off".
2267        properties.push(prop::ALERT);
2268    }
2269    if has(cap::TIME) {
2270        // The clock is live rather than configuration, but it is read
2271        // here for the same reason the alert is: a phone that just
2272        // attached should know whether the device knows the time, not
2273        // wait for the next announcement to find out.
2274        properties.extend([prop::TIME, prop::TZ_OFFSET]);
2275    }
2276    if has(cap::GNSS) {
2277        properties.extend([
2278            prop::GNSS_ENABLED,
2279            prop::GNSS_LOCATION,
2280            prop::GNSS_ALTITUDE,
2281            prop::GNSS_FIX,
2282            prop::GNSS_PRECISION,
2283            prop::GNSS_SATELLITES,
2284            prop::GNSS_IDENT_UPDATE,
2285            prop::GNSS_IDENT_PRECISION,
2286            prop::GNSS_TIME_TRUST,
2287        ]);
2288    }
2289    if has(cap::ADVERT) {
2290        properties.extend([
2291            prop::ADVERT_INTERVAL,
2292            prop::BEACON_INTERVAL,
2293            prop::STARTUP_BEACON,
2294        ]);
2295    }
2296    Ok(properties)
2297}
2298
2299fn ulcp_refresh_properties(capabilities: Vec<u8>) -> Result<Vec<u32>, MobileError> {
2300    let decoded = decode_capabilities(&capabilities)?;
2301    validate_capability_dependencies(&decoded)?;
2302    let has = |capability| decoded.contains(&capability);
2303    let mut properties = Vec::new();
2304    if has(cap::DEV_IDENTITY) {
2305        properties.push(prop::DEV_KEY);
2306    }
2307    if has(cap::DEV_NAME) {
2308        properties.push(prop::DEV_NAME);
2309    }
2310    if has(cap::BATTERY) {
2311        properties.push(prop::BATTERY);
2312    }
2313    if has(cap::HOST_FILTER) {
2314        properties.push(prop::HOST_KEY);
2315    }
2316    properties.extend(ulcp_inspection_properties(capabilities)?);
2317    Ok(properties)
2318}
2319
2320/// Validate and reduce the property responses from the read-only post-attach
2321/// inspection.
2322///
2323/// The four properties every ULCP device must answer — the interface type
2324/// and the live PHY triple — are required: without them there is no radio
2325/// to describe. Everything else is capability-gated and merely *expected*,
2326/// so a device that refuses one, or answers it with something undecodable,
2327/// yields a snapshot with that setting absent and named in
2328/// `unreadable_properties` rather than no snapshot at all.
2329#[uniffi::export]
2330pub fn inspect_ulcp_sync(
2331    responses: Vec<UlcpPropertyFrameRecord>,
2332) -> Result<UlcpSyncRecord, MobileError> {
2333    let value = |key| property_value(&responses, key);
2334    let capabilities = decode_capabilities(value(prop::CAPS)?)?;
2335    validate_capability_dependencies(&capabilities)?;
2336    let has = |capability| capabilities.contains(&capability);
2337
2338    let interface = decode_exact_pui(value(prop::INTERFACE_TYPE)?)?;
2339    if interface != INTERFACE_TYPE {
2340        return Err(MobileError::InvalidUlcpFrame);
2341    }
2342    let phy_enabled = decode_bool(value(prop::PHY_ENABLED)?)?;
2343    let frequency_khz = decode_u32(value(prop::PHY_FREQ)?)?;
2344    let transmit_power_dbm = decode_i8(value(prop::PHY_TX_POWER)?)?;
2345
2346    let mut expected = ExpectedProperties {
2347        responses: &responses,
2348        unreadable: Vec::new(),
2349    };
2350    let lora = has(cap::PHY_LORA);
2351    let bandwidth_hz = expected.read(lora, prop::PHY_LORA_BW, decode_u32);
2352    let spreading_factor = expected.read(lora, prop::PHY_LORA_SF, decode_u8);
2353    let coding_rate_denom = expected.read(lora, prop::PHY_LORA_CR, decode_u8);
2354    let duty = has(cap::PHY_DUTY_LIMIT);
2355    let duty_cycle_now = expected.read(duty, prop::PHY_DUTY_NOW, decode_u16);
2356    let duty_cycle_limit = expected.read(duty, prop::PHY_DUTY_LIMIT, decode_u16);
2357    let saved = expected.read(has(cap::SAVE), prop::SAVED, decode_saved);
2358    let queue = has(cap::HOST_RX_QUEUE);
2359    let queued_frames = expected.read(queue, prop::HOST_RX_QUEUE_COUNT, decode_u16);
2360    let dropped_frames = expected.read(queue, prop::HOST_RX_QUEUE_DROPPED, decode_u32);
2361    let filter_count = expected.read(
2362        has(cap::HOST_FILTER),
2363        prop::HOST_RX_FILTERS,
2364        decode_filter_count,
2365    );
2366    let host_keys = has(cap::HOST_KEYS);
2367    let host_channel_count = expected.read(
2368        host_keys,
2369        prop::HOST_CHANNEL_KEYS,
2370        decode_fixed_count::<{ items::CHANNEL_ID_LEN }>,
2371    );
2372    let host_peer_count = expected.read(
2373        host_keys,
2374        prop::HOST_PEER_KEYS,
2375        decode_fixed_count::<{ items::PUBLIC_KEY_LEN }>,
2376    );
2377    let auto_ack = expected.read(has(cap::HOST_AUTO_ACK), prop::HOST_AUTO_ACK, decode_bool);
2378    let dev_identity = has(cap::DEV_IDENTITY);
2379    let dev_peer_keys = expected.read(
2380        dev_identity,
2381        prop::DEV_PEERS,
2382        decode_fixed_list::<{ items::PUBLIC_KEY_LEN }>,
2383    );
2384    let dev_channel_ids = expected.read(
2385        dev_identity,
2386        prop::DEV_CHANNEL_KEYS,
2387        decode_fixed_list::<{ items::CHANNEL_ID_LEN }>,
2388    );
2389
2390    // Every part of the policy is read before any of it is required, so one
2391    // unreadable property does not hide the others behind it.
2392    let forwards = has(cap::REPEATER);
2393    let repeater_enabled = expected.read(forwards, prop::MAC_REPEATER_ENABLED, decode_bool);
2394    let regions = expected.read(forwards, prop::MAC_REPEATER_REGIONS, decode_region_list);
2395    let default_region = expected.read(
2396        forwards,
2397        prop::MAC_REPEATER_DEFAULT_REGION,
2398        decode_optional_region,
2399    );
2400    let min_rssi_dbm = expected.read(forwards, prop::MAC_REPEATER_MIN_RSSI, |value| {
2401        decode_optional(value, decode_i16)
2402    });
2403    let min_snr_db = expected.read(forwards, prop::MAC_REPEATER_MIN_SNR, |value| {
2404        decode_optional(value, decode_i8)
2405    });
2406    let repeater = (|| {
2407        Some(UlcpRepeaterSettingsRecord {
2408            enabled: repeater_enabled?,
2409            regions: regions?,
2410            default_region: default_region?,
2411            min_rssi_dbm: min_rssi_dbm?,
2412            min_snr_db: min_snr_db?,
2413        })
2414    })();
2415
2416    // An empty PROP_IDENT_ROLE is the device saying it derives its own
2417    // role, which is the same `None` a device without CAP_IDENT reports.
2418    let ident = has(cap::IDENT);
2419    let ident_role = expected
2420        .read(ident, prop::IDENT_ROLE, |value| {
2421            decode_optional(value, decode_u8)
2422        })
2423        .flatten();
2424    let ident_mobile = expected.read(ident, prop::IDENT_MOBILE, decode_bool);
2425    let dev_discoverable = expected.read(dev_identity, prop::DEV_DISCOVERABLE, decode_bool);
2426
2427    let tz_offset_min = expected.read(has(cap::TIME), prop::TZ_OFFSET, decode_i16);
2428
2429    // Read whole, like the forwarding policy above and for the same
2430    // reason: this is written as a set.
2431    let positioning = has(cap::GNSS);
2432    let gnss_enabled = expected.read(positioning, prop::GNSS_ENABLED, decode_bool);
2433    let ident_update = expected.read(positioning, prop::GNSS_IDENT_UPDATE, decode_bool);
2434    let ident_precision = expected.read(positioning, prop::GNSS_IDENT_PRECISION, decode_precision);
2435    let time_trust = expected.read(positioning, prop::GNSS_TIME_TRUST, decode_bool);
2436    let gnss = (|| {
2437        Some(UlcpGnssSettingsRecord {
2438            enabled: gnss_enabled?,
2439            ident_update: ident_update?,
2440            ident_precision: ident_precision?,
2441            time_trust: time_trust?,
2442        })
2443    })();
2444
2445    // Also read whole: the two schedules together are how much airtime
2446    // this device claims, which is one decision.
2447    let announces = has(cap::ADVERT);
2448    let advert_interval = expected.read(announces, prop::ADVERT_INTERVAL, decode_u32);
2449    let beacon_interval = expected.read(announces, prop::BEACON_INTERVAL, decode_u32);
2450    let startup_beacon = expected.read(announces, prop::STARTUP_BEACON, decode_bool);
2451    let advert = (|| {
2452        Some(UlcpAdvertSettingsRecord {
2453            advert_interval_seconds: advert_interval?,
2454            beacon_interval_seconds: beacon_interval?,
2455            startup_beacon: startup_beacon?,
2456        })
2457    })();
2458
2459    let mut unreadable_properties = expected.unreadable;
2460    unreadable_properties.sort_unstable();
2461
2462    Ok(UlcpSyncRecord {
2463        capability_count: capabilities
2464            .len()
2465            .try_into()
2466            .map_err(|_| MobileError::InvalidUlcpFrame)?,
2467        has_host_filtering: has(cap::HOST_FILTER),
2468        supports_offline_queue: has(cap::HOST_RX_QUEUE),
2469        supports_delegated_ack: has(cap::HOST_AUTO_ACK),
2470        supports_device_name: has(cap::DEV_NAME),
2471        supports_lora: has(cap::PHY_LORA),
2472        supports_duty_cycle_limit: has(cap::PHY_DUTY_LIMIT),
2473        supports_battery: has(cap::BATTERY),
2474        supports_repeater: has(cap::REPEATER),
2475        supports_ident: has(cap::IDENT),
2476        supports_device_identity: has(cap::DEV_IDENTITY),
2477        supports_time: has(cap::TIME),
2478        supports_gnss: positioning,
2479        supports_advert: announces,
2480        phy_enabled,
2481        frequency_khz,
2482        transmit_power_dbm,
2483        bandwidth_hz,
2484        spreading_factor,
2485        coding_rate_denom,
2486        duty_cycle_now,
2487        duty_cycle_limit,
2488        saved,
2489        queued_frames,
2490        dropped_frames,
2491        filter_count,
2492        host_channel_count,
2493        host_peer_count,
2494        auto_ack,
2495        repeater,
2496        dev_peer_keys,
2497        dev_channel_ids,
2498        ident_role,
2499        ident_mobile,
2500        dev_discoverable,
2501        tz_offset_min,
2502        gnss,
2503        advert,
2504        unreadable_properties,
2505    })
2506}
2507
2508/// Properties only ever written as a set. Writing part of a modem profile
2509/// or part of a forwarding policy leaves the device running a configuration
2510/// nobody asked for, so one unreadable member withdraws the whole group.
2511/// These are the same groupings the reduction reports as a unit.
2512const WHOLE_WRITE_GROUPS: [&[u32]; 4] = [
2513    &[prop::PHY_LORA_BW, prop::PHY_LORA_SF, prop::PHY_LORA_CR],
2514    &[
2515        prop::MAC_REPEATER_ENABLED,
2516        prop::MAC_REPEATER_REGIONS,
2517        prop::MAC_REPEATER_DEFAULT_REGION,
2518        prop::MAC_REPEATER_MIN_RSSI,
2519        prop::MAC_REPEATER_MIN_SNR,
2520    ],
2521    &[
2522        prop::GNSS_ENABLED,
2523        prop::GNSS_IDENT_UPDATE,
2524        prop::GNSS_IDENT_PRECISION,
2525        prop::GNSS_TIME_TRUST,
2526    ],
2527    &[
2528        prop::ADVERT_INTERVAL,
2529        prop::BEACON_INTERVAL,
2530        prop::STARTUP_BEACON,
2531    ],
2532];
2533
2534/// The capability-gated half of an inspection: properties the device is
2535/// expected to answer, each of which it may nevertheless decline.
2536struct ExpectedProperties<'a> {
2537    responses: &'a [UlcpPropertyFrameRecord],
2538    unreadable: Vec<u32>,
2539}
2540
2541impl ExpectedProperties<'_> {
2542    /// Decode `key` when the device advertises the capability that gates it.
2543    ///
2544    /// A missing or undecodable value is recorded and reported as `None`
2545    /// rather than failing the whole reduction: the setting is unknown, which
2546    /// is a fact about one property and not about the device as a whole.
2547    fn read<T>(
2548        &mut self,
2549        gated_on: bool,
2550        key: u32,
2551        decode: impl FnOnce(&[u8]) -> Result<T, MobileError>,
2552    ) -> Option<T> {
2553        if !gated_on {
2554            return None;
2555        }
2556        match property_value(self.responses, key).and_then(decode) {
2557            Ok(value) => Some(value),
2558            Err(_) => {
2559                self.unreadable.push(key);
2560                None
2561            }
2562        }
2563    }
2564}
2565
2566fn property_value(responses: &[UlcpPropertyFrameRecord], key: u32) -> Result<&[u8], MobileError> {
2567    let mut matching = responses
2568        .iter()
2569        .filter(|response| response.property_id == key);
2570    let response = matching.next().ok_or(MobileError::InvalidUlcpFrame)?;
2571    if matching.next().is_some() || response.command != Cmd::PropIs as u8 {
2572        return Err(MobileError::InvalidUlcpFrame);
2573    }
2574    Ok(&response.value)
2575}
2576
2577fn decode_capabilities(value: &[u8]) -> Result<Vec<u32>, MobileError> {
2578    let mut capabilities = Vec::new();
2579    let mut rest = value;
2580    while !rest.is_empty() {
2581        let (capability, used) = pui::decode(rest).map_err(|_| MobileError::InvalidUlcpFrame)?;
2582        if capabilities.contains(&capability) {
2583            return Err(MobileError::InvalidUlcpFrame);
2584        }
2585        capabilities.push(capability);
2586        rest = &rest[used..];
2587    }
2588    Ok(capabilities)
2589}
2590
2591fn validate_capability_dependencies(capabilities: &[u32]) -> Result<(), MobileError> {
2592    let has = |capability| capabilities.contains(&capability);
2593    if has(cap::HOST_RX_QUEUE) && !has(cap::HOST_FILTER)
2594        || has(cap::HOST_KEYS) && !has(cap::HOST_FILTER)
2595        || has(cap::HOST_AUTO_ACK) && (!has(cap::HOST_KEYS) || !has(cap::HOST_RX_QUEUE))
2596        // A device with no identity of its own has nothing to forward for
2597        // and nothing to advertise.
2598        || has(cap::REPEATER) && !has(cap::DEV_IDENTITY)
2599        || has(cap::IDENT) && !has(cap::DEV_IDENTITY)
2600        // What a scheduled advertisement carries *is* the device identity.
2601        || has(cap::ADVERT) && !has(cap::DEV_IDENTITY)
2602        // A receiver that cannot set a clock is still a receiver, but the
2603        // device also dates its fixes, so CAP_GNSS implies CAP_TIME.
2604        || has(cap::GNSS) && !has(cap::TIME)
2605    {
2606        return Err(MobileError::InvalidUlcpFrame);
2607    }
2608    Ok(())
2609}
2610
2611fn decode_exact_pui(value: &[u8]) -> Result<u32, MobileError> {
2612    let (decoded, used) = pui::decode(value).map_err(|_| MobileError::InvalidUlcpFrame)?;
2613    (used == value.len())
2614        .then_some(decoded)
2615        .ok_or(MobileError::InvalidUlcpFrame)
2616}
2617
2618fn decode_bool(value: &[u8]) -> Result<bool, MobileError> {
2619    match value {
2620        [0] => Ok(false),
2621        [1] => Ok(true),
2622        _ => Err(MobileError::InvalidUlcpFrame),
2623    }
2624}
2625
2626fn decode_saved(value: &[u8]) -> Result<SavedSnapshotRecord, MobileError> {
2627    match value {
2628        [saved::NONE] => Ok(SavedSnapshotRecord::None),
2629        [saved::CURRENT] => Ok(SavedSnapshotRecord::Current),
2630        [saved::FALLBACK] => Ok(SavedSnapshotRecord::Fallback),
2631        [saved::UNREADABLE] => Ok(SavedSnapshotRecord::Unreadable),
2632        _ => Err(MobileError::InvalidUlcpFrame),
2633    }
2634}
2635
2636fn decode_u16(value: &[u8]) -> Result<u16, MobileError> {
2637    value
2638        .try_into()
2639        .map(u16::from_le_bytes)
2640        .map_err(|_| MobileError::InvalidUlcpFrame)
2641}
2642
2643fn decode_u8(value: &[u8]) -> Result<u8, MobileError> {
2644    value
2645        .first()
2646        .copied()
2647        .filter(|_| value.len() == 1)
2648        .ok_or(MobileError::InvalidUlcpFrame)
2649}
2650
2651fn decode_i8(value: &[u8]) -> Result<i8, MobileError> {
2652    decode_u8(value).map(|value| value as i8)
2653}
2654
2655/// A location precision, which is only ever 1–7 bytes. A device
2656/// reporting anything else is reporting a setting this phone cannot
2657/// present, so it is recorded as unreadable rather than shown.
2658fn decode_precision(value: &[u8]) -> Result<u8, MobileError> {
2659    decode_u8(value)
2660        .ok()
2661        .filter(|bytes| (1..=MAX_PRECISION).contains(bytes))
2662        .ok_or(MobileError::InvalidUlcpFrame)
2663}
2664
2665fn decode_i16(value: &[u8]) -> Result<i16, MobileError> {
2666    value
2667        .try_into()
2668        .map(i16::from_le_bytes)
2669        .map_err(|_| MobileError::InvalidUlcpFrame)
2670}
2671
2672/// Decode a property whose empty value means "unset" rather than zero.
2673fn decode_optional<T>(
2674    value: &[u8],
2675    decode: impl Fn(&[u8]) -> Result<T, MobileError>,
2676) -> Result<Option<T>, MobileError> {
2677    if value.is_empty() {
2678        return Ok(None);
2679    }
2680    decode(value).map(Some)
2681}
2682
2683/// Split a `PROP_MAC_REPEATER_REGIONS` value into individual codes.
2684///
2685/// Deliberately imposes no upper bound: how many regions a device holds
2686/// is its own business, and a device reporting more than this phone would
2687/// ever write is not a malformed frame.
2688fn decode_region_list(value: &[u8]) -> Result<Vec<Vec<u8>>, MobileError> {
2689    if !value.len().is_multiple_of(items::REGION_CODE_LEN) {
2690        return Err(MobileError::InvalidUlcpFrame);
2691    }
2692    Ok(value
2693        .chunks(items::REGION_CODE_LEN)
2694        .map(<[u8]>::to_vec)
2695        .collect())
2696}
2697
2698fn decode_optional_region(value: &[u8]) -> Result<Option<Vec<u8>>, MobileError> {
2699    match value.len() {
2700        0 => Ok(None),
2701        items::REGION_CODE_LEN => Ok(Some(value.to_vec())),
2702        _ => Err(MobileError::InvalidUlcpFrame),
2703    }
2704}
2705
2706/// Order one configuration pass: everything that changes live PHY
2707/// behavior happens with the radio down, and the radio comes back up only
2708/// once the complete new profile is in place.
2709///
2710/// `device_values` are the device-domain writes, which ride between the
2711/// two PHY_ENABLED writes for the same reason the PHY parameters do — a
2712/// repeater must not start forwarding under half of its new policy.
2713fn configuration_values(
2714    settings: UlcpRadioSettingsRecord,
2715    device_values: Vec<(u32, Vec<u8>)>,
2716) -> Vec<(u32, Vec<u8>)> {
2717    let mut values = Vec::new();
2718    if !settings.phy_enabled {
2719        values.push((prop::PHY_ENABLED, vec![0]));
2720    }
2721    if let Some(name) = settings.device_name {
2722        values.push((prop::DEV_NAME, name.into_bytes()));
2723    }
2724    values.extend([
2725        (
2726            prop::PHY_FREQ,
2727            settings.frequency_khz.to_le_bytes().to_vec(),
2728        ),
2729        (prop::PHY_TX_POWER, vec![settings.transmit_power_dbm as u8]),
2730    ]);
2731    if let (Some(bandwidth), Some(sf), Some(cr)) = (
2732        settings.bandwidth_hz,
2733        settings.spreading_factor,
2734        settings.coding_rate_denom,
2735    ) {
2736        values.extend([
2737            (prop::PHY_LORA_BW, bandwidth.to_le_bytes().to_vec()),
2738            (prop::PHY_LORA_SF, vec![sf]),
2739            (prop::PHY_LORA_CR, vec![cr]),
2740        ]);
2741    }
2742    if let Some(limit) = settings.duty_cycle_limit {
2743        values.push((prop::PHY_DUTY_LIMIT, limit.to_le_bytes().to_vec()));
2744    }
2745    values.extend(device_values);
2746    if settings.phy_enabled {
2747        values.push((prop::PHY_ENABLED, vec![1]));
2748    }
2749    values
2750}
2751
2752/// Check the device-domain half of a commissioning record against what
2753/// the device says it can do, and reduce it to property writes.
2754///
2755/// Capability-gated fields must be present exactly when the capability
2756/// is: the record states a whole desired configuration, so a field the
2757/// device cannot honor is a caller mistake rather than something to
2758/// silently drop.
2759fn validate_device_settings(
2760    configuration: &UlcpDeviceConfigRecord,
2761    state: &UlcpSessionState,
2762) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
2763    let mut values = Vec::new();
2764
2765    let supports_ident = state.has_capability(cap::IDENT)?;
2766    if configuration.ident_mobile.is_some() != supports_ident
2767        || (configuration.ident_role.is_some() && !supports_ident)
2768    {
2769        return Err(MobileError::InvalidUlcpFrame);
2770    }
2771    if supports_ident {
2772        // An empty PROP_IDENT_ROLE hands the choice back to the device.
2773        values.push((
2774            prop::IDENT_ROLE,
2775            configuration
2776                .ident_role
2777                .map(|role| vec![role])
2778                .unwrap_or_default(),
2779        ));
2780        values.push((
2781            prop::IDENT_MOBILE,
2782            vec![configuration.ident_mobile.unwrap_or(false) as u8],
2783        ));
2784    }
2785
2786    let supports_dev_identity = state.has_capability(cap::DEV_IDENTITY)?;
2787    if configuration.dev_discoverable.is_some() != supports_dev_identity {
2788        return Err(MobileError::InvalidUlcpFrame);
2789    }
2790    if let Some(discoverable) = configuration.dev_discoverable {
2791        values.push((prop::DEV_DISCOVERABLE, vec![discoverable as u8]));
2792    }
2793
2794    let supports_repeater = state.has_capability(cap::REPEATER)?;
2795    if configuration.repeater.is_some() != supports_repeater {
2796        return Err(MobileError::InvalidUlcpFrame);
2797    }
2798    if let Some(repeater) = &configuration.repeater {
2799        let mut regions = Vec::with_capacity(repeater.regions.len() * items::REGION_CODE_LEN);
2800        for region in &repeater.regions {
2801            if region.len() != items::REGION_CODE_LEN {
2802                return Err(MobileError::InvalidUlcpFrame);
2803            }
2804            regions.extend_from_slice(region);
2805        }
2806        if let Some(default_region) = &repeater.default_region {
2807            if default_region.len() != items::REGION_CODE_LEN {
2808                return Err(MobileError::InvalidUlcpFrame);
2809            }
2810        }
2811        // Enabling last means the forwarding policy is already whole by
2812        // the time the device starts acting on it. The device does not
2813        // cross-check the default region against the forwarding list —
2814        // that is a SHOULD the presenting UI is better placed to warn on.
2815        values.extend([
2816            (prop::MAC_REPEATER_REGIONS, regions),
2817            (
2818                prop::MAC_REPEATER_DEFAULT_REGION,
2819                repeater.default_region.clone().unwrap_or_default(),
2820            ),
2821            (
2822                prop::MAC_REPEATER_MIN_RSSI,
2823                repeater
2824                    .min_rssi_dbm
2825                    .map(|rssi| rssi.to_le_bytes().to_vec())
2826                    .unwrap_or_default(),
2827            ),
2828            (
2829                prop::MAC_REPEATER_MIN_SNR,
2830                repeater
2831                    .min_snr_db
2832                    .map(|snr| vec![snr as u8])
2833                    .unwrap_or_default(),
2834            ),
2835            (prop::MAC_REPEATER_ENABLED, vec![repeater.enabled as u8]),
2836        ]);
2837    }
2838
2839    values.extend(positioning_values(
2840        configuration.gnss,
2841        configuration.tz_offset_min,
2842        state,
2843    )?);
2844    values.extend(advert_values(configuration.advert, state)?);
2845    Ok(values)
2846}
2847
2848/// Reduce the advertisement policy to property writes.
2849///
2850/// Split out for the same reason [`positioning_values`] is: a tethered
2851/// phone changes these on its companion radio without commissioning it,
2852/// and both paths have to produce the same writes.
2853fn advert_values(
2854    advert: Option<UlcpAdvertSettingsRecord>,
2855    state: &UlcpSessionState,
2856) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
2857    let announces = state.has_capability(cap::ADVERT)?;
2858    if advert.is_some() != announces {
2859        return Err(MobileError::InvalidUlcpFrame);
2860    }
2861    let Some(advert) = advert else {
2862        return Ok(Vec::new());
2863    };
2864    // The device refuses these too. Catching them here means an
2865    // out-of-range interval fails before any of the group has been
2866    // written, rather than leaving the schedule half-changed.
2867    for interval in [
2868        advert.advert_interval_seconds,
2869        advert.beacon_interval_seconds,
2870    ] {
2871        if interval != 0
2872            && !(MIN_AUTO_ANNOUNCE_INTERVAL_S..=MAX_AUTO_ANNOUNCE_INTERVAL_S).contains(&interval)
2873        {
2874            return Err(MobileError::InvalidUlcpFrame);
2875        }
2876    }
2877    Ok(vec![
2878        (
2879            prop::ADVERT_INTERVAL,
2880            advert.advert_interval_seconds.to_le_bytes().to_vec(),
2881        ),
2882        (
2883            prop::BEACON_INTERVAL,
2884            advert.beacon_interval_seconds.to_le_bytes().to_vec(),
2885        ),
2886        (prop::STARTUP_BEACON, vec![advert.startup_beacon as u8]),
2887    ])
2888}
2889
2890/// Reduce the zone and the positioning policy to property writes.
2891///
2892/// Split out because these are the one part of a device's own domain a
2893/// phone changes on its *companion* radio without commissioning it —
2894/// [`MobileUlcpSession::configure_positioning`] writes exactly this list
2895/// and nothing else, where [`validate_device_settings`] folds it into a
2896/// whole-domain write. Same values either way, so the two paths cannot
2897/// drift apart.
2898///
2899/// Each field must be present exactly when its capability is: these
2900/// state a whole desired setting rather than a patch.
2901fn positioning_values(
2902    gnss: Option<UlcpGnssSettingsRecord>,
2903    tz_offset_min: Option<i16>,
2904    state: &UlcpSessionState,
2905) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
2906    let mut values = Vec::new();
2907
2908    let keeps_time = state.has_capability(cap::TIME)?;
2909    if tz_offset_min.is_some() != keeps_time {
2910        return Err(MobileError::InvalidUlcpFrame);
2911    }
2912    if let Some(minutes) = tz_offset_min {
2913        // The extremes of the zone database, not of the encoding: a
2914        // fourteen-hour offset is Kiritimati, and anything past it is a
2915        // caller mistake rather than a place.
2916        if !(-12 * 60..=14 * 60).contains(&minutes) {
2917            return Err(MobileError::InvalidUlcpFrame);
2918        }
2919        values.push((prop::TZ_OFFSET, minutes.to_le_bytes().to_vec()));
2920    }
2921
2922    let positioning = state.has_capability(cap::GNSS)?;
2923    if gnss.is_some() != positioning {
2924        return Err(MobileError::InvalidUlcpFrame);
2925    }
2926    if let Some(gnss) = gnss {
2927        if !(1..=MAX_PRECISION).contains(&gnss.ident_precision) {
2928            return Err(MobileError::InvalidUlcpFrame);
2929        }
2930        // Enabled last, so a receiver that starts looking does it under
2931        // the disclosure and trust policy just written rather than the
2932        // one it happened to be holding.
2933        values.extend([
2934            (prop::GNSS_IDENT_UPDATE, vec![gnss.ident_update as u8]),
2935            (prop::GNSS_IDENT_PRECISION, vec![gnss.ident_precision]),
2936            (prop::GNSS_TIME_TRUST, vec![gnss.time_trust as u8]),
2937            (prop::GNSS_ENABLED, vec![gnss.enabled as u8]),
2938        ]);
2939    }
2940    Ok(values)
2941}
2942
2943fn validate_radio_settings(
2944    settings: &UlcpRadioSettingsRecord,
2945    state: &UlcpSessionState,
2946) -> Result<(), MobileError> {
2947    if settings.frequency_khz == 0 {
2948        return Err(MobileError::InvalidUlcpFrame);
2949    }
2950    if let Some(name) = &settings.device_name {
2951        if !state.has_capability(cap::DEV_NAME)?
2952            || name.is_empty()
2953            || name.len() > 64
2954            || name.as_bytes().contains(&0)
2955        {
2956            return Err(MobileError::InvalidUlcpFrame);
2957        }
2958    }
2959    let lora = (
2960        settings.bandwidth_hz,
2961        settings.spreading_factor,
2962        settings.coding_rate_denom,
2963    );
2964    match lora {
2965        (None, None, None) if !state.has_capability(cap::PHY_LORA)? => {}
2966        (Some(bandwidth), Some(sf), Some(cr))
2967            if state.has_capability(cap::PHY_LORA)?
2968                && bandwidth > 0
2969                && (5..=12).contains(&sf)
2970                && (5..=8).contains(&cr) => {}
2971        _ => return Err(MobileError::InvalidUlcpFrame),
2972    }
2973    if settings.duty_cycle_limit.is_some() != state.has_capability(cap::PHY_DUTY_LIMIT)? {
2974        return Err(MobileError::InvalidUlcpFrame);
2975    }
2976    Ok(())
2977}
2978
2979fn decode_u32(value: &[u8]) -> Result<u32, MobileError> {
2980    value
2981        .try_into()
2982        .map(u32::from_le_bytes)
2983        .map_err(|_| MobileError::InvalidUlcpFrame)
2984}
2985
2986/// Split a concatenation of fixed-width items into the items themselves.
2987/// The lossless counterpart of [`decode_fixed_count`], for properties whose
2988/// GET form reads back full values rather than digests.
2989fn decode_fixed_list<const N: usize>(value: &[u8]) -> Result<Vec<Vec<u8>>, MobileError> {
2990    items::fixed_items::<N>(value)
2991        .map_err(|_| MobileError::InvalidUlcpFrame)?
2992        .map(|item| Ok(item.to_vec()))
2993        .collect()
2994}
2995
2996fn decode_fixed_count<const N: usize>(value: &[u8]) -> Result<u32, MobileError> {
2997    let count = items::fixed_items::<N>(value)
2998        .map_err(|_| MobileError::InvalidUlcpFrame)?
2999        .count();
3000    count.try_into().map_err(|_| MobileError::InvalidUlcpFrame)
3001}
3002
3003fn decode_filter_count(value: &[u8]) -> Result<u32, MobileError> {
3004    let mut count = 0u32;
3005    for item in items::prefixed_items(value) {
3006        let item = item.map_err(|_| MobileError::InvalidUlcpFrame)?;
3007        Filter::decode(item).map_err(|_| MobileError::InvalidUlcpFrame)?;
3008        count = count.checked_add(1).ok_or(MobileError::InvalidUlcpFrame)?;
3009    }
3010    Ok(count)
3011}
3012
3013/// Split a ULCP frame into ATT values using the negotiated maximum write
3014/// length. The returned values include the one-octet SAR header.
3015#[uniffi::export]
3016pub fn ulcp_gatt_segments(
3017    frame: Vec<u8>,
3018    maximum_value_length: u16,
3019) -> Result<Vec<GattSegmentRecord>, MobileError> {
3020    let segment_payload = usize::from(maximum_value_length)
3021        .checked_sub(1)
3022        .filter(|length| *length > 0)
3023        .ok_or(MobileError::InvalidGattSegment)?;
3024    if frame.len() > MAX_FRAME {
3025        return Err(MobileError::InvalidUlcpFrame);
3026    }
3027
3028    Ok(gatt::segments(&frame, segment_payload)
3029        .map(|segment| {
3030            let mut value = vec![0; segment.payload().len() + 1];
3031            let length = segment
3032                .write_to(&mut value)
3033                .expect("sized from the segment payload");
3034            value.truncate(length);
3035            GattSegmentRecord { value }
3036        })
3037        .collect())
3038}
3039
3040/// Encode a `CMD_PROP_GET` request with the shared ULCP codec.
3041#[uniffi::export]
3042pub fn ulcp_prop_get(transaction_id: u8, property_id: u32) -> Result<Vec<u8>, MobileError> {
3043    let mut output = [0; 8];
3044    let length = frame::prop_get(&mut output, transaction_id, property_id)
3045        .map_err(|_| MobileError::InvalidUlcpFrame)?;
3046    Ok(output[..length].to_vec())
3047}
3048
3049/// Encode a `CMD_PROP_SET` request with the shared ULCP codec.
3050#[uniffi::export]
3051pub fn ulcp_prop_set(
3052    transaction_id: u8,
3053    property_id: u32,
3054    value: Vec<u8>,
3055) -> Result<Vec<u8>, MobileError> {
3056    if value.len() > MAX_FRAME {
3057        return Err(MobileError::InvalidUlcpFrame);
3058    }
3059    let mut output = vec![0; MAX_FRAME];
3060    let length = frame::prop_set(&mut output, transaction_id, property_id, &value)
3061        .map_err(|_| MobileError::InvalidUlcpFrame)?;
3062    output.truncate(length);
3063    Ok(output)
3064}
3065
3066/// Encode a `CMD_PROP_INSERT` request. Deliberately not exported: typed
3067/// session operations own multi-value mutations.
3068fn ulcp_prop_insert(
3069    transaction_id: u8,
3070    property_id: u32,
3071    item: &[u8],
3072) -> Result<Vec<u8>, MobileError> {
3073    let mut output = vec![0; MAX_FRAME];
3074    let length = frame::prop_insert(&mut output, transaction_id, property_id, item)
3075        .map_err(|_| MobileError::InvalidUlcpFrame)?;
3076    output.truncate(length);
3077    Ok(output)
3078}
3079
3080/// Encode a `CMD_PROP_REMOVE` request. Deliberately not exported, like
3081/// [`ulcp_prop_insert`].
3082fn ulcp_prop_remove(
3083    transaction_id: u8,
3084    property_id: u32,
3085    selector: &[u8],
3086) -> Result<Vec<u8>, MobileError> {
3087    let mut output = vec![0; MAX_FRAME];
3088    let length = frame::prop_remove(&mut output, transaction_id, property_id, selector)
3089        .map_err(|_| MobileError::InvalidUlcpFrame)?;
3090    output.truncate(length);
3091    Ok(output)
3092}
3093
3094/// Capacity of the device identity's peer list (`PROP_DEV_PEERS`).
3095///
3096/// A label constant only — the device's `NOMEM` stays authoritative for
3097/// when the list is actually full.
3098#[uniffi::export]
3099pub fn ulcp_max_dev_peers() -> u8 {
3100    8
3101}
3102
3103/// Capacity of the device identity's channel list (`PROP_DEV_CHANNEL_KEYS`).
3104///
3105/// A label constant, like [`ulcp_max_dev_peers`].
3106#[uniffi::export]
3107pub fn ulcp_max_dev_channels() -> u8 {
3108    8
3109}
3110
3111/// Derive the identifier a device will echo for a channel key.
3112fn dev_channel_id(channel_key: &[u8]) -> Result<Vec<u8>, MobileError> {
3113    let bytes: [u8; items::CHANNEL_KEY_LEN] = channel_key
3114        .try_into()
3115        .map_err(|_| MobileError::InvalidChannelKeyLength)?;
3116    Ok(crate::derive_channel_id(bytes.to_vec())?)
3117}
3118
3119/// Encode a `CMD_SAVE` request with the shared ULCP codec.
3120#[uniffi::export]
3121pub fn ulcp_save(transaction_id: u8) -> Result<Vec<u8>, MobileError> {
3122    let mut output = [0; 2];
3123    let length =
3124        frame::save(&mut output, transaction_id).map_err(|_| MobileError::InvalidUlcpFrame)?;
3125    Ok(output[..length].to_vec())
3126}
3127
3128/// Encode a `CMD_FACTORY_RESET` request with the shared ULCP codec.
3129#[uniffi::export]
3130pub fn ulcp_factory_reset(transaction_id: u8) -> Result<Vec<u8>, MobileError> {
3131    let mut output = [0; 2];
3132    let length = frame::factory_reset(&mut output, transaction_id)
3133        .map_err(|_| MobileError::InvalidUlcpFrame)?;
3134    Ok(output[..length].to_vec())
3135}
3136
3137/// Decode an exact packed status value from `PROP_LAST_STATUS`.
3138#[uniffi::export]
3139pub fn inspect_ulcp_status(value: Vec<u8>) -> Result<u32, MobileError> {
3140    decode_exact_pui(&value)
3141}
3142
3143fn ulcp_operation_error(
3144    operation: String,
3145    value: &[u8],
3146) -> Result<UlcpOperationErrorRecord, MobileError> {
3147    let status_code = inspect_ulcp_status(value.to_vec())?;
3148    let status = umsh_ulcp::Status(status_code);
3149    if status == umsh_ulcp::Status::OK {
3150        // A property operation that promised an echoed value cannot silently
3151        // substitute status-only success. That is a real session violation,
3152        // not a reported operation error.
3153        return Err(MobileError::InvalidUlcpFrame);
3154    }
3155    Ok(UlcpOperationErrorRecord {
3156        operation,
3157        status_code,
3158        status_name: format!("{status:?}"),
3159    })
3160}
3161
3162/// Parse and validate a property notification or response.
3163#[uniffi::export]
3164pub fn inspect_ulcp_property_frame(bytes: Vec<u8>) -> Result<UlcpPropertyFrameRecord, MobileError> {
3165    let parsed = PropertyNotification::parse(&bytes).map_err(|_| MobileError::InvalidUlcpFrame)?;
3166    let command = match parsed.kind {
3167        PropertyNotificationKind::Is => Cmd::PropIs,
3168        PropertyNotificationKind::Inserted => Cmd::PropInserted,
3169        PropertyNotificationKind::Removed => Cmd::PropRemoved,
3170    };
3171    Ok(UlcpPropertyFrameRecord {
3172        transaction_id: parsed.tid,
3173        command: command as u8,
3174        property_id: parsed.key,
3175        value: parsed.value.to_vec(),
3176    })
3177}
3178
3179/// Validate and reduce a `PROP_BATTERY` value to fields used by mobile UI.
3180#[uniffi::export]
3181pub fn inspect_ulcp_battery(value: Vec<u8>) -> Result<UlcpBatteryRecord, MobileError> {
3182    let battery = BatteryStatus::decode(&value).map_err(|_| MobileError::InvalidUlcpFrame)?;
3183    Ok(UlcpBatteryRecord {
3184        percentage: battery.level_percent,
3185        voltage_mv: battery.voltage_mv,
3186        charge_state: battery.charge_state.map(UlcpChargeState::from_wire),
3187    })
3188}
3189
3190/// Validate and reduce a `PROP_ALERT` value.
3191#[uniffi::export]
3192pub fn inspect_ulcp_alert(value: Vec<u8>) -> Result<UlcpAlertState, MobileError> {
3193    let (code, consumed) = pui::decode(&value).map_err(|_| MobileError::InvalidUlcpFrame)?;
3194    if consumed != value.len() {
3195        return Err(MobileError::InvalidUlcpFrame);
3196    }
3197    AlertState::from_code(code)
3198        .map(UlcpAlertState::from_wire)
3199        .ok_or(MobileError::InvalidUlcpFrame)
3200}
3201
3202/// Read a region code from what someone typed, yielding the two wire
3203/// octets used everywhere else in the ULCP and mesh surfaces.
3204///
3205/// Three ASCII letters are a nearest-airport IATA code, `0xXXXX` is a
3206/// literal code, and anything else is a region *name* hashed into a
3207/// disjoint part of the code space — so "SJC" and "San Jose" are
3208/// deliberately different regions, and no name can ever collide with an
3209/// airport.
3210#[uniffi::export]
3211pub fn region_code_from_string(text: String) -> Result<Vec<u8>, MobileError> {
3212    text.parse::<RegionCode>()
3213        .map(|code| code.to_bytes().to_vec())
3214        .map_err(|_| MobileError::InvalidRegionCode)
3215}
3216
3217/// Render a region code for display. Codes derived from an airport come
3218/// back as their three letters; everything else as `0xXXXX`, which
3219/// [`region_code_from_string`] reads back.
3220#[uniffi::export]
3221pub fn region_code_description(code: Vec<u8>) -> Result<String, MobileError> {
3222    let bytes: [u8; items::REGION_CODE_LEN] = code
3223        .try_into()
3224        .map_err(|_| MobileError::InvalidRegionCode)?;
3225    Ok(RegionCode::from_bytes(bytes).to_string())
3226}
3227
3228/// Stateful, bounded receiver for Frame Out notifications.
3229#[derive(uniffi::Object)]
3230pub struct MobileGattReassembler {
3231    inner: Mutex<Reassembler<MAX_FRAME>>,
3232}
3233
3234#[uniffi::export]
3235impl MobileGattReassembler {
3236    #[uniffi::constructor]
3237    pub fn new() -> Arc<Self> {
3238        Arc::new(Self {
3239            inner: Mutex::new(Reassembler::new()),
3240        })
3241    }
3242
3243    /// Consume one ATT value, returning a complete ULCP frame when the
3244    /// segment ends one. Invalid input resets the shared reassembly state.
3245    pub fn push(&self, segment: Vec<u8>) -> Result<Option<Vec<u8>>, MobileError> {
3246        let mut reassembler = self.inner.lock().expect("GATT reassembler mutex poisoned");
3247        match reassembler.push(&segment) {
3248            None => Ok(None),
3249            Some(Ok(frame)) => Ok(Some(frame.to_vec())),
3250            Some(Err(_)) => Err(MobileError::InvalidGattSegment),
3251        }
3252    }
3253
3254    pub fn reset(&self) {
3255        self.inner
3256            .lock()
3257            .expect("GATT reassembler mutex poisoned")
3258            .reset();
3259    }
3260}
3261
3262#[cfg(test)]
3263mod tests {
3264    use super::*;
3265    use umsh_ulcp::PropPayload;
3266
3267    fn response(property_id: u32, value: &[u8]) -> UlcpPropertyFrameRecord {
3268        UlcpPropertyFrameRecord {
3269            transaction_id: 1,
3270            command: Cmd::PropIs as u8,
3271            property_id,
3272            value: value.to_vec(),
3273        }
3274    }
3275
3276    fn encoded_capabilities(values: &[u32]) -> Vec<u8> {
3277        let mut encoded = Vec::new();
3278        for value in values {
3279            let mut bytes = [0; pui::MAX_LEN];
3280            let len = pui::encode(*value, &mut bytes).unwrap();
3281            encoded.extend_from_slice(&bytes[..len]);
3282        }
3283        encoded
3284    }
3285
3286    fn property_request(bytes: &[u8]) -> (u8, u32) {
3287        let parsed = Frame::parse(bytes).unwrap();
3288        assert_eq!(parsed.command(), Some(Cmd::PropGet));
3289        let (property, used) = pui::decode(parsed.payload).unwrap();
3290        assert_eq!(used, parsed.payload.len());
3291        (parsed.header.tid(), property)
3292    }
3293
3294    fn property_response(tid: u8, property: u32, value: &[u8]) -> Vec<u8> {
3295        let mut bytes = vec![0; MAX_FRAME];
3296        let length = frame::prop_is(&mut bytes, tid, property, value).unwrap();
3297        bytes.truncate(length);
3298        bytes
3299    }
3300
3301    fn answer_requests(
3302        session: &MobileUlcpSession,
3303        requests: Vec<Vec<u8>>,
3304        value: impl Fn(u32) -> (u32, Vec<u8>),
3305    ) -> UlcpSessionUpdateRecord {
3306        let mut last = None;
3307        for request in requests {
3308            let (tid, requested) = property_request(&request);
3309            let (returned, bytes) = value(requested);
3310            last = Some(
3311                session
3312                    .consume(property_response(tid, returned, &bytes))
3313                    .unwrap(),
3314            );
3315        }
3316        last.unwrap()
3317    }
3318
3319    /// Capabilities of a device that is a full mesh citizen in its own
3320    /// right: it has an identity, advertises one, and can forward.
3321    fn commissionable_capabilities() -> Vec<u32> {
3322        vec![
3323            cap::HOST_FILTER,
3324            cap::SAVE,
3325            cap::DEV_NAME,
3326            cap::DEV_IDENTITY,
3327            cap::REPEATER,
3328            cap::IDENT,
3329        ]
3330    }
3331
3332    /// Answer whatever the session asks for, for a device with the
3333    /// capabilities above and a factory-default device domain.
3334    fn commissionable_value(property: u32) -> (u32, Vec<u8>) {
3335        let value = match property {
3336            prop::LAST_STATUS => vec![0],
3337            prop::PROTOCOL_VERSION => vec![6, 0],
3338            prop::CAPS => encoded_capabilities(&commissionable_capabilities()),
3339            prop::DEV_NAME => b"Ridge repeater".to_vec(),
3340            prop::DEV_KEY => vec![0x5A; 32],
3341            prop::BATTERY => Vec::new(),
3342            prop::INTERFACE_TYPE => vec![INTERFACE_TYPE as u8],
3343            prop::PHY_ENABLED => vec![1],
3344            prop::PHY_FREQ => 915_000u32.to_le_bytes().to_vec(),
3345            prop::PHY_TX_POWER => vec![14],
3346            prop::SAVED => vec![saved::CURRENT],
3347            prop::HOST_RX_FILTERS => Vec::new(),
3348            prop::MAC_REPEATER_ENABLED => vec![0],
3349            prop::MAC_REPEATER_REGIONS
3350            | prop::MAC_REPEATER_DEFAULT_REGION
3351            | prop::MAC_REPEATER_MIN_RSSI
3352            | prop::MAC_REPEATER_MIN_SNR
3353            | prop::IDENT_ROLE
3354            | prop::DEV_PEERS
3355            | prop::DEV_CHANNEL_KEYS => Vec::new(),
3356            prop::IDENT_MOBILE => vec![0],
3357            prop::DEV_DISCOVERABLE => vec![1],
3358            other => unreachable!("unexpected property {other}"),
3359        };
3360        (property, value)
3361    }
3362
3363    /// Answer every bounded read batch until the session stops asking.
3364    fn drive_reads(
3365        session: &MobileUlcpSession,
3366        requests: Vec<Vec<u8>>,
3367        value: impl Fn(u32) -> (u32, Vec<u8>),
3368    ) -> UlcpSessionUpdateRecord {
3369        let mut pending = requests;
3370        let mut last = None;
3371        while !pending.is_empty() {
3372            let update = answer_requests(session, pending, &value);
3373            pending = update.outbound_frames.clone();
3374            last = Some(update);
3375        }
3376        last.expect("at least one batch")
3377    }
3378
3379    /// Bring a session to `Attached` against a commissionable device that
3380    /// reports `host_key` as its tethered host.
3381    fn attach_commissionable(
3382        session: &MobileUlcpSession,
3383        selected_host_key: Option<Vec<u8>>,
3384        host_key: Vec<u8>,
3385    ) -> UlcpSessionUpdateRecord {
3386        let begin = session.begin(selected_host_key).unwrap();
3387        drive_reads(session, begin.outbound_frames, move |property| {
3388            if property == prop::HOST_KEY {
3389                (property, host_key.clone())
3390            } else {
3391                commissionable_value(property)
3392            }
3393        })
3394    }
3395
3396    /// Bring a session to `Attached` against a device that also offers the
3397    /// host key tables, which the commissionable fixture deliberately does
3398    /// not.
3399    fn attach_host_keys_capable(session: &MobileUlcpSession) -> UlcpSessionUpdateRecord {
3400        let mut capabilities = commissionable_capabilities();
3401        capabilities.push(cap::HOST_KEYS);
3402        let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
3403        drive_reads(
3404            session,
3405            begin.outbound_frames,
3406            move |property| match property {
3407                prop::CAPS => (property, encoded_capabilities(&capabilities)),
3408                prop::HOST_KEY => (property, vec![0xAA; 32]),
3409                prop::HOST_CHANNEL_KEYS | prop::HOST_PEER_KEYS => (property, Vec::new()),
3410                _ => commissionable_value(property),
3411            },
3412        )
3413    }
3414
3415    /// Consume a configuration batch, returning the writes it made as a
3416    /// property map, the order they were issued in, and the `CMD_SAVE`
3417    /// transaction that closed it.
3418    fn drive_configuration(
3419        session: &MobileUlcpSession,
3420        first_batch: Vec<Vec<u8>>,
3421    ) -> (HashMap<u32, Vec<u8>>, Vec<u32>, u8) {
3422        let mut pending = VecDeque::from(first_batch);
3423        let mut written = HashMap::new();
3424        let mut order = Vec::new();
3425        loop {
3426            let request = pending.pop_front().expect("configuration ends in a save");
3427            let parsed = Frame::parse(&request).unwrap();
3428            if parsed.command() == Some(Cmd::Save) {
3429                return (written, order, parsed.header.tid());
3430            }
3431            assert_eq!(parsed.command(), Some(Cmd::PropSet));
3432            let payload = PropPayload::parse(parsed.payload).unwrap();
3433            order.push(payload.key);
3434            written.insert(payload.key, payload.value.to_vec());
3435            let update = session
3436                .consume(property_response(
3437                    parsed.header.tid(),
3438                    payload.key,
3439                    payload.value,
3440                ))
3441                .unwrap_or_else(|error| {
3442                    panic!("write of property {} failed: {error:?}", payload.key)
3443                });
3444            pending.extend(update.outbound_frames);
3445        }
3446    }
3447
3448    #[test]
3449    fn exported_gatt_round_trip_uses_shared_codec() {
3450        let frame = ulcp_prop_get(3, 4_864).unwrap();
3451        let segments = ulcp_gatt_segments(frame.clone(), 4).unwrap();
3452        let receiver = MobileGattReassembler::new();
3453        let mut completed = None;
3454        for segment in segments {
3455            if let Some(value) = receiver.push(segment.value).unwrap() {
3456                completed = Some(value);
3457            }
3458        }
3459        assert_eq!(completed, Some(frame));
3460    }
3461
3462    #[test]
3463    fn property_response_is_validated_and_typed() {
3464        let mut bytes = [0; 16];
3465        let length = frame::prop_is(&mut bytes, 5, 64, &[1, 2, 3]).unwrap();
3466        assert_eq!(
3467            inspect_ulcp_property_frame(bytes[..length].to_vec()).unwrap(),
3468            UlcpPropertyFrameRecord {
3469                transaction_id: 5,
3470                command: Cmd::PropIs as u8,
3471                property_id: 64,
3472                value: vec![1, 2, 3],
3473            }
3474        );
3475    }
3476
3477    #[test]
3478    fn property_set_uses_shared_frame_codec() {
3479        let encoded = ulcp_prop_set(6, 96, vec![7; 32]).unwrap();
3480        let parsed = Frame::parse(&encoded).unwrap();
3481        assert_eq!(parsed.header.tid(), 6);
3482        assert_eq!(parsed.command(), Some(Cmd::PropSet));
3483        let payload = PropPayload::parse(parsed.payload).unwrap();
3484        assert_eq!(payload.key, 96);
3485        assert_eq!(payload.value, &[7; 32]);
3486    }
3487
3488    #[test]
3489    fn save_and_status_use_shared_frame_codec() {
3490        let encoded = ulcp_save(7).unwrap();
3491        let parsed = Frame::parse(&encoded).unwrap();
3492        assert_eq!(parsed.header.tid(), 7);
3493        assert_eq!(parsed.command(), Some(Cmd::Save));
3494        assert!(parsed.payload.is_empty());
3495
3496        assert_eq!(inspect_ulcp_status(vec![0]).unwrap(), 0);
3497        assert_eq!(
3498            inspect_ulcp_status(vec![0x80]),
3499            Err(MobileError::InvalidUlcpFrame)
3500        );
3501    }
3502
3503    #[test]
3504    fn exported_transport_rejects_invalid_bounds_and_segments() {
3505        assert_eq!(
3506            ulcp_gatt_segments(vec![0; MAX_FRAME + 1], 20),
3507            Err(MobileError::InvalidUlcpFrame)
3508        );
3509        assert_eq!(
3510            ulcp_gatt_segments(vec![1], 1),
3511            Err(MobileError::InvalidGattSegment)
3512        );
3513        assert_eq!(
3514            MobileGattReassembler::new().push(vec![]),
3515            Err(MobileError::InvalidGattSegment)
3516        );
3517    }
3518
3519    #[test]
3520    fn battery_reduction_preserves_supported_ui_fields() {
3521        assert_eq!(
3522            inspect_ulcp_battery(vec![0b110, 82, 1]).unwrap(),
3523            UlcpBatteryRecord {
3524                percentage: Some(82),
3525                voltage_mv: None,
3526                charge_state: Some(UlcpChargeState::Charging),
3527            }
3528        );
3529        assert_eq!(
3530            inspect_ulcp_battery(vec![0b111, 0xEC, 0x0E, 82, 0]).unwrap(),
3531            UlcpBatteryRecord {
3532                percentage: Some(82),
3533                voltage_mv: Some(3820),
3534                charge_state: Some(UlcpChargeState::Discharging),
3535            }
3536        );
3537        assert_eq!(
3538            inspect_ulcp_battery(vec![]).unwrap(),
3539            UlcpBatteryRecord {
3540                percentage: None,
3541                voltage_mv: None,
3542                charge_state: None,
3543            }
3544        );
3545    }
3546
3547    #[test]
3548    fn minimal_inspection_is_small_and_validated() {
3549        assert_eq!(
3550            ulcp_inspection_properties(vec![cap::WRITABLE_RAW_STREAM as u8]).unwrap(),
3551            [
3552                prop::INTERFACE_TYPE,
3553                prop::PHY_ENABLED,
3554                prop::PHY_FREQ,
3555                prop::PHY_TX_POWER,
3556            ]
3557        );
3558        let sync = inspect_ulcp_sync(vec![
3559            response(prop::CAPS, &[cap::WRITABLE_RAW_STREAM as u8]),
3560            response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
3561            response(prop::PHY_ENABLED, &[1]),
3562            response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
3563            response(prop::PHY_TX_POWER, &[14]),
3564        ])
3565        .unwrap();
3566        assert!(sync.phy_enabled);
3567        assert_eq!(sync.frequency_khz, 915_000);
3568        assert_eq!(sync.transmit_power_dbm, 14);
3569        assert!(!sync.has_host_filtering);
3570        assert_eq!(sync.queued_frames, None);
3571    }
3572
3573    #[test]
3574    fn full_inspection_reports_only_digest_counts() {
3575        let capabilities = (cap::HOST_FILTER..=cap::BATTERY)
3576            .map(|capability| capability as u8)
3577            .collect::<Vec<_>>();
3578        let properties = ulcp_inspection_properties(capabilities.clone()).unwrap();
3579        assert!(properties.contains(&prop::HOST_RX_FILTERS));
3580        assert!(properties.contains(&prop::HOST_RX_QUEUE_COUNT));
3581        assert!(properties.contains(&prop::HOST_AUTO_ACK));
3582
3583        let sync = inspect_ulcp_sync(vec![
3584            response(prop::CAPS, &capabilities),
3585            response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
3586            response(prop::PHY_ENABLED, &[1]),
3587            response(prop::PHY_FREQ, &868_100u32.to_le_bytes()),
3588            response(prop::PHY_TX_POWER, &[22]),
3589            response(prop::SAVED, &[1]),
3590            response(prop::HOST_RX_FILTERS, &[]),
3591            response(prop::HOST_CHANNEL_KEYS, &[1, 2, 3, 4]),
3592            response(prop::HOST_PEER_KEYS, &[7; 32]),
3593            response(prop::HOST_RX_QUEUE_COUNT, &3u16.to_le_bytes()),
3594            response(prop::HOST_RX_QUEUE_DROPPED, &4u32.to_le_bytes()),
3595            response(prop::HOST_AUTO_ACK, &[1]),
3596            response(prop::DEV_PEERS, &[7; 64]),
3597            response(prop::DEV_DISCOVERABLE, &[1]),
3598        ])
3599        .unwrap();
3600        assert_eq!(sync.saved, Some(SavedSnapshotRecord::Current));
3601        assert_eq!(sync.queued_frames, Some(3));
3602        assert_eq!(sync.dropped_frames, Some(4));
3603        assert_eq!(sync.filter_count, Some(0));
3604        assert_eq!(sync.host_channel_count, Some(2));
3605        assert_eq!(sync.host_peer_count, Some(1));
3606        assert_eq!(sync.auto_ack, Some(true));
3607        // The device-identity peer list is the one key table read back
3608        // losslessly rather than as a digest count.
3609        assert!(sync.supports_device_identity);
3610        assert_eq!(sync.dev_peer_keys, Some(vec![vec![7; 32], vec![7; 32]]));
3611    }
3612
3613    #[test]
3614    fn invalid_capability_dependencies_and_values_fail_closed() {
3615        assert_eq!(
3616            ulcp_inspection_properties(vec![cap::HOST_RX_QUEUE as u8]),
3617            Err(MobileError::InvalidUlcpFrame)
3618        );
3619        assert_eq!(
3620            inspect_ulcp_sync(vec![
3621                response(prop::CAPS, &[]),
3622                response(prop::INTERFACE_TYPE, &[7]),
3623                response(prop::PHY_ENABLED, &[1]),
3624                response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
3625            ]),
3626            Err(MobileError::InvalidUlcpFrame)
3627        );
3628    }
3629
3630    #[test]
3631    fn mobile_session_owns_sync_tids_and_attaches_transparent_radio() {
3632        let session = MobileUlcpSession::new();
3633        let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
3634        assert_eq!(begin.snapshot.phase, UlcpSessionPhase::Synchronizing);
3635        assert_eq!(begin.outbound_frames.len(), 7);
3636        assert_eq!(
3637            begin
3638                .outbound_frames
3639                .iter()
3640                .map(|request| property_request(request).0)
3641                .collect::<Vec<_>>(),
3642            [1, 2, 3, 4, 5, 6, 7]
3643        );
3644
3645        let inspection =
3646            answer_requests(&session, begin.outbound_frames, |property| match property {
3647                prop::LAST_STATUS => (property, vec![0]),
3648                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
3649                prop::CAPS => (property, vec![cap::WRITABLE_RAW_STREAM as u8]),
3650                prop::DEV_KEY => (property, Vec::new()),
3651                prop::DEV_NAME => (property, b"Transparent".to_vec()),
3652                prop::BATTERY => (property, Vec::new()),
3653                prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
3654                _ => unreachable!(),
3655            });
3656        assert_eq!(inspection.outbound_frames.len(), 4);
3657        assert_eq!(
3658            inspection.snapshot.host_ownership,
3659            UlcpHostOwnership::Unsupported
3660        );
3661
3662        let attached =
3663            answer_requests(
3664                &session,
3665                inspection.outbound_frames,
3666                |property| match property {
3667                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
3668                    prop::PHY_ENABLED => (property, vec![1]),
3669                    prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
3670                    prop::PHY_TX_POWER => (property, vec![14]),
3671                    _ => unreachable!(),
3672                },
3673            );
3674        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
3675        assert_eq!(
3676            attached.snapshot.device_name.as_deref(),
3677            Some("Transparent")
3678        );
3679        assert_eq!(
3680            attached.snapshot.provisioning.unwrap().frequency_khz,
3681            915_000
3682        );
3683    }
3684
3685    #[test]
3686    fn mobile_session_owns_claim_then_save_choreography() {
3687        let host_key = vec![0xAA; 32];
3688        let session = MobileUlcpSession::new();
3689        let begin = session.begin(Some(host_key.clone())).unwrap();
3690        let awaiting =
3691            answer_requests(&session, begin.outbound_frames, |property| match property {
3692                prop::LAST_STATUS => (property, vec![0]),
3693                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
3694                prop::CAPS => (property, vec![cap::HOST_FILTER as u8, cap::SAVE as u8]),
3695                prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY | prop::HOST_KEY => {
3696                    (property, Vec::new())
3697                }
3698                _ => unreachable!(),
3699            });
3700        assert_eq!(awaiting.snapshot.phase, UlcpSessionPhase::AwaitingHost);
3701        assert_eq!(
3702            awaiting.snapshot.host_ownership,
3703            UlcpHostOwnership::Unclaimed
3704        );
3705
3706        let claim = session.claim(host_key.clone()).unwrap();
3707        assert_eq!(claim.snapshot.phase, UlcpSessionPhase::Claiming);
3708        assert_eq!(claim.outbound_frames.len(), 1);
3709        let parsed_claim = Frame::parse(&claim.outbound_frames[0]).unwrap();
3710        assert_eq!(parsed_claim.command(), Some(Cmd::PropSet));
3711        let payload = PropPayload::parse(parsed_claim.payload).unwrap();
3712        assert_eq!(payload.key, prop::HOST_KEY);
3713        assert_eq!(payload.value, host_key);
3714
3715        let save = session
3716            .consume(property_response(
3717                parsed_claim.header.tid(),
3718                prop::HOST_KEY,
3719                &host_key,
3720            ))
3721            .unwrap();
3722        assert_eq!(save.outbound_frames.len(), 1);
3723        let parsed_save = Frame::parse(&save.outbound_frames[0]).unwrap();
3724        assert_eq!(parsed_save.command(), Some(Cmd::Save));
3725
3726        let inspection = session
3727            .consume(property_response(
3728                parsed_save.header.tid(),
3729                prop::LAST_STATUS,
3730                &[0],
3731            ))
3732            .unwrap();
3733        assert_eq!(inspection.outbound_frames.len(), 6);
3734        let attached =
3735            answer_requests(
3736                &session,
3737                inspection.outbound_frames,
3738                |property| match property {
3739                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
3740                    prop::PHY_ENABLED => (property, vec![1]),
3741                    prop::PHY_FREQ => (property, 868_100u32.to_le_bytes().to_vec()),
3742                    prop::PHY_TX_POWER => (property, vec![14]),
3743                    prop::SAVED => (property, vec![1]),
3744                    prop::HOST_RX_FILTERS => (property, Vec::new()),
3745                    _ => unreachable!(),
3746                },
3747            );
3748        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
3749        assert_eq!(attached.snapshot.host_ownership, UlcpHostOwnership::Ours);
3750        assert_eq!(
3751            attached.snapshot.provisioning.unwrap().saved,
3752            Some(SavedSnapshotRecord::Current)
3753        );
3754
3755        let changed_host = session
3756            .consume(property_response(
3757                frame::TID_UNSOLICITED,
3758                prop::HOST_KEY,
3759                &[0xBB; 32],
3760            ))
3761            .unwrap();
3762        assert_eq!(changed_host.snapshot.phase, UlcpSessionPhase::AwaitingHost);
3763        assert_eq!(
3764            changed_host.snapshot.host_ownership,
3765            UlcpHostOwnership::OtherHost
3766        );
3767    }
3768
3769    #[test]
3770    fn administrative_session_attaches_without_claiming_anyones_radio() {
3771        let phone = vec![0xAA; 32];
3772        let other_phone = vec![0xBB; 32];
3773
3774        // A radio someone else tethered. An administrative session has no
3775        // decision to put to the user, so it attaches — and still reports
3776        // whose radio it is, because that is worth showing.
3777        let session = MobileUlcpSession::administrative();
3778        let attached = attach_commissionable(&session, Some(phone.clone()), other_phone.clone());
3779        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
3780        assert_eq!(
3781            attached.snapshot.host_ownership,
3782            UlcpHostOwnership::OtherHost
3783        );
3784        assert_eq!(
3785            session.claim(phone.clone()),
3786            Err(MobileError::AdministrativeSession)
3787        );
3788
3789        // An unclaimed radio likewise: commissioning ten repeaters must
3790        // not leave this phone's host key on any of them.
3791        let unclaimed = MobileUlcpSession::administrative();
3792        let attached = attach_commissionable(&unclaimed, Some(phone.clone()), Vec::new());
3793        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
3794        assert_eq!(
3795            attached.snapshot.host_ownership,
3796            UlcpHostOwnership::Unclaimed
3797        );
3798
3799        // The tethered session is the one that must pause: it is about to
3800        // take the radio from the other phone.
3801        let tethered = MobileUlcpSession::new();
3802        let begin = tethered.begin(Some(phone.clone())).unwrap();
3803        let awaiting = answer_requests(&tethered, begin.outbound_frames, move |property| {
3804            if property == prop::HOST_KEY {
3805                (property, other_phone.clone())
3806            } else {
3807                commissionable_value(property)
3808            }
3809        });
3810        assert_eq!(awaiting.snapshot.phase, UlcpSessionPhase::AwaitingHost);
3811
3812        // A host-key change pushed mid-session does not evict an
3813        // administrative session either.
3814        let pushed = session
3815            .consume(property_response(
3816                frame::TID_UNSOLICITED,
3817                prop::HOST_KEY,
3818                &[0xCC; 32],
3819            ))
3820            .unwrap();
3821        assert_eq!(pushed.snapshot.phase, UlcpSessionPhase::Attached);
3822        assert_eq!(pushed.snapshot.host_ownership, UlcpHostOwnership::OtherHost);
3823    }
3824
3825    #[test]
3826    fn attached_snapshot_reports_the_devices_own_domain() {
3827        let session = MobileUlcpSession::administrative();
3828        let attached = attach_commissionable(&session, None, Vec::new());
3829        let provisioning = attached.snapshot.provisioning.unwrap();
3830        assert!(provisioning.supports_repeater);
3831        assert!(provisioning.supports_ident);
3832        assert_eq!(provisioning.ident_role, None);
3833        assert_eq!(provisioning.ident_mobile, Some(false));
3834        assert_eq!(
3835            provisioning.repeater,
3836            Some(UlcpRepeaterSettingsRecord {
3837                enabled: false,
3838                regions: Vec::new(),
3839                default_region: None,
3840                min_rssi_dbm: None,
3841                min_snr_db: None,
3842            })
3843        );
3844
3845        // A device with no repeater or identity capability reports the
3846        // absence rather than a default-shaped policy.
3847        let plain = inspect_ulcp_sync(vec![
3848            response(prop::CAPS, &[cap::WRITABLE_RAW_STREAM as u8]),
3849            response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
3850            response(prop::PHY_ENABLED, &[1]),
3851            response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
3852            response(prop::PHY_TX_POWER, &[14]),
3853        ])
3854        .unwrap();
3855        assert!(!plain.supports_repeater);
3856        assert!(!plain.supports_ident);
3857        assert_eq!(plain.repeater, None);
3858        assert_eq!(plain.ident_mobile, None);
3859    }
3860
3861    #[test]
3862    fn repeater_policy_round_trips_through_the_sync_reducer() {
3863        let capabilities = encoded_capabilities(&commissionable_capabilities());
3864        let sync = inspect_ulcp_sync(vec![
3865            response(prop::CAPS, &capabilities),
3866            response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
3867            response(prop::PHY_ENABLED, &[1]),
3868            response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
3869            response(prop::PHY_TX_POWER, &[14]),
3870            response(prop::SAVED, &[saved::CURRENT]),
3871            response(prop::HOST_RX_FILTERS, &[]),
3872            response(prop::MAC_REPEATER_ENABLED, &[1]),
3873            // SJC and SFO, the two-octet codes exactly as advertised.
3874            response(prop::MAC_REPEATER_REGIONS, &[0x78, 0x53, 0x7C, 0x0F]),
3875            response(prop::MAC_REPEATER_DEFAULT_REGION, &[0x78, 0x53]),
3876            response(prop::MAC_REPEATER_MIN_RSSI, &(-115i16).to_le_bytes()),
3877            response(prop::MAC_REPEATER_MIN_SNR, &[(-7i8) as u8]),
3878            response(prop::IDENT_ROLE, &[3]),
3879            response(prop::IDENT_MOBILE, &[1]),
3880            response(prop::DEV_PEERS, &[]),
3881            response(prop::DEV_DISCOVERABLE, &[1]),
3882        ])
3883        .unwrap();
3884        assert_eq!(
3885            sync.repeater,
3886            Some(UlcpRepeaterSettingsRecord {
3887                enabled: true,
3888                regions: vec![vec![0x78, 0x53], vec![0x7C, 0x0F]],
3889                default_region: Some(vec![0x78, 0x53]),
3890                min_rssi_dbm: Some(-115),
3891                min_snr_db: Some(-7),
3892            })
3893        );
3894        assert_eq!(sync.ident_role, Some(3));
3895        assert_eq!(sync.ident_mobile, Some(true));
3896
3897        // An odd-length region list is not a set of region codes, so the
3898        // policy is not reported — but the device still is. A repeater
3899        // without an identity of its own, on the other hand, is not a
3900        // repeater, and that is a malformed capability set.
3901        let malformed = |property, value: &[u8]| {
3902            let mut responses = vec![
3903                response(prop::CAPS, &capabilities),
3904                response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
3905                response(prop::PHY_ENABLED, &[1]),
3906                response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
3907                response(prop::PHY_TX_POWER, &[14]),
3908                response(prop::SAVED, &[saved::CURRENT]),
3909                response(prop::MAC_REPEATER_ENABLED, &[0]),
3910                response(prop::MAC_REPEATER_REGIONS, &[]),
3911                response(prop::MAC_REPEATER_DEFAULT_REGION, &[]),
3912                response(prop::MAC_REPEATER_MIN_RSSI, &[]),
3913                response(prop::MAC_REPEATER_MIN_SNR, &[]),
3914                response(prop::IDENT_ROLE, &[]),
3915                response(prop::IDENT_MOBILE, &[0]),
3916                response(prop::DEV_PEERS, &[]),
3917                response(prop::DEV_DISCOVERABLE, &[1]),
3918            ];
3919            responses.retain(|entry| entry.property_id != property);
3920            responses.push(response(property, value));
3921            inspect_ulcp_sync(responses)
3922        };
3923        for property in [
3924            prop::MAC_REPEATER_REGIONS,
3925            prop::MAC_REPEATER_DEFAULT_REGION,
3926            prop::MAC_REPEATER_MIN_RSSI,
3927        ] {
3928            let sync = malformed(property, &[0x8D, 0x53, 0x7C]).expect("device still described");
3929            assert_eq!(sync.repeater, None, "property {property}");
3930            assert!(sync.supports_repeater, "property {property}");
3931            assert!(
3932                sync.unreadable_properties.contains(&property),
3933                "property {property}"
3934            );
3935        }
3936        assert_eq!(
3937            ulcp_inspection_properties(encoded_capabilities(&[cap::REPEATER])),
3938            Err(MobileError::InvalidUlcpFrame)
3939        );
3940    }
3941
3942    /// A device that refuses one capability-gated property — firmware
3943    /// older than the capability it advertises — is a device with one
3944    /// unknown setting, not one this phone cannot administer. It attaches,
3945    /// it describes itself, and it stays configurable; the refused setting
3946    /// is absent, named, and left out of the write that follows.
3947    #[test]
3948    fn a_refused_property_still_yields_an_administrable_device() {
3949        let session = MobileUlcpSession::administrative();
3950        let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
3951        let attached = drive_reads(&session, begin.outbound_frames, |property| match property {
3952            // One property of its own, and one part of a policy that is
3953            // only meaningful whole.
3954            prop::DEV_DISCOVERABLE | prop::MAC_REPEATER_MIN_RSSI => (
3955                prop::LAST_STATUS,
3956                vec![umsh_ulcp::Status::PROP_NOT_FOUND.0 as u8],
3957            ),
3958            prop::HOST_KEY => (property, vec![0xBB; 32]),
3959            other => commissionable_value(other),
3960        });
3961
3962        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
3963        // Nobody asked to read that property in particular; they asked to
3964        // attach, and the attach succeeded. Reporting it as a failed
3965        // operation is what used to strand the caller with no snapshot.
3966        assert_eq!(attached.operation_error, None);
3967        let sync = attached.snapshot.provisioning.expect("device described");
3968        assert!(sync.supports_device_identity);
3969        assert_eq!(sync.dev_discoverable, None);
3970        assert_eq!(
3971            sync.unreadable_properties,
3972            vec![prop::MAC_REPEATER_MIN_RSSI, prop::DEV_DISCOVERABLE]
3973        );
3974        // The rest of the same capability is unaffected.
3975        assert_eq!(sync.dev_peer_keys, Some(Vec::new()));
3976        // A policy missing one part is not a policy, so none of it is
3977        // reported — the device still says it can forward.
3978        assert!(sync.supports_repeater);
3979        assert_eq!(sync.repeater, None);
3980
3981        let configured = session
3982            .configure_device(UlcpDeviceConfigRecord {
3983                radio: UlcpRadioSettingsRecord {
3984                    device_name: None,
3985                    phy_enabled: true,
3986                    frequency_khz: 906_875,
3987                    transmit_power_dbm: 20,
3988                    bandwidth_hz: None,
3989                    spreading_factor: None,
3990                    coding_rate_denom: None,
3991                    duty_cycle_limit: None,
3992                },
3993                ident_role: None,
3994                ident_mobile: Some(true),
3995                dev_discoverable: Some(true),
3996                repeater: Some(UlcpRepeaterSettingsRecord {
3997                    enabled: false,
3998                    regions: Vec::new(),
3999                    default_region: None,
4000                    min_rssi_dbm: None,
4001                    min_snr_db: None,
4002                }),
4003                tz_offset_min: None,
4004                gnss: None,
4005                advert: None,
4006            })
4007            .unwrap();
4008        let (written, _, _) = drive_configuration(&session, configured.outbound_frames);
4009        // The write the device would have rejected — failing the whole
4010        // pass over a setting nobody can even see — is never sent, and
4011        // neither is the rest of the policy it belongs to: a device left
4012        // forwarding under half a policy is worse than one left alone.
4013        assert!(!written.contains_key(&prop::DEV_DISCOVERABLE));
4014        for property in [
4015            prop::MAC_REPEATER_ENABLED,
4016            prop::MAC_REPEATER_REGIONS,
4017            prop::MAC_REPEATER_DEFAULT_REGION,
4018            prop::MAC_REPEATER_MIN_RSSI,
4019            prop::MAC_REPEATER_MIN_SNR,
4020        ] {
4021            assert!(!written.contains_key(&property), "property {property}");
4022        }
4023        assert_eq!(written.get(&prop::IDENT_MOBILE), Some(&vec![1]));
4024        assert_eq!(
4025            written.get(&prop::PHY_FREQ),
4026            Some(&906_875u32.to_le_bytes().to_vec())
4027        );
4028    }
4029
4030    #[test]
4031    fn configuring_a_device_writes_its_whole_domain_as_a_property_map() {
4032        let session = MobileUlcpSession::administrative();
4033        let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xBB; 32]);
4034        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
4035
4036        let configured = session
4037            .configure_device(UlcpDeviceConfigRecord {
4038                radio: UlcpRadioSettingsRecord {
4039                    device_name: Some("Ridge repeater".into()),
4040                    phy_enabled: true,
4041                    frequency_khz: 906_875,
4042                    transmit_power_dbm: 22,
4043                    bandwidth_hz: None,
4044                    spreading_factor: None,
4045                    coding_rate_denom: None,
4046                    duty_cycle_limit: None,
4047                },
4048                ident_role: Some(3),
4049                ident_mobile: Some(false),
4050                dev_discoverable: Some(false),
4051                repeater: Some(UlcpRepeaterSettingsRecord {
4052                    enabled: true,
4053                    regions: vec![vec![0x78, 0x53]],
4054                    default_region: Some(vec![0x78, 0x53]),
4055                    min_rssi_dbm: Some(-115),
4056                    min_snr_db: Some(-7),
4057                }),
4058                tz_offset_min: None,
4059                gnss: None,
4060                advert: None,
4061            })
4062            .unwrap();
4063        assert_eq!(configured.snapshot.phase, UlcpSessionPhase::Configuring);
4064
4065        let (written, order, save_tid) = drive_configuration(&session, configured.outbound_frames);
4066
4067        // The whole configuration is a property -> value map with nothing
4068        // else in it. A template feature that produces such a map has
4069        // everything it needs; nothing here is shaped around this record.
4070        assert_eq!(
4071            written,
4072            HashMap::from([
4073                (prop::DEV_NAME, b"Ridge repeater".to_vec()),
4074                (prop::PHY_FREQ, 906_875u32.to_le_bytes().to_vec()),
4075                (prop::PHY_TX_POWER, vec![22]),
4076                (prop::IDENT_ROLE, vec![3]),
4077                (prop::IDENT_MOBILE, vec![0]),
4078                (prop::DEV_DISCOVERABLE, vec![0]),
4079                (prop::MAC_REPEATER_REGIONS, vec![0x78, 0x53]),
4080                (prop::MAC_REPEATER_DEFAULT_REGION, vec![0x78, 0x53]),
4081                (
4082                    prop::MAC_REPEATER_MIN_RSSI,
4083                    (-115i16).to_le_bytes().to_vec()
4084                ),
4085                (prop::MAC_REPEATER_MIN_SNR, vec![(-7i8) as u8]),
4086                (prop::MAC_REPEATER_ENABLED, vec![1]),
4087                (prop::PHY_ENABLED, vec![1]),
4088            ])
4089        );
4090        // Forwarding starts only once the whole policy — and the radio it
4091        // forwards over — is in place.
4092        assert_eq!(
4093            &order[order.len() - 2..],
4094            &[prop::MAC_REPEATER_ENABLED, prop::PHY_ENABLED]
4095        );
4096
4097        let attached = session
4098            .consume(property_response(save_tid, prop::LAST_STATUS, &[0]))
4099            .unwrap();
4100        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
4101        assert_eq!(
4102            attached.snapshot.host_ownership,
4103            UlcpHostOwnership::OtherHost
4104        );
4105        let provisioning = attached.snapshot.provisioning.unwrap();
4106        assert_eq!(provisioning.frequency_khz, 906_875);
4107        assert_eq!(provisioning.ident_role, Some(3));
4108        assert_eq!(
4109            provisioning.repeater,
4110            Some(UlcpRepeaterSettingsRecord {
4111                enabled: true,
4112                regions: vec![vec![0x78, 0x53]],
4113                default_region: Some(vec![0x78, 0x53]),
4114                min_rssi_dbm: Some(-115),
4115                min_snr_db: Some(-7),
4116            })
4117        );
4118    }
4119
4120    #[test]
4121    fn device_configuration_must_match_what_the_device_can_do() {
4122        let session = MobileUlcpSession::administrative();
4123        attach_commissionable(&session, None, Vec::new());
4124
4125        let radio = UlcpRadioSettingsRecord {
4126            device_name: None,
4127            phy_enabled: true,
4128            frequency_khz: 915_000,
4129            transmit_power_dbm: 14,
4130            bandwidth_hz: None,
4131            spreading_factor: None,
4132            coding_rate_denom: None,
4133            duty_cycle_limit: None,
4134        };
4135        let repeater = UlcpRepeaterSettingsRecord {
4136            enabled: true,
4137            regions: Vec::new(),
4138            default_region: None,
4139            min_rssi_dbm: None,
4140            min_snr_db: None,
4141        };
4142        let configure = |ident_role, ident_mobile, dev_discoverable, repeater| {
4143            session.configure_device(UlcpDeviceConfigRecord {
4144                radio: radio.clone(),
4145                ident_role,
4146                ident_mobile,
4147                dev_discoverable,
4148                repeater,
4149                tz_offset_min: None,
4150                gnss: None,
4151                advert: None,
4152            })
4153        };
4154
4155        // Every capability-gated field is required, because the record
4156        // states a whole desired configuration rather than a patch.
4157        assert_eq!(
4158            configure(Some(3), None, Some(true), Some(repeater.clone())),
4159            Err(MobileError::InvalidUlcpFrame)
4160        );
4161        assert_eq!(
4162            configure(None, Some(false), Some(true), None),
4163            Err(MobileError::InvalidUlcpFrame)
4164        );
4165        assert_eq!(
4166            configure(None, Some(false), None, Some(repeater.clone())),
4167            Err(MobileError::InvalidUlcpFrame)
4168        );
4169        // A region code is two octets or it is not a region code.
4170        assert_eq!(
4171            configure(
4172                None,
4173                Some(false),
4174                Some(true),
4175                Some(UlcpRepeaterSettingsRecord {
4176                    regions: vec![vec![0x78]],
4177                    ..repeater.clone()
4178                })
4179            ),
4180            Err(MobileError::InvalidUlcpFrame)
4181        );
4182        assert_eq!(
4183            configure(
4184                None,
4185                Some(false),
4186                Some(true),
4187                Some(UlcpRepeaterSettingsRecord {
4188                    default_region: Some(vec![0x78, 0x53, 0x00]),
4189                    ..repeater.clone()
4190                })
4191            ),
4192            Err(MobileError::InvalidUlcpFrame)
4193        );
4194
4195        // An omitted role is the device deriving its own, written as an
4196        // empty value rather than skipped.
4197        let configured = configure(None, Some(true), Some(true), Some(repeater)).unwrap();
4198        let (written, ..) = drive_configuration(&session, configured.outbound_frames);
4199        assert_eq!(written.get(&prop::IDENT_ROLE), Some(&Vec::new()));
4200        assert_eq!(written.get(&prop::IDENT_MOBILE), Some(&vec![1]));
4201        assert_eq!(written.get(&prop::DEV_DISCOVERABLE), Some(&vec![1]));
4202        assert_eq!(written.get(&prop::MAC_REPEATER_REGIONS), Some(&Vec::new()));
4203        assert_eq!(written.get(&prop::MAC_REPEATER_MIN_RSSI), Some(&Vec::new()));
4204    }
4205
4206    #[test]
4207    fn region_codes_convert_between_text_and_wire_octets() {
4208        assert_eq!(region_code_from_string("SJC".into()).unwrap(), [0x78, 0x53]);
4209        assert_eq!(region_code_description(vec![0x78, 0x53]).unwrap(), "SJC");
4210        // A name lands outside the airport letter space, so it never
4211        // renders as three letters and round-trips through hex.
4212        let named = region_code_from_string("Rogue Valley".into()).unwrap();
4213        assert_eq!(named, [0xDF, 0x6F]);
4214        let described = region_code_description(named.clone()).unwrap();
4215        assert_eq!(described, "0xDF6F");
4216        assert_eq!(region_code_from_string(described).unwrap(), named);
4217
4218        assert_eq!(
4219            region_code_from_string("  ".into()),
4220            Err(MobileError::InvalidRegionCode)
4221        );
4222        assert_eq!(
4223            region_code_description(vec![0x78]),
4224            Err(MobileError::InvalidRegionCode)
4225        );
4226    }
4227
4228    #[test]
4229    fn mobile_session_rejects_mismatched_transaction_response() {
4230        let session = MobileUlcpSession::new();
4231        let begin = session.begin(None).unwrap();
4232        let (tid, _) = property_request(&begin.outbound_frames[0]);
4233        assert_eq!(
4234            session.consume(property_response(tid, prop::PHY_FREQ, &[0; 4])),
4235            Err(MobileError::InvalidUlcpFrame)
4236        );
4237    }
4238
4239    #[test]
4240    fn mobile_session_emits_typed_raw_receive_during_sync() {
4241        let session = MobileUlcpSession::new();
4242        session.begin(None).unwrap();
4243
4244        let metadata = BufferedRxMeta {
4245            rx: umsh_ulcp::RxMeta {
4246                rssi_dbm: Some(-87),
4247                lqi: core::num::NonZeroU8::new(42),
4248                snr_cb: Some(125),
4249            },
4250            flags: RX_FLAG_BUFFERED | RX_FLAG_ACKED,
4251            age_s: 9,
4252        };
4253        let mut metadata_bytes = [0; BufferedRxMeta::WIRE_LEN];
4254        metadata.encode(&mut metadata_bytes).unwrap();
4255        let mut bytes = vec![0; MAX_FRAME];
4256        let len = frame::str_recv(
4257            &mut bytes,
4258            umsh_ulcp::ids::stream::PHY_RAW,
4259            &[1, 2, 3],
4260            &metadata_bytes,
4261        )
4262        .unwrap();
4263        bytes.truncate(len);
4264
4265        let update = session.consume(bytes).unwrap();
4266        assert_eq!(update.received_frames.len(), 1);
4267        assert_eq!(
4268            update.received_frames[0],
4269            UlcpReceivedFrameRecord {
4270                data: vec![1, 2, 3],
4271                rssi_dbm: Some(-87),
4272                lqi: Some(42),
4273                snr_cb: Some(125),
4274                was_buffered: true,
4275                was_acknowledged: true,
4276                age_seconds: 9,
4277            }
4278        );
4279        assert!(update.outbound_frames.is_empty());
4280        assert!(update.waiting_for_responses);
4281    }
4282
4283    #[test]
4284    fn mobile_session_reports_raw_transmit_rejection_without_ending_session() {
4285        let session = MobileUlcpSession::new();
4286        let begin = session.begin(None).unwrap();
4287        let inspection =
4288            answer_requests(&session, begin.outbound_frames, |property| match property {
4289                prop::LAST_STATUS => (property, vec![0]),
4290                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
4291                prop::CAPS => (property, vec![cap::WRITABLE_RAW_STREAM as u8]),
4292                prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY => (property, Vec::new()),
4293                prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
4294                _ => unreachable!(),
4295            });
4296        let attached =
4297            answer_requests(
4298                &session,
4299                inspection.outbound_frames,
4300                |property| match property {
4301                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
4302                    prop::PHY_ENABLED => (property, vec![1]),
4303                    prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
4304                    prop::PHY_TX_POWER => (property, vec![14]),
4305                    _ => unreachable!(),
4306                },
4307            );
4308        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
4309
4310        let transmit = session.transmit_raw(vec![1, 2, 3], false).unwrap();
4311        assert!(transmit.raw_transmit_pending);
4312        assert_eq!(transmit.raw_transmit_result, None);
4313        assert_eq!(transmit.outbound_frames.len(), 1);
4314        let second_transmit = session.transmit_raw(vec![4], false).unwrap();
4315        assert_ne!(
4316            transmit.raw_transmit_started_transaction_id,
4317            second_transmit.raw_transmit_started_transaction_id
4318        );
4319
4320        let request = Frame::parse(&transmit.outbound_frames[0]).unwrap();
4321        let rejected = session
4322            .consume(property_response(
4323                request.header.tid(),
4324                prop::LAST_STATUS,
4325                &[umsh_ulcp::Status::INVALID_STATE.0 as u8],
4326            ))
4327            .unwrap();
4328        assert_eq!(rejected.snapshot.phase, UlcpSessionPhase::Attached);
4329        assert!(rejected.raw_transmit_pending);
4330        assert_eq!(
4331            rejected.raw_transmit_result,
4332            Some(UlcpRawTransmitResultRecord {
4333                transaction_id: request.header.tid(),
4334                status_code: umsh_ulcp::Status::INVALID_STATE.0,
4335                status_name: "Status::INVALID_STATE".into(),
4336                disposition: UlcpRawTransmitDisposition::Rejected,
4337            })
4338        );
4339        let second_request = Frame::parse(&second_transmit.outbound_frames[0]).unwrap();
4340        let completed = session
4341            .consume(property_response(
4342                second_request.header.tid(),
4343                prop::LAST_STATUS,
4344                &[umsh_ulcp::Status::OK.0 as u8],
4345            ))
4346            .unwrap();
4347        assert!(!completed.raw_transmit_pending);
4348
4349        // A radio-level rejection completes only that send; the attached
4350        // session remains usable for the next raw frame.
4351        let retryable = session.transmit_raw(vec![5], false).unwrap();
4352        let request = Frame::parse(&retryable.outbound_frames[0]).unwrap();
4353        let busy = session
4354            .consume(property_response(
4355                request.header.tid(),
4356                prop::LAST_STATUS,
4357                &[umsh_ulcp::Status::BUSY.0 as u8],
4358            ))
4359            .unwrap();
4360        assert_eq!(
4361            busy.raw_transmit_result.unwrap().disposition,
4362            UlcpRawTransmitDisposition::Retry
4363        );
4364
4365        let abandoned = session.transmit_raw(vec![6], false).unwrap();
4366        let abandoned_request = Frame::parse(&abandoned.outbound_frames[0]).unwrap();
4367        assert!(
4368            !session
4369                .abandon_raw_transmits(vec![abandoned_request.header.tid()])
4370                .raw_transmit_pending
4371        );
4372
4373        // A status error for an ordinary property operation is also
4374        // nonfatal. Finish the rest of the bounded batch, recover to Attached,
4375        // and prove the same session can issue another raw transmission.
4376        let configured = session
4377            .configure(UlcpRadioSettingsRecord {
4378                device_name: None,
4379                phy_enabled: true,
4380                frequency_khz: 915_000,
4381                transmit_power_dbm: 14,
4382                bandwidth_hz: None,
4383                spreading_factor: None,
4384                coding_rate_denom: None,
4385                duty_cycle_limit: None,
4386            })
4387            .unwrap();
4388        let mut final_update = None;
4389        for (index, request) in configured.outbound_frames.into_iter().enumerate() {
4390            let parsed = Frame::parse(&request).unwrap();
4391            let payload = PropPayload::parse(parsed.payload).unwrap();
4392            let response = if index == 0 {
4393                property_response(
4394                    parsed.header.tid(),
4395                    prop::LAST_STATUS,
4396                    &[umsh_ulcp::Status::INVALID_ARGUMENT.0 as u8],
4397                )
4398            } else {
4399                property_response(parsed.header.tid(), payload.key, payload.value)
4400            };
4401            let update = session.consume(response).unwrap();
4402            if index == 0 {
4403                assert_eq!(
4404                    update.operation_error,
4405                    Some(UlcpOperationErrorRecord {
4406                        operation: format!("set property {}", payload.key),
4407                        status_code: umsh_ulcp::Status::INVALID_ARGUMENT.0,
4408                        status_name: "Status::INVALID_ARGUMENT".into(),
4409                    })
4410                );
4411            }
4412            final_update = Some(update);
4413        }
4414        assert_eq!(
4415            final_update.unwrap().snapshot.phase,
4416            UlcpSessionPhase::Attached
4417        );
4418        assert!(session.transmit_raw(vec![6], false).is_ok());
4419    }
4420
4421    #[test]
4422    fn mobile_session_verifies_radio_configuration_then_saves() {
4423        let session = MobileUlcpSession::new();
4424        let begin = session.begin(None).unwrap();
4425        let inspection =
4426            answer_requests(&session, begin.outbound_frames, |property| match property {
4427                prop::LAST_STATUS => (property, vec![0]),
4428                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
4429                prop::CAPS => (
4430                    property,
4431                    encoded_capabilities(&[
4432                        cap::SAVE,
4433                        cap::DEV_NAME,
4434                        cap::PHY_LORA,
4435                        cap::PHY_DUTY_LIMIT,
4436                    ]),
4437                ),
4438                prop::DEV_NAME => (property, b"Old name".to_vec()),
4439                prop::DEV_KEY | prop::BATTERY => (property, Vec::new()),
4440                prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
4441                _ => unreachable!(),
4442            });
4443        let partial =
4444            answer_requests(
4445                &session,
4446                inspection.outbound_frames,
4447                |property| match property {
4448                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
4449                    prop::PHY_ENABLED => (property, vec![1]),
4450                    prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
4451                    prop::PHY_TX_POWER => (property, vec![14]),
4452                    prop::PHY_LORA_BW => (property, 125_000u32.to_le_bytes().to_vec()),
4453                    prop::PHY_LORA_SF => (property, vec![9]),
4454                    prop::PHY_LORA_CR => (property, vec![5]),
4455                    _ => unreachable!(),
4456                },
4457            );
4458        let attached = answer_requests(
4459            &session,
4460            partial.outbound_frames,
4461            |property| match property {
4462                prop::PHY_DUTY_NOW => (property, 65u16.to_le_bytes().to_vec()),
4463                prop::PHY_DUTY_LIMIT => (property, 655u16.to_le_bytes().to_vec()),
4464                prop::SAVED => (property, vec![1]),
4465                _ => unreachable!(),
4466            },
4467        );
4468        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
4469
4470        let configured = session
4471            .configure(UlcpRadioSettingsRecord {
4472                device_name: Some("Trail radio".into()),
4473                phy_enabled: true,
4474                frequency_khz: 868_100,
4475                transmit_power_dbm: 20,
4476                bandwidth_hz: Some(250_000),
4477                spreading_factor: Some(10),
4478                coding_rate_denom: Some(6),
4479                duty_cycle_limit: Some(6_553),
4480            })
4481            .unwrap();
4482        assert_eq!(configured.snapshot.phase, UlcpSessionPhase::Configuring);
4483        assert_eq!(
4484            configured.outbound_frames.len(),
4485            usize::from(frame::TID_MAX)
4486        );
4487        let mut pending = VecDeque::from(configured.outbound_frames);
4488        let mut configured_properties = Vec::new();
4489        let save_tid = loop {
4490            let request = pending.pop_front().unwrap();
4491            let parsed = Frame::parse(&request).unwrap();
4492            if parsed.command() == Some(Cmd::Save) {
4493                break parsed.header.tid();
4494            }
4495            assert_eq!(parsed.command(), Some(Cmd::PropSet));
4496            let payload = PropPayload::parse(parsed.payload).unwrap();
4497            configured_properties.push(payload.key);
4498            // This radio tops out below the 20 dBm asked for and answers
4499            // with the power it will actually use. A `CMD_PROP_IS` is the
4500            // device's word on the property, so the session takes it.
4501            let answer: &[u8] = match payload.key {
4502                prop::PHY_TX_POWER => &[17],
4503                _ => payload.value,
4504            };
4505            let update = session
4506                .consume(property_response(parsed.header.tid(), payload.key, answer))
4507                .unwrap_or_else(|error| {
4508                    panic!(
4509                        "configuration response for property {} failed: {error:?}",
4510                        payload.key
4511                    )
4512                });
4513            pending.extend(update.outbound_frames);
4514        };
4515        assert_eq!(configured_properties.last(), Some(&prop::PHY_ENABLED));
4516        let attached = session
4517            .consume(property_response(save_tid, prop::LAST_STATUS, &[0]))
4518            .unwrap();
4519        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
4520        assert_eq!(
4521            attached.snapshot.device_name.as_deref(),
4522            Some("Trail radio")
4523        );
4524        let provisioning = attached.snapshot.provisioning.unwrap();
4525        assert_eq!(provisioning.frequency_khz, 868_100);
4526        // 20 dBm was written; the device said 17, so 17 is what the phone
4527        // shows.
4528        assert_eq!(provisioning.transmit_power_dbm, 17);
4529        assert_eq!(provisioning.bandwidth_hz, Some(250_000));
4530        assert_eq!(provisioning.spreading_factor, Some(10));
4531        assert_eq!(provisioning.coding_rate_denom, Some(6));
4532        assert_eq!(provisioning.duty_cycle_now, Some(65));
4533        assert_eq!(provisioning.duty_cycle_limit, Some(6_553));
4534
4535        let pushed = session
4536            .consume(property_response(
4537                frame::TID_UNSOLICITED,
4538                prop::PHY_DUTY_NOW,
4539                &131u16.to_le_bytes(),
4540            ))
4541            .unwrap();
4542        assert_eq!(
4543            pushed.snapshot.provisioning.unwrap().duty_cycle_now,
4544            Some(131)
4545        );
4546
4547        let refresh = session.refresh().unwrap();
4548        assert_eq!(refresh.snapshot.phase, UlcpSessionPhase::Attached);
4549        assert!(refresh.waiting_for_responses);
4550        let refresh_tail =
4551            answer_requests(
4552                &session,
4553                refresh.outbound_frames,
4554                |property| match property {
4555                    prop::DEV_NAME => (property, b"Fresh name".to_vec()),
4556                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
4557                    prop::PHY_ENABLED => (property, vec![1]),
4558                    prop::PHY_FREQ => (property, 910_525u32.to_le_bytes().to_vec()),
4559                    prop::PHY_TX_POWER => (property, vec![18]),
4560                    prop::PHY_LORA_BW => (property, 62_500u32.to_le_bytes().to_vec()),
4561                    prop::PHY_LORA_SF => (property, vec![7]),
4562                    _ => unreachable!(),
4563                },
4564            );
4565        let refreshed =
4566            answer_requests(
4567                &session,
4568                refresh_tail.outbound_frames,
4569                |property| match property {
4570                    prop::PHY_LORA_CR => (property, vec![5]),
4571                    prop::PHY_DUTY_NOW => (property, 262u16.to_le_bytes().to_vec()),
4572                    prop::PHY_DUTY_LIMIT => (property, 655u16.to_le_bytes().to_vec()),
4573                    prop::SAVED => (property, vec![1]),
4574                    _ => unreachable!(),
4575                },
4576            );
4577        assert_eq!(refreshed.snapshot.phase, UlcpSessionPhase::Attached);
4578        assert!(!refreshed.waiting_for_responses);
4579        assert_eq!(
4580            refreshed.snapshot.device_name.as_deref(),
4581            Some("Fresh name")
4582        );
4583        let refreshed = refreshed.snapshot.provisioning.unwrap();
4584        assert_eq!(refreshed.frequency_khz, 910_525);
4585        assert_eq!(refreshed.duty_cycle_now, Some(262));
4586        assert_eq!(refreshed.duty_cycle_limit, Some(655));
4587    }
4588
4589    fn inserted_response(tid: u8, property: u32, item: &[u8]) -> Vec<u8> {
4590        let mut bytes = vec![0; MAX_FRAME];
4591        let length = frame::prop_inserted(&mut bytes, tid, property, item).unwrap();
4592        bytes.truncate(length);
4593        bytes
4594    }
4595
4596    fn removed_response(tid: u8, property: u32, item: &[u8]) -> Vec<u8> {
4597        let mut bytes = vec![0; MAX_FRAME];
4598        let length = frame::prop_removed(&mut bytes, tid, property, item).unwrap();
4599        bytes.truncate(length);
4600        bytes
4601    }
4602
4603    fn dev_peer_keys(update: &UlcpSessionUpdateRecord) -> Vec<Vec<u8>> {
4604        update
4605            .snapshot
4606            .provisioning
4607            .as_ref()
4608            .unwrap()
4609            .dev_peer_keys
4610            .clone()
4611            .unwrap()
4612    }
4613
4614    #[test]
4615    fn device_peer_insert_and_remove_patch_the_table_and_chain_a_save() {
4616        let session = MobileUlcpSession::new();
4617        let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
4618        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
4619        assert_eq!(dev_peer_keys(&attached), Vec::<Vec<u8>>::new());
4620
4621        let insert = session.insert_device_peer(vec![0xC1; 32]).unwrap();
4622        assert_eq!(insert.outbound_frames.len(), 1);
4623        let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
4624        assert_eq!(request.command(), Some(Cmd::PropInsert));
4625
4626        let confirmed = session
4627            .consume(inserted_response(
4628                request.header.tid(),
4629                prop::DEV_PEERS,
4630                &[0xC1; 32],
4631            ))
4632            .unwrap();
4633        assert_eq!(dev_peer_keys(&confirmed), vec![vec![0xC1; 32]]);
4634        assert_eq!(confirmed.operation_error, None);
4635        // The mutation is live but unsaved; CMD_SAVE rides behind it.
4636        assert!(confirmed.waiting_for_responses);
4637        assert_eq!(confirmed.outbound_frames.len(), 1);
4638        let save = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
4639        assert_eq!(save.command(), Some(Cmd::Save));
4640
4641        let saved = session
4642            .consume(property_response(
4643                save.header.tid(),
4644                prop::LAST_STATUS,
4645                &[umsh_ulcp::Status::OK.0 as u8],
4646            ))
4647            .unwrap();
4648        assert_eq!(saved.operation_error, None);
4649        assert!(!saved.waiting_for_responses);
4650        assert_eq!(saved.snapshot.phase, UlcpSessionPhase::Attached);
4651
4652        let remove = session.remove_device_peer(vec![0xC1; 32]).unwrap();
4653        let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
4654        assert_eq!(request.command(), Some(Cmd::PropRemove));
4655        let confirmed = session
4656            .consume(removed_response(
4657                request.header.tid(),
4658                prop::DEV_PEERS,
4659                &[0xC1; 32],
4660            ))
4661            .unwrap();
4662        assert_eq!(dev_peer_keys(&confirmed), Vec::<Vec<u8>>::new());
4663        let save = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
4664        assert_eq!(save.command(), Some(Cmd::Save));
4665        let saved = session
4666            .consume(property_response(
4667                save.header.tid(),
4668                prop::LAST_STATUS,
4669                &[umsh_ulcp::Status::OK.0 as u8],
4670            ))
4671            .unwrap();
4672        assert!(!saved.waiting_for_responses);
4673    }
4674
4675    #[test]
4676    fn device_peer_failures_report_status_without_ending_the_session() {
4677        let session = MobileUlcpSession::new();
4678        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
4679
4680        // NOMEM: the list is full. Nothing changed on the device, so the
4681        // cache stays put and no save is chained.
4682        let insert = session.insert_device_peer(vec![0xC2; 32]).unwrap();
4683        let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
4684        let full = session
4685            .consume(property_response(
4686                request.header.tid(),
4687                prop::LAST_STATUS,
4688                &[umsh_ulcp::Status::NOMEM.0 as u8],
4689            ))
4690            .unwrap();
4691        assert_eq!(
4692            full.operation_error,
4693            Some(UlcpOperationErrorRecord {
4694                operation: "insert device peer".into(),
4695                status_code: umsh_ulcp::Status::NOMEM.0,
4696                status_name: "Status::NOMEM".into(),
4697            })
4698        );
4699        assert_eq!(dev_peer_keys(&full), Vec::<Vec<u8>>::new());
4700        assert!(!full.waiting_for_responses);
4701        assert_eq!(full.snapshot.phase, UlcpSessionPhase::Attached);
4702
4703        // ALREADY: the key is on the device; the cache reflects that even
4704        // though the operation reports a non-OK status.
4705        let insert = session.insert_device_peer(vec![0xC3; 32]).unwrap();
4706        let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
4707        let already = session
4708            .consume(property_response(
4709                request.header.tid(),
4710                prop::LAST_STATUS,
4711                &[umsh_ulcp::Status::ALREADY.0 as u8],
4712            ))
4713            .unwrap();
4714        assert_eq!(
4715            already.operation_error.as_ref().unwrap().status_name,
4716            "Status::ALREADY"
4717        );
4718        assert_eq!(dev_peer_keys(&already), vec![vec![0xC3; 32]]);
4719
4720        // ITEM_NOT_FOUND on remove: the key is not on the device, which is
4721        // what the caller asked for.
4722        let remove = session.remove_device_peer(vec![0xC3; 32]).unwrap();
4723        let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
4724        let missing = session
4725            .consume(property_response(
4726                request.header.tid(),
4727                prop::LAST_STATUS,
4728                &[umsh_ulcp::Status::ITEM_NOT_FOUND.0 as u8],
4729            ))
4730            .unwrap();
4731        assert_eq!(
4732            missing.operation_error.as_ref().unwrap().status_name,
4733            "Status::ITEM_NOT_FOUND"
4734        );
4735        assert_eq!(dev_peer_keys(&missing), Vec::<Vec<u8>>::new());
4736
4737        // The session remains attached and usable.
4738        assert!(session.insert_device_peer(vec![0xC4; 32]).is_ok());
4739    }
4740
4741    fn dev_channel_ids(update: &UlcpSessionUpdateRecord) -> Vec<Vec<u8>> {
4742        update
4743            .snapshot
4744            .provisioning
4745            .as_ref()
4746            .unwrap()
4747            .dev_channel_ids
4748            .clone()
4749            .unwrap()
4750    }
4751
4752    #[test]
4753    fn device_channel_insert_and_remove_track_identifiers_not_keys() {
4754        let session = MobileUlcpSession::new();
4755        let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
4756        assert_eq!(dev_channel_ids(&attached), Vec::<Vec<u8>>::new());
4757
4758        // The wire carries the key; the device answers with the derived
4759        // identifier, because a channel key is never read back.
4760        let key = vec![0xB7; 32];
4761        let id = crate::derive_channel_id(key.clone()).unwrap();
4762        assert_eq!(id.len(), items::CHANNEL_ID_LEN);
4763
4764        let insert = session.insert_device_channel_key(key.clone()).unwrap();
4765        let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
4766        assert_eq!(request.command(), Some(Cmd::PropInsert));
4767
4768        let confirmed = session
4769            .consume(inserted_response(
4770                request.header.tid(),
4771                prop::DEV_CHANNEL_KEYS,
4772                &id,
4773            ))
4774            .unwrap();
4775        assert_eq!(dev_channel_ids(&confirmed), vec![id.clone()]);
4776        assert_eq!(confirmed.operation_error, None);
4777        let save = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
4778        assert_eq!(save.command(), Some(Cmd::Save));
4779        let saved = session
4780            .consume(property_response(
4781                save.header.tid(),
4782                prop::LAST_STATUS,
4783                &[umsh_ulcp::Status::OK.0 as u8],
4784            ))
4785            .unwrap();
4786        assert!(!saved.waiting_for_responses);
4787
4788        // Removal selects by key and is likewise confirmed by identifier.
4789        let remove = session.remove_device_channel_key(key).unwrap();
4790        let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
4791        assert_eq!(request.command(), Some(Cmd::PropRemove));
4792        let confirmed = session
4793            .consume(removed_response(
4794                request.header.tid(),
4795                prop::DEV_CHANNEL_KEYS,
4796                &id,
4797            ))
4798            .unwrap();
4799        assert_eq!(dev_channel_ids(&confirmed), Vec::<Vec<u8>>::new());
4800    }
4801
4802    #[test]
4803    fn device_channel_failures_report_status_without_ending_the_session() {
4804        let session = MobileUlcpSession::new();
4805        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
4806
4807        let key = vec![0xC7; 32];
4808        let id = crate::derive_channel_id(key.clone()).unwrap();
4809
4810        let insert = session.insert_device_channel_key(key.clone()).unwrap();
4811        let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
4812        let full = session
4813            .consume(property_response(
4814                request.header.tid(),
4815                prop::LAST_STATUS,
4816                &[umsh_ulcp::Status::NOMEM.0 as u8],
4817            ))
4818            .unwrap();
4819        assert_eq!(
4820            full.operation_error,
4821            Some(UlcpOperationErrorRecord {
4822                operation: "insert device channel key".into(),
4823                status_code: umsh_ulcp::Status::NOMEM.0,
4824                status_name: "Status::NOMEM".into(),
4825            })
4826        );
4827        assert_eq!(dev_channel_ids(&full), Vec::<Vec<u8>>::new());
4828        assert_eq!(full.snapshot.phase, UlcpSessionPhase::Attached);
4829
4830        // ALREADY means the device holds it, so the cache says so too.
4831        let insert = session.insert_device_channel_key(key.clone()).unwrap();
4832        let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
4833        let already = session
4834            .consume(property_response(
4835                request.header.tid(),
4836                prop::LAST_STATUS,
4837                &[umsh_ulcp::Status::ALREADY.0 as u8],
4838            ))
4839            .unwrap();
4840        assert_eq!(dev_channel_ids(&already), vec![id]);
4841
4842        let remove = session.remove_device_channel_key(key).unwrap();
4843        let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
4844        let missing = session
4845            .consume(property_response(
4846                request.header.tid(),
4847                prop::LAST_STATUS,
4848                &[umsh_ulcp::Status::ITEM_NOT_FOUND.0 as u8],
4849            ))
4850            .unwrap();
4851        assert_eq!(
4852            missing.operation_error.as_ref().unwrap().status_name,
4853            "Status::ITEM_NOT_FOUND"
4854        );
4855        assert_eq!(dev_channel_ids(&missing), Vec::<Vec<u8>>::new());
4856
4857        assert!(session.insert_device_channel_key(vec![0xC8; 32]).is_ok());
4858    }
4859
4860    fn host_channel_count(update: &UlcpSessionUpdateRecord) -> Option<u32> {
4861        update
4862            .snapshot
4863            .provisioning
4864            .as_ref()
4865            .unwrap()
4866            .host_channel_count
4867    }
4868
4869    #[test]
4870    fn host_channel_reconcile_inserts_what_the_radio_is_missing() {
4871        let session = MobileUlcpSession::new();
4872        let attached = attach_host_keys_capable(&session);
4873        assert_eq!(host_channel_count(&attached), Some(0));
4874
4875        let first = vec![0xD1; 32];
4876        let second = vec![0xD2; 32];
4877        let update = session
4878            .reconcile_host_channel_keys(vec![first.clone(), second.clone()])
4879            .unwrap();
4880
4881        // One insert at a time; the next rides on the previous confirmation.
4882        assert_eq!(update.outbound_frames.len(), 1);
4883        let request = Frame::parse(&update.outbound_frames[0]).unwrap();
4884        assert_eq!(request.command(), Some(Cmd::PropInsert));
4885        let confirmed = session
4886            .consume(inserted_response(
4887                request.header.tid(),
4888                prop::HOST_CHANNEL_KEYS,
4889                &crate::derive_channel_id(first).unwrap(),
4890            ))
4891            .unwrap();
4892        assert_eq!(confirmed.outbound_frames.len(), 1);
4893        let request = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
4894        let done = session
4895            .consume(inserted_response(
4896                request.header.tid(),
4897                prop::HOST_CHANNEL_KEYS,
4898                &crate::derive_channel_id(second).unwrap(),
4899            ))
4900            .unwrap();
4901        assert!(done.outbound_frames.is_empty());
4902        assert!(!done.waiting_for_responses);
4903        assert_eq!(done.operation_error, None);
4904        assert_eq!(host_channel_count(&done), Some(2));
4905    }
4906
4907    #[test]
4908    fn host_channel_reconcile_is_a_no_op_when_the_radio_already_matches() {
4909        let session = MobileUlcpSession::new();
4910        attach_host_keys_capable(&session);
4911        let key = vec![0xD3; 32];
4912
4913        let update = session
4914            .reconcile_host_channel_keys(vec![key.clone()])
4915            .unwrap();
4916        let request = Frame::parse(&update.outbound_frames[0]).unwrap();
4917        session
4918            .consume(inserted_response(
4919                request.header.tid(),
4920                prop::HOST_CHANNEL_KEYS,
4921                &crate::derive_channel_id(key.clone()).unwrap(),
4922            ))
4923            .unwrap();
4924
4925        // Reconciling the same set again asks the radio for nothing, which is
4926        // what makes reconnecting cheap.
4927        let again = session.reconcile_host_channel_keys(vec![key]).unwrap();
4928        assert!(again.outbound_frames.is_empty());
4929        assert!(!again.waiting_for_responses);
4930    }
4931
4932    #[test]
4933    fn host_channel_reconcile_replaces_the_table_to_shed_an_unknown_channel() {
4934        let session = MobileUlcpSession::new();
4935        attach_host_keys_capable(&session);
4936        let stranger = vec![0xD4; 32];
4937
4938        // Something else provisioned a channel this phone has no key for.
4939        let update = session
4940            .reconcile_host_channel_keys(vec![stranger.clone()])
4941            .unwrap();
4942        let request = Frame::parse(&update.outbound_frames[0]).unwrap();
4943        session
4944            .consume(inserted_response(
4945                request.header.tid(),
4946                prop::HOST_CHANNEL_KEYS,
4947                &crate::derive_channel_id(stranger).unwrap(),
4948            ))
4949            .unwrap();
4950
4951        // Removal selects by key, so an unnameable entry forces a whole-table
4952        // write rather than a remove.
4953        let mine = vec![0xD5; 32];
4954        let replace = session
4955            .reconcile_host_channel_keys(vec![mine.clone()])
4956            .unwrap();
4957        let request = Frame::parse(&replace.outbound_frames[0]).unwrap();
4958        assert_eq!(request.command(), Some(Cmd::PropSet));
4959
4960        let done = session
4961            .consume(property_response(
4962                request.header.tid(),
4963                prop::HOST_CHANNEL_KEYS,
4964                &crate::derive_channel_id(mine).unwrap(),
4965            ))
4966            .unwrap();
4967        assert_eq!(host_channel_count(&done), Some(1));
4968        assert!(!done.waiting_for_responses);
4969    }
4970
4971    #[test]
4972    fn a_full_host_channel_table_reports_status_and_stays_attached() {
4973        let session = MobileUlcpSession::new();
4974        attach_host_keys_capable(&session);
4975
4976        let update = session
4977            .reconcile_host_channel_keys(vec![vec![0xD6; 32], vec![0xD7; 32]])
4978            .unwrap();
4979        let request = Frame::parse(&update.outbound_frames[0]).unwrap();
4980        let full = session
4981            .consume(property_response(
4982                request.header.tid(),
4983                prop::LAST_STATUS,
4984                &[umsh_ulcp::Status::NOMEM.0 as u8],
4985            ))
4986            .unwrap();
4987
4988        assert_eq!(
4989            full.operation_error.as_ref().unwrap().status_name,
4990            "Status::NOMEM"
4991        );
4992        // The pass stops rather than hammering a table it cannot fit, and the
4993        // session stays usable.
4994        assert!(full.outbound_frames.is_empty());
4995        assert!(!full.waiting_for_responses);
4996        assert_eq!(full.snapshot.phase, UlcpSessionPhase::Attached);
4997    }
4998
4999    #[test]
5000    fn an_already_stored_host_channel_key_is_success() {
5001        let session = MobileUlcpSession::new();
5002        attach_host_keys_capable(&session);
5003
5004        let update = session
5005            .reconcile_host_channel_keys(vec![vec![0xD8; 32]])
5006            .unwrap();
5007        let request = Frame::parse(&update.outbound_frames[0]).unwrap();
5008        let already = session
5009            .consume(property_response(
5010                request.header.tid(),
5011                prop::LAST_STATUS,
5012                &[umsh_ulcp::Status::ALREADY.0 as u8],
5013            ))
5014            .unwrap();
5015        assert_eq!(already.operation_error, None);
5016        assert!(!already.waiting_for_responses);
5017    }
5018
5019    #[test]
5020    fn device_channel_keys_must_be_full_length() {
5021        let session = MobileUlcpSession::new();
5022        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
5023        assert_eq!(
5024            session.insert_device_channel_key(vec![0x01; 31]),
5025            Err(MobileError::InvalidChannelKeyLength)
5026        );
5027        assert_eq!(
5028            session.remove_device_channel_key(Vec::new()),
5029            Err(MobileError::InvalidChannelKeyLength)
5030        );
5031    }
5032
5033    #[test]
5034    fn device_peer_operations_require_the_device_identity_capability() {
5035        let session = MobileUlcpSession::new();
5036        let begin = session.begin(None).unwrap();
5037        let inspection =
5038            answer_requests(&session, begin.outbound_frames, |property| match property {
5039                prop::LAST_STATUS => (property, vec![0]),
5040                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
5041                prop::CAPS => (property, vec![cap::WRITABLE_RAW_STREAM as u8]),
5042                prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY => (property, Vec::new()),
5043                prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
5044                _ => unreachable!(),
5045            });
5046        let attached =
5047            answer_requests(
5048                &session,
5049                inspection.outbound_frames,
5050                |property| match property {
5051                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
5052                    prop::PHY_ENABLED => (property, vec![1]),
5053                    prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
5054                    prop::PHY_TX_POWER => (property, vec![14]),
5055                    _ => unreachable!(),
5056                },
5057            );
5058        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5059        assert_eq!(
5060            session.insert_device_peer(vec![0xC1; 32]).unwrap_err(),
5061            MobileError::InvalidUlcpFrame
5062        );
5063        assert_eq!(
5064            session.remove_device_peer(vec![0xC1; 32]).unwrap_err(),
5065            MobileError::InvalidUlcpFrame
5066        );
5067        // And a malformed key is rejected before any frame is built.
5068        assert_eq!(
5069            session.insert_device_peer(vec![0xC1; 31]).unwrap_err(),
5070            MobileError::InvalidPublicKeyLength
5071        );
5072    }
5073
5074    /// Attach a battery-reporting device and return the session sitting in
5075    /// the attached phase.
5076    fn attached_battery_session() -> std::sync::Arc<MobileUlcpSession> {
5077        let session = MobileUlcpSession::new();
5078        let begin = session.begin(None).unwrap();
5079        let inspection =
5080            answer_requests(&session, begin.outbound_frames, |property| match property {
5081                prop::LAST_STATUS => (property, vec![0]),
5082                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
5083                prop::CAPS => (property, encoded_capabilities(&[cap::BATTERY])),
5084                // Voltage + level + charge state, discharging at 60 %.
5085                prop::BATTERY => (property, vec![0b111, 0x74, 0x0E, 60, 0]),
5086                prop::DEV_KEY | prop::DEV_NAME => (property, Vec::new()),
5087                prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
5088                _ => unreachable!(),
5089            });
5090        let attached =
5091            answer_requests(
5092                &session,
5093                inspection.outbound_frames,
5094                |property| match property {
5095                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
5096                    prop::PHY_ENABLED => (property, vec![1]),
5097                    prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
5098                    prop::PHY_TX_POWER => (property, vec![14]),
5099                    _ => unreachable!(),
5100                },
5101            );
5102        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5103        session
5104    }
5105
5106    #[test]
5107    fn battery_is_reported_once_per_measurement_not_on_every_update() {
5108        let session = attached_battery_session();
5109
5110        // An unsolicited snapshot is carried by the update that receives
5111        // it: 45 %, now charging.
5112        let pushed = session
5113            .consume(property_response(
5114                frame::TID_UNSOLICITED,
5115                prop::BATTERY,
5116                &[0b111, 0x10, 0x10, 45, 1],
5117            ))
5118            .unwrap();
5119        let battery = pushed.snapshot.battery.expect("push carries the snapshot");
5120        assert_eq!(battery.percentage, Some(45));
5121        assert_eq!(battery.voltage_mv, Some(0x1010));
5122        assert_eq!(battery.charge_state, Some(UlcpChargeState::Charging));
5123
5124        // A later update that carries no measurement must not repeat it.
5125        // Consumers timestamp what they receive, so a repeat would report
5126        // a stale reading as a fresh one.
5127        let unrelated = session
5128            .consume(property_response(
5129                frame::TID_UNSOLICITED,
5130                prop::DEV_NAME,
5131                b"Ridge repeater",
5132            ))
5133            .unwrap();
5134        assert!(unrelated.snapshot.battery.is_none());
5135        assert_eq!(
5136            unrelated.snapshot.device_name.as_deref(),
5137            Some("Ridge repeater"),
5138            "unrelated state still propagates"
5139        );
5140
5141        // The next measurement is reported again.
5142        let again = session
5143            .consume(property_response(
5144                frame::TID_UNSOLICITED,
5145                prop::BATTERY,
5146                &[0b111, 0x20, 0x10, 50, 1],
5147            ))
5148            .unwrap();
5149        assert_eq!(
5150            again.snapshot.battery.expect("second push").percentage,
5151            Some(50)
5152        );
5153    }
5154
5155    /// Attach an alert-capable device sitting in the attached phase.
5156    fn attached_alert_session() -> std::sync::Arc<MobileUlcpSession> {
5157        let session = MobileUlcpSession::new();
5158        let begin = session.begin(None).unwrap();
5159        let inspection =
5160            answer_requests(&session, begin.outbound_frames, |property| match property {
5161                prop::LAST_STATUS => (property, vec![0]),
5162                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
5163                prop::CAPS => (property, encoded_capabilities(&[cap::ALERT])),
5164                prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY => (property, Vec::new()),
5165                prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
5166                _ => unreachable!(),
5167            });
5168        let attached =
5169            answer_requests(
5170                &session,
5171                inspection.outbound_frames,
5172                |property| match property {
5173                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
5174                    prop::PHY_ENABLED => (property, vec![1]),
5175                    prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
5176                    prop::PHY_TX_POWER => (property, vec![14]),
5177                    prop::ALERT => (property, vec![0]),
5178                    _ => unreachable!(),
5179                },
5180            );
5181        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5182        assert_eq!(attached.snapshot.alert, Some(UlcpAlertState::None));
5183        session
5184    }
5185
5186    #[test]
5187    fn alert_state_follows_the_radio_not_the_request() {
5188        let session = attached_alert_session();
5189
5190        let request = session.set_alert(UlcpAlertState::Locate).unwrap();
5191        let [frame] = &request.outbound_frames[..] else {
5192            panic!("one CMD_PROP_SET");
5193        };
5194        let parsed = Frame::parse(frame).unwrap();
5195        assert_eq!(parsed.command(), Some(Cmd::PropSet));
5196        let payload = PropPayload::parse(parsed.payload).unwrap();
5197        assert_eq!((payload.key, payload.value), (prop::ALERT, &[1u8][..]));
5198
5199        let started = session
5200            .consume(property_response(parsed.header.tid(), prop::ALERT, &[1]))
5201            .unwrap();
5202        assert_eq!(started.snapshot.alert, Some(UlcpAlertState::Locate));
5203
5204        // Unlike battery, the state persists across unrelated updates —
5205        // the UI mirrors it rather than reacting to it once.
5206        let unrelated = session
5207            .consume(property_response(
5208                frame::TID_UNSOLICITED,
5209                prop::DEV_NAME,
5210                b"Ridge repeater",
5211            ))
5212            .unwrap();
5213        assert_eq!(unrelated.snapshot.alert, Some(UlcpAlertState::Locate));
5214
5215        // Someone presses the button on the radio (or its deadline
5216        // expires): the unsolicited update is what the phone believes.
5217        let cancelled = session
5218            .consume(property_response(frame::TID_UNSOLICITED, prop::ALERT, &[0]))
5219            .unwrap();
5220        assert_eq!(cancelled.snapshot.alert, Some(UlcpAlertState::None));
5221        assert_eq!(cancelled.snapshot.phase, UlcpSessionPhase::Attached);
5222    }
5223
5224    #[test]
5225    fn alert_needs_the_capability() {
5226        let session = attached_battery_session();
5227        assert_eq!(
5228            session.set_alert(UlcpAlertState::Locate).unwrap_err(),
5229            MobileError::UnsupportedCapability
5230        );
5231        // And a radio that never reported one leaves the field empty, so
5232        // the UI can hide the control rather than show a dead button.
5233        let update = session
5234            .consume(property_response(
5235                frame::TID_UNSOLICITED,
5236                prop::BATTERY,
5237                &[0b111, 0x10, 0x10, 45, 1],
5238            ))
5239            .unwrap();
5240        assert_eq!(update.snapshot.alert, None);
5241    }
5242
5243    #[test]
5244    fn malformed_alert_values_are_rejected() {
5245        assert_eq!(inspect_ulcp_alert(vec![0]).unwrap(), UlcpAlertState::None);
5246        assert_eq!(inspect_ulcp_alert(vec![1]).unwrap(), UlcpAlertState::Locate);
5247        // Unknown state, trailing bytes, and the empty value.
5248        assert!(inspect_ulcp_alert(vec![2]).is_err());
5249        assert!(inspect_ulcp_alert(vec![1, 0]).is_err());
5250        assert!(inspect_ulcp_alert(Vec::new()).is_err());
5251    }
5252
5253    /// A commissionable device that also keeps a clock and has a receiver
5254    /// holding a three-dimensional fix.
5255    fn attach_positioning(session: &MobileUlcpSession) -> UlcpSessionUpdateRecord {
5256        let mut capabilities = commissionable_capabilities();
5257        capabilities.extend([cap::TIME, cap::GNSS]);
5258        let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
5259        drive_reads(
5260            session,
5261            begin.outbound_frames,
5262            move |property| match property {
5263                prop::CAPS => (property, encoded_capabilities(&capabilities)),
5264                prop::HOST_KEY => (property, vec![0xAA; 32]),
5265                prop::TIME => (property, 1_754_000_000u32.to_le_bytes().to_vec()),
5266                // Pacific daylight time, which is an offset and not a zone
5267                // — the device has no database to shift itself with.
5268                prop::TZ_OFFSET => (property, (-420i16).to_le_bytes().to_vec()),
5269                prop::GNSS_ENABLED | prop::GNSS_IDENT_UPDATE | prop::GNSS_TIME_TRUST => {
5270                    (property, vec![1])
5271                }
5272                prop::GNSS_LOCATION => (property, placed_location().as_bytes().to_vec()),
5273                prop::GNSS_ALTITUDE => (property, 71i32.to_le_bytes().to_vec()),
5274                prop::GNSS_FIX => (property, vec![2]),
5275                prop::GNSS_PRECISION => (property, 62u16.to_le_bytes().to_vec()),
5276                prop::GNSS_SATELLITES => (property, vec![9, 14]),
5277                prop::GNSS_IDENT_PRECISION => (property, vec![5]),
5278                _ => commissionable_value(property),
5279            },
5280        )
5281    }
5282
5283    fn attach_advertising(session: &MobileUlcpSession) -> UlcpSessionUpdateRecord {
5284        let mut capabilities = commissionable_capabilities();
5285        capabilities.push(cap::ADVERT);
5286        let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
5287        drive_reads(
5288            session,
5289            begin.outbound_frames,
5290            move |property| match property {
5291                prop::CAPS => (property, encoded_capabilities(&capabilities)),
5292                prop::HOST_KEY => (property, vec![0xAA; 32]),
5293                prop::ADVERT_INTERVAL => (property, 14_400u32.to_le_bytes().to_vec()),
5294                prop::BEACON_INTERVAL => (property, 3_600u32.to_le_bytes().to_vec()),
5295                prop::STARTUP_BEACON => (property, vec![1]),
5296                _ => commissionable_value(property),
5297            },
5298        )
5299    }
5300
5301    #[test]
5302    fn advertisement_policy_folds_into_the_sync_record() {
5303        let session = MobileUlcpSession::administrative();
5304        let update = attach_advertising(&session);
5305        let sync = update.snapshot.provisioning.expect("device described");
5306
5307        assert!(sync.supports_advert);
5308        assert_eq!(
5309            sync.advert,
5310            Some(UlcpAdvertSettingsRecord {
5311                advert_interval_seconds: 14_400,
5312                beacon_interval_seconds: 3_600,
5313                startup_beacon: true,
5314            })
5315        );
5316    }
5317
5318    /// A device that never claimed `CAP_ADVERT` has no schedule to report,
5319    /// and the read must not go looking for one.
5320    #[test]
5321    fn a_device_without_the_capability_reports_no_advertisement_policy() {
5322        let session = MobileUlcpSession::administrative();
5323        let update = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
5324        let sync = update.snapshot.provisioning.expect("device described");
5325
5326        assert!(!sync.supports_advert);
5327        assert_eq!(sync.advert, None);
5328    }
5329
5330    #[test]
5331    fn configure_advertising_writes_the_whole_schedule() {
5332        let session = MobileUlcpSession::administrative();
5333        attach_advertising(&session);
5334
5335        let configured = session
5336            .configure_advertising(Some(UlcpAdvertSettingsRecord {
5337                advert_interval_seconds: 0,
5338                beacon_interval_seconds: 1_800,
5339                startup_beacon: false,
5340            }))
5341            .unwrap();
5342        let (written, _, _) = drive_configuration(&session, configured.outbound_frames);
5343        assert_eq!(
5344            written.get(&prop::ADVERT_INTERVAL).map(Vec::as_slice),
5345            Some(&0u32.to_le_bytes()[..])
5346        );
5347        assert_eq!(
5348            written.get(&prop::BEACON_INTERVAL).map(Vec::as_slice),
5349            Some(&1_800u32.to_le_bytes()[..])
5350        );
5351        assert_eq!(
5352            written.get(&prop::STARTUP_BEACON).map(Vec::as_slice),
5353            Some(&[0u8][..])
5354        );
5355    }
5356
5357    /// Catching the bounds here means an out-of-range interval fails
5358    /// before any of the group is written, rather than half-changing the
5359    /// schedule.
5360    #[test]
5361    fn an_advertisement_record_must_match_what_the_device_can_do() {
5362        let session = MobileUlcpSession::administrative();
5363        attach_advertising(&session);
5364        let whole = UlcpAdvertSettingsRecord {
5365            advert_interval_seconds: 14_400,
5366            beacon_interval_seconds: 3_600,
5367            startup_beacon: true,
5368        };
5369
5370        // Absent on a device that advertises the capability.
5371        assert_eq!(
5372            session.configure_advertising(None),
5373            Err(MobileError::InvalidUlcpFrame)
5374        );
5375        for out_of_range in [
5376            MIN_AUTO_ANNOUNCE_INTERVAL_S - 1,
5377            MAX_AUTO_ANNOUNCE_INTERVAL_S + 1,
5378        ] {
5379            assert_eq!(
5380                session.configure_advertising(Some(UlcpAdvertSettingsRecord {
5381                    beacon_interval_seconds: out_of_range,
5382                    ..whole
5383                })),
5384                Err(MobileError::InvalidUlcpFrame)
5385            );
5386        }
5387        // Zero is the off switch, not a too-short interval.
5388        assert!(
5389            session
5390                .configure_advertising(Some(UlcpAdvertSettingsRecord {
5391                    beacon_interval_seconds: 0,
5392                    ..whole
5393                }))
5394                .is_ok()
5395        );
5396    }
5397
5398    /// Present on a device that does not advertise the capability is the
5399    /// mirror-image mistake, and is refused the same way.
5400    #[test]
5401    fn an_advertisement_record_is_refused_without_the_capability() {
5402        let session = MobileUlcpSession::administrative();
5403        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
5404        assert_eq!(
5405            session.configure_advertising(Some(UlcpAdvertSettingsRecord {
5406                advert_interval_seconds: 14_400,
5407                beacon_interval_seconds: 3_600,
5408                startup_beacon: true,
5409            })),
5410            Err(MobileError::InvalidUlcpFrame)
5411        );
5412    }
5413
5414    /// A five-byte fix — a ~38 m cell, the default identity precision.
5415    fn placed_location() -> NodeLocation {
5416        NodeLocation::from_e7(377_749_290, -1_224_194_160, 5)
5417    }
5418
5419    #[test]
5420    fn a_positioning_device_reports_its_fix_and_its_policy() {
5421        let session = MobileUlcpSession::new();
5422        let attached = attach_positioning(&session);
5423        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5424
5425        let sync = attached.snapshot.provisioning.clone().expect("described");
5426        assert!(sync.supports_time && sync.supports_gnss);
5427        assert_eq!(sync.tz_offset_min, Some(-420));
5428        assert_eq!(
5429            sync.gnss,
5430            Some(UlcpGnssSettingsRecord {
5431                enabled: true,
5432                ident_update: true,
5433                ident_precision: 5,
5434                time_trust: true,
5435            })
5436        );
5437        assert!(sync.unreadable_properties.is_empty());
5438
5439        let gnss = attached.snapshot.gnss.expect("a receiver was read");
5440        assert_eq!(gnss.fix, UlcpFixKind::ThreeD);
5441        assert_eq!(gnss.altitude_m, Some(71));
5442        assert_eq!(gnss.accuracy_dm, Some(62));
5443        assert_eq!(
5444            (gnss.satellites_used, gnss.satellites_in_view),
5445            (9, Some(14))
5446        );
5447        assert_eq!(gnss.location, placed_location().as_bytes());
5448        // The center of the cell the fix named, which is as close to the
5449        // encoded position as a cell that size can be.
5450        let (latitude, longitude) = (gnss.latitude_deg.unwrap(), gnss.longitude_deg.unwrap());
5451        assert!((latitude - 37.774_929).abs() < 5e-4, "{latitude}");
5452        assert!((longitude + 122.419_416).abs() < 5e-4, "{longitude}");
5453        assert!((38.0..39.0).contains(&gnss.location_cell_meters.unwrap()));
5454    }
5455
5456    #[test]
5457    fn the_clock_is_reported_once_and_the_fix_is_mirrored() {
5458        let session = MobileUlcpSession::new();
5459        attach_positioning(&session);
5460
5461        // Unrelated news must not restamp the clock read at attach as a
5462        // fresh one, but it must not lose the position either: the pin
5463        // stays on the map, the clock does not get a new timestamp.
5464        let unrelated = session
5465            .consume(property_response(
5466                frame::TID_UNSOLICITED,
5467                prop::DEV_NAME,
5468                b"Ridge repeater",
5469            ))
5470            .unwrap();
5471        assert_eq!(unrelated.snapshot.time, None);
5472        assert_eq!(
5473            unrelated.snapshot.gnss.expect("still known").fix,
5474            UlcpFixKind::ThreeD
5475        );
5476
5477        // An announced fix change folds into what is already known rather
5478        // than replacing it: the satellite count came a frame earlier and
5479        // is still true.
5480        let lost = session
5481            .consume(property_response(
5482                frame::TID_UNSOLICITED,
5483                prop::GNSS_FIX,
5484                &[1],
5485            ))
5486            .unwrap();
5487        let gnss = lost.snapshot.gnss.expect("still known");
5488        assert_eq!(gnss.fix, UlcpFixKind::TwoD);
5489        assert_eq!(gnss.satellites_used, 9);
5490        assert_eq!(gnss.altitude_m, Some(71));
5491
5492        // The device finding the time on its own is announced, and is
5493        // reported once like any other reading.
5494        let stepped = session
5495            .consume(property_response(
5496                frame::TID_UNSOLICITED,
5497                prop::TIME,
5498                &1_754_000_600u32.to_le_bytes(),
5499            ))
5500            .unwrap();
5501        assert_eq!(
5502            stepped.snapshot.time,
5503            Some(UlcpTimeRecord {
5504                epoch_seconds: Some(1_754_000_600)
5505            })
5506        );
5507        assert_eq!(stepped.snapshot.phase, UlcpSessionPhase::Attached);
5508    }
5509
5510    #[test]
5511    fn sampling_a_position_asks_for_the_position_and_nothing_else() {
5512        let session = MobileUlcpSession::new();
5513        attach_positioning(&session);
5514
5515        // The device never announces where it is — a receiver reports
5516        // about a fix a second and noise moves the reading, so a host that
5517        // wants a position asks for one.
5518        let poll = session.refresh_positioning().unwrap();
5519        let asked: Vec<u32> = poll
5520            .outbound_frames
5521            .iter()
5522            .map(|frame| {
5523                let parsed = Frame::parse(frame).unwrap();
5524                PropPayload::parse(parsed.payload).unwrap().key
5525            })
5526            .collect();
5527        assert_eq!(
5528            asked,
5529            vec![
5530                prop::GNSS_LOCATION,
5531                prop::GNSS_ALTITUDE,
5532                prop::GNSS_FIX,
5533                prop::GNSS_PRECISION,
5534                prop::GNSS_SATELLITES,
5535            ]
5536        );
5537
5538        // Narrower than a full refresh, which is the point: a screen
5539        // watching a position must not re-read the radio's whole
5540        // configuration once a minute to do it.
5541        assert!(!asked.contains(&prop::PHY_FREQ));
5542        assert!(!asked.contains(&prop::DEV_NAME));
5543
5544        // The answers land in the same snapshot field the announcements
5545        // used to fill, so nothing downstream can tell the two apart.
5546        let sampled = answer_requests(&session, poll.outbound_frames, |property| match property {
5547            prop::GNSS_LOCATION => (property, placed_location().as_bytes().to_vec()),
5548            prop::GNSS_ALTITUDE => (property, 88i32.to_le_bytes().to_vec()),
5549            prop::GNSS_FIX => (property, vec![2]),
5550            prop::GNSS_PRECISION => (property, 40u16.to_le_bytes().to_vec()),
5551            prop::GNSS_SATELLITES => (property, vec![11, 15]),
5552            _ => unreachable!("{property}"),
5553        });
5554        let gnss = sampled.snapshot.gnss.expect("a receiver was sampled");
5555        assert_eq!(gnss.altitude_m, Some(88));
5556        assert_eq!(gnss.satellites_used, 11);
5557        assert_eq!(sampled.snapshot.phase, UlcpSessionPhase::Attached);
5558    }
5559
5560    #[test]
5561    fn a_radio_without_a_receiver_is_never_asked_where_it_is() {
5562        // Every positioning property would be refused one at a time; the
5563        // question is not worth asking at all.
5564        let session = attached_battery_session();
5565        assert_eq!(
5566            session.refresh_positioning(),
5567            Err(MobileError::InvalidUlcpFrame)
5568        );
5569    }
5570
5571    #[test]
5572    fn setting_the_clock_writes_the_epoch_and_clearing_it_writes_nothing() {
5573        let session = MobileUlcpSession::new();
5574        attach_positioning(&session);
5575
5576        let request = session.set_time(Some(1_754_000_900)).unwrap();
5577        let [frame] = &request.outbound_frames[..] else {
5578            panic!("one CMD_PROP_SET");
5579        };
5580        let parsed = Frame::parse(frame).unwrap();
5581        let payload = PropPayload::parse(parsed.payload).unwrap();
5582        assert_eq!(payload.key, prop::TIME);
5583        assert_eq!(payload.value, &1_754_000_900u32.to_le_bytes()[..]);
5584
5585        // What the device answers is what the snapshot reports, even when
5586        // it is not what was written — a trusted receiver may have moved
5587        // the clock between the write and the echo.
5588        let set = session
5589            .consume(property_response(
5590                parsed.header.tid(),
5591                prop::TIME,
5592                &1_754_000_901u32.to_le_bytes(),
5593            ))
5594            .unwrap();
5595        assert_eq!(
5596            set.snapshot.time,
5597            Some(UlcpTimeRecord {
5598                epoch_seconds: Some(1_754_000_901)
5599            })
5600        );
5601
5602        // The empty value is how a clock goes back to unknown.
5603        let clearing = session.set_time(None).unwrap();
5604        let parsed = Frame::parse(&clearing.outbound_frames[0]).unwrap();
5605        let payload = PropPayload::parse(parsed.payload).unwrap();
5606        assert_eq!((payload.key, payload.value), (prop::TIME, &[][..]));
5607        let cleared = session
5608            .consume(property_response(parsed.header.tid(), prop::TIME, &[]))
5609            .unwrap();
5610        assert_eq!(
5611            cleared.snapshot.time,
5612            Some(UlcpTimeRecord {
5613                epoch_seconds: None
5614            })
5615        );
5616    }
5617
5618    #[test]
5619    fn the_clock_needs_the_capability() {
5620        let session = MobileUlcpSession::new();
5621        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
5622        assert_eq!(
5623            session.set_time(Some(1_754_000_900)).unwrap_err(),
5624            MobileError::UnsupportedCapability
5625        );
5626        let sync = session
5627            .refresh()
5628            .unwrap()
5629            .snapshot
5630            .provisioning
5631            .expect("described");
5632        assert!(!sync.supports_time && !sync.supports_gnss);
5633        assert_eq!((sync.tz_offset_min, sync.gnss), (None, None));
5634    }
5635
5636    #[test]
5637    fn positioning_settings_are_written_whole_with_the_receiver_last() {
5638        let session = MobileUlcpSession::administrative();
5639        attach_positioning(&session);
5640
5641        let configured = session
5642            .configure_device(UlcpDeviceConfigRecord {
5643                radio: UlcpRadioSettingsRecord {
5644                    device_name: None,
5645                    phy_enabled: true,
5646                    frequency_khz: 915_000,
5647                    transmit_power_dbm: 14,
5648                    bandwidth_hz: None,
5649                    spreading_factor: None,
5650                    coding_rate_denom: None,
5651                    duty_cycle_limit: None,
5652                },
5653                ident_role: None,
5654                ident_mobile: Some(false),
5655                dev_discoverable: Some(true),
5656                repeater: Some(UlcpRepeaterSettingsRecord {
5657                    enabled: false,
5658                    regions: Vec::new(),
5659                    default_region: None,
5660                    min_rssi_dbm: None,
5661                    min_snr_db: None,
5662                }),
5663                tz_offset_min: Some(60),
5664                gnss: Some(UlcpGnssSettingsRecord {
5665                    enabled: true,
5666                    ident_update: false,
5667                    ident_precision: 3,
5668                    time_trust: false,
5669                }),
5670                advert: None,
5671            })
5672            .unwrap();
5673        let (written, order, _) = drive_configuration(&session, configured.outbound_frames);
5674        assert_eq!(
5675            written.get(&prop::TZ_OFFSET),
5676            Some(&60i16.to_le_bytes().to_vec())
5677        );
5678        assert_eq!(written.get(&prop::GNSS_IDENT_UPDATE), Some(&vec![0]));
5679        assert_eq!(written.get(&prop::GNSS_IDENT_PRECISION), Some(&vec![3]));
5680        assert_eq!(written.get(&prop::GNSS_TIME_TRUST), Some(&vec![0]));
5681        assert_eq!(written.get(&prop::GNSS_ENABLED), Some(&vec![1]));
5682
5683        // The receiver starts under the disclosure and trust policy just
5684        // written, never the one it happened to be holding.
5685        let switch = order.iter().position(|key| *key == prop::GNSS_ENABLED);
5686        for policy in [
5687            prop::GNSS_IDENT_UPDATE,
5688            prop::GNSS_IDENT_PRECISION,
5689            prop::GNSS_TIME_TRUST,
5690        ] {
5691            assert!(
5692                order.iter().position(|key| *key == policy) < switch,
5693                "{policy}"
5694            );
5695        }
5696    }
5697
5698    #[test]
5699    fn a_tethered_phone_changes_positioning_without_restating_the_domain() {
5700        // The companion case: switching a receiver on must not require
5701        // saying anything about the radio's role or what it forwards.
5702        let session = MobileUlcpSession::new();
5703        attach_positioning(&session);
5704
5705        let configured = session
5706            .configure_positioning(
5707                Some(UlcpGnssSettingsRecord {
5708                    enabled: false,
5709                    ident_update: false,
5710                    ident_precision: 3,
5711                    time_trust: false,
5712                }),
5713                Some(0),
5714            )
5715            .unwrap();
5716        let (written, order, save_tid) = drive_configuration(&session, configured.outbound_frames);
5717
5718        assert_eq!(
5719            written,
5720            HashMap::from([
5721                (prop::TZ_OFFSET, 0i16.to_le_bytes().to_vec()),
5722                (prop::GNSS_IDENT_UPDATE, vec![0]),
5723                (prop::GNSS_IDENT_PRECISION, vec![3]),
5724                (prop::GNSS_TIME_TRUST, vec![0]),
5725                (prop::GNSS_ENABLED, vec![0]),
5726            ]),
5727            "only the zone and the positioning policy are written"
5728        );
5729        assert_eq!(order.last(), Some(&prop::GNSS_ENABLED));
5730
5731        // It closes like any configuration pass: a save, then the
5732        // device's own answers reduced into a fresh snapshot.
5733        let attached = session
5734            .consume(property_response(save_tid, prop::LAST_STATUS, &[0]))
5735            .unwrap();
5736        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5737        assert_eq!(
5738            attached.snapshot.provisioning.unwrap().gnss,
5739            Some(UlcpGnssSettingsRecord {
5740                enabled: false,
5741                ident_update: false,
5742                ident_precision: 3,
5743                time_trust: false,
5744            })
5745        );
5746    }
5747
5748    #[test]
5749    fn positioning_on_its_own_still_matches_the_capabilities() {
5750        let session = MobileUlcpSession::new();
5751        attach_positioning(&session);
5752        let whole = UlcpGnssSettingsRecord {
5753            enabled: true,
5754            ident_update: false,
5755            ident_precision: 5,
5756            time_trust: true,
5757        };
5758        // The same presence rule as the whole-domain write.
5759        assert_eq!(
5760            session.configure_positioning(Some(whole), None),
5761            Err(MobileError::InvalidUlcpFrame)
5762        );
5763        assert_eq!(
5764            session.configure_positioning(None, Some(0)),
5765            Err(MobileError::InvalidUlcpFrame)
5766        );
5767
5768        // And a radio with neither capability has nothing to configure,
5769        // which is a caller mistake rather than an empty success.
5770        let plain = MobileUlcpSession::new();
5771        attach_commissionable(&plain, Some(vec![0xAA; 32]), vec![0xAA; 32]);
5772        assert_eq!(
5773            plain.configure_positioning(None, None),
5774            Err(MobileError::UnsupportedCapability)
5775        );
5776    }
5777
5778    #[test]
5779    fn a_positioning_record_must_match_what_the_device_can_do() {
5780        let session = MobileUlcpSession::administrative();
5781        attach_positioning(&session);
5782
5783        let whole = UlcpDeviceConfigRecord {
5784            radio: UlcpRadioSettingsRecord {
5785                device_name: None,
5786                phy_enabled: true,
5787                frequency_khz: 915_000,
5788                transmit_power_dbm: 14,
5789                bandwidth_hz: None,
5790                spreading_factor: None,
5791                coding_rate_denom: None,
5792                duty_cycle_limit: None,
5793            },
5794            ident_role: None,
5795            ident_mobile: Some(false),
5796            dev_discoverable: Some(true),
5797            repeater: Some(UlcpRepeaterSettingsRecord {
5798                enabled: false,
5799                regions: Vec::new(),
5800                default_region: None,
5801                min_rssi_dbm: None,
5802                min_snr_db: None,
5803            }),
5804            tz_offset_min: Some(0),
5805            gnss: Some(UlcpGnssSettingsRecord {
5806                enabled: true,
5807                ident_update: false,
5808                ident_precision: 5,
5809                time_trust: true,
5810            }),
5811            advert: None,
5812        };
5813
5814        // Both gated fields are required on a device that has them.
5815        assert_eq!(
5816            session.configure_device(UlcpDeviceConfigRecord {
5817                tz_offset_min: None,
5818                ..whole.clone()
5819            }),
5820            Err(MobileError::InvalidUlcpFrame)
5821        );
5822        assert_eq!(
5823            session.configure_device(UlcpDeviceConfigRecord {
5824                gnss: None,
5825                ..whole.clone()
5826            }),
5827            Err(MobileError::InvalidUlcpFrame)
5828        );
5829        // A precision outside 1–7 names no cell.
5830        assert_eq!(
5831            session.configure_device(UlcpDeviceConfigRecord {
5832                gnss: Some(UlcpGnssSettingsRecord {
5833                    ident_precision: 8,
5834                    ..whole.gnss.unwrap()
5835                }),
5836                ..whole.clone()
5837            }),
5838            Err(MobileError::InvalidUlcpFrame)
5839        );
5840        // And an offset no zone on Earth uses.
5841        assert_eq!(
5842            session.configure_device(UlcpDeviceConfigRecord {
5843                tz_offset_min: Some(15 * 60),
5844                ..whole.clone()
5845            }),
5846            Err(MobileError::InvalidUlcpFrame)
5847        );
5848    }
5849
5850    #[test]
5851    fn half_a_positioning_policy_is_withdrawn_whole() {
5852        let session = MobileUlcpSession::administrative();
5853        let mut capabilities = commissionable_capabilities();
5854        capabilities.extend([cap::TIME, cap::GNSS]);
5855        let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
5856        let attached = drive_reads(
5857            &session,
5858            begin.outbound_frames,
5859            move |property| match property {
5860                prop::CAPS => (property, encoded_capabilities(&capabilities)),
5861                prop::HOST_KEY => (property, vec![0xAA; 32]),
5862                // Firmware older than the capability it advertises.
5863                prop::GNSS_TIME_TRUST => (
5864                    prop::LAST_STATUS,
5865                    vec![umsh_ulcp::Status::PROP_NOT_FOUND.0 as u8],
5866                ),
5867                prop::TIME => (property, Vec::new()),
5868                prop::TZ_OFFSET => (property, 0i16.to_le_bytes().to_vec()),
5869                prop::GNSS_ENABLED | prop::GNSS_IDENT_UPDATE => (property, vec![0]),
5870                prop::GNSS_IDENT_PRECISION => (property, vec![5]),
5871                prop::GNSS_LOCATION | prop::GNSS_ALTITUDE | prop::GNSS_PRECISION => {
5872                    (property, Vec::new())
5873                }
5874                prop::GNSS_FIX => (property, vec![0]),
5875                prop::GNSS_SATELLITES => (property, vec![0]),
5876                _ => commissionable_value(property),
5877            },
5878        );
5879
5880        let sync = attached.snapshot.provisioning.clone().expect("described");
5881        assert!(sync.supports_gnss);
5882        assert_eq!(sync.gnss, None);
5883        assert_eq!(sync.unreadable_properties, vec![prop::GNSS_TIME_TRUST]);
5884        // A receiver that is off answers the facts it is sure of and
5885        // leaves the position empty.
5886        let gnss = attached.snapshot.gnss.expect("the receiver was read");
5887        assert_eq!(gnss.fix, UlcpFixKind::None);
5888        assert!(gnss.location.is_empty());
5889        assert_eq!(gnss.latitude_deg, None);
5890
5891        // None of the group is written, because a receiver switched on
5892        // under half a policy is worse than one left alone.
5893        let configured = session
5894            .configure_device(UlcpDeviceConfigRecord {
5895                radio: UlcpRadioSettingsRecord {
5896                    device_name: None,
5897                    phy_enabled: true,
5898                    frequency_khz: 915_000,
5899                    transmit_power_dbm: 14,
5900                    bandwidth_hz: None,
5901                    spreading_factor: None,
5902                    coding_rate_denom: None,
5903                    duty_cycle_limit: None,
5904                },
5905                ident_role: None,
5906                ident_mobile: Some(false),
5907                dev_discoverable: Some(true),
5908                repeater: Some(UlcpRepeaterSettingsRecord {
5909                    enabled: false,
5910                    regions: Vec::new(),
5911                    default_region: None,
5912                    min_rssi_dbm: None,
5913                    min_snr_db: None,
5914                }),
5915                tz_offset_min: Some(0),
5916                gnss: Some(UlcpGnssSettingsRecord {
5917                    enabled: true,
5918                    ident_update: true,
5919                    ident_precision: 5,
5920                    time_trust: true,
5921                }),
5922                advert: None,
5923            })
5924            .unwrap();
5925        let (written, _, _) = drive_configuration(&session, configured.outbound_frames);
5926        for property in [
5927            prop::GNSS_ENABLED,
5928            prop::GNSS_IDENT_UPDATE,
5929            prop::GNSS_IDENT_PRECISION,
5930            prop::GNSS_TIME_TRUST,
5931        ] {
5932            assert!(!written.contains_key(&property), "property {property}");
5933        }
5934        // The zone is its own property and is unaffected.
5935        assert_eq!(
5936            written.get(&prop::TZ_OFFSET),
5937            Some(&0i16.to_le_bytes().to_vec())
5938        );
5939    }
5940
5941    #[test]
5942    fn a_receiver_without_a_clock_is_not_a_device_this_phone_believes() {
5943        // CAP_GNSS requires CAP_TIME: the device dates its own fixes, so a
5944        // receiver with no clock to date them against is a malformed
5945        // capability set rather than a limited device.
5946        assert_eq!(
5947            ulcp_inspection_properties(encoded_capabilities(&[cap::GNSS])),
5948            Err(MobileError::InvalidUlcpFrame)
5949        );
5950
5951        // The clock is read at attach along with the policy, so a phone
5952        // that has just connected knows whether the device knows the time
5953        // rather than waiting for the next announcement to find out.
5954        let asked =
5955            ulcp_inspection_properties(encoded_capabilities(&[cap::TIME, cap::GNSS])).unwrap();
5956        for property in [
5957            prop::TIME,
5958            prop::TZ_OFFSET,
5959            prop::GNSS_ENABLED,
5960            prop::GNSS_LOCATION,
5961            prop::GNSS_FIX,
5962            prop::GNSS_SATELLITES,
5963            prop::GNSS_TIME_TRUST,
5964        ] {
5965            assert!(asked.contains(&property), "property {property}");
5966        }
5967        // And a device with a clock and no receiver is asked for neither.
5968        let clock_only = ulcp_inspection_properties(encoded_capabilities(&[cap::TIME])).unwrap();
5969        assert!(clock_only.contains(&prop::TIME));
5970        assert!(!clock_only.contains(&prop::GNSS_ENABLED));
5971    }
5972
5973    #[test]
5974    fn a_precision_outside_the_encoding_names_no_cell() {
5975        assert_eq!(ulcp_location_cell_meters(0), None);
5976        assert_eq!(ulcp_location_cell_meters(8), None);
5977        // The default identity precision discloses a ~38 m cell.
5978        let five = ulcp_location_cell_meters(5).unwrap();
5979        assert!((38.0..39.0).contains(&five), "{five}");
5980    }
5981
5982    #[test]
5983    fn battery_push_does_not_move_an_attached_session_out_of_phase() {
5984        let session = attached_battery_session();
5985        let pushed = session
5986            .consume(property_response(
5987                frame::TID_UNSOLICITED,
5988                prop::BATTERY,
5989                &[0b111, 0x10, 0x10, 45, 1],
5990            ))
5991            .unwrap();
5992        assert_eq!(pushed.snapshot.phase, UlcpSessionPhase::Attached);
5993        assert!(!pushed.waiting_for_responses);
5994        assert!(pushed.outbound_frames.is_empty());
5995    }
5996
5997    #[test]
5998    fn battery_push_to_a_tethered_session_on_another_phones_radio_stays_attached() {
5999        // A tethered claim that did not take is an answer, not a failure:
6000        // synchronization completes and the session attaches while
6001        // `ownership()` reports `OtherHost` (see the `Claim` arm). Battery
6002        // is the first unsolicited notification that arrives *routinely*,
6003        // so this is the path that turns a latent re-decision into one the
6004        // user would actually see — a settled session must not bounce back
6005        // to awaiting-host every time the radio reports its charge.
6006        let session = MobileUlcpSession::new();
6007        let ours = vec![0x11; 32];
6008        let theirs = vec![0x22; 32];
6009        let begin = session.begin(Some(ours.clone())).unwrap();
6010        let synchronized =
6011            answer_requests(&session, begin.outbound_frames, |property| match property {
6012                prop::LAST_STATUS => (property, vec![0]),
6013                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
6014                // A readable `PROP_HOST_KEY` requires `CAP_HOST_FILTER`
6015                // and vice versa (`advance_completed_stage` enforces it).
6016                prop::CAPS => (
6017                    property,
6018                    encoded_capabilities(&[cap::BATTERY, cap::HOST_FILTER]),
6019                ),
6020                prop::BATTERY => (property, vec![0b111, 0x74, 0x0E, 60, 0]),
6021                prop::DEV_KEY | prop::DEV_NAME => (property, Vec::new()),
6022                // The radio already belongs to another identity.
6023                prop::HOST_KEY => (property, theirs.clone()),
6024                _ => unreachable!(),
6025            });
6026        assert_eq!(
6027            synchronized.snapshot.host_ownership,
6028            UlcpHostOwnership::OtherHost
6029        );
6030        assert_eq!(synchronized.snapshot.phase, UlcpSessionPhase::AwaitingHost);
6031
6032        // The user takes the radio; the device refuses the write and keeps
6033        // reporting the other identity's key. No `CAP_SAVE`, so the claim
6034        // answer runs straight into inspection.
6035        let claim = session.claim(ours).unwrap();
6036        let claim_tid = Frame::parse(&claim.outbound_frames[0])
6037            .unwrap()
6038            .header
6039            .tid();
6040        let claimed = session
6041            .consume(property_response(claim_tid, prop::HOST_KEY, &theirs))
6042            .unwrap();
6043        let attached = answer_requests(
6044            &session,
6045            claimed.outbound_frames,
6046            |property| match property {
6047                prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
6048                prop::PHY_ENABLED => (property, vec![1]),
6049                prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
6050                prop::PHY_TX_POWER => (property, vec![14]),
6051                prop::HOST_RX_FILTERS => (property, Vec::new()),
6052                _ => unreachable!(),
6053            },
6054        );
6055        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
6056        assert_eq!(
6057            attached.snapshot.host_ownership,
6058            UlcpHostOwnership::OtherHost,
6059            "the claim did not take"
6060        );
6061
6062        let pushed = session
6063            .consume(property_response(
6064                frame::TID_UNSOLICITED,
6065                prop::BATTERY,
6066                &[0b111, 0x10, 0x10, 45, 1],
6067            ))
6068            .unwrap();
6069        assert_eq!(
6070            pushed.snapshot.battery.expect("push carries it").percentage,
6071            Some(45)
6072        );
6073        assert_eq!(
6074            pushed.snapshot.phase,
6075            UlcpSessionPhase::Attached,
6076            "a battery report is not an attach decision"
6077        );
6078
6079        // The decision itself is still re-opened by the one change that
6080        // warrants it: the radio reporting a different owner.
6081        let reclaimed = session
6082            .consume(property_response(
6083                frame::TID_UNSOLICITED,
6084                prop::HOST_KEY,
6085                &[0x33; 32],
6086            ))
6087            .unwrap();
6088        assert_eq!(
6089            reclaimed.snapshot.phase,
6090            UlcpSessionPhase::AwaitingHost,
6091            "a third phone taking the radio is the user's call"
6092        );
6093    }
6094}