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    hdlc,
13    host::{PropertyNotification, PropertyNotificationError, TidAllocator},
14    ids::{
15        INTERFACE_TYPE, MAX_AUTO_ANNOUNCE_INTERVAL_S, MIN_AUTO_ANNOUNCE_INTERVAL_S,
16        PROTOCOL_MAJOR_VERSION, PROTOCOL_MINOR_VERSION, cap, prop, saved,
17    },
18    items::{self, Filter},
19    meta::{BufferedRxMeta, RX_FLAG_ACKED, RX_FLAG_BUFFERED},
20    pui,
21};
22
23use crate::{
24    MobileError,
25    mobile_mesh::{MobileMeshManagementAnswerRecord, MobileMeshPropertyWriteRecord},
26};
27
28/// One header-prefixed ATT value produced by ULCP GATT segmentation.
29#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
30pub struct GattSegmentRecord {
31    pub value: Vec<u8>,
32}
33
34/// A validated property-bearing ULCP frame.
35#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
36pub struct UlcpPropertyFrameRecord {
37    pub transaction_id: u8,
38    pub command: u8,
39    pub property_id: u32,
40    pub value: Vec<u8>,
41}
42
43/// UI-relevant fields from a validated `PROP_BATTERY` value.
44#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
45pub struct UlcpBatteryRecord {
46    pub percentage: Option<u8>,
47    /// Measured terminal voltage in millivolts, when the device reports it.
48    pub voltage_mv: Option<u16>,
49    /// What the charging system is doing, when the device reports it.
50    /// Whether the radio is on external power follows from this rather
51    /// than being carried separately.
52    pub charge_state: Option<UlcpChargeState>,
53}
54
55/// The charge state a device reports in `PROP_BATTERY`.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
57pub enum UlcpChargeState {
58    /// Running off the battery.
59    Discharging,
60    /// On external power, taking charge.
61    Charging,
62    /// On external power, charge complete.
63    Charged,
64}
65
66impl UlcpChargeState {
67    fn from_wire(state: BatteryChargeState) -> Self {
68        match state {
69            BatteryChargeState::Discharging => Self::Discharging,
70            BatteryChargeState::Charging => Self::Charging,
71            BatteryChargeState::Charged => Self::Charged,
72        }
73    }
74}
75
76/// The device identity's autonomous flood-forwarding policy.
77///
78/// The filter is written as region strings — the same strings the device
79/// advertises as its Supported Regions identity option — while the
80/// default tag is a 2-octet code, because that is what goes on the air
81/// packet by packet. [`region_code_from_string`] and
82/// [`region_code_description`] convert between the two.
83#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
84pub struct UlcpRepeaterSettingsRecord {
85    /// `PROP_MAC_REPEATER_ENABLED`. The remaining fields are inert while
86    /// this is false, but are still read and written.
87    pub enabled: bool,
88    /// `PROP_MAC_REPEATER_REGIONS`: which region-tagged floods to
89    /// forward, as region strings of 1 to 24 octets — a short code, a
90    /// name, or a literal `0x1234`. Empty imposes no regional
91    /// restriction rather than blocking every flood.
92    pub regions: Vec<String>,
93    /// `PROP_MAC_REPEATER_DEFAULT_REGION`: the tag inserted into an
94    /// untagged flood before forwarding it. `None` forwards untagged.
95    pub default_region: Option<Vec<u8>>,
96    /// `PROP_MAC_REPEATER_MIN_RSSI` in dBm. `None` accepts any.
97    pub min_rssi_dbm: Option<i16>,
98    /// `PROP_MAC_REPEATER_MIN_SNR` in whole dB. `None` accepts any.
99    pub min_snr_db: Option<i8>,
100}
101
102/// The device's positioning policy: whether the receiver runs, and what
103/// is done with what it finds.
104///
105/// Read and written as a whole, like [`UlcpRepeaterSettingsRecord`] and
106/// for the same reason — a receiver switched on under half a policy
107/// starts advertising a position nobody just agreed to. `enabled` is
108/// written last so the rest is already in force when it does.
109#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)]
110pub struct UlcpGnssSettingsRecord {
111    /// `PROP_GNSS_ENABLED`: whether the receiver is powered. Off is the
112    /// lowest power state the board can reach, and on most of them the
113    /// receiver is the largest continuous load there is.
114    pub enabled: bool,
115    /// `PROP_GNSS_IDENT_UPDATE`: whether fixes refresh the location the
116    /// node advertises in its identity.
117    pub ident_update: bool,
118    /// `PROP_GNSS_IDENT_PRECISION`: how many location bytes that
119    /// advertised position is clamped to, 1 (coarsest) through 7. This is
120    /// a disclosure control — see [`ulcp_location_cell_meters`].
121    pub ident_precision: u8,
122    /// `PROP_GNSS_TIME_TRUST`: whether receiver-derived time may set the
123    /// wall clock. Cleared, a hand-set clock is safe from a jammed or
124    /// spoofed sky; position reporting is unaffected.
125    pub time_trust: bool,
126}
127
128/// What the device announces without being asked.
129///
130/// Read and written as a whole, like [`UlcpGnssSettingsRecord`], because
131/// the two schedules are how much of the mesh's airtime this device
132/// claims and an operator sets that as one decision.
133#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)]
134pub struct UlcpAdvertSettingsRecord {
135    /// `PROP_ADVERT_INTERVAL`: seconds between signed identity
136    /// advertisements, 0 for none. An advertisement reaches only the
137    /// device's own neighbours.
138    pub advert_interval_seconds: u32,
139    /// `PROP_BEACON_INTERVAL`: seconds between empty beacons, 0 for none.
140    /// A beacon floods, collecting the path back to the device as it
141    /// goes, and costs a fraction of an advertisement.
142    pub beacon_interval_seconds: u32,
143    /// `PROP_STARTUP_BEACON`: whether one beacon goes out at bring-up.
144    pub startup_beacon: bool,
145}
146
147/// Where the device says it is: the claim it advertises, not a
148/// measurement.
149///
150/// A position names a cell rather than a point, so the encoded cell is
151/// carried verbatim alongside what it decodes to — the bytes are what a
152/// region proposal needs, because the cell's bounds *are* the
153/// uncertainty, and the degrees are what a readout shows.
154#[derive(Clone, Debug, PartialEq, uniffi::Record)]
155pub struct UlcpIdentPositionRecord {
156    /// `PROP_IDENT_LOCATION` verbatim. Empty is a device advertising no
157    /// position, which is a value rather than an absence.
158    pub location: Vec<u8>,
159    /// The center of the advertised cell, absent when there is none.
160    pub latitude_deg: Option<f64>,
161    /// See `latitude_deg`.
162    pub longitude_deg: Option<f64>,
163    /// How wide the advertised cell is at the equator, in meters.
164    pub cell_meters: Option<f64>,
165    /// `PROP_IDENT_ALTITUDE` in whole meters, absent at no stated height.
166    pub altitude_m: Option<i32>,
167}
168
169/// `PROP_GNSS_FIX`: what kind of position solution the receiver has.
170#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, uniffi::Enum)]
171pub enum UlcpFixKind {
172    /// No solution — the receiver is off, or on and still searching.
173    #[default]
174    None,
175    /// Position without altitude.
176    TwoD,
177    /// Position and altitude.
178    ThreeD,
179}
180
181impl UlcpFixKind {
182    fn from_wire(fix: FixKind) -> Self {
183        match fix {
184            FixKind::None => Self::None,
185            FixKind::TwoD => Self::TwoD,
186            FixKind::ThreeD => Self::ThreeD,
187        }
188    }
189}
190
191/// What the receiver currently reports, folded from the five positioning
192/// telemetry properties.
193///
194/// Unlike a battery reading this is carried on *every* snapshot rather
195/// than reported once: it is state the UI mirrors — a map pin does not
196/// disappear because an unrelated property arrived — and the receiver
197/// announces position and fix changes on its own schedule.
198#[derive(Clone, Debug, PartialEq, uniffi::Record)]
199pub struct UlcpGnssRecord {
200    pub fix: UlcpFixKind,
201    /// `PROP_GNSS_LOCATION` as it travels: the interleaved
202    /// variable-precision grid code, empty without a fix. Carried
203    /// verbatim so a caller can compare or forward the cell itself
204    /// rather than re-encoding degrees.
205    pub location: Vec<u8>,
206    /// Center of the encoded cell, in degrees. `None` without a fix.
207    ///
208    /// A location names a cell rather than a point; `location_cell_meters`
209    /// says how large that cell is, and rendering a pin without it claims
210    /// a precision the device did not report. Widened from the f32 the
211    /// decoder works in, because that is the shape every consumer of a
212    /// coordinate wants.
213    pub latitude_deg: Option<f64>,
214    pub longitude_deg: Option<f64>,
215    /// Approximate width of the encoded cell at the equator, in meters.
216    pub location_cell_meters: Option<f64>,
217    /// `PROP_GNSS_ALTITUDE` in meters above the WGS-84 ellipsoid.
218    pub altitude_m: Option<i32>,
219    /// `PROP_GNSS_PRECISION`: estimated horizontal accuracy in
220    /// decimeters. An estimate scaled from dilution of precision, not a
221    /// measured error bound.
222    pub accuracy_dm: Option<u16>,
223    /// Satellites contributing to the solution. Reads 0 while the
224    /// receiver is off.
225    pub satellites_used: u8,
226    /// Satellites in view, when the receiver reports them.
227    pub satellites_in_view: Option<u8>,
228}
229
230/// `PROP_TIME`: what the device's wall clock read when it last reported.
231///
232/// Take-once, like a battery reading and for the same reason: a clock
233/// value means nothing without the instant it was received, so a consumer
234/// stamps what arrives. Republishing it on unrelated updates would
235/// restamp a stale reading as a fresh one.
236#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)]
237pub struct UlcpTimeRecord {
238    /// Seconds since the Unix epoch, or `None` when the device does not
239    /// know what time it is. A device that has never had a fix, a manual
240    /// set, or a retained RTC is in that state, and says so rather than
241    /// reporting zero.
242    pub epoch_seconds: Option<u32>,
243}
244
245/// Read-only, capability-gated device state gathered after host ownership
246/// has been resolved. Counts describe digest forms and contain no key material.
247// Not `Eq`: the advertised position carries decoded degrees, and a
248// coordinate is a measurement rather than an identity. Nothing compares
249// snapshots for anything but equality.
250#[derive(Clone, Debug, PartialEq, uniffi::Record)]
251pub struct UlcpSyncRecord {
252    pub capability_count: u32,
253    pub has_host_filtering: bool,
254    pub supports_offline_queue: bool,
255    pub supports_delegated_ack: bool,
256    pub supports_device_name: bool,
257    /// `PROP_DEV_NAME`, when the device has one and reported it. Empty is
258    /// a device with no name rather than a device named "".
259    pub device_name: Option<String>,
260    pub supports_lora: bool,
261    pub supports_duty_cycle_limit: bool,
262    /// The device measures its own power state (`CAP_BATTERY`). A device
263    /// without it never reports a battery, so callers have nothing to show.
264    pub supports_battery: bool,
265    /// `PROP_BATTERY`, when the device measures one and has reported it.
266    ///
267    /// A reading rather than a setting, and absent for a reason that is
268    /// never a fault: on the local link a device announces this on its own
269    /// schedule, so a session that has only just attached has not heard one
270    /// yet. Deliberately not counted among `unreadable_properties` — there
271    /// is no setting here to be written over.
272    pub battery: Option<UlcpBatteryRecord>,
273    /// The device can forward for the mesh on its own (`CAP_REPEATER`).
274    pub supports_repeater: bool,
275    /// The device serves and configures its own advertised node identity
276    /// (`CAP_IDENT`).
277    pub supports_ident: bool,
278    /// The device has an identity domain of its own (`CAP_DEV_IDENTITY`),
279    /// including the `PROP_DEV_PEERS` list.
280    pub supports_device_identity: bool,
281    /// The device keeps a wall clock (`CAP_TIME`). It says nothing about
282    /// where the time comes from, or whether the device currently knows
283    /// it — an unset clock is a device with `CAP_TIME` and no epoch.
284    pub supports_time: bool,
285    /// A GNSS receiver is fitted (`CAP_GNSS`), so the positioning
286    /// properties exist and the device can locate itself.
287    pub supports_gnss: bool,
288    /// The device announces itself on a schedule of its own
289    /// (`CAP_ADVERT`).
290    pub supports_advert: bool,
291    /// The device answers Node Management Requests from the administrators
292    /// it lists (`CAP_ADMIN`). A device without it can only ever be
293    /// configured by whoever is holding it.
294    pub supports_admin: bool,
295    /// The device can make itself conspicuous on request (`CAP_ALERT`).
296    pub supports_alert: bool,
297    /// The device can restart its hardware on request (`CAP_REBOOT`).
298    pub supports_reboot: bool,
299    /// `PROP_ALERT`: what the device is doing to make itself findable, when
300    /// it has said. Live state like `battery`, and absent on the same terms.
301    pub alert: Option<UlcpAlertState>,
302    pub phy_enabled: bool,
303    pub frequency_khz: u32,
304    pub transmit_power_dbm: i8,
305    pub bandwidth_hz: Option<u32>,
306    pub spreading_factor: Option<u8>,
307    pub coding_rate_denom: Option<u8>,
308    pub duty_cycle_now: Option<u16>,
309    pub duty_cycle_limit: Option<u16>,
310    pub saved: Option<SavedSnapshotRecord>,
311    pub queued_frames: Option<u16>,
312    pub dropped_frames: Option<u32>,
313    pub filter_count: Option<u32>,
314    pub host_channel_count: Option<u32>,
315    pub host_peer_count: Option<u32>,
316    pub auto_ack: Option<bool>,
317    /// Present when `supports_repeater` and the device reported the whole
318    /// policy.
319    pub repeater: Option<UlcpRepeaterSettingsRecord>,
320    /// `PROP_DEV_PEERS`: the peer public keys stored on the device
321    /// identity, read back losslessly. Present when
322    /// `supports_device_identity` and the device reported the list.
323    pub dev_peer_keys: Option<Vec<Vec<u8>>>,
324    /// `PROP_DEV_ADMINS`: the node public keys allowed to manage this device
325    /// over the mesh, read back losslessly. Present when `supports_admin`
326    /// and the device reported the list; an empty list is a device that
327    /// nobody may manage remotely.
328    pub dev_admin_keys: Option<Vec<Vec<u8>>>,
329    /// `PROP_DEV_CHANNEL_KEYS`: the two-octet identifiers of the channels the
330    /// device identity has joined. Key material is never read back, so a
331    /// caller names these by deriving identifiers from the keys it holds; one
332    /// that matches nothing locally is a channel the device knows and this
333    /// phone does not.
334    pub dev_channel_ids: Option<Vec<Vec<u8>>>,
335    /// `PROP_IDENT_ROLE`. `None` covers "the device derives its role from
336    /// what it is actually doing", "no `CAP_IDENT`", and "the device would
337    /// not report it" — `supports_ident` and `unreadable_properties`
338    /// distinguish them.
339    pub ident_role: Option<u8>,
340    /// `PROP_IDENT_MOBILE`. Present when `supports_ident` and the device
341    /// reported it.
342    pub ident_mobile: Option<bool>,
343    /// Where the device says it is. Present when `supports_ident` and the
344    /// device reported its position; a device that advertises none reports
345    /// an empty cell rather than nothing.
346    pub ident_position: Option<UlcpIdentPositionRecord>,
347    /// `PROP_DEV_DISCOVERABLE`: whether the device identity answers
348    /// Identity Requests. Present when `supports_device_identity` and the
349    /// device reported it.
350    pub dev_discoverable: Option<bool>,
351    /// `PROP_TZ_OFFSET` in minutes east of UTC. Present when
352    /// `supports_time` and the device reported it.
353    ///
354    /// The zone is configuration and the epoch is not: where a device is
355    /// meant to be is known even when what time it is is not, which is
356    /// why this is here and the clock reading is on the session snapshot.
357    pub tz_offset_min: Option<i16>,
358    /// The positioning policy. Present when `supports_gnss` and the
359    /// device reported the whole of it.
360    pub gnss: Option<UlcpGnssSettingsRecord>,
361    /// The advertisement policy. Present when `supports_advert` and the
362    /// device reported the whole of it.
363    pub advert: Option<UlcpAdvertSettingsRecord>,
364    /// Capability-gated properties the device advertised but would not
365    /// report, in ascending order.
366    ///
367    /// A device that refuses a property — old firmware behind a newer
368    /// capability, a property it never implemented — is a device with an
369    /// unknown setting, not one this phone cannot administer. Their values
370    /// are absent above, they are left out of configuration writes, and
371    /// nothing about them can be verified after a save.
372    pub unreadable_properties: Vec<u32>,
373}
374
375/// `PROP_SAVED`: what the radio reports about its stored snapshot.
376///
377/// `Fallback` and `Unreadable` are the values worth surfacing: the radio
378/// is running on configuration older than the one last saved, or on none
379/// at all, and looks healthy otherwise.
380#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
381pub enum SavedSnapshotRecord {
382    /// Nothing is saved.
383    None,
384    /// The newest saved generation is in effect.
385    Current,
386    /// A newer generation was rejected at boot; an older one is in
387    /// effect. Saving again clears it.
388    Fallback,
389    /// A snapshot exists but could not be read; the radio booted with
390    /// factory defaults.
391    Unreadable,
392}
393
394/// Long-lived host-session phase. Swift maps this value to UI link state but
395/// does not implement ULCP transitions itself.
396#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
397pub enum UlcpSessionPhase {
398    Idle,
399    Synchronizing,
400    AwaitingHost,
401    Claiming,
402    Configuring,
403    Attached,
404}
405
406/// Complete desired live radio configuration. Capability-gated fields must be
407/// omitted when the device does not advertise their associated capability.
408#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
409pub struct UlcpRadioSettingsRecord {
410    pub device_name: Option<String>,
411    pub phy_enabled: bool,
412    pub frequency_khz: u32,
413    pub transmit_power_dbm: i8,
414    pub bandwidth_hz: Option<u32>,
415    pub spreading_factor: Option<u8>,
416    pub coding_rate_denom: Option<u8>,
417    pub duty_cycle_limit: Option<u16>,
418}
419
420/// Complete desired configuration of a device's *own* domain: what it is
421/// and what it does when no phone is attached.
422///
423/// This is the commissioning counterpart to [`UlcpRadioSettingsRecord`],
424/// which describes only the radio. Every capability-gated field must be
425/// present exactly when the device advertises the matching capability, so
426/// the record always states a whole desired configuration rather than a
427/// patch — a property that a future template feature can lean on.
428#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
429pub struct UlcpDeviceConfigRecord {
430    /// The live radio profile, applied with the same disable-first,
431    /// enable-last ordering [`MobileUlcpSession::configure`] uses.
432    pub radio: UlcpRadioSettingsRecord,
433    /// `PROP_IDENT_ROLE`, or `None` to let the device derive its
434    /// advertised role from what it is actually doing. Requires
435    /// `CAP_IDENT`.
436    pub ident_role: Option<u8>,
437    /// `PROP_IDENT_MOBILE`. Present exactly when the device advertises
438    /// `CAP_IDENT`.
439    pub ident_mobile: Option<bool>,
440    /// `PROP_DEV_DISCOVERABLE`: whether the device identity answers
441    /// Identity Requests. Present exactly when the device advertises
442    /// `CAP_DEV_IDENTITY`.
443    pub dev_discoverable: Option<bool>,
444    /// The flood-forwarding policy. Present exactly when the device
445    /// advertises `CAP_REPEATER`.
446    pub repeater: Option<UlcpRepeaterSettingsRecord>,
447    /// `PROP_TZ_OFFSET` in minutes east of UTC. Present exactly when the
448    /// device advertises `CAP_TIME`.
449    ///
450    /// The clock itself is not here: it is live state rather than
451    /// configuration, is never saved, and is set with
452    /// [`MobileUlcpSession::set_time`].
453    pub tz_offset_min: Option<i16>,
454    /// The positioning policy. Present exactly when the device advertises
455    /// `CAP_GNSS`.
456    pub gnss: Option<UlcpGnssSettingsRecord>,
457    /// The advertisement policy. Present exactly when the device
458    /// advertises `CAP_ADVERT`.
459    pub advert: Option<UlcpAdvertSettingsRecord>,
460}
461
462/// Present one folded [`GnssSnapshot`] as the record Swift sees.
463fn gnss_record(snapshot: &GnssSnapshot) -> UlcpGnssRecord {
464    let bytes = snapshot.location();
465    let placed = (!bytes.is_empty()).then(|| NodeLocation::from_bytes(bytes).center());
466    UlcpGnssRecord {
467        fix: UlcpFixKind::from_wire(snapshot.fix),
468        location: bytes.to_vec(),
469        latitude_deg: placed.map(|(latitude, _)| latitude.into()),
470        longitude_deg: placed.map(|(_, longitude)| longitude.into()),
471        location_cell_meters: (!bytes.is_empty())
472            .then(|| ulcp_location_cell_meters(bytes.len() as u8))
473            .flatten(),
474        altitude_m: snapshot.altitude_m,
475        accuracy_dm: snapshot.accuracy_dm,
476        satellites_used: snapshot.sats_used,
477        satellites_in_view: snapshot.sats_in_view,
478    }
479}
480
481/// Approximate width, at the equator, of the cell one location precision
482/// names — 2,500 km at one byte down to 15 cm at seven. `None` outside
483/// 1–7.
484///
485/// This is what makes a precision mean something to a person: the setting
486/// is a disclosure control, and how much it discloses is an area, not a
487/// byte count. Fractional because the finest two cells are smaller than a
488/// meter, which a whole number could only report as zero.
489#[uniffi::export]
490pub fn ulcp_location_cell_meters(precision_bytes: u8) -> Option<f64> {
491    // 360° of longitude divided into 16^N cells, at 111,320 m per degree.
492    (1..=MAX_PRECISION)
493        .contains(&precision_bytes)
494        .then(|| 360.0 * 111_320.0 / 16f64.powi(precision_bytes.into()))
495}
496
497/// `PROP_ALERT`: what the radio is doing to make itself findable.
498#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
499pub enum UlcpAlertState {
500    /// Nothing; the nominal state.
501    None,
502    /// The radio is making itself as conspicuous as its hardware allows
503    /// — beeping, flashing, or both, depending on the board.
504    Locate,
505}
506
507impl UlcpAlertState {
508    fn from_wire(state: AlertState) -> Self {
509        match state {
510            AlertState::None => Self::None,
511            AlertState::Locate => Self::Locate,
512        }
513    }
514
515    fn to_wire(self) -> AlertState {
516        match self {
517            Self::None => AlertState::None,
518            Self::Locate => AlertState::Locate,
519        }
520    }
521}
522
523/// Authoritative comparison of `PROP_HOST_KEY` with the selected phone identity.
524#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
525pub enum UlcpHostOwnership {
526    Unknown,
527    LocalIdentityUnavailable,
528    Unsupported,
529    Unclaimed,
530    Ours,
531    OtherHost,
532}
533
534/// Typed state published after each bounded ULCP-session transition.
535///
536/// Not `Eq`: a position is degrees, and floating point has no total
537/// equality to offer.
538#[derive(Clone, Debug, PartialEq, uniffi::Record)]
539pub struct UlcpSessionSnapshotRecord {
540    pub generation: u64,
541    pub phase: UlcpSessionPhase,
542    pub host_ownership: UlcpHostOwnership,
543    pub device_key: Option<Vec<u8>>,
544    pub device_name: Option<String>,
545    pub battery: Option<UlcpBatteryRecord>,
546    /// `PROP_ALERT`, or `None` on a radio without `CAP_ALERT`.
547    ///
548    /// Unlike `battery`, this is carried on *every* snapshot rather than
549    /// reported once: it is state the UI mirrors, and the radio ends an
550    /// alert on its own — a button press or its deadline — so the button
551    /// must follow the radio rather than what the phone last asked for.
552    pub alert: Option<UlcpAlertState>,
553    /// A clock reading that arrived with this update, on a `CAP_TIME`
554    /// device. Reported once — see [`UlcpTimeRecord`].
555    pub time: Option<UlcpTimeRecord>,
556    /// What the receiver reports, on a `CAP_GNSS` device, or `None` until
557    /// the first positioning property is read. Mirrored like `alert`
558    /// rather than taken like `battery`.
559    pub gnss: Option<UlcpGnssRecord>,
560    pub provisioning: Option<UlcpSyncRecord>,
561}
562
563/// What the platform adapter should do after a completed raw PHY request.
564#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
565pub enum UlcpRawTransmitDisposition {
566    Sent,
567    Retry,
568    Rejected,
569}
570
571/// Typed completion of one host-requested raw PHY transmission.
572#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
573pub struct UlcpRawTransmitResultRecord {
574    pub transaction_id: u8,
575    pub status_code: u32,
576    pub status_name: String,
577    pub disposition: UlcpRawTransmitDisposition,
578}
579
580/// A correlated CRP operation completed with a non-OK `PROP_LAST_STATUS`.
581/// This is an operation failure, never evidence that the transport framing is
582/// corrupt or that the BLE connection should be closed.
583#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
584pub struct UlcpOperationErrorRecord {
585    pub operation: String,
586    pub status_code: u32,
587    pub status_name: String,
588}
589
590/// One property value the device announced on its own — `CMD_PROP_IS`
591/// with the unsolicited transaction — as opposed to the answer to
592/// anything this session asked.
593#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
594pub struct UlcpPropertyPushRecord {
595    pub property_id: u32,
596    pub value: Vec<u8>,
597}
598
599/// The completion of one local management operation started with
600/// [`MobileUlcpSession::begin_property_fetch`],
601/// [`begin_property_writes`](MobileUlcpSession::begin_property_writes), or
602/// [`begin_save`](MobileUlcpSession::begin_save).
603///
604/// Answers wear the same record the mesh management path reports, and mean
605/// the same things: a value is what the device holds, a status in its
606/// place is a refusal of that one property. What differs is the carrier —
607/// here the completion rides the session update instead of a mesh event.
608#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
609pub struct UlcpLocalManagementEventRecord {
610    pub answers: Vec<MobileMeshManagementAnswerRecord>,
611    /// The `CMD_SAVE` outcome, on a save. `None` on fetches, on writes,
612    /// and on a save the device has no `CAP_SAVE` to answer.
613    pub status_code: Option<u32>,
614}
615
616/// Work produced by the Rust ULCP session. Frames are complete ULCP
617/// frames; the platform adapter remains responsible for GATT segmentation and
618/// write backpressure.
619#[derive(Clone, Debug, PartialEq, uniffi::Record)]
620pub struct UlcpSessionUpdateRecord {
621    pub outbound_frames: Vec<Vec<u8>>,
622    pub received_frames: Vec<UlcpReceivedFrameRecord>,
623    pub snapshot: UlcpSessionSnapshotRecord,
624    pub waiting_for_responses: bool,
625    /// True while one host-requested raw PHY transmission is awaiting the
626    /// radio's `PROP_LAST_STATUS` completion.
627    pub raw_transmit_pending: bool,
628    /// Transaction allocated by `transmit_raw` in this update, if any.
629    pub raw_transmit_started_transaction_id: Option<u8>,
630    /// Completion for the raw PHY transmission consumed by this update.
631    /// Rejections are ordinary radio-level send failures, not malformed
632    /// ULCP frames.
633    pub raw_transmit_result: Option<UlcpRawTransmitResultRecord>,
634    /// Non-transmit operation error consumed by this update. The ULCP
635    /// session has already recovered to a stable stage and remains usable.
636    pub operation_error: Option<UlcpOperationErrorRecord>,
637    /// Completion of the local management operation, when this update
638    /// carries one.
639    pub management_event: Option<UlcpLocalManagementEventRecord>,
640    /// Values the device announced unsolicited with this update, verbatim.
641    /// The snapshot has already absorbed what it recognizes; these carry
642    /// the raw octets to whoever caches values by property number.
643    pub pushed_properties: Vec<UlcpPropertyPushRecord>,
644}
645
646/// One validated raw mesh frame delivered by the companion radio.
647#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
648pub struct UlcpReceivedFrameRecord {
649    pub data: Vec<u8>,
650    pub rssi_dbm: Option<i16>,
651    pub lqi: Option<u8>,
652    pub snr_cb: Option<i16>,
653    pub was_buffered: bool,
654    pub was_acknowledged: bool,
655    pub age_seconds: u32,
656}
657
658#[derive(Clone, Copy, Debug, PartialEq, Eq)]
659enum SessionStage {
660    Idle,
661    Initial,
662    Inspection,
663    Refreshing,
664    Claiming,
665    Saving,
666    Configuring,
667    SavingConfiguration,
668    AwaitingHost,
669    Attached,
670}
671
672#[derive(Clone, Debug, PartialEq, Eq)]
673enum ExpectedResponse {
674    Property(u32),
675    Claim,
676    Save,
677    /// A `CMD_PROP_SET` of this property. The value written is not kept:
678    /// the device's `CMD_PROP_IS` is what the property is worth, so there
679    /// is nothing to compare it against.
680    ConfigurationProperty(u32),
681    SaveConfiguration,
682    RawTransmit,
683    /// A `CMD_PROP_INSERT` of this key into a device-domain public-key
684    /// table: `PROP_DEV_PEERS` or `PROP_DEV_ADMINS`. The two are the same
685    /// operation on different lists — a table of 32-byte keys the device
686    /// echoes back item by item — so they are confirmed the same way.
687    DevKeyInsert {
688        property: u32,
689        item: Vec<u8>,
690    },
691    /// A `CMD_PROP_REMOVE` of this key from a device-domain public-key table.
692    DevKeyRemove {
693        property: u32,
694        item: Vec<u8>,
695    },
696    /// The `CMD_SAVE` chained behind a device-domain key-table mutation.
697    SaveDevKeys {
698        property: u32,
699    },
700    /// A `CMD_PROP_INSERT` into `PROP_DEV_CHANNEL_KEYS`. Carries the derived
701    /// identifier rather than the key, because the device confirms a channel
702    /// mutation by echoing the identifier — key material is never read back.
703    DevChannelInsert(Vec<u8>),
704    /// A `CMD_PROP_REMOVE` from `PROP_DEV_CHANNEL_KEYS`, selected by key and
705    /// confirmed by identifier.
706    DevChannelRemove(Vec<u8>),
707    /// The `CMD_SAVE` chained behind a device-channel mutation.
708    SaveDevChannels,
709    /// One `CMD_PROP_INSERT` in the host channel-key reconciliation, carrying
710    /// the keys still to be sent. `ALREADY` is success here: a channel key is
711    /// its own item, so a duplicate insert asserts a state that already holds.
712    HostChannelInsert(VecDeque<Vec<u8>>),
713    /// The whole-table `CMD_PROP_SET` used when the device holds a channel
714    /// this phone cannot name, and so cannot select for removal.
715    HostChannelReplace,
716    /// A `CMD_PROP_GET` issued by a local management fetch. Unlike
717    /// `Property`, a refusal is an answer to record, never a stage
718    /// failure: the caller asked an open question about one property.
719    ManagementGet(u32),
720    /// A `CMD_PROP_SET` issued by a local management write, answered by
721    /// the device's echo or a per-property refusal.
722    ManagementSet(u32),
723    /// The `CMD_SAVE` issued by a local management save.
724    ManagementSave,
725    /// A payloadless management command answered by a bare
726    /// `PROP_LAST_STATUS`: the two bond commands. Completion carries the
727    /// device's status, which is the whole of what they report.
728    ManagementCommand,
729}
730
731impl ExpectedResponse {
732    /// Whether this response belongs to the local management operation,
733    /// which is what decides when that operation continues or completes.
734    fn is_management(&self) -> bool {
735        matches!(
736            self,
737            Self::ManagementGet(_)
738                | Self::ManagementSet(_)
739                | Self::ManagementSave
740                | Self::ManagementCommand
741        )
742    }
743}
744
745/// One local management operation in flight: what is still to ask, what
746/// is still to write, and what the device has answered so far.
747///
748/// The local counterpart of a mesh management exchange, kept to the same
749/// shape deliberately — one operation at a time, answers accumulated
750/// until everything is answered, refusals recorded per property rather
751/// than failing the run.
752#[derive(Debug, Default)]
753struct LocalManagement {
754    fetch_queue: VecDeque<u32>,
755    write_queue: VecDeque<(u32, Vec<u8>)>,
756    answers: Vec<MobileMeshManagementAnswerRecord>,
757    save_status: Option<u32>,
758}
759
760struct UlcpSessionState {
761    generation: u64,
762    /// Which relationship this session represents. Held here, not only on
763    /// the object, because ownership resolution is what it changes: an
764    /// administrative session reports foreign ownership truthfully but
765    /// never waits for a host decision it will not make.
766    mode: UlcpAttachMode,
767    /// Whether post-attach inspection reads only what attaching itself
768    /// requires, leaving everything else to be asked for on demand.
769    lazy_inspection: bool,
770    stage: SessionStage,
771    tids: TidAllocator,
772    expected: HashMap<u8, ExpectedResponse>,
773    selected_host_key: Option<[u8; 32]>,
774    radio_host_key: Option<Vec<u8>>,
775    host_key_unsupported: bool,
776    responses: HashMap<u32, UlcpPropertyFrameRecord>,
777    inspection_queue: VecDeque<u32>,
778    configuration_queue: VecDeque<(u32, Vec<u8>)>,
779    device_key: Option<Vec<u8>>,
780    device_name: Option<String>,
781    /// A battery snapshot this session has received and not yet reported.
782    ///
783    /// Deliberately *not* a cache: it is taken when an update record is
784    /// built, so `UlcpSessionSnapshotRecord::battery` means "a fresh
785    /// measurement arrived with this update" rather than "the last
786    /// measurement ever seen". Battery is live telemetry — a consumer that
787    /// timestamps what it receives would otherwise restamp a minutes-old
788    /// reading on every unrelated update and report it as current.
789    battery: Option<UlcpBatteryRecord>,
790    /// The radio's live `PROP_ALERT`, or `None` until one is read (and
791    /// permanently on a radio without `CAP_ALERT`). Held rather than
792    /// taken: it is a state to mirror, not an event to report once.
793    alert: Option<UlcpAlertState>,
794    /// A `PROP_TIME` reading not yet reported. Taken, for the reason
795    /// [`UlcpTimeRecord`] gives.
796    time: Option<UlcpTimeRecord>,
797    /// The receiver's view of the world, folded from whichever
798    /// positioning properties have arrived. Held: the properties are
799    /// announced separately, so taking it would report a fix without the
800    /// satellite count that came a frame earlier.
801    gnss: Option<GnssSnapshot>,
802    provisioning: Option<UlcpSyncRecord>,
803    stage_failure_pending: bool,
804    /// The local management operation in flight, if any.
805    management: Option<LocalManagement>,
806    /// A completed management operation not yet reported. Taken by the
807    /// next update, like a battery reading.
808    management_event: Option<UlcpLocalManagementEventRecord>,
809    /// Values announced unsolicited and not yet reported, verbatim.
810    pushed_properties: Vec<UlcpPropertyPushRecord>,
811}
812
813impl Default for UlcpSessionState {
814    fn default() -> Self {
815        Self {
816            generation: 0,
817            mode: UlcpAttachMode::Tethered,
818            lazy_inspection: false,
819            stage: SessionStage::Idle,
820            tids: TidAllocator::new(),
821            expected: HashMap::new(),
822            selected_host_key: None,
823            radio_host_key: None,
824            host_key_unsupported: false,
825            responses: HashMap::new(),
826            inspection_queue: VecDeque::new(),
827            configuration_queue: VecDeque::new(),
828            device_key: None,
829            device_name: None,
830            battery: None,
831            alert: None,
832            time: None,
833            gnss: None,
834            provisioning: None,
835            stage_failure_pending: false,
836            management: None,
837            management_event: None,
838            pushed_properties: Vec::new(),
839        }
840    }
841}
842
843/// The two relationships a phone can have with a radio.
844///
845/// They are different things with different lifecycles, and Swift should
846/// model them as different objects: "my radio" is exactly one, tethered,
847/// and re-provisioned on every attach; "radios I administer" is any
848/// number, configured but never claimed. One list must not serve both.
849#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, uniffi::Enum)]
850pub enum UlcpAttachMode {
851    /// This phone is the radio's tethered host: it claims the radio and
852    /// the radio filters, queues and acknowledges on its behalf.
853    #[default]
854    Tethered,
855    /// This phone is administering the radio without claiming it. A
856    /// phone commissioning ten repeaters must not write its host key on
857    /// any of them.
858    Administrative,
859}
860
861/// Stateful mobile host session for the ULCP.
862///
863/// This is the protocol boundary: it consumes complete reassembled ULCP
864/// frames and owns TIDs, response matching, capability-driven synchronization,
865/// host ownership, and claim/save choreography. Platform code owns only the
866/// transport lifecycle, byte shuttling, and timers.
867#[derive(uniffi::Object)]
868pub struct MobileUlcpSession {
869    inner: Mutex<UlcpSessionState>,
870    mode: UlcpAttachMode,
871    lazy_inspection: bool,
872}
873
874#[uniffi::export]
875impl MobileUlcpSession {
876    /// A session for the phone's own radio: the one it tethers to.
877    #[uniffi::constructor]
878    pub fn new() -> Arc<Self> {
879        Arc::new(Self::with_mode(UlcpAttachMode::Tethered))
880    }
881
882    /// A session for a radio this phone administers but does not claim.
883    /// [`Self::claim`] is refused; everything else behaves identically.
884    #[uniffi::constructor]
885    pub fn administrative() -> Arc<Self> {
886        Arc::new(Self::with_mode(UlcpAttachMode::Administrative))
887    }
888
889    /// An administrative session that attaches without reading the device
890    /// whole.
891    ///
892    /// Post-attach inspection is cut to what attaching itself requires —
893    /// the interface check and the always-present radio basics — so the
894    /// link is usable in a couple of exchanges instead of tens. Everything
895    /// else is read on demand through
896    /// [`Self::begin_property_fetch`], which is the point: a settings
897    /// screen that reads lazily has no use for an attach that reads
898    /// everything first.
899    ///
900    /// The provisioning snapshot such a session reports lists every
901    /// unread capability-gated property as unreadable, so the
902    /// whole-record configure calls — which withdraw writes to unreadable
903    /// properties — are not meaningful here. A lazy session writes
904    /// through [`Self::begin_property_writes`].
905    #[uniffi::constructor]
906    pub fn administrative_lazy() -> Arc<Self> {
907        let mut session = Self::with_mode(UlcpAttachMode::Administrative);
908        session.lazy_inspection = true;
909        Arc::new(session)
910    }
911
912    /// Which relationship this session represents.
913    pub fn attach_mode(&self) -> UlcpAttachMode {
914        self.mode
915    }
916
917    /// Begin post-attach synchronization for a new transport generation.
918    pub fn begin(
919        &self,
920        selected_host_key: Option<Vec<u8>>,
921    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
922        let selected_host_key = selected_host_key
923            .map(|key| {
924                key.try_into()
925                    .map_err(|_| MobileError::InvalidPublicKeyLength)
926            })
927            .transpose()?;
928        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
929        let generation = state.generation.wrapping_add(1);
930        *state = UlcpSessionState {
931            generation,
932            mode: self.mode,
933            lazy_inspection: self.lazy_inspection,
934            stage: SessionStage::Initial,
935            selected_host_key,
936            ..UlcpSessionState::default()
937        };
938
939        let mut outbound = Vec::new();
940        for property in [
941            prop::LAST_STATUS,
942            prop::PROTOCOL_VERSION,
943            prop::CAPS,
944            prop::DEV_KEY,
945            prop::DEV_NAME,
946            prop::BATTERY,
947            prop::HOST_KEY,
948        ] {
949            outbound.push(state.get_property(property)?);
950        }
951        Ok(state.update(outbound))
952    }
953
954    /// Replace an unclaimed or other-host configuration with this phone's key.
955    pub fn claim(&self, host_key: Vec<u8>) -> Result<UlcpSessionUpdateRecord, MobileError> {
956        // Commissioning is not tethering: an administrative session
957        // configures the radio's own domain and never writes a host key.
958        if self.mode == UlcpAttachMode::Administrative {
959            return Err(MobileError::AdministrativeSession);
960        }
961        let host_key: [u8; 32] = host_key
962            .try_into()
963            .map_err(|_| MobileError::InvalidPublicKeyLength)?;
964        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
965        if state.stage != SessionStage::AwaitingHost
966            || !matches!(
967                state.ownership(),
968                UlcpHostOwnership::Unclaimed | UlcpHostOwnership::OtherHost
969            )
970        {
971            return Err(MobileError::InvalidUlcpFrame);
972        }
973        state.selected_host_key = Some(host_key);
974        state.stage = SessionStage::Claiming;
975        state.expected.clear();
976        let tid = state.allocate_tid();
977        state.expected.insert(tid, ExpectedResponse::Claim);
978        let frame = ulcp_prop_set(tid, prop::HOST_KEY, host_key.to_vec())?;
979        Ok(state.update(vec![frame]))
980    }
981
982    /// Erase ALL mutable state on the radio (saved provisioning, device
983    /// identity, BLE bonds, pairing PIN, every persisted journal) and
984    /// reboot it. The radio does not reply — the reset drops the link —
985    /// so this is fire-and-forget: send the frame, then treat the ensuing
986    /// disconnect as completion. Permitted from any stage so a misbehaving
987    /// radio can always be wiped; unlike `claim`/`configure` it makes no
988    /// stage or ownership demands.
989    pub fn factory_reset(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
990        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
991        let tid = state.allocate_tid();
992        // Deliberately no ExpectedResponse: the device wipes storage and
993        // reboots without answering, so `update` reports
994        // waiting_for_responses = false and the caller does not block.
995        let frame = ulcp_factory_reset(tid)?;
996        Ok(state.update(vec![frame]))
997    }
998
999    /// Restart the radio (`CMD_REBOOT`), keeping everything it has
1000    /// persisted. Fire-and-forget for the same reason
1001    /// [`Self::factory_reset`] is: a radio that restarts answers nothing
1002    /// and the reboot drops the link. A radio without `CAP_REBOOT`
1003    /// answers `STATUS_UNIMPLEMENTED` instead, which arrives as an
1004    /// ordinary unsolicited status.
1005    ///
1006    /// Permitted from any stage, again like the factory reset: a radio
1007    /// worth restarting is often one that is not answering properly.
1008    pub fn reboot(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
1009        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1010        let tid = state.allocate_tid();
1011        let frame = ulcp_reboot(tid)?;
1012        Ok(state.update(vec![frame]))
1013    }
1014
1015    /// Start or stop the radio's locate alert (`PROP_ALERT`) so a
1016    /// misplaced radio can be found.
1017    ///
1018    /// Not part of `configure_device`, and never saved: this is live
1019    /// behavior rather than configuration, and it deliberately survives
1020    /// the phone walking out of BLE range — which is precisely when a
1021    /// search needs it. What ends it is this call, a button press at the
1022    /// radio, or the radio's own deadline; the latter two arrive as an
1023    /// unsolicited `PROP_ALERT` carried on the session snapshot.
1024    ///
1025    /// Re-sending `Locate` while an alert is running restarts that
1026    /// deadline, which is how a longer search keeps the alert alive.
1027    pub fn set_alert(&self, state: UlcpAlertState) -> Result<UlcpSessionUpdateRecord, MobileError> {
1028        let mut session = self.inner.lock().expect("ULCP session mutex poisoned");
1029        if session.stage != SessionStage::Attached {
1030            return Err(MobileError::InvalidUlcpFrame);
1031        }
1032        if !session.has_capability(cap::ALERT)? {
1033            return Err(MobileError::UnsupportedCapability);
1034        }
1035        let value = encode_alert_state(state)?;
1036        let tid = session.allocate_tid();
1037        session
1038            .expected
1039            .insert(tid, ExpectedResponse::Property(prop::ALERT));
1040        let frame = ulcp_prop_set(tid, prop::ALERT, value)?;
1041        Ok(session.update(vec![frame]))
1042    }
1043
1044    /// Set — or clear — the device's wall clock (`PROP_TIME`).
1045    ///
1046    /// Live state rather than configuration, and never saved: an epoch
1047    /// written to flash would come back arbitrarily wrong, since nothing
1048    /// bounds how long a device spends powered off. So this is not part
1049    /// of [`Self::configure_device`], which carries the time *zone* —
1050    /// where the device is meant to be is worth persisting even when what
1051    /// time it is is not.
1052    ///
1053    /// `None` clears the clock back to unknown, which is what a device
1054    /// reports before its first fix. On a device whose receiver is
1055    /// trusted for time, a fix will overwrite whatever is set here.
1056    ///
1057    /// The device answers with the epoch it now holds; that answer, not
1058    /// the value written, is what the session snapshot reports.
1059    pub fn set_time(
1060        &self,
1061        epoch_seconds: Option<u32>,
1062    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1063        let mut session = self.inner.lock().expect("ULCP session mutex poisoned");
1064        if session.stage != SessionStage::Attached {
1065            return Err(MobileError::InvalidUlcpFrame);
1066        }
1067        if !session.has_capability(cap::TIME)? {
1068            return Err(MobileError::UnsupportedCapability);
1069        }
1070        let value = epoch_seconds
1071            .map(|epoch| epoch.to_le_bytes().to_vec())
1072            .unwrap_or_default();
1073        let tid = session.allocate_tid();
1074        session
1075            .expected
1076            .insert(tid, ExpectedResponse::Property(prop::TIME));
1077        let frame = ulcp_prop_set(tid, prop::TIME, value)?;
1078        Ok(session.update(vec![frame]))
1079    }
1080
1081    /// Apply, verify, and persist a complete radio-settings snapshot.
1082    pub fn configure(
1083        &self,
1084        settings: UlcpRadioSettingsRecord,
1085    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1086        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1087        if state.stage != SessionStage::Attached {
1088            return Err(MobileError::InvalidUlcpFrame);
1089        }
1090        validate_radio_settings(&settings, DeviceCapabilities::read(&state)?)?;
1091
1092        state.expected.clear();
1093        state.configuration_queue = state.writable(configuration_values(settings, Vec::new()));
1094        let mut outbound = Vec::new();
1095        state.start_configuration(&mut outbound)?;
1096        Ok(state.update(outbound))
1097    }
1098
1099    /// Apply, verify, and persist a complete configuration of the device's
1100    /// own domain: its radio, the role it advertises, and whether and how
1101    /// it forwards for the mesh on its own.
1102    ///
1103    /// This is what commissioning writes. It touches nothing in the host
1104    /// domain — no host key, no filters, no queues — so it is equally
1105    /// valid from an administrative session on someone else's radio and
1106    /// from a tethered session on this phone's own.
1107    pub fn configure_device(
1108        &self,
1109        configuration: UlcpDeviceConfigRecord,
1110    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1111        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1112        if state.stage != SessionStage::Attached {
1113            return Err(MobileError::InvalidUlcpFrame);
1114        }
1115        let capabilities = DeviceCapabilities::read(&state)?;
1116        validate_radio_settings(&configuration.radio, capabilities)?;
1117        let device_values = validate_device_settings(&configuration, capabilities)?;
1118
1119        state.expected.clear();
1120        state.configuration_queue =
1121            state.writable(configuration_values(configuration.radio, device_values));
1122        let mut outbound = Vec::new();
1123        state.start_configuration(&mut outbound)?;
1124        Ok(state.update(outbound))
1125    }
1126
1127    /// Apply and persist the time zone and the positioning policy, and
1128    /// nothing else.
1129    ///
1130    /// [`Self::configure_device`] can write these too, as part of a whole
1131    /// device domain — that is what commissioning does. This exists for
1132    /// the case commissioning does not cover: a phone changing the
1133    /// positioning settings of the radio it is *tethered* to, which has
1134    /// no reason to restate that radio's role, discoverability, or
1135    /// forwarding policy in order to switch a receiver on.
1136    ///
1137    /// Each argument must be present exactly when the device advertises
1138    /// the matching capability, and the four positioning properties
1139    /// travel together for the reason [`UlcpGnssSettingsRecord`] gives.
1140    /// The write is echo-verified property by property and closed with a
1141    /// save, like any other configuration pass.
1142    pub fn configure_positioning(
1143        &self,
1144        gnss: Option<UlcpGnssSettingsRecord>,
1145        tz_offset_min: Option<i16>,
1146    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1147        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1148        if state.stage != SessionStage::Attached {
1149            return Err(MobileError::InvalidUlcpFrame);
1150        }
1151        let values = positioning_values(gnss, tz_offset_min, DeviceCapabilities::read(&state)?)?;
1152        // A radio with neither capability has nothing here to configure,
1153        // which is a caller mistake rather than an empty success.
1154        if values.is_empty() {
1155            return Err(MobileError::UnsupportedCapability);
1156        }
1157
1158        state.expected.clear();
1159        state.configuration_queue = state.writable(values);
1160        let mut outbound = Vec::new();
1161        state.start_configuration(&mut outbound)?;
1162        Ok(state.update(outbound))
1163    }
1164
1165    /// Apply and persist the advertisement policy, and nothing else.
1166    ///
1167    /// The tethered-radio counterpart of [`Self::configure_positioning`]:
1168    /// a phone changing how often its own radio announces itself has no
1169    /// reason to restate that radio's role, forwarding policy, or
1170    /// receiver settings to do it.
1171    pub fn configure_advertising(
1172        &self,
1173        advert: Option<UlcpAdvertSettingsRecord>,
1174    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1175        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1176        if state.stage != SessionStage::Attached {
1177            return Err(MobileError::InvalidUlcpFrame);
1178        }
1179        let values = advert_values(advert, DeviceCapabilities::read(&state)?)?;
1180        // A radio that announces nothing on its own has nothing here to
1181        // configure, which is a caller mistake rather than a no-op.
1182        if values.is_empty() {
1183            return Err(MobileError::UnsupportedCapability);
1184        }
1185
1186        state.expected.clear();
1187        state.configuration_queue = state.writable(values);
1188        let mut outbound = Vec::new();
1189        state.start_configuration(&mut outbound)?;
1190        Ok(state.update(outbound))
1191    }
1192
1193    /// Re-read every capability-gated property represented by the mobile
1194    /// snapshot. The existing snapshot remains usable while the bounded
1195    /// refresh is in flight; authoritative provisioning is published when
1196    /// the full capability-gated read completes.
1197    pub fn refresh(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
1198        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1199        if state.stage != SessionStage::Attached || !state.expected.is_empty() {
1200            return Err(MobileError::InvalidUlcpFrame);
1201        }
1202        let capabilities = state
1203            .responses
1204            .get(&prop::CAPS)
1205            .ok_or(MobileError::InvalidUlcpFrame)?
1206            .value
1207            .clone();
1208        state.inspection_queue = ulcp_refresh_properties(capabilities)?.into();
1209        let mut outbound = Vec::new();
1210        state.start_refresh(&mut outbound)?;
1211        Ok(state.update(outbound))
1212    }
1213
1214    /// Sample where the device is, and how well it knows.
1215    ///
1216    /// The device announces a fix indicator and nothing else about a
1217    /// position — a receiver reports about a fix a second and ordinary
1218    /// noise moves the reading, so announcing any of this would keep the
1219    /// radio transmitting for a host that may not be looking. A host that
1220    /// *is* looking asks, at whatever rate it can use the answer.
1221    ///
1222    /// Deliberately narrower than [`refresh`](Self::refresh): the five
1223    /// positioning properties and nothing else, so a screen watching a
1224    /// position does not re-read the PHY triple every time it looks.
1225    pub fn refresh_positioning(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
1226        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1227        if state.stage != SessionStage::Attached || !state.expected.is_empty() {
1228            return Err(MobileError::InvalidUlcpFrame);
1229        }
1230        let capabilities = state
1231            .responses
1232            .get(&prop::CAPS)
1233            .ok_or(MobileError::InvalidUlcpFrame)?
1234            .value
1235            .clone();
1236        // A device without the capability has nothing to sample, and
1237        // asking anyway would earn a refusal per property.
1238        if !decode_capabilities(&capabilities)?.contains(&cap::GNSS) {
1239            return Err(MobileError::InvalidUlcpFrame);
1240        }
1241        state.inspection_queue = VecDeque::from(vec![
1242            prop::GNSS_LOCATION,
1243            prop::GNSS_ALTITUDE,
1244            prop::GNSS_FIX,
1245            prop::GNSS_PRECISION,
1246            prop::GNSS_SATELLITES,
1247        ]);
1248        let mut outbound = Vec::new();
1249        state.start_refresh(&mut outbound)?;
1250        Ok(state.update(outbound))
1251    }
1252
1253    /// Read the named properties, whatever they are, and answer with what
1254    /// the device said about each.
1255    ///
1256    /// The local counterpart of a mesh management fetch, and it reports
1257    /// the same way: one answer per property, a refusal recorded as that
1258    /// property's status rather than failing the run. The completion
1259    /// arrives as [`UlcpSessionUpdateRecord::management_event`] once every
1260    /// answer is in. One operation may run at a time.
1261    ///
1262    /// Values read fold into the session's own snapshot as well, so a
1263    /// settings screen reading a property does not leave the attached
1264    /// provisioning stale.
1265    pub fn begin_property_fetch(
1266        &self,
1267        property_ids: Vec<u32>,
1268    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1269        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1270        state.begin_local_management()?;
1271        state.management = Some(LocalManagement {
1272            fetch_queue: property_ids.into(),
1273            ..LocalManagement::default()
1274        });
1275        let mut outbound = Vec::new();
1276        state.continue_local_management(&mut outbound)?;
1277        Ok(state.update(outbound))
1278    }
1279
1280    /// Write the given properties, in the given order, and answer with
1281    /// what the device says each is now worth.
1282    ///
1283    /// The order is the caller's to state and is preserved — a dirty-write
1284    /// plan brackets the radio with `PROP_PHY_ENABLED`, and reordering it
1285    /// would ask the device to retune mid-transmission. Writes go out one
1286    /// at a time for the same reason. A refusal is recorded as that
1287    /// property's answer and the run continues, matching the mesh path.
1288    ///
1289    /// Nothing is saved: persistence is a separate, explicit
1290    /// [`Self::begin_save`], again matching the mesh path.
1291    pub fn begin_property_writes(
1292        &self,
1293        writes: Vec<MobileMeshPropertyWriteRecord>,
1294    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1295        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1296        state.begin_local_management()?;
1297        state.management = Some(LocalManagement {
1298            write_queue: writes
1299                .into_iter()
1300                .map(|write| (write.property_id, write.value))
1301                .collect(),
1302            ..LocalManagement::default()
1303        });
1304        let mut outbound = Vec::new();
1305        state.continue_local_management(&mut outbound)?;
1306        Ok(state.update(outbound))
1307    }
1308
1309    /// Persist whatever the device is holding, reporting the `CMD_SAVE`
1310    /// status on the completion event.
1311    ///
1312    /// On a device without `CAP_SAVE` there is nothing to ask, and the
1313    /// operation completes immediately with no status — running
1314    /// configuration is all such a device has.
1315    pub fn begin_save(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
1316        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1317        state.begin_local_management()?;
1318        if !state.has_capability(cap::SAVE)? {
1319            state.management_event = Some(UlcpLocalManagementEventRecord {
1320                answers: Vec::new(),
1321                status_code: None,
1322            });
1323            return Ok(state.update(Vec::new()));
1324        }
1325        state.management = Some(LocalManagement::default());
1326        let tid = state.allocate_tid();
1327        state.expected.insert(tid, ExpectedResponse::ManagementSave);
1328        let frame = ulcp_save(tid)?;
1329        Ok(state.update(vec![frame]))
1330    }
1331
1332    /// Delete every Bluetooth bond the radio holds, along with its
1333    /// pairing PIN (`CMD_BLE_CLEAR_BONDS`). The radio then opens a
1334    /// pairing window, which is what makes it reachable again.
1335    ///
1336    /// Over Bluetooth this severs the caller's own link: the bond that
1337    /// carried the command is one of the bonds deleted. The status
1338    /// arrives first, so the completion event still reports what
1339    /// happened.
1340    pub fn begin_ble_clear_bonds(&self) -> Result<UlcpSessionUpdateRecord, MobileError> {
1341        self.begin_ble_command(ulcp_ble_clear_bonds)
1342    }
1343
1344    /// Store one channel key on the radio's device identity
1345    /// (`PROP_DEV_CHANNEL_KEYS`), then persist with a chained `CMD_SAVE` when
1346    /// the device can.
1347    ///
1348    /// This is the device's own channel membership, independent of the phone's:
1349    /// it is what the device uses for its own advertisements, blind-unicast
1350    /// addressing, and repeater filtering, and it survives host replacement.
1351    ///
1352    /// Requires an attached, otherwise-idle session on a device advertising
1353    /// `CAP_DEV_IDENTITY`, and the device additionally requires an encrypted
1354    /// link before it will accept key material. Failures surface as
1355    /// `operation_error` with the device's status name — `NOMEM` when the list
1356    /// is full (capacity [`ulcp_max_dev_channels`]), `ALREADY` when the key is
1357    /// already stored, which callers should treat as success.
1358    pub fn insert_device_channel_key(
1359        &self,
1360        channel_key: Vec<u8>,
1361    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1362        let id = dev_channel_id(&channel_key)?;
1363        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1364        state.begin_device_domain_operation(cap::DEV_IDENTITY)?;
1365        let tid = state.allocate_tid();
1366        state
1367            .expected
1368            .insert(tid, ExpectedResponse::DevChannelInsert(id));
1369        let frame = ulcp_prop_insert(tid, prop::DEV_CHANNEL_KEYS, &channel_key)?;
1370        Ok(state.update(vec![frame]))
1371    }
1372
1373    /// Remove one channel key from the radio's device identity
1374    /// (`PROP_DEV_CHANNEL_KEYS`), then persist with a chained `CMD_SAVE` when
1375    /// the device can.
1376    ///
1377    /// The remove selector is the key itself, so only a channel the caller
1378    /// still holds the key for can be removed this way. Same preconditions as
1379    /// [`Self::insert_device_channel_key`]; `ITEM_NOT_FOUND` surfaces as
1380    /// `operation_error` and callers should treat it as success.
1381    pub fn remove_device_channel_key(
1382        &self,
1383        channel_key: Vec<u8>,
1384    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1385        let id = dev_channel_id(&channel_key)?;
1386        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1387        state.begin_device_domain_operation(cap::DEV_IDENTITY)?;
1388        let tid = state.allocate_tid();
1389        state
1390            .expected
1391            .insert(tid, ExpectedResponse::DevChannelRemove(id));
1392        let frame = ulcp_prop_remove(tid, prop::DEV_CHANNEL_KEYS, &channel_key)?;
1393        Ok(state.update(vec![frame]))
1394    }
1395
1396    /// Make the radio's host channel-key table (`PROP_HOST_CHANNEL_KEYS`)
1397    /// match the phone identity's joined channels.
1398    ///
1399    /// The radio needs these keys to recognize multicast and blind-unicast
1400    /// traffic addressed to channels this phone has joined, and to queue it
1401    /// while the phone is away. That is bookkeeping between the app and its
1402    /// own radio, not a user-facing setting: callers reconcile on attach and
1403    /// after every join or leave, and never surface it.
1404    ///
1405    /// The host domain is volatile — the device does not persist it — so no
1406    /// `CMD_SAVE` is chained and reconciling on attach is what makes it stick.
1407    /// Requires an attached, idle session on a device advertising
1408    /// `CAP_HOST_KEYS`; otherwise the table is not this session's to manage
1409    /// and the call reports that the capability is missing.
1410    ///
1411    /// Returns without any frames when the device already holds exactly the
1412    /// requested set.
1413    pub fn reconcile_host_channel_keys(
1414        &self,
1415        keys: Vec<Vec<u8>>,
1416    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1417        let mut desired = VecDeque::with_capacity(keys.len());
1418        let mut desired_ids = Vec::with_capacity(keys.len());
1419        for key in keys {
1420            desired_ids.push(dev_channel_id(&key)?);
1421            desired.push_back(key);
1422        }
1423
1424        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1425        if state.stage != SessionStage::Attached || !state.expected.is_empty() {
1426            return Err(MobileError::InvalidUlcpFrame);
1427        }
1428        if !state.has_capability(cap::HOST_KEYS)? {
1429            return Err(MobileError::UnsupportedCapability);
1430        }
1431
1432        let current = state
1433            .responses
1434            .get(&prop::HOST_CHANNEL_KEYS)
1435            .map(|entry| entry.value.clone())
1436            .unwrap_or_default();
1437        let current_ids: Vec<Vec<u8>> = current
1438            .chunks(items::CHANNEL_ID_LEN)
1439            .map(<[u8]>::to_vec)
1440            .collect();
1441
1442        // Shedding a channel needs its key as the remove selector, and an
1443        // identifier this phone cannot derive is one whose key it does not
1444        // hold. The table is small, so one whole-table write says everything.
1445        if current_ids.iter().any(|id| !desired_ids.contains(id)) {
1446            let table: Vec<u8> = desired.iter().flatten().copied().collect();
1447            let tid = state.allocate_tid();
1448            state
1449                .expected
1450                .insert(tid, ExpectedResponse::HostChannelReplace);
1451            state.set_host_channel_ids(&desired_ids);
1452            let frame = ulcp_prop_set(tid, prop::HOST_CHANNEL_KEYS, table)?;
1453            return Ok(state.update(vec![frame]));
1454        }
1455
1456        desired.retain(|key| {
1457            !current_ids
1458                .iter()
1459                .any(|id| dev_channel_id(key).is_ok_and(|derived| &derived == id))
1460        });
1461        match state.next_host_channel_insert(desired) {
1462            Some(frame) => Ok(state.update(vec![frame])),
1463            None => Ok(state.update(Vec::new())),
1464        }
1465    }
1466
1467    /// Store one peer public key on the radio's device identity
1468    /// (`PROP_DEV_PEERS`), then persist with a chained `CMD_SAVE` when the
1469    /// device can.
1470    ///
1471    /// Requires an attached, otherwise-idle session on a device advertising
1472    /// `CAP_DEV_IDENTITY`. Failures surface as `operation_error` with the
1473    /// device's status name — `NOMEM` when the list is full (capacity
1474    /// [`ulcp_max_dev_peers`]), `ALREADY` when the key is already stored,
1475    /// which callers should treat as success.
1476    pub fn insert_device_peer(
1477        &self,
1478        public_key: Vec<u8>,
1479    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1480        self.insert_device_key(cap::DEV_IDENTITY, prop::DEV_PEERS, public_key)
1481    }
1482
1483    /// Remove one peer public key from the radio's device identity
1484    /// (`PROP_DEV_PEERS`), then persist with a chained `CMD_SAVE` when the
1485    /// device can.
1486    ///
1487    /// Same preconditions as [`Self::insert_device_peer`]. `ITEM_NOT_FOUND`
1488    /// surfaces as `operation_error` and callers should treat it as success —
1489    /// the key is not on the device either way.
1490    pub fn remove_device_peer(
1491        &self,
1492        public_key: Vec<u8>,
1493    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1494        self.remove_device_key(cap::DEV_IDENTITY, prop::DEV_PEERS, public_key)
1495    }
1496
1497    /// Store one administrator public key on the radio's device identity
1498    /// (`PROP_DEV_ADMINS`), then persist with a chained `CMD_SAVE` when the
1499    /// device can.
1500    ///
1501    /// This is the bench half of node management: a key listed here may
1502    /// manage this radio over the mesh, so the phone puts its own node key
1503    /// on a radio it is attached to and manages it later from across the
1504    /// valley. The list is what authorizes an administrator — no pairwise
1505    /// provisioning follows, because the session is derived from the two
1506    /// identities.
1507    ///
1508    /// Requires an attached, otherwise-idle session on a device advertising
1509    /// `CAP_ADMIN`. Failures surface as `operation_error` with the device's
1510    /// status name — `NOMEM` when the list is full (capacity
1511    /// [`ulcp_max_dev_admins`]), `ALREADY` when the key is already listed,
1512    /// which callers should treat as success.
1513    pub fn insert_device_admin(
1514        &self,
1515        public_key: Vec<u8>,
1516    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1517        self.insert_device_key(cap::ADMIN, prop::DEV_ADMINS, public_key)
1518    }
1519
1520    /// Remove one administrator public key from the radio's device identity
1521    /// (`PROP_DEV_ADMINS`), then persist with a chained `CMD_SAVE` when the
1522    /// device can.
1523    ///
1524    /// Same preconditions as [`Self::insert_device_admin`]. Emptying the
1525    /// list is how a device stops being manageable over the mesh at all.
1526    /// `ITEM_NOT_FOUND` surfaces as `operation_error` and callers should
1527    /// treat it as success — the key is not listed either way.
1528    pub fn remove_device_admin(
1529        &self,
1530        public_key: Vec<u8>,
1531    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1532        self.remove_device_key(cap::ADMIN, prop::DEV_ADMINS, public_key)
1533    }
1534
1535    /// Queue one complete raw UMSH frame on `STR_PHY_RAW`.
1536    ///
1537    /// The platform adapter supplies only opaque bytes from `MobileMeshSession`;
1538    /// Rust owns the ULCP command, stream identifier, metadata, TID, and
1539    /// confirmation matching. `nocca` sets `TX_FLAG_NOCCA` so the device
1540    /// transmits without its pre-transmit channel-activity check — used for
1541    /// immediate MAC acks (see [`MobileMeshOutboundFrameRecord::nocca`]).
1542    pub fn transmit_raw(
1543        &self,
1544        data: Vec<u8>,
1545        nocca: bool,
1546    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
1547        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1548        let raw_pipeline_active = state
1549            .expected
1550            .values()
1551            .all(|expected| matches!(expected, ExpectedResponse::RawTransmit));
1552        if state.stage != SessionStage::Attached || !raw_pipeline_active || data.is_empty() {
1553            return Err(MobileError::InvalidUlcpFrame);
1554        }
1555        let mut available_tid = None;
1556        for _ in 0..usize::from(frame::TID_MAX) {
1557            let candidate = state.allocate_tid();
1558            if !state.expected.contains_key(&candidate) {
1559                available_tid = Some(candidate);
1560                break;
1561            }
1562        }
1563        let tid = available_tid.ok_or(MobileError::InvalidUlcpFrame)?;
1564        state.expected.insert(tid, ExpectedResponse::RawTransmit);
1565        let mut metadata = [0u8; umsh_ulcp::TxMeta::WIRE_LEN];
1566        let flags = if nocca {
1567            umsh_ulcp::meta::TX_FLAG_NOCCA
1568        } else {
1569            0
1570        };
1571        umsh_ulcp::TxMeta {
1572            flags,
1573            ..umsh_ulcp::TxMeta::default()
1574        }
1575        .encode(&mut metadata)
1576        .map_err(|_| MobileError::InvalidUlcpFrame)?;
1577        let mut frame = vec![0u8; data.len() + 16];
1578        let len = umsh_ulcp::frame::str_send(
1579            &mut frame,
1580            tid,
1581            umsh_ulcp::ids::stream::PHY_RAW,
1582            &data,
1583            &metadata,
1584        )
1585        .map_err(|_| MobileError::InvalidUlcpFrame)?;
1586        frame.truncate(len);
1587        let mut update = state.update(vec![frame]);
1588        update.raw_transmit_started_transaction_id = Some(tid);
1589        Ok(update)
1590    }
1591
1592    /// Consume one complete ULCP frame and advance the session reducer.
1593    pub fn consume(&self, frame: Vec<u8>) -> Result<UlcpSessionUpdateRecord, MobileError> {
1594        let parsed = Frame::parse(&frame).map_err(|_| MobileError::UlcpFrameUnparsable)?;
1595        if parsed.command() == Some(Cmd::StrRecv) {
1596            if parsed.header.tid() != frame::TID_UNSOLICITED {
1597                return Err(MobileError::UlcpUnexpectedFrame);
1598            }
1599            let payload = StreamPayload::parse(parsed.payload)
1600                .map_err(|_| MobileError::UlcpMalformedPayload)?;
1601            if payload.stream != umsh_ulcp::ids::stream::PHY_RAW {
1602                return Err(MobileError::UlcpUnexpectedFrame);
1603            }
1604            let metadata = BufferedRxMeta::decode(payload.metadata)
1605                .map_err(|_| MobileError::UlcpMalformedPayload)?;
1606            let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1607            if state.stage == SessionStage::Idle {
1608                return Err(MobileError::UlcpUnexpectedFrame);
1609            }
1610            return Ok(state.update_with_received(vec![UlcpReceivedFrameRecord {
1611                data: payload.data.to_vec(),
1612                rssi_dbm: metadata.rx.rssi_dbm,
1613                lqi: metadata.rx.lqi.map(core::num::NonZeroU8::get),
1614                snr_cb: metadata.rx.snr_cb,
1615                was_buffered: metadata.flags & RX_FLAG_BUFFERED != 0,
1616                was_acknowledged: metadata.flags & RX_FLAG_ACKED != 0,
1617                age_seconds: metadata.age_s,
1618            }]));
1619        }
1620        let response = inspect_ulcp_property_frame(frame)?;
1621        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
1622        let mut outbound = Vec::new();
1623        let mut raw_transmit_result = None;
1624        let mut operation_error = None;
1625
1626        if response.transaction_id == frame::TID_UNSOLICITED {
1627            if response.command == Cmd::PropIs as u8 {
1628                state
1629                    .responses
1630                    .insert(response.property_id, response.clone());
1631                // Carried out verbatim as well as folded into the
1632                // snapshot, so a consumer caching values by property
1633                // number hears about it without knowing the property.
1634                state.pushed_properties.push(UlcpPropertyPushRecord {
1635                    property_id: response.property_id,
1636                    value: response.value.clone(),
1637                });
1638            }
1639            state.apply_property(&response)?;
1640            state.refresh_attached_snapshot(Some(response.property_id))?;
1641            return Ok(state.update(outbound));
1642        }
1643
1644        let expected = state
1645            .expected
1646            .remove(&response.transaction_id)
1647            .ok_or(MobileError::UlcpUnexpectedFrame)?;
1648        match expected {
1649            ExpectedResponse::Property(property) => {
1650                if response.property_id == prop::LAST_STATUS && property != prop::LAST_STATUS {
1651                    // A capability-gated property the device declines is one
1652                    // unknown setting, not an unusable device: drop any value
1653                    // cached from an earlier read so the reduction reports it
1654                    // as unreadable, and carry on with the rest of the queue.
1655                    let expected_property = matches!(
1656                        state.stage,
1657                        SessionStage::Inspection | SessionStage::Refreshing
1658                    );
1659                    if expected_property {
1660                        state.responses.remove(&property);
1661                    } else {
1662                        operation_error = Some(ulcp_operation_error(
1663                            format!("read property {property}"),
1664                            response.value.as_slice(),
1665                        )?);
1666                        let optional_initial_property = state.stage == SessionStage::Initial
1667                            && matches!(
1668                                property,
1669                                prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY | prop::HOST_KEY
1670                            );
1671                        state.stage_failure_pending |= !optional_initial_property;
1672                        if state.stage == SessionStage::Initial && property == prop::HOST_KEY {
1673                            state.host_key_unsupported = true;
1674                        }
1675                    }
1676                } else {
1677                    if response.property_id != property || response.command != Cmd::PropIs as u8 {
1678                        return Err(MobileError::UlcpMismatchedResponse);
1679                    }
1680                    state.responses.insert(property, response.clone());
1681                    state.apply_property(&response)?;
1682                }
1683            }
1684            ExpectedResponse::Claim => {
1685                if response.property_id == prop::LAST_STATUS {
1686                    operation_error = Some(ulcp_operation_error(
1687                        "claim host identity".to_owned(),
1688                        response.value.as_slice(),
1689                    )?);
1690                    state.stage_failure_pending = true;
1691                } else {
1692                    if response.property_id != prop::HOST_KEY
1693                        || response.command != Cmd::PropIs as u8
1694                    {
1695                        return Err(MobileError::UlcpMismatchedResponse);
1696                    }
1697                    // Whatever the device reports is its host key, even if
1698                    // it is not the one just written — a claim that did not
1699                    // take means this radio belongs to someone else, which
1700                    // `ownership()` reads off this value and reports as
1701                    // `OtherHost`. That is an answer, not a broken session.
1702                    state.radio_host_key = Some(response.value.clone());
1703                    state.responses.insert(prop::HOST_KEY, response);
1704                    if state.has_capability(cap::SAVE)? {
1705                        state.stage = SessionStage::Saving;
1706                        let tid = state.allocate_tid();
1707                        state.expected.insert(tid, ExpectedResponse::Save);
1708                        outbound.push(ulcp_save(tid)?);
1709                    } else {
1710                        state.start_inspection(&mut outbound)?;
1711                    }
1712                }
1713            }
1714            ExpectedResponse::Save => {
1715                if response.property_id != prop::LAST_STATUS
1716                    || response.command != Cmd::PropIs as u8
1717                {
1718                    return Err(MobileError::UlcpMismatchedResponse);
1719                }
1720                if inspect_ulcp_status(response.value.clone())? != 0 {
1721                    operation_error = Some(ulcp_operation_error(
1722                        "save claimed host identity".to_owned(),
1723                        response.value.as_slice(),
1724                    )?);
1725                    state.stage_failure_pending = true;
1726                } else {
1727                    state.start_inspection(&mut outbound)?;
1728                }
1729            }
1730            ExpectedResponse::ConfigurationProperty(property) => {
1731                if response.property_id == prop::LAST_STATUS {
1732                    operation_error = Some(ulcp_operation_error(
1733                        format!("set property {property}"),
1734                        response.value.as_slice(),
1735                    )?);
1736                    state.stage_failure_pending = true;
1737                    // The status frame describes the write, not the property.
1738                    // Filing it under the property would leave the snapshot
1739                    // reducing a status code as that property's value.
1740                    state.responses.remove(&property);
1741                } else if response.property_id != property || response.command != Cmd::PropIs as u8
1742                {
1743                    return Err(MobileError::UlcpMismatchedResponse);
1744                } else {
1745                    // A `CMD_PROP_IS` is the device's authoritative value,
1746                    // whatever was written. It reports what the device holds
1747                    // — clamped to hardware, reduced to what it supports,
1748                    // changed for a reason this host has no view of — and a
1749                    // value differing from the write is that report, not a
1750                    // fault. The snapshot published to the UI is what the
1751                    // device says, never what was asked for. Failure is a
1752                    // `PROP_LAST_STATUS`, handled above.
1753                    state.responses.insert(property, response.clone());
1754                    state.apply_property(&response)?;
1755                }
1756            }
1757            ExpectedResponse::SaveConfiguration => {
1758                if response.property_id != prop::LAST_STATUS
1759                    || response.command != Cmd::PropIs as u8
1760                {
1761                    return Err(MobileError::UlcpMismatchedResponse);
1762                }
1763                if inspect_ulcp_status(response.value.clone())? != 0 {
1764                    operation_error = Some(ulcp_operation_error(
1765                        "save radio configuration".to_owned(),
1766                        response.value.as_slice(),
1767                    )?);
1768                    state.stage_failure_pending = true;
1769                } else {
1770                    state.finish_configuration()?;
1771                }
1772            }
1773            ExpectedResponse::RawTransmit => {
1774                if response.property_id != prop::LAST_STATUS
1775                    || response.command != Cmd::PropIs as u8
1776                {
1777                    return Err(MobileError::UlcpMismatchedResponse);
1778                }
1779                let status_code = inspect_ulcp_status(response.value)?;
1780                let status = umsh_ulcp::Status(status_code);
1781                raw_transmit_result = Some(UlcpRawTransmitResultRecord {
1782                    transaction_id: response.transaction_id,
1783                    status_code,
1784                    status_name: format!("{status:?}"),
1785                    disposition: if status == umsh_ulcp::Status::OK {
1786                        UlcpRawTransmitDisposition::Sent
1787                    } else if status == umsh_ulcp::Status::BUSY
1788                        || status == umsh_ulcp::Status::CCA_FAILURE
1789                    {
1790                        // Both are transient channel-contention refusals: the
1791                        // frame never left the radio, so retry with backoff.
1792                        UlcpRawTransmitDisposition::Retry
1793                    } else {
1794                        UlcpRawTransmitDisposition::Rejected
1795                    },
1796                });
1797            }
1798            ExpectedResponse::HostChannelInsert(mut remaining) => {
1799                if response.property_id == prop::LAST_STATUS {
1800                    let error = ulcp_operation_error(
1801                        "provision host channel key".to_owned(),
1802                        response.value.as_slice(),
1803                    )?;
1804                    // A channel key is its own item, so ALREADY asserts the
1805                    // state that was asked for. Anything else — NOMEM above
1806                    // all — stops the pass; the phone still runs its own MAC
1807                    // while attached, so this degrades radio-side filtering
1808                    // rather than the user's ability to use the channel.
1809                    if error.status_code != umsh_ulcp::Status::ALREADY.0 {
1810                        operation_error = Some(error);
1811                        state.refresh_attached_snapshot(None)?;
1812                        remaining.clear();
1813                    }
1814                } else if response.property_id != prop::HOST_CHANNEL_KEYS
1815                    || response.command != Cmd::PropInserted as u8
1816                {
1817                    return Err(MobileError::UlcpMismatchedResponse);
1818                }
1819                if let Some(frame) = state.next_host_channel_insert(remaining) {
1820                    outbound.push(frame);
1821                } else {
1822                    state.refresh_attached_snapshot(None)?;
1823                }
1824            }
1825            ExpectedResponse::HostChannelReplace => {
1826                if response.property_id == prop::LAST_STATUS {
1827                    operation_error = Some(ulcp_operation_error(
1828                        "provision host channel keys".to_owned(),
1829                        response.value.as_slice(),
1830                    )?);
1831                } else if response.property_id != prop::HOST_CHANNEL_KEYS
1832                    || response.command != Cmd::PropIs as u8
1833                {
1834                    return Err(MobileError::UlcpMismatchedResponse);
1835                }
1836                state.refresh_attached_snapshot(None)?;
1837            }
1838            ExpectedResponse::DevChannelInsert(id) => {
1839                if response.property_id == prop::LAST_STATUS {
1840                    let error = ulcp_operation_error(
1841                        "insert device channel key".to_owned(),
1842                        response.value.as_slice(),
1843                    )?;
1844                    // ALREADY is the device saying the channel is stored.
1845                    if error.status_code == umsh_ulcp::Status::ALREADY.0 {
1846                        state.patch_dev_channels(&id, true);
1847                        state.refresh_attached_snapshot(None)?;
1848                    }
1849                    operation_error = Some(error);
1850                } else {
1851                    if response.property_id != prop::DEV_CHANNEL_KEYS
1852                        || response.command != Cmd::PropInserted as u8
1853                        || response.value != id
1854                    {
1855                        return Err(MobileError::UlcpMismatchedResponse);
1856                    }
1857                    state.patch_dev_channels(&id, true);
1858                    if state.has_capability(cap::SAVE)? {
1859                        let tid = state.allocate_tid();
1860                        state
1861                            .expected
1862                            .insert(tid, ExpectedResponse::SaveDevChannels);
1863                        outbound.push(ulcp_save(tid)?);
1864                    }
1865                    state.refresh_attached_snapshot(None)?;
1866                }
1867            }
1868            ExpectedResponse::DevChannelRemove(id) => {
1869                if response.property_id == prop::LAST_STATUS {
1870                    let error = ulcp_operation_error(
1871                        "remove device channel key".to_owned(),
1872                        response.value.as_slice(),
1873                    )?;
1874                    if error.status_code == umsh_ulcp::Status::ITEM_NOT_FOUND.0 {
1875                        state.patch_dev_channels(&id, false);
1876                        state.refresh_attached_snapshot(None)?;
1877                    }
1878                    operation_error = Some(error);
1879                } else {
1880                    if response.property_id != prop::DEV_CHANNEL_KEYS
1881                        || response.command != Cmd::PropRemoved as u8
1882                        || response.value != id
1883                    {
1884                        return Err(MobileError::UlcpMismatchedResponse);
1885                    }
1886                    state.patch_dev_channels(&id, false);
1887                    if state.has_capability(cap::SAVE)? {
1888                        let tid = state.allocate_tid();
1889                        state
1890                            .expected
1891                            .insert(tid, ExpectedResponse::SaveDevChannels);
1892                        outbound.push(ulcp_save(tid)?);
1893                    }
1894                    state.refresh_attached_snapshot(None)?;
1895                }
1896            }
1897            ExpectedResponse::SaveDevChannels => {
1898                if response.property_id != prop::LAST_STATUS
1899                    || response.command != Cmd::PropIs as u8
1900                {
1901                    return Err(MobileError::UlcpMismatchedResponse);
1902                }
1903                if inspect_ulcp_status(response.value.clone())? != 0 {
1904                    operation_error = Some(ulcp_operation_error(
1905                        "save device channel keys".to_owned(),
1906                        response.value.as_slice(),
1907                    )?);
1908                }
1909            }
1910            ExpectedResponse::DevKeyInsert { property, item } => {
1911                let table = dev_key_table_name(property);
1912                if response.property_id == prop::LAST_STATUS {
1913                    let error = ulcp_operation_error(
1914                        format!("insert device {table}"),
1915                        response.value.as_slice(),
1916                    )?;
1917                    // ALREADY is the device saying the key is stored; keep
1918                    // the cache truthful even though the operation "failed".
1919                    if error.status_code == umsh_ulcp::Status::ALREADY.0 {
1920                        state.patch_dev_keys(property, &item, true);
1921                        state.refresh_attached_snapshot(None)?;
1922                    }
1923                    operation_error = Some(error);
1924                } else {
1925                    if response.property_id != property
1926                        || response.command != Cmd::PropInserted as u8
1927                        || response.value != item
1928                    {
1929                        return Err(MobileError::UlcpMismatchedResponse);
1930                    }
1931                    state.patch_dev_keys(property, &item, true);
1932                    if state.has_capability(cap::SAVE)? {
1933                        let tid = state.allocate_tid();
1934                        state
1935                            .expected
1936                            .insert(tid, ExpectedResponse::SaveDevKeys { property });
1937                        outbound.push(ulcp_save(tid)?);
1938                    }
1939                    state.refresh_attached_snapshot(None)?;
1940                }
1941            }
1942            ExpectedResponse::DevKeyRemove { property, item } => {
1943                let table = dev_key_table_name(property);
1944                if response.property_id == prop::LAST_STATUS {
1945                    let error = ulcp_operation_error(
1946                        format!("remove device {table}"),
1947                        response.value.as_slice(),
1948                    )?;
1949                    // ITEM_NOT_FOUND means the key is not on the device,
1950                    // which is the state the caller asked for.
1951                    if error.status_code == umsh_ulcp::Status::ITEM_NOT_FOUND.0 {
1952                        state.patch_dev_keys(property, &item, false);
1953                        state.refresh_attached_snapshot(None)?;
1954                    }
1955                    operation_error = Some(error);
1956                } else {
1957                    if response.property_id != property
1958                        || response.command != Cmd::PropRemoved as u8
1959                        || response.value != item
1960                    {
1961                        return Err(MobileError::UlcpMismatchedResponse);
1962                    }
1963                    state.patch_dev_keys(property, &item, false);
1964                    if state.has_capability(cap::SAVE)? {
1965                        let tid = state.allocate_tid();
1966                        state
1967                            .expected
1968                            .insert(tid, ExpectedResponse::SaveDevKeys { property });
1969                        outbound.push(ulcp_save(tid)?);
1970                    }
1971                    state.refresh_attached_snapshot(None)?;
1972                }
1973            }
1974            ExpectedResponse::SaveDevKeys { property } => {
1975                if response.property_id != prop::LAST_STATUS
1976                    || response.command != Cmd::PropIs as u8
1977                {
1978                    return Err(MobileError::UlcpMismatchedResponse);
1979                }
1980                if inspect_ulcp_status(response.value.clone())? != 0 {
1981                    // The live mutation stuck; only persistence failed. The
1982                    // session stays attached and the caller sees the same
1983                    // `saved` warning path a failed configuration save uses.
1984                    let table = dev_key_table_name(property);
1985                    operation_error = Some(ulcp_operation_error(
1986                        format!("save device {table}s"),
1987                        response.value.as_slice(),
1988                    )?);
1989                }
1990            }
1991            ExpectedResponse::ManagementGet(property) => {
1992                if response.property_id == prop::LAST_STATUS && property != prop::LAST_STATUS {
1993                    // A refusal is the device's whole answer about this
1994                    // property. Drop any stale cached value so the session
1995                    // snapshot agrees with what was just reported.
1996                    let status_code = inspect_ulcp_status(response.value.clone())?;
1997                    state.responses.remove(&property);
1998                    state.record_management_answer(MobileMeshManagementAnswerRecord {
1999                        property_id: property,
2000                        value: None,
2001                        status_code: Some(status_code),
2002                    });
2003                } else if response.property_id != property || response.command != Cmd::PropIs as u8
2004                {
2005                    return Err(MobileError::UlcpMismatchedResponse);
2006                } else {
2007                    state.responses.insert(property, response.clone());
2008                    state.apply_property(&response)?;
2009                    state.record_management_answer(MobileMeshManagementAnswerRecord {
2010                        property_id: property,
2011                        value: Some(response.value.clone()),
2012                        status_code: None,
2013                    });
2014                }
2015                state.continue_local_management(&mut outbound)?;
2016            }
2017            ExpectedResponse::ManagementSet(property) => {
2018                if response.property_id == prop::LAST_STATUS && property != prop::LAST_STATUS {
2019                    // A refused write leaves the device holding whatever it
2020                    // held. The answer records the refusal and the run
2021                    // continues — the caller decides per property, like the
2022                    // mesh path.
2023                    let status_code = inspect_ulcp_status(response.value.clone())?;
2024                    state.record_management_answer(MobileMeshManagementAnswerRecord {
2025                        property_id: property,
2026                        value: None,
2027                        status_code: Some(status_code),
2028                    });
2029                } else if response.property_id != property || response.command != Cmd::PropIs as u8
2030                {
2031                    return Err(MobileError::UlcpMismatchedResponse);
2032                } else {
2033                    // The echo is the device's authoritative value, whatever
2034                    // was written — see the ConfigurationProperty arm.
2035                    state.responses.insert(property, response.clone());
2036                    state.apply_property(&response)?;
2037                    state.record_management_answer(MobileMeshManagementAnswerRecord {
2038                        property_id: property,
2039                        value: Some(response.value.clone()),
2040                        status_code: None,
2041                    });
2042                }
2043                state.continue_local_management(&mut outbound)?;
2044            }
2045            ExpectedResponse::ManagementSave | ExpectedResponse::ManagementCommand => {
2046                if response.property_id != prop::LAST_STATUS
2047                    || response.command != Cmd::PropIs as u8
2048                {
2049                    return Err(MobileError::UlcpMismatchedResponse);
2050                }
2051                let status_code = inspect_ulcp_status(response.value.clone())?;
2052                if let Some(op) = state.management.as_mut() {
2053                    op.save_status = Some(status_code);
2054                }
2055                state.continue_local_management(&mut outbound)?;
2056            }
2057        }
2058
2059        if state.expected.is_empty() {
2060            if state.stage_failure_pending {
2061                state.stage_failure_pending = false;
2062                state.recover_from_operation_failure(&mut outbound)?;
2063            } else {
2064                state.advance_completed_stage(&mut outbound)?;
2065            }
2066        }
2067        Ok(state.update_with(outbound, Vec::new(), raw_transmit_result, operation_error))
2068    }
2069
2070    /// Invalidate all outstanding transactions for a disconnected transport.
2071    pub fn reset(&self) -> UlcpSessionUpdateRecord {
2072        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
2073        let generation = state.generation.wrapping_add(1);
2074        *state = UlcpSessionState {
2075            generation,
2076            mode: self.mode,
2077            lazy_inspection: self.lazy_inspection,
2078            ..UlcpSessionState::default()
2079        };
2080        state.update(Vec::new())
2081    }
2082
2083    /// Abandon raw transactions whose GATT writes were rejected locally.
2084    /// Their late correlated responses are ignored once; the attachment and
2085    /// all non-raw session state remain intact.
2086    pub fn abandon_raw_transmits(&self, transaction_ids: Vec<u8>) -> UlcpSessionUpdateRecord {
2087        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
2088        for tid in transaction_ids {
2089            if matches!(
2090                state.expected.get(&tid),
2091                Some(ExpectedResponse::RawTransmit)
2092            ) {
2093                state.expected.remove(&tid);
2094            }
2095        }
2096        state.update(Vec::new())
2097    }
2098}
2099
2100impl MobileUlcpSession {
2101    /// The shared body of the two bond commands: one payloadless frame,
2102    /// answered by one status, reported on the management completion.
2103    fn begin_ble_command(
2104        &self,
2105        encode: fn(u8) -> Result<Vec<u8>, MobileError>,
2106    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
2107        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
2108        state.begin_local_management()?;
2109        if !state.has_capability(cap::BLE)? {
2110            return Err(MobileError::UnsupportedCapability);
2111        }
2112        state.management = Some(LocalManagement::default());
2113        let tid = state.allocate_tid();
2114        state
2115            .expected
2116            .insert(tid, ExpectedResponse::ManagementCommand);
2117        let frame = encode(tid)?;
2118        Ok(state.update(vec![frame]))
2119    }
2120
2121    fn with_mode(mode: UlcpAttachMode) -> Self {
2122        Self {
2123            inner: Mutex::new(UlcpSessionState {
2124                mode,
2125                ..UlcpSessionState::default()
2126            }),
2127            mode,
2128            lazy_inspection: false,
2129        }
2130    }
2131
2132    /// Add one key to a device-domain public-key table.
2133    fn insert_device_key(
2134        &self,
2135        capability: u32,
2136        property: u32,
2137        public_key: Vec<u8>,
2138    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
2139        let public_key: [u8; 32] = public_key
2140            .try_into()
2141            .map_err(|_| MobileError::InvalidPublicKeyLength)?;
2142        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
2143        state.begin_device_domain_operation(capability)?;
2144        let tid = state.allocate_tid();
2145        state.expected.insert(
2146            tid,
2147            ExpectedResponse::DevKeyInsert {
2148                property,
2149                item: public_key.to_vec(),
2150            },
2151        );
2152        let frame = ulcp_prop_insert(tid, property, &public_key)?;
2153        Ok(state.update(vec![frame]))
2154    }
2155
2156    /// Take one key out of a device-domain public-key table.
2157    fn remove_device_key(
2158        &self,
2159        capability: u32,
2160        property: u32,
2161        public_key: Vec<u8>,
2162    ) -> Result<UlcpSessionUpdateRecord, MobileError> {
2163        let public_key: [u8; 32] = public_key
2164            .try_into()
2165            .map_err(|_| MobileError::InvalidPublicKeyLength)?;
2166        let mut state = self.inner.lock().expect("ULCP session mutex poisoned");
2167        state.begin_device_domain_operation(capability)?;
2168        let tid = state.allocate_tid();
2169        state.expected.insert(
2170            tid,
2171            ExpectedResponse::DevKeyRemove {
2172                property,
2173                item: public_key.to_vec(),
2174            },
2175        );
2176        let frame = ulcp_prop_remove(tid, property, &public_key)?;
2177        Ok(state.update(vec![frame]))
2178    }
2179}
2180
2181/// What a device-domain key table holds, for the operation names an error
2182/// carries.
2183fn dev_key_table_name(property: u32) -> &'static str {
2184    match property {
2185        prop::DEV_ADMINS => "administrator",
2186        _ => "peer",
2187    }
2188}
2189
2190impl UlcpSessionState {
2191    fn allocate_tid(&mut self) -> u8 {
2192        self.tids.allocate()
2193    }
2194
2195    fn get_property(&mut self, property: u32) -> Result<Vec<u8>, MobileError> {
2196        let tid = self.allocate_tid();
2197        self.expected
2198            .insert(tid, ExpectedResponse::Property(property));
2199        ulcp_prop_get(tid, property)
2200    }
2201
2202    fn phase(&self) -> UlcpSessionPhase {
2203        match self.stage {
2204            SessionStage::Idle => UlcpSessionPhase::Idle,
2205            SessionStage::Initial | SessionStage::Inspection | SessionStage::Saving => {
2206                UlcpSessionPhase::Synchronizing
2207            }
2208            // A refresh deliberately preserves the attached phase so live UI
2209            // does not disappear while fresh authoritative values are read.
2210            SessionStage::Refreshing => UlcpSessionPhase::Attached,
2211            SessionStage::AwaitingHost => UlcpSessionPhase::AwaitingHost,
2212            SessionStage::Claiming => UlcpSessionPhase::Claiming,
2213            SessionStage::Configuring | SessionStage::SavingConfiguration => {
2214                UlcpSessionPhase::Configuring
2215            }
2216            SessionStage::Attached => UlcpSessionPhase::Attached,
2217        }
2218    }
2219
2220    fn ownership(&self) -> UlcpHostOwnership {
2221        if self.host_key_unsupported {
2222            return UlcpHostOwnership::Unsupported;
2223        }
2224        let Some(radio_key) = self.radio_host_key.as_deref() else {
2225            return UlcpHostOwnership::Unknown;
2226        };
2227        if radio_key.is_empty() {
2228            return UlcpHostOwnership::Unclaimed;
2229        }
2230        match self.selected_host_key {
2231            None => UlcpHostOwnership::LocalIdentityUnavailable,
2232            Some(selected) if radio_key == selected => UlcpHostOwnership::Ours,
2233            Some(_) => UlcpHostOwnership::OtherHost,
2234        }
2235    }
2236
2237    fn update(&mut self, outbound_frames: Vec<Vec<u8>>) -> UlcpSessionUpdateRecord {
2238        self.update_with(outbound_frames, Vec::new(), None, None)
2239    }
2240
2241    fn update_with_received(
2242        &mut self,
2243        received_frames: Vec<UlcpReceivedFrameRecord>,
2244    ) -> UlcpSessionUpdateRecord {
2245        self.update_with(Vec::new(), received_frames, None, None)
2246    }
2247
2248    fn update_with(
2249        &mut self,
2250        outbound_frames: Vec<Vec<u8>>,
2251        received_frames: Vec<UlcpReceivedFrameRecord>,
2252        raw_transmit_result: Option<UlcpRawTransmitResultRecord>,
2253        operation_error: Option<UlcpOperationErrorRecord>,
2254    ) -> UlcpSessionUpdateRecord {
2255        let raw_transmit_pending = self
2256            .expected
2257            .values()
2258            .any(|expected| matches!(expected, ExpectedResponse::RawTransmit));
2259        UlcpSessionUpdateRecord {
2260            outbound_frames,
2261            received_frames,
2262            snapshot: UlcpSessionSnapshotRecord {
2263                generation: self.generation,
2264                phase: self.phase(),
2265                host_ownership: self.ownership(),
2266                device_key: self.device_key.clone(),
2267                device_name: self.device_name.clone(),
2268                // Taken, not cloned: reported once, on the update that
2269                // actually carries a new measurement.
2270                battery: self.battery.take(),
2271                alert: self.alert,
2272                time: self.time.take(),
2273                gnss: self.gnss.as_ref().map(gnss_record),
2274                provisioning: self.provisioning.clone(),
2275            },
2276            waiting_for_responses: !self.expected.is_empty(),
2277            raw_transmit_pending,
2278            raw_transmit_started_transaction_id: None,
2279            raw_transmit_result,
2280            operation_error,
2281            // Taken, like the battery: a completion is reported on the
2282            // one update that carries it.
2283            management_event: self.management_event.take(),
2284            pushed_properties: std::mem::take(&mut self.pushed_properties),
2285        }
2286    }
2287
2288    fn apply_property(&mut self, response: &UlcpPropertyFrameRecord) -> Result<(), MobileError> {
2289        if response.command != Cmd::PropIs as u8 {
2290            // Insert/remove notifications are valid protocol frames, but none
2291            // of the mobile snapshot fields are multi-value payloads.
2292            return Ok(());
2293        }
2294        match response.property_id {
2295            prop::DEV_KEY => {
2296                if response.value.is_empty() {
2297                    self.device_key = None;
2298                } else if response.value.len() == items::PUBLIC_KEY_LEN {
2299                    self.device_key = Some(response.value.clone());
2300                } else {
2301                    return Err(MobileError::InvalidUlcpFrame);
2302                }
2303            }
2304            prop::DEV_NAME => {
2305                let name = core::str::from_utf8(&response.value)
2306                    .map_err(|_| MobileError::InvalidUlcpFrame)?;
2307                self.device_name = (!name.is_empty()).then(|| name.to_owned());
2308            }
2309            prop::BATTERY => {
2310                self.battery = Some(inspect_ulcp_battery(response.value.clone())?);
2311            }
2312            prop::ALERT => {
2313                // Arrives both as the answer to a write and unsolicited,
2314                // when the radio ends the alert itself.
2315                self.alert = Some(inspect_ulcp_alert(response.value.clone())?);
2316            }
2317            prop::TIME => {
2318                // Announced when the clock goes from unknown to known and
2319                // whenever it steps, which is how a phone learns the
2320                // device found the time on its own.
2321                self.time = Some(UlcpTimeRecord {
2322                    epoch_seconds: decode_optional(&response.value, decode_u32)?,
2323                });
2324            }
2325            key if umsh_ulcp::gnss::is_positioning_property(key) => {
2326                // Read or announced, indifferently: a device is meant to
2327                // announce only the fix indicator and leave a position to
2328                // be sampled, but one that volunteers a position anyway is
2329                // carrying the value a read would have returned, so this
2330                // takes it. Folding rather than replacing is what lets a
2331                // single property arrive without erasing the others.
2332                self.gnss
2333                    .get_or_insert(GnssSnapshot::SEARCHING)
2334                    .absorb(key, &response.value)
2335                    .map_err(|_| MobileError::InvalidUlcpFrame)?;
2336            }
2337            prop::HOST_KEY => {
2338                if !response.value.is_empty() && response.value.len() != items::PUBLIC_KEY_LEN {
2339                    return Err(MobileError::InvalidUlcpFrame);
2340                }
2341                self.radio_host_key = Some(response.value.clone());
2342            }
2343            _ => {}
2344        }
2345        Ok(())
2346    }
2347
2348    /// Whether synchronization may proceed straight to inspection without
2349    /// pausing for the user to decide about host ownership.
2350    ///
2351    /// A tethered session must pause: it is about to become the radio's
2352    /// host, and taking a radio from another phone is a decision only the
2353    /// user can make. An administrative session never claims anything, so
2354    /// there is no decision to pause for — whose radio this is stays worth
2355    /// reporting, but only as information.
2356    fn attaches_without_host_decision(&self) -> bool {
2357        self.mode == UlcpAttachMode::Administrative
2358            || matches!(
2359                self.ownership(),
2360                UlcpHostOwnership::Ours | UlcpHostOwnership::Unsupported
2361            )
2362    }
2363
2364    /// Gate a device-domain mutation: attached, no other operation in
2365    /// flight, and the device advertising whatever capability puts the
2366    /// table being written within reach.
2367    fn begin_device_domain_operation(&mut self, capability: u32) -> Result<(), MobileError> {
2368        if self.stage != SessionStage::Attached || !self.expected.is_empty() {
2369            return Err(MobileError::InvalidUlcpFrame);
2370        }
2371        if !self.has_capability(capability)? {
2372            return Err(MobileError::InvalidUlcpFrame);
2373        }
2374        Ok(())
2375    }
2376
2377    /// Gate a local management operation: attached, nothing else in
2378    /// flight. One operation at a time is the same discipline the mesh
2379    /// path enforces, and what lets a completion be attributed to the one
2380    /// operation that could have produced it.
2381    ///
2382    /// Raw PHY transmissions are not "something else": a tethered radio
2383    /// carries mesh traffic continuously, each exchange is matched by its
2384    /// own transaction, and a settings screen that could only work on a
2385    /// quiet mesh would rarely work at all.
2386    fn begin_local_management(&mut self) -> Result<(), MobileError> {
2387        if self.stage != SessionStage::Attached || self.management.is_some() {
2388            return Err(MobileError::InvalidUlcpFrame);
2389        }
2390        let busy = self
2391            .expected
2392            .values()
2393            .any(|expected| !matches!(expected, ExpectedResponse::RawTransmit));
2394        if busy {
2395            return Err(MobileError::InvalidUlcpFrame);
2396        }
2397        Ok(())
2398    }
2399
2400    /// A transaction identifier no outstanding exchange is using.
2401    ///
2402    /// The allocator cycles blindly, which is safe for the staged bulk
2403    /// reads — they only run with nothing outstanding — but a management
2404    /// round can coexist with a live one-off like an alert write, and
2405    /// must not reuse its identifier.
2406    fn allocate_management_tid(&mut self) -> Result<u8, MobileError> {
2407        for _ in 0..usize::from(frame::TID_MAX) {
2408            let tid = self.tids.allocate();
2409            if !self.expected.contains_key(&tid) {
2410                return Ok(tid);
2411            }
2412        }
2413        Err(MobileError::InvalidUlcpFrame)
2414    }
2415
2416    /// Record one answer for the local management operation in flight.
2417    fn record_management_answer(&mut self, answer: MobileMeshManagementAnswerRecord) {
2418        if let Some(op) = self.management.as_mut() {
2419            op.answers.push(answer);
2420        }
2421    }
2422
2423    /// Issue the next round of the local management operation, or complete
2424    /// it. Called at the start of the operation and each time one of its
2425    /// answers arrives; does nothing while any of them remain outstanding.
2426    fn continue_local_management(
2427        &mut self,
2428        outbound: &mut Vec<Vec<u8>>,
2429    ) -> Result<(), MobileError> {
2430        if self.expected.values().any(ExpectedResponse::is_management) {
2431            return Ok(());
2432        }
2433        let Some(mut op) = self.management.take() else {
2434            return Ok(());
2435        };
2436        if let Some((property, value)) = op.write_queue.pop_front() {
2437            // One write at a time: the plan's order is load-bearing (the
2438            // PHY bracket), and a device applies what it is asked in the
2439            // order asked only if it is asked in that order.
2440            let tid = self.allocate_management_tid()?;
2441            self.expected
2442                .insert(tid, ExpectedResponse::ManagementSet(property));
2443            outbound.push(ulcp_prop_set(tid, property, value)?);
2444            self.management = Some(op);
2445        } else if !op.fetch_queue.is_empty() {
2446            let budget = usize::from(frame::TID_MAX).saturating_sub(self.expected.len());
2447            for _ in 0..budget {
2448                let Some(property) = op.fetch_queue.pop_front() else {
2449                    break;
2450                };
2451                let tid = self.allocate_management_tid()?;
2452                self.expected
2453                    .insert(tid, ExpectedResponse::ManagementGet(property));
2454                outbound.push(ulcp_prop_get(tid, property)?);
2455            }
2456            self.management = Some(op);
2457        } else {
2458            self.management_event = Some(UlcpLocalManagementEventRecord {
2459                answers: op.answers,
2460                status_code: op.save_status,
2461            });
2462            self.refresh_attached_snapshot(None)?;
2463        }
2464        Ok(())
2465    }
2466
2467    /// Queue the next host channel-key insert, if any remain. The cached
2468    /// digest is updated as each key is accepted.
2469    fn next_host_channel_insert(&mut self, mut remaining: VecDeque<Vec<u8>>) -> Option<Vec<u8>> {
2470        let key = remaining.pop_front()?;
2471        let tid = self.allocate_tid();
2472        let frame = ulcp_prop_insert(tid, prop::HOST_CHANNEL_KEYS, &key).ok()?;
2473        self.expected
2474            .insert(tid, ExpectedResponse::HostChannelInsert(remaining));
2475        if let Ok(id) = dev_channel_id(&key) {
2476            self.push_host_channel_id(&id);
2477        }
2478        Some(frame)
2479    }
2480
2481    /// Replace the cached `PROP_HOST_CHANNEL_KEYS` digest wholesale.
2482    fn set_host_channel_ids(&mut self, ids: &[Vec<u8>]) {
2483        let value = ids.concat();
2484        self.host_channel_entry().value = value;
2485    }
2486
2487    fn push_host_channel_id(&mut self, id: &[u8]) {
2488        let entry = self.host_channel_entry();
2489        if !entry.value.chunks(items::CHANNEL_ID_LEN).any(|c| c == id) {
2490            entry.value.extend_from_slice(id);
2491        }
2492    }
2493
2494    fn host_channel_entry(&mut self) -> &mut UlcpPropertyFrameRecord {
2495        self.responses
2496            .entry(prop::HOST_CHANNEL_KEYS)
2497            .or_insert_with(|| UlcpPropertyFrameRecord {
2498                transaction_id: frame::TID_UNSOLICITED,
2499                command: Cmd::PropIs as u8,
2500                property_id: prop::HOST_CHANNEL_KEYS,
2501                value: Vec::new(),
2502            })
2503    }
2504
2505    /// Patch the cached `PROP_DEV_CHANNEL_KEYS` digest after a confirmed
2506    /// mutation. The cached value is a list of derived identifiers, so this
2507    /// tracks identifiers rather than key material.
2508    fn patch_dev_channels(&mut self, id: &[u8], present: bool) {
2509        let entry = self
2510            .responses
2511            .entry(prop::DEV_CHANNEL_KEYS)
2512            .or_insert_with(|| UlcpPropertyFrameRecord {
2513                transaction_id: frame::TID_UNSOLICITED,
2514                command: Cmd::PropIs as u8,
2515                property_id: prop::DEV_CHANNEL_KEYS,
2516                value: Vec::new(),
2517            });
2518        let mut value = Vec::with_capacity(entry.value.len() + id.len());
2519        let mut found = false;
2520        for chunk in entry.value.chunks(items::CHANNEL_ID_LEN) {
2521            if chunk == id {
2522                found = true;
2523                if !present {
2524                    continue;
2525                }
2526            }
2527            value.extend_from_slice(chunk);
2528        }
2529        if present && !found {
2530            value.extend_from_slice(id);
2531        }
2532        entry.value = value;
2533    }
2534
2535    /// Patch a cached device-domain public-key table after a confirmed
2536    /// mutation, keeping it lossless without a round-trip re-read.
2537    fn patch_dev_keys(&mut self, property: u32, key: &[u8], present: bool) {
2538        let entry = self
2539            .responses
2540            .entry(property)
2541            .or_insert_with(|| UlcpPropertyFrameRecord {
2542                transaction_id: frame::TID_UNSOLICITED,
2543                command: Cmd::PropIs as u8,
2544                property_id: property,
2545                value: Vec::new(),
2546            });
2547        let mut value = Vec::with_capacity(entry.value.len() + key.len());
2548        let mut found = false;
2549        for chunk in entry.value.chunks(items::PUBLIC_KEY_LEN) {
2550            if chunk == key {
2551                found = true;
2552                if !present {
2553                    continue;
2554                }
2555            }
2556            value.extend_from_slice(chunk);
2557        }
2558        if present && !found {
2559            value.extend_from_slice(key);
2560        }
2561        entry.value = value;
2562    }
2563
2564    fn has_capability(&self, capability: u32) -> Result<bool, MobileError> {
2565        let capabilities = self
2566            .responses
2567            .get(&prop::CAPS)
2568            .ok_or(MobileError::InvalidUlcpFrame)?;
2569        Ok(decode_capabilities(&capabilities.value)?.contains(&capability))
2570    }
2571
2572    fn writable(&self, values: Vec<(u32, Vec<u8>)>) -> VecDeque<(u32, Vec<u8>)> {
2573        let unreadable = self
2574            .provisioning
2575            .as_ref()
2576            .map(|sync| sync.unreadable_properties.as_slice())
2577            .unwrap_or_default();
2578        writable(values, unreadable).into()
2579    }
2580
2581    fn advance_completed_stage(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2582        match self.stage {
2583            SessionStage::Initial => {
2584                let version = self
2585                    .responses
2586                    .get(&prop::PROTOCOL_VERSION)
2587                    .ok_or(MobileError::InvalidUlcpFrame)?;
2588                if version.value != [PROTOCOL_MAJOR_VERSION, PROTOCOL_MINOR_VERSION] {
2589                    return Err(MobileError::InvalidUlcpFrame);
2590                }
2591                let capabilities = self
2592                    .responses
2593                    .get(&prop::CAPS)
2594                    .ok_or(MobileError::InvalidUlcpFrame)?;
2595                self.inspection_queue = if self.lazy_inspection {
2596                    // Only what the sync reduction insists on: the
2597                    // interface check and the radio basics every device
2598                    // has. The rest is read on demand, which is the whole
2599                    // point of a lazy session.
2600                    VecDeque::from(vec![
2601                        prop::INTERFACE_TYPE,
2602                        prop::PHY_ENABLED,
2603                        prop::PHY_FREQ,
2604                        prop::PHY_TX_POWER,
2605                    ])
2606                } else {
2607                    ulcp_inspection_properties(capabilities.value.clone())?.into()
2608                };
2609                let advertises_host_filter = self.has_capability(cap::HOST_FILTER)?;
2610                if advertises_host_filter == self.host_key_unsupported {
2611                    return Err(MobileError::InvalidUlcpFrame);
2612                }
2613                if self.attaches_without_host_decision() {
2614                    self.start_inspection(outbound)?;
2615                } else {
2616                    self.stage = SessionStage::AwaitingHost;
2617                }
2618            }
2619            SessionStage::Inspection => self.start_inspection(outbound)?,
2620            SessionStage::Refreshing => self.start_refresh(outbound)?,
2621            SessionStage::Configuring => {
2622                if !self.configuration_queue.is_empty() {
2623                    self.start_configuration(outbound)?;
2624                } else if self.has_capability(cap::SAVE)? {
2625                    self.stage = SessionStage::SavingConfiguration;
2626                    let tid = self.allocate_tid();
2627                    self.expected
2628                        .insert(tid, ExpectedResponse::SaveConfiguration);
2629                    outbound.push(ulcp_save(tid)?);
2630                } else {
2631                    self.finish_configuration()?;
2632                }
2633            }
2634            SessionStage::Claiming
2635            | SessionStage::Saving
2636            | SessionStage::AwaitingHost
2637            | SessionStage::Attached
2638            | SessionStage::SavingConfiguration
2639            | SessionStage::Idle => {}
2640        }
2641        Ok(())
2642    }
2643
2644    /// Abort only the failed operation stage. A correlated CRP status error
2645    /// never invalidates GATT framing and therefore never resets the session.
2646    fn recover_from_operation_failure(
2647        &mut self,
2648        outbound: &mut Vec<Vec<u8>>,
2649    ) -> Result<(), MobileError> {
2650        self.configuration_queue.clear();
2651        self.inspection_queue.clear();
2652        match self.stage {
2653            SessionStage::Claiming => self.stage = SessionStage::AwaitingHost,
2654            SessionStage::Saving => {
2655                // The host-key write succeeded even if persistence did not.
2656                // Continue attaching while reporting that SAVE failed.
2657                self.start_inspection(outbound)?;
2658            }
2659            SessionStage::Refreshing
2660            | SessionStage::Configuring
2661            | SessionStage::SavingConfiguration => {
2662                // Retain the last authoritative snapshot. Property echoes that
2663                // completed before the failed operation remain available for
2664                // the next explicit refresh.
2665                self.stage = SessionStage::Attached;
2666            }
2667            SessionStage::Inspection if self.provisioning.is_some() => {
2668                self.stage = SessionStage::Attached;
2669            }
2670            SessionStage::Initial | SessionStage::Inspection => {
2671                // The transport is healthy but the initial snapshot is not
2672                // trustworthy enough to attach. Stay connected and report the
2673                // operation error; reconnect/refresh may retry synchronization.
2674                self.stage = SessionStage::Initial;
2675            }
2676            SessionStage::Attached | SessionStage::AwaitingHost | SessionStage::Idle => {}
2677        }
2678        Ok(())
2679    }
2680
2681    fn finish_configuration(&mut self) -> Result<(), MobileError> {
2682        let responses = self.responses.values().cloned().collect();
2683        self.provisioning = Some(inspect_ulcp_sync(responses)?);
2684        self.stage = SessionStage::Attached;
2685        Ok(())
2686    }
2687
2688    fn start_configuration(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2689        self.stage = SessionStage::Configuring;
2690        for _ in 0..usize::from(frame::TID_MAX) {
2691            let Some((property, value)) = self.configuration_queue.pop_front() else {
2692                break;
2693            };
2694            let tid = self.allocate_tid();
2695            self.expected
2696                .insert(tid, ExpectedResponse::ConfigurationProperty(property));
2697            outbound.push(ulcp_prop_set(tid, property, value)?);
2698        }
2699        Ok(())
2700    }
2701
2702    fn start_inspection(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2703        self.stage = SessionStage::Inspection;
2704        if self.inspection_queue.is_empty() {
2705            let responses = self.responses.values().cloned().collect();
2706            self.provisioning = Some(inspect_ulcp_sync(responses)?);
2707            self.stage = SessionStage::Attached;
2708            return Ok(());
2709        }
2710        for _ in 0..usize::from(frame::TID_MAX) {
2711            let Some(property) = self.inspection_queue.pop_front() else {
2712                break;
2713            };
2714            outbound.push(self.get_property(property)?);
2715        }
2716        Ok(())
2717    }
2718
2719    fn start_refresh(&mut self, outbound: &mut Vec<Vec<u8>>) -> Result<(), MobileError> {
2720        self.stage = SessionStage::Refreshing;
2721        if self.inspection_queue.is_empty() {
2722            let responses = self.responses.values().cloned().collect();
2723            self.provisioning = Some(inspect_ulcp_sync(responses)?);
2724            self.stage = SessionStage::Attached;
2725            return Ok(());
2726        }
2727        for _ in 0..usize::from(frame::TID_MAX) {
2728            let Some(property) = self.inspection_queue.pop_front() else {
2729                break;
2730            };
2731            outbound.push(self.get_property(property)?);
2732        }
2733        Ok(())
2734    }
2735
2736    /// Recompute the attached provisioning snapshot after device state
2737    /// changed under an established session.
2738    ///
2739    /// `changed_property` is the property the triggering notification
2740    /// carried, or `None` for a change this session made itself.
2741    ///
2742    /// Re-opening the host decision is deliberately limited to a
2743    /// `PROP_HOST_KEY` change. Another phone claiming the radio out from
2744    /// under an attached session is a question only the user can answer,
2745    /// so that case still returns to the host prompt. Every *other*
2746    /// published value is news, not a decision: a session attached to a
2747    /// radio owned by another identity (a tethered claim that did not
2748    /// take, which attaches deliberately — see the `Claim` arm) would
2749    /// otherwise be thrown back to the prompt by any unsolicited update at
2750    /// all. `PROP_BATTERY` makes that concrete, being the one notification
2751    /// that arrives on its own schedule for the life of the session.
2752    fn refresh_attached_snapshot(
2753        &mut self,
2754        changed_property: Option<u32>,
2755    ) -> Result<(), MobileError> {
2756        if self.stage != SessionStage::Attached {
2757            return Ok(());
2758        }
2759        let responses = self.responses.values().cloned().collect();
2760        self.provisioning = Some(inspect_ulcp_sync(responses)?);
2761        if changed_property == Some(prop::HOST_KEY) && !self.attaches_without_host_decision() {
2762            self.stage = SessionStage::AwaitingHost;
2763        }
2764        Ok(())
2765    }
2766}
2767
2768/// Return the authoritative properties needed for the read-only post-attach
2769/// inspection, gated by the supplied `PROP_CAPS` value.
2770#[uniffi::export]
2771pub fn ulcp_inspection_properties(capabilities: Vec<u8>) -> Result<Vec<u32>, MobileError> {
2772    let capabilities = decode_capabilities(&capabilities)?;
2773    validate_capability_dependencies(&capabilities)?;
2774    let has = |capability| capabilities.contains(&capability);
2775
2776    let mut properties = vec![
2777        prop::INTERFACE_TYPE,
2778        prop::PHY_ENABLED,
2779        prop::PHY_FREQ,
2780        prop::PHY_TX_POWER,
2781    ];
2782    if has(cap::PHY_LORA) {
2783        properties.extend([prop::PHY_LORA_BW, prop::PHY_LORA_SF, prop::PHY_LORA_CR]);
2784    }
2785    if has(cap::PHY_DUTY_LIMIT) {
2786        properties.extend([prop::PHY_DUTY_NOW, prop::PHY_DUTY_LIMIT]);
2787    }
2788    if has(cap::SAVE) {
2789        properties.push(prop::SAVED);
2790    }
2791    if has(cap::HOST_FILTER) {
2792        properties.push(prop::HOST_RX_FILTERS);
2793    }
2794    if has(cap::HOST_KEYS) {
2795        properties.extend([prop::HOST_CHANNEL_KEYS, prop::HOST_PEER_KEYS]);
2796    }
2797    if has(cap::HOST_RX_QUEUE) {
2798        properties.extend([prop::HOST_RX_QUEUE_COUNT, prop::HOST_RX_QUEUE_DROPPED]);
2799    }
2800    if has(cap::HOST_AUTO_ACK) {
2801        properties.push(prop::HOST_AUTO_ACK);
2802    }
2803    if has(cap::REPEATER) {
2804        properties.extend([
2805            prop::MAC_REPEATER_ENABLED,
2806            prop::MAC_REPEATER_REGIONS,
2807            prop::MAC_REPEATER_DEFAULT_REGION,
2808            prop::MAC_REPEATER_MIN_RSSI,
2809            prop::MAC_REPEATER_MIN_SNR,
2810        ]);
2811    }
2812    if has(cap::IDENT) {
2813        properties.extend([
2814            prop::IDENT_ROLE,
2815            prop::IDENT_MOBILE,
2816            // Where the device says it is. Read at attach because it is
2817            // the position a region proposal starts from, and a phone at
2818            // a bench cannot ask for it separately: the local link's
2819            // snapshot is the whole of what a setup sheet knows.
2820            prop::IDENT_LOCATION,
2821            prop::IDENT_ALTITUDE,
2822        ]);
2823    }
2824    if has(cap::DEV_IDENTITY) {
2825        properties.extend([
2826            prop::DEV_PEERS,
2827            prop::DEV_CHANNEL_KEYS,
2828            prop::DEV_DISCOVERABLE,
2829        ]);
2830    }
2831    if has(cap::ADMIN) {
2832        properties.push(prop::DEV_ADMINS);
2833    }
2834    if has(cap::ALERT) {
2835        // Read at attach so a phone reconnecting mid-search finds the
2836        // alert it left running rather than a stale "off".
2837        properties.push(prop::ALERT);
2838    }
2839    if has(cap::TIME) {
2840        // The clock is live rather than configuration, but it is read
2841        // here for the same reason the alert is: a phone that just
2842        // attached should know whether the device knows the time, not
2843        // wait for the next announcement to find out.
2844        properties.extend([prop::TIME, prop::TZ_OFFSET]);
2845    }
2846    if has(cap::GNSS) {
2847        properties.extend([
2848            prop::GNSS_ENABLED,
2849            prop::GNSS_LOCATION,
2850            prop::GNSS_ALTITUDE,
2851            prop::GNSS_FIX,
2852            prop::GNSS_PRECISION,
2853            prop::GNSS_SATELLITES,
2854            prop::GNSS_IDENT_UPDATE,
2855            prop::GNSS_IDENT_PRECISION,
2856            prop::GNSS_TIME_TRUST,
2857        ]);
2858    }
2859    if has(cap::ADVERT) {
2860        properties.extend([
2861            prop::ADVERT_INTERVAL,
2862            prop::BEACON_INTERVAL,
2863            prop::STARTUP_BEACON,
2864        ]);
2865    }
2866    Ok(properties)
2867}
2868
2869pub(crate) fn ulcp_refresh_properties(capabilities: Vec<u8>) -> Result<Vec<u32>, MobileError> {
2870    let decoded = decode_capabilities(&capabilities)?;
2871    validate_capability_dependencies(&decoded)?;
2872    let has = |capability| decoded.contains(&capability);
2873    let mut properties = Vec::new();
2874    if has(cap::DEV_IDENTITY) {
2875        properties.push(prop::DEV_KEY);
2876    }
2877    if has(cap::DEV_NAME) {
2878        properties.push(prop::DEV_NAME);
2879    }
2880    if has(cap::BATTERY) {
2881        properties.push(prop::BATTERY);
2882    }
2883    if has(cap::HOST_FILTER) {
2884        properties.push(prop::HOST_KEY);
2885    }
2886    properties.extend(ulcp_inspection_properties(capabilities)?);
2887    Ok(properties)
2888}
2889
2890/// Validate and reduce the property responses from the read-only post-attach
2891/// inspection.
2892///
2893/// The four properties every ULCP device must answer — the interface type
2894/// and the live PHY triple — are required: without them there is no radio
2895/// to describe. Everything else is capability-gated and merely *expected*,
2896/// so a device that refuses one, or answers it with something undecodable,
2897/// yields a snapshot with that setting absent and named in
2898/// `unreadable_properties` rather than no snapshot at all.
2899#[uniffi::export]
2900pub fn inspect_ulcp_sync(
2901    responses: Vec<UlcpPropertyFrameRecord>,
2902) -> Result<UlcpSyncRecord, MobileError> {
2903    let value = |key| property_value(&responses, key);
2904    let capabilities = decode_capabilities(value(prop::CAPS)?)?;
2905    validate_capability_dependencies(&capabilities)?;
2906    let has = |capability| capabilities.contains(&capability);
2907
2908    let interface = decode_exact_pui(value(prop::INTERFACE_TYPE)?)?;
2909    if interface != INTERFACE_TYPE {
2910        return Err(MobileError::InvalidUlcpFrame);
2911    }
2912    let phy_enabled = decode_bool(value(prop::PHY_ENABLED)?)?;
2913    let frequency_khz = decode_u32(value(prop::PHY_FREQ)?)?;
2914    let transmit_power_dbm = decode_i8(value(prop::PHY_TX_POWER)?)?;
2915
2916    let mut expected = ExpectedProperties {
2917        responses: &responses,
2918        unreadable: Vec::new(),
2919    };
2920    let lora = has(cap::PHY_LORA);
2921    let bandwidth_hz = expected.read(lora, prop::PHY_LORA_BW, decode_u32);
2922    let spreading_factor = expected.read(lora, prop::PHY_LORA_SF, decode_u8);
2923    let coding_rate_denom = expected.read(lora, prop::PHY_LORA_CR, decode_u8);
2924    let duty = has(cap::PHY_DUTY_LIMIT);
2925    let duty_cycle_now = expected.read(duty, prop::PHY_DUTY_NOW, decode_u16);
2926    let duty_cycle_limit = expected.read(duty, prop::PHY_DUTY_LIMIT, decode_u16);
2927    let saved = expected.read(has(cap::SAVE), prop::SAVED, decode_saved);
2928    let queue = has(cap::HOST_RX_QUEUE);
2929    let queued_frames = expected.read(queue, prop::HOST_RX_QUEUE_COUNT, decode_u16);
2930    let dropped_frames = expected.read(queue, prop::HOST_RX_QUEUE_DROPPED, decode_u32);
2931    let filter_count = expected.read(
2932        has(cap::HOST_FILTER),
2933        prop::HOST_RX_FILTERS,
2934        decode_filter_count,
2935    );
2936    let host_keys = has(cap::HOST_KEYS);
2937    let host_channel_count = expected.read(
2938        host_keys,
2939        prop::HOST_CHANNEL_KEYS,
2940        decode_fixed_count::<{ items::CHANNEL_ID_LEN }>,
2941    );
2942    let host_peer_count = expected.read(
2943        host_keys,
2944        prop::HOST_PEER_KEYS,
2945        decode_fixed_count::<{ items::PUBLIC_KEY_LEN }>,
2946    );
2947    let auto_ack = expected.read(has(cap::HOST_AUTO_ACK), prop::HOST_AUTO_ACK, decode_bool);
2948    let dev_identity = has(cap::DEV_IDENTITY);
2949    let dev_peer_keys = expected.read(
2950        dev_identity,
2951        prop::DEV_PEERS,
2952        decode_fixed_list::<{ items::PUBLIC_KEY_LEN }>,
2953    );
2954    let dev_channel_ids = expected.read(
2955        dev_identity,
2956        prop::DEV_CHANNEL_KEYS,
2957        decode_fixed_list::<{ items::CHANNEL_ID_LEN }>,
2958    );
2959    let manageable = has(cap::ADMIN);
2960    let dev_admin_keys = expected.read(
2961        manageable,
2962        prop::DEV_ADMINS,
2963        decode_fixed_list::<{ items::PUBLIC_KEY_LEN }>,
2964    );
2965    // Read here as well as onto the session snapshot, because a phone
2966    // reading a device across the mesh has no session with it and this
2967    // record is the whole of what it learned.
2968    let device_name = expected
2969        .read(has(cap::DEV_NAME), prop::DEV_NAME, decode_device_name)
2970        .flatten();
2971
2972    // Readings, not settings, so they go around `expected`: a device that
2973    // has not reported its battery yet has not withheld a setting, and
2974    // saying it had would put "its battery" in a notice about configuration
2975    // this phone is about to write over. On the local link neither is even
2976    // asked for at attach — the battery arrives unsolicited — while a
2977    // reading across the mesh asks for both and gets an answer or none.
2978    let battery = reported_value(&responses, has(cap::BATTERY), prop::BATTERY, |value| {
2979        inspect_ulcp_battery(value.to_vec())
2980    });
2981    let alert = reported_value(&responses, has(cap::ALERT), prop::ALERT, |value| {
2982        inspect_ulcp_alert(value.to_vec())
2983    });
2984
2985    // Every part of the policy is read before any of it is required, so one
2986    // unreadable property does not hide the others behind it.
2987    let forwards = has(cap::REPEATER);
2988    let repeater_enabled = expected.read(forwards, prop::MAC_REPEATER_ENABLED, decode_bool);
2989    let regions = expected.read(forwards, prop::MAC_REPEATER_REGIONS, decode_region_list);
2990    let default_region = expected.read(
2991        forwards,
2992        prop::MAC_REPEATER_DEFAULT_REGION,
2993        decode_optional_region,
2994    );
2995    let min_rssi_dbm = expected.read(forwards, prop::MAC_REPEATER_MIN_RSSI, |value| {
2996        decode_optional(value, decode_i16)
2997    });
2998    let min_snr_db = expected.read(forwards, prop::MAC_REPEATER_MIN_SNR, |value| {
2999        decode_optional(value, decode_i8)
3000    });
3001    let repeater = (|| {
3002        Some(UlcpRepeaterSettingsRecord {
3003            enabled: repeater_enabled?,
3004            regions: regions?,
3005            default_region: default_region?,
3006            min_rssi_dbm: min_rssi_dbm?,
3007            min_snr_db: min_snr_db?,
3008        })
3009    })();
3010
3011    // An empty PROP_IDENT_ROLE is the device saying it derives its own
3012    // role, which is the same `None` a device without CAP_IDENT reports.
3013    let ident = has(cap::IDENT);
3014    let ident_role = expected
3015        .read(ident, prop::IDENT_ROLE, |value| {
3016            decode_optional(value, decode_u8)
3017        })
3018        .flatten();
3019    let ident_mobile = expected.read(ident, prop::IDENT_MOBILE, decode_bool);
3020    // Read as a pair, like the policies above: the cell and the height
3021    // are one statement of where the device is, and a device keeping its
3022    // own position refuses both together.
3023    let ident_location = expected.read(ident, prop::IDENT_LOCATION, |value| {
3024        Ok::<Vec<u8>, MobileError>(value.to_vec())
3025    });
3026    let ident_altitude = expected.read(ident, prop::IDENT_ALTITUDE, decode_optional_altitude);
3027    let ident_position = (|| {
3028        let location = ident_location?;
3029        let placed = (!location.is_empty()).then(|| NodeLocation::from_bytes(&location).center());
3030        Some(UlcpIdentPositionRecord {
3031            latitude_deg: placed.map(|(latitude, _)| latitude.into()),
3032            longitude_deg: placed.map(|(_, longitude)| longitude.into()),
3033            cell_meters: (!location.is_empty())
3034                .then(|| ulcp_location_cell_meters(location.len() as u8))
3035                .flatten(),
3036            altitude_m: ident_altitude?,
3037            location,
3038        })
3039    })();
3040    let dev_discoverable = expected.read(dev_identity, prop::DEV_DISCOVERABLE, decode_bool);
3041
3042    let tz_offset_min = expected.read(has(cap::TIME), prop::TZ_OFFSET, decode_i16);
3043
3044    // Read whole, like the forwarding policy above and for the same
3045    // reason: this is written as a set.
3046    let positioning = has(cap::GNSS);
3047    let gnss_enabled = expected.read(positioning, prop::GNSS_ENABLED, decode_bool);
3048    let ident_update = expected.read(positioning, prop::GNSS_IDENT_UPDATE, decode_bool);
3049    let ident_precision = expected.read(positioning, prop::GNSS_IDENT_PRECISION, decode_precision);
3050    let time_trust = expected.read(positioning, prop::GNSS_TIME_TRUST, decode_bool);
3051    let gnss = (|| {
3052        Some(UlcpGnssSettingsRecord {
3053            enabled: gnss_enabled?,
3054            ident_update: ident_update?,
3055            ident_precision: ident_precision?,
3056            time_trust: time_trust?,
3057        })
3058    })();
3059
3060    // Also read whole: the two schedules together are how much airtime
3061    // this device claims, which is one decision.
3062    let announces = has(cap::ADVERT);
3063    let advert_interval = expected.read(announces, prop::ADVERT_INTERVAL, decode_u32);
3064    let beacon_interval = expected.read(announces, prop::BEACON_INTERVAL, decode_u32);
3065    let startup_beacon = expected.read(announces, prop::STARTUP_BEACON, decode_bool);
3066    let advert = (|| {
3067        Some(UlcpAdvertSettingsRecord {
3068            advert_interval_seconds: advert_interval?,
3069            beacon_interval_seconds: beacon_interval?,
3070            startup_beacon: startup_beacon?,
3071        })
3072    })();
3073
3074    let mut unreadable_properties = expected.unreadable;
3075    unreadable_properties.sort_unstable();
3076
3077    Ok(UlcpSyncRecord {
3078        capability_count: capabilities
3079            .len()
3080            .try_into()
3081            .map_err(|_| MobileError::InvalidUlcpFrame)?,
3082        has_host_filtering: has(cap::HOST_FILTER),
3083        supports_offline_queue: has(cap::HOST_RX_QUEUE),
3084        supports_delegated_ack: has(cap::HOST_AUTO_ACK),
3085        supports_device_name: has(cap::DEV_NAME),
3086        device_name,
3087        supports_lora: has(cap::PHY_LORA),
3088        supports_duty_cycle_limit: has(cap::PHY_DUTY_LIMIT),
3089        supports_battery: has(cap::BATTERY),
3090        battery,
3091        supports_repeater: has(cap::REPEATER),
3092        supports_ident: has(cap::IDENT),
3093        supports_device_identity: has(cap::DEV_IDENTITY),
3094        supports_time: has(cap::TIME),
3095        supports_gnss: positioning,
3096        supports_advert: announces,
3097        supports_admin: manageable,
3098        supports_alert: has(cap::ALERT),
3099        supports_reboot: has(cap::REBOOT),
3100        alert,
3101        phy_enabled,
3102        frequency_khz,
3103        transmit_power_dbm,
3104        bandwidth_hz,
3105        spreading_factor,
3106        coding_rate_denom,
3107        duty_cycle_now,
3108        duty_cycle_limit,
3109        saved,
3110        queued_frames,
3111        dropped_frames,
3112        filter_count,
3113        host_channel_count,
3114        host_peer_count,
3115        auto_ack,
3116        repeater,
3117        dev_peer_keys,
3118        dev_admin_keys,
3119        dev_channel_ids,
3120        ident_role,
3121        ident_mobile,
3122        ident_position,
3123        dev_discoverable,
3124        tz_offset_min,
3125        gnss,
3126        advert,
3127        unreadable_properties,
3128    })
3129}
3130
3131// ─── Managing one device, a screen at a time ─────────────────────────────
3132
3133/// One screenful of a device's settings.
3134///
3135/// Reading a device whole costs tens of properties and several round
3136/// trips, which over a link a few hops deep is the difference between a
3137/// screen that opens and one that is waited on. A category is what one
3138/// screen shows, and asking for exactly that is normally one exchange.
3139#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
3140pub enum UlcpManageCategory {
3141    /// What the battery reports. Read-only.
3142    Power,
3143    /// The radio: frequency, power, the modem profile, the duty ledger.
3144    Radio,
3145    /// Traffic counters, measured duty cycle, and uptime.
3146    Statistics,
3147    /// What the device advertises about itself, including where it is.
3148    Identity,
3149    /// The receiver, and what it currently sees. The fix is read-only.
3150    Gnss,
3151    /// The wall clock: what time the device holds, where it is meant to
3152    /// be, and whether the receiver may set the clock.
3153    Time,
3154    /// Bluetooth: whether the device can be reached over it, how many
3155    /// hosts are paired, and the two bond commands.
3156    Bluetooth,
3157    /// The forwarding policy.
3158    Repeater,
3159    /// Who this device talks to, and who may manage it.
3160    PeerNodes,
3161}
3162
3163/// The property numbers the management screens name.
3164///
3165/// A screen has to say which fields the operator edited, and it caches
3166/// values under the number the device answered for, so the numbers cross
3167/// the boundary whether or not anyone likes it. Handing them over once,
3168/// from the same constants everything else here is built on, is what
3169/// keeps a second copy from being written down somewhere in Swift.
3170#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Record)]
3171pub struct UlcpManagedPropertyIds {
3172    pub caps: u32,
3173    pub device_version: u32,
3174    pub device_model: u32,
3175    pub device_name: u32,
3176    pub battery: u32,
3177    pub phy_enabled: u32,
3178    pub frequency: u32,
3179    pub transmit_power: u32,
3180    pub lora_bandwidth: u32,
3181    pub lora_spreading_factor: u32,
3182    pub lora_coding_rate: u32,
3183    pub duty_cycle_now: u32,
3184    pub duty_cycle_limit: u32,
3185    pub stat_tx_packets: u32,
3186    pub stat_tx_channel_busy: u32,
3187    pub stat_rx_packets: u32,
3188    pub stat_rx_bad_crc: u32,
3189    pub stat_rx_non_umsh: u32,
3190    pub stat_rx_accepted: u32,
3191    pub stat_forwarded: u32,
3192    pub stat_forward_dropped: u32,
3193    pub stat_forward_cancelled: u32,
3194    pub ident_role: u32,
3195    pub ident_mobile: u32,
3196    pub ident_location: u32,
3197    pub ident_altitude: u32,
3198    pub dev_discoverable: u32,
3199    pub gnss_ident_update: u32,
3200    pub gnss_ident_precision: u32,
3201    pub uptime: u32,
3202    pub advert_interval: u32,
3203    pub beacon_interval: u32,
3204    pub startup_beacon: u32,
3205    pub gnss_enabled: u32,
3206    pub gnss_time_trust: u32,
3207    pub ble_enabled: u32,
3208    pub ble_bond_count: u32,
3209    pub ble_link: u32,
3210    pub ble_pairing: u32,
3211    pub time: u32,
3212    pub tz_offset: u32,
3213    pub alert: u32,
3214    pub repeater_enabled: u32,
3215    pub repeater_regions: u32,
3216    pub repeater_default_region: u32,
3217    pub repeater_min_rssi: u32,
3218    pub repeater_min_snr: u32,
3219    pub dev_peers: u32,
3220    pub dev_admins: u32,
3221}
3222
3223/// The property numbers, from the constants themselves.
3224#[uniffi::export]
3225pub fn ulcp_managed_property_ids() -> UlcpManagedPropertyIds {
3226    UlcpManagedPropertyIds {
3227        caps: prop::CAPS,
3228        device_version: prop::DEV_VERSION,
3229        device_model: prop::DEV_MODEL,
3230        device_name: prop::DEV_NAME,
3231        battery: prop::BATTERY,
3232        phy_enabled: prop::PHY_ENABLED,
3233        frequency: prop::PHY_FREQ,
3234        transmit_power: prop::PHY_TX_POWER,
3235        lora_bandwidth: prop::PHY_LORA_BW,
3236        lora_spreading_factor: prop::PHY_LORA_SF,
3237        lora_coding_rate: prop::PHY_LORA_CR,
3238        duty_cycle_now: prop::PHY_DUTY_NOW,
3239        duty_cycle_limit: prop::PHY_DUTY_LIMIT,
3240        stat_tx_packets: prop::STAT_TX_PACKETS,
3241        stat_tx_channel_busy: prop::STAT_TX_CHANNEL_BUSY,
3242        stat_rx_packets: prop::STAT_RX_PACKETS,
3243        stat_rx_bad_crc: prop::STAT_RX_BAD_CRC,
3244        stat_rx_non_umsh: prop::STAT_RX_NON_UMSH,
3245        stat_rx_accepted: prop::STAT_RX_ACCEPTED,
3246        stat_forwarded: prop::STAT_FORWARDED,
3247        stat_forward_dropped: prop::STAT_FORWARD_DROPPED,
3248        stat_forward_cancelled: prop::STAT_FORWARD_CANCELLED,
3249        ident_role: prop::IDENT_ROLE,
3250        ident_mobile: prop::IDENT_MOBILE,
3251        ident_location: prop::IDENT_LOCATION,
3252        ident_altitude: prop::IDENT_ALTITUDE,
3253        dev_discoverable: prop::DEV_DISCOVERABLE,
3254        gnss_ident_update: prop::GNSS_IDENT_UPDATE,
3255        gnss_ident_precision: prop::GNSS_IDENT_PRECISION,
3256        uptime: prop::UPTIME,
3257        advert_interval: prop::ADVERT_INTERVAL,
3258        beacon_interval: prop::BEACON_INTERVAL,
3259        startup_beacon: prop::STARTUP_BEACON,
3260        gnss_enabled: prop::GNSS_ENABLED,
3261        gnss_time_trust: prop::GNSS_TIME_TRUST,
3262        ble_enabled: prop::BLE_ENABLED,
3263        ble_bond_count: prop::BLE_BOND_COUNT,
3264        ble_link: prop::BLE_LINK,
3265        ble_pairing: prop::BLE_PAIRING,
3266        time: prop::TIME,
3267        tz_offset: prop::TZ_OFFSET,
3268        alert: prop::ALERT,
3269        repeater_enabled: prop::MAC_REPEATER_ENABLED,
3270        repeater_regions: prop::MAC_REPEATER_REGIONS,
3271        repeater_default_region: prop::MAC_REPEATER_DEFAULT_REGION,
3272        repeater_min_rssi: prop::MAC_REPEATER_MIN_RSSI,
3273        repeater_min_snr: prop::MAC_REPEATER_MIN_SNR,
3274        dev_peers: prop::DEV_PEERS,
3275        dev_admins: prop::DEV_ADMINS,
3276    }
3277}
3278
3279/// The four properties a device is identified by, asked for together.
3280///
3281/// Capabilities first, so a device that declines the batch teaches the
3282/// crawl to ask one at a time before the rest.
3283#[uniffi::export]
3284pub fn ulcp_card_properties() -> Vec<u32> {
3285    vec![
3286        prop::CAPS,
3287        prop::DEV_VERSION,
3288        prop::DEV_MODEL,
3289        prop::DEV_NAME,
3290    ]
3291}
3292
3293/// What one category asks for, given what the device says it can do.
3294///
3295/// Capability-gated so a screen never spends airtime on a property the
3296/// device does not have, and filtered to what an administrator may
3297/// reach.
3298#[uniffi::export]
3299pub fn ulcp_category_properties(
3300    category: UlcpManageCategory,
3301    capabilities: Vec<u8>,
3302) -> Result<Vec<u32>, MobileError> {
3303    let capabilities = decode_capabilities(&capabilities)?;
3304    validate_capability_dependencies(&capabilities)?;
3305    let has = |capability| capabilities.contains(&capability);
3306    let mut properties = Vec::new();
3307    let mut when = |gate: bool, keys: &[u32]| {
3308        if gate {
3309            properties.extend_from_slice(keys);
3310        }
3311    };
3312
3313    match category {
3314        UlcpManageCategory::Power => when(has(cap::BATTERY), &[prop::BATTERY]),
3315        UlcpManageCategory::Radio => {
3316            when(
3317                true,
3318                &[prop::PHY_ENABLED, prop::PHY_FREQ, prop::PHY_TX_POWER],
3319            );
3320            when(
3321                has(cap::PHY_LORA),
3322                &[prop::PHY_LORA_BW, prop::PHY_LORA_SF, prop::PHY_LORA_CR],
3323            );
3324            when(
3325                has(cap::PHY_DUTY_LIMIT),
3326                &[prop::PHY_DUTY_NOW, prop::PHY_DUTY_LIMIT],
3327            );
3328        }
3329        UlcpManageCategory::Statistics => {
3330            when(
3331                has(cap::STATS),
3332                &[
3333                    prop::STAT_TX_PACKETS,
3334                    prop::STAT_TX_CHANNEL_BUSY,
3335                    prop::STAT_RX_PACKETS,
3336                    prop::STAT_RX_BAD_CRC,
3337                    prop::STAT_RX_NON_UMSH,
3338                    prop::STAT_RX_ACCEPTED,
3339                    prop::PHY_DUTY_NOW,
3340                    prop::UPTIME,
3341                ],
3342            );
3343            when(
3344                has(cap::STATS) && has(cap::REPEATER),
3345                &[
3346                    prop::STAT_FORWARDED,
3347                    prop::STAT_FORWARD_DROPPED,
3348                    prop::STAT_FORWARD_CANCELLED,
3349                ],
3350            );
3351        }
3352        UlcpManageCategory::Identity => {
3353            when(has(cap::DEV_NAME), &[prop::DEV_NAME]);
3354            when(
3355                has(cap::IDENT),
3356                &[
3357                    prop::IDENT_ROLE,
3358                    prop::IDENT_MOBILE,
3359                    prop::IDENT_LOCATION,
3360                    prop::IDENT_ALTITUDE,
3361                ],
3362            );
3363            when(has(cap::DEV_IDENTITY), &[prop::DEV_DISCOVERABLE]);
3364            // Whether the device maintains its own position decides
3365            // whether the location rows above are editable at all, so
3366            // this screen has to know even though the receiver has its
3367            // own.
3368            when(
3369                has(cap::GNSS),
3370                &[prop::GNSS_IDENT_UPDATE, prop::GNSS_IDENT_PRECISION],
3371            );
3372            when(
3373                has(cap::ADVERT),
3374                &[
3375                    prop::ADVERT_INTERVAL,
3376                    prop::BEACON_INTERVAL,
3377                    prop::STARTUP_BEACON,
3378                ],
3379            );
3380        }
3381        UlcpManageCategory::Gnss => when(
3382            has(cap::GNSS),
3383            &[
3384                prop::GNSS_ENABLED,
3385                prop::GNSS_LOCATION,
3386                prop::GNSS_ALTITUDE,
3387                prop::GNSS_FIX,
3388                prop::GNSS_PRECISION,
3389                prop::GNSS_SATELLITES,
3390            ],
3391        ),
3392        UlcpManageCategory::Time => {
3393            // Ungated: a device with no wall clock may still know how
3394            // long it has been up, and a refusal costs one slot.
3395            when(true, &[prop::UPTIME]);
3396            when(has(cap::TIME), &[prop::TIME, prop::TZ_OFFSET]);
3397            // Whether the receiver may set the clock is the clock's
3398            // business, so it lives here rather than with the receiver.
3399            when(has(cap::GNSS), &[prop::GNSS_TIME_TRUST]);
3400        }
3401        UlcpManageCategory::Bluetooth => {
3402            // One capability covers the whole transport, so the count is
3403            // asked for unconditionally: a device that does not manage
3404            // its own bonds refuses it, and the refusal is what tells the
3405            // screen to leave the actions out. Asking costs one slot in a
3406            // batch that is being sent anyway.
3407            when(
3408                has(cap::BLE),
3409                &[
3410                    prop::BLE_ENABLED,
3411                    prop::BLE_BOND_COUNT,
3412                    prop::BLE_LINK,
3413                    prop::BLE_PAIRING,
3414                ],
3415            );
3416        }
3417        UlcpManageCategory::Repeater => when(
3418            has(cap::REPEATER),
3419            &[
3420                prop::MAC_REPEATER_ENABLED,
3421                prop::MAC_REPEATER_REGIONS,
3422                prop::MAC_REPEATER_DEFAULT_REGION,
3423                prop::MAC_REPEATER_MIN_RSSI,
3424                prop::MAC_REPEATER_MIN_SNR,
3425            ],
3426        ),
3427        UlcpManageCategory::PeerNodes => {
3428            when(has(cap::DEV_IDENTITY), &[prop::DEV_PEERS]);
3429            when(has(cap::ADMIN), &[prop::DEV_ADMINS]);
3430        }
3431    }
3432    properties.retain(|&key| umsh_ulcp::ids::admin_reachable(key));
3433    Ok(properties)
3434}
3435
3436/// What a device is, as opposed to how it is configured.
3437///
3438/// The four properties worth learning once and keeping: capabilities and
3439/// firmware version change only when the firmware does, the model never,
3440/// and the name rarely. Cached against the version, this is what lets
3441/// opening a device's settings cost nothing at all.
3442#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
3443pub struct UlcpDeviceCardRecord {
3444    /// `PROP_CAPS` verbatim, to plan later reads against without asking
3445    /// the device again.
3446    pub capabilities: Vec<u8>,
3447    /// `PROP_DEV_VERSION`: what firmware it is running. The natural key
3448    /// for everything cached about it — capabilities cannot change
3449    /// without this changing too.
3450    pub device_version: Option<String>,
3451    /// `PROP_DEV_MODEL`: the hardware, when the device names it.
3452    pub device_model: Option<String>,
3453    pub device_name: Option<String>,
3454    pub supports_device_name: bool,
3455    pub supports_battery: bool,
3456    pub supports_lora: bool,
3457    pub supports_duty_cycle_limit: bool,
3458    pub supports_repeater: bool,
3459    pub supports_ident: bool,
3460    pub supports_device_identity: bool,
3461    pub supports_gnss: bool,
3462    pub supports_advert: bool,
3463    pub supports_admin: bool,
3464    pub supports_alert: bool,
3465    /// Whether the device has a Bluetooth transport it can be made
3466    /// unreachable over (`CAP_BLE`).
3467    pub supports_ble: bool,
3468    /// Whether a Restart control is worth offering (`CAP_REBOOT`).
3469    pub supports_reboot: bool,
3470    pub supports_save: bool,
3471    /// Whether batched property reads are worth trying. A device that
3472    /// declines one is asked again a property at a time, so this is a
3473    /// hint rather than a contract.
3474    pub supports_multi: bool,
3475}
3476
3477/// Reduce the answers to a card read into what a device is.
3478///
3479/// Capabilities are required — without them there is nothing to plan the
3480/// rest against. Everything else is absent rather than fatal: a device
3481/// that will not name its hardware is a device that does not name its
3482/// hardware.
3483#[uniffi::export]
3484pub fn inspect_ulcp_device_card(
3485    responses: Vec<UlcpPropertyFrameRecord>,
3486) -> Result<UlcpDeviceCardRecord, MobileError> {
3487    let raw = property_value(&responses, prop::CAPS)?.to_vec();
3488    let capabilities = decode_capabilities(&raw)?;
3489    validate_capability_dependencies(&capabilities)?;
3490    let has = |capability| capabilities.contains(&capability);
3491    let text = |key| {
3492        property_value(&responses, key)
3493            .and_then(decode_device_name)
3494            .ok()
3495            .flatten()
3496    };
3497
3498    Ok(UlcpDeviceCardRecord {
3499        capabilities: raw,
3500        device_version: text(prop::DEV_VERSION),
3501        device_model: text(prop::DEV_MODEL),
3502        device_name: text(prop::DEV_NAME),
3503        supports_device_name: has(cap::DEV_NAME),
3504        supports_battery: has(cap::BATTERY),
3505        supports_lora: has(cap::PHY_LORA),
3506        supports_duty_cycle_limit: has(cap::PHY_DUTY_LIMIT),
3507        supports_repeater: has(cap::REPEATER),
3508        supports_ident: has(cap::IDENT),
3509        supports_device_identity: has(cap::DEV_IDENTITY),
3510        supports_gnss: has(cap::GNSS),
3511        supports_advert: has(cap::ADVERT),
3512        supports_admin: has(cap::ADMIN),
3513        supports_alert: has(cap::ALERT),
3514        supports_ble: has(cap::BLE),
3515        supports_reboot: has(cap::REBOOT),
3516        supports_save: has(cap::SAVE),
3517        supports_multi: has(cap::CMD_MULTI),
3518    })
3519}
3520
3521/// Everything the management screens show, all of it optional.
3522///
3523/// A category read answers a handful of properties, so anything outside
3524/// it is simply absent — this record says what the last read of *some*
3525/// category found, and a screen fills in from it whatever it recognizes.
3526/// The counterpart to [`UlcpSyncRecord`], which describes a whole device
3527/// and can insist on the properties every device must answer.
3528#[derive(Clone, Debug, Default, PartialEq, uniffi::Record)]
3529pub struct UlcpDevicePropertiesRecord {
3530    pub battery: Option<UlcpBatteryRecord>,
3531    pub phy_enabled: Option<bool>,
3532    pub frequency_khz: Option<u32>,
3533    pub transmit_power_dbm: Option<i8>,
3534    pub bandwidth_hz: Option<u32>,
3535    pub spreading_factor: Option<u8>,
3536    pub coding_rate_denom: Option<u8>,
3537    pub duty_cycle_now: Option<u16>,
3538    pub duty_cycle_limit: Option<u16>,
3539    pub stat_tx_packets: Option<u32>,
3540    pub stat_tx_channel_busy: Option<u32>,
3541    pub stat_rx_packets: Option<u32>,
3542    pub stat_rx_bad_crc: Option<u32>,
3543    pub stat_rx_non_umsh: Option<u32>,
3544    pub stat_rx_accepted: Option<u32>,
3545    pub stat_forwarded: Option<u32>,
3546    pub stat_forward_dropped: Option<u32>,
3547    pub stat_forward_cancelled: Option<u32>,
3548    pub device_name: Option<String>,
3549    /// `None` is the device deriving its own role, which reads the same
3550    /// as never having been told.
3551    pub ident_role: Option<u8>,
3552    pub ident_mobile: Option<bool>,
3553    /// `PROP_IDENT_LOCATION` verbatim. Empty is a device advertising no
3554    /// position, which is different from not having been asked.
3555    pub ident_location: Option<Vec<u8>>,
3556    pub ident_latitude_deg: Option<f64>,
3557    pub ident_longitude_deg: Option<f64>,
3558    /// Width of the advertised cell at the equator, in meters — what the
3559    /// length of the location actually discloses.
3560    pub ident_location_cell_meters: Option<f64>,
3561    pub ident_altitude_m: Option<i32>,
3562    pub dev_discoverable: Option<bool>,
3563    /// Whether the device maintains its own advertised position. Set,
3564    /// the location and altitude above are the device's to write and a
3565    /// host's write is refused.
3566    pub gnss_ident_update: Option<bool>,
3567    pub gnss_ident_precision: Option<u8>,
3568    /// `PROP_UPTIME`: seconds since the device booted. Absent on a
3569    /// device that does not report it.
3570    pub uptime_seconds: Option<u32>,
3571    pub advert_interval_seconds: Option<u32>,
3572    pub beacon_interval_seconds: Option<u32>,
3573    pub startup_beacon: Option<bool>,
3574    pub gnss_enabled: Option<bool>,
3575    /// What the receiver currently sees. Read-only, and absent on a
3576    /// device with no receiver.
3577    pub gnss: Option<UlcpGnssRecord>,
3578    pub gnss_time_trust: Option<bool>,
3579    /// Whether the device can be reached over Bluetooth.
3580    pub ble_enabled: Option<bool>,
3581    /// How many hosts are paired with it. Read-only, and absent on a
3582    /// device that does not manage its own bonds.
3583    pub ble_bond_count: Option<u8>,
3584    /// Whether a host is on the device's Bluetooth right now: 0 nobody,
3585    /// 1 connected, 2 attached and running ULCP. Read-only, and always 2
3586    /// when the question was asked over Bluetooth — the session asking is
3587    /// the session it reports.
3588    pub ble_link: Option<u8>,
3589    /// Whether a pairing window is open. Read-write, and absent on a
3590    /// device that does not manage its own bonds.
3591    pub ble_pairing: Option<bool>,
3592    /// What the device's clock read when it answered. Present when the
3593    /// clock was asked about; the inner epoch is absent on a device that
3594    /// has not found the time.
3595    pub time: Option<UlcpTimeRecord>,
3596    pub tz_offset_min: Option<i16>,
3597    pub repeater_enabled: Option<bool>,
3598    pub repeater_regions: Option<Vec<String>>,
3599    pub repeater_default_region: Option<Vec<u8>>,
3600    pub repeater_min_rssi_dbm: Option<i16>,
3601    pub repeater_min_snr_db: Option<i8>,
3602    pub dev_peer_keys: Option<Vec<Vec<u8>>>,
3603    pub dev_admin_keys: Option<Vec<Vec<u8>>>,
3604}
3605
3606/// Encode a place as the cell a device advertises it from.
3607///
3608/// The counterpart to the latitude and longitude an inspection reports:
3609/// what goes on the air is a cell rather than a point, and how large that
3610/// cell is — the precision, which is also the value's length — is what the
3611/// device discloses. See [`ulcp_location_cell_meters`] for what each
3612/// precision is worth in meters.
3613///
3614/// Precision must be 1 through 7. A coordinate outside its own range is
3615/// refused rather than wrapped: a longitude of 200° is a typo, not a
3616/// place.
3617#[uniffi::export]
3618pub fn ulcp_encode_location(
3619    latitude_deg: f64,
3620    longitude_deg: f64,
3621    precision: u8,
3622) -> Result<Vec<u8>, MobileError> {
3623    if !(1..=MAX_PRECISION).contains(&precision)
3624        || !(-90.0..=90.0).contains(&latitude_deg)
3625        || !(-180.0..=180.0).contains(&longitude_deg)
3626    {
3627        return Err(MobileError::InvalidUlcpFrame);
3628    }
3629    // Through the exact constructor rather than the float one: at the
3630    // finest precisions, rounding a typed-in coordinate into a binary
3631    // float can land it in the neighboring cell.
3632    let e7 = |degrees: f64| (degrees * 1e7).round() as i32;
3633    Ok(
3634        NodeLocation::from_e7(e7(latitude_deg), e7(longitude_deg), precision)
3635            .as_bytes()
3636            .to_vec(),
3637    )
3638}
3639
3640/// Present one remembered value as the property frame the inspectors read.
3641///
3642/// What [`ulcp_records_from_answers`] does for a fresh answer, for a value
3643/// that came out of a cache instead. Cached octets and answered octets are
3644/// the same octets, so they decode through the same path — and a caller
3645/// never has to know which command byte a reported value wears.
3646#[uniffi::export]
3647pub fn ulcp_property_record(property_id: u32, value: Vec<u8>) -> UlcpPropertyFrameRecord {
3648    UlcpPropertyFrameRecord {
3649        // A management exchange is correlated by its envelope token, so
3650        // every request on one carries transaction zero.
3651        transaction_id: 0,
3652        command: Cmd::PropIs as u8,
3653        property_id,
3654        value,
3655    }
3656}
3657
3658/// Decode whatever a category read answered.
3659///
3660/// Nothing is required and nothing fails: a property that did not come
3661/// back, or came back undecodable, is left absent. Which properties were
3662/// *refused* is a separate question, and the answers to the read say so
3663/// directly.
3664#[uniffi::export]
3665pub fn inspect_ulcp_properties(
3666    responses: Vec<UlcpPropertyFrameRecord>,
3667) -> UlcpDevicePropertiesRecord {
3668    let at = &responses;
3669    let location = optional_value(at, prop::IDENT_LOCATION, |value| {
3670        Ok::<Vec<u8>, MobileError>(value.to_vec())
3671    });
3672    let placed = location
3673        .as_ref()
3674        .filter(|bytes| !bytes.is_empty())
3675        .map(|bytes| NodeLocation::from_bytes(bytes).center());
3676
3677    UlcpDevicePropertiesRecord {
3678        battery: optional_value(at, prop::BATTERY, |value| {
3679            inspect_ulcp_battery(value.to_vec())
3680        }),
3681        phy_enabled: optional_value(at, prop::PHY_ENABLED, decode_bool),
3682        frequency_khz: optional_value(at, prop::PHY_FREQ, decode_u32),
3683        transmit_power_dbm: optional_value(at, prop::PHY_TX_POWER, decode_i8),
3684        bandwidth_hz: optional_value(at, prop::PHY_LORA_BW, decode_u32),
3685        spreading_factor: optional_value(at, prop::PHY_LORA_SF, decode_u8),
3686        coding_rate_denom: optional_value(at, prop::PHY_LORA_CR, decode_u8),
3687        duty_cycle_now: optional_value(at, prop::PHY_DUTY_NOW, decode_u16),
3688        duty_cycle_limit: optional_value(at, prop::PHY_DUTY_LIMIT, decode_u16),
3689        stat_tx_packets: optional_value(at, prop::STAT_TX_PACKETS, decode_u32),
3690        stat_tx_channel_busy: optional_value(at, prop::STAT_TX_CHANNEL_BUSY, decode_u32),
3691        stat_rx_packets: optional_value(at, prop::STAT_RX_PACKETS, decode_u32),
3692        stat_rx_bad_crc: optional_value(at, prop::STAT_RX_BAD_CRC, decode_u32),
3693        stat_rx_non_umsh: optional_value(at, prop::STAT_RX_NON_UMSH, decode_u32),
3694        stat_rx_accepted: optional_value(at, prop::STAT_RX_ACCEPTED, decode_u32),
3695        stat_forwarded: optional_value(at, prop::STAT_FORWARDED, decode_u32),
3696        stat_forward_dropped: optional_value(at, prop::STAT_FORWARD_DROPPED, decode_u32),
3697        stat_forward_cancelled: optional_value(at, prop::STAT_FORWARD_CANCELLED, decode_u32),
3698        device_name: optional_value(at, prop::DEV_NAME, decode_device_name).flatten(),
3699        ident_role: optional_value(at, prop::IDENT_ROLE, |value| {
3700            decode_optional(value, decode_u8)
3701        })
3702        .flatten(),
3703        ident_mobile: optional_value(at, prop::IDENT_MOBILE, decode_bool),
3704        ident_latitude_deg: placed.map(|(latitude, _)| latitude.into()),
3705        ident_longitude_deg: placed.map(|(_, longitude)| longitude.into()),
3706        ident_location_cell_meters: location
3707            .as_ref()
3708            .filter(|bytes| !bytes.is_empty())
3709            .and_then(|bytes| ulcp_location_cell_meters(bytes.len() as u8)),
3710        ident_location: location,
3711        ident_altitude_m: optional_value(at, prop::IDENT_ALTITUDE, decode_optional_altitude)
3712            .flatten(),
3713        dev_discoverable: optional_value(at, prop::DEV_DISCOVERABLE, decode_bool),
3714        gnss_ident_update: optional_value(at, prop::GNSS_IDENT_UPDATE, decode_bool),
3715        gnss_ident_precision: optional_value(at, prop::GNSS_IDENT_PRECISION, decode_precision),
3716        uptime_seconds: optional_value(at, prop::UPTIME, decode_u32),
3717        advert_interval_seconds: optional_value(at, prop::ADVERT_INTERVAL, decode_u32),
3718        beacon_interval_seconds: optional_value(at, prop::BEACON_INTERVAL, decode_u32),
3719        startup_beacon: optional_value(at, prop::STARTUP_BEACON, decode_bool),
3720        gnss_enabled: optional_value(at, prop::GNSS_ENABLED, decode_bool),
3721        gnss: gnss_readout(at),
3722        gnss_time_trust: optional_value(at, prop::GNSS_TIME_TRUST, decode_bool),
3723        ble_enabled: optional_value(at, prop::BLE_ENABLED, decode_bool),
3724        ble_bond_count: optional_value(at, prop::BLE_BOND_COUNT, decode_u8),
3725        ble_link: optional_value(at, prop::BLE_LINK, decode_u8),
3726        ble_pairing: optional_value(at, prop::BLE_PAIRING, decode_bool),
3727        time: optional_value(at, prop::TIME, |value| {
3728            Ok::<UlcpTimeRecord, MobileError>(UlcpTimeRecord {
3729                epoch_seconds: decode_optional(value, decode_u32)?,
3730            })
3731        }),
3732        tz_offset_min: optional_value(at, prop::TZ_OFFSET, decode_i16),
3733        repeater_enabled: optional_value(at, prop::MAC_REPEATER_ENABLED, decode_bool),
3734        repeater_regions: optional_value(at, prop::MAC_REPEATER_REGIONS, decode_region_list),
3735        repeater_default_region: optional_value(
3736            at,
3737            prop::MAC_REPEATER_DEFAULT_REGION,
3738            decode_optional_region,
3739        )
3740        .flatten(),
3741        repeater_min_rssi_dbm: optional_value(at, prop::MAC_REPEATER_MIN_RSSI, |value| {
3742            decode_optional(value, decode_i16)
3743        })
3744        .flatten(),
3745        repeater_min_snr_db: optional_value(at, prop::MAC_REPEATER_MIN_SNR, |value| {
3746            decode_optional(value, decode_i8)
3747        })
3748        .flatten(),
3749        dev_peer_keys: optional_value(
3750            at,
3751            prop::DEV_PEERS,
3752            decode_fixed_list::<{ items::PUBLIC_KEY_LEN }>,
3753        ),
3754        dev_admin_keys: optional_value(
3755            at,
3756            prop::DEV_ADMINS,
3757            decode_fixed_list::<{ items::PUBLIC_KEY_LEN }>,
3758        ),
3759    }
3760}
3761
3762/// Fold whatever positioning properties came back into one readout.
3763///
3764/// `None` unless the fix indicator arrived: without it there is nothing
3765/// to say whether the rest describes a position or the absence of one.
3766fn gnss_readout(responses: &[UlcpPropertyFrameRecord]) -> Option<UlcpGnssRecord> {
3767    let mut snapshot = GnssSnapshot::SEARCHING;
3768    property_value(responses, prop::GNSS_FIX)
3769        .ok()
3770        .and_then(|value| snapshot.absorb(prop::GNSS_FIX, value).ok())?;
3771    for key in [
3772        prop::GNSS_LOCATION,
3773        prop::GNSS_ALTITUDE,
3774        prop::GNSS_PRECISION,
3775        prop::GNSS_SATELLITES,
3776    ] {
3777        if let Ok(value) = property_value(responses, key) {
3778            let _ = snapshot.absorb(key, value);
3779        }
3780    }
3781    Some(gnss_record(&snapshot))
3782}
3783
3784/// `PROP_IDENT_ALTITUDE`: empty, or a minimal-length signed integer.
3785fn decode_optional_altitude(value: &[u8]) -> Result<Option<i32>, MobileError> {
3786    match value {
3787        [] => Ok(None),
3788        bytes => umsh_ulcp::sint::decode(bytes)
3789            .map(Some)
3790            .map_err(|_| MobileError::InvalidUlcpFrame),
3791    }
3792}
3793
3794/// Encode only the properties the operator actually changed.
3795///
3796/// The whole point of the category screens: a device several hops away
3797/// takes one write for one edit, rather than a restatement of its entire
3798/// configuration. `dirty_property_ids` names what was edited; anything
3799/// else in `desired` is ignored, so a record filled in from a stale
3800/// cache cannot write stale values back.
3801///
3802/// The radio is bracketed when any of its parameters move: the PHY goes
3803/// down first and comes back up last, so a device is never asked to
3804/// change the frequency it is transmitting on.
3805#[uniffi::export]
3806pub fn ulcp_dirty_writes(
3807    desired: UlcpDevicePropertiesRecord,
3808    dirty_property_ids: Vec<u32>,
3809) -> Result<Vec<MobileMeshPropertyWriteRecord>, MobileError> {
3810    let mut dirty: Vec<u32> = dirty_property_ids;
3811    dirty.sort_unstable();
3812    dirty.dedup();
3813
3814    let mut values: Vec<(u32, Vec<u8>)> = Vec::new();
3815    for key in &dirty {
3816        // A property named as edited whose value is absent is a caller
3817        // mistake: there is nothing to write, and silently skipping it
3818        // would report success for an edit that never happened.
3819        let missing = || MobileError::InvalidUlcpFrame;
3820        let value = match *key {
3821            prop::PHY_ENABLED => vec![desired.phy_enabled.ok_or_else(missing)? as u8],
3822            prop::PHY_FREQ => desired
3823                .frequency_khz
3824                .ok_or_else(missing)?
3825                .to_le_bytes()
3826                .to_vec(),
3827            prop::PHY_TX_POWER => vec![desired.transmit_power_dbm.ok_or_else(missing)? as u8],
3828            prop::PHY_LORA_BW => desired
3829                .bandwidth_hz
3830                .ok_or_else(missing)?
3831                .to_le_bytes()
3832                .to_vec(),
3833            prop::PHY_LORA_SF => vec![desired.spreading_factor.ok_or_else(missing)?],
3834            prop::PHY_LORA_CR => vec![desired.coding_rate_denom.ok_or_else(missing)?],
3835            prop::PHY_DUTY_LIMIT => desired
3836                .duty_cycle_limit
3837                .ok_or_else(missing)?
3838                .to_le_bytes()
3839                .to_vec(),
3840            prop::STAT_TX_PACKETS
3841            | prop::STAT_TX_CHANNEL_BUSY
3842            | prop::STAT_RX_PACKETS
3843            | prop::STAT_RX_BAD_CRC
3844            | prop::STAT_RX_NON_UMSH
3845            | prop::STAT_RX_ACCEPTED
3846            | prop::STAT_FORWARDED
3847            | prop::STAT_FORWARD_DROPPED
3848            | prop::STAT_FORWARD_CANCELLED => 0u32.to_le_bytes().to_vec(),
3849            prop::DEV_NAME => desired
3850                .device_name
3851                .clone()
3852                .ok_or_else(missing)?
3853                .into_bytes(),
3854            // Empty is a legitimate value for both: the device derives
3855            // its own role, and advertises no position.
3856            prop::IDENT_ROLE => desired
3857                .ident_role
3858                .map(|role| vec![role])
3859                .unwrap_or_default(),
3860            prop::IDENT_MOBILE => vec![desired.ident_mobile.ok_or_else(missing)? as u8],
3861            prop::IDENT_LOCATION => {
3862                let location = desired.ident_location.clone().unwrap_or_default();
3863                if location.len() > MAX_PRECISION as usize {
3864                    return Err(MobileError::InvalidUlcpFrame);
3865                }
3866                location
3867            }
3868            prop::IDENT_ALTITUDE => match desired.ident_altitude_m {
3869                Some(meters) => {
3870                    let mut buf = [0u8; umsh_ulcp::sint::MAX_LEN];
3871                    let len = umsh_ulcp::sint::encode(meters, &mut buf)
3872                        .map_err(|_| MobileError::InvalidUlcpFrame)?;
3873                    buf[..len].to_vec()
3874                }
3875                None => Vec::new(),
3876            },
3877            prop::DEV_DISCOVERABLE => vec![desired.dev_discoverable.ok_or_else(missing)? as u8],
3878            prop::GNSS_ENABLED => vec![desired.gnss_enabled.ok_or_else(missing)? as u8],
3879            prop::GNSS_IDENT_UPDATE => vec![desired.gnss_ident_update.ok_or_else(missing)? as u8],
3880            prop::GNSS_IDENT_PRECISION => {
3881                let precision = desired.gnss_ident_precision.ok_or_else(missing)?;
3882                if !(1..=MAX_PRECISION).contains(&precision) {
3883                    return Err(MobileError::InvalidUlcpFrame);
3884                }
3885                vec![precision]
3886            }
3887            prop::GNSS_TIME_TRUST => vec![desired.gnss_time_trust.ok_or_else(missing)? as u8],
3888            // The bond count and link state are read-only and have no
3889            // arms here; the reachability and pairing-window toggles are
3890            // what a phone can write.
3891            prop::BLE_ENABLED => vec![desired.ble_enabled.ok_or_else(missing)? as u8],
3892            prop::BLE_PAIRING => vec![desired.ble_pairing.ok_or_else(missing)? as u8],
3893            // Empty clears the clock back to unknown, which is what a
3894            // device reports before its first fix.
3895            prop::TIME => desired
3896                .time
3897                .ok_or_else(missing)?
3898                .epoch_seconds
3899                .map(|epoch| epoch.to_le_bytes().to_vec())
3900                .unwrap_or_default(),
3901            prop::TZ_OFFSET => desired
3902                .tz_offset_min
3903                .ok_or_else(missing)?
3904                .to_le_bytes()
3905                .to_vec(),
3906            prop::ADVERT_INTERVAL => desired
3907                .advert_interval_seconds
3908                .ok_or_else(missing)?
3909                .to_le_bytes()
3910                .to_vec(),
3911            prop::BEACON_INTERVAL => desired
3912                .beacon_interval_seconds
3913                .ok_or_else(missing)?
3914                .to_le_bytes()
3915                .to_vec(),
3916            prop::STARTUP_BEACON => vec![desired.startup_beacon.ok_or_else(missing)? as u8],
3917            prop::MAC_REPEATER_ENABLED => vec![desired.repeater_enabled.ok_or_else(missing)? as u8],
3918            prop::MAC_REPEATER_REGIONS => encode_region_list(
3919                desired
3920                    .repeater_regions
3921                    .clone()
3922                    .ok_or_else(missing)?
3923                    .as_slice(),
3924            )?,
3925            // Empty is "never tag" and "no threshold" respectively.
3926            prop::MAC_REPEATER_DEFAULT_REGION => {
3927                let region = desired.repeater_default_region.clone().unwrap_or_default();
3928                if !region.is_empty() && region.len() != items::REGION_CODE_LEN {
3929                    return Err(MobileError::InvalidUlcpFrame);
3930                }
3931                region
3932            }
3933            prop::MAC_REPEATER_MIN_RSSI => desired
3934                .repeater_min_rssi_dbm
3935                .map(|rssi| rssi.to_le_bytes().to_vec())
3936                .unwrap_or_default(),
3937            prop::MAC_REPEATER_MIN_SNR => desired
3938                .repeater_min_snr_db
3939                .map(|snr| vec![snr as u8])
3940                .unwrap_or_default(),
3941            // Everything else is either read-only or edited as a table,
3942            // one entry at a time.
3943            _ => return Err(MobileError::InvalidUlcpFrame),
3944        };
3945        values.push((*key, value));
3946    }
3947
3948    // Bracket the radio when anything it is transmitting under moves.
3949    const RADIO: [u32; 6] = [
3950        prop::PHY_FREQ,
3951        prop::PHY_TX_POWER,
3952        prop::PHY_LORA_BW,
3953        prop::PHY_LORA_SF,
3954        prop::PHY_LORA_CR,
3955        prop::PHY_DUTY_LIMIT,
3956    ];
3957    if values.iter().any(|(key, _)| RADIO.contains(key)) {
3958        let ends_enabled = match values.iter().find(|(key, _)| *key == prop::PHY_ENABLED) {
3959            Some((_, value)) => value.first() == Some(&1),
3960            // Not edited, so it ends however it started — which the
3961            // caller states by filling this in from the last read.
3962            None => desired.phy_enabled.unwrap_or(true),
3963        };
3964        values.retain(|(key, _)| *key != prop::PHY_ENABLED);
3965        values.insert(0, (prop::PHY_ENABLED, vec![0]));
3966        values.push((prop::PHY_ENABLED, vec![ends_enabled as u8]));
3967    }
3968
3969    Ok(values
3970        .into_iter()
3971        .map(|(property_id, value)| MobileMeshPropertyWriteRecord { property_id, value })
3972        .collect())
3973}
3974
3975/// Properties only ever written as a set. Writing part of a modem profile
3976/// or part of a forwarding policy leaves the device running a configuration
3977/// nobody asked for, so one unreadable member withdraws the whole group.
3978/// These are the same groupings the reduction reports as a unit.
3979const WHOLE_WRITE_GROUPS: [&[u32]; 4] = [
3980    &[prop::PHY_LORA_BW, prop::PHY_LORA_SF, prop::PHY_LORA_CR],
3981    &[
3982        prop::MAC_REPEATER_ENABLED,
3983        prop::MAC_REPEATER_REGIONS,
3984        prop::MAC_REPEATER_DEFAULT_REGION,
3985        prop::MAC_REPEATER_MIN_RSSI,
3986        prop::MAC_REPEATER_MIN_SNR,
3987    ],
3988    &[
3989        prop::GNSS_ENABLED,
3990        prop::GNSS_IDENT_UPDATE,
3991        prop::GNSS_IDENT_PRECISION,
3992        prop::GNSS_TIME_TRUST,
3993    ],
3994    &[
3995        prop::ADVERT_INTERVAL,
3996        prop::BEACON_INTERVAL,
3997        prop::STARTUP_BEACON,
3998    ],
3999];
4000
4001/// The capability-gated half of an inspection: properties the device is
4002/// expected to answer, each of which it may nevertheless decline.
4003struct ExpectedProperties<'a> {
4004    responses: &'a [UlcpPropertyFrameRecord],
4005    unreadable: Vec<u32>,
4006}
4007
4008impl ExpectedProperties<'_> {
4009    /// Decode `key` when the device advertises the capability that gates it.
4010    ///
4011    /// A missing or undecodable value is recorded and reported as `None`
4012    /// rather than failing the whole reduction: the setting is unknown, which
4013    /// is a fact about one property and not about the device as a whole.
4014    fn read<T>(
4015        &mut self,
4016        gated_on: bool,
4017        key: u32,
4018        decode: impl FnOnce(&[u8]) -> Result<T, MobileError>,
4019    ) -> Option<T> {
4020        if !gated_on {
4021            return None;
4022        }
4023        match property_value(self.responses, key).and_then(decode) {
4024            Ok(value) => Some(value),
4025            Err(_) => {
4026                self.unreadable.push(key);
4027                None
4028            }
4029        }
4030    }
4031}
4032
4033/// Decode a live reading the device may or may not have reported.
4034///
4035/// The counterpart to [`ExpectedProperties::read`] for values that are not
4036/// configuration: absence is ordinary rather than a withheld setting, so
4037/// nothing is recorded and the reduction reports what it has.
4038fn reported_value<T>(
4039    responses: &[UlcpPropertyFrameRecord],
4040    gated_on: bool,
4041    key: u32,
4042    decode: impl FnOnce(&[u8]) -> Result<T, MobileError>,
4043) -> Option<T> {
4044    if !gated_on {
4045        return None;
4046    }
4047    optional_value(responses, key, decode)
4048}
4049
4050/// Decode a property a reply need not contain at all.
4051///
4052/// What [`reported_value`] does once a capability says the property
4053/// should be there, for the reductions that make no such demand: a
4054/// category read answers a handful of properties and says nothing about
4055/// the rest.
4056fn optional_value<T>(
4057    responses: &[UlcpPropertyFrameRecord],
4058    key: u32,
4059    decode: impl FnOnce(&[u8]) -> Result<T, MobileError>,
4060) -> Option<T> {
4061    property_value(responses, key).and_then(decode).ok()
4062}
4063
4064fn property_value(responses: &[UlcpPropertyFrameRecord], key: u32) -> Result<&[u8], MobileError> {
4065    let mut matching = responses
4066        .iter()
4067        .filter(|response| response.property_id == key);
4068    let response = matching.next().ok_or(MobileError::InvalidUlcpFrame)?;
4069    if matching.next().is_some() || response.command != Cmd::PropIs as u8 {
4070        return Err(MobileError::InvalidUlcpFrame);
4071    }
4072    Ok(&response.value)
4073}
4074
4075pub(crate) fn decode_capabilities(value: &[u8]) -> Result<Vec<u32>, MobileError> {
4076    let mut capabilities = Vec::new();
4077    let mut rest = value;
4078    while !rest.is_empty() {
4079        let (capability, used) = pui::decode(rest).map_err(|_| MobileError::InvalidUlcpFrame)?;
4080        if capabilities.contains(&capability) {
4081            return Err(MobileError::InvalidUlcpFrame);
4082        }
4083        capabilities.push(capability);
4084        rest = &rest[used..];
4085    }
4086    Ok(capabilities)
4087}
4088
4089fn validate_capability_dependencies(capabilities: &[u32]) -> Result<(), MobileError> {
4090    let has = |capability| capabilities.contains(&capability);
4091    if has(cap::HOST_RX_QUEUE) && !has(cap::HOST_FILTER)
4092        || has(cap::HOST_KEYS) && !has(cap::HOST_FILTER)
4093        || has(cap::HOST_AUTO_ACK) && (!has(cap::HOST_KEYS) || !has(cap::HOST_RX_QUEUE))
4094        // A device with no identity of its own has nothing to forward for
4095        // and nothing to advertise.
4096        || has(cap::REPEATER) && !has(cap::DEV_IDENTITY)
4097        || has(cap::IDENT) && !has(cap::DEV_IDENTITY)
4098        // An administrator is authorized against the device identity and
4099        // reaches the device domain, so there is nothing to manage without
4100        // one.
4101        || has(cap::ADMIN) && !has(cap::DEV_IDENTITY)
4102        // What a scheduled advertisement carries *is* the device identity.
4103        || has(cap::ADVERT) && !has(cap::DEV_IDENTITY)
4104        // A receiver that cannot set a clock is still a receiver, but the
4105        // device also dates its fixes, so CAP_GNSS implies CAP_TIME.
4106        || has(cap::GNSS) && !has(cap::TIME)
4107    {
4108        return Err(MobileError::InvalidUlcpFrame);
4109    }
4110    Ok(())
4111}
4112
4113fn decode_exact_pui(value: &[u8]) -> Result<u32, MobileError> {
4114    let (decoded, used) = pui::decode(value).map_err(|_| MobileError::InvalidUlcpFrame)?;
4115    (used == value.len())
4116        .then_some(decoded)
4117        .ok_or(MobileError::InvalidUlcpFrame)
4118}
4119
4120fn decode_bool(value: &[u8]) -> Result<bool, MobileError> {
4121    match value {
4122        [0] => Ok(false),
4123        [1] => Ok(true),
4124        _ => Err(MobileError::InvalidUlcpFrame),
4125    }
4126}
4127
4128fn decode_saved(value: &[u8]) -> Result<SavedSnapshotRecord, MobileError> {
4129    match value {
4130        [saved::NONE] => Ok(SavedSnapshotRecord::None),
4131        [saved::CURRENT] => Ok(SavedSnapshotRecord::Current),
4132        [saved::FALLBACK] => Ok(SavedSnapshotRecord::Fallback),
4133        [saved::UNREADABLE] => Ok(SavedSnapshotRecord::Unreadable),
4134        _ => Err(MobileError::InvalidUlcpFrame),
4135    }
4136}
4137
4138fn decode_u16(value: &[u8]) -> Result<u16, MobileError> {
4139    value
4140        .try_into()
4141        .map(u16::from_le_bytes)
4142        .map_err(|_| MobileError::InvalidUlcpFrame)
4143}
4144
4145fn decode_u8(value: &[u8]) -> Result<u8, MobileError> {
4146    value
4147        .first()
4148        .copied()
4149        .filter(|_| value.len() == 1)
4150        .ok_or(MobileError::InvalidUlcpFrame)
4151}
4152
4153fn decode_i8(value: &[u8]) -> Result<i8, MobileError> {
4154    decode_u8(value).map(|value| value as i8)
4155}
4156
4157/// A location precision, which is only ever 1–7 bytes. A device
4158/// reporting anything else is reporting a setting this phone cannot
4159/// present, so it is recorded as unreadable rather than shown.
4160fn decode_precision(value: &[u8]) -> Result<u8, MobileError> {
4161    decode_u8(value)
4162        .ok()
4163        .filter(|bytes| (1..=MAX_PRECISION).contains(bytes))
4164        .ok_or(MobileError::InvalidUlcpFrame)
4165}
4166
4167fn decode_i16(value: &[u8]) -> Result<i16, MobileError> {
4168    value
4169        .try_into()
4170        .map(i16::from_le_bytes)
4171        .map_err(|_| MobileError::InvalidUlcpFrame)
4172}
4173
4174/// Decode a property whose empty value means "unset" rather than zero.
4175fn decode_optional<T>(
4176    value: &[u8],
4177    decode: impl Fn(&[u8]) -> Result<T, MobileError>,
4178) -> Result<Option<T>, MobileError> {
4179    if value.is_empty() {
4180        return Ok(None);
4181    }
4182    decode(value).map(Some)
4183}
4184
4185/// Split a `PROP_MAC_REPEATER_REGIONS` value into its region strings.
4186///
4187/// Deliberately imposes no upper bound: how many regions a device holds
4188/// is its own business, and a device reporting more than this phone would
4189/// ever write is not a malformed frame.
4190fn decode_region_list(value: &[u8]) -> Result<Vec<String>, MobileError> {
4191    let mut regions = Vec::new();
4192    for item in items::prefixed_items(value) {
4193        let item = item.map_err(|_| MobileError::InvalidUlcpFrame)?;
4194        let text = core::str::from_utf8(item).map_err(|_| MobileError::InvalidUlcpFrame)?;
4195        regions.push(text.to_owned());
4196    }
4197    Ok(regions)
4198}
4199
4200/// Pack region strings back into a `PROP_MAC_REPEATER_REGIONS` value.
4201///
4202/// Over-long names are refused here rather than on the air: the device
4203/// rejects them outright, and one rejected write abandons everything
4204/// after it.
4205fn encode_region_list(regions: &[String]) -> Result<Vec<u8>, MobileError> {
4206    let mut value = Vec::new();
4207    for region in regions {
4208        if !(1..=items::REGION_STRING_MAX_LEN).contains(&region.len()) {
4209            return Err(MobileError::InvalidUlcpFrame);
4210        }
4211        let mut item = vec![0u8; region.len() + 4];
4212        let len = items::encode_prefixed_item(region.as_bytes(), &mut item)
4213            .map_err(|_| MobileError::InvalidUlcpFrame)?;
4214        value.extend_from_slice(&item[..len]);
4215    }
4216    Ok(value)
4217}
4218
4219fn decode_optional_region(value: &[u8]) -> Result<Option<Vec<u8>>, MobileError> {
4220    match value.len() {
4221        0 => Ok(None),
4222        items::REGION_CODE_LEN => Ok(Some(value.to_vec())),
4223        _ => Err(MobileError::InvalidUlcpFrame),
4224    }
4225}
4226
4227/// Drop the writes the device has already refused to answer for.
4228///
4229/// A capability-gated property that would not read is one the device does
4230/// not implement, so writing it fails — and one rejected write abandons
4231/// the whole configuration pass. The caller still states a complete
4232/// configuration; what cannot land is left out here, where the device's
4233/// own answers are known, rather than in the form.
4234fn writable(values: Vec<(u32, Vec<u8>)>, unreadable: &[u32]) -> Vec<(u32, Vec<u8>)> {
4235    if unreadable.is_empty() {
4236        return values;
4237    }
4238    let dropped = |property: u32| {
4239        unreadable.contains(&property)
4240            || WHOLE_WRITE_GROUPS.iter().any(|group| {
4241                group.contains(&property) && group.iter().any(|part| unreadable.contains(part))
4242            })
4243    };
4244    values
4245        .into_iter()
4246        .filter(|(property, _)| !dropped(*property))
4247        .collect()
4248}
4249
4250/// Reduce a whole device configuration to the property writes that state
4251/// it, in the order they must be sent, against a device known only by
4252/// what a completed read reported.
4253///
4254/// This is [`MobileUlcpSession::configure_device`] with the session taken
4255/// out of it: an administrator on the mesh writes the same properties, in
4256/// the same order, and drops the same unreadable ones — it just has no
4257/// attached device to ask, only the record it read.
4258pub(crate) fn device_config_writes(
4259    configuration: UlcpDeviceConfigRecord,
4260    reported: &UlcpSyncRecord,
4261) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
4262    let capabilities = DeviceCapabilities::reported(reported);
4263    validate_radio_settings(&configuration.radio, capabilities)?;
4264    let device_values = validate_device_settings(&configuration, capabilities)?;
4265    Ok(writable(
4266        configuration_values(configuration.radio, device_values),
4267        &reported.unreadable_properties,
4268    ))
4269}
4270
4271/// What a device can be told, as either half of the app learns it.
4272///
4273/// A bench session reads the capability list off the device it is holding
4274/// open; a mesh administrator reads it out of the record a whole-device
4275/// read produced. The configuration reduces to the same writes either
4276/// way, so both build one of these and nothing below has to ask which
4277/// side it came from.
4278#[derive(Clone, Copy)]
4279struct DeviceCapabilities {
4280    device_name: bool,
4281    lora: bool,
4282    duty_cycle_limit: bool,
4283    ident: bool,
4284    dev_identity: bool,
4285    repeater: bool,
4286    time: bool,
4287    gnss: bool,
4288    advert: bool,
4289}
4290
4291impl DeviceCapabilities {
4292    /// What the attached device answered `PROP_CAPS` with.
4293    fn read(state: &UlcpSessionState) -> Result<Self, MobileError> {
4294        Ok(Self {
4295            device_name: state.has_capability(cap::DEV_NAME)?,
4296            lora: state.has_capability(cap::PHY_LORA)?,
4297            duty_cycle_limit: state.has_capability(cap::PHY_DUTY_LIMIT)?,
4298            ident: state.has_capability(cap::IDENT)?,
4299            dev_identity: state.has_capability(cap::DEV_IDENTITY)?,
4300            repeater: state.has_capability(cap::REPEATER)?,
4301            time: state.has_capability(cap::TIME)?,
4302            gnss: state.has_capability(cap::GNSS)?,
4303            advert: state.has_capability(cap::ADVERT)?,
4304        })
4305    }
4306
4307    /// The same list as a completed read reports it.
4308    fn reported(sync: &UlcpSyncRecord) -> Self {
4309        Self {
4310            device_name: sync.supports_device_name,
4311            lora: sync.supports_lora,
4312            duty_cycle_limit: sync.supports_duty_cycle_limit,
4313            ident: sync.supports_ident,
4314            dev_identity: sync.supports_device_identity,
4315            repeater: sync.supports_repeater,
4316            time: sync.supports_time,
4317            gnss: sync.supports_gnss,
4318            advert: sync.supports_advert,
4319        }
4320    }
4321}
4322
4323/// Order one configuration pass: everything that changes live PHY
4324/// behavior happens with the radio down, and the radio comes back up only
4325/// once the complete new profile is in place.
4326///
4327/// `device_values` are the device-domain writes, which ride between the
4328/// two PHY_ENABLED writes for the same reason the PHY parameters do — a
4329/// repeater must not start forwarding under half of its new policy.
4330fn configuration_values(
4331    settings: UlcpRadioSettingsRecord,
4332    device_values: Vec<(u32, Vec<u8>)>,
4333) -> Vec<(u32, Vec<u8>)> {
4334    let mut values = Vec::new();
4335    if !settings.phy_enabled {
4336        values.push((prop::PHY_ENABLED, vec![0]));
4337    }
4338    if let Some(name) = settings.device_name {
4339        values.push((prop::DEV_NAME, name.into_bytes()));
4340    }
4341    values.extend([
4342        (
4343            prop::PHY_FREQ,
4344            settings.frequency_khz.to_le_bytes().to_vec(),
4345        ),
4346        (prop::PHY_TX_POWER, vec![settings.transmit_power_dbm as u8]),
4347    ]);
4348    if let (Some(bandwidth), Some(sf), Some(cr)) = (
4349        settings.bandwidth_hz,
4350        settings.spreading_factor,
4351        settings.coding_rate_denom,
4352    ) {
4353        values.extend([
4354            (prop::PHY_LORA_BW, bandwidth.to_le_bytes().to_vec()),
4355            (prop::PHY_LORA_SF, vec![sf]),
4356            (prop::PHY_LORA_CR, vec![cr]),
4357        ]);
4358    }
4359    if let Some(limit) = settings.duty_cycle_limit {
4360        values.push((prop::PHY_DUTY_LIMIT, limit.to_le_bytes().to_vec()));
4361    }
4362    values.extend(device_values);
4363    if settings.phy_enabled {
4364        values.push((prop::PHY_ENABLED, vec![1]));
4365    }
4366    values
4367}
4368
4369/// Check the device-domain half of a commissioning record against what
4370/// the device says it can do, and reduce it to property writes.
4371///
4372/// Capability-gated fields must be present exactly when the capability
4373/// is: the record states a whole desired configuration, so a field the
4374/// device cannot honor is a caller mistake rather than something to
4375/// silently drop.
4376fn validate_device_settings(
4377    configuration: &UlcpDeviceConfigRecord,
4378    capabilities: DeviceCapabilities,
4379) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
4380    let mut values = Vec::new();
4381
4382    let supports_ident = capabilities.ident;
4383    if configuration.ident_mobile.is_some() != supports_ident
4384        || (configuration.ident_role.is_some() && !supports_ident)
4385    {
4386        return Err(MobileError::InvalidUlcpFrame);
4387    }
4388    if supports_ident {
4389        // An empty PROP_IDENT_ROLE hands the choice back to the device.
4390        values.push((
4391            prop::IDENT_ROLE,
4392            configuration
4393                .ident_role
4394                .map(|role| vec![role])
4395                .unwrap_or_default(),
4396        ));
4397        values.push((
4398            prop::IDENT_MOBILE,
4399            vec![configuration.ident_mobile.unwrap_or(false) as u8],
4400        ));
4401    }
4402
4403    let supports_dev_identity = capabilities.dev_identity;
4404    if configuration.dev_discoverable.is_some() != supports_dev_identity {
4405        return Err(MobileError::InvalidUlcpFrame);
4406    }
4407    if let Some(discoverable) = configuration.dev_discoverable {
4408        values.push((prop::DEV_DISCOVERABLE, vec![discoverable as u8]));
4409    }
4410
4411    let supports_repeater = capabilities.repeater;
4412    if configuration.repeater.is_some() != supports_repeater {
4413        return Err(MobileError::InvalidUlcpFrame);
4414    }
4415    if let Some(repeater) = &configuration.repeater {
4416        let regions = encode_region_list(&repeater.regions)?;
4417        if let Some(default_region) = &repeater.default_region {
4418            if default_region.len() != items::REGION_CODE_LEN {
4419                return Err(MobileError::InvalidUlcpFrame);
4420            }
4421        }
4422        // Enabling last means the forwarding policy is already whole by
4423        // the time the device starts acting on it. The device does not
4424        // cross-check the default region against the forwarding list —
4425        // that is a SHOULD the presenting UI is better placed to warn on.
4426        values.extend([
4427            (prop::MAC_REPEATER_REGIONS, regions),
4428            (
4429                prop::MAC_REPEATER_DEFAULT_REGION,
4430                repeater.default_region.clone().unwrap_or_default(),
4431            ),
4432            (
4433                prop::MAC_REPEATER_MIN_RSSI,
4434                repeater
4435                    .min_rssi_dbm
4436                    .map(|rssi| rssi.to_le_bytes().to_vec())
4437                    .unwrap_or_default(),
4438            ),
4439            (
4440                prop::MAC_REPEATER_MIN_SNR,
4441                repeater
4442                    .min_snr_db
4443                    .map(|snr| vec![snr as u8])
4444                    .unwrap_or_default(),
4445            ),
4446            (prop::MAC_REPEATER_ENABLED, vec![repeater.enabled as u8]),
4447        ]);
4448    }
4449
4450    values.extend(positioning_values(
4451        configuration.gnss,
4452        configuration.tz_offset_min,
4453        capabilities,
4454    )?);
4455    values.extend(advert_values(configuration.advert, capabilities)?);
4456    Ok(values)
4457}
4458
4459/// Reduce the advertisement policy to property writes.
4460///
4461/// Split out for the same reason [`positioning_values`] is: a tethered
4462/// phone changes these on its companion radio without commissioning it,
4463/// and both paths have to produce the same writes.
4464fn advert_values(
4465    advert: Option<UlcpAdvertSettingsRecord>,
4466    capabilities: DeviceCapabilities,
4467) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
4468    let announces = capabilities.advert;
4469    if advert.is_some() != announces {
4470        return Err(MobileError::InvalidUlcpFrame);
4471    }
4472    let Some(advert) = advert else {
4473        return Ok(Vec::new());
4474    };
4475    // The device refuses these too. Catching them here means an
4476    // out-of-range interval fails before any of the group has been
4477    // written, rather than leaving the schedule half-changed.
4478    for interval in [
4479        advert.advert_interval_seconds,
4480        advert.beacon_interval_seconds,
4481    ] {
4482        if interval != 0
4483            && !(MIN_AUTO_ANNOUNCE_INTERVAL_S..=MAX_AUTO_ANNOUNCE_INTERVAL_S).contains(&interval)
4484        {
4485            return Err(MobileError::InvalidUlcpFrame);
4486        }
4487    }
4488    Ok(vec![
4489        (
4490            prop::ADVERT_INTERVAL,
4491            advert.advert_interval_seconds.to_le_bytes().to_vec(),
4492        ),
4493        (
4494            prop::BEACON_INTERVAL,
4495            advert.beacon_interval_seconds.to_le_bytes().to_vec(),
4496        ),
4497        (prop::STARTUP_BEACON, vec![advert.startup_beacon as u8]),
4498    ])
4499}
4500
4501/// Reduce the zone and the positioning policy to property writes.
4502///
4503/// Split out because these are the one part of a device's own domain a
4504/// phone changes on its *companion* radio without commissioning it —
4505/// [`MobileUlcpSession::configure_positioning`] writes exactly this list
4506/// and nothing else, where [`validate_device_settings`] folds it into a
4507/// whole-domain write. Same values either way, so the two paths cannot
4508/// drift apart.
4509///
4510/// Each field must be present exactly when its capability is: these
4511/// state a whole desired setting rather than a patch.
4512fn positioning_values(
4513    gnss: Option<UlcpGnssSettingsRecord>,
4514    tz_offset_min: Option<i16>,
4515    capabilities: DeviceCapabilities,
4516) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
4517    let mut values = Vec::new();
4518
4519    let keeps_time = capabilities.time;
4520    if tz_offset_min.is_some() != keeps_time {
4521        return Err(MobileError::InvalidUlcpFrame);
4522    }
4523    if let Some(minutes) = tz_offset_min {
4524        // The extremes of the zone database, not of the encoding: a
4525        // fourteen-hour offset is Kiritimati, and anything past it is a
4526        // caller mistake rather than a place.
4527        if !(-12 * 60..=14 * 60).contains(&minutes) {
4528            return Err(MobileError::InvalidUlcpFrame);
4529        }
4530        values.push((prop::TZ_OFFSET, minutes.to_le_bytes().to_vec()));
4531    }
4532
4533    let positioning = capabilities.gnss;
4534    if gnss.is_some() != positioning {
4535        return Err(MobileError::InvalidUlcpFrame);
4536    }
4537    if let Some(gnss) = gnss {
4538        if !(1..=MAX_PRECISION).contains(&gnss.ident_precision) {
4539            return Err(MobileError::InvalidUlcpFrame);
4540        }
4541        // Enabled last, so a receiver that starts looking does it under
4542        // the disclosure and trust policy just written rather than the
4543        // one it happened to be holding.
4544        values.extend([
4545            (prop::GNSS_IDENT_UPDATE, vec![gnss.ident_update as u8]),
4546            (prop::GNSS_IDENT_PRECISION, vec![gnss.ident_precision]),
4547            (prop::GNSS_TIME_TRUST, vec![gnss.time_trust as u8]),
4548            (prop::GNSS_ENABLED, vec![gnss.enabled as u8]),
4549        ]);
4550    }
4551    Ok(values)
4552}
4553
4554fn validate_radio_settings(
4555    settings: &UlcpRadioSettingsRecord,
4556    capabilities: DeviceCapabilities,
4557) -> Result<(), MobileError> {
4558    if settings.frequency_khz == 0 {
4559        return Err(MobileError::InvalidUlcpFrame);
4560    }
4561    if let Some(name) = &settings.device_name {
4562        if !capabilities.device_name
4563            || name.is_empty()
4564            || name.len() > 64
4565            || name.as_bytes().contains(&0)
4566        {
4567            return Err(MobileError::InvalidUlcpFrame);
4568        }
4569    }
4570    let lora = (
4571        settings.bandwidth_hz,
4572        settings.spreading_factor,
4573        settings.coding_rate_denom,
4574    );
4575    match lora {
4576        (None, None, None) if !capabilities.lora => {}
4577        (Some(bandwidth), Some(sf), Some(cr))
4578            if capabilities.lora
4579                && bandwidth > 0
4580                && (5..=12).contains(&sf)
4581                && (5..=8).contains(&cr) => {}
4582        _ => return Err(MobileError::InvalidUlcpFrame),
4583    }
4584    if settings.duty_cycle_limit.is_some() != capabilities.duty_cycle_limit {
4585        return Err(MobileError::InvalidUlcpFrame);
4586    }
4587    Ok(())
4588}
4589
4590/// A device name, which is UTF-8 and may be empty — a device that has not
4591/// been named, rather than one named nothing.
4592fn decode_device_name(value: &[u8]) -> Result<Option<String>, MobileError> {
4593    let name = core::str::from_utf8(value).map_err(|_| MobileError::InvalidUlcpFrame)?;
4594    Ok((!name.is_empty()).then(|| name.to_owned()))
4595}
4596
4597fn decode_u32(value: &[u8]) -> Result<u32, MobileError> {
4598    value
4599        .try_into()
4600        .map(u32::from_le_bytes)
4601        .map_err(|_| MobileError::InvalidUlcpFrame)
4602}
4603
4604/// Split a concatenation of fixed-width items into the items themselves.
4605/// The lossless counterpart of [`decode_fixed_count`], for properties whose
4606/// GET form reads back full values rather than digests.
4607fn decode_fixed_list<const N: usize>(value: &[u8]) -> Result<Vec<Vec<u8>>, MobileError> {
4608    items::fixed_items::<N>(value)
4609        .map_err(|_| MobileError::InvalidUlcpFrame)?
4610        .map(|item| Ok(item.to_vec()))
4611        .collect()
4612}
4613
4614fn decode_fixed_count<const N: usize>(value: &[u8]) -> Result<u32, MobileError> {
4615    let count = items::fixed_items::<N>(value)
4616        .map_err(|_| MobileError::InvalidUlcpFrame)?
4617        .count();
4618    count.try_into().map_err(|_| MobileError::InvalidUlcpFrame)
4619}
4620
4621fn decode_filter_count(value: &[u8]) -> Result<u32, MobileError> {
4622    let mut count = 0u32;
4623    for item in items::prefixed_items(value) {
4624        let item = item.map_err(|_| MobileError::InvalidUlcpFrame)?;
4625        Filter::decode(item).map_err(|_| MobileError::InvalidUlcpFrame)?;
4626        count = count.checked_add(1).ok_or(MobileError::InvalidUlcpFrame)?;
4627    }
4628    Ok(count)
4629}
4630
4631/// Split a ULCP frame into ATT values using the negotiated maximum write
4632/// length. The returned values include the one-octet SAR header.
4633#[uniffi::export]
4634pub fn ulcp_gatt_segments(
4635    frame: Vec<u8>,
4636    maximum_value_length: u16,
4637) -> Result<Vec<GattSegmentRecord>, MobileError> {
4638    let segment_payload = usize::from(maximum_value_length)
4639        .checked_sub(1)
4640        .filter(|length| *length > 0)
4641        .ok_or(MobileError::GattMtuTooSmall)?;
4642    if frame.len() > MAX_FRAME {
4643        return Err(MobileError::InvalidUlcpFrame);
4644    }
4645
4646    Ok(gatt::segments(&frame, segment_payload)
4647        .map(|segment| {
4648            let mut value = vec![0; segment.payload().len() + 1];
4649            let length = segment
4650                .write_to(&mut value)
4651                .expect("sized from the segment payload");
4652            value.truncate(length);
4653            GattSegmentRecord { value }
4654        })
4655        .collect())
4656}
4657
4658/// Frame a ULCP frame for an HDLC-Lite byte stream — a serial port, or
4659/// a socket standing in for one — including both delimiting flags.
4660///
4661/// The byte-stream counterpart of [`ulcp_gatt_segments`]: a stream has
4662/// no segmentation, so one frame encodes to one write.
4663#[uniffi::export]
4664pub fn ulcp_hdlc_encode(frame: Vec<u8>) -> Result<Vec<u8>, MobileError> {
4665    if frame.len() > MAX_FRAME {
4666        return Err(MobileError::InvalidUlcpFrame);
4667    }
4668    let mut wire = vec![0; hdlc::max_encoded_len(frame.len())];
4669    let length =
4670        hdlc::encode_frame(&frame, &mut wire).map_err(|_| MobileError::InvalidUlcpFrame)?;
4671    wire.truncate(length);
4672    Ok(wire)
4673}
4674
4675/// Encode a `CMD_PROP_GET` request with the shared ULCP codec.
4676#[uniffi::export]
4677pub fn ulcp_prop_get(transaction_id: u8, property_id: u32) -> Result<Vec<u8>, MobileError> {
4678    let mut output = [0; 8];
4679    let length = frame::prop_get(&mut output, transaction_id, property_id)
4680        .map_err(|_| MobileError::InvalidUlcpFrame)?;
4681    Ok(output[..length].to_vec())
4682}
4683
4684/// Encode a `CMD_PROP_SET` request with the shared ULCP codec.
4685#[uniffi::export]
4686pub fn ulcp_prop_set(
4687    transaction_id: u8,
4688    property_id: u32,
4689    value: Vec<u8>,
4690) -> Result<Vec<u8>, MobileError> {
4691    if value.len() > MAX_FRAME {
4692        return Err(MobileError::InvalidUlcpFrame);
4693    }
4694    let mut output = vec![0; MAX_FRAME];
4695    let length = frame::prop_set(&mut output, transaction_id, property_id, &value)
4696        .map_err(|_| MobileError::InvalidUlcpFrame)?;
4697    output.truncate(length);
4698    Ok(output)
4699}
4700
4701/// Encode a `CMD_PROP_INSERT` request. Deliberately not exported: typed
4702/// session operations own multi-value mutations.
4703fn ulcp_prop_insert(
4704    transaction_id: u8,
4705    property_id: u32,
4706    item: &[u8],
4707) -> Result<Vec<u8>, MobileError> {
4708    let mut output = vec![0; MAX_FRAME];
4709    let length = frame::prop_insert(&mut output, transaction_id, property_id, item)
4710        .map_err(|_| MobileError::InvalidUlcpFrame)?;
4711    output.truncate(length);
4712    Ok(output)
4713}
4714
4715/// Encode a `CMD_PROP_REMOVE` request. Deliberately not exported, like
4716/// [`ulcp_prop_insert`].
4717fn ulcp_prop_remove(
4718    transaction_id: u8,
4719    property_id: u32,
4720    selector: &[u8],
4721) -> Result<Vec<u8>, MobileError> {
4722    let mut output = vec![0; MAX_FRAME];
4723    let length = frame::prop_remove(&mut output, transaction_id, property_id, selector)
4724        .map_err(|_| MobileError::InvalidUlcpFrame)?;
4725    output.truncate(length);
4726    Ok(output)
4727}
4728
4729/// Capacity of the device identity's peer list (`PROP_DEV_PEERS`).
4730///
4731/// A label constant only — the device's `NOMEM` stays authoritative for
4732/// when the list is actually full.
4733#[uniffi::export]
4734pub fn ulcp_max_dev_peers() -> u8 {
4735    8
4736}
4737
4738/// Capacity of the device identity's channel list (`PROP_DEV_CHANNEL_KEYS`).
4739///
4740/// A label constant, like [`ulcp_max_dev_peers`].
4741#[uniffi::export]
4742pub fn ulcp_max_dev_channels() -> u8 {
4743    8
4744}
4745
4746/// Capacity of the device identity's administrator list
4747/// (`PROP_DEV_ADMINS`).
4748///
4749/// A label constant, like [`ulcp_max_dev_peers`].
4750#[uniffi::export]
4751pub fn ulcp_max_dev_admins() -> u8 {
4752    8
4753}
4754
4755/// One vetted PHY profile, as a preset picker consumes it.
4756///
4757/// A crossing of `umsh_ulcp::profiles::PhyProfile`. The bindings carry
4758/// no constants, so the table travels as a function.
4759#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
4760pub struct RadioPresetRecord {
4761    pub id: String,
4762    pub name: String,
4763    pub frequency_khz: u32,
4764    pub bandwidth_hz: u32,
4765    pub spreading_factor: u8,
4766    pub coding_rate_denom: u8,
4767    /// Absent where the profile has no vetted power, in which case
4768    /// adopting it leaves a device's configured power alone.
4769    pub transmit_power_dbm: Option<i8>,
4770    pub duty_cycle_limit: u16,
4771    pub sync_word: u16,
4772    pub tx_preamble_symbols: u16,
4773}
4774
4775/// Every vetted radio profile, in the order to offer them: the profile
4776/// a device ships on first.
4777#[uniffi::export]
4778pub fn ulcp_radio_presets() -> Vec<RadioPresetRecord> {
4779    umsh_ulcp::profiles::VETTED
4780        .iter()
4781        .map(|profile| RadioPresetRecord {
4782            id: profile.id.to_string(),
4783            name: profile.name.to_string(),
4784            frequency_khz: profile.freq_khz,
4785            bandwidth_hz: profile.bw_hz,
4786            spreading_factor: profile.sf,
4787            coding_rate_denom: profile.cr_denom,
4788            transmit_power_dbm: profile.tx_power_dbm,
4789            duty_cycle_limit: profile.duty_limit,
4790            sync_word: profile.sync_word,
4791            tx_preamble_symbols: profile.tx_preamble_symbols,
4792        })
4793        .collect()
4794}
4795
4796/// The LoRa bandwidths a device accepts, in Hz, ascending.
4797#[uniffi::export]
4798pub fn ulcp_supported_bandwidths_hz() -> Vec<u32> {
4799    umsh_ulcp::profiles::SUPPORTED_BANDWIDTHS_HZ.to_vec()
4800}
4801
4802/// Derive the identifier a device will echo for a channel key.
4803fn dev_channel_id(channel_key: &[u8]) -> Result<Vec<u8>, MobileError> {
4804    let bytes: [u8; items::CHANNEL_KEY_LEN] = channel_key
4805        .try_into()
4806        .map_err(|_| MobileError::InvalidChannelKeyLength)?;
4807    Ok(crate::derive_channel_id(bytes.to_vec())?)
4808}
4809
4810/// Encode a `CMD_SAVE` request with the shared ULCP codec.
4811#[uniffi::export]
4812pub fn ulcp_save(transaction_id: u8) -> Result<Vec<u8>, MobileError> {
4813    let mut output = [0; 2];
4814    let length =
4815        frame::save(&mut output, transaction_id).map_err(|_| MobileError::InvalidUlcpFrame)?;
4816    Ok(output[..length].to_vec())
4817}
4818
4819/// Encode a `CMD_FACTORY_RESET` request with the shared ULCP codec.
4820#[uniffi::export]
4821pub fn ulcp_factory_reset(transaction_id: u8) -> Result<Vec<u8>, MobileError> {
4822    let mut output = [0; 2];
4823    let length = frame::factory_reset(&mut output, transaction_id)
4824        .map_err(|_| MobileError::InvalidUlcpFrame)?;
4825    Ok(output[..length].to_vec())
4826}
4827
4828/// Encode a `CMD_REBOOT` request with the shared ULCP codec.
4829#[uniffi::export]
4830pub fn ulcp_reboot(transaction_id: u8) -> Result<Vec<u8>, MobileError> {
4831    let mut output = [0; 2];
4832    let length =
4833        frame::reboot(&mut output, transaction_id).map_err(|_| MobileError::InvalidUlcpFrame)?;
4834    Ok(output[..length].to_vec())
4835}
4836
4837/// Encode a `CMD_BLE_CLEAR_BONDS` request with the shared ULCP codec.
4838#[uniffi::export]
4839pub fn ulcp_ble_clear_bonds(transaction_id: u8) -> Result<Vec<u8>, MobileError> {
4840    let mut output = [0; 2];
4841    let length = frame::ble_clear_bonds(&mut output, transaction_id)
4842        .map_err(|_| MobileError::InvalidUlcpFrame)?;
4843    Ok(output[..length].to_vec())
4844}
4845
4846/// What a status code is called.
4847///
4848/// A device answering across the mesh reports a bare code, where a device
4849/// on the local link reports one already named in
4850/// [`UlcpOperationErrorRecord`]. Both are the same statuses, so both are
4851/// named the same way here rather than by a table on the other side of
4852/// the bindings that would have to be kept in step with this one.
4853#[uniffi::export]
4854pub fn ulcp_status_name(status: u32) -> String {
4855    format!("{:?}", umsh_ulcp::Status(status))
4856}
4857
4858/// Decode an exact packed status value from `PROP_LAST_STATUS`.
4859#[uniffi::export]
4860pub fn inspect_ulcp_status(value: Vec<u8>) -> Result<u32, MobileError> {
4861    decode_exact_pui(&value)
4862}
4863
4864fn ulcp_operation_error(
4865    operation: String,
4866    value: &[u8],
4867) -> Result<UlcpOperationErrorRecord, MobileError> {
4868    let status_code = inspect_ulcp_status(value.to_vec())?;
4869    let status = umsh_ulcp::Status(status_code);
4870    if status == umsh_ulcp::Status::OK {
4871        // A property operation that promised an echoed value cannot silently
4872        // substitute status-only success. That is a real session violation,
4873        // not a reported operation error.
4874        return Err(MobileError::InvalidUlcpFrame);
4875    }
4876    Ok(UlcpOperationErrorRecord {
4877        operation,
4878        status_code,
4879        status_name: format!("{status:?}"),
4880    })
4881}
4882
4883/// Summarize a frame's header for a diagnostic log.
4884///
4885/// Deliberately structural: transaction, command, property, and lengths,
4886/// never payload bytes. A platform logging this alongside a rejected
4887/// frame's cause learns what arrived without putting message contents
4888/// in the system log.
4889#[uniffi::export]
4890pub fn describe_ulcp_frame(bytes: Vec<u8>) -> String {
4891    let Ok(parsed) = Frame::parse(&bytes) else {
4892        return format!("unparsable len={}", bytes.len());
4893    };
4894    let command = match parsed.command() {
4895        Some(cmd) => format!("{cmd:?}({})", parsed.cmd),
4896        None => format!("unknown({})", parsed.cmd),
4897    };
4898    let property = match PropertyNotification::parse(&bytes) {
4899        Ok(notification) => format!(
4900            " prop=0x{:04x} value={}B",
4901            notification.key,
4902            notification.value.len()
4903        ),
4904        Err(_) => String::new(),
4905    };
4906    format!(
4907        "tid={} cmd={command}{property} len={}",
4908        parsed.header.tid(),
4909        bytes.len()
4910    )
4911}
4912
4913/// Parse and validate a property notification or response.
4914#[uniffi::export]
4915pub fn inspect_ulcp_property_frame(bytes: Vec<u8>) -> Result<UlcpPropertyFrameRecord, MobileError> {
4916    let parsed = PropertyNotification::parse(&bytes).map_err(|cause| match cause {
4917        PropertyNotificationError::MalformedFrame => MobileError::UlcpFrameUnparsable,
4918        PropertyNotificationError::UnexpectedCommand => MobileError::UlcpUnexpectedCommand,
4919        PropertyNotificationError::MalformedPayload => MobileError::UlcpMalformedPayload,
4920    })?;
4921    Ok(UlcpPropertyFrameRecord {
4922        transaction_id: parsed.tid,
4923        command: parsed.kind.command() as u8,
4924        property_id: parsed.key,
4925        value: parsed.value.to_vec(),
4926    })
4927}
4928
4929/// Validate and reduce a `PROP_BATTERY` value to fields used by mobile UI.
4930#[uniffi::export]
4931pub fn inspect_ulcp_battery(value: Vec<u8>) -> Result<UlcpBatteryRecord, MobileError> {
4932    let battery = BatteryStatus::decode(&value).map_err(|_| MobileError::InvalidUlcpFrame)?;
4933    Ok(UlcpBatteryRecord {
4934        percentage: battery.level_percent,
4935        voltage_mv: battery.voltage_mv,
4936        charge_state: battery.charge_state.map(UlcpChargeState::from_wire),
4937    })
4938}
4939
4940/// Encode a `PROP_ALERT` value.
4941///
4942/// Shared by the local link and by a mesh administrator so an alert is one
4943/// encoding rather than two that have to agree.
4944pub(crate) fn encode_alert_state(state: UlcpAlertState) -> Result<Vec<u8>, MobileError> {
4945    let mut value = [0u8; pui::MAX_LEN];
4946    let len = pui::encode(state.to_wire().code(), &mut value)
4947        .map_err(|_| MobileError::InvalidUlcpFrame)?;
4948    Ok(value[..len].to_vec())
4949}
4950
4951/// Validate and reduce a `PROP_ALERT` value.
4952#[uniffi::export]
4953pub fn inspect_ulcp_alert(value: Vec<u8>) -> Result<UlcpAlertState, MobileError> {
4954    let (code, consumed) = pui::decode(&value).map_err(|_| MobileError::InvalidUlcpFrame)?;
4955    if consumed != value.len() {
4956        return Err(MobileError::InvalidUlcpFrame);
4957    }
4958    AlertState::from_code(code)
4959        .map(UlcpAlertState::from_wire)
4960        .ok_or(MobileError::InvalidUlcpFrame)
4961}
4962
4963/// Read a region code from what someone typed, yielding the two wire
4964/// octets used everywhere else in the ULCP and mesh surfaces.
4965///
4966/// One to three ASCII letters or digits are a short code — an airport,
4967/// a country, a state — `0xXXXX` is a literal code, and anything else is a
4968/// region *name* hashed into a part of the code space disjoint from the
4969/// all-letter short codes. So "SJC" and "San Jose" are deliberately
4970/// different regions, and no name can ever collide with a letter code.
4971#[uniffi::export]
4972pub fn region_code_from_string(text: String) -> Result<Vec<u8>, MobileError> {
4973    text.parse::<RegionCode>()
4974        .map(|code| code.to_bytes().to_vec())
4975        .map_err(|_| MobileError::InvalidRegionCode)
4976}
4977
4978/// Render a region code for display. Codes derived from an all-letter
4979/// short code come back as those letters; everything else as `0xXXXX`,
4980/// which [`region_code_from_string`] reads back.
4981#[uniffi::export]
4982pub fn region_code_description(code: Vec<u8>) -> Result<String, MobileError> {
4983    let bytes: [u8; items::REGION_CODE_LEN] = code
4984        .try_into()
4985        .map_err(|_| MobileError::InvalidRegionCode)?;
4986    Ok(RegionCode::from_bytes(bytes).to_string())
4987}
4988
4989/// Stateful, bounded receiver for Frame Out notifications.
4990#[derive(uniffi::Object)]
4991pub struct MobileGattReassembler {
4992    inner: Mutex<Reassembler<MAX_FRAME>>,
4993}
4994
4995#[uniffi::export]
4996impl MobileGattReassembler {
4997    #[uniffi::constructor]
4998    pub fn new() -> Arc<Self> {
4999        Arc::new(Self {
5000            inner: Mutex::new(Reassembler::new()),
5001        })
5002    }
5003
5004    /// Consume one ATT value, returning a complete ULCP frame when the
5005    /// segment ends one. Invalid input resets the shared reassembly state.
5006    pub fn push(&self, segment: Vec<u8>) -> Result<Option<Vec<u8>>, MobileError> {
5007        let mut reassembler = self.inner.lock().expect("GATT reassembler mutex poisoned");
5008        match reassembler.push(&segment) {
5009            None => Ok(None),
5010            Some(Ok(frame)) => Ok(Some(frame.to_vec())),
5011            Some(Err(cause)) => Err(cause.into()),
5012        }
5013    }
5014
5015    pub fn reset(&self) {
5016        self.inner
5017            .lock()
5018            .expect("GATT reassembler mutex poisoned")
5019            .reset();
5020    }
5021}
5022
5023/// Stateful, bounded receiver for an HDLC-Lite byte stream.
5024///
5025/// Sized to admit exactly the frames [`MobileGattReassembler`] does:
5026/// the decoder's bound counts the two FCS octets, which a ULCP frame's
5027/// own length does not.
5028#[derive(uniffi::Object)]
5029pub struct MobileHdlcDecoder {
5030    inner: Mutex<hdlc::Decoder<{ MAX_FRAME + 2 }>>,
5031}
5032
5033#[uniffi::export]
5034impl MobileHdlcDecoder {
5035    #[uniffi::constructor]
5036    pub fn new() -> Arc<Self> {
5037        Arc::new(Self {
5038            inner: Mutex::new(hdlc::Decoder::new()),
5039        })
5040    }
5041
5042    /// Consume received bytes, returning every frame they completed.
5043    ///
5044    /// A stream delivers arbitrary chunks rather than whole frames, so
5045    /// one call can complete none or several. Corrupt and oversized
5046    /// frames are discarded rather than reported: the decoder
5047    /// resynchronizes on the next flag, and a byte stream can carry
5048    /// line noise that belongs to nobody — a bridge opening a serial
5049    /// port mid-transmission, most commonly. This is the one place the
5050    /// two transports differ, GATT being reliable enough that a bad
5051    /// segment is a protocol violation worth surfacing.
5052    pub fn push(&self, bytes: Vec<u8>) -> Vec<Vec<u8>> {
5053        let mut decoder = self.inner.lock().expect("HDLC decoder mutex poisoned");
5054        let mut frames = Vec::new();
5055        for byte in bytes {
5056            if let Some(Ok(frame)) = decoder.push(byte) {
5057                frames.push(frame.to_vec());
5058            }
5059        }
5060        frames
5061    }
5062
5063    /// Discard any partially received frame. Used when the link comes
5064    /// up, so a half-frame from a previous connection cannot merge into
5065    /// the first frame of this one.
5066    pub fn reset(&self) {
5067        self.inner
5068            .lock()
5069            .expect("HDLC decoder mutex poisoned")
5070            .reset();
5071    }
5072}
5073
5074#[cfg(test)]
5075mod tests {
5076    use super::*;
5077    use umsh_ulcp::PropPayload;
5078
5079    fn response(property_id: u32, value: &[u8]) -> UlcpPropertyFrameRecord {
5080        UlcpPropertyFrameRecord {
5081            transaction_id: 1,
5082            command: Cmd::PropIs as u8,
5083            property_id,
5084            value: value.to_vec(),
5085        }
5086    }
5087
5088    fn encoded_capabilities(values: &[u32]) -> Vec<u8> {
5089        let mut encoded = Vec::new();
5090        for value in values {
5091            let mut bytes = [0; pui::MAX_LEN];
5092            let len = pui::encode(*value, &mut bytes).unwrap();
5093            encoded.extend_from_slice(&bytes[..len]);
5094        }
5095        encoded
5096    }
5097
5098    fn property_request(bytes: &[u8]) -> (u8, u32) {
5099        let parsed = Frame::parse(bytes).unwrap();
5100        assert_eq!(parsed.command(), Some(Cmd::PropGet));
5101        let (property, used) = pui::decode(parsed.payload).unwrap();
5102        assert_eq!(used, parsed.payload.len());
5103        (parsed.header.tid(), property)
5104    }
5105
5106    fn property_response(tid: u8, property: u32, value: &[u8]) -> Vec<u8> {
5107        let mut bytes = vec![0; MAX_FRAME];
5108        let length = frame::prop_is(&mut bytes, tid, property, value).unwrap();
5109        bytes.truncate(length);
5110        bytes
5111    }
5112
5113    fn answer_requests(
5114        session: &MobileUlcpSession,
5115        requests: Vec<Vec<u8>>,
5116        value: impl Fn(u32) -> (u32, Vec<u8>),
5117    ) -> UlcpSessionUpdateRecord {
5118        let mut last = None;
5119        for request in requests {
5120            let (tid, requested) = property_request(&request);
5121            let (returned, bytes) = value(requested);
5122            last = Some(
5123                session
5124                    .consume(property_response(tid, returned, &bytes))
5125                    .unwrap(),
5126            );
5127        }
5128        last.unwrap()
5129    }
5130
5131    /// Capabilities of a device that is a full mesh citizen in its own
5132    /// right: it has an identity, advertises one, can forward, and answers
5133    /// to the administrators it lists.
5134    fn commissionable_capabilities() -> Vec<u32> {
5135        vec![
5136            cap::HOST_FILTER,
5137            cap::SAVE,
5138            cap::DEV_NAME,
5139            cap::DEV_IDENTITY,
5140            cap::REPEATER,
5141            cap::IDENT,
5142            cap::ADMIN,
5143        ]
5144    }
5145
5146    /// Answer whatever the session asks for, for a device with the
5147    /// capabilities above and a factory-default device domain.
5148    fn commissionable_value(property: u32) -> (u32, Vec<u8>) {
5149        let value = match property {
5150            prop::LAST_STATUS => vec![0],
5151            prop::PROTOCOL_VERSION => vec![6, 0],
5152            prop::CAPS => encoded_capabilities(&commissionable_capabilities()),
5153            prop::DEV_NAME => b"Ridge repeater".to_vec(),
5154            prop::DEV_KEY => vec![0x5A; 32],
5155            prop::BATTERY => Vec::new(),
5156            prop::INTERFACE_TYPE => vec![INTERFACE_TYPE as u8],
5157            prop::PHY_ENABLED => vec![1],
5158            prop::PHY_FREQ => 915_000u32.to_le_bytes().to_vec(),
5159            prop::PHY_TX_POWER => vec![14],
5160            prop::SAVED => vec![saved::CURRENT],
5161            prop::HOST_RX_FILTERS => Vec::new(),
5162            prop::MAC_REPEATER_ENABLED => vec![0],
5163            prop::MAC_REPEATER_REGIONS
5164            | prop::MAC_REPEATER_DEFAULT_REGION
5165            | prop::MAC_REPEATER_MIN_RSSI
5166            | prop::MAC_REPEATER_MIN_SNR
5167            | prop::IDENT_ROLE
5168            // A factory-default device states no position and no height.
5169            | prop::IDENT_LOCATION
5170            | prop::IDENT_ALTITUDE
5171            | prop::DEV_PEERS
5172            | prop::DEV_ADMINS
5173            | prop::DEV_CHANNEL_KEYS => Vec::new(),
5174            prop::IDENT_MOBILE => vec![0],
5175            prop::DEV_DISCOVERABLE => vec![1],
5176            other => unreachable!("unexpected property {other}"),
5177        };
5178        (property, value)
5179    }
5180
5181    /// Answer every bounded read batch until the session stops asking.
5182    fn drive_reads(
5183        session: &MobileUlcpSession,
5184        requests: Vec<Vec<u8>>,
5185        value: impl Fn(u32) -> (u32, Vec<u8>),
5186    ) -> UlcpSessionUpdateRecord {
5187        let mut pending = requests;
5188        let mut last = None;
5189        while !pending.is_empty() {
5190            let update = answer_requests(session, pending, &value);
5191            pending = update.outbound_frames.clone();
5192            last = Some(update);
5193        }
5194        last.expect("at least one batch")
5195    }
5196
5197    /// Bring a session to `Attached` against a commissionable device that
5198    /// reports `host_key` as its tethered host.
5199    fn attach_commissionable(
5200        session: &MobileUlcpSession,
5201        selected_host_key: Option<Vec<u8>>,
5202        host_key: Vec<u8>,
5203    ) -> UlcpSessionUpdateRecord {
5204        let begin = session.begin(selected_host_key).unwrap();
5205        drive_reads(session, begin.outbound_frames, move |property| {
5206            if property == prop::HOST_KEY {
5207                (property, host_key.clone())
5208            } else {
5209                commissionable_value(property)
5210            }
5211        })
5212    }
5213
5214    /// Bring a session to `Attached` against a device that also offers the
5215    /// host key tables, which the commissionable fixture deliberately does
5216    /// not.
5217    fn attach_host_keys_capable(session: &MobileUlcpSession) -> UlcpSessionUpdateRecord {
5218        let mut capabilities = commissionable_capabilities();
5219        capabilities.push(cap::HOST_KEYS);
5220        let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
5221        drive_reads(
5222            session,
5223            begin.outbound_frames,
5224            move |property| match property {
5225                prop::CAPS => (property, encoded_capabilities(&capabilities)),
5226                prop::HOST_KEY => (property, vec![0xAA; 32]),
5227                prop::HOST_CHANNEL_KEYS | prop::HOST_PEER_KEYS => (property, Vec::new()),
5228                _ => commissionable_value(property),
5229            },
5230        )
5231    }
5232
5233    /// Consume a configuration batch, returning the writes it made as a
5234    /// property map, the order they were issued in, and the `CMD_SAVE`
5235    /// transaction that closed it.
5236    fn drive_configuration(
5237        session: &MobileUlcpSession,
5238        first_batch: Vec<Vec<u8>>,
5239    ) -> (HashMap<u32, Vec<u8>>, Vec<u32>, u8) {
5240        let mut pending = VecDeque::from(first_batch);
5241        let mut written = HashMap::new();
5242        let mut order = Vec::new();
5243        loop {
5244            let request = pending.pop_front().expect("configuration ends in a save");
5245            let parsed = Frame::parse(&request).unwrap();
5246            if parsed.command() == Some(Cmd::Save) {
5247                return (written, order, parsed.header.tid());
5248            }
5249            assert_eq!(parsed.command(), Some(Cmd::PropSet));
5250            let payload = PropPayload::parse(parsed.payload).unwrap();
5251            order.push(payload.key);
5252            written.insert(payload.key, payload.value.to_vec());
5253            let update = session
5254                .consume(property_response(
5255                    parsed.header.tid(),
5256                    payload.key,
5257                    payload.value,
5258                ))
5259                .unwrap_or_else(|error| {
5260                    panic!("write of property {} failed: {error:?}", payload.key)
5261                });
5262            pending.extend(update.outbound_frames);
5263        }
5264    }
5265
5266    #[test]
5267    fn exported_gatt_round_trip_uses_shared_codec() {
5268        let frame = ulcp_prop_get(3, 4_864).unwrap();
5269        let segments = ulcp_gatt_segments(frame.clone(), 4).unwrap();
5270        let receiver = MobileGattReassembler::new();
5271        let mut completed = None;
5272        for segment in segments {
5273            if let Some(value) = receiver.push(segment.value).unwrap() {
5274                completed = Some(value);
5275            }
5276        }
5277        assert_eq!(completed, Some(frame));
5278    }
5279
5280    #[test]
5281    fn exported_hdlc_round_trip_uses_shared_codec() {
5282        let frame = ulcp_prop_get(3, 4_864).unwrap();
5283        let wire = ulcp_hdlc_encode(frame.clone()).unwrap();
5284
5285        // The export must produce the bytes the shared encoder does,
5286        // or a phone and a serial host would disagree on the wire.
5287        let mut expected = vec![0; hdlc::max_encoded_len(frame.len())];
5288        let length = hdlc::encode_frame(&frame, &mut expected).unwrap();
5289        expected.truncate(length);
5290        assert_eq!(wire, expected);
5291
5292        let decoder = MobileHdlcDecoder::new();
5293        assert_eq!(decoder.push(wire), vec![frame]);
5294    }
5295
5296    #[test]
5297    fn hdlc_decoding_spans_chunk_boundaries_and_batches() {
5298        // A stream hands over arbitrary chunks: a frame can arrive in
5299        // pieces, and one read can carry several frames.
5300        let first = ulcp_prop_get(1, 4_864).unwrap();
5301        let second = ulcp_prop_get(2, 4_865).unwrap();
5302        let mut wire = ulcp_hdlc_encode(first.clone()).unwrap();
5303        wire.extend(ulcp_hdlc_encode(second.clone()).unwrap());
5304
5305        let decoder = MobileHdlcDecoder::new();
5306        let split = wire.len() / 3;
5307        assert!(decoder.push(wire[..split].to_vec()).is_empty());
5308        assert_eq!(decoder.push(wire[split..].to_vec()), vec![first, second]);
5309    }
5310
5311    #[test]
5312    fn hdlc_decoding_escapes_the_framing_bytes() {
5313        // A frame whose payload spells the delimiters must survive.
5314        let frame = vec![hdlc::FLAG, hdlc::ESCAPE, 0x11, 0x13, 0x00, 0xFF];
5315        let decoder = MobileHdlcDecoder::new();
5316        assert_eq!(
5317            decoder.push(ulcp_hdlc_encode(frame.clone()).unwrap()),
5318            vec![frame]
5319        );
5320    }
5321
5322    #[test]
5323    fn hdlc_decoding_resynchronizes_past_noise() {
5324        // Opening a bridged port mid-transmission leaves a partial
5325        // frame in the stream; the next good one must still arrive.
5326        let frame = ulcp_prop_get(7, 4_864).unwrap();
5327        let mut wire = vec![0x01, 0x02, hdlc::FLAG, 0xDE, 0xAD];
5328        wire.extend(ulcp_hdlc_encode(frame.clone()).unwrap());
5329
5330        let decoder = MobileHdlcDecoder::new();
5331        assert_eq!(decoder.push(wire), vec![frame]);
5332    }
5333
5334    #[test]
5335    fn hdlc_encoding_admits_exactly_what_gatt_does() {
5336        // Both transports carry the same frames, so a phone cannot
5337        // build one it could send over BLE but not over a socket.
5338        let largest = vec![0xA5; MAX_FRAME];
5339        let decoder = MobileHdlcDecoder::new();
5340        assert_eq!(
5341            decoder.push(ulcp_hdlc_encode(largest.clone()).unwrap()),
5342            vec![largest]
5343        );
5344        assert!(matches!(
5345            ulcp_hdlc_encode(vec![0xA5; MAX_FRAME + 1]),
5346            Err(MobileError::InvalidUlcpFrame)
5347        ));
5348    }
5349
5350    #[test]
5351    fn a_reset_decoder_drops_the_partial_frame() {
5352        let frame = ulcp_prop_get(5, 4_864).unwrap();
5353        let wire = ulcp_hdlc_encode(frame.clone()).unwrap();
5354
5355        let decoder = MobileHdlcDecoder::new();
5356        assert!(decoder.push(wire[..wire.len() - 2].to_vec()).is_empty());
5357        // Without the reset the tail would finish the stale frame.
5358        decoder.reset();
5359        assert!(decoder.push(wire[wire.len() - 2..].to_vec()).is_empty());
5360        assert_eq!(decoder.push(wire), vec![frame]);
5361    }
5362
5363    #[test]
5364    fn property_response_is_validated_and_typed() {
5365        let mut bytes = [0; 16];
5366        let length = frame::prop_is(&mut bytes, 5, 64, &[1, 2, 3]).unwrap();
5367        assert_eq!(
5368            inspect_ulcp_property_frame(bytes[..length].to_vec()).unwrap(),
5369            UlcpPropertyFrameRecord {
5370                transaction_id: 5,
5371                command: Cmd::PropIs as u8,
5372                property_id: 64,
5373                value: vec![1, 2, 3],
5374            }
5375        );
5376    }
5377
5378    #[test]
5379    fn property_set_uses_shared_frame_codec() {
5380        let encoded = ulcp_prop_set(6, 96, vec![7; 32]).unwrap();
5381        let parsed = Frame::parse(&encoded).unwrap();
5382        assert_eq!(parsed.header.tid(), 6);
5383        assert_eq!(parsed.command(), Some(Cmd::PropSet));
5384        let payload = PropPayload::parse(parsed.payload).unwrap();
5385        assert_eq!(payload.key, 96);
5386        assert_eq!(payload.value, &[7; 32]);
5387    }
5388
5389    #[test]
5390    fn save_and_status_use_shared_frame_codec() {
5391        let encoded = ulcp_save(7).unwrap();
5392        let parsed = Frame::parse(&encoded).unwrap();
5393        assert_eq!(parsed.header.tid(), 7);
5394        assert_eq!(parsed.command(), Some(Cmd::Save));
5395        assert!(parsed.payload.is_empty());
5396
5397        assert_eq!(inspect_ulcp_status(vec![0]).unwrap(), 0);
5398        assert_eq!(
5399            inspect_ulcp_status(vec![0x80]),
5400            Err(MobileError::InvalidUlcpFrame)
5401        );
5402    }
5403
5404    #[test]
5405    fn exported_transport_rejects_invalid_bounds_and_segments() {
5406        assert_eq!(
5407            ulcp_gatt_segments(vec![0; MAX_FRAME + 1], 20),
5408            Err(MobileError::InvalidUlcpFrame)
5409        );
5410        assert_eq!(
5411            ulcp_gatt_segments(vec![1], 1),
5412            Err(MobileError::GattMtuTooSmall)
5413        );
5414        assert_eq!(
5415            MobileGattReassembler::new().push(vec![]),
5416            Err(MobileError::GattSegmentRunt)
5417        );
5418        // Each reassembly failure names itself, so a fatal link teardown
5419        // says which one happened rather than "invalid segment".
5420        let receiver = MobileGattReassembler::new();
5421        assert_eq!(
5422            receiver.push(vec![0xC0]),
5423            Err(MobileError::GattSegmentOrphan)
5424        );
5425        assert_eq!(
5426            receiver.push(vec![0x08]),
5427            Err(MobileError::GattSegmentReservedBits)
5428        );
5429        let mut oversized = vec![0u8; MAX_FRAME + 2];
5430        oversized[0] = gatt::SAR_FIRST << 6;
5431        assert_eq!(
5432            receiver.push(oversized),
5433            Err(MobileError::GattSegmentTooLong)
5434        );
5435    }
5436
5437    #[test]
5438    fn battery_reduction_preserves_supported_ui_fields() {
5439        assert_eq!(
5440            inspect_ulcp_battery(vec![0b110, 82, 1]).unwrap(),
5441            UlcpBatteryRecord {
5442                percentage: Some(82),
5443                voltage_mv: None,
5444                charge_state: Some(UlcpChargeState::Charging),
5445            }
5446        );
5447        assert_eq!(
5448            inspect_ulcp_battery(vec![0b111, 0xEC, 0x0E, 82, 0]).unwrap(),
5449            UlcpBatteryRecord {
5450                percentage: Some(82),
5451                voltage_mv: Some(3820),
5452                charge_state: Some(UlcpChargeState::Discharging),
5453            }
5454        );
5455        assert_eq!(
5456            inspect_ulcp_battery(vec![]).unwrap(),
5457            UlcpBatteryRecord {
5458                percentage: None,
5459                voltage_mv: None,
5460                charge_state: None,
5461            }
5462        );
5463    }
5464
5465    #[test]
5466    fn a_reading_is_carried_when_reported_and_missed_quietly_when_not() {
5467        let capabilities = encoded_capabilities(&[cap::BATTERY, cap::ALERT]);
5468        let base = |extra: Vec<UlcpPropertyFrameRecord>| {
5469            let mut responses = vec![
5470                response(prop::CAPS, &capabilities),
5471                response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
5472                response(prop::PHY_ENABLED, &[1]),
5473                response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
5474                response(prop::PHY_TX_POWER, &[14]),
5475            ];
5476            responses.extend(extra);
5477            inspect_ulcp_sync(responses).unwrap()
5478        };
5479
5480        let reported = base(vec![
5481            response(prop::BATTERY, &[0b110, 82, 1]),
5482            response(prop::ALERT, &[AlertState::Locate.code() as u8]),
5483        ]);
5484        assert_eq!(reported.battery.unwrap().percentage, Some(82));
5485        assert_eq!(reported.alert, Some(UlcpAlertState::Locate));
5486
5487        // The case that matters: a device that has said nothing about
5488        // either is not a device withholding settings. Both are absent, and
5489        // neither shows up in the notice about configuration this phone
5490        // would write over.
5491        let silent = base(Vec::new());
5492        assert!(silent.supports_battery && silent.supports_alert);
5493        assert_eq!(silent.battery, None);
5494        assert_eq!(silent.alert, None);
5495        assert!(silent.unreadable_properties.is_empty());
5496    }
5497
5498    #[test]
5499    fn minimal_inspection_is_small_and_validated() {
5500        assert_eq!(
5501            ulcp_inspection_properties(vec![cap::WRITABLE_RAW_STREAM as u8]).unwrap(),
5502            [
5503                prop::INTERFACE_TYPE,
5504                prop::PHY_ENABLED,
5505                prop::PHY_FREQ,
5506                prop::PHY_TX_POWER,
5507            ]
5508        );
5509        let sync = inspect_ulcp_sync(vec![
5510            response(prop::CAPS, &[cap::WRITABLE_RAW_STREAM as u8]),
5511            response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
5512            response(prop::PHY_ENABLED, &[1]),
5513            response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
5514            response(prop::PHY_TX_POWER, &[14]),
5515        ])
5516        .unwrap();
5517        assert!(sync.phy_enabled);
5518        assert_eq!(sync.frequency_khz, 915_000);
5519        assert_eq!(sync.transmit_power_dbm, 14);
5520        assert!(!sync.has_host_filtering);
5521        assert_eq!(sync.queued_frames, None);
5522    }
5523
5524    #[test]
5525    fn full_inspection_reports_only_digest_counts() {
5526        let capabilities = (cap::HOST_FILTER..=cap::BATTERY)
5527            .map(|capability| capability as u8)
5528            .collect::<Vec<_>>();
5529        let properties = ulcp_inspection_properties(capabilities.clone()).unwrap();
5530        assert!(properties.contains(&prop::HOST_RX_FILTERS));
5531        assert!(properties.contains(&prop::HOST_RX_QUEUE_COUNT));
5532        assert!(properties.contains(&prop::HOST_AUTO_ACK));
5533
5534        let sync = inspect_ulcp_sync(vec![
5535            response(prop::CAPS, &capabilities),
5536            response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
5537            response(prop::PHY_ENABLED, &[1]),
5538            response(prop::PHY_FREQ, &868_100u32.to_le_bytes()),
5539            response(prop::PHY_TX_POWER, &[22]),
5540            response(prop::SAVED, &[1]),
5541            response(prop::HOST_RX_FILTERS, &[]),
5542            response(prop::HOST_CHANNEL_KEYS, &[1, 2, 3, 4]),
5543            response(prop::HOST_PEER_KEYS, &[7; 32]),
5544            response(prop::HOST_RX_QUEUE_COUNT, &3u16.to_le_bytes()),
5545            response(prop::HOST_RX_QUEUE_DROPPED, &4u32.to_le_bytes()),
5546            response(prop::HOST_AUTO_ACK, &[1]),
5547            response(prop::DEV_PEERS, &[7; 64]),
5548            response(prop::DEV_DISCOVERABLE, &[1]),
5549        ])
5550        .unwrap();
5551        assert_eq!(sync.saved, Some(SavedSnapshotRecord::Current));
5552        assert_eq!(sync.queued_frames, Some(3));
5553        assert_eq!(sync.dropped_frames, Some(4));
5554        assert_eq!(sync.filter_count, Some(0));
5555        assert_eq!(sync.host_channel_count, Some(2));
5556        assert_eq!(sync.host_peer_count, Some(1));
5557        assert_eq!(sync.auto_ack, Some(true));
5558        // The device-identity peer list is the one key table read back
5559        // losslessly rather than as a digest count.
5560        assert!(sync.supports_device_identity);
5561        assert_eq!(sync.dev_peer_keys, Some(vec![vec![7; 32], vec![7; 32]]));
5562    }
5563
5564    #[test]
5565    fn invalid_capability_dependencies_and_values_fail_closed() {
5566        assert_eq!(
5567            ulcp_inspection_properties(vec![cap::HOST_RX_QUEUE as u8]),
5568            Err(MobileError::InvalidUlcpFrame)
5569        );
5570        assert_eq!(
5571            inspect_ulcp_sync(vec![
5572                response(prop::CAPS, &[]),
5573                response(prop::INTERFACE_TYPE, &[7]),
5574                response(prop::PHY_ENABLED, &[1]),
5575                response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
5576            ]),
5577            Err(MobileError::InvalidUlcpFrame)
5578        );
5579    }
5580
5581    #[test]
5582    fn mobile_session_owns_sync_tids_and_attaches_transparent_radio() {
5583        let session = MobileUlcpSession::new();
5584        let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
5585        assert_eq!(begin.snapshot.phase, UlcpSessionPhase::Synchronizing);
5586        assert_eq!(begin.outbound_frames.len(), 7);
5587        assert_eq!(
5588            begin
5589                .outbound_frames
5590                .iter()
5591                .map(|request| property_request(request).0)
5592                .collect::<Vec<_>>(),
5593            [1, 2, 3, 4, 5, 6, 7]
5594        );
5595
5596        let inspection =
5597            answer_requests(&session, begin.outbound_frames, |property| match property {
5598                prop::LAST_STATUS => (property, vec![0]),
5599                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
5600                prop::CAPS => (property, vec![cap::WRITABLE_RAW_STREAM as u8]),
5601                prop::DEV_KEY => (property, Vec::new()),
5602                prop::DEV_NAME => (property, b"Transparent".to_vec()),
5603                prop::BATTERY => (property, Vec::new()),
5604                prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
5605                _ => unreachable!(),
5606            });
5607        assert_eq!(inspection.outbound_frames.len(), 4);
5608        assert_eq!(
5609            inspection.snapshot.host_ownership,
5610            UlcpHostOwnership::Unsupported
5611        );
5612
5613        let attached =
5614            answer_requests(
5615                &session,
5616                inspection.outbound_frames,
5617                |property| match property {
5618                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
5619                    prop::PHY_ENABLED => (property, vec![1]),
5620                    prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
5621                    prop::PHY_TX_POWER => (property, vec![14]),
5622                    _ => unreachable!(),
5623                },
5624            );
5625        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5626        assert_eq!(
5627            attached.snapshot.device_name.as_deref(),
5628            Some("Transparent")
5629        );
5630        assert_eq!(
5631            attached.snapshot.provisioning.unwrap().frequency_khz,
5632            915_000
5633        );
5634    }
5635
5636    #[test]
5637    fn mobile_session_owns_claim_then_save_choreography() {
5638        let host_key = vec![0xAA; 32];
5639        let session = MobileUlcpSession::new();
5640        let begin = session.begin(Some(host_key.clone())).unwrap();
5641        let awaiting =
5642            answer_requests(&session, begin.outbound_frames, |property| match property {
5643                prop::LAST_STATUS => (property, vec![0]),
5644                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
5645                prop::CAPS => (property, vec![cap::HOST_FILTER as u8, cap::SAVE as u8]),
5646                prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY | prop::HOST_KEY => {
5647                    (property, Vec::new())
5648                }
5649                _ => unreachable!(),
5650            });
5651        assert_eq!(awaiting.snapshot.phase, UlcpSessionPhase::AwaitingHost);
5652        assert_eq!(
5653            awaiting.snapshot.host_ownership,
5654            UlcpHostOwnership::Unclaimed
5655        );
5656
5657        let claim = session.claim(host_key.clone()).unwrap();
5658        assert_eq!(claim.snapshot.phase, UlcpSessionPhase::Claiming);
5659        assert_eq!(claim.outbound_frames.len(), 1);
5660        let parsed_claim = Frame::parse(&claim.outbound_frames[0]).unwrap();
5661        assert_eq!(parsed_claim.command(), Some(Cmd::PropSet));
5662        let payload = PropPayload::parse(parsed_claim.payload).unwrap();
5663        assert_eq!(payload.key, prop::HOST_KEY);
5664        assert_eq!(payload.value, host_key);
5665
5666        let save = session
5667            .consume(property_response(
5668                parsed_claim.header.tid(),
5669                prop::HOST_KEY,
5670                &host_key,
5671            ))
5672            .unwrap();
5673        assert_eq!(save.outbound_frames.len(), 1);
5674        let parsed_save = Frame::parse(&save.outbound_frames[0]).unwrap();
5675        assert_eq!(parsed_save.command(), Some(Cmd::Save));
5676
5677        let inspection = session
5678            .consume(property_response(
5679                parsed_save.header.tid(),
5680                prop::LAST_STATUS,
5681                &[0],
5682            ))
5683            .unwrap();
5684        assert_eq!(inspection.outbound_frames.len(), 6);
5685        let attached =
5686            answer_requests(
5687                &session,
5688                inspection.outbound_frames,
5689                |property| match property {
5690                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
5691                    prop::PHY_ENABLED => (property, vec![1]),
5692                    prop::PHY_FREQ => (property, 868_100u32.to_le_bytes().to_vec()),
5693                    prop::PHY_TX_POWER => (property, vec![14]),
5694                    prop::SAVED => (property, vec![1]),
5695                    prop::HOST_RX_FILTERS => (property, Vec::new()),
5696                    _ => unreachable!(),
5697                },
5698            );
5699        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5700        assert_eq!(attached.snapshot.host_ownership, UlcpHostOwnership::Ours);
5701        assert_eq!(
5702            attached.snapshot.provisioning.unwrap().saved,
5703            Some(SavedSnapshotRecord::Current)
5704        );
5705
5706        let changed_host = session
5707            .consume(property_response(
5708                frame::TID_UNSOLICITED,
5709                prop::HOST_KEY,
5710                &[0xBB; 32],
5711            ))
5712            .unwrap();
5713        assert_eq!(changed_host.snapshot.phase, UlcpSessionPhase::AwaitingHost);
5714        assert_eq!(
5715            changed_host.snapshot.host_ownership,
5716            UlcpHostOwnership::OtherHost
5717        );
5718    }
5719
5720    #[test]
5721    fn administrative_session_attaches_without_claiming_anyones_radio() {
5722        let phone = vec![0xAA; 32];
5723        let other_phone = vec![0xBB; 32];
5724
5725        // A radio someone else tethered. An administrative session has no
5726        // decision to put to the user, so it attaches — and still reports
5727        // whose radio it is, because that is worth showing.
5728        let session = MobileUlcpSession::administrative();
5729        let attached = attach_commissionable(&session, Some(phone.clone()), other_phone.clone());
5730        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5731        assert_eq!(
5732            attached.snapshot.host_ownership,
5733            UlcpHostOwnership::OtherHost
5734        );
5735        assert_eq!(
5736            session.claim(phone.clone()),
5737            Err(MobileError::AdministrativeSession)
5738        );
5739
5740        // An unclaimed radio likewise: commissioning ten repeaters must
5741        // not leave this phone's host key on any of them.
5742        let unclaimed = MobileUlcpSession::administrative();
5743        let attached = attach_commissionable(&unclaimed, Some(phone.clone()), Vec::new());
5744        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5745        assert_eq!(
5746            attached.snapshot.host_ownership,
5747            UlcpHostOwnership::Unclaimed
5748        );
5749
5750        // The tethered session is the one that must pause: it is about to
5751        // take the radio from the other phone.
5752        let tethered = MobileUlcpSession::new();
5753        let begin = tethered.begin(Some(phone.clone())).unwrap();
5754        let awaiting = answer_requests(&tethered, begin.outbound_frames, move |property| {
5755            if property == prop::HOST_KEY {
5756                (property, other_phone.clone())
5757            } else {
5758                commissionable_value(property)
5759            }
5760        });
5761        assert_eq!(awaiting.snapshot.phase, UlcpSessionPhase::AwaitingHost);
5762
5763        // A host-key change pushed mid-session does not evict an
5764        // administrative session either.
5765        let pushed = session
5766            .consume(property_response(
5767                frame::TID_UNSOLICITED,
5768                prop::HOST_KEY,
5769                &[0xCC; 32],
5770            ))
5771            .unwrap();
5772        assert_eq!(pushed.snapshot.phase, UlcpSessionPhase::Attached);
5773        assert_eq!(pushed.snapshot.host_ownership, UlcpHostOwnership::OtherHost);
5774    }
5775
5776    #[test]
5777    fn attached_snapshot_reports_the_devices_own_domain() {
5778        let session = MobileUlcpSession::administrative();
5779        let attached = attach_commissionable(&session, None, Vec::new());
5780        let provisioning = attached.snapshot.provisioning.unwrap();
5781        assert!(provisioning.supports_repeater);
5782        assert!(provisioning.supports_ident);
5783        assert_eq!(provisioning.ident_role, None);
5784        assert_eq!(provisioning.ident_mobile, Some(false));
5785        assert_eq!(
5786            provisioning.repeater,
5787            Some(UlcpRepeaterSettingsRecord {
5788                enabled: false,
5789                regions: Vec::new(),
5790                default_region: None,
5791                min_rssi_dbm: None,
5792                min_snr_db: None,
5793            })
5794        );
5795
5796        // A device with no repeater or identity capability reports the
5797        // absence rather than a default-shaped policy.
5798        let plain = inspect_ulcp_sync(vec![
5799            response(prop::CAPS, &[cap::WRITABLE_RAW_STREAM as u8]),
5800            response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
5801            response(prop::PHY_ENABLED, &[1]),
5802            response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
5803            response(prop::PHY_TX_POWER, &[14]),
5804        ])
5805        .unwrap();
5806        assert!(!plain.supports_repeater);
5807        assert!(!plain.supports_ident);
5808        assert_eq!(plain.repeater, None);
5809        assert_eq!(plain.ident_mobile, None);
5810    }
5811
5812    #[test]
5813    fn repeater_policy_round_trips_through_the_sync_reducer() {
5814        let capabilities = encoded_capabilities(&commissionable_capabilities());
5815        let sync = inspect_ulcp_sync(vec![
5816            response(prop::CAPS, &capabilities),
5817            response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
5818            response(prop::PHY_ENABLED, &[1]),
5819            response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
5820            response(prop::PHY_TX_POWER, &[14]),
5821            response(prop::SAVED, &[saved::CURRENT]),
5822            response(prop::HOST_RX_FILTERS, &[]),
5823            response(prop::MAC_REPEATER_ENABLED, &[1]),
5824            // SJC and SFO, the strings exactly as they were written.
5825            response(
5826                prop::MAC_REPEATER_REGIONS,
5827                &[3, b'S', b'J', b'C', 3, b'S', b'F', b'O'],
5828            ),
5829            response(prop::MAC_REPEATER_DEFAULT_REGION, &[0x78, 0x53]),
5830            response(prop::MAC_REPEATER_MIN_RSSI, &(-115i16).to_le_bytes()),
5831            response(prop::MAC_REPEATER_MIN_SNR, &[(-7i8) as u8]),
5832            response(prop::IDENT_ROLE, &[3]),
5833            response(prop::IDENT_MOBILE, &[1]),
5834            response(prop::DEV_PEERS, &[]),
5835            response(prop::DEV_DISCOVERABLE, &[1]),
5836        ])
5837        .unwrap();
5838        assert_eq!(
5839            sync.repeater,
5840            Some(UlcpRepeaterSettingsRecord {
5841                enabled: true,
5842                regions: vec!["SJC".to_owned(), "SFO".to_owned()],
5843                default_region: Some(vec![0x78, 0x53]),
5844                min_rssi_dbm: Some(-115),
5845                min_snr_db: Some(-7),
5846            })
5847        );
5848        assert_eq!(sync.ident_role, Some(3));
5849        assert_eq!(sync.ident_mobile, Some(true));
5850
5851        // An odd-length region list is not a set of region codes, so the
5852        // policy is not reported — but the device still is. A repeater
5853        // without an identity of its own, on the other hand, is not a
5854        // repeater, and that is a malformed capability set.
5855        let malformed = |property, value: &[u8]| {
5856            let mut responses = vec![
5857                response(prop::CAPS, &capabilities),
5858                response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
5859                response(prop::PHY_ENABLED, &[1]),
5860                response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
5861                response(prop::PHY_TX_POWER, &[14]),
5862                response(prop::SAVED, &[saved::CURRENT]),
5863                response(prop::MAC_REPEATER_ENABLED, &[0]),
5864                response(prop::MAC_REPEATER_REGIONS, &[]),
5865                response(prop::MAC_REPEATER_DEFAULT_REGION, &[]),
5866                response(prop::MAC_REPEATER_MIN_RSSI, &[]),
5867                response(prop::MAC_REPEATER_MIN_SNR, &[]),
5868                response(prop::IDENT_ROLE, &[]),
5869                response(prop::IDENT_MOBILE, &[0]),
5870                response(prop::DEV_PEERS, &[]),
5871                response(prop::DEV_DISCOVERABLE, &[1]),
5872            ];
5873            responses.retain(|entry| entry.property_id != property);
5874            responses.push(response(property, value));
5875            inspect_ulcp_sync(responses)
5876        };
5877        for property in [
5878            prop::MAC_REPEATER_REGIONS,
5879            prop::MAC_REPEATER_DEFAULT_REGION,
5880            prop::MAC_REPEATER_MIN_RSSI,
5881        ] {
5882            let sync = malformed(property, &[0x8D, 0x53, 0x7C]).expect("device still described");
5883            assert_eq!(sync.repeater, None, "property {property}");
5884            assert!(sync.supports_repeater, "property {property}");
5885            assert!(
5886                sync.unreadable_properties.contains(&property),
5887                "property {property}"
5888            );
5889        }
5890        assert_eq!(
5891            ulcp_inspection_properties(encoded_capabilities(&[cap::REPEATER])),
5892            Err(MobileError::InvalidUlcpFrame)
5893        );
5894    }
5895
5896    /// A device that refuses one capability-gated property — firmware
5897    /// older than the capability it advertises — is a device with one
5898    /// unknown setting, not one this phone cannot administer. It attaches,
5899    /// it describes itself, and it stays configurable; the refused setting
5900    /// is absent, named, and left out of the write that follows.
5901    #[test]
5902    fn a_refused_property_still_yields_an_administrable_device() {
5903        let session = MobileUlcpSession::administrative();
5904        let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
5905        let attached = drive_reads(&session, begin.outbound_frames, |property| match property {
5906            // One property of its own, and one part of a policy that is
5907            // only meaningful whole.
5908            prop::DEV_DISCOVERABLE | prop::MAC_REPEATER_MIN_RSSI => (
5909                prop::LAST_STATUS,
5910                vec![umsh_ulcp::Status::PROP_NOT_FOUND.0 as u8],
5911            ),
5912            prop::HOST_KEY => (property, vec![0xBB; 32]),
5913            other => commissionable_value(other),
5914        });
5915
5916        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5917        // Nobody asked to read that property in particular; they asked to
5918        // attach, and the attach succeeded. Reporting it as a failed
5919        // operation is what used to strand the caller with no snapshot.
5920        assert_eq!(attached.operation_error, None);
5921        let sync = attached.snapshot.provisioning.expect("device described");
5922        assert!(sync.supports_device_identity);
5923        assert_eq!(sync.dev_discoverable, None);
5924        assert_eq!(
5925            sync.unreadable_properties,
5926            vec![prop::MAC_REPEATER_MIN_RSSI, prop::DEV_DISCOVERABLE]
5927        );
5928        // The rest of the same capability is unaffected.
5929        assert_eq!(sync.dev_peer_keys, Some(Vec::new()));
5930        // A policy missing one part is not a policy, so none of it is
5931        // reported — the device still says it can forward.
5932        assert!(sync.supports_repeater);
5933        assert_eq!(sync.repeater, None);
5934
5935        let configured = session
5936            .configure_device(UlcpDeviceConfigRecord {
5937                radio: UlcpRadioSettingsRecord {
5938                    device_name: None,
5939                    phy_enabled: true,
5940                    frequency_khz: 906_875,
5941                    transmit_power_dbm: 20,
5942                    bandwidth_hz: None,
5943                    spreading_factor: None,
5944                    coding_rate_denom: None,
5945                    duty_cycle_limit: None,
5946                },
5947                ident_role: None,
5948                ident_mobile: Some(true),
5949                dev_discoverable: Some(true),
5950                repeater: Some(UlcpRepeaterSettingsRecord {
5951                    enabled: false,
5952                    regions: Vec::new(),
5953                    default_region: None,
5954                    min_rssi_dbm: None,
5955                    min_snr_db: None,
5956                }),
5957                tz_offset_min: None,
5958                gnss: None,
5959                advert: None,
5960            })
5961            .unwrap();
5962        let (written, _, _) = drive_configuration(&session, configured.outbound_frames);
5963        // The write the device would have rejected — failing the whole
5964        // pass over a setting nobody can even see — is never sent, and
5965        // neither is the rest of the policy it belongs to: a device left
5966        // forwarding under half a policy is worse than one left alone.
5967        assert!(!written.contains_key(&prop::DEV_DISCOVERABLE));
5968        for property in [
5969            prop::MAC_REPEATER_ENABLED,
5970            prop::MAC_REPEATER_REGIONS,
5971            prop::MAC_REPEATER_DEFAULT_REGION,
5972            prop::MAC_REPEATER_MIN_RSSI,
5973            prop::MAC_REPEATER_MIN_SNR,
5974        ] {
5975            assert!(!written.contains_key(&property), "property {property}");
5976        }
5977        assert_eq!(written.get(&prop::IDENT_MOBILE), Some(&vec![1]));
5978        assert_eq!(
5979            written.get(&prop::PHY_FREQ),
5980            Some(&906_875u32.to_le_bytes().to_vec())
5981        );
5982    }
5983
5984    #[test]
5985    fn configuring_a_device_writes_its_whole_domain_as_a_property_map() {
5986        let session = MobileUlcpSession::administrative();
5987        let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xBB; 32]);
5988        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
5989
5990        let configured = session
5991            .configure_device(UlcpDeviceConfigRecord {
5992                radio: UlcpRadioSettingsRecord {
5993                    device_name: Some("Ridge repeater".into()),
5994                    phy_enabled: true,
5995                    frequency_khz: 906_875,
5996                    transmit_power_dbm: 22,
5997                    bandwidth_hz: None,
5998                    spreading_factor: None,
5999                    coding_rate_denom: None,
6000                    duty_cycle_limit: None,
6001                },
6002                ident_role: Some(3),
6003                ident_mobile: Some(false),
6004                dev_discoverable: Some(false),
6005                repeater: Some(UlcpRepeaterSettingsRecord {
6006                    enabled: true,
6007                    regions: vec!["SJC".to_owned()],
6008                    default_region: Some(vec![0x78, 0x53]),
6009                    min_rssi_dbm: Some(-115),
6010                    min_snr_db: Some(-7),
6011                }),
6012                tz_offset_min: None,
6013                gnss: None,
6014                advert: None,
6015            })
6016            .unwrap();
6017        assert_eq!(configured.snapshot.phase, UlcpSessionPhase::Configuring);
6018
6019        let (written, order, save_tid) = drive_configuration(&session, configured.outbound_frames);
6020
6021        // The whole configuration is a property -> value map with nothing
6022        // else in it. A template feature that produces such a map has
6023        // everything it needs; nothing here is shaped around this record.
6024        assert_eq!(
6025            written,
6026            HashMap::from([
6027                (prop::DEV_NAME, b"Ridge repeater".to_vec()),
6028                (prop::PHY_FREQ, 906_875u32.to_le_bytes().to_vec()),
6029                (prop::PHY_TX_POWER, vec![22]),
6030                (prop::IDENT_ROLE, vec![3]),
6031                (prop::IDENT_MOBILE, vec![0]),
6032                (prop::DEV_DISCOVERABLE, vec![0]),
6033                (prop::MAC_REPEATER_REGIONS, vec![3, b'S', b'J', b'C']),
6034                (prop::MAC_REPEATER_DEFAULT_REGION, vec![0x78, 0x53]),
6035                (
6036                    prop::MAC_REPEATER_MIN_RSSI,
6037                    (-115i16).to_le_bytes().to_vec()
6038                ),
6039                (prop::MAC_REPEATER_MIN_SNR, vec![(-7i8) as u8]),
6040                (prop::MAC_REPEATER_ENABLED, vec![1]),
6041                (prop::PHY_ENABLED, vec![1]),
6042            ])
6043        );
6044        // Forwarding starts only once the whole policy — and the radio it
6045        // forwards over — is in place.
6046        assert_eq!(
6047            &order[order.len() - 2..],
6048            &[prop::MAC_REPEATER_ENABLED, prop::PHY_ENABLED]
6049        );
6050
6051        let attached = session
6052            .consume(property_response(save_tid, prop::LAST_STATUS, &[0]))
6053            .unwrap();
6054        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
6055        assert_eq!(
6056            attached.snapshot.host_ownership,
6057            UlcpHostOwnership::OtherHost
6058        );
6059        let provisioning = attached.snapshot.provisioning.unwrap();
6060        assert_eq!(provisioning.frequency_khz, 906_875);
6061        assert_eq!(provisioning.ident_role, Some(3));
6062        assert_eq!(
6063            provisioning.repeater,
6064            Some(UlcpRepeaterSettingsRecord {
6065                enabled: true,
6066                regions: vec!["SJC".to_owned()],
6067                default_region: Some(vec![0x78, 0x53]),
6068                min_rssi_dbm: Some(-115),
6069                min_snr_db: Some(-7),
6070            })
6071        );
6072    }
6073
6074    #[test]
6075    fn device_configuration_must_match_what_the_device_can_do() {
6076        let session = MobileUlcpSession::administrative();
6077        attach_commissionable(&session, None, Vec::new());
6078
6079        let radio = UlcpRadioSettingsRecord {
6080            device_name: None,
6081            phy_enabled: true,
6082            frequency_khz: 915_000,
6083            transmit_power_dbm: 14,
6084            bandwidth_hz: None,
6085            spreading_factor: None,
6086            coding_rate_denom: None,
6087            duty_cycle_limit: None,
6088        };
6089        let repeater = UlcpRepeaterSettingsRecord {
6090            enabled: true,
6091            regions: Vec::new(),
6092            default_region: None,
6093            min_rssi_dbm: None,
6094            min_snr_db: None,
6095        };
6096        let configure = |ident_role, ident_mobile, dev_discoverable, repeater| {
6097            session.configure_device(UlcpDeviceConfigRecord {
6098                radio: radio.clone(),
6099                ident_role,
6100                ident_mobile,
6101                dev_discoverable,
6102                repeater,
6103                tz_offset_min: None,
6104                gnss: None,
6105                advert: None,
6106            })
6107        };
6108
6109        // Every capability-gated field is required, because the record
6110        // states a whole desired configuration rather than a patch.
6111        assert_eq!(
6112            configure(Some(3), None, Some(true), Some(repeater.clone())),
6113            Err(MobileError::InvalidUlcpFrame)
6114        );
6115        assert_eq!(
6116            configure(None, Some(false), Some(true), None),
6117            Err(MobileError::InvalidUlcpFrame)
6118        );
6119        assert_eq!(
6120            configure(None, Some(false), None, Some(repeater.clone())),
6121            Err(MobileError::InvalidUlcpFrame)
6122        );
6123        // A region string is 1 to 24 octets; outside that the device
6124        // refuses it, so the write never leaves the phone.
6125        for bad in ["", &"A".repeat(items::REGION_STRING_MAX_LEN + 1)] {
6126            assert_eq!(
6127                configure(
6128                    None,
6129                    Some(false),
6130                    Some(true),
6131                    Some(UlcpRepeaterSettingsRecord {
6132                        regions: vec![bad.to_owned()],
6133                        ..repeater.clone()
6134                    })
6135                ),
6136                Err(MobileError::InvalidUlcpFrame)
6137            );
6138        }
6139        assert_eq!(
6140            configure(
6141                None,
6142                Some(false),
6143                Some(true),
6144                Some(UlcpRepeaterSettingsRecord {
6145                    default_region: Some(vec![0x78, 0x53, 0x00]),
6146                    ..repeater.clone()
6147                })
6148            ),
6149            Err(MobileError::InvalidUlcpFrame)
6150        );
6151
6152        // An omitted role is the device deriving its own, written as an
6153        // empty value rather than skipped.
6154        let configured = configure(None, Some(true), Some(true), Some(repeater)).unwrap();
6155        let (written, ..) = drive_configuration(&session, configured.outbound_frames);
6156        assert_eq!(written.get(&prop::IDENT_ROLE), Some(&Vec::new()));
6157        assert_eq!(written.get(&prop::IDENT_MOBILE), Some(&vec![1]));
6158        assert_eq!(written.get(&prop::DEV_DISCOVERABLE), Some(&vec![1]));
6159        assert_eq!(written.get(&prop::MAC_REPEATER_REGIONS), Some(&Vec::new()));
6160        assert_eq!(written.get(&prop::MAC_REPEATER_MIN_RSSI), Some(&Vec::new()));
6161    }
6162
6163    #[test]
6164    fn region_codes_convert_between_text_and_wire_octets() {
6165        assert_eq!(region_code_from_string("SJC".into()).unwrap(), [0x78, 0x53]);
6166        assert_eq!(region_code_description(vec![0x78, 0x53]).unwrap(), "SJC");
6167        // A two-letter short code is a region in its own right.
6168        assert_eq!(region_code_from_string("WA".into()).unwrap(), [0x8F, 0xE8]);
6169        assert_eq!(region_code_description(vec![0x8F, 0xE8]).unwrap(), "WA");
6170        // A name lands outside the letter space, so it never renders as
6171        // letters and round-trips through hex.
6172        let named = region_code_from_string("Rogue Valley".into()).unwrap();
6173        assert_eq!(named, [0xC0, 0xF9]);
6174        let described = region_code_description(named.clone()).unwrap();
6175        assert_eq!(described, "0xC0F9");
6176        assert_eq!(region_code_from_string(described).unwrap(), named);
6177
6178        // Case is not part of a region's identity, on either derivation.
6179        assert_eq!(region_code_from_string("sjc".into()).unwrap(), [0x78, 0x53]);
6180        assert_eq!(
6181            region_code_from_string("rogue valley".into()).unwrap(),
6182            named
6183        );
6184
6185        assert_eq!(
6186            region_code_from_string("  ".into()),
6187            Err(MobileError::InvalidRegionCode)
6188        );
6189        assert_eq!(
6190            region_code_description(vec![0x78]),
6191            Err(MobileError::InvalidRegionCode)
6192        );
6193    }
6194
6195    #[test]
6196    fn mobile_session_rejects_mismatched_transaction_response() {
6197        let session = MobileUlcpSession::new();
6198        let begin = session.begin(None).unwrap();
6199        let (tid, _) = property_request(&begin.outbound_frames[0]);
6200        assert_eq!(
6201            session.consume(property_response(tid, prop::PHY_FREQ, &[0; 4])),
6202            Err(MobileError::UlcpMismatchedResponse)
6203        );
6204        // The rejection consumed the expectation, so a second response on
6205        // that transaction is a different fault — nobody is waiting on it —
6206        // and says so rather than reusing one catch-all.
6207        assert_eq!(
6208            session.consume(property_response(tid, prop::PHY_FREQ, &[0; 4])),
6209            Err(MobileError::UlcpUnexpectedFrame)
6210        );
6211    }
6212
6213    #[test]
6214    fn received_frame_causes_are_distinguishable() {
6215        let session = MobileUlcpSession::new();
6216        assert_eq!(
6217            session.consume(vec![0x00, 0x06]),
6218            Err(MobileError::UlcpFrameUnparsable)
6219        );
6220        // A well-formed command this session does not handle — a newer
6221        // firmware's unsolicited notification, or a `CMD_PROP_ARE` — is
6222        // named as such, not reported as a corrupt frame.
6223        let mut save = [0u8; 8];
6224        let len = frame::save(&mut save, 1).unwrap();
6225        assert_eq!(
6226            session.consume(save[..len].to_vec()),
6227            Err(MobileError::UlcpUnexpectedCommand)
6228        );
6229    }
6230
6231    #[test]
6232    fn frame_descriptions_name_the_command_without_payload_bytes() {
6233        let mut bytes = [0u8; 16];
6234        let len = frame::prop_is(&mut bytes, 3, 0x1234, &[5, 6]).unwrap();
6235        assert_eq!(
6236            describe_ulcp_frame(bytes[..len].to_vec()),
6237            "tid=3 cmd=PropIs(6) prop=0x1234 value=2B len=6"
6238        );
6239        assert_eq!(describe_ulcp_frame(vec![0x00]), "unparsable len=1");
6240    }
6241
6242    #[test]
6243    fn mobile_session_emits_typed_raw_receive_during_sync() {
6244        let session = MobileUlcpSession::new();
6245        session.begin(None).unwrap();
6246
6247        let metadata = BufferedRxMeta {
6248            rx: umsh_ulcp::RxMeta {
6249                rssi_dbm: Some(-87),
6250                lqi: core::num::NonZeroU8::new(42),
6251                snr_cb: Some(125),
6252            },
6253            flags: RX_FLAG_BUFFERED | RX_FLAG_ACKED,
6254            age_s: 9,
6255        };
6256        let mut metadata_bytes = [0; BufferedRxMeta::WIRE_LEN];
6257        metadata.encode(&mut metadata_bytes).unwrap();
6258        let mut bytes = vec![0; MAX_FRAME];
6259        let len = frame::str_recv(
6260            &mut bytes,
6261            umsh_ulcp::ids::stream::PHY_RAW,
6262            &[1, 2, 3],
6263            &metadata_bytes,
6264        )
6265        .unwrap();
6266        bytes.truncate(len);
6267
6268        let update = session.consume(bytes).unwrap();
6269        assert_eq!(update.received_frames.len(), 1);
6270        assert_eq!(
6271            update.received_frames[0],
6272            UlcpReceivedFrameRecord {
6273                data: vec![1, 2, 3],
6274                rssi_dbm: Some(-87),
6275                lqi: Some(42),
6276                snr_cb: Some(125),
6277                was_buffered: true,
6278                was_acknowledged: true,
6279                age_seconds: 9,
6280            }
6281        );
6282        assert!(update.outbound_frames.is_empty());
6283        assert!(update.waiting_for_responses);
6284    }
6285
6286    #[test]
6287    fn mobile_session_reports_raw_transmit_rejection_without_ending_session() {
6288        let session = MobileUlcpSession::new();
6289        let begin = session.begin(None).unwrap();
6290        let inspection =
6291            answer_requests(&session, begin.outbound_frames, |property| match property {
6292                prop::LAST_STATUS => (property, vec![0]),
6293                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
6294                prop::CAPS => (property, vec![cap::WRITABLE_RAW_STREAM as u8]),
6295                prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY => (property, Vec::new()),
6296                prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
6297                _ => unreachable!(),
6298            });
6299        let attached =
6300            answer_requests(
6301                &session,
6302                inspection.outbound_frames,
6303                |property| match property {
6304                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
6305                    prop::PHY_ENABLED => (property, vec![1]),
6306                    prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
6307                    prop::PHY_TX_POWER => (property, vec![14]),
6308                    _ => unreachable!(),
6309                },
6310            );
6311        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
6312
6313        let transmit = session.transmit_raw(vec![1, 2, 3], false).unwrap();
6314        assert!(transmit.raw_transmit_pending);
6315        assert_eq!(transmit.raw_transmit_result, None);
6316        assert_eq!(transmit.outbound_frames.len(), 1);
6317        let second_transmit = session.transmit_raw(vec![4], false).unwrap();
6318        assert_ne!(
6319            transmit.raw_transmit_started_transaction_id,
6320            second_transmit.raw_transmit_started_transaction_id
6321        );
6322
6323        let request = Frame::parse(&transmit.outbound_frames[0]).unwrap();
6324        let rejected = session
6325            .consume(property_response(
6326                request.header.tid(),
6327                prop::LAST_STATUS,
6328                &[umsh_ulcp::Status::INVALID_STATE.0 as u8],
6329            ))
6330            .unwrap();
6331        assert_eq!(rejected.snapshot.phase, UlcpSessionPhase::Attached);
6332        assert!(rejected.raw_transmit_pending);
6333        assert_eq!(
6334            rejected.raw_transmit_result,
6335            Some(UlcpRawTransmitResultRecord {
6336                transaction_id: request.header.tid(),
6337                status_code: umsh_ulcp::Status::INVALID_STATE.0,
6338                status_name: "Status::INVALID_STATE".into(),
6339                disposition: UlcpRawTransmitDisposition::Rejected,
6340            })
6341        );
6342        let second_request = Frame::parse(&second_transmit.outbound_frames[0]).unwrap();
6343        let completed = session
6344            .consume(property_response(
6345                second_request.header.tid(),
6346                prop::LAST_STATUS,
6347                &[umsh_ulcp::Status::OK.0 as u8],
6348            ))
6349            .unwrap();
6350        assert!(!completed.raw_transmit_pending);
6351
6352        // A radio-level rejection completes only that send; the attached
6353        // session remains usable for the next raw frame.
6354        let retryable = session.transmit_raw(vec![5], false).unwrap();
6355        let request = Frame::parse(&retryable.outbound_frames[0]).unwrap();
6356        let busy = session
6357            .consume(property_response(
6358                request.header.tid(),
6359                prop::LAST_STATUS,
6360                &[umsh_ulcp::Status::BUSY.0 as u8],
6361            ))
6362            .unwrap();
6363        assert_eq!(
6364            busy.raw_transmit_result.unwrap().disposition,
6365            UlcpRawTransmitDisposition::Retry
6366        );
6367
6368        let abandoned = session.transmit_raw(vec![6], false).unwrap();
6369        let abandoned_request = Frame::parse(&abandoned.outbound_frames[0]).unwrap();
6370        assert!(
6371            !session
6372                .abandon_raw_transmits(vec![abandoned_request.header.tid()])
6373                .raw_transmit_pending
6374        );
6375
6376        // A status error for an ordinary property operation is also
6377        // nonfatal. Finish the rest of the bounded batch, recover to Attached,
6378        // and prove the same session can issue another raw transmission.
6379        let configured = session
6380            .configure(UlcpRadioSettingsRecord {
6381                device_name: None,
6382                phy_enabled: true,
6383                frequency_khz: 915_000,
6384                transmit_power_dbm: 14,
6385                bandwidth_hz: None,
6386                spreading_factor: None,
6387                coding_rate_denom: None,
6388                duty_cycle_limit: None,
6389            })
6390            .unwrap();
6391        let mut final_update = None;
6392        for (index, request) in configured.outbound_frames.into_iter().enumerate() {
6393            let parsed = Frame::parse(&request).unwrap();
6394            let payload = PropPayload::parse(parsed.payload).unwrap();
6395            let response = if index == 0 {
6396                property_response(
6397                    parsed.header.tid(),
6398                    prop::LAST_STATUS,
6399                    &[umsh_ulcp::Status::INVALID_ARGUMENT.0 as u8],
6400                )
6401            } else {
6402                property_response(parsed.header.tid(), payload.key, payload.value)
6403            };
6404            let update = session.consume(response).unwrap();
6405            if index == 0 {
6406                assert_eq!(
6407                    update.operation_error,
6408                    Some(UlcpOperationErrorRecord {
6409                        operation: format!("set property {}", payload.key),
6410                        status_code: umsh_ulcp::Status::INVALID_ARGUMENT.0,
6411                        status_name: "Status::INVALID_ARGUMENT".into(),
6412                    })
6413                );
6414            }
6415            final_update = Some(update);
6416        }
6417        assert_eq!(
6418            final_update.unwrap().snapshot.phase,
6419            UlcpSessionPhase::Attached
6420        );
6421        assert!(session.transmit_raw(vec![6], false).is_ok());
6422    }
6423
6424    #[test]
6425    fn mobile_session_verifies_radio_configuration_then_saves() {
6426        let session = MobileUlcpSession::new();
6427        let begin = session.begin(None).unwrap();
6428        let inspection =
6429            answer_requests(&session, begin.outbound_frames, |property| match property {
6430                prop::LAST_STATUS => (property, vec![0]),
6431                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
6432                prop::CAPS => (
6433                    property,
6434                    encoded_capabilities(&[
6435                        cap::SAVE,
6436                        cap::DEV_NAME,
6437                        cap::PHY_LORA,
6438                        cap::PHY_DUTY_LIMIT,
6439                    ]),
6440                ),
6441                prop::DEV_NAME => (property, b"Old name".to_vec()),
6442                prop::DEV_KEY | prop::BATTERY => (property, Vec::new()),
6443                prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
6444                _ => unreachable!(),
6445            });
6446        let partial =
6447            answer_requests(
6448                &session,
6449                inspection.outbound_frames,
6450                |property| match property {
6451                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
6452                    prop::PHY_ENABLED => (property, vec![1]),
6453                    prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
6454                    prop::PHY_TX_POWER => (property, vec![14]),
6455                    prop::PHY_LORA_BW => (property, 125_000u32.to_le_bytes().to_vec()),
6456                    prop::PHY_LORA_SF => (property, vec![9]),
6457                    prop::PHY_LORA_CR => (property, vec![5]),
6458                    _ => unreachable!(),
6459                },
6460            );
6461        let attached = answer_requests(
6462            &session,
6463            partial.outbound_frames,
6464            |property| match property {
6465                prop::PHY_DUTY_NOW => (property, 65u16.to_le_bytes().to_vec()),
6466                prop::PHY_DUTY_LIMIT => (property, 655u16.to_le_bytes().to_vec()),
6467                prop::SAVED => (property, vec![1]),
6468                _ => unreachable!(),
6469            },
6470        );
6471        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
6472
6473        let configured = session
6474            .configure(UlcpRadioSettingsRecord {
6475                device_name: Some("Trail radio".into()),
6476                phy_enabled: true,
6477                frequency_khz: 868_100,
6478                transmit_power_dbm: 20,
6479                bandwidth_hz: Some(250_000),
6480                spreading_factor: Some(10),
6481                coding_rate_denom: Some(6),
6482                duty_cycle_limit: Some(6_553),
6483            })
6484            .unwrap();
6485        assert_eq!(configured.snapshot.phase, UlcpSessionPhase::Configuring);
6486        assert_eq!(
6487            configured.outbound_frames.len(),
6488            usize::from(frame::TID_MAX)
6489        );
6490        let mut pending = VecDeque::from(configured.outbound_frames);
6491        let mut configured_properties = Vec::new();
6492        let save_tid = loop {
6493            let request = pending.pop_front().unwrap();
6494            let parsed = Frame::parse(&request).unwrap();
6495            if parsed.command() == Some(Cmd::Save) {
6496                break parsed.header.tid();
6497            }
6498            assert_eq!(parsed.command(), Some(Cmd::PropSet));
6499            let payload = PropPayload::parse(parsed.payload).unwrap();
6500            configured_properties.push(payload.key);
6501            // This radio tops out below the 20 dBm asked for and answers
6502            // with the power it will actually use. A `CMD_PROP_IS` is the
6503            // device's word on the property, so the session takes it.
6504            let answer: &[u8] = match payload.key {
6505                prop::PHY_TX_POWER => &[17],
6506                _ => payload.value,
6507            };
6508            let update = session
6509                .consume(property_response(parsed.header.tid(), payload.key, answer))
6510                .unwrap_or_else(|error| {
6511                    panic!(
6512                        "configuration response for property {} failed: {error:?}",
6513                        payload.key
6514                    )
6515                });
6516            pending.extend(update.outbound_frames);
6517        };
6518        assert_eq!(configured_properties.last(), Some(&prop::PHY_ENABLED));
6519        let attached = session
6520            .consume(property_response(save_tid, prop::LAST_STATUS, &[0]))
6521            .unwrap();
6522        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
6523        assert_eq!(
6524            attached.snapshot.device_name.as_deref(),
6525            Some("Trail radio")
6526        );
6527        let provisioning = attached.snapshot.provisioning.unwrap();
6528        assert_eq!(provisioning.frequency_khz, 868_100);
6529        // 20 dBm was written; the device said 17, so 17 is what the phone
6530        // shows.
6531        assert_eq!(provisioning.transmit_power_dbm, 17);
6532        assert_eq!(provisioning.bandwidth_hz, Some(250_000));
6533        assert_eq!(provisioning.spreading_factor, Some(10));
6534        assert_eq!(provisioning.coding_rate_denom, Some(6));
6535        assert_eq!(provisioning.duty_cycle_now, Some(65));
6536        assert_eq!(provisioning.duty_cycle_limit, Some(6_553));
6537
6538        let pushed = session
6539            .consume(property_response(
6540                frame::TID_UNSOLICITED,
6541                prop::PHY_DUTY_NOW,
6542                &131u16.to_le_bytes(),
6543            ))
6544            .unwrap();
6545        assert_eq!(
6546            pushed.snapshot.provisioning.unwrap().duty_cycle_now,
6547            Some(131)
6548        );
6549
6550        let refresh = session.refresh().unwrap();
6551        assert_eq!(refresh.snapshot.phase, UlcpSessionPhase::Attached);
6552        assert!(refresh.waiting_for_responses);
6553        let refresh_tail =
6554            answer_requests(
6555                &session,
6556                refresh.outbound_frames,
6557                |property| match property {
6558                    prop::DEV_NAME => (property, b"Fresh name".to_vec()),
6559                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
6560                    prop::PHY_ENABLED => (property, vec![1]),
6561                    prop::PHY_FREQ => (property, 910_525u32.to_le_bytes().to_vec()),
6562                    prop::PHY_TX_POWER => (property, vec![18]),
6563                    prop::PHY_LORA_BW => (property, 62_500u32.to_le_bytes().to_vec()),
6564                    prop::PHY_LORA_SF => (property, vec![7]),
6565                    _ => unreachable!(),
6566                },
6567            );
6568        let refreshed =
6569            answer_requests(
6570                &session,
6571                refresh_tail.outbound_frames,
6572                |property| match property {
6573                    prop::PHY_LORA_CR => (property, vec![5]),
6574                    prop::PHY_DUTY_NOW => (property, 262u16.to_le_bytes().to_vec()),
6575                    prop::PHY_DUTY_LIMIT => (property, 655u16.to_le_bytes().to_vec()),
6576                    prop::SAVED => (property, vec![1]),
6577                    _ => unreachable!(),
6578                },
6579            );
6580        assert_eq!(refreshed.snapshot.phase, UlcpSessionPhase::Attached);
6581        assert!(!refreshed.waiting_for_responses);
6582        assert_eq!(
6583            refreshed.snapshot.device_name.as_deref(),
6584            Some("Fresh name")
6585        );
6586        let refreshed = refreshed.snapshot.provisioning.unwrap();
6587        assert_eq!(refreshed.frequency_khz, 910_525);
6588        assert_eq!(refreshed.duty_cycle_now, Some(262));
6589        assert_eq!(refreshed.duty_cycle_limit, Some(655));
6590    }
6591
6592    fn inserted_response(tid: u8, property: u32, item: &[u8]) -> Vec<u8> {
6593        let mut bytes = vec![0; MAX_FRAME];
6594        let length = frame::prop_inserted(&mut bytes, tid, property, item).unwrap();
6595        bytes.truncate(length);
6596        bytes
6597    }
6598
6599    fn removed_response(tid: u8, property: u32, item: &[u8]) -> Vec<u8> {
6600        let mut bytes = vec![0; MAX_FRAME];
6601        let length = frame::prop_removed(&mut bytes, tid, property, item).unwrap();
6602        bytes.truncate(length);
6603        bytes
6604    }
6605
6606    fn dev_peer_keys(update: &UlcpSessionUpdateRecord) -> Vec<Vec<u8>> {
6607        update
6608            .snapshot
6609            .provisioning
6610            .as_ref()
6611            .unwrap()
6612            .dev_peer_keys
6613            .clone()
6614            .unwrap()
6615    }
6616
6617    #[test]
6618    fn device_peer_insert_and_remove_patch_the_table_and_chain_a_save() {
6619        let session = MobileUlcpSession::new();
6620        let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
6621        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
6622        assert_eq!(dev_peer_keys(&attached), Vec::<Vec<u8>>::new());
6623
6624        let insert = session.insert_device_peer(vec![0xC1; 32]).unwrap();
6625        assert_eq!(insert.outbound_frames.len(), 1);
6626        let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
6627        assert_eq!(request.command(), Some(Cmd::PropInsert));
6628
6629        let confirmed = session
6630            .consume(inserted_response(
6631                request.header.tid(),
6632                prop::DEV_PEERS,
6633                &[0xC1; 32],
6634            ))
6635            .unwrap();
6636        assert_eq!(dev_peer_keys(&confirmed), vec![vec![0xC1; 32]]);
6637        assert_eq!(confirmed.operation_error, None);
6638        // The mutation is live but unsaved; CMD_SAVE rides behind it.
6639        assert!(confirmed.waiting_for_responses);
6640        assert_eq!(confirmed.outbound_frames.len(), 1);
6641        let save = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
6642        assert_eq!(save.command(), Some(Cmd::Save));
6643
6644        let saved = session
6645            .consume(property_response(
6646                save.header.tid(),
6647                prop::LAST_STATUS,
6648                &[umsh_ulcp::Status::OK.0 as u8],
6649            ))
6650            .unwrap();
6651        assert_eq!(saved.operation_error, None);
6652        assert!(!saved.waiting_for_responses);
6653        assert_eq!(saved.snapshot.phase, UlcpSessionPhase::Attached);
6654
6655        let remove = session.remove_device_peer(vec![0xC1; 32]).unwrap();
6656        let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
6657        assert_eq!(request.command(), Some(Cmd::PropRemove));
6658        let confirmed = session
6659            .consume(removed_response(
6660                request.header.tid(),
6661                prop::DEV_PEERS,
6662                &[0xC1; 32],
6663            ))
6664            .unwrap();
6665        assert_eq!(dev_peer_keys(&confirmed), Vec::<Vec<u8>>::new());
6666        let save = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
6667        assert_eq!(save.command(), Some(Cmd::Save));
6668        let saved = session
6669            .consume(property_response(
6670                save.header.tid(),
6671                prop::LAST_STATUS,
6672                &[umsh_ulcp::Status::OK.0 as u8],
6673            ))
6674            .unwrap();
6675        assert!(!saved.waiting_for_responses);
6676    }
6677
6678    #[test]
6679    fn device_peer_failures_report_status_without_ending_the_session() {
6680        let session = MobileUlcpSession::new();
6681        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
6682
6683        // NOMEM: the list is full. Nothing changed on the device, so the
6684        // cache stays put and no save is chained.
6685        let insert = session.insert_device_peer(vec![0xC2; 32]).unwrap();
6686        let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
6687        let full = session
6688            .consume(property_response(
6689                request.header.tid(),
6690                prop::LAST_STATUS,
6691                &[umsh_ulcp::Status::NOMEM.0 as u8],
6692            ))
6693            .unwrap();
6694        assert_eq!(
6695            full.operation_error,
6696            Some(UlcpOperationErrorRecord {
6697                operation: "insert device peer".into(),
6698                status_code: umsh_ulcp::Status::NOMEM.0,
6699                status_name: "Status::NOMEM".into(),
6700            })
6701        );
6702        assert_eq!(dev_peer_keys(&full), Vec::<Vec<u8>>::new());
6703        assert!(!full.waiting_for_responses);
6704        assert_eq!(full.snapshot.phase, UlcpSessionPhase::Attached);
6705
6706        // ALREADY: the key is on the device; the cache reflects that even
6707        // though the operation reports a non-OK status.
6708        let insert = session.insert_device_peer(vec![0xC3; 32]).unwrap();
6709        let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
6710        let already = session
6711            .consume(property_response(
6712                request.header.tid(),
6713                prop::LAST_STATUS,
6714                &[umsh_ulcp::Status::ALREADY.0 as u8],
6715            ))
6716            .unwrap();
6717        assert_eq!(
6718            already.operation_error.as_ref().unwrap().status_name,
6719            "Status::ALREADY"
6720        );
6721        assert_eq!(dev_peer_keys(&already), vec![vec![0xC3; 32]]);
6722
6723        // ITEM_NOT_FOUND on remove: the key is not on the device, which is
6724        // what the caller asked for.
6725        let remove = session.remove_device_peer(vec![0xC3; 32]).unwrap();
6726        let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
6727        let missing = session
6728            .consume(property_response(
6729                request.header.tid(),
6730                prop::LAST_STATUS,
6731                &[umsh_ulcp::Status::ITEM_NOT_FOUND.0 as u8],
6732            ))
6733            .unwrap();
6734        assert_eq!(
6735            missing.operation_error.as_ref().unwrap().status_name,
6736            "Status::ITEM_NOT_FOUND"
6737        );
6738        assert_eq!(dev_peer_keys(&missing), Vec::<Vec<u8>>::new());
6739
6740        // The session remains attached and usable.
6741        assert!(session.insert_device_peer(vec![0xC4; 32]).is_ok());
6742    }
6743
6744    fn dev_admin_keys(update: &UlcpSessionUpdateRecord) -> Vec<Vec<u8>> {
6745        update
6746            .snapshot
6747            .provisioning
6748            .as_ref()
6749            .unwrap()
6750            .dev_admin_keys
6751            .clone()
6752            .unwrap()
6753    }
6754
6755    #[test]
6756    fn listing_an_administrator_is_the_bench_half_of_node_management() {
6757        let session = MobileUlcpSession::new();
6758        let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
6759        assert!(
6760            attached
6761                .snapshot
6762                .provisioning
6763                .as_ref()
6764                .unwrap()
6765                .supports_admin
6766        );
6767        assert_eq!(dev_admin_keys(&attached), Vec::<Vec<u8>>::new());
6768
6769        // The phone's own node key, put on a radio it is holding so it can
6770        // manage that radio from somewhere else later.
6771        let phone = vec![0x11; 32];
6772        let insert = session.insert_device_admin(phone.clone()).unwrap();
6773        let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
6774        assert_eq!(request.command(), Some(Cmd::PropInsert));
6775
6776        let confirmed = session
6777            .consume(inserted_response(
6778                request.header.tid(),
6779                prop::DEV_ADMINS,
6780                &phone,
6781            ))
6782            .unwrap();
6783        assert_eq!(dev_admin_keys(&confirmed), vec![phone.clone()]);
6784        // Administrators are the one table that must survive a reboot to be
6785        // worth anything, so the save rides behind the mutation.
6786        let save = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
6787        assert_eq!(save.command(), Some(Cmd::Save));
6788        session
6789            .consume(property_response(
6790                save.header.tid(),
6791                prop::LAST_STATUS,
6792                &[umsh_ulcp::Status::OK.0 as u8],
6793            ))
6794            .unwrap();
6795
6796        // The peer table is a different list and is untouched by any of it.
6797        assert_eq!(dev_peer_keys(&confirmed), Vec::<Vec<u8>>::new());
6798
6799        let remove = session.remove_device_admin(phone.clone()).unwrap();
6800        let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
6801        assert_eq!(request.command(), Some(Cmd::PropRemove));
6802        let confirmed = session
6803            .consume(removed_response(
6804                request.header.tid(),
6805                prop::DEV_ADMINS,
6806                &phone,
6807            ))
6808            .unwrap();
6809        assert_eq!(dev_admin_keys(&confirmed), Vec::<Vec<u8>>::new());
6810    }
6811
6812    /// An administrator on the mesh has no session to hand a configuration
6813    /// to, only the record its read produced. It must still write exactly
6814    /// what a phone holding the device would write, in the same order.
6815    #[test]
6816    fn a_configuration_reduces_the_same_way_with_or_without_a_session() {
6817        let configuration = UlcpDeviceConfigRecord {
6818            radio: UlcpRadioSettingsRecord {
6819                device_name: Some("Ridge repeater".into()),
6820                phy_enabled: true,
6821                frequency_khz: 906_875,
6822                transmit_power_dbm: 20,
6823                bandwidth_hz: None,
6824                spreading_factor: None,
6825                coding_rate_denom: None,
6826                duty_cycle_limit: None,
6827            },
6828            ident_role: None,
6829            ident_mobile: Some(false),
6830            dev_discoverable: Some(true),
6831            repeater: Some(UlcpRepeaterSettingsRecord {
6832                enabled: true,
6833                regions: Vec::new(),
6834                default_region: None,
6835                min_rssi_dbm: None,
6836                min_snr_db: None,
6837            }),
6838            tz_offset_min: None,
6839            gnss: None,
6840            advert: None,
6841        };
6842
6843        let session = MobileUlcpSession::new();
6844        let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
6845        let reported = attached.snapshot.provisioning.clone().unwrap();
6846
6847        let configured = session.configure_device(configuration.clone()).unwrap();
6848        let (written, order, _) = drive_configuration(&session, configured.outbound_frames);
6849
6850        let writes = device_config_writes(configuration, &reported).unwrap();
6851        assert_eq!(
6852            writes.iter().map(|(key, _)| *key).collect::<Vec<_>>(),
6853            order
6854        );
6855        for (key, value) in &writes {
6856            assert_eq!(written.get(key), Some(value));
6857        }
6858        // Not vacuous: the name leads, and the PHY is the last thing turned
6859        // on — a repeater must not start forwarding under half a policy.
6860        assert_eq!(writes.first().unwrap().0, prop::DEV_NAME);
6861        assert_eq!(writes.last().unwrap(), &(prop::PHY_ENABLED, vec![1]));
6862    }
6863
6864    /// A device that would not report a property is a device that will
6865    /// refuse to be told it, whichever side is doing the telling.
6866    #[test]
6867    fn an_unreadable_property_is_left_out_of_an_administrator_s_write() {
6868        let mut reported = attach_commissionable(
6869            &MobileUlcpSession::new(),
6870            Some(vec![0xAA; 32]),
6871            vec![0xAA; 32],
6872        )
6873        .snapshot
6874        .provisioning
6875        .clone()
6876        .unwrap();
6877        reported.unreadable_properties = vec![prop::DEV_DISCOVERABLE];
6878
6879        let configuration = UlcpDeviceConfigRecord {
6880            radio: UlcpRadioSettingsRecord {
6881                device_name: None,
6882                phy_enabled: true,
6883                frequency_khz: 906_875,
6884                transmit_power_dbm: 20,
6885                bandwidth_hz: None,
6886                spreading_factor: None,
6887                coding_rate_denom: None,
6888                duty_cycle_limit: None,
6889            },
6890            ident_role: None,
6891            ident_mobile: Some(false),
6892            dev_discoverable: Some(true),
6893            repeater: Some(UlcpRepeaterSettingsRecord {
6894                enabled: true,
6895                regions: Vec::new(),
6896                default_region: None,
6897                min_rssi_dbm: None,
6898                min_snr_db: None,
6899            }),
6900            tz_offset_min: None,
6901            gnss: None,
6902            advert: None,
6903        };
6904        // The record still states discoverability — the form does not know
6905        // which properties a device refuses — and the write does not.
6906        let writes = device_config_writes(configuration, &reported).unwrap();
6907        assert!(!writes.iter().any(|(key, _)| *key == prop::DEV_DISCOVERABLE));
6908    }
6909
6910    #[test]
6911    fn an_administrator_failure_names_the_list_it_came_from() {
6912        let session = MobileUlcpSession::new();
6913        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
6914
6915        let insert = session.insert_device_admin(vec![0x22; 32]).unwrap();
6916        let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
6917        let full = session
6918            .consume(property_response(
6919                request.header.tid(),
6920                prop::LAST_STATUS,
6921                &[umsh_ulcp::Status::NOMEM.0 as u8],
6922            ))
6923            .unwrap();
6924        assert_eq!(
6925            full.operation_error,
6926            Some(UlcpOperationErrorRecord {
6927                operation: "insert device administrator".into(),
6928                status_code: umsh_ulcp::Status::NOMEM.0,
6929                status_name: "Status::NOMEM".into(),
6930            })
6931        );
6932        assert_eq!(dev_admin_keys(&full), Vec::<Vec<u8>>::new());
6933        assert_eq!(full.snapshot.phase, UlcpSessionPhase::Attached);
6934    }
6935
6936    #[test]
6937    fn a_radio_that_cannot_be_managed_refuses_the_administrator_table() {
6938        let session = MobileUlcpSession::new();
6939        // CAP_ADMIN withheld: the list does not exist on this device, so
6940        // writing it is not something to try and fail at.
6941        let capabilities = vec![
6942            cap::HOST_FILTER,
6943            cap::SAVE,
6944            cap::DEV_NAME,
6945            cap::DEV_IDENTITY,
6946        ];
6947        let sync = inspect_ulcp_sync(vec![
6948            response(prop::CAPS, &encoded_capabilities(&capabilities)),
6949            response(prop::INTERFACE_TYPE, &[INTERFACE_TYPE as u8]),
6950            response(prop::PHY_ENABLED, &[1]),
6951            response(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
6952            response(prop::PHY_TX_POWER, &[14]),
6953            response(prop::HOST_RX_FILTERS, &[]),
6954            response(prop::SAVED, &[saved::CURRENT]),
6955            response(prop::DEV_PEERS, &[]),
6956            response(prop::DEV_CHANNEL_KEYS, &[]),
6957            response(prop::DEV_DISCOVERABLE, &[1]),
6958        ])
6959        .unwrap();
6960        assert!(!sync.supports_admin);
6961        assert_eq!(sync.dev_admin_keys, None);
6962        assert!(!sync.unreadable_properties.contains(&prop::DEV_ADMINS));
6963        assert!(
6964            !ulcp_inspection_properties(encoded_capabilities(&capabilities))
6965                .unwrap()
6966                .contains(&prop::DEV_ADMINS)
6967        );
6968
6969        assert!(session.insert_device_admin(vec![0x33; 32]).is_err());
6970    }
6971
6972    #[test]
6973    fn an_administrator_list_needs_a_device_identity_to_authorize_against() {
6974        assert!(
6975            ulcp_inspection_properties(encoded_capabilities(&[cap::ADMIN])).is_err(),
6976            "CAP_ADMIN without CAP_DEV_IDENTITY is not a device this phone can describe"
6977        );
6978    }
6979
6980    fn dev_channel_ids(update: &UlcpSessionUpdateRecord) -> Vec<Vec<u8>> {
6981        update
6982            .snapshot
6983            .provisioning
6984            .as_ref()
6985            .unwrap()
6986            .dev_channel_ids
6987            .clone()
6988            .unwrap()
6989    }
6990
6991    #[test]
6992    fn device_channel_insert_and_remove_track_identifiers_not_keys() {
6993        let session = MobileUlcpSession::new();
6994        let attached = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
6995        assert_eq!(dev_channel_ids(&attached), Vec::<Vec<u8>>::new());
6996
6997        // The wire carries the key; the device answers with the derived
6998        // identifier, because a channel key is never read back.
6999        let key = vec![0xB7; 32];
7000        let id = crate::derive_channel_id(key.clone()).unwrap();
7001        assert_eq!(id.len(), items::CHANNEL_ID_LEN);
7002
7003        let insert = session.insert_device_channel_key(key.clone()).unwrap();
7004        let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
7005        assert_eq!(request.command(), Some(Cmd::PropInsert));
7006
7007        let confirmed = session
7008            .consume(inserted_response(
7009                request.header.tid(),
7010                prop::DEV_CHANNEL_KEYS,
7011                &id,
7012            ))
7013            .unwrap();
7014        assert_eq!(dev_channel_ids(&confirmed), vec![id.clone()]);
7015        assert_eq!(confirmed.operation_error, None);
7016        let save = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
7017        assert_eq!(save.command(), Some(Cmd::Save));
7018        let saved = session
7019            .consume(property_response(
7020                save.header.tid(),
7021                prop::LAST_STATUS,
7022                &[umsh_ulcp::Status::OK.0 as u8],
7023            ))
7024            .unwrap();
7025        assert!(!saved.waiting_for_responses);
7026
7027        // Removal selects by key and is likewise confirmed by identifier.
7028        let remove = session.remove_device_channel_key(key).unwrap();
7029        let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
7030        assert_eq!(request.command(), Some(Cmd::PropRemove));
7031        let confirmed = session
7032            .consume(removed_response(
7033                request.header.tid(),
7034                prop::DEV_CHANNEL_KEYS,
7035                &id,
7036            ))
7037            .unwrap();
7038        assert_eq!(dev_channel_ids(&confirmed), Vec::<Vec<u8>>::new());
7039    }
7040
7041    #[test]
7042    fn device_channel_failures_report_status_without_ending_the_session() {
7043        let session = MobileUlcpSession::new();
7044        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
7045
7046        let key = vec![0xC7; 32];
7047        let id = crate::derive_channel_id(key.clone()).unwrap();
7048
7049        let insert = session.insert_device_channel_key(key.clone()).unwrap();
7050        let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
7051        let full = session
7052            .consume(property_response(
7053                request.header.tid(),
7054                prop::LAST_STATUS,
7055                &[umsh_ulcp::Status::NOMEM.0 as u8],
7056            ))
7057            .unwrap();
7058        assert_eq!(
7059            full.operation_error,
7060            Some(UlcpOperationErrorRecord {
7061                operation: "insert device channel key".into(),
7062                status_code: umsh_ulcp::Status::NOMEM.0,
7063                status_name: "Status::NOMEM".into(),
7064            })
7065        );
7066        assert_eq!(dev_channel_ids(&full), Vec::<Vec<u8>>::new());
7067        assert_eq!(full.snapshot.phase, UlcpSessionPhase::Attached);
7068
7069        // ALREADY means the device holds it, so the cache says so too.
7070        let insert = session.insert_device_channel_key(key.clone()).unwrap();
7071        let request = Frame::parse(&insert.outbound_frames[0]).unwrap();
7072        let already = session
7073            .consume(property_response(
7074                request.header.tid(),
7075                prop::LAST_STATUS,
7076                &[umsh_ulcp::Status::ALREADY.0 as u8],
7077            ))
7078            .unwrap();
7079        assert_eq!(dev_channel_ids(&already), vec![id]);
7080
7081        let remove = session.remove_device_channel_key(key).unwrap();
7082        let request = Frame::parse(&remove.outbound_frames[0]).unwrap();
7083        let missing = session
7084            .consume(property_response(
7085                request.header.tid(),
7086                prop::LAST_STATUS,
7087                &[umsh_ulcp::Status::ITEM_NOT_FOUND.0 as u8],
7088            ))
7089            .unwrap();
7090        assert_eq!(
7091            missing.operation_error.as_ref().unwrap().status_name,
7092            "Status::ITEM_NOT_FOUND"
7093        );
7094        assert_eq!(dev_channel_ids(&missing), Vec::<Vec<u8>>::new());
7095
7096        assert!(session.insert_device_channel_key(vec![0xC8; 32]).is_ok());
7097    }
7098
7099    fn host_channel_count(update: &UlcpSessionUpdateRecord) -> Option<u32> {
7100        update
7101            .snapshot
7102            .provisioning
7103            .as_ref()
7104            .unwrap()
7105            .host_channel_count
7106    }
7107
7108    #[test]
7109    fn host_channel_reconcile_inserts_what_the_radio_is_missing() {
7110        let session = MobileUlcpSession::new();
7111        let attached = attach_host_keys_capable(&session);
7112        assert_eq!(host_channel_count(&attached), Some(0));
7113
7114        let first = vec![0xD1; 32];
7115        let second = vec![0xD2; 32];
7116        let update = session
7117            .reconcile_host_channel_keys(vec![first.clone(), second.clone()])
7118            .unwrap();
7119
7120        // One insert at a time; the next rides on the previous confirmation.
7121        assert_eq!(update.outbound_frames.len(), 1);
7122        let request = Frame::parse(&update.outbound_frames[0]).unwrap();
7123        assert_eq!(request.command(), Some(Cmd::PropInsert));
7124        let confirmed = session
7125            .consume(inserted_response(
7126                request.header.tid(),
7127                prop::HOST_CHANNEL_KEYS,
7128                &crate::derive_channel_id(first).unwrap(),
7129            ))
7130            .unwrap();
7131        assert_eq!(confirmed.outbound_frames.len(), 1);
7132        let request = Frame::parse(&confirmed.outbound_frames[0]).unwrap();
7133        let done = session
7134            .consume(inserted_response(
7135                request.header.tid(),
7136                prop::HOST_CHANNEL_KEYS,
7137                &crate::derive_channel_id(second).unwrap(),
7138            ))
7139            .unwrap();
7140        assert!(done.outbound_frames.is_empty());
7141        assert!(!done.waiting_for_responses);
7142        assert_eq!(done.operation_error, None);
7143        assert_eq!(host_channel_count(&done), Some(2));
7144    }
7145
7146    #[test]
7147    fn host_channel_reconcile_is_a_no_op_when_the_radio_already_matches() {
7148        let session = MobileUlcpSession::new();
7149        attach_host_keys_capable(&session);
7150        let key = vec![0xD3; 32];
7151
7152        let update = session
7153            .reconcile_host_channel_keys(vec![key.clone()])
7154            .unwrap();
7155        let request = Frame::parse(&update.outbound_frames[0]).unwrap();
7156        session
7157            .consume(inserted_response(
7158                request.header.tid(),
7159                prop::HOST_CHANNEL_KEYS,
7160                &crate::derive_channel_id(key.clone()).unwrap(),
7161            ))
7162            .unwrap();
7163
7164        // Reconciling the same set again asks the radio for nothing, which is
7165        // what makes reconnecting cheap.
7166        let again = session.reconcile_host_channel_keys(vec![key]).unwrap();
7167        assert!(again.outbound_frames.is_empty());
7168        assert!(!again.waiting_for_responses);
7169    }
7170
7171    #[test]
7172    fn host_channel_reconcile_replaces_the_table_to_shed_an_unknown_channel() {
7173        let session = MobileUlcpSession::new();
7174        attach_host_keys_capable(&session);
7175        let stranger = vec![0xD4; 32];
7176
7177        // Something else provisioned a channel this phone has no key for.
7178        let update = session
7179            .reconcile_host_channel_keys(vec![stranger.clone()])
7180            .unwrap();
7181        let request = Frame::parse(&update.outbound_frames[0]).unwrap();
7182        session
7183            .consume(inserted_response(
7184                request.header.tid(),
7185                prop::HOST_CHANNEL_KEYS,
7186                &crate::derive_channel_id(stranger).unwrap(),
7187            ))
7188            .unwrap();
7189
7190        // Removal selects by key, so an unnameable entry forces a whole-table
7191        // write rather than a remove.
7192        let mine = vec![0xD5; 32];
7193        let replace = session
7194            .reconcile_host_channel_keys(vec![mine.clone()])
7195            .unwrap();
7196        let request = Frame::parse(&replace.outbound_frames[0]).unwrap();
7197        assert_eq!(request.command(), Some(Cmd::PropSet));
7198
7199        let done = session
7200            .consume(property_response(
7201                request.header.tid(),
7202                prop::HOST_CHANNEL_KEYS,
7203                &crate::derive_channel_id(mine).unwrap(),
7204            ))
7205            .unwrap();
7206        assert_eq!(host_channel_count(&done), Some(1));
7207        assert!(!done.waiting_for_responses);
7208    }
7209
7210    #[test]
7211    fn a_full_host_channel_table_reports_status_and_stays_attached() {
7212        let session = MobileUlcpSession::new();
7213        attach_host_keys_capable(&session);
7214
7215        let update = session
7216            .reconcile_host_channel_keys(vec![vec![0xD6; 32], vec![0xD7; 32]])
7217            .unwrap();
7218        let request = Frame::parse(&update.outbound_frames[0]).unwrap();
7219        let full = session
7220            .consume(property_response(
7221                request.header.tid(),
7222                prop::LAST_STATUS,
7223                &[umsh_ulcp::Status::NOMEM.0 as u8],
7224            ))
7225            .unwrap();
7226
7227        assert_eq!(
7228            full.operation_error.as_ref().unwrap().status_name,
7229            "Status::NOMEM"
7230        );
7231        // The pass stops rather than hammering a table it cannot fit, and the
7232        // session stays usable.
7233        assert!(full.outbound_frames.is_empty());
7234        assert!(!full.waiting_for_responses);
7235        assert_eq!(full.snapshot.phase, UlcpSessionPhase::Attached);
7236    }
7237
7238    #[test]
7239    fn an_already_stored_host_channel_key_is_success() {
7240        let session = MobileUlcpSession::new();
7241        attach_host_keys_capable(&session);
7242
7243        let update = session
7244            .reconcile_host_channel_keys(vec![vec![0xD8; 32]])
7245            .unwrap();
7246        let request = Frame::parse(&update.outbound_frames[0]).unwrap();
7247        let already = session
7248            .consume(property_response(
7249                request.header.tid(),
7250                prop::LAST_STATUS,
7251                &[umsh_ulcp::Status::ALREADY.0 as u8],
7252            ))
7253            .unwrap();
7254        assert_eq!(already.operation_error, None);
7255        assert!(!already.waiting_for_responses);
7256    }
7257
7258    #[test]
7259    fn device_channel_keys_must_be_full_length() {
7260        let session = MobileUlcpSession::new();
7261        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
7262        assert_eq!(
7263            session.insert_device_channel_key(vec![0x01; 31]),
7264            Err(MobileError::InvalidChannelKeyLength)
7265        );
7266        assert_eq!(
7267            session.remove_device_channel_key(Vec::new()),
7268            Err(MobileError::InvalidChannelKeyLength)
7269        );
7270    }
7271
7272    #[test]
7273    fn device_peer_operations_require_the_device_identity_capability() {
7274        let session = MobileUlcpSession::new();
7275        let begin = session.begin(None).unwrap();
7276        let inspection =
7277            answer_requests(&session, begin.outbound_frames, |property| match property {
7278                prop::LAST_STATUS => (property, vec![0]),
7279                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
7280                prop::CAPS => (property, vec![cap::WRITABLE_RAW_STREAM as u8]),
7281                prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY => (property, Vec::new()),
7282                prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
7283                _ => unreachable!(),
7284            });
7285        let attached =
7286            answer_requests(
7287                &session,
7288                inspection.outbound_frames,
7289                |property| match property {
7290                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
7291                    prop::PHY_ENABLED => (property, vec![1]),
7292                    prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
7293                    prop::PHY_TX_POWER => (property, vec![14]),
7294                    _ => unreachable!(),
7295                },
7296            );
7297        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
7298        assert_eq!(
7299            session.insert_device_peer(vec![0xC1; 32]).unwrap_err(),
7300            MobileError::InvalidUlcpFrame
7301        );
7302        assert_eq!(
7303            session.remove_device_peer(vec![0xC1; 32]).unwrap_err(),
7304            MobileError::InvalidUlcpFrame
7305        );
7306        // And a malformed key is rejected before any frame is built.
7307        assert_eq!(
7308            session.insert_device_peer(vec![0xC1; 31]).unwrap_err(),
7309            MobileError::InvalidPublicKeyLength
7310        );
7311    }
7312
7313    /// Attach a battery-reporting device and return the session sitting in
7314    /// the attached phase.
7315    fn attached_battery_session() -> std::sync::Arc<MobileUlcpSession> {
7316        let session = MobileUlcpSession::new();
7317        let begin = session.begin(None).unwrap();
7318        let inspection =
7319            answer_requests(&session, begin.outbound_frames, |property| match property {
7320                prop::LAST_STATUS => (property, vec![0]),
7321                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
7322                prop::CAPS => (property, encoded_capabilities(&[cap::BATTERY])),
7323                // Voltage + level + charge state, discharging at 60 %.
7324                prop::BATTERY => (property, vec![0b111, 0x74, 0x0E, 60, 0]),
7325                prop::DEV_KEY | prop::DEV_NAME => (property, Vec::new()),
7326                prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
7327                _ => unreachable!(),
7328            });
7329        let attached =
7330            answer_requests(
7331                &session,
7332                inspection.outbound_frames,
7333                |property| match property {
7334                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
7335                    prop::PHY_ENABLED => (property, vec![1]),
7336                    prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
7337                    prop::PHY_TX_POWER => (property, vec![14]),
7338                    _ => unreachable!(),
7339                },
7340            );
7341        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
7342        session
7343    }
7344
7345    #[test]
7346    fn battery_is_reported_once_per_measurement_not_on_every_update() {
7347        let session = attached_battery_session();
7348
7349        // An unsolicited snapshot is carried by the update that receives
7350        // it: 45 %, now charging.
7351        let pushed = session
7352            .consume(property_response(
7353                frame::TID_UNSOLICITED,
7354                prop::BATTERY,
7355                &[0b111, 0x10, 0x10, 45, 1],
7356            ))
7357            .unwrap();
7358        let battery = pushed.snapshot.battery.expect("push carries the snapshot");
7359        assert_eq!(battery.percentage, Some(45));
7360        assert_eq!(battery.voltage_mv, Some(0x1010));
7361        assert_eq!(battery.charge_state, Some(UlcpChargeState::Charging));
7362
7363        // A later update that carries no measurement must not repeat it.
7364        // Consumers timestamp what they receive, so a repeat would report
7365        // a stale reading as a fresh one.
7366        let unrelated = session
7367            .consume(property_response(
7368                frame::TID_UNSOLICITED,
7369                prop::DEV_NAME,
7370                b"Ridge repeater",
7371            ))
7372            .unwrap();
7373        assert!(unrelated.snapshot.battery.is_none());
7374        assert_eq!(
7375            unrelated.snapshot.device_name.as_deref(),
7376            Some("Ridge repeater"),
7377            "unrelated state still propagates"
7378        );
7379
7380        // The next measurement is reported again.
7381        let again = session
7382            .consume(property_response(
7383                frame::TID_UNSOLICITED,
7384                prop::BATTERY,
7385                &[0b111, 0x20, 0x10, 50, 1],
7386            ))
7387            .unwrap();
7388        assert_eq!(
7389            again.snapshot.battery.expect("second push").percentage,
7390            Some(50)
7391        );
7392    }
7393
7394    /// Attach an alert-capable device sitting in the attached phase.
7395    fn attached_alert_session() -> std::sync::Arc<MobileUlcpSession> {
7396        let session = MobileUlcpSession::new();
7397        let begin = session.begin(None).unwrap();
7398        let inspection =
7399            answer_requests(&session, begin.outbound_frames, |property| match property {
7400                prop::LAST_STATUS => (property, vec![0]),
7401                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
7402                prop::CAPS => (property, encoded_capabilities(&[cap::ALERT])),
7403                prop::DEV_KEY | prop::DEV_NAME | prop::BATTERY => (property, Vec::new()),
7404                prop::HOST_KEY => (prop::LAST_STATUS, vec![2]),
7405                _ => unreachable!(),
7406            });
7407        let attached =
7408            answer_requests(
7409                &session,
7410                inspection.outbound_frames,
7411                |property| match property {
7412                    prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
7413                    prop::PHY_ENABLED => (property, vec![1]),
7414                    prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
7415                    prop::PHY_TX_POWER => (property, vec![14]),
7416                    prop::ALERT => (property, vec![0]),
7417                    _ => unreachable!(),
7418                },
7419            );
7420        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
7421        assert_eq!(attached.snapshot.alert, Some(UlcpAlertState::None));
7422        session
7423    }
7424
7425    #[test]
7426    fn alert_state_follows_the_radio_not_the_request() {
7427        let session = attached_alert_session();
7428
7429        let request = session.set_alert(UlcpAlertState::Locate).unwrap();
7430        let [frame] = &request.outbound_frames[..] else {
7431            panic!("one CMD_PROP_SET");
7432        };
7433        let parsed = Frame::parse(frame).unwrap();
7434        assert_eq!(parsed.command(), Some(Cmd::PropSet));
7435        let payload = PropPayload::parse(parsed.payload).unwrap();
7436        assert_eq!((payload.key, payload.value), (prop::ALERT, &[1u8][..]));
7437
7438        let started = session
7439            .consume(property_response(parsed.header.tid(), prop::ALERT, &[1]))
7440            .unwrap();
7441        assert_eq!(started.snapshot.alert, Some(UlcpAlertState::Locate));
7442
7443        // Unlike battery, the state persists across unrelated updates —
7444        // the UI mirrors it rather than reacting to it once.
7445        let unrelated = session
7446            .consume(property_response(
7447                frame::TID_UNSOLICITED,
7448                prop::DEV_NAME,
7449                b"Ridge repeater",
7450            ))
7451            .unwrap();
7452        assert_eq!(unrelated.snapshot.alert, Some(UlcpAlertState::Locate));
7453
7454        // Someone presses the button on the radio (or its deadline
7455        // expires): the unsolicited update is what the phone believes.
7456        let cancelled = session
7457            .consume(property_response(frame::TID_UNSOLICITED, prop::ALERT, &[0]))
7458            .unwrap();
7459        assert_eq!(cancelled.snapshot.alert, Some(UlcpAlertState::None));
7460        assert_eq!(cancelled.snapshot.phase, UlcpSessionPhase::Attached);
7461    }
7462
7463    #[test]
7464    fn alert_needs_the_capability() {
7465        let session = attached_battery_session();
7466        assert_eq!(
7467            session.set_alert(UlcpAlertState::Locate).unwrap_err(),
7468            MobileError::UnsupportedCapability
7469        );
7470        // And a radio that never reported one leaves the field empty, so
7471        // the UI can hide the control rather than show a dead button.
7472        let update = session
7473            .consume(property_response(
7474                frame::TID_UNSOLICITED,
7475                prop::BATTERY,
7476                &[0b111, 0x10, 0x10, 45, 1],
7477            ))
7478            .unwrap();
7479        assert_eq!(update.snapshot.alert, None);
7480    }
7481
7482    #[test]
7483    fn malformed_alert_values_are_rejected() {
7484        assert_eq!(inspect_ulcp_alert(vec![0]).unwrap(), UlcpAlertState::None);
7485        assert_eq!(inspect_ulcp_alert(vec![1]).unwrap(), UlcpAlertState::Locate);
7486        // Unknown state, trailing bytes, and the empty value.
7487        assert!(inspect_ulcp_alert(vec![2]).is_err());
7488        assert!(inspect_ulcp_alert(vec![1, 0]).is_err());
7489        assert!(inspect_ulcp_alert(Vec::new()).is_err());
7490    }
7491
7492    /// A commissionable device that also keeps a clock and has a receiver
7493    /// holding a three-dimensional fix.
7494    fn attach_positioning(session: &MobileUlcpSession) -> UlcpSessionUpdateRecord {
7495        let mut capabilities = commissionable_capabilities();
7496        capabilities.extend([cap::TIME, cap::GNSS]);
7497        let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
7498        drive_reads(
7499            session,
7500            begin.outbound_frames,
7501            move |property| match property {
7502                prop::CAPS => (property, encoded_capabilities(&capabilities)),
7503                prop::HOST_KEY => (property, vec![0xAA; 32]),
7504                prop::TIME => (property, 1_754_000_000u32.to_le_bytes().to_vec()),
7505                // Pacific daylight time, which is an offset and not a zone
7506                // — the device has no database to shift itself with.
7507                prop::TZ_OFFSET => (property, (-420i16).to_le_bytes().to_vec()),
7508                prop::GNSS_ENABLED | prop::GNSS_IDENT_UPDATE | prop::GNSS_TIME_TRUST => {
7509                    (property, vec![1])
7510                }
7511                prop::GNSS_LOCATION => (property, placed_location().as_bytes().to_vec()),
7512                prop::GNSS_ALTITUDE => (property, 71i32.to_le_bytes().to_vec()),
7513                prop::GNSS_FIX => (property, vec![2]),
7514                prop::GNSS_PRECISION => (property, 62u16.to_le_bytes().to_vec()),
7515                prop::GNSS_SATELLITES => (property, vec![9, 14]),
7516                prop::GNSS_IDENT_PRECISION => (property, vec![5]),
7517                _ => commissionable_value(property),
7518            },
7519        )
7520    }
7521
7522    fn attach_advertising(session: &MobileUlcpSession) -> UlcpSessionUpdateRecord {
7523        let mut capabilities = commissionable_capabilities();
7524        capabilities.push(cap::ADVERT);
7525        let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
7526        drive_reads(
7527            session,
7528            begin.outbound_frames,
7529            move |property| match property {
7530                prop::CAPS => (property, encoded_capabilities(&capabilities)),
7531                prop::HOST_KEY => (property, vec![0xAA; 32]),
7532                prop::ADVERT_INTERVAL => (property, 14_400u32.to_le_bytes().to_vec()),
7533                prop::BEACON_INTERVAL => (property, 3_600u32.to_le_bytes().to_vec()),
7534                prop::STARTUP_BEACON => (property, vec![1]),
7535                _ => commissionable_value(property),
7536            },
7537        )
7538    }
7539
7540    #[test]
7541    fn advertisement_policy_folds_into_the_sync_record() {
7542        let session = MobileUlcpSession::administrative();
7543        let update = attach_advertising(&session);
7544        let sync = update.snapshot.provisioning.expect("device described");
7545
7546        assert!(sync.supports_advert);
7547        assert_eq!(
7548            sync.advert,
7549            Some(UlcpAdvertSettingsRecord {
7550                advert_interval_seconds: 14_400,
7551                beacon_interval_seconds: 3_600,
7552                startup_beacon: true,
7553            })
7554        );
7555    }
7556
7557    /// A commissioning phone has no way to ask for one property on its
7558    /// own, so the position the device advertises has to arrive with the
7559    /// attach snapshot — it is where a region proposal starts from.
7560    #[test]
7561    fn the_advertised_position_folds_into_the_sync_record() {
7562        let cell = NodeLocation::from_lat_lon(37.5119, -122.2495, 4);
7563        let session = MobileUlcpSession::administrative();
7564        let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
7565        let bytes = cell.as_bytes().to_vec();
7566        let update = drive_reads(
7567            &session,
7568            begin.outbound_frames,
7569            move |property| match property {
7570                prop::HOST_KEY => (property, vec![0xAA; 32]),
7571                prop::IDENT_LOCATION => (property, bytes.clone()),
7572                prop::IDENT_ALTITUDE => (property, vec![0x64]),
7573                _ => commissionable_value(property),
7574            },
7575        );
7576        let sync = update.snapshot.provisioning.expect("device described");
7577        let position = sync.ident_position.expect("position reported");
7578
7579        assert_eq!(position.location, cell.as_bytes());
7580        assert!((position.latitude_deg.unwrap() - 37.5119).abs() < 0.01);
7581        assert!((position.longitude_deg.unwrap() + 122.2495).abs() < 0.01);
7582        assert!((610.0..613.0).contains(&position.cell_meters.unwrap()));
7583        assert_eq!(position.altitude_m, Some(100));
7584    }
7585
7586    /// A device that states no position reports an empty cell, which is a
7587    /// value: it is placed nowhere, not unread.
7588    #[test]
7589    fn an_unplaced_device_reports_an_empty_cell() {
7590        let session = MobileUlcpSession::administrative();
7591        let update = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
7592        let sync = update.snapshot.provisioning.expect("device described");
7593        let position = sync.ident_position.expect("position reported");
7594
7595        assert!(position.location.is_empty());
7596        assert_eq!(position.latitude_deg, None);
7597        assert_eq!(position.cell_meters, None);
7598        assert_eq!(position.altitude_m, None);
7599    }
7600
7601    /// A device that never claimed `CAP_ADVERT` has no schedule to report,
7602    /// and the read must not go looking for one.
7603    #[test]
7604    fn a_device_without_the_capability_reports_no_advertisement_policy() {
7605        let session = MobileUlcpSession::administrative();
7606        let update = attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
7607        let sync = update.snapshot.provisioning.expect("device described");
7608
7609        assert!(!sync.supports_advert);
7610        assert_eq!(sync.advert, None);
7611    }
7612
7613    #[test]
7614    fn configure_advertising_writes_the_whole_schedule() {
7615        let session = MobileUlcpSession::administrative();
7616        attach_advertising(&session);
7617
7618        let configured = session
7619            .configure_advertising(Some(UlcpAdvertSettingsRecord {
7620                advert_interval_seconds: 0,
7621                beacon_interval_seconds: 1_800,
7622                startup_beacon: false,
7623            }))
7624            .unwrap();
7625        let (written, _, _) = drive_configuration(&session, configured.outbound_frames);
7626        assert_eq!(
7627            written.get(&prop::ADVERT_INTERVAL).map(Vec::as_slice),
7628            Some(&0u32.to_le_bytes()[..])
7629        );
7630        assert_eq!(
7631            written.get(&prop::BEACON_INTERVAL).map(Vec::as_slice),
7632            Some(&1_800u32.to_le_bytes()[..])
7633        );
7634        assert_eq!(
7635            written.get(&prop::STARTUP_BEACON).map(Vec::as_slice),
7636            Some(&[0u8][..])
7637        );
7638    }
7639
7640    /// Catching the bounds here means an out-of-range interval fails
7641    /// before any of the group is written, rather than half-changing the
7642    /// schedule.
7643    #[test]
7644    fn an_advertisement_record_must_match_what_the_device_can_do() {
7645        let session = MobileUlcpSession::administrative();
7646        attach_advertising(&session);
7647        let whole = UlcpAdvertSettingsRecord {
7648            advert_interval_seconds: 14_400,
7649            beacon_interval_seconds: 3_600,
7650            startup_beacon: true,
7651        };
7652
7653        // Absent on a device that advertises the capability.
7654        assert_eq!(
7655            session.configure_advertising(None),
7656            Err(MobileError::InvalidUlcpFrame)
7657        );
7658        for out_of_range in [
7659            MIN_AUTO_ANNOUNCE_INTERVAL_S - 1,
7660            MAX_AUTO_ANNOUNCE_INTERVAL_S + 1,
7661        ] {
7662            assert_eq!(
7663                session.configure_advertising(Some(UlcpAdvertSettingsRecord {
7664                    beacon_interval_seconds: out_of_range,
7665                    ..whole
7666                })),
7667                Err(MobileError::InvalidUlcpFrame)
7668            );
7669        }
7670        // Zero is the off switch, not a too-short interval.
7671        assert!(
7672            session
7673                .configure_advertising(Some(UlcpAdvertSettingsRecord {
7674                    beacon_interval_seconds: 0,
7675                    ..whole
7676                }))
7677                .is_ok()
7678        );
7679    }
7680
7681    /// Present on a device that does not advertise the capability is the
7682    /// mirror-image mistake, and is refused the same way.
7683    #[test]
7684    fn an_advertisement_record_is_refused_without_the_capability() {
7685        let session = MobileUlcpSession::administrative();
7686        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
7687        assert_eq!(
7688            session.configure_advertising(Some(UlcpAdvertSettingsRecord {
7689                advert_interval_seconds: 14_400,
7690                beacon_interval_seconds: 3_600,
7691                startup_beacon: true,
7692            })),
7693            Err(MobileError::InvalidUlcpFrame)
7694        );
7695    }
7696
7697    /// A five-byte fix — a ~38 m cell, the default identity precision.
7698    fn placed_location() -> NodeLocation {
7699        NodeLocation::from_e7(377_749_290, -1_224_194_160, 5)
7700    }
7701
7702    #[test]
7703    fn a_positioning_device_reports_its_fix_and_its_policy() {
7704        let session = MobileUlcpSession::new();
7705        let attached = attach_positioning(&session);
7706        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
7707
7708        let sync = attached.snapshot.provisioning.clone().expect("described");
7709        assert!(sync.supports_time && sync.supports_gnss);
7710        assert_eq!(sync.tz_offset_min, Some(-420));
7711        assert_eq!(
7712            sync.gnss,
7713            Some(UlcpGnssSettingsRecord {
7714                enabled: true,
7715                ident_update: true,
7716                ident_precision: 5,
7717                time_trust: true,
7718            })
7719        );
7720        assert!(sync.unreadable_properties.is_empty());
7721
7722        let gnss = attached.snapshot.gnss.expect("a receiver was read");
7723        assert_eq!(gnss.fix, UlcpFixKind::ThreeD);
7724        assert_eq!(gnss.altitude_m, Some(71));
7725        assert_eq!(gnss.accuracy_dm, Some(62));
7726        assert_eq!(
7727            (gnss.satellites_used, gnss.satellites_in_view),
7728            (9, Some(14))
7729        );
7730        assert_eq!(gnss.location, placed_location().as_bytes());
7731        // The center of the cell the fix named, which is as close to the
7732        // encoded position as a cell that size can be.
7733        let (latitude, longitude) = (gnss.latitude_deg.unwrap(), gnss.longitude_deg.unwrap());
7734        assert!((latitude - 37.774_929).abs() < 5e-4, "{latitude}");
7735        assert!((longitude + 122.419_416).abs() < 5e-4, "{longitude}");
7736        assert!((38.0..39.0).contains(&gnss.location_cell_meters.unwrap()));
7737    }
7738
7739    #[test]
7740    fn the_clock_is_reported_once_and_the_fix_is_mirrored() {
7741        let session = MobileUlcpSession::new();
7742        attach_positioning(&session);
7743
7744        // Unrelated news must not restamp the clock read at attach as a
7745        // fresh one, but it must not lose the position either: the pin
7746        // stays on the map, the clock does not get a new timestamp.
7747        let unrelated = session
7748            .consume(property_response(
7749                frame::TID_UNSOLICITED,
7750                prop::DEV_NAME,
7751                b"Ridge repeater",
7752            ))
7753            .unwrap();
7754        assert_eq!(unrelated.snapshot.time, None);
7755        assert_eq!(
7756            unrelated.snapshot.gnss.expect("still known").fix,
7757            UlcpFixKind::ThreeD
7758        );
7759
7760        // An announced fix change folds into what is already known rather
7761        // than replacing it: the satellite count came a frame earlier and
7762        // is still true.
7763        let lost = session
7764            .consume(property_response(
7765                frame::TID_UNSOLICITED,
7766                prop::GNSS_FIX,
7767                &[1],
7768            ))
7769            .unwrap();
7770        let gnss = lost.snapshot.gnss.expect("still known");
7771        assert_eq!(gnss.fix, UlcpFixKind::TwoD);
7772        assert_eq!(gnss.satellites_used, 9);
7773        assert_eq!(gnss.altitude_m, Some(71));
7774
7775        // The device finding the time on its own is announced, and is
7776        // reported once like any other reading.
7777        let stepped = session
7778            .consume(property_response(
7779                frame::TID_UNSOLICITED,
7780                prop::TIME,
7781                &1_754_000_600u32.to_le_bytes(),
7782            ))
7783            .unwrap();
7784        assert_eq!(
7785            stepped.snapshot.time,
7786            Some(UlcpTimeRecord {
7787                epoch_seconds: Some(1_754_000_600)
7788            })
7789        );
7790        assert_eq!(stepped.snapshot.phase, UlcpSessionPhase::Attached);
7791    }
7792
7793    #[test]
7794    fn sampling_a_position_asks_for_the_position_and_nothing_else() {
7795        let session = MobileUlcpSession::new();
7796        attach_positioning(&session);
7797
7798        // The device never announces where it is — a receiver reports
7799        // about a fix a second and noise moves the reading, so a host that
7800        // wants a position asks for one.
7801        let poll = session.refresh_positioning().unwrap();
7802        let asked: Vec<u32> = poll
7803            .outbound_frames
7804            .iter()
7805            .map(|frame| {
7806                let parsed = Frame::parse(frame).unwrap();
7807                PropPayload::parse(parsed.payload).unwrap().key
7808            })
7809            .collect();
7810        assert_eq!(
7811            asked,
7812            vec![
7813                prop::GNSS_LOCATION,
7814                prop::GNSS_ALTITUDE,
7815                prop::GNSS_FIX,
7816                prop::GNSS_PRECISION,
7817                prop::GNSS_SATELLITES,
7818            ]
7819        );
7820
7821        // Narrower than a full refresh, which is the point: a screen
7822        // watching a position must not re-read the radio's whole
7823        // configuration once a minute to do it.
7824        assert!(!asked.contains(&prop::PHY_FREQ));
7825        assert!(!asked.contains(&prop::DEV_NAME));
7826
7827        // The answers land in the same snapshot field the announcements
7828        // used to fill, so nothing downstream can tell the two apart.
7829        let sampled = answer_requests(&session, poll.outbound_frames, |property| match property {
7830            prop::GNSS_LOCATION => (property, placed_location().as_bytes().to_vec()),
7831            prop::GNSS_ALTITUDE => (property, 88i32.to_le_bytes().to_vec()),
7832            prop::GNSS_FIX => (property, vec![2]),
7833            prop::GNSS_PRECISION => (property, 40u16.to_le_bytes().to_vec()),
7834            prop::GNSS_SATELLITES => (property, vec![11, 15]),
7835            _ => unreachable!("{property}"),
7836        });
7837        let gnss = sampled.snapshot.gnss.expect("a receiver was sampled");
7838        assert_eq!(gnss.altitude_m, Some(88));
7839        assert_eq!(gnss.satellites_used, 11);
7840        assert_eq!(sampled.snapshot.phase, UlcpSessionPhase::Attached);
7841    }
7842
7843    #[test]
7844    fn a_radio_without_a_receiver_is_never_asked_where_it_is() {
7845        // Every positioning property would be refused one at a time; the
7846        // question is not worth asking at all.
7847        let session = attached_battery_session();
7848        assert_eq!(
7849            session.refresh_positioning(),
7850            Err(MobileError::InvalidUlcpFrame)
7851        );
7852    }
7853
7854    #[test]
7855    fn setting_the_clock_writes_the_epoch_and_clearing_it_writes_nothing() {
7856        let session = MobileUlcpSession::new();
7857        attach_positioning(&session);
7858
7859        let request = session.set_time(Some(1_754_000_900)).unwrap();
7860        let [frame] = &request.outbound_frames[..] else {
7861            panic!("one CMD_PROP_SET");
7862        };
7863        let parsed = Frame::parse(frame).unwrap();
7864        let payload = PropPayload::parse(parsed.payload).unwrap();
7865        assert_eq!(payload.key, prop::TIME);
7866        assert_eq!(payload.value, &1_754_000_900u32.to_le_bytes()[..]);
7867
7868        // What the device answers is what the snapshot reports, even when
7869        // it is not what was written — a trusted receiver may have moved
7870        // the clock between the write and the echo.
7871        let set = session
7872            .consume(property_response(
7873                parsed.header.tid(),
7874                prop::TIME,
7875                &1_754_000_901u32.to_le_bytes(),
7876            ))
7877            .unwrap();
7878        assert_eq!(
7879            set.snapshot.time,
7880            Some(UlcpTimeRecord {
7881                epoch_seconds: Some(1_754_000_901)
7882            })
7883        );
7884
7885        // The empty value is how a clock goes back to unknown.
7886        let clearing = session.set_time(None).unwrap();
7887        let parsed = Frame::parse(&clearing.outbound_frames[0]).unwrap();
7888        let payload = PropPayload::parse(parsed.payload).unwrap();
7889        assert_eq!((payload.key, payload.value), (prop::TIME, &[][..]));
7890        let cleared = session
7891            .consume(property_response(parsed.header.tid(), prop::TIME, &[]))
7892            .unwrap();
7893        assert_eq!(
7894            cleared.snapshot.time,
7895            Some(UlcpTimeRecord {
7896                epoch_seconds: None
7897            })
7898        );
7899    }
7900
7901    #[test]
7902    fn the_clock_needs_the_capability() {
7903        let session = MobileUlcpSession::new();
7904        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
7905        assert_eq!(
7906            session.set_time(Some(1_754_000_900)).unwrap_err(),
7907            MobileError::UnsupportedCapability
7908        );
7909        let sync = session
7910            .refresh()
7911            .unwrap()
7912            .snapshot
7913            .provisioning
7914            .expect("described");
7915        assert!(!sync.supports_time && !sync.supports_gnss);
7916        assert_eq!((sync.tz_offset_min, sync.gnss), (None, None));
7917    }
7918
7919    #[test]
7920    fn positioning_settings_are_written_whole_with_the_receiver_last() {
7921        let session = MobileUlcpSession::administrative();
7922        attach_positioning(&session);
7923
7924        let configured = session
7925            .configure_device(UlcpDeviceConfigRecord {
7926                radio: UlcpRadioSettingsRecord {
7927                    device_name: None,
7928                    phy_enabled: true,
7929                    frequency_khz: 915_000,
7930                    transmit_power_dbm: 14,
7931                    bandwidth_hz: None,
7932                    spreading_factor: None,
7933                    coding_rate_denom: None,
7934                    duty_cycle_limit: None,
7935                },
7936                ident_role: None,
7937                ident_mobile: Some(false),
7938                dev_discoverable: Some(true),
7939                repeater: Some(UlcpRepeaterSettingsRecord {
7940                    enabled: false,
7941                    regions: Vec::new(),
7942                    default_region: None,
7943                    min_rssi_dbm: None,
7944                    min_snr_db: None,
7945                }),
7946                tz_offset_min: Some(60),
7947                gnss: Some(UlcpGnssSettingsRecord {
7948                    enabled: true,
7949                    ident_update: false,
7950                    ident_precision: 3,
7951                    time_trust: false,
7952                }),
7953                advert: None,
7954            })
7955            .unwrap();
7956        let (written, order, _) = drive_configuration(&session, configured.outbound_frames);
7957        assert_eq!(
7958            written.get(&prop::TZ_OFFSET),
7959            Some(&60i16.to_le_bytes().to_vec())
7960        );
7961        assert_eq!(written.get(&prop::GNSS_IDENT_UPDATE), Some(&vec![0]));
7962        assert_eq!(written.get(&prop::GNSS_IDENT_PRECISION), Some(&vec![3]));
7963        assert_eq!(written.get(&prop::GNSS_TIME_TRUST), Some(&vec![0]));
7964        assert_eq!(written.get(&prop::GNSS_ENABLED), Some(&vec![1]));
7965
7966        // The receiver starts under the disclosure and trust policy just
7967        // written, never the one it happened to be holding.
7968        let switch = order.iter().position(|key| *key == prop::GNSS_ENABLED);
7969        for policy in [
7970            prop::GNSS_IDENT_UPDATE,
7971            prop::GNSS_IDENT_PRECISION,
7972            prop::GNSS_TIME_TRUST,
7973        ] {
7974            assert!(
7975                order.iter().position(|key| *key == policy) < switch,
7976                "{policy}"
7977            );
7978        }
7979    }
7980
7981    #[test]
7982    fn a_tethered_phone_changes_positioning_without_restating_the_domain() {
7983        // The companion case: switching a receiver on must not require
7984        // saying anything about the radio's role or what it forwards.
7985        let session = MobileUlcpSession::new();
7986        attach_positioning(&session);
7987
7988        let configured = session
7989            .configure_positioning(
7990                Some(UlcpGnssSettingsRecord {
7991                    enabled: false,
7992                    ident_update: false,
7993                    ident_precision: 3,
7994                    time_trust: false,
7995                }),
7996                Some(0),
7997            )
7998            .unwrap();
7999        let (written, order, save_tid) = drive_configuration(&session, configured.outbound_frames);
8000
8001        assert_eq!(
8002            written,
8003            HashMap::from([
8004                (prop::TZ_OFFSET, 0i16.to_le_bytes().to_vec()),
8005                (prop::GNSS_IDENT_UPDATE, vec![0]),
8006                (prop::GNSS_IDENT_PRECISION, vec![3]),
8007                (prop::GNSS_TIME_TRUST, vec![0]),
8008                (prop::GNSS_ENABLED, vec![0]),
8009            ]),
8010            "only the zone and the positioning policy are written"
8011        );
8012        assert_eq!(order.last(), Some(&prop::GNSS_ENABLED));
8013
8014        // It closes like any configuration pass: a save, then the
8015        // device's own answers reduced into a fresh snapshot.
8016        let attached = session
8017            .consume(property_response(save_tid, prop::LAST_STATUS, &[0]))
8018            .unwrap();
8019        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
8020        assert_eq!(
8021            attached.snapshot.provisioning.unwrap().gnss,
8022            Some(UlcpGnssSettingsRecord {
8023                enabled: false,
8024                ident_update: false,
8025                ident_precision: 3,
8026                time_trust: false,
8027            })
8028        );
8029    }
8030
8031    #[test]
8032    fn positioning_on_its_own_still_matches_the_capabilities() {
8033        let session = MobileUlcpSession::new();
8034        attach_positioning(&session);
8035        let whole = UlcpGnssSettingsRecord {
8036            enabled: true,
8037            ident_update: false,
8038            ident_precision: 5,
8039            time_trust: true,
8040        };
8041        // The same presence rule as the whole-domain write.
8042        assert_eq!(
8043            session.configure_positioning(Some(whole), None),
8044            Err(MobileError::InvalidUlcpFrame)
8045        );
8046        assert_eq!(
8047            session.configure_positioning(None, Some(0)),
8048            Err(MobileError::InvalidUlcpFrame)
8049        );
8050
8051        // And a radio with neither capability has nothing to configure,
8052        // which is a caller mistake rather than an empty success.
8053        let plain = MobileUlcpSession::new();
8054        attach_commissionable(&plain, Some(vec![0xAA; 32]), vec![0xAA; 32]);
8055        assert_eq!(
8056            plain.configure_positioning(None, None),
8057            Err(MobileError::UnsupportedCapability)
8058        );
8059    }
8060
8061    #[test]
8062    fn a_positioning_record_must_match_what_the_device_can_do() {
8063        let session = MobileUlcpSession::administrative();
8064        attach_positioning(&session);
8065
8066        let whole = UlcpDeviceConfigRecord {
8067            radio: UlcpRadioSettingsRecord {
8068                device_name: None,
8069                phy_enabled: true,
8070                frequency_khz: 915_000,
8071                transmit_power_dbm: 14,
8072                bandwidth_hz: None,
8073                spreading_factor: None,
8074                coding_rate_denom: None,
8075                duty_cycle_limit: None,
8076            },
8077            ident_role: None,
8078            ident_mobile: Some(false),
8079            dev_discoverable: Some(true),
8080            repeater: Some(UlcpRepeaterSettingsRecord {
8081                enabled: false,
8082                regions: Vec::new(),
8083                default_region: None,
8084                min_rssi_dbm: None,
8085                min_snr_db: None,
8086            }),
8087            tz_offset_min: Some(0),
8088            gnss: Some(UlcpGnssSettingsRecord {
8089                enabled: true,
8090                ident_update: false,
8091                ident_precision: 5,
8092                time_trust: true,
8093            }),
8094            advert: None,
8095        };
8096
8097        // Both gated fields are required on a device that has them.
8098        assert_eq!(
8099            session.configure_device(UlcpDeviceConfigRecord {
8100                tz_offset_min: None,
8101                ..whole.clone()
8102            }),
8103            Err(MobileError::InvalidUlcpFrame)
8104        );
8105        assert_eq!(
8106            session.configure_device(UlcpDeviceConfigRecord {
8107                gnss: None,
8108                ..whole.clone()
8109            }),
8110            Err(MobileError::InvalidUlcpFrame)
8111        );
8112        // A precision outside 1–7 names no cell.
8113        assert_eq!(
8114            session.configure_device(UlcpDeviceConfigRecord {
8115                gnss: Some(UlcpGnssSettingsRecord {
8116                    ident_precision: 8,
8117                    ..whole.gnss.unwrap()
8118                }),
8119                ..whole.clone()
8120            }),
8121            Err(MobileError::InvalidUlcpFrame)
8122        );
8123        // And an offset no zone on Earth uses.
8124        assert_eq!(
8125            session.configure_device(UlcpDeviceConfigRecord {
8126                tz_offset_min: Some(15 * 60),
8127                ..whole.clone()
8128            }),
8129            Err(MobileError::InvalidUlcpFrame)
8130        );
8131    }
8132
8133    #[test]
8134    fn half_a_positioning_policy_is_withdrawn_whole() {
8135        let session = MobileUlcpSession::administrative();
8136        let mut capabilities = commissionable_capabilities();
8137        capabilities.extend([cap::TIME, cap::GNSS]);
8138        let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
8139        let attached = drive_reads(
8140            &session,
8141            begin.outbound_frames,
8142            move |property| match property {
8143                prop::CAPS => (property, encoded_capabilities(&capabilities)),
8144                prop::HOST_KEY => (property, vec![0xAA; 32]),
8145                // Firmware older than the capability it advertises.
8146                prop::GNSS_TIME_TRUST => (
8147                    prop::LAST_STATUS,
8148                    vec![umsh_ulcp::Status::PROP_NOT_FOUND.0 as u8],
8149                ),
8150                prop::TIME => (property, Vec::new()),
8151                prop::TZ_OFFSET => (property, 0i16.to_le_bytes().to_vec()),
8152                prop::GNSS_ENABLED | prop::GNSS_IDENT_UPDATE => (property, vec![0]),
8153                prop::GNSS_IDENT_PRECISION => (property, vec![5]),
8154                prop::GNSS_LOCATION | prop::GNSS_ALTITUDE | prop::GNSS_PRECISION => {
8155                    (property, Vec::new())
8156                }
8157                prop::GNSS_FIX => (property, vec![0]),
8158                prop::GNSS_SATELLITES => (property, vec![0]),
8159                _ => commissionable_value(property),
8160            },
8161        );
8162
8163        let sync = attached.snapshot.provisioning.clone().expect("described");
8164        assert!(sync.supports_gnss);
8165        assert_eq!(sync.gnss, None);
8166        assert_eq!(sync.unreadable_properties, vec![prop::GNSS_TIME_TRUST]);
8167        // A receiver that is off answers the facts it is sure of and
8168        // leaves the position empty.
8169        let gnss = attached.snapshot.gnss.expect("the receiver was read");
8170        assert_eq!(gnss.fix, UlcpFixKind::None);
8171        assert!(gnss.location.is_empty());
8172        assert_eq!(gnss.latitude_deg, None);
8173
8174        // None of the group is written, because a receiver switched on
8175        // under half a policy is worse than one left alone.
8176        let configured = session
8177            .configure_device(UlcpDeviceConfigRecord {
8178                radio: UlcpRadioSettingsRecord {
8179                    device_name: None,
8180                    phy_enabled: true,
8181                    frequency_khz: 915_000,
8182                    transmit_power_dbm: 14,
8183                    bandwidth_hz: None,
8184                    spreading_factor: None,
8185                    coding_rate_denom: None,
8186                    duty_cycle_limit: None,
8187                },
8188                ident_role: None,
8189                ident_mobile: Some(false),
8190                dev_discoverable: Some(true),
8191                repeater: Some(UlcpRepeaterSettingsRecord {
8192                    enabled: false,
8193                    regions: Vec::new(),
8194                    default_region: None,
8195                    min_rssi_dbm: None,
8196                    min_snr_db: None,
8197                }),
8198                tz_offset_min: Some(0),
8199                gnss: Some(UlcpGnssSettingsRecord {
8200                    enabled: true,
8201                    ident_update: true,
8202                    ident_precision: 5,
8203                    time_trust: true,
8204                }),
8205                advert: None,
8206            })
8207            .unwrap();
8208        let (written, _, _) = drive_configuration(&session, configured.outbound_frames);
8209        for property in [
8210            prop::GNSS_ENABLED,
8211            prop::GNSS_IDENT_UPDATE,
8212            prop::GNSS_IDENT_PRECISION,
8213            prop::GNSS_TIME_TRUST,
8214        ] {
8215            assert!(!written.contains_key(&property), "property {property}");
8216        }
8217        // The zone is its own property and is unaffected.
8218        assert_eq!(
8219            written.get(&prop::TZ_OFFSET),
8220            Some(&0i16.to_le_bytes().to_vec())
8221        );
8222    }
8223
8224    #[test]
8225    fn a_receiver_without_a_clock_is_not_a_device_this_phone_believes() {
8226        // CAP_GNSS requires CAP_TIME: the device dates its own fixes, so a
8227        // receiver with no clock to date them against is a malformed
8228        // capability set rather than a limited device.
8229        assert_eq!(
8230            ulcp_inspection_properties(encoded_capabilities(&[cap::GNSS])),
8231            Err(MobileError::InvalidUlcpFrame)
8232        );
8233
8234        // The clock is read at attach along with the policy, so a phone
8235        // that has just connected knows whether the device knows the time
8236        // rather than waiting for the next announcement to find out.
8237        let asked =
8238            ulcp_inspection_properties(encoded_capabilities(&[cap::TIME, cap::GNSS])).unwrap();
8239        for property in [
8240            prop::TIME,
8241            prop::TZ_OFFSET,
8242            prop::GNSS_ENABLED,
8243            prop::GNSS_LOCATION,
8244            prop::GNSS_FIX,
8245            prop::GNSS_SATELLITES,
8246            prop::GNSS_TIME_TRUST,
8247        ] {
8248            assert!(asked.contains(&property), "property {property}");
8249        }
8250        // And a device with a clock and no receiver is asked for neither.
8251        let clock_only = ulcp_inspection_properties(encoded_capabilities(&[cap::TIME])).unwrap();
8252        assert!(clock_only.contains(&prop::TIME));
8253        assert!(!clock_only.contains(&prop::GNSS_ENABLED));
8254    }
8255
8256    #[test]
8257    fn a_precision_outside_the_encoding_names_no_cell() {
8258        assert_eq!(ulcp_location_cell_meters(0), None);
8259        assert_eq!(ulcp_location_cell_meters(8), None);
8260        // The default identity precision discloses a ~38 m cell.
8261        let five = ulcp_location_cell_meters(5).unwrap();
8262        assert!((38.0..39.0).contains(&five), "{five}");
8263    }
8264
8265    #[test]
8266    fn battery_push_does_not_move_an_attached_session_out_of_phase() {
8267        let session = attached_battery_session();
8268        let pushed = session
8269            .consume(property_response(
8270                frame::TID_UNSOLICITED,
8271                prop::BATTERY,
8272                &[0b111, 0x10, 0x10, 45, 1],
8273            ))
8274            .unwrap();
8275        assert_eq!(pushed.snapshot.phase, UlcpSessionPhase::Attached);
8276        assert!(!pushed.waiting_for_responses);
8277        assert!(pushed.outbound_frames.is_empty());
8278    }
8279
8280    #[test]
8281    fn battery_push_to_a_tethered_session_on_another_phones_radio_stays_attached() {
8282        // A tethered claim that did not take is an answer, not a failure:
8283        // synchronization completes and the session attaches while
8284        // `ownership()` reports `OtherHost` (see the `Claim` arm). Battery
8285        // is the first unsolicited notification that arrives *routinely*,
8286        // so this is the path that turns a latent re-decision into one the
8287        // user would actually see — a settled session must not bounce back
8288        // to awaiting-host every time the radio reports its charge.
8289        let session = MobileUlcpSession::new();
8290        let ours = vec![0x11; 32];
8291        let theirs = vec![0x22; 32];
8292        let begin = session.begin(Some(ours.clone())).unwrap();
8293        let synchronized =
8294            answer_requests(&session, begin.outbound_frames, |property| match property {
8295                prop::LAST_STATUS => (property, vec![0]),
8296                prop::PROTOCOL_VERSION => (property, vec![6, 0]),
8297                // A readable `PROP_HOST_KEY` requires `CAP_HOST_FILTER`
8298                // and vice versa (`advance_completed_stage` enforces it).
8299                prop::CAPS => (
8300                    property,
8301                    encoded_capabilities(&[cap::BATTERY, cap::HOST_FILTER]),
8302                ),
8303                prop::BATTERY => (property, vec![0b111, 0x74, 0x0E, 60, 0]),
8304                prop::DEV_KEY | prop::DEV_NAME => (property, Vec::new()),
8305                // The radio already belongs to another identity.
8306                prop::HOST_KEY => (property, theirs.clone()),
8307                _ => unreachable!(),
8308            });
8309        assert_eq!(
8310            synchronized.snapshot.host_ownership,
8311            UlcpHostOwnership::OtherHost
8312        );
8313        assert_eq!(synchronized.snapshot.phase, UlcpSessionPhase::AwaitingHost);
8314
8315        // The user takes the radio; the device refuses the write and keeps
8316        // reporting the other identity's key. No `CAP_SAVE`, so the claim
8317        // answer runs straight into inspection.
8318        let claim = session.claim(ours).unwrap();
8319        let claim_tid = Frame::parse(&claim.outbound_frames[0])
8320            .unwrap()
8321            .header
8322            .tid();
8323        let claimed = session
8324            .consume(property_response(claim_tid, prop::HOST_KEY, &theirs))
8325            .unwrap();
8326        let attached = answer_requests(
8327            &session,
8328            claimed.outbound_frames,
8329            |property| match property {
8330                prop::INTERFACE_TYPE => (property, vec![INTERFACE_TYPE as u8]),
8331                prop::PHY_ENABLED => (property, vec![1]),
8332                prop::PHY_FREQ => (property, 915_000u32.to_le_bytes().to_vec()),
8333                prop::PHY_TX_POWER => (property, vec![14]),
8334                prop::HOST_RX_FILTERS => (property, Vec::new()),
8335                _ => unreachable!(),
8336            },
8337        );
8338        assert_eq!(attached.snapshot.phase, UlcpSessionPhase::Attached);
8339        assert_eq!(
8340            attached.snapshot.host_ownership,
8341            UlcpHostOwnership::OtherHost,
8342            "the claim did not take"
8343        );
8344
8345        let pushed = session
8346            .consume(property_response(
8347                frame::TID_UNSOLICITED,
8348                prop::BATTERY,
8349                &[0b111, 0x10, 0x10, 45, 1],
8350            ))
8351            .unwrap();
8352        assert_eq!(
8353            pushed.snapshot.battery.expect("push carries it").percentage,
8354            Some(45)
8355        );
8356        assert_eq!(
8357            pushed.snapshot.phase,
8358            UlcpSessionPhase::Attached,
8359            "a battery report is not an attach decision"
8360        );
8361
8362        // The decision itself is still re-opened by the one change that
8363        // warrants it: the radio reporting a different owner.
8364        let reclaimed = session
8365            .consume(property_response(
8366                frame::TID_UNSOLICITED,
8367                prop::HOST_KEY,
8368                &[0x33; 32],
8369            ))
8370            .unwrap();
8371        assert_eq!(
8372            reclaimed.snapshot.phase,
8373            UlcpSessionPhase::AwaitingHost,
8374            "a third phone taking the radio is the user's call"
8375        );
8376    }
8377
8378    // ─── Cached, category-at-a-time remote management ────────────────────
8379
8380    /// The capabilities of a full-featured tracker, for planning against.
8381    fn managed_capabilities() -> Vec<u8> {
8382        encoded_capabilities(&[
8383            cap::DEV_NAME,
8384            cap::BATTERY,
8385            cap::PHY_LORA,
8386            cap::PHY_DUTY_LIMIT,
8387            cap::REPEATER,
8388            cap::IDENT,
8389            cap::DEV_IDENTITY,
8390            cap::GNSS,
8391            cap::TIME,
8392            cap::ADVERT,
8393            cap::ADMIN,
8394            cap::ALERT,
8395            cap::BLE,
8396            cap::STATS,
8397            cap::SAVE,
8398            cap::CMD_MULTI,
8399        ])
8400    }
8401
8402    /// Bluetooth has one capability, so the screen asks for everything it
8403    /// could show and lets the device refuse what it does not have. What
8404    /// the caps list decides is only whether there is a screen at all.
8405    #[test]
8406    fn the_bluetooth_screen_asks_for_everything_and_lets_the_device_refuse() {
8407        let expected = vec![
8408            prop::BLE_ENABLED,
8409            prop::BLE_BOND_COUNT,
8410            prop::BLE_LINK,
8411            prop::BLE_PAIRING,
8412        ];
8413        assert_eq!(
8414            ulcp_category_properties(UlcpManageCategory::Bluetooth, managed_capabilities())
8415                .unwrap(),
8416            expected
8417        );
8418        assert_eq!(
8419            ulcp_category_properties(
8420                UlcpManageCategory::Bluetooth,
8421                encoded_capabilities(&[cap::BLE])
8422            )
8423            .unwrap(),
8424            expected,
8425            "CAP_BLE alone asks the same questions; the answers differ"
8426        );
8427        assert!(
8428            ulcp_category_properties(UlcpManageCategory::Bluetooth, encoded_capabilities(&[]))
8429                .unwrap()
8430                .is_empty(),
8431            "a device with no Bluetooth has no screen at all"
8432        );
8433    }
8434
8435    /// The count and the link report what the transport holds and is
8436    /// doing, and neither is ever written back; only the toggle is
8437    /// editable.
8438    #[test]
8439    fn the_bond_count_is_read_but_never_written() {
8440        let record = inspect_ulcp_properties(vec![
8441            response(prop::BLE_ENABLED, &[1]),
8442            response(prop::BLE_BOND_COUNT, &[3]),
8443            response(prop::BLE_LINK, &[2]),
8444            response(prop::BLE_PAIRING, &[1]),
8445        ]);
8446        assert_eq!(record.ble_enabled, Some(true));
8447        assert_eq!(record.ble_bond_count, Some(3));
8448        assert_eq!(record.ble_link, Some(2));
8449        assert_eq!(record.ble_pairing, Some(true));
8450
8451        // A device that answered the toggle but not the rest is a device
8452        // with a transport it does not manage, and the record says which
8453        // questions went unanswered rather than inventing zeroes.
8454        let partial = inspect_ulcp_properties(vec![response(prop::BLE_ENABLED, &[1])]);
8455        assert_eq!(partial.ble_bond_count, None);
8456        assert_eq!(partial.ble_link, None);
8457
8458        assert_eq!(
8459            dirty(
8460                UlcpDevicePropertiesRecord {
8461                    ble_enabled: Some(false),
8462                    ble_bond_count: Some(3),
8463                    ..Default::default()
8464                },
8465                &[prop::BLE_ENABLED],
8466            )
8467            .unwrap(),
8468            vec![(prop::BLE_ENABLED, vec![0])]
8469        );
8470        assert_eq!(
8471            dirty(
8472                UlcpDevicePropertiesRecord {
8473                    ble_pairing: Some(false),
8474                    ..Default::default()
8475                },
8476                &[prop::BLE_PAIRING],
8477            )
8478            .unwrap(),
8479            vec![(prop::BLE_PAIRING, vec![0])],
8480            "the window is a toggle, closable from the same switch that opens it"
8481        );
8482        assert!(
8483            dirty(
8484                UlcpDevicePropertiesRecord {
8485                    ble_bond_count: Some(0),
8486                    ..Default::default()
8487                },
8488                &[prop::BLE_BOND_COUNT],
8489            )
8490            .is_err(),
8491            "the count is not a way to forget a host"
8492        );
8493    }
8494
8495    #[test]
8496    fn a_category_asks_only_for_its_own_screen() {
8497        let caps = managed_capabilities();
8498        let radio = ulcp_category_properties(UlcpManageCategory::Radio, caps.clone()).unwrap();
8499        assert_eq!(
8500            radio,
8501            vec![
8502                prop::PHY_ENABLED,
8503                prop::PHY_FREQ,
8504                prop::PHY_TX_POWER,
8505                prop::PHY_LORA_BW,
8506                prop::PHY_LORA_SF,
8507                prop::PHY_LORA_CR,
8508                prop::PHY_DUTY_NOW,
8509                prop::PHY_DUTY_LIMIT,
8510            ]
8511        );
8512        assert_eq!(
8513            ulcp_category_properties(UlcpManageCategory::Power, caps).unwrap(),
8514            vec![prop::BATTERY],
8515            "one property is the whole of a power screen"
8516        );
8517    }
8518
8519    #[test]
8520    fn the_statistics_screen_is_capability_gated_and_resets_with_zeroes() {
8521        let full = ulcp_category_properties(UlcpManageCategory::Statistics, managed_capabilities())
8522            .unwrap();
8523        assert_eq!(
8524            full,
8525            vec![
8526                prop::STAT_TX_PACKETS,
8527                prop::STAT_TX_CHANNEL_BUSY,
8528                prop::STAT_RX_PACKETS,
8529                prop::STAT_RX_BAD_CRC,
8530                prop::STAT_RX_NON_UMSH,
8531                prop::STAT_RX_ACCEPTED,
8532                prop::PHY_DUTY_NOW,
8533                prop::UPTIME,
8534                prop::STAT_FORWARDED,
8535                prop::STAT_FORWARD_DROPPED,
8536                prop::STAT_FORWARD_CANCELLED,
8537            ]
8538        );
8539        assert!(
8540            ulcp_category_properties(
8541                UlcpManageCategory::Statistics,
8542                encoded_capabilities(&[cap::DEV_IDENTITY, cap::REPEATER])
8543            )
8544            .unwrap()
8545            .is_empty(),
8546            "duty cycle and uptime do not keep the screen visible without CAP_STATS"
8547        );
8548        assert_eq!(
8549            ulcp_category_properties(
8550                UlcpManageCategory::Statistics,
8551                encoded_capabilities(&[cap::STATS])
8552            )
8553            .unwrap(),
8554            full[..8],
8555            "forwarding counters need CAP_REPEATER"
8556        );
8557
8558        let record = inspect_ulcp_properties(vec![
8559            response(prop::STAT_TX_PACKETS, &23u32.to_le_bytes()),
8560            response(prop::STAT_RX_BAD_CRC, &4u32.to_le_bytes()),
8561            response(prop::PHY_DUTY_NOW, &655u16.to_le_bytes()),
8562            response(prop::UPTIME, &3600u32.to_le_bytes()),
8563        ]);
8564        assert_eq!(record.stat_tx_packets, Some(23));
8565        assert_eq!(record.stat_rx_bad_crc, Some(4));
8566        assert_eq!(record.duty_cycle_now, Some(655));
8567        assert_eq!(record.uptime_seconds, Some(3600));
8568
8569        assert_eq!(
8570            dirty(
8571                UlcpDevicePropertiesRecord::default(),
8572                &[prop::STAT_RX_BAD_CRC, prop::STAT_TX_PACKETS],
8573            )
8574            .unwrap(),
8575            vec![
8576                (prop::STAT_TX_PACKETS, vec![0, 0, 0, 0]),
8577                (prop::STAT_RX_BAD_CRC, vec![0, 0, 0, 0]),
8578            ],
8579            "counter reset writes do not require invented desired values"
8580        );
8581    }
8582
8583    /// The bug this pins: an edit to one property must not drag its
8584    /// neighbors into the write. A device holding a value nobody touched
8585    /// keeps it because nothing was sent, not because the same value was
8586    /// sent back — restating it can be refused, and a refusal abandons
8587    /// the settings someone actually changed.
8588    #[test]
8589    fn only_what_was_edited_is_written() {
8590        let written = dirty(
8591            UlcpDevicePropertiesRecord {
8592                gnss_enabled: Some(true),
8593                gnss_ident_update: Some(false),
8594                gnss_ident_precision: Some(4),
8595                gnss_time_trust: Some(true),
8596                ..Default::default()
8597            },
8598            &[prop::GNSS_IDENT_UPDATE],
8599        )
8600        .unwrap();
8601        assert_eq!(
8602            written,
8603            vec![(prop::GNSS_IDENT_UPDATE, vec![0])],
8604            "the other three GNSS settings were not edited, so they do not travel"
8605        );
8606    }
8607
8608    #[test]
8609    fn a_category_leaves_out_what_the_device_cannot_do() {
8610        // A repeater with no receiver and no modem knobs.
8611        let caps = encoded_capabilities(&[cap::REPEATER, cap::IDENT, cap::DEV_IDENTITY]);
8612        assert_eq!(
8613            ulcp_category_properties(UlcpManageCategory::Radio, caps.clone()).unwrap(),
8614            vec![prop::PHY_ENABLED, prop::PHY_FREQ, prop::PHY_TX_POWER],
8615            "a device without CAP_PHY_LORA is not asked for a modem profile"
8616        );
8617        assert!(
8618            ulcp_category_properties(UlcpManageCategory::Gnss, caps.clone())
8619                .unwrap()
8620                .is_empty(),
8621            "a device with no receiver has no GNSS screen to fill"
8622        );
8623        let identity = ulcp_category_properties(UlcpManageCategory::Identity, caps).unwrap();
8624        assert!(
8625            identity.contains(&prop::IDENT_LOCATION),
8626            "a fixed repeater still has a position to state"
8627        );
8628        assert!(
8629            !identity.contains(&prop::GNSS_IDENT_UPDATE),
8630            "nothing to auto-update from"
8631        );
8632    }
8633
8634    #[test]
8635    fn the_card_is_what_survives_between_openings() {
8636        let card = inspect_ulcp_device_card(vec![
8637            response(prop::CAPS, &managed_capabilities()),
8638            response(prop::DEV_VERSION, b"fw-2026.08.01"),
8639            response(prop::DEV_MODEL, b"T1000-E"),
8640            response(prop::DEV_NAME, b"Ridge"),
8641        ])
8642        .unwrap();
8643        assert_eq!(card.device_version.as_deref(), Some("fw-2026.08.01"));
8644        assert_eq!(card.device_model.as_deref(), Some("T1000-E"));
8645        assert_eq!(card.device_name.as_deref(), Some("Ridge"));
8646        assert!(card.supports_alert, "the find-my-device button is offered");
8647        assert!(card.supports_multi, "batched reads are worth trying");
8648        assert_eq!(
8649            card.capabilities,
8650            managed_capabilities(),
8651            "kept verbatim, to plan later reads against without asking again"
8652        );
8653    }
8654
8655    #[test]
8656    fn a_card_without_capabilities_is_no_card_at_all() {
8657        assert!(
8658            inspect_ulcp_device_card(vec![response(prop::DEV_NAME, b"Ridge")]).is_err(),
8659            "nothing can be planned against a device that would not say what it is"
8660        );
8661    }
8662
8663    #[test]
8664    fn a_card_tolerates_a_device_that_names_neither_firmware_nor_model() {
8665        let card =
8666            inspect_ulcp_device_card(vec![response(prop::CAPS, &managed_capabilities())]).unwrap();
8667        assert_eq!(card.device_version, None);
8668        assert_eq!(card.device_model, None);
8669        assert!(card.supports_gnss, "the capabilities still read");
8670    }
8671
8672    #[test]
8673    fn a_category_read_says_nothing_about_the_categories_it_did_not_ask_for() {
8674        let read = inspect_ulcp_properties(vec![
8675            response(prop::PHY_ENABLED, &[1]),
8676            response(prop::PHY_FREQ, &906_875u32.to_le_bytes()),
8677            response(prop::PHY_TX_POWER, &[22]),
8678            response(prop::PHY_LORA_SF, &[11]),
8679        ]);
8680        assert_eq!(read.phy_enabled, Some(true));
8681        assert_eq!(read.frequency_khz, Some(906_875));
8682        assert_eq!(read.transmit_power_dbm, Some(22));
8683        assert_eq!(read.spreading_factor, Some(11));
8684        assert_eq!(read.device_name, None, "nobody asked");
8685        assert_eq!(read.repeater_enabled, None);
8686        assert_eq!(read.gnss, None);
8687    }
8688
8689    #[test]
8690    fn an_unreadable_answer_leaves_its_field_absent_rather_than_failing_the_read() {
8691        let read = inspect_ulcp_properties(vec![
8692            response(prop::PHY_FREQ, &[0x01, 0x02]),
8693            response(prop::PHY_TX_POWER, &[17]),
8694        ]);
8695        assert_eq!(read.frequency_khz, None, "two octets are not a frequency");
8696        assert_eq!(
8697            read.transmit_power_dbm,
8698            Some(17),
8699            "one bad answer does not cost the screen the rest"
8700        );
8701    }
8702
8703    #[test]
8704    fn an_advertised_position_reads_as_a_place() {
8705        let cell = [0x84, 0x21, 0x9f, 0x40];
8706        let read = inspect_ulcp_properties(vec![
8707            response(prop::IDENT_LOCATION, &cell),
8708            response(prop::IDENT_ALTITUDE, &[0xC8, 0x00]),
8709        ]);
8710        assert_eq!(read.ident_location.as_deref(), Some(&cell[..]));
8711        assert!(read.ident_latitude_deg.is_some());
8712        assert!(read.ident_longitude_deg.is_some());
8713        assert_eq!(
8714            read.ident_location_cell_meters,
8715            ulcp_location_cell_meters(4),
8716            "what the four octets actually disclose"
8717        );
8718        assert_eq!(
8719            read.ident_altitude_m,
8720            Some(200),
8721            "a padded altitude reads the same as a minimal one"
8722        );
8723    }
8724
8725    #[test]
8726    fn a_device_advertising_no_position_is_not_a_device_that_was_never_asked() {
8727        let read = inspect_ulcp_properties(vec![
8728            response(prop::IDENT_LOCATION, &[]),
8729            response(prop::IDENT_ALTITUDE, &[]),
8730        ]);
8731        assert_eq!(read.ident_location, Some(Vec::new()));
8732        assert_eq!(read.ident_latitude_deg, None);
8733        assert_eq!(read.ident_altitude_m, None);
8734    }
8735
8736    #[test]
8737    fn a_place_encodes_to_the_cell_it_reads_back_as() {
8738        let cell = ulcp_encode_location(37.3382, -121.8863, 5).unwrap();
8739        assert_eq!(cell.len(), 5, "the precision is the value's length");
8740        let read = inspect_ulcp_properties(vec![response(prop::IDENT_LOCATION, &cell)]);
8741        assert!(
8742            (read.ident_latitude_deg.unwrap() - 37.3382).abs() < 0.01,
8743            "the cell the point falls in"
8744        );
8745        assert!((read.ident_longitude_deg.unwrap() + 121.8863).abs() < 0.01);
8746    }
8747
8748    #[test]
8749    fn a_coordinate_off_the_globe_is_a_typo_rather_than_a_place() {
8750        assert!(ulcp_encode_location(37.0, 200.0, 5).is_err());
8751        assert!(ulcp_encode_location(91.0, 0.0, 5).is_err());
8752        assert!(ulcp_encode_location(37.0, 0.0, 0).is_err());
8753        assert!(ulcp_encode_location(37.0, 0.0, 8).is_err());
8754    }
8755
8756    #[test]
8757    fn a_negative_altitude_is_an_ordinary_place() {
8758        let read = inspect_ulcp_properties(vec![response(prop::IDENT_ALTITUDE, &[0x9C])]);
8759        assert_eq!(read.ident_altitude_m, Some(-100), "Death Valley reads");
8760    }
8761
8762    #[test]
8763    fn a_receiver_readout_needs_the_fix_to_mean_anything() {
8764        let without = inspect_ulcp_properties(vec![
8765            response(prop::GNSS_ENABLED, &[1]),
8766            response(prop::GNSS_SATELLITES, &[7, 9]),
8767        ]);
8768        assert_eq!(without.gnss_enabled, Some(true));
8769        assert_eq!(
8770            without.gnss, None,
8771            "satellite counts alone do not say whether there is a position"
8772        );
8773
8774        let with = inspect_ulcp_properties(vec![
8775            response(prop::GNSS_FIX, &[FixKind::ThreeD as u8]),
8776            response(prop::GNSS_LOCATION, &[0x84, 0x21, 0x9f, 0x40]),
8777            response(prop::GNSS_ALTITUDE, &1_400i32.to_le_bytes()),
8778            response(prop::GNSS_SATELLITES, &[7, 9]),
8779        ]);
8780        let readout = with.gnss.expect("a fix makes a readout");
8781        assert_eq!(readout.altitude_m, Some(1_400));
8782        assert_eq!(readout.satellites_used, 7);
8783        assert!(readout.latitude_deg.is_some());
8784    }
8785
8786    /// The writes a dirty-apply produced, in order.
8787    fn dirty(
8788        desired: UlcpDevicePropertiesRecord,
8789        edited: &[u32],
8790    ) -> Result<Vec<(u32, Vec<u8>)>, MobileError> {
8791        Ok(ulcp_dirty_writes(desired, edited.to_vec())?
8792            .into_iter()
8793            .map(|write| (write.property_id, write.value))
8794            .collect())
8795    }
8796
8797    #[test]
8798    fn one_edit_is_one_write() {
8799        let written = dirty(
8800            UlcpDevicePropertiesRecord {
8801                device_name: Some("Saddle".into()),
8802                // Everything a full read would have filled in, none of
8803                // which was touched.
8804                ident_mobile: Some(true),
8805                repeater_enabled: Some(true),
8806                dev_discoverable: Some(false),
8807                ..Default::default()
8808            },
8809            &[prop::DEV_NAME],
8810        )
8811        .unwrap();
8812        assert_eq!(
8813            written,
8814            vec![(prop::DEV_NAME, b"Saddle".to_vec())],
8815            "the point of the whole design: a rename costs one write"
8816        );
8817    }
8818
8819    #[test]
8820    fn an_edit_with_nothing_to_write_is_a_caller_mistake() {
8821        assert!(
8822            dirty(UlcpDevicePropertiesRecord::default(), &[prop::DEV_NAME]).is_err(),
8823            "reporting success for an edit that carries no value would be a lie"
8824        );
8825    }
8826
8827    #[test]
8828    fn a_modem_knob_travels_alone_inside_the_bracket() {
8829        let written = dirty(
8830            UlcpDevicePropertiesRecord {
8831                phy_enabled: Some(true),
8832                bandwidth_hz: Some(250_000),
8833                spreading_factor: Some(10),
8834                coding_rate_denom: Some(5),
8835                ..Default::default()
8836            },
8837            &[prop::PHY_LORA_SF],
8838        )
8839        .unwrap();
8840        let keys: Vec<u32> = written.iter().map(|(key, _)| *key).collect();
8841        assert_eq!(
8842            keys,
8843            vec![prop::PHY_ENABLED, prop::PHY_LORA_SF, prop::PHY_ENABLED],
8844            "the untouched bandwidth and coding rate stay home"
8845        );
8846        assert_eq!(written.first().unwrap().1, vec![0], "the radio goes down");
8847        assert_eq!(
8848            written.last().unwrap().1,
8849            vec![1],
8850            "and comes back up as it was"
8851        );
8852    }
8853
8854    #[test]
8855    fn a_radio_left_off_stays_off_after_the_change() {
8856        let written = dirty(
8857            UlcpDevicePropertiesRecord {
8858                phy_enabled: Some(false),
8859                frequency_khz: Some(915_000),
8860                ..Default::default()
8861            },
8862            &[prop::PHY_FREQ],
8863        )
8864        .unwrap();
8865        assert_eq!(written.first().unwrap(), &(prop::PHY_ENABLED, vec![0]));
8866        assert_eq!(
8867            written.last().unwrap(),
8868            &(prop::PHY_ENABLED, vec![0]),
8869            "bringing the radio up would be a change nobody asked for"
8870        );
8871    }
8872
8873    #[test]
8874    fn turning_the_radio_off_is_not_bracketed() {
8875        let written = dirty(
8876            UlcpDevicePropertiesRecord {
8877                phy_enabled: Some(false),
8878                ..Default::default()
8879            },
8880            &[prop::PHY_ENABLED],
8881        )
8882        .unwrap();
8883        assert_eq!(
8884            written,
8885            vec![(prop::PHY_ENABLED, vec![0])],
8886            "nothing is transmitting under a parameter that moved"
8887        );
8888    }
8889
8890    #[test]
8891    fn an_altitude_takes_no_more_octets_than_it_needs() {
8892        for (meters, expected) in [
8893            (100i32, vec![0x64]),
8894            (-100, vec![0x9C]),
8895            (200, vec![0xC8, 0x00]),
8896            (-200, vec![0x38, 0xFF]),
8897            (100_000, vec![0xA0, 0x86, 0x01]),
8898            (-100_000, vec![0x60, 0x79, 0xFE]),
8899        ] {
8900            let written = dirty(
8901                UlcpDevicePropertiesRecord {
8902                    ident_altitude_m: Some(meters),
8903                    ..Default::default()
8904                },
8905                &[prop::IDENT_ALTITUDE],
8906            )
8907            .unwrap();
8908            assert_eq!(
8909                written,
8910                vec![(prop::IDENT_ALTITUDE, expected)],
8911                "{meters} m"
8912            );
8913        }
8914    }
8915
8916    #[test]
8917    fn clearing_a_position_writes_the_clearing() {
8918        let written = dirty(
8919            UlcpDevicePropertiesRecord {
8920                ident_location: None,
8921                ident_altitude_m: None,
8922                ..Default::default()
8923            },
8924            &[prop::IDENT_LOCATION, prop::IDENT_ALTITUDE],
8925        )
8926        .unwrap();
8927        assert_eq!(
8928            written,
8929            vec![
8930                (prop::IDENT_LOCATION, Vec::new()),
8931                (prop::IDENT_ALTITUDE, Vec::new()),
8932            ],
8933            "an empty value is how a device is told it has no position"
8934        );
8935    }
8936
8937    #[test]
8938    fn a_position_finer_than_the_encoding_allows_is_refused_here() {
8939        assert!(
8940            dirty(
8941                UlcpDevicePropertiesRecord {
8942                    ident_location: Some(vec![0; 8]),
8943                    ..Default::default()
8944                },
8945                &[prop::IDENT_LOCATION],
8946            )
8947            .is_err(),
8948            "the device would refuse it, costing a round trip that could only fail"
8949        );
8950    }
8951
8952    #[test]
8953    fn a_read_only_property_is_not_something_to_apply() {
8954        assert!(
8955            dirty(
8956                UlcpDevicePropertiesRecord {
8957                    duty_cycle_now: Some(12),
8958                    ..Default::default()
8959                },
8960                &[prop::PHY_DUTY_NOW],
8961            )
8962            .is_err(),
8963            "a device's own report of its past hour is not the phone's to state"
8964        );
8965    }
8966
8967    #[test]
8968    fn a_forwarding_policy_edit_travels_alone() {
8969        let written = dirty(
8970            UlcpDevicePropertiesRecord {
8971                repeater_enabled: Some(true),
8972                repeater_regions: Some(vec!["SJC".into()]),
8973                repeater_default_region: None,
8974                repeater_min_rssi_dbm: Some(-115),
8975                repeater_min_snr_db: None,
8976                ..Default::default()
8977            },
8978            &[prop::MAC_REPEATER_MIN_RSSI, prop::MAC_REPEATER_REGIONS],
8979        )
8980        .unwrap();
8981        assert_eq!(
8982            written.iter().map(|(key, _)| *key).collect::<Vec<_>>(),
8983            vec![prop::MAC_REPEATER_REGIONS, prop::MAC_REPEATER_MIN_RSSI],
8984            "the three untouched policy settings stay home"
8985        );
8986        assert_eq!(
8987            written[0].1,
8988            vec![3, b'S', b'J', b'C'],
8989            "regions pack the same way they do over the local link"
8990        );
8991    }
8992
8993    // ─── Local management operations ─────────────────────────────────
8994
8995    /// A `CMD_PROP_SET` request, decoded.
8996    fn set_request(bytes: &[u8]) -> (u8, u32, Vec<u8>) {
8997        let parsed = Frame::parse(bytes).unwrap();
8998        assert_eq!(parsed.command(), Some(Cmd::PropSet));
8999        let payload = PropPayload::parse(parsed.payload).unwrap();
9000        (parsed.header.tid(), payload.key, payload.value.to_vec())
9001    }
9002
9003    #[test]
9004    fn local_fetch_reports_values_and_refusals_alike() {
9005        let session = MobileUlcpSession::new();
9006        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
9007
9008        let update = session
9009            .begin_property_fetch(vec![prop::DEV_NAME, prop::GNSS_ENABLED])
9010            .unwrap();
9011        assert_eq!(update.outbound_frames.len(), 2);
9012
9013        let (name_tid, name_prop) = property_request(&update.outbound_frames[0]);
9014        assert_eq!(name_prop, prop::DEV_NAME);
9015        let (gnss_tid, gnss_prop) = property_request(&update.outbound_frames[1]);
9016        assert_eq!(gnss_prop, prop::GNSS_ENABLED);
9017
9018        let mid = session
9019            .consume(property_response(name_tid, prop::DEV_NAME, b"Ridge"))
9020            .unwrap();
9021        assert!(
9022            mid.management_event.is_none(),
9023            "half-answered is not answered"
9024        );
9025        // The device has no receiver, so it refuses the second property.
9026        let done = session
9027            .consume(property_response(
9028                gnss_tid,
9029                prop::LAST_STATUS,
9030                &[umsh_ulcp::Status::PROP_NOT_FOUND.0 as u8],
9031            ))
9032            .unwrap();
9033        let event = done.management_event.expect("both answers are in");
9034        assert_eq!(
9035            event.answers,
9036            vec![
9037                MobileMeshManagementAnswerRecord {
9038                    property_id: prop::DEV_NAME,
9039                    value: Some(b"Ridge".to_vec()),
9040                    status_code: None,
9041                },
9042                MobileMeshManagementAnswerRecord {
9043                    property_id: prop::GNSS_ENABLED,
9044                    value: None,
9045                    status_code: Some(umsh_ulcp::Status::PROP_NOT_FOUND.0),
9046                },
9047            ],
9048            "a refusal is that property's answer, not the operation's failure"
9049        );
9050        assert_eq!(event.status_code, None);
9051        assert_eq!(
9052            done.snapshot.device_name.as_deref(),
9053            Some("Ridge"),
9054            "what a fetch learns, the session snapshot learns too"
9055        );
9056    }
9057
9058    #[test]
9059    fn local_fetch_asks_in_bounded_batches() {
9060        let session = MobileUlcpSession::new();
9061        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
9062
9063        // Nine properties: seven in the first round, two in the second.
9064        let properties: Vec<u32> = vec![
9065            prop::DEV_NAME,
9066            prop::PHY_ENABLED,
9067            prop::PHY_FREQ,
9068            prop::PHY_TX_POWER,
9069            prop::IDENT_MOBILE,
9070            prop::DEV_DISCOVERABLE,
9071            prop::MAC_REPEATER_ENABLED,
9072            prop::DEV_PEERS,
9073            prop::DEV_ADMINS,
9074        ];
9075        let update = session.begin_property_fetch(properties.clone()).unwrap();
9076        assert_eq!(
9077            update.outbound_frames.len(),
9078            usize::from(frame::TID_MAX),
9079            "a round asks for no more than the transaction space holds"
9080        );
9081        let second = answer_requests(&session, update.outbound_frames, commissionable_value);
9082        assert_eq!(second.outbound_frames.len(), 2);
9083        assert!(second.management_event.is_none());
9084        let done = answer_requests(&session, second.outbound_frames, commissionable_value);
9085        let event = done.management_event.expect("all nine answered");
9086        assert_eq!(event.answers.len(), properties.len());
9087    }
9088
9089    #[test]
9090    fn local_writes_go_out_one_at_a_time_and_survive_a_refusal() {
9091        let session = MobileUlcpSession::new();
9092        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
9093
9094        let update = session
9095            .begin_property_writes(vec![
9096                MobileMeshPropertyWriteRecord {
9097                    property_id: prop::DEV_NAME,
9098                    value: b"Saddle".to_vec(),
9099                },
9100                MobileMeshPropertyWriteRecord {
9101                    property_id: prop::IDENT_MOBILE,
9102                    value: vec![1],
9103                },
9104            ])
9105            .unwrap();
9106        assert_eq!(
9107            update.outbound_frames.len(),
9108            1,
9109            "order is load-bearing, so nothing is pipelined"
9110        );
9111        let (tid, property, value) = set_request(&update.outbound_frames[0]);
9112        assert_eq!(
9113            (property, value.as_slice()),
9114            (prop::DEV_NAME, &b"Saddle"[..])
9115        );
9116
9117        // The device refuses the name; the run continues to the next write.
9118        let mid = session
9119            .consume(property_response(
9120                tid,
9121                prop::LAST_STATUS,
9122                &[umsh_ulcp::Status::INVALID_ARGUMENT.0 as u8],
9123            ))
9124            .unwrap();
9125        assert!(mid.management_event.is_none());
9126        assert_eq!(mid.outbound_frames.len(), 1);
9127        let (tid, property, value) = set_request(&mid.outbound_frames[0]);
9128        assert_eq!((property, value), (prop::IDENT_MOBILE, vec![1]));
9129
9130        let done = session
9131            .consume(property_response(tid, prop::IDENT_MOBILE, &[1]))
9132            .unwrap();
9133        let event = done.management_event.expect("both writes answered");
9134        assert_eq!(
9135            event.answers,
9136            vec![
9137                MobileMeshManagementAnswerRecord {
9138                    property_id: prop::DEV_NAME,
9139                    value: None,
9140                    status_code: Some(umsh_ulcp::Status::INVALID_ARGUMENT.0),
9141                },
9142                MobileMeshManagementAnswerRecord {
9143                    property_id: prop::IDENT_MOBILE,
9144                    value: Some(vec![1]),
9145                    status_code: None,
9146                },
9147            ],
9148        );
9149    }
9150
9151    #[test]
9152    fn local_save_reports_the_device_status() {
9153        let session = MobileUlcpSession::new();
9154        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
9155
9156        let update = session.begin_save().unwrap();
9157        assert_eq!(update.outbound_frames.len(), 1);
9158        let parsed = Frame::parse(&update.outbound_frames[0]).unwrap();
9159        assert_eq!(parsed.command(), Some(Cmd::Save));
9160        let done = session
9161            .consume(property_response(
9162                parsed.header.tid(),
9163                prop::LAST_STATUS,
9164                &[0],
9165            ))
9166            .unwrap();
9167        let event = done.management_event.expect("the save answered");
9168        assert!(event.answers.is_empty());
9169        assert_eq!(event.status_code, Some(0));
9170    }
9171
9172    #[test]
9173    fn local_save_without_the_capability_is_already_done() {
9174        let session = MobileUlcpSession::new();
9175        let capabilities: Vec<u32> = commissionable_capabilities()
9176            .into_iter()
9177            .filter(|&capability| capability != cap::SAVE)
9178            .collect();
9179        let begin = session.begin(Some(vec![0xAA; 32])).unwrap();
9180        drive_reads(
9181            &session,
9182            begin.outbound_frames,
9183            move |property| match property {
9184                prop::CAPS => (property, encoded_capabilities(&capabilities)),
9185                prop::HOST_KEY => (property, vec![0xAA; 32]),
9186                _ => commissionable_value(property),
9187            },
9188        );
9189
9190        let update = session.begin_save().unwrap();
9191        assert!(update.outbound_frames.is_empty());
9192        let event = update
9193            .management_event
9194            .expect("nothing to ask, so the operation is already complete");
9195        assert_eq!(event.status_code, None);
9196    }
9197
9198    #[test]
9199    fn one_local_operation_at_a_time() {
9200        let session = MobileUlcpSession::new();
9201        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
9202
9203        let update = session.begin_property_fetch(vec![prop::DEV_NAME]).unwrap();
9204        assert!(
9205            session.begin_property_fetch(vec![prop::DEV_NAME]).is_err(),
9206            "a second operation must wait for the first"
9207        );
9208        assert!(session.refresh().is_err(), "so must a refresh");
9209
9210        let (tid, _) = property_request(&update.outbound_frames[0]);
9211        let done = session
9212            .consume(property_response(tid, prop::DEV_NAME, b"Ridge"))
9213            .unwrap();
9214        assert!(done.management_event.is_some());
9215        assert!(
9216            session.begin_property_fetch(vec![prop::DEV_NAME]).is_ok(),
9217            "and once it completes, the next may run"
9218        );
9219    }
9220
9221    #[test]
9222    fn an_unsolicited_value_is_carried_out_verbatim() {
9223        let session = MobileUlcpSession::new();
9224        attach_commissionable(&session, Some(vec![0xAA; 32]), vec![0xAA; 32]);
9225
9226        let update = session
9227            .consume(property_response(
9228                frame::TID_UNSOLICITED,
9229                prop::IDENT_MOBILE,
9230                &[1],
9231            ))
9232            .unwrap();
9233        assert_eq!(
9234            update.pushed_properties,
9235            vec![UlcpPropertyPushRecord {
9236                property_id: prop::IDENT_MOBILE,
9237                value: vec![1],
9238            }],
9239            "a push reaches whoever caches values by number, not just the snapshot"
9240        );
9241    }
9242
9243    #[test]
9244    fn a_lazy_administrative_attach_reads_only_what_attaching_requires() {
9245        let session = MobileUlcpSession::administrative_lazy();
9246        let begin = session.begin(None).unwrap();
9247        let mut asked = Vec::new();
9248        let mut pending = begin.outbound_frames;
9249        let mut last = None;
9250        while !pending.is_empty() {
9251            let update = answer_requests(&session, pending.clone(), |property| match property {
9252                // Someone else's radio, which an administrative session
9253                // attaches to anyway.
9254                prop::HOST_KEY => (property, vec![0xBB; 32]),
9255                _ => commissionable_value(property),
9256            });
9257            for request in &pending {
9258                asked.push(property_request(request).1);
9259            }
9260            pending = update.outbound_frames.clone();
9261            last = Some(update);
9262        }
9263        let update = last.unwrap();
9264        assert_eq!(update.snapshot.phase, UlcpSessionPhase::Attached);
9265        assert_eq!(
9266            asked.len(),
9267            11,
9268            "the seven-property preamble and the four the sync reduction insists on"
9269        );
9270        assert!(
9271            !asked.contains(&prop::MAC_REPEATER_ENABLED),
9272            "the device domain is left to be read on demand"
9273        );
9274
9275        // And reading on demand works.
9276        let fetch = session
9277            .begin_property_fetch(vec![prop::MAC_REPEATER_ENABLED])
9278            .unwrap();
9279        let (tid, _) = property_request(&fetch.outbound_frames[0]);
9280        let done = session
9281            .consume(property_response(tid, prop::MAC_REPEATER_ENABLED, &[0]))
9282            .unwrap();
9283        assert!(done.management_event.is_some());
9284    }
9285
9286    #[test]
9287    fn radio_presets_cross_the_bindings_whole() {
9288        let presets = ulcp_radio_presets();
9289        assert_eq!(presets.len(), umsh_ulcp::profiles::VETTED.len());
9290        assert_eq!(presets[0].id, umsh_ulcp::profiles::DEFAULT.id);
9291
9292        // Every field of every vetted profile, rather than one profile's
9293        // numbers written out again. A field dropped or crossed onto the
9294        // wrong one is what this conversion can get wrong; which entry holds
9295        // the default, and what it is tuned to, belongs to the profile table.
9296        for (preset, profile) in presets.iter().zip(umsh_ulcp::profiles::VETTED) {
9297            assert_eq!(preset.id, profile.id);
9298            assert_eq!(preset.name, profile.name);
9299            assert_eq!(preset.frequency_khz, profile.freq_khz);
9300            assert_eq!(preset.bandwidth_hz, profile.bw_hz);
9301            assert_eq!(preset.spreading_factor, profile.sf);
9302            assert_eq!(preset.coding_rate_denom, profile.cr_denom);
9303            assert_eq!(preset.transmit_power_dbm, profile.tx_power_dbm);
9304            assert_eq!(preset.duty_cycle_limit, profile.duty_limit);
9305            assert_eq!(preset.sync_word, profile.sync_word);
9306            assert_eq!(preset.tx_preamble_symbols, profile.tx_preamble_symbols);
9307        }
9308
9309        // A profile with no vetted power crosses as an absent one
9310        // rather than a zero.
9311        assert!(
9312            presets
9313                .iter()
9314                .any(|preset| preset.transmit_power_dbm.is_none()),
9315            "the optional power is reachable from the app"
9316        );
9317        assert_eq!(
9318            ulcp_supported_bandwidths_hz(),
9319            umsh_ulcp::profiles::SUPPORTED_BANDWIDTHS_HZ
9320        );
9321    }
9322}