umsh_ulcp_device/
session.rs

1//! The ULCP session state machine.
2
3use heapless::{Deque, Vec as HeaplessVec};
4use umsh_core::options::{OptionDecoder, OptionEncoder};
5use umsh_core::{
6    ChannelKey, EncodeError, NodeHint, PacketBuilder, PacketHeader, PacketType, SourceAddrRef,
7};
8use umsh_crypto::replay::{ReplayVerdict, ReplayWindow};
9use umsh_crypto::{AesProvider, CryptoEngine, PairwiseKeys, Sha256Provider};
10use umsh_ulcp::Status;
11use umsh_ulcp::airtime::lora_airtime_ms;
12use umsh_ulcp::alert::AlertState;
13use umsh_ulcp::battery::{self, BatteryStatus};
14use umsh_ulcp::frame::{self, Cmd, Frame, PropPayload, StreamPayload, TID_UNSOLICITED};
15use umsh_ulcp::gnss::{self, GnssSnapshot};
16use umsh_ulcp::ids::{
17    self, DEFAULT_ADVERT_INTERVAL_S, DEFAULT_BEACON_INTERVAL_S, MAX_AUTO_ANNOUNCE_INTERVAL_S,
18    MIN_AUTO_ANNOUNCE_INTERVAL_S, cap, prop, stream,
19};
20use umsh_ulcp::items::{self, Filter, ItemError, REGION_CODE_LEN};
21use umsh_ulcp::meta::{self, BufferedRxMeta, RX_FLAG_ACKED, RX_FLAG_BUFFERED, RxMeta, TxMeta};
22use umsh_ulcp::pui;
23
24use crate::duty::DutyLedger;
25
26/// Largest radio payload the session can carry (SX126x-class limit).
27pub const MAX_MTU: usize = 255;
28
29/// Maximum UTF-8 byte length of `PROP_DEV_NAME`.
30pub const MAX_DEVICE_NAME_LEN: usize = 64;
31
32/// Room for a `CMD_STR_RECV` frame around a full-MTU payload.
33const SCRATCH: usize = MAX_MTU + 24;
34
35/// Largest encoded property value the session produces (bounded by
36/// `PROP_HOST_PEER_KEYS`' digest form: one public key per entry).
37const PROP_BUF: usize = MAX_PEER_KEYS * items::PUBLIC_KEY_LEN + 16;
38
39/// LoRa bandwidths accepted for `PROP_PHY_LORA_BW`, in Hz.
40const SUPPORTED_BW_HZ: [u32; 10] = [
41    7_810, 10_420, 15_630, 20_830, 31_250, 41_670, 62_500, 125_000, 250_000, 500_000,
42];
43
44/// Radio configuration owned by the session and pushed to the radio
45/// via [`Effect::ApplyRadio`].
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub struct RadioSettings {
48    pub enabled: bool,
49    pub freq_khz: u32,
50    pub bw_hz: u32,
51    pub sf: u8,
52    pub cr_denom: u8,
53    pub tx_power_dbm: i8,
54}
55
56/// Transmit power selection for one pending transmit.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum TxPower {
59    /// Use the configured `PROP_PHY_TX_POWER`.
60    Default,
61    /// Transmit at the radio's maximum power.
62    Max,
63    /// Explicit per-frame override in dBm.
64    Dbm(i8),
65}
66
67/// Which battery measurements the platform is *capable* of reporting
68/// through `PROP_BATTERY`. Fixed for the life of a session: these bits
69/// bound the field-flags octet of every snapshot.
70///
71/// An individual sample may populate fewer fields than are advertised —
72/// a level estimated from resting terminal voltage has no value while the
73/// pack is charging, and the spec would rather see the field omitted than
74/// a number the device knows to be wrong. The reverse is refused: a
75/// sample carrying a field the platform never claimed cannot be encoded
76/// honestly, so it is rejected.
77#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
78pub struct BatteryFields {
79    pub voltage: bool,
80    pub level: bool,
81    pub charge_state: bool,
82}
83
84impl BatteryFields {
85    /// Battery-powered operation with no reporting support: `CAP_BATTERY`
86    /// is advertised and `PROP_BATTERY` answers the empty value.
87    pub const NONE: Self = Self {
88        voltage: false,
89        level: false,
90        charge_state: false,
91    };
92
93    /// Whether any measurement is reported (a `GET` must sample).
94    pub const fn any(self) -> bool {
95        self.voltage || self.level || self.charge_state
96    }
97
98    /// Whether `snapshot` populates only fields this platform advertises.
99    fn matches(self, snapshot: &BatteryStatus) -> bool {
100        (self.voltage || snapshot.voltage_mv.is_none())
101            && (self.level || snapshot.level_percent.is_none())
102            && (self.charge_state || snapshot.charge_state.is_none())
103    }
104}
105
106/// How this board serves `PROP_ALERT`.
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub struct AlertConfig {
109    /// How long `ALERT_LOCATE` runs before the device returns itself to
110    /// `ALERT_NONE`. The spec requires *some* bound and recommends a few
111    /// minutes: a lost radio is usually a nearly-flat radio, and the host
112    /// that armed the alert is by definition somewhere else.
113    pub timeout_ms: u32,
114}
115
116impl AlertConfig {
117    /// The recommended bound — five minutes.
118    pub const DEFAULT: Self = Self {
119        timeout_ms: 5 * 60 * 1000,
120    };
121}
122
123/// That this board keeps a wall clock (`CAP_TIME`).
124///
125/// A marker: the capability's whole statement is that `PROP_TIME` and
126/// `PROP_TZ_OFFSET` exist, and it says nothing about where the time comes
127/// from or how much of a power cycle it survives. Both of those are
128/// platform business, and neither is anything the session could answer.
129#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130pub struct TimeConfig;
131
132/// That this board has a GNSS receiver (`CAP_GNSS`).
133///
134/// Dependent on [`TimeConfig`]: a board that advertises this without a
135/// wall clock would be claiming a time source for a clock it does not
136/// have.
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138pub struct GnssConfig {
139    /// Post-reset value of `PROP_GNSS_ENABLED`.
140    ///
141    /// Off almost everywhere, because on a battery a receiver nobody
142    /// asked for is the largest thing on the bill. The exception is a
143    /// board whose whole job is to sit outdoors and know where it is —
144    /// there, off is the surprising answer, and a fixed node that has to
145    /// be told to find itself after every reset is a worse default than
146    /// the power it costs.
147    ///
148    /// This is the *post-reset* value, so it decides only what an
149    /// unconfigured board does. A saved snapshot overrides it in either
150    /// direction, and `CMD_RST` returns to it.
151    pub default_enabled: bool,
152}
153
154impl GnssConfig {
155    /// A receiver that stays off until asked.
156    pub const DEFAULT: Self = Self {
157        default_enabled: false,
158    };
159
160    /// A receiver that runs unless switched off.
161    pub const ALWAYS_ON: Self = Self {
162        default_enabled: true,
163    };
164}
165
166/// Post-reset value of `PROP_GNSS_IDENT_PRECISION`: a ~38 × 19 m cell,
167/// fine enough to place a node on a street and coarse enough not to place
168/// it in a room.
169pub const DEFAULT_IDENT_PRECISION: u8 = 5;
170
171/// Largest `PROP_GNSS_IDENT_PRECISION`, matching the location encoding's
172/// own maximum.
173pub const MAX_IDENT_PRECISION: u8 = gnss::MAX_LOCATION_LEN as u8;
174
175/// Fixed properties of the device this session runs on.
176#[derive(Clone, Copy, Debug)]
177pub struct SessionConfig {
178    /// `PROP_DEV_VERSION` string (without NUL terminator).
179    pub dev_version: &'static str,
180    /// Factory/post-reset value of `PROP_DEV_NAME`.
181    pub default_device_name: &'static str,
182    /// `PROP_PHY_MTU`; must not exceed [`MAX_MTU`].
183    pub mtu: u16,
184    /// The only sync word this firmware can use; `PROP_PHY_LORA_SW`
185    /// sets must match it (v0 limitation).
186    pub sync_word: u16,
187    /// Lowest transmit power the radio supports, in dBm.
188    pub min_tx_power_dbm: i8,
189    /// Highest transmit power the radio supports, in dBm.
190    pub max_tx_power_dbm: i8,
191    /// Tunable frequency range in kHz, inclusive.
192    pub freq_khz_min: u32,
193    pub freq_khz_max: u32,
194    /// Post-reset radio settings. `enabled` is forced off on reset per
195    /// the spec regardless of what this carries.
196    pub defaults: RadioSettings,
197    /// Post-reset `PROP_PHY_DUTY_LIMIT`.
198    pub default_duty_limit: u16,
199    /// The shared duty ledger. The session owns the limit's lifecycle
200    /// and records its own transmissions here, but the ledger is
201    /// consulted by every radio client on the device (the device node's
202    /// TX path draws from the same budget), so `PROP_PHY_DUTY_NOW`
203    /// reports the combined figure and `PROP_PHY_DUTY_LIMIT` bounds the
204    /// combined airtime.
205    pub duty: &'static DutyLedger,
206    /// `None`: not battery powered; `CAP_BATTERY` is absent and
207    /// `PROP_BATTERY` is unknown. `Some`: the capability is advertised
208    /// and the fields say which measurements the platform reports.
209    pub battery: Option<BatteryFields>,
210    /// `None`: the board has no way to make itself conspicuous;
211    /// `CAP_ALERT` is absent and `PROP_ALERT` is unknown. `Some`: the
212    /// capability is advertised and the config carries the deadline.
213    pub alert: Option<AlertConfig>,
214    /// `None`: the board keeps no wall clock; `CAP_TIME` is absent and
215    /// `PROP_TIME` / `PROP_TZ_OFFSET` are unknown.
216    pub time: Option<TimeConfig>,
217    /// `None`: no GNSS receiver; `CAP_GNSS` is absent and the positioning
218    /// properties are unknown. Meaningful only alongside
219    /// [`time`](Self::time) — see [`GnssConfig`].
220    pub gnss: Option<GnssConfig>,
221    /// Whether an ambient light sensor is fitted. When set,
222    /// `CAP_ILLUMINANCE` is advertised and `PROP_ILLUMINANCE` samples on
223    /// every read; otherwise the property is unknown.
224    pub illuminance: bool,
225}
226
227/// Physical-radio outcome of the transmit started by
228/// [`Effect::StartTransmit`], reported via [`Session::on_tx_result`].
229#[derive(Clone, Copy, Debug, PartialEq, Eq)]
230pub enum TxOutcome {
231    /// The frame left the radio.
232    Sent,
233    /// The pre-transmit channel-activity check found the channel busy;
234    /// the frame was never transmitted. Completes a host transmit with
235    /// `STATUS_CCA_FAILURE`.
236    ChannelBusy,
237    /// The radio failed to transmit the frame.
238    Failed,
239}
240
241/// A radio side effect for the caller to execute.
242#[derive(Clone, Copy, Debug, PartialEq, Eq)]
243pub enum Effect {
244    /// The radio configuration changed; (re)apply it.
245    ApplyRadio(RadioSettings),
246    /// Begin transmitting [`Session::tx_data`] at
247    /// [`Session::tx_power`]; report completion with
248    /// [`Session::on_tx_result`].
249    StartTransmit,
250    /// Build and sign the device identity's node-identity blob and feed
251    /// it back with [`Session::respond_identity_blob`], quoting this
252    /// `tid`. The platform owns the signing key and the advertised
253    /// profile; the session owns only the properties that shape it.
254    SignIdentity { tid: u8 },
255    /// Sample the current instantaneous RSSI from the radio and feed the
256    /// result back with [`Session::respond_rssi`], quoting this `tid`. Emitted
257    /// for a `PROP_PHY_RSSI` get while the PHY is enabled, because the session
258    /// itself has no live view of the radio.
259    SampleRssi { tid: u8 },
260    /// Obtain a battery status snapshot from the platform's battery
261    /// source and feed it back with [`Session::respond_battery`], quoting
262    /// this `tid`. Emitted for a `PROP_BATTERY` get when at least one
263    /// measurement is reported; the session never caches readings, so
264    /// every get samples.
265    SampleBattery { tid: u8 },
266    /// Take an ambient light measurement and feed it back with
267    /// [`Session::respond_illuminance`], quoting this `tid`. Emitted for a
268    /// `PROP_ILLUMINANCE` get; like the battery, nothing is cached, so
269    /// every get samples.
270    SampleIlluminance { tid: u8 },
271    /// Apply and persist a new BLE pairing PIN, then complete the deferred
272    /// property transaction with [`Session::respond_pin_set`].
273    SetPairingPin { tid: u8, pin: Option<u32> },
274    /// Read the platform's wall clock and feed it back with
275    /// [`Session::respond_time`], quoting this `tid`. Emitted for a
276    /// `PROP_TIME` get: the clock belongs to the platform, which is the
277    /// only layer that knows whether it has been set and how far it has
278    /// advanced since.
279    ReadTime { tid: u8 },
280    /// A `PROP_TIME` write. `Some` sets the platform wall clock to that
281    /// Unix second; `None` returns it to not knowing what time it is,
282    /// which is what stops a device with a screen from displaying a clock.
283    ///
284    /// A manual set outranks every receiver-derived one — the operator is
285    /// the more authoritative source by definition — so the platform
286    /// applies this unconditionally, including while
287    /// `PROP_GNSS_TIME_TRUST` is clear.
288    ApplyTime { epoch: Option<u32> },
289    /// Sample the receiver's current view of position and constellation
290    /// and feed it back with [`Session::respond_gnss`], quoting this `tid`
291    /// and `key`. Emitted for a get of any positioning property; the
292    /// session never caches a reading, so every get samples.
293    SampleGnss { tid: u8, key: u32 },
294    /// The live human-readable device name changed. Transports that expose a
295    /// name should refresh it without disrupting the active session.
296    DeviceNameChanged,
297    /// A `CMD_QUEUE_DRAIN` accepted a non-empty queue. Repeatedly call
298    /// [`Session::drain_step`] until it returns `false`, flushing the
299    /// emitted frame to the transport between calls (each step emits at
300    /// most one frame, so a bounded emitter never overflows and the
301    /// transport can apply backpressure).
302    DrainQueue,
303    /// `CMD_SAVE`: durably store the bytes produced by
304    /// [`Session::encode_snapshot`], replacing any previous snapshot,
305    /// then complete with [`Session::respond_save`]. Success must not
306    /// be reported before the write has committed.
307    SaveSnapshot { tid: u8 },
308    /// `CMD_CLEAR`: erase the stored snapshot and all other persisted
309    /// provisioning — including the independently persisted device
310    /// identity — then complete with [`Session::respond_clear`]. Live
311    /// state, BLE bonds, and the pairing PIN are unaffected.
312    ClearSaved { tid: u8 },
313    /// A `PROP_DEV_PRIVATE_KEY` write is provisioning the device
314    /// identity. Read the staged request with
315    /// [`Session::identity_request`], build the keypair (drawing the
316    /// secret from a cryptographically secure RNG when the request is
317    /// [`IdentitySource::Generate`]), persist it durably, and complete
318    /// with [`Session::respond_identity`]. Success must not be
319    /// reported before the identity is durably stored (spec
320    /// §PROP_DEV_PRIVATE_KEY).
321    ProvisionIdentity { tid: u8 },
322    /// The locate alert changed; start or stop the board's physical
323    /// indication. Carries the authoritative new state, so a board can
324    /// treat it as idempotent — it is emitted for a host write, a local
325    /// cancellation, and the deadline alike.
326    ApplyAlert(AlertState),
327    /// `CMD_FACTORY_RESET`: erase ALL mutable state — every persisted
328    /// journal (saved snapshot, device identity, frame-counter
329    /// boundaries, BLE bonds, pairing PIN) — and reboot. The platform
330    /// performs the wipe and reset; nothing is emitted and no `respond_*`
331    /// completion follows, because the reboot drops the link. In-RAM
332    /// session state is discarded by the reset itself.
333    FactoryReset,
334}
335
336/// A staged `PROP_DEV_PRIVATE_KEY` provisioning request (see
337/// [`Effect::ProvisionIdentity`]).
338#[derive(Clone, Copy)]
339pub enum IdentitySource {
340    /// Install this Ed25519 private key.
341    Install([u8; PRIVATE_KEY_LEN]),
342    /// Generate a fresh private key on-device; it must come from a
343    /// cryptographically secure random number generator and never
344    /// leave the device.
345    Generate,
346}
347
348struct PendingTx {
349    data: HeaplessVec<u8, MAX_MTU>,
350    tid: u8,
351    airtime_ms: u32,
352    power: TxPower,
353    /// True for device-initiated transmissions (delegated MAC acks):
354    /// completion must not disturb `PROP_LAST_STATUS`, which may still
355    /// hold a reset code the next host needs to see.
356    autonomous: bool,
357    /// Queue-entry sequence handle of the frame this transmission
358    /// acknowledges. Only on confirmed transmission does the entry earn
359    /// `RX_FLAG_ACKED` — the host MUST NOT re-ack a flagged frame, so
360    /// the flag must never assert an ack that was not actually sent.
361    ack_for: Option<u16>,
362    /// `TX_FLAG_NOCCA`: transmit without the pre-transmit
363    /// channel-activity check.
364    nocca: bool,
365}
366
367/// A delegated MAC acknowledgement ready to transmit.
368struct AckPlan {
369    /// The 8-byte ack trailer (`ack_mic || ack_tag`). The ack carries no
370    /// destination hint — it is correlated by this trailer.
371    trailer: [u8; 8],
372    /// Flood-return radius when the acknowledged frame arrived by
373    /// flood: its accumulated hop count seeds the ack's remaining hops
374    /// (mirroring the MAC's cached flood-route behavior). `None` for
375    /// direct traffic — the ack is then direct too.
376    flood_hops: Option<u8>,
377}
378
379/// Outcome of evaluating a detached received frame against the
380/// provisioned keys (spec §Inbound Queueing, §Acknowledgement
381/// Delegation).
382enum SecureRx {
383    /// Not authenticated (no keys, ambiguous source, bad MIC, or a
384    /// suspected replay outside the window): queue it unacknowledged —
385    /// hints only over-accept and the host MAC remains authoritative.
386    Plain,
387    /// Authenticated and new. `ack` is present when the frame requests
388    /// acknowledgement (never for multicast); `identity` keys later
389    /// duplicate coalescing and deferred ack marking.
390    New {
391        ack: Option<AckPlan>,
392        identity: Option<RxIdentity>,
393    },
394    /// Authenticated duplicate of a previously accepted frame: it is
395    /// coalesced rather than queued again. `ack` is present when the
396    /// idempotent re-acknowledgement window permits retransmitting its
397    /// ack; `identity` locates the original entry so a confirmed re-ack
398    /// can mark it.
399    Duplicate {
400        ack: Option<AckPlan>,
401        identity: Option<RxIdentity>,
402    },
403}
404
405/// Outcome of dispatching a property key for encoding.
406enum PropValue {
407    Encoded(usize),
408    Unimplemented,
409    Unknown,
410}
411
412/// State belonging to the device itself, independent of which
413/// host is attached (spec §State Classes, device domain). Survives
414/// attach and host replacement; `CMD_RST` restores its post-reset
415/// values.
416struct DeviceDomain {
417    settings: RadioSettings,
418    name: [u8; MAX_DEVICE_NAME_LEN],
419    name_len: usize,
420    /// `PROP_DEV_CHANNEL_KEYS`: the device identity's own channels.
421    /// Independent of the host domain — they survive host replacement
422    /// and never create implicit host receive filters.
423    channel_keys: ChannelKeyTable,
424    /// `PROP_DEV_PEERS`: peer public keys the device node recognizes.
425    peers: DevPeerTable,
426    /// `PROP_MAC_REPEATER_ENABLED`: when set, the device identity's
427    /// on-board MAC autonomously forwards overheard routable frames.
428    /// Persisted device-domain state; takes effect only once a device
429    /// identity is provisioned (store-and-defer otherwise).
430    repeater_enabled: bool,
431    /// `PROP_MAC_REPEATER_REGIONS`: the flood-forwarding region filter.
432    /// Empty imposes no regional restriction. Configurable while
433    /// forwarding is disabled; it simply takes effect when enabled.
434    repeater_regions: RepeaterRegions,
435    /// `PROP_MAC_REPEATER_DEFAULT_REGION`: the code inserted into an
436    /// untagged flood packet, or `None` to never tag.
437    ///
438    /// Deliberately independent of `repeater_regions`: the filter says
439    /// what this device is willing to carry, while tagging asserts where
440    /// the packet is. A device can do either without the other.
441    repeater_default_region: Option<[u8; REGION_CODE_LEN]>,
442    /// `PROP_MAC_REPEATER_MIN_RSSI`: minimum received RSSI in dBm for
443    /// flood forwarding, or `None` for no threshold.
444    repeater_min_rssi: Option<i16>,
445    /// `PROP_MAC_REPEATER_MIN_SNR`: minimum received SNR in whole dB for
446    /// flood forwarding, or `None` for no threshold.
447    repeater_min_snr: Option<i8>,
448    /// `PROP_IDENT_ROLE`: the advertised `ROLE` byte, or `None` to
449    /// derive it from what the device is actually doing.
450    ///
451    /// Role and forwarding are separate dimensions: a mobile repeater
452    /// and a fixed tracker must both be representable. Forwarding
453    /// remains a *fact* the device reports through the `REP` capability
454    /// bit; the role is what it presents itself as, which is
455    /// configuration.
456    ident_role: Option<u8>,
457    /// `PROP_IDENT_MOBILE`: whether the device identity advertises the
458    /// `MOB` capability bit. Orthogonal to tethered versus standalone,
459    /// which is a transient local relationship and appears in no node
460    /// identity at all.
461    ident_mobile: bool,
462    /// `PROP_DEV_DISCOVERABLE`: whether the device identity answers
463    /// Identity Requests. On by default — a deployed device is
464    /// infrastructure, and being askable is most of the point; the
465    /// property is the opt-out.
466    dev_discoverable: bool,
467    /// `PROP_ADVERT_INTERVAL`: seconds between unsolicited advertisements,
468    /// 0 for none. Independent of `dev_discoverable`, which governs only
469    /// whether the device answers when asked.
470    advert_interval_s: u32,
471    /// `PROP_BEACON_INTERVAL`: seconds between unsolicited beacons, 0 for
472    /// none. Separate from the advertisement interval because the two
473    /// announce different things at different costs — a beacon is a path,
474    /// an advertisement is an identity — and a mesh usually wants the
475    /// cheap one far more often than the expensive one.
476    beacon_interval_s: u32,
477    /// `PROP_STARTUP_BEACON`: whether one beacon goes out once the device
478    /// comes up. On by default: a node that has just rebooted is exactly
479    /// the node whose neighbours' cached paths are most likely stale.
480    startup_beacon: bool,
481    /// `PROP_TZ_OFFSET`: minutes east of UTC. Configuration rather than
482    /// measurement — where a device is meant to be is known even when the
483    /// time is not — so unlike `PROP_TIME` it always has a value.
484    tz_offset_min: i16,
485    /// `PROP_GNSS_ENABLED`: whether the receiver is powered.
486    ///
487    /// Off by default on most boards. A receiver is the largest
488    /// continuous load on a battery, and a device that has never been
489    /// told to care where it is should not be spending one finding out.
490    /// A board whose job is to know where it is says otherwise through
491    /// [`GnssConfig::default_enabled`].
492    gnss_enabled: bool,
493    /// `PROP_GNSS_IDENT_UPDATE`: whether fixes refresh the advertised node
494    /// identity's location. Off by default: broadcasting where you are is
495    /// a decision, not a default.
496    gnss_ident_update: bool,
497    /// `PROP_GNSS_IDENT_PRECISION`: how far the advertised location is
498    /// clamped down from what the receiver actually knows.
499    gnss_ident_precision: u8,
500    /// `PROP_GNSS_TIME_TRUST`: whether receiver-derived time may set the
501    /// wall clock. On by default — the sky is normally the best clock a
502    /// board has — and the opt-out for when it demonstrably is not.
503    gnss_time_trust: bool,
504}
505
506impl DeviceDomain {
507    fn post_reset(config: &SessionConfig) -> Self {
508        let mut settings = config.defaults;
509        settings.enabled = false;
510        let mut name = [0; MAX_DEVICE_NAME_LEN];
511        let name_len = config.default_device_name.len();
512        name[..name_len].copy_from_slice(config.default_device_name.as_bytes());
513        // Duty accounting restarts with the domain; the limit and the
514        // ledger's modulation view return to the configured defaults.
515        config.duty.reset_accounting();
516        config.duty.set_limit(config.default_duty_limit);
517        config
518            .duty
519            .set_phy(settings.sf, settings.bw_hz, settings.cr_denom);
520        Self {
521            settings,
522            name,
523            name_len,
524            channel_keys: ChannelKeyTable::default(),
525            peers: DevPeerTable::default(),
526            repeater_enabled: false,
527            repeater_regions: RepeaterRegions::default(),
528            repeater_default_region: None,
529            repeater_min_rssi: None,
530            repeater_min_snr: None,
531            ident_role: None,
532            ident_mobile: false,
533            dev_discoverable: true,
534            advert_interval_s: DEFAULT_ADVERT_INTERVAL_S,
535            beacon_interval_s: DEFAULT_BEACON_INTERVAL_S,
536            startup_beacon: true,
537            tz_offset_min: 0,
538            gnss_enabled: config.gnss.is_some_and(|gnss| gnss.default_enabled),
539            gnss_ident_update: false,
540            gnss_ident_precision: DEFAULT_IDENT_PRECISION,
541            gnss_time_trust: true,
542        }
543    }
544}
545
546/// Maximum number of explicit `PROP_HOST_RX_FILTERS` entries.
547pub const MAX_RX_FILTERS: usize = 16;
548
549/// The explicit receive filter table: an unordered set with fixed
550/// capacity. Whole-table replacement builds a candidate table first so
551/// a failed set never leaves a partial mixture (spec §Mutation
552/// Atomicity).
553#[derive(Clone, Copy)]
554struct FilterTable {
555    entries: [Filter; MAX_RX_FILTERS],
556    len: usize,
557}
558
559impl Default for FilterTable {
560    fn default() -> Self {
561        Self {
562            entries: [Filter::PktType(0); MAX_RX_FILTERS],
563            len: 0,
564        }
565    }
566}
567
568impl FilterTable {
569    fn iter(&self) -> impl Iterator<Item = &Filter> {
570        self.entries[..self.len].iter()
571    }
572
573    fn is_empty(&self) -> bool {
574        self.len == 0
575    }
576
577    /// Add a filter; duplicates fail with `STATUS_ALREADY`, a full
578    /// table with `STATUS_NOMEM`.
579    fn insert(&mut self, filter: Filter) -> Result<(), Status> {
580        if self.iter().any(|existing| *existing == filter) {
581            return Err(Status::ALREADY);
582        }
583        if self.len == MAX_RX_FILTERS {
584            return Err(Status::NOMEM);
585        }
586        self.entries[self.len] = filter;
587        self.len += 1;
588        Ok(())
589    }
590
591    /// Remove the filter matching `filter` (the selector is the full
592    /// item); a missing item fails with `STATUS_ITEM_NOT_FOUND`.
593    fn remove(&mut self, filter: Filter) -> Result<(), Status> {
594        let Some(index) = self.iter().position(|existing| *existing == filter) else {
595            return Err(Status::ITEM_NOT_FOUND);
596        };
597        self.len -= 1;
598        self.entries[index] = self.entries[self.len];
599        Ok(())
600    }
601
602    /// Parse a whole-table `CMD_PROP_SET` value (PUI-length-prefixed
603    /// filter entries) into a complete replacement table, validating
604    /// everything before the caller commits it. Duplicate items in the
605    /// value collapse, matching the property's set semantics.
606    fn parse_table(value: &[u8]) -> Result<Self, Status> {
607        let mut table = Self::default();
608        for item in items::prefixed_items(value) {
609            let filter = decode_filter(item.map_err(table_error)?)?;
610            match table.insert(filter) {
611                Ok(()) | Err(Status::ALREADY) => {}
612                Err(status) => return Err(status),
613            }
614        }
615        Ok(table)
616    }
617}
618
619/// Decode and validate one filter item. Unrecognized types, mismatched
620/// value lengths, and out-of-range packet types are invalid arguments
621/// per the `PROP_HOST_RX_FILTERS` spec.
622fn decode_filter(item: &[u8]) -> Result<Filter, Status> {
623    let filter = Filter::decode(item).map_err(|_| Status::INVALID_ARGUMENT)?;
624    if matches!(filter, Filter::PktType(pkt_type) if pkt_type > 7) {
625        return Err(Status::INVALID_ARGUMENT);
626    }
627    Ok(filter)
628}
629
630/// Map a table-structure decoding failure (bad or truncated item
631/// length prefix) to a status. Entry-level problems are invalid
632/// arguments; a value that cannot be split into items at all is
633/// malformed.
634fn table_error(error: ItemError) -> Status {
635    match error {
636        ItemError::BadPrefix | ItemError::Truncated => Status::PARSE_ERROR,
637        _ => Status::INVALID_ARGUMENT,
638    }
639}
640
641/// `PROP_HOST_RX_QUEUE_CAPACITY`: the fixed size of the inbound queue.
642pub const RX_QUEUE_CAPACITY: usize = 16;
643
644/// The logical identity of an authenticated received packet: the frame
645/// counter plus the verified MIC (which covers the channel or pairwise
646/// keys, the addressing, and the body). A Route Retry form preserves
647/// both, so it matches its original. Unauthenticated frames have no
648/// identity and are never coalesced.
649#[derive(Clone, Copy, PartialEq, Eq)]
650struct RxIdentity {
651    counter: u32,
652    mic: [u8; 16],
653    mic_len: u8,
654}
655
656impl RxIdentity {
657    fn new(counter: u32, mic: &[u8]) -> Option<Self> {
658        if mic.is_empty() || mic.len() > 16 {
659            return None;
660        }
661        let mut padded = [0u8; 16];
662        padded[..mic.len()].copy_from_slice(mic);
663        Some(Self {
664            counter,
665            mic: padded,
666            mic_len: mic.len() as u8,
667        })
668    }
669}
670
671/// One inbound-queue entry: the frame, its receive metadata, the time
672/// of reception, whether the device acknowledged it on the host's behalf,
673/// and — for authenticated frames — the logical packet identity used
674/// for duplicate coalescing and deferred ack marking.
675#[derive(Clone, Copy)]
676struct QueueEntry {
677    data: [u8; MAX_MTU],
678    len: u16,
679    rssi_dbm: i16,
680    snr_cb: i16,
681    lqi: Option<core::num::NonZeroU8>,
682    rx_time_ms: u64,
683    acked: bool,
684    /// Monotonic (wrapping) sequence number: a stable handle that a
685    /// pending ack transmission can use to mark this exact entry
686    /// later, immune to queue rotation and eviction.
687    seq: u16,
688    identity: Option<RxIdentity>,
689}
690
691impl QueueEntry {
692    const EMPTY: Self = Self {
693        data: [0; MAX_MTU],
694        len: 0,
695        rssi_dbm: 0,
696        snr_cb: 0,
697        lqi: None,
698        rx_time_ms: 0,
699        acked: false,
700        seq: 0,
701        identity: None,
702    };
703
704    fn frame(&self) -> &[u8] {
705        &self.data[..usize::from(self.len)]
706    }
707}
708
709/// The circular FIFO inbound queue (spec §Inbound Queueing). When full,
710/// accepting a new frame evicts the oldest entry and counts it in
711/// `PROP_HOST_RX_QUEUE_DROPPED`, so the queue always holds the most
712/// recent accepted traffic.
713struct RxQueue {
714    entries: [QueueEntry; RX_QUEUE_CAPACITY],
715    /// Index of the oldest entry.
716    head: usize,
717    len: usize,
718    dropped: u32,
719    /// Next entry sequence number. Never reset — a stale ack handle
720    /// from before a queue reset must not match a new entry.
721    next_seq: u16,
722}
723
724impl Default for RxQueue {
725    fn default() -> Self {
726        Self {
727            entries: [QueueEntry::EMPTY; RX_QUEUE_CAPACITY],
728            head: 0,
729            len: 0,
730            dropped: 0,
731            next_seq: 0,
732        }
733    }
734}
735
736impl RxQueue {
737    /// Reset to empty without constructing a fresh entry array (the
738    /// array is several KB; hosts of this crate include embedded
739    /// stacks). The sequence counter deliberately survives.
740    fn clear(&mut self) {
741        self.head = 0;
742        self.len = 0;
743        self.dropped = 0;
744        for entry in &mut self.entries {
745            entry.identity = None;
746        }
747    }
748
749    /// Append an entry (evicting the oldest when full) and return its
750    /// sequence handle.
751    fn push(
752        &mut self,
753        data: &[u8],
754        rssi_dbm: i16,
755        snr_cb: i16,
756        lqi: Option<core::num::NonZeroU8>,
757        rx_time_ms: u64,
758        identity: Option<RxIdentity>,
759    ) -> u16 {
760        debug_assert!(data.len() <= MAX_MTU);
761        if self.len == RX_QUEUE_CAPACITY {
762            self.head = (self.head + 1) % RX_QUEUE_CAPACITY;
763            self.len -= 1;
764            self.dropped = self.dropped.wrapping_add(1);
765        }
766        let seq = self.next_seq;
767        self.next_seq = self.next_seq.wrapping_add(1);
768        let slot = (self.head + self.len) % RX_QUEUE_CAPACITY;
769        let entry = &mut self.entries[slot];
770        entry.data[..data.len()].copy_from_slice(data);
771        entry.len = data.len() as u16;
772        entry.rssi_dbm = rssi_dbm;
773        entry.snr_cb = snr_cb;
774        entry.lqi = lqi;
775        entry.rx_time_ms = rx_time_ms;
776        entry.acked = false;
777        entry.seq = seq;
778        entry.identity = identity;
779        self.len += 1;
780        seq
781    }
782
783    fn pop_front(&mut self) -> Option<QueueEntry> {
784        if self.len == 0 {
785            return None;
786        }
787        let entry = self.entries[self.head];
788        self.head = (self.head + 1) % RX_QUEUE_CAPACITY;
789        self.len -= 1;
790        Some(entry)
791    }
792
793    fn iter(&self) -> impl Iterator<Item = &QueueEntry> {
794        (0..self.len).map(|offset| &self.entries[(self.head + offset) % RX_QUEUE_CAPACITY])
795    }
796
797    /// The sequence handle of the queued entry holding this logical
798    /// packet, if it is still queued.
799    fn seq_for_identity(&self, identity: &RxIdentity) -> Option<u16> {
800        self.iter()
801            .find(|entry| entry.identity.as_ref() == Some(identity))
802            .map(|entry| entry.seq)
803    }
804
805    /// Mark the entry with this sequence handle acknowledged. A handle
806    /// whose entry was drained, evicted, or discarded matches nothing.
807    fn mark_acked(&mut self, seq: u16) {
808        let Some(offset) = (0..self.len)
809            .find(|offset| self.entries[(self.head + offset) % RX_QUEUE_CAPACITY].seq == seq)
810        else {
811            return;
812        };
813        self.entries[(self.head + offset) % RX_QUEUE_CAPACITY].acked = true;
814    }
815}
816
817/// Maximum number of `PROP_HOST_CHANNEL_KEYS` entries.
818pub const MAX_CHANNEL_KEYS: usize = 8;
819/// Maximum number of `PROP_HOST_PEER_KEYS` entries.
820pub const MAX_PEER_KEYS: usize = 8;
821
822/// One provisioned host channel key with its derived channel
823/// identifier (the digest form, and an implicit receive filter).
824#[derive(Clone, Copy)]
825struct ChannelKeyEntry {
826    key: [u8; items::CHANNEL_KEY_LEN],
827    id: [u8; items::CHANNEL_ID_LEN],
828}
829
830/// `PROP_HOST_CHANNEL_KEYS`: an unordered set of channel keys. The
831/// remove selector is the key; the digest form is the derived channel
832/// identifier.
833#[derive(Clone, Copy, Default)]
834struct ChannelKeyTable {
835    entries: [Option<ChannelKeyEntry>; MAX_CHANNEL_KEYS],
836    len: usize,
837}
838
839impl ChannelKeyTable {
840    fn iter(&self) -> impl Iterator<Item = &ChannelKeyEntry> {
841        self.entries[..self.len]
842            .iter()
843            .map(|entry| entry.as_ref().expect("entries below len are populated"))
844    }
845
846    fn insert(&mut self, entry: ChannelKeyEntry) -> Result<(), Status> {
847        if self.iter().any(|existing| existing.key == entry.key) {
848            return Err(Status::ALREADY);
849        }
850        if self.len == MAX_CHANNEL_KEYS {
851            return Err(Status::NOMEM);
852        }
853        self.entries[self.len] = Some(entry);
854        self.len += 1;
855        Ok(())
856    }
857
858    /// Remove by channel key, returning the removed entry's derived
859    /// identifier (the digest form).
860    fn remove(&mut self, key: &[u8; items::CHANNEL_KEY_LEN]) -> Result<[u8; 2], Status> {
861        let Some(index) = self.iter().position(|existing| existing.key == *key) else {
862            return Err(Status::ITEM_NOT_FOUND);
863        };
864        let id = self.entries[index].expect("populated").id;
865        self.len -= 1;
866        self.entries[index] = self.entries[self.len];
867        self.entries[self.len] = None;
868        Ok(id)
869    }
870}
871
872/// One provisioned peer: the host-derived pairwise key material plus
873/// this peer's replay window. The window is keyed by the peer's
874/// identity — replacing the key material leaves it untouched (spec
875/// §PROP_HOST_PEER_KEYS), and it is never saved (spec §Saved State).
876struct PeerSlot {
877    entry: items::PeerKeyEntry,
878    window: ReplayWindow,
879}
880
881/// `PROP_HOST_PEER_KEYS`: pairwise key material for provisioned peers.
882/// Keyed by peer public key (the digest form and remove selector);
883/// inserting a matching public key replaces the stored key material.
884#[derive(Default)]
885struct PeerKeyTable {
886    entries: [Option<PeerSlot>; MAX_PEER_KEYS],
887    len: usize,
888}
889
890impl PeerKeyTable {
891    fn iter(&self) -> impl Iterator<Item = &PeerSlot> {
892        self.entries[..self.len]
893            .iter()
894            .map(|slot| slot.as_ref().expect("entries below len are populated"))
895    }
896
897    /// Insert or replace (by public key). Replacement updates only the
898    /// stored key material per the spec: the peer's replay window and
899    /// anything else keyed by its identity are unaffected.
900    fn insert(&mut self, entry: items::PeerKeyEntry) -> Result<(), Status> {
901        if let Some(existing) = self.entries[..self.len]
902            .iter_mut()
903            .flatten()
904            .find(|existing| existing.entry.public_key == entry.public_key)
905        {
906            existing.entry = entry;
907            return Ok(());
908        }
909        if self.len == MAX_PEER_KEYS {
910            return Err(Status::NOMEM);
911        }
912        self.entries[self.len] = Some(PeerSlot {
913            entry,
914            window: ReplayWindow::new(),
915        });
916        self.len += 1;
917        Ok(())
918    }
919
920    /// Replace the *entry set* with `desired` while preserving
921    /// *per-entry state*: peers present in both keep their replay
922    /// window, peers `desired` omits are removed, peers it adds start at
923    /// first contact.
924    ///
925    /// This is what a whole-table `CMD_PROP_SET` means, and the
926    /// distinction is load-bearing rather than stylistic. A host
927    /// re-asserts its complete desired table on every attach, many times
928    /// a day; building a fresh table and swapping it in would reset
929    /// every peer's replay baseline that often, where the documented
930    /// resynchronization path assumes reboot frequency.
931    ///
932    /// "Merge" would be the wrong word for it: entries the value omits
933    /// do not survive.
934    fn reconcile(&mut self, desired: &[items::PeerKeyEntry]) {
935        let mut index = 0;
936        while index < self.len {
937            let public_key = self.entries[index]
938                .as_ref()
939                .expect("entries below len are populated")
940                .entry
941                .public_key;
942            if desired.iter().any(|entry| entry.public_key == public_key) {
943                index += 1;
944                continue;
945            }
946            self.len -= 1;
947            self.entries[index] = self.entries[self.len].take();
948        }
949        // Removals ran first and `desired` is bounded by capacity, so no
950        // insert here can fail. `insert` leaves a matching peer's window
951        // untouched, which is the whole point.
952        for entry in desired {
953            let _ = self.insert(*entry);
954        }
955    }
956
957    /// Remove by peer public key. The peer's replay window goes with
958    /// it; re-provisioning starts over at first contact.
959    fn remove(&mut self, public_key: &[u8; items::PUBLIC_KEY_LEN]) -> Result<(), Status> {
960        let Some(index) = self
961            .iter()
962            .position(|existing| existing.entry.public_key == *public_key)
963        else {
964            return Err(Status::ITEM_NOT_FOUND);
965        };
966        self.len -= 1;
967        self.entries[index] = self.entries[self.len].take();
968        Ok(())
969    }
970
971    /// Resolve a received source address to a provisioned peer index:
972    /// by full public key when present, otherwise by **unique** 3-byte
973    /// prefix match (spec §Acknowledgement Delegation; an ambiguous
974    /// hint does not resolve).
975    fn resolve_source(&self, source: &SourceAddrRef, frame: &[u8]) -> Option<usize> {
976        match source {
977            SourceAddrRef::FullKeyAt { offset } => {
978                let key = frame.get(*offset..*offset + items::PUBLIC_KEY_LEN)?;
979                self.iter().position(|slot| slot.entry.public_key == *key)
980            }
981            SourceAddrRef::Hint(hint) => {
982                let mut matches = self
983                    .iter()
984                    .enumerate()
985                    .filter(|(_, slot)| slot.entry.public_key[..3] == hint.0);
986                let (index, _) = matches.next()?;
987                matches.next().is_none().then_some(index)
988            }
989            _ => None,
990        }
991    }
992}
993
994/// Ed25519 private keys are 32 octets, like public keys.
995pub const PRIVATE_KEY_LEN: usize = 32;
996
997/// Maximum number of `PROP_DEV_PEERS` entries.
998pub const MAX_DEV_PEERS: usize = 8;
999
1000/// `PROP_DEV_PEERS`: an unordered set of peer public keys. No key
1001/// material — the device holds the device identity's private key and
1002/// performs its own key agreement — so the digest form and remove
1003/// selector are both the item itself.
1004#[derive(Clone, Copy, Default)]
1005struct DevPeerTable {
1006    entries: [[u8; items::PUBLIC_KEY_LEN]; MAX_DEV_PEERS],
1007    len: usize,
1008}
1009
1010impl DevPeerTable {
1011    fn iter(&self) -> impl Iterator<Item = &[u8; items::PUBLIC_KEY_LEN]> {
1012        self.entries[..self.len].iter()
1013    }
1014
1015    /// Add a peer; duplicates fail with `STATUS_ALREADY`, a full table
1016    /// with `STATUS_NOMEM`.
1017    fn insert(&mut self, public_key: [u8; items::PUBLIC_KEY_LEN]) -> Result<(), Status> {
1018        if self.iter().any(|existing| *existing == public_key) {
1019            return Err(Status::ALREADY);
1020        }
1021        if self.len == MAX_DEV_PEERS {
1022            return Err(Status::NOMEM);
1023        }
1024        self.entries[self.len] = public_key;
1025        self.len += 1;
1026        Ok(())
1027    }
1028
1029    /// Remove by public key (the full item is the selector); a missing
1030    /// item fails with `STATUS_ITEM_NOT_FOUND`.
1031    fn remove(&mut self, public_key: &[u8; items::PUBLIC_KEY_LEN]) -> Result<(), Status> {
1032        let Some(index) = self.iter().position(|existing| existing == public_key) else {
1033            return Err(Status::ITEM_NOT_FOUND);
1034        };
1035        self.len -= 1;
1036        self.entries[index] = self.entries[self.len];
1037        Ok(())
1038    }
1039
1040    /// Parse a whole-table `CMD_PROP_SET` value (fixed 32-octet items)
1041    /// into a complete replacement table; duplicate items collapse.
1042    fn parse_table(value: &[u8]) -> Result<Self, Status> {
1043        let mut table = Self::default();
1044        for item in items::fixed_items::<{ items::PUBLIC_KEY_LEN }>(value)
1045            .map_err(|_| Status::INVALID_ARGUMENT)?
1046        {
1047            match table.insert(*item) {
1048                Ok(()) | Err(Status::ALREADY) => {}
1049                Err(status) => return Err(status),
1050            }
1051        }
1052        Ok(table)
1053    }
1054}
1055
1056/// Maximum number of `PROP_MAC_REPEATER_REGIONS` entries. Matches the
1057/// MAC's own repeater region capacity, so any value this session accepts
1058/// fits the forwarding policy it ends up configuring.
1059pub const MAX_REPEATER_REGIONS: usize = 8;
1060
1061/// `PROP_MAC_REPEATER_REGIONS`: the region codes the device identity
1062/// flood-forwards for.
1063///
1064/// Held in the wire encoding — codes concatenated with no delimiter —
1065/// because that is byte-for-byte the Supported Regions node identity
1066/// option, so the property value and the advertisement are the same
1067/// bytes and neither has to reshape the other.
1068#[derive(Clone, Copy, Default)]
1069struct RepeaterRegions {
1070    bytes: [u8; MAX_REPEATER_REGIONS * REGION_CODE_LEN],
1071    len: usize,
1072}
1073
1074impl RepeaterRegions {
1075    fn as_slice(&self) -> &[u8] {
1076        &self.bytes[..self.len]
1077    }
1078
1079    fn is_empty(&self) -> bool {
1080        self.len == 0
1081    }
1082
1083    /// Parse a whole-value write. An odd length is a malformed value
1084    /// rather than an oversized one; more codes than the device can hold
1085    /// is `STATUS_NOMEM`, the same answer the key tables give.
1086    fn parse(value: &[u8]) -> Result<Self, Status> {
1087        if !value.len().is_multiple_of(REGION_CODE_LEN) {
1088            return Err(Status::INVALID_ARGUMENT);
1089        }
1090        if value.len() > MAX_REPEATER_REGIONS * REGION_CODE_LEN {
1091            return Err(Status::NOMEM);
1092        }
1093        let mut regions = Self::default();
1094        regions.bytes[..value.len()].copy_from_slice(value);
1095        regions.len = value.len();
1096        Ok(regions)
1097    }
1098}
1099
1100/// Parse a `PROP_MAC_REPEATER_DEFAULT_REGION` value: one region code, or
1101/// empty for "never tag".
1102fn parse_region_code(value: &[u8]) -> Result<Option<[u8; REGION_CODE_LEN]>, Status> {
1103    match value.len() {
1104        0 => Ok(None),
1105        REGION_CODE_LEN => Ok(Some([value[0], value[1]])),
1106        _ => Err(Status::INVALID_ARGUMENT),
1107    }
1108}
1109
1110/// Number of recently-transmitted frames whose MIC prefixes we remember for
1111/// receive filtering. Sized to cover what can still produce an echo — a
1112/// returning ack over a round trip, a repeat within the confirmation window;
1113/// 4 bytes each, so the whole ring is tiny.
1114const TRANSMITTED_MIC_SLOTS: usize = 16;
1115
1116/// A small ring of 4-byte MIC prefixes for frames this radio has
1117/// transmitted. Two kinds of returning traffic identify themselves by such a
1118/// prefix and nothing else the filter can hold on to:
1119///
1120/// - a **MAC ack**, which carries no destination hint; its public `ack_mic`
1121///   is defined as the first 4 bytes of the acknowledged frame's MIC
1122/// - a **repeat** of our own frame carried onward by a repeater, whose
1123///   destination hint is the remote peer's; the rewrite may touch only
1124///   mutable routing state, so the MIC rides through unchanged — the same
1125///   identity the host's forwarding-confirmation machinery keys on
1126///
1127/// One table serves both: whatever the packet type, a trailer opening with a
1128/// remembered prefix is an echo of something we sent.
1129///
1130/// Eviction is **lazy**: entries are displaced oldest-first only when the
1131/// ring fills, and are *never* removed on a match. A single send can be
1132/// echoed several times — acks arriving over different routes, repeats from
1133/// different repeaters — each carrying distinct routing state; keeping the
1134/// entry live lets the host collect all of them.
1135#[derive(Default)]
1136struct TransmittedMics {
1137    slots: [[u8; 4]; TRANSMITTED_MIC_SLOTS],
1138    /// Number of populated slots, saturating at `TRANSMITTED_MIC_SLOTS`.
1139    filled: usize,
1140    /// Next write position (ring cursor).
1141    cursor: usize,
1142}
1143
1144impl TransmittedMics {
1145    /// Record a transmitted frame's MIC prefix, skipping duplicates so
1146    /// retransmissions of the same frame don't crowd out other entries.
1147    fn note(&mut self, mic: [u8; 4]) {
1148        if self.contains(&mic) {
1149            return;
1150        }
1151        self.slots[self.cursor] = mic;
1152        self.cursor = (self.cursor + 1) % TRANSMITTED_MIC_SLOTS;
1153        if self.filled < TRANSMITTED_MIC_SLOTS {
1154            self.filled += 1;
1155        }
1156    }
1157
1158    /// Whether `mic` matches a still-remembered transmitted frame.
1159    fn contains(&self, mic: &[u8; 4]) -> bool {
1160        self.slots[..self.filled].iter().any(|slot| slot == mic)
1161    }
1162}
1163
1164/// State belonging to the configured tethered host identity (spec
1165/// §State Classes, host domain): host key, key tables, filters,
1166/// auto-ACK policy, and the inbound queue. The `CAP_HOST_AUTO_ACK`
1167/// increment extends it; host replacement resets it as one unit.
1168#[derive(Default)]
1169struct HostDomain {
1170    /// `PROP_HOST_KEY`; `None` means no host identity is configured.
1171    key: Option<[u8; items::PUBLIC_KEY_LEN]>,
1172    /// `PROP_HOST_RX_FILTERS`.
1173    filters: FilterTable,
1174    /// `PROP_HOST_CHANNEL_KEYS`.
1175    channel_keys: ChannelKeyTable,
1176    /// `PROP_HOST_PEER_KEYS`.
1177    peer_keys: PeerKeyTable,
1178    /// `PROP_HOST_AUTO_ACK`: acknowledge qualifying frames on the
1179    /// host's behalf while detached.
1180    auto_ack: bool,
1181    /// The inbound queue, populated while the host is detached.
1182    queue: RxQueue,
1183    /// MIC prefixes of frames we have transmitted, used to recognize
1184    /// returning echoes: MAC acks (which carry no destination hint) and
1185    /// repeats of our own sends (whose destination hint is the peer's).
1186    transmitted_mics: TransmittedMics,
1187}
1188
1189impl HostDomain {
1190    /// Reset the whole domain to defaults with `key` installed,
1191    /// in place: the domain embeds the multi-KB queue array, and a
1192    /// wholesale struct replacement would stage that array on the
1193    /// caller's stack.
1194    fn reset(&mut self, key: Option<[u8; items::PUBLIC_KEY_LEN]>) {
1195        self.key = key;
1196        self.filters = FilterTable::default();
1197        self.channel_keys = ChannelKeyTable::default();
1198        self.peer_keys = PeerKeyTable::default();
1199        self.auto_ack = false;
1200        self.queue.clear();
1201        self.transmitted_mics = TransmittedMics::default();
1202    }
1203
1204    /// Record the MIC prefix of a frame we are about to transmit, so its
1205    /// echoes — a returning MAC ack, a repeater's onward copy — can be
1206    /// recognized as ours. MAC acks we emit ourselves are skipped: their
1207    /// trailer names the *other* side's frame, which needs no pass-through.
1208    fn note_tx_mic(&mut self, frame: &[u8]) {
1209        let Ok(header) = PacketHeader::parse(frame) else {
1210            return;
1211        };
1212        if header.fcf.packet_type() == PacketType::MacAck {
1213            return;
1214        }
1215        if let Some(mic) = frame.get(header.mic_range.clone())
1216            && mic.len() >= 4
1217        {
1218            self.transmitted_mics.note([mic[0], mic[1], mic[2], mic[3]]);
1219        }
1220    }
1221
1222    /// Spec §Receive Filtering compatibility rule: with no host key, no
1223    /// host channel keys, and an empty explicit table, filtering is
1224    /// unconfigured and every received frame is accepted.
1225    fn filtering_configured(&self) -> bool {
1226        self.key.is_some() || !self.filters.is_empty() || self.channel_keys.len != 0
1227    }
1228
1229    /// Whether receive filtering accepts this frame for live delivery:
1230    /// a Broadcast packet is addressed to every node — the host
1231    /// included — so it is implicitly accepted. The broadcast rule is
1232    /// live-only: while the host is detached, ambient broadcast
1233    /// traffic must not displace queued unicast frames, so the queue
1234    /// path uses [`accepts_frame`](Self::accepts_frame) directly.
1235    fn accepts_live_frame(&self, data: &[u8]) -> bool {
1236        if PacketHeader::parse(data)
1237            .is_ok_and(|header| header.fcf.packet_type() == PacketType::Broadcast)
1238        {
1239            return true;
1240        }
1241        self.accepts_frame(data)
1242    }
1243
1244    /// Whether receive filtering accepts this frame: any explicit
1245    /// filter or the implicit destination-hint filter for the host key
1246    /// matches. Hints are prefilters — over-acceptance is fine, the
1247    /// host verifies cryptographically. A frame that does not parse as
1248    /// UMSH can match no filter.
1249    fn accepts_frame(&self, data: &[u8]) -> bool {
1250        if !self.filtering_configured() {
1251            return true;
1252        }
1253        let Ok(header) = PacketHeader::parse(data) else {
1254            return false;
1255        };
1256        // A frame whose trailer opens with the MIC prefix of something we
1257        // transmitted is an echo of our own send, accepted regardless of
1258        // packet type: a MAC ack's public ack_mic is defined as those 4
1259        // bytes, and a repeater's onward copy carries the MIC verbatim.
1260        // Neither is addressed to us — the ack has no destination hint at
1261        // all, the repeat names the remote peer — so without this rule the
1262        // host could never see its ack arrive or its frame carried onward,
1263        // and its forwarding-confirmation machinery would retry sends the
1264        // mesh already accepted. Entries evict lazily, so multiple echoes of
1265        // one send — acks over different routes, repeats from different
1266        // repeaters — all pass. A miss falls through to the explicit filters
1267        // below (a FILTER_PKT_TYPE entry for MacAck must still be honored),
1268        // preserving the union-of-filters rule.
1269        if let Some(mic) = data.get(header.mic_range.start..header.mic_range.start + 4)
1270            && self
1271                .transmitted_mics
1272                .contains(&[mic[0], mic[1], mic[2], mic[3]])
1273        {
1274            return true;
1275        }
1276        let dst = header.dst.map(|hint| hint.0);
1277        if let Some(key) = &self.key
1278            && dst == Some([key[0], key[1], key[2]])
1279        {
1280            return true;
1281        }
1282        let channel = header.channel.map(|channel| channel.0);
1283        // Each provisioned host channel key's derived identifier is an
1284        // implicit channel filter.
1285        if channel.is_some()
1286            && self
1287                .channel_keys
1288                .iter()
1289                .any(|entry| channel == Some(entry.id))
1290        {
1291            return true;
1292        }
1293        let pkt_type = header.fcf.packet_type() as u8;
1294        self.filters.iter().any(|filter| match filter {
1295            Filter::DestHint(hint) => dst == Some(*hint),
1296            Filter::ChannelId(id) => channel == Some(*id),
1297            Filter::PktType(filtered) => pkt_type == *filtered,
1298        })
1299    }
1300}
1301
1302/// Largest encoded snapshot the session produces (see
1303/// [`Session::encode_snapshot`]); sized for every table at capacity
1304/// with headroom for future properties.
1305///
1306/// The option framing costs roughly two octets per table entry over the
1307/// retired positional format, which the ~1.55 KB worst case here already
1308/// includes (`snapshot_at_capacity_fits_the_buffer` pins it). It must
1309/// stay within `umsh_journal_store::proto::MAX_PAYLOAD`.
1310pub const SNAPSHOT_MAX: usize = 1792;
1311
1312/// Snapshot payload format discriminator.
1313///
1314/// Not a version in the usual sense: the option list behind it evolves
1315/// by allocating property numbers, so this byte changes only if the
1316/// *framing* changes. It exists because a retired positional payload
1317/// does not reliably fail the option decoder — the leading `0x03` of the
1318/// last positional format reads as a well-formed option header (delta 0,
1319/// length 3) — so without a discriminator a stale snapshot would
1320/// mis-decode into a plausible-looking domain instead of being rejected.
1321/// Values 1–3 are the retired positional formats and are never decoded;
1322/// re-provisioning replaces them.
1323const SNAPSHOT_FORMAT: u8 = 4;
1324
1325/// When a saved property lands during a restore.
1326///
1327/// Apply order is schema metadata and deliberately independent of the
1328/// property numbering, which was not allocated with ordering in mind:
1329/// `PROP_PHY_ENABLED` is 32 and so precedes every PHY parameter it
1330/// depends on, and applying in identifier order would bring the radio up
1331/// before configuring it.
1332#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1333enum ApplyPhase {
1334    /// Key material and identity tables. Nothing in the snapshot
1335    /// depends on anything else in this phase.
1336    Keys,
1337    /// PHY parameters, device behavior, and everything else that must
1338    /// be in place before the radio comes up.
1339    Config,
1340    /// Bring-up, after the radio is fully configured.
1341    Enable,
1342}
1343
1344impl ApplyPhase {
1345    const ORDER: [Self; 3] = [Self::Keys, Self::Config, Self::Enable];
1346}
1347
1348/// One saved property: its ULCP identifier, when it applies, and
1349/// whether the snapshot may carry more than one of it.
1350struct SavedProperty {
1351    number: u16,
1352    phase: ApplyPhase,
1353    /// Table-valued: one option per entry, so repeats are expected.
1354    /// Single-valued properties reject a second occurrence.
1355    repeatable: bool,
1356}
1357
1358/// Build a schema row, narrowing the identifier deliberately: property
1359/// identifiers are `u32` in [`ids`] and reach 4864, while the option
1360/// codec numbers options in `u16`. Everything saved fits.
1361const fn saved(number: u32, phase: ApplyPhase, repeatable: bool) -> SavedProperty {
1362    assert!(
1363        number <= u16::MAX as u32,
1364        "saved property number must fit u16"
1365    );
1366    SavedProperty {
1367        number: number as u16,
1368        phase,
1369        repeatable,
1370    }
1371}
1372
1373/// Every property `CMD_SAVE` persists.
1374///
1375/// **Rows must be in ascending identifier order.** The option encoder
1376/// emits deltas and refuses a number below the last one written, so this
1377/// ordering is a codec requirement — and precisely why it cannot also
1378/// carry the apply order, which comes from `phase`.
1379///
1380/// Adding a saved property is one row plus its arm in
1381/// [`SavedState::encode_into`] / [`SavedState::absorb_option`]. Removing
1382/// one is deleting its row: the number is retired and never reused, and
1383/// an older snapshot that still carries it decodes with the option
1384/// skipped.
1385///
1386/// **The host domain is deliberately absent.** It is volatile across
1387/// reboot by design — a detached radio keeps filtering, queueing and
1388/// acknowledging for its host while powered, and forgets on power cycle
1389/// — so 96 (`PROP_HOST_KEY`), 97, 98, 99 and 100 are retired numbers
1390/// here, not omissions. An older snapshot that still carries them
1391/// decodes with those options skipped, which is exactly the wanted
1392/// behavior and needed no migration code.
1393const SAVED_SCHEMA: &[SavedProperty] = &[
1394    saved(prop::PHY_ENABLED, ApplyPhase::Enable, false),
1395    saved(prop::PHY_FREQ, ApplyPhase::Config, false),
1396    saved(prop::PHY_TX_POWER, ApplyPhase::Config, false),
1397    saved(prop::PHY_LORA_BW, ApplyPhase::Config, false),
1398    saved(prop::PHY_LORA_SF, ApplyPhase::Config, false),
1399    saved(prop::PHY_LORA_CR, ApplyPhase::Config, false),
1400    saved(prop::DEV_KEY, ApplyPhase::Keys, false),
1401    saved(prop::DEV_CHANNEL_KEYS, ApplyPhase::Keys, true),
1402    saved(prop::DEV_PEERS, ApplyPhase::Keys, true),
1403    saved(prop::DEV_NAME, ApplyPhase::Config, false),
1404    saved(prop::MAC_REPEATER_ENABLED, ApplyPhase::Config, false),
1405    saved(prop::IDENT_ROLE, ApplyPhase::Config, false),
1406    saved(prop::IDENT_MOBILE, ApplyPhase::Config, false),
1407    saved(prop::MAC_REPEATER_REGIONS, ApplyPhase::Config, false),
1408    saved(prop::MAC_REPEATER_DEFAULT_REGION, ApplyPhase::Config, false),
1409    saved(prop::MAC_REPEATER_MIN_RSSI, ApplyPhase::Config, false),
1410    saved(prop::MAC_REPEATER_MIN_SNR, ApplyPhase::Config, false),
1411    saved(prop::DEV_DISCOVERABLE, ApplyPhase::Config, false),
1412    saved(prop::ADVERT_INTERVAL, ApplyPhase::Config, false),
1413    saved(prop::BEACON_INTERVAL, ApplyPhase::Config, false),
1414    saved(prop::STARTUP_BEACON, ApplyPhase::Config, false),
1415    saved(prop::GNSS_ENABLED, ApplyPhase::Config, false),
1416    saved(prop::PHY_DUTY_LIMIT, ApplyPhase::Config, false),
1417    saved(prop::TZ_OFFSET, ApplyPhase::Config, false),
1418    saved(prop::GNSS_IDENT_UPDATE, ApplyPhase::Config, false),
1419    saved(prop::GNSS_IDENT_PRECISION, ApplyPhase::Config, false),
1420    saved(prop::GNSS_TIME_TRUST, ApplyPhase::Config, false),
1421];
1422
1423/// [`SavedState::decode`] tracks which single-valued properties it has
1424/// already seen in one `u32` of schema-index bits, so the schema cannot
1425/// outgrow that word without the repeat check silently going blind.
1426const _: () = assert!(
1427    SAVED_SCHEMA.len() <= u32::BITS as usize,
1428    "SAVED_SCHEMA outgrew the duplicate-detection bitmask"
1429);
1430
1431/// Why a stored snapshot payload was rejected. Rejection is never
1432/// silent: the boot path walks back a generation and reports through
1433/// `PROP_SAVED` (spec §Saved State).
1434#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1435pub enum SnapshotError {
1436    /// The leading discriminator is not a format this firmware reads.
1437    /// Retired positional payloads land here.
1438    UnknownFormat,
1439    /// The option block is truncated, out of order, or repeats a
1440    /// single-valued property.
1441    Malformed,
1442    /// A known option carried a value this firmware refuses: out of
1443    /// range, wrong length, or a table over capacity.
1444    InvalidValue,
1445}
1446
1447/// What `PROP_SAVED` reports about the stored snapshot.
1448#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1449pub enum SavedStatus {
1450    /// Nothing is saved.
1451    #[default]
1452    None,
1453    /// The newest saved generation is in effect.
1454    Current,
1455    /// A newer generation was rejected and an older one is in effect —
1456    /// the device is running on generation N−1 and needs attention.
1457    Fallback,
1458    /// A snapshot exists but no generation could be read; the device
1459    /// booted bare.
1460    Unreadable,
1461}
1462
1463impl SavedStatus {
1464    /// The `PROP_SAVED` octet.
1465    pub const fn as_octet(self) -> u8 {
1466        match self {
1467            Self::None => ids::saved::NONE,
1468            Self::Current => ids::saved::CURRENT,
1469            Self::Fallback => ids::saved::FALLBACK,
1470            Self::Unreadable => ids::saved::UNREADABLE,
1471        }
1472    }
1473}
1474
1475/// The saved-state subset of the **device domain** (spec §Saved State):
1476/// everything `CMD_SAVE` persists and `CMD_RESTORE`/`CMD_RST` revert to.
1477///
1478/// The host domain is not here. It is volatile across reboot and only
1479/// across reboot: a detached radio keeps its host's keys, filters and
1480/// queue while powered — that is the entire value of the host domain —
1481/// and forgets them on power cycle, when the host re-provisions in full.
1482/// Also excluded: queue contents, per-peer replay baselines, and the
1483/// independently persisted device identity keypair.
1484#[derive(Clone)]
1485struct SavedState {
1486    settings: RadioSettings,
1487    duty_limit: u16,
1488    name: [u8; MAX_DEVICE_NAME_LEN],
1489    name_len: usize,
1490    /// `PROP_DEV_KEY` at the time of the save: which node this snapshot
1491    /// describes.
1492    ///
1493    /// Saved as provenance, not as configuration — the identity keypair
1494    /// lives in its own journal and a restore never installs it. It
1495    /// exists so that restoring a repeater's saved domain onto
1496    /// replacement hardware cannot bring the PHY up under the
1497    /// replacement's throwaway identity, advertising as a node it is
1498    /// not. `None` means the snapshot does not say, which is permissive.
1499    dev_key: Option<[u8; items::PUBLIC_KEY_LEN]>,
1500    dev_channel_keys: ChannelKeyTable,
1501    dev_peers: DevPeerTable,
1502    repeater_enabled: bool,
1503    repeater_regions: RepeaterRegions,
1504    repeater_default_region: Option<[u8; REGION_CODE_LEN]>,
1505    repeater_min_rssi: Option<i16>,
1506    repeater_min_snr: Option<i8>,
1507    ident_role: Option<u8>,
1508    ident_mobile: bool,
1509    dev_discoverable: bool,
1510    advert_interval_s: u32,
1511    beacon_interval_s: u32,
1512    startup_beacon: bool,
1513    tz_offset_min: i16,
1514    gnss_enabled: bool,
1515    gnss_ident_update: bool,
1516    gnss_ident_precision: u8,
1517    gnss_time_trust: bool,
1518}
1519
1520impl SavedState {
1521    /// Capture the saveable subset of the live device domain.
1522    fn capture(
1523        device: &DeviceDomain,
1524        duty_limit: u16,
1525        dev_key: Option<[u8; items::PUBLIC_KEY_LEN]>,
1526    ) -> Self {
1527        Self {
1528            settings: device.settings,
1529            duty_limit,
1530            name: device.name,
1531            name_len: device.name_len,
1532            dev_key,
1533            dev_channel_keys: device.channel_keys,
1534            dev_peers: device.peers,
1535            repeater_enabled: device.repeater_enabled,
1536            repeater_regions: device.repeater_regions,
1537            repeater_default_region: device.repeater_default_region,
1538            repeater_min_rssi: device.repeater_min_rssi,
1539            repeater_min_snr: device.repeater_min_snr,
1540            ident_role: device.ident_role,
1541            ident_mobile: device.ident_mobile,
1542            dev_discoverable: device.dev_discoverable,
1543            advert_interval_s: device.advert_interval_s,
1544            beacon_interval_s: device.beacon_interval_s,
1545            startup_beacon: device.startup_beacon,
1546            tz_offset_min: device.tz_offset_min,
1547            gnss_enabled: device.gnss_enabled,
1548            gnss_ident_update: device.gnss_ident_update,
1549            gnss_ident_precision: device.gnss_ident_precision,
1550            gnss_time_trust: device.gnss_time_trust,
1551        }
1552    }
1553
1554    /// The state a snapshot carrying no options at all decodes to:
1555    /// every saved property at its documented post-reset value. Forward
1556    /// compatibility rests on this — an option an older writer never
1557    /// emitted is simply absent, and absence is the default.
1558    fn defaults(config: &SessionConfig) -> Self {
1559        let mut settings = config.defaults;
1560        settings.enabled = false;
1561        let mut name = [0u8; MAX_DEVICE_NAME_LEN];
1562        let name_len = config.default_device_name.len();
1563        name[..name_len].copy_from_slice(config.default_device_name.as_bytes());
1564        Self {
1565            settings,
1566            duty_limit: config.default_duty_limit,
1567            name,
1568            name_len,
1569            dev_key: None,
1570            dev_channel_keys: ChannelKeyTable::default(),
1571            dev_peers: DevPeerTable::default(),
1572            repeater_enabled: false,
1573            repeater_regions: RepeaterRegions::default(),
1574            repeater_default_region: None,
1575            repeater_min_rssi: None,
1576            repeater_min_snr: None,
1577            ident_role: None,
1578            ident_mobile: false,
1579            dev_discoverable: true,
1580            advert_interval_s: DEFAULT_ADVERT_INTERVAL_S,
1581            beacon_interval_s: DEFAULT_BEACON_INTERVAL_S,
1582            startup_beacon: true,
1583            tz_offset_min: 0,
1584            // Must track `DeviceDomain::post_reset`: this is the baseline
1585            // a snapshot's absent options decode against, so a board that
1586            // boots its receiver on has to see that here too, or a
1587            // snapshot saved while it was on would restore it off.
1588            gnss_enabled: config.gnss.is_some_and(|gnss| gnss.default_enabled),
1589            gnss_ident_update: false,
1590            gnss_ident_precision: DEFAULT_IDENT_PRECISION,
1591            gnss_time_trust: true,
1592        }
1593    }
1594
1595    /// Encode as a format byte followed by an option list keyed by ULCP
1596    /// property identifier, in [`SAVED_SCHEMA`] order.
1597    ///
1598    /// Tables emit one option per entry; a value equal to its default —
1599    /// an absent host key, an empty table — is omitted, since absence
1600    /// and the default decode identically. Scalars are always written,
1601    /// because omitting one would silently mean "whatever this firmware
1602    /// build calls the default" rather than the value that was saved.
1603    fn encode(&self, out: &mut [u8]) -> Option<usize> {
1604        let (format, rest) = out.split_first_mut()?;
1605        *format = SNAPSHOT_FORMAT;
1606        let mut encoder = OptionEncoder::new(rest);
1607        for entry in SAVED_SCHEMA {
1608            self.encode_into(&mut encoder, entry.number).ok()?;
1609        }
1610        Some(1 + encoder.finish())
1611    }
1612
1613    /// Emit every option for one saved property. Table properties emit
1614    /// zero or more; single-valued properties emit zero or one.
1615    fn encode_into(&self, encoder: &mut OptionEncoder<'_>, number: u16) -> Result<(), EncodeError> {
1616        match u32::from(number) {
1617            prop::PHY_ENABLED => encoder.put(number, &[self.settings.enabled as u8]),
1618            prop::PHY_FREQ => encoder.put(number, &self.settings.freq_khz.to_le_bytes()),
1619            prop::PHY_TX_POWER => encoder.put(number, &[self.settings.tx_power_dbm as u8]),
1620            prop::PHY_LORA_BW => encoder.put(number, &self.settings.bw_hz.to_le_bytes()),
1621            prop::PHY_LORA_SF => encoder.put(number, &[self.settings.sf]),
1622            prop::PHY_LORA_CR => encoder.put(number, &[self.settings.cr_denom]),
1623            // Channel-key options carry the key itself, not the derived
1624            // identifier the GET form reports: a snapshot has to restore
1625            // the device to working order, and the digest form cannot.
1626            prop::DEV_KEY => match &self.dev_key {
1627                Some(key) => encoder.put(number, key),
1628                None => Ok(()),
1629            },
1630            prop::DEV_CHANNEL_KEYS => {
1631                for entry in self.dev_channel_keys.iter() {
1632                    encoder.put(number, &entry.key)?;
1633                }
1634                Ok(())
1635            }
1636            prop::DEV_PEERS => {
1637                for public_key in self.dev_peers.iter() {
1638                    encoder.put(number, public_key)?;
1639                }
1640                Ok(())
1641            }
1642            prop::DEV_NAME => encoder.put(number, &self.name[..self.name_len]),
1643            prop::MAC_REPEATER_ENABLED => encoder.put(number, &[self.repeater_enabled as u8]),
1644            prop::IDENT_ROLE => match self.ident_role {
1645                Some(role) => encoder.put(number, &[role]),
1646                None => Ok(()),
1647            },
1648            prop::IDENT_MOBILE => encoder.put(number, &[self.ident_mobile as u8]),
1649            // The unset forms of the repeater policy are all "empty", and
1650            // empty is the default, so an unset gate is omitted outright
1651            // rather than written as a zero-length option.
1652            prop::MAC_REPEATER_REGIONS => match self.repeater_regions.is_empty() {
1653                true => Ok(()),
1654                false => encoder.put(number, self.repeater_regions.as_slice()),
1655            },
1656            prop::MAC_REPEATER_DEFAULT_REGION => match &self.repeater_default_region {
1657                Some(code) => encoder.put(number, code),
1658                None => Ok(()),
1659            },
1660            prop::MAC_REPEATER_MIN_RSSI => match self.repeater_min_rssi {
1661                Some(rssi) => encoder.put(number, &rssi.to_le_bytes()),
1662                None => Ok(()),
1663            },
1664            prop::MAC_REPEATER_MIN_SNR => match self.repeater_min_snr {
1665                Some(snr) => encoder.put(number, &[snr as u8]),
1666                None => Ok(()),
1667            },
1668            prop::DEV_DISCOVERABLE => encoder.put(number, &[self.dev_discoverable as u8]),
1669            prop::ADVERT_INTERVAL => encoder.put(number, &self.advert_interval_s.to_le_bytes()),
1670            prop::BEACON_INTERVAL => encoder.put(number, &self.beacon_interval_s.to_le_bytes()),
1671            prop::STARTUP_BEACON => encoder.put(number, &[self.startup_beacon as u8]),
1672            prop::GNSS_ENABLED => encoder.put(number, &[self.gnss_enabled as u8]),
1673            prop::PHY_DUTY_LIMIT => encoder.put(number, &self.duty_limit.to_le_bytes()),
1674            prop::TZ_OFFSET => encoder.put(number, &self.tz_offset_min.to_le_bytes()),
1675            prop::GNSS_IDENT_UPDATE => encoder.put(number, &[self.gnss_ident_update as u8]),
1676            prop::GNSS_IDENT_PRECISION => encoder.put(number, &[self.gnss_ident_precision]),
1677            prop::GNSS_TIME_TRUST => encoder.put(number, &[self.gnss_time_trust as u8]),
1678            _ => unreachable!("SAVED_SCHEMA row without an encoder arm"),
1679        }
1680    }
1681
1682    /// Decode a stored snapshot into a candidate state, without touching
1683    /// any live domain. Values are checked with the same validators the
1684    /// live property setters use, but transport authorization is
1685    /// deliberately bypassed: at boot there is no attached link to
1686    /// authorize anything, and the snapshot's provenance is the device's
1687    /// own flash.
1688    ///
1689    /// Channel identifiers are left unset here and re-derived by
1690    /// [`SavedState::derive_channel_ids`] rather than trusted from
1691    /// storage, which is what keeps this step free of the crypto engine
1692    /// — so a caller can ask whether a stored generation is readable
1693    /// without building one.
1694    ///
1695    /// Unknown options are skipped (a newer writer's property, or a
1696    /// retired one). Everything else — a bad format byte, a malformed
1697    /// option block, a repeated single-valued property, an out-of-range
1698    /// value, a table over capacity — rejects the whole payload, leaving
1699    /// the caller to fall back to an older generation.
1700    fn decode(config: &SessionConfig, bytes: &[u8]) -> Result<Self, SnapshotError> {
1701        let (format, options) = bytes.split_first().ok_or(SnapshotError::Malformed)?;
1702        if *format != SNAPSHOT_FORMAT {
1703            return Err(SnapshotError::UnknownFormat);
1704        }
1705        let mut state = Self::defaults(config);
1706        let mut seen: u32 = 0;
1707        for item in OptionDecoder::new(options) {
1708            let (number, value) = item.map_err(|_| SnapshotError::Malformed)?;
1709            let Some(index) = SAVED_SCHEMA.iter().position(|entry| entry.number == number) else {
1710                continue;
1711            };
1712            if !SAVED_SCHEMA[index].repeatable {
1713                let bit = 1u32 << index;
1714                if seen & bit != 0 {
1715                    return Err(SnapshotError::Malformed);
1716                }
1717                seen |= bit;
1718            }
1719            state.absorb_option(config, number, value)?;
1720        }
1721        Ok(state)
1722    }
1723
1724    /// Validate and store one decoded option.
1725    fn absorb_option(
1726        &mut self,
1727        config: &SessionConfig,
1728        number: u16,
1729        value: &[u8],
1730    ) -> Result<(), SnapshotError> {
1731        let invalid = |_| SnapshotError::InvalidValue;
1732        match u32::from(number) {
1733            prop::PHY_ENABLED => self.settings.enabled = parse_bool(value).map_err(invalid)?,
1734            prop::PHY_FREQ => {
1735                self.settings.freq_khz = validate_freq_khz(config, value).map_err(invalid)?
1736            }
1737            // A snapshot carrying a power this radio cannot reach — one
1738            // restored across a hardware change — clamps like a live
1739            // write rather than failing the whole restore.
1740            prop::PHY_TX_POWER => {
1741                self.settings.tx_power_dbm = clamp_tx_power(config, value).map_err(invalid)?
1742            }
1743            prop::PHY_LORA_BW => self.settings.bw_hz = validate_bw_hz(value).map_err(invalid)?,
1744            prop::PHY_LORA_SF => self.settings.sf = validate_sf(value).map_err(invalid)?,
1745            prop::PHY_LORA_CR => self.settings.cr_denom = validate_cr(value).map_err(invalid)?,
1746            prop::DEV_KEY => {
1747                self.dev_key = Some(value.try_into().map_err(|_| SnapshotError::InvalidValue)?);
1748            }
1749            prop::DEV_CHANNEL_KEYS => {
1750                let key = channel_key_item(value)?;
1751                self.dev_channel_keys
1752                    .insert(ChannelKeyEntry {
1753                        key,
1754                        id: [0; items::CHANNEL_ID_LEN],
1755                    })
1756                    .map_err(invalid)?;
1757            }
1758            prop::DEV_PEERS => {
1759                let public_key: [u8; items::PUBLIC_KEY_LEN] =
1760                    value.try_into().map_err(|_| SnapshotError::InvalidValue)?;
1761                self.dev_peers.insert(public_key).map_err(invalid)?;
1762            }
1763            prop::DEV_NAME => {
1764                if !valid_device_name(value) {
1765                    return Err(SnapshotError::InvalidValue);
1766                }
1767                self.name = [0; MAX_DEVICE_NAME_LEN];
1768                self.name[..value.len()].copy_from_slice(value);
1769                self.name_len = value.len();
1770            }
1771            prop::MAC_REPEATER_ENABLED => {
1772                self.repeater_enabled = parse_bool(value).map_err(invalid)?
1773            }
1774            prop::IDENT_ROLE => self.ident_role = Some(parse_u8(value).map_err(invalid)?),
1775            prop::IDENT_MOBILE => self.ident_mobile = parse_bool(value).map_err(invalid)?,
1776            prop::MAC_REPEATER_REGIONS => {
1777                self.repeater_regions = RepeaterRegions::parse(value).map_err(invalid)?
1778            }
1779            prop::MAC_REPEATER_DEFAULT_REGION => {
1780                self.repeater_default_region = parse_region_code(value).map_err(invalid)?
1781            }
1782            prop::MAC_REPEATER_MIN_RSSI => {
1783                self.repeater_min_rssi = Some(parse_i16(value).map_err(invalid)?)
1784            }
1785            prop::MAC_REPEATER_MIN_SNR => {
1786                self.repeater_min_snr = Some(parse_i8(value).map_err(invalid)?)
1787            }
1788            prop::DEV_DISCOVERABLE => self.dev_discoverable = parse_bool(value).map_err(invalid)?,
1789            prop::ADVERT_INTERVAL => {
1790                self.advert_interval_s = validate_announce_interval(value).map_err(invalid)?
1791            }
1792            prop::BEACON_INTERVAL => {
1793                self.beacon_interval_s = validate_announce_interval(value).map_err(invalid)?
1794            }
1795            prop::STARTUP_BEACON => self.startup_beacon = parse_bool(value).map_err(invalid)?,
1796            prop::GNSS_ENABLED => self.gnss_enabled = parse_bool(value).map_err(invalid)?,
1797            prop::PHY_DUTY_LIMIT => self.duty_limit = parse_u16(value).map_err(invalid)?,
1798            prop::TZ_OFFSET => self.tz_offset_min = validate_tz_offset(value).map_err(invalid)?,
1799            prop::GNSS_IDENT_UPDATE => {
1800                self.gnss_ident_update = parse_bool(value).map_err(invalid)?
1801            }
1802            prop::GNSS_IDENT_PRECISION => {
1803                self.gnss_ident_precision = validate_ident_precision(value).map_err(invalid)?
1804            }
1805            prop::GNSS_TIME_TRUST => self.gnss_time_trust = parse_bool(value).map_err(invalid)?,
1806            _ => unreachable!("SAVED_SCHEMA row without a decoder arm"),
1807        }
1808        Ok(())
1809    }
1810
1811    /// Re-derive the channel identifiers left unset by
1812    /// [`SavedState::decode`]. Deriving rather than storing keeps the
1813    /// identifier a function of the key, so a stored snapshot cannot
1814    /// assert a mismatched pair.
1815    fn derive_channel_ids<A: AesProvider, S: Sha256Provider>(
1816        &mut self,
1817        engine: &CryptoEngine<A, S>,
1818    ) {
1819        let table = &mut self.dev_channel_keys;
1820        for entry in table.entries[..table.len].iter_mut().flatten() {
1821            entry.id = engine.derive_channel_id(&ChannelKey(entry.key)).0;
1822        }
1823    }
1824}
1825
1826/// A channel-key option value: the raw symmetric key.
1827fn channel_key_item(value: &[u8]) -> Result<[u8; items::CHANNEL_KEY_LEN], SnapshotError> {
1828    value.try_into().map_err(|_| SnapshotError::InvalidValue)
1829}
1830
1831/// State that exists only while a host is attached (spec §State
1832/// Classes): transaction correlation and session-scoped properties.
1833/// Reset on every attach without touching the radio.
1834struct SessionState<const TX: usize> {
1835    /// `PROP_MAC_PROMISCUOUS` — the only session-scoped property.
1836    promiscuous: bool,
1837    /// Accepted host transmissions, including the one currently owned by the
1838    /// physical radio. Keeping this queue in the device lets a host pipeline
1839    /// fragmented messages without waiting one LoRa round trip per fragment.
1840    pending: Deque<PendingTx, TX>,
1841    /// A drain in progress ([`Effect::DrainQueue`]). Covers exactly the
1842    /// frames queued when `CMD_QUEUE_DRAIN` arrived; an attach or
1843    /// detach abandons the drain, leaving undelivered frames queued.
1844    drain: Option<DrainState>,
1845    /// A device-identity provisioning awaiting its durable write
1846    /// ([`Effect::ProvisionIdentity`]). A detach mid-flight abandons
1847    /// the transaction; flash remains the source of truth either way
1848    /// (see [`Session::respond_identity`]).
1849    pending_identity: Option<PendingIdentity>,
1850}
1851
1852impl<const TX: usize> Default for SessionState<TX> {
1853    fn default() -> Self {
1854        Self {
1855            promiscuous: false,
1856            pending: Deque::new(),
1857            drain: None,
1858            pending_identity: None,
1859        }
1860    }
1861}
1862
1863struct PendingIdentity {
1864    tid: u8,
1865    /// The private key to install, or `None` to generate one on-device.
1866    secret: Option<[u8; PRIVATE_KEY_LEN]>,
1867}
1868
1869struct DrainState {
1870    tid: u8,
1871    remaining: usize,
1872}
1873
1874pub struct Session<A: AesProvider, S: Sha256Provider, const TX: usize = 1> {
1875    config: SessionConfig,
1876    /// Protocol crypto (channel-identifier derivation now; packet
1877    /// authentication and delegated acknowledgement with
1878    /// `CAP_HOST_AUTO_ACK`).
1879    engine: CryptoEngine<A, S>,
1880    device: DeviceDomain,
1881    host: HostDomain,
1882    session: SessionState<TX>,
1883    /// Whether a host is currently attached: accepted frames are
1884    /// delivered live when true and queued when false. Starts detached;
1885    /// the transport binding reports attach/detach edges.
1886    attached: bool,
1887    /// Whether the attached transport meets its security binding for
1888    /// key provisioning (spec §Provisioning Security): physical
1889    /// possession for serial, an encrypted bonded LESC link for BLE.
1890    link_secure: bool,
1891    /// RAM mirror of the durably saved snapshot (`None` when nothing
1892    /// is saved). Post-reset values and `CMD_RESTORE` come from here;
1893    /// the firmware keeps the flash journal in sync through the
1894    /// save/clear/wipe effects.
1895    saved: Option<SavedState>,
1896    /// Whether any stored generation was rejected at boot. Latched for
1897    /// the life of the boot: a device running on generation N−1, or on
1898    /// nothing at all because every generation was unreadable, must stay
1899    /// distinguishable from one that simply has nothing saved.
1900    snapshot_rejected: bool,
1901    /// `PROP_DEV_KEY`: the live device identity public key.
1902    dev_key: Option<[u8; items::PUBLIC_KEY_LEN]>,
1903    /// RAM mirror of the *independently persisted* identity — the
1904    /// value `CMD_RST` reverts to. Identical to `dev_key` except
1905    /// between a `CMD_CLEAR` (which erases only the durable copy; live
1906    /// state is unaffected) and the reset that completes the factory
1907    /// wipe. Never part of the snapshot: `CMD_RESTORE` cannot revert
1908    /// the identity.
1909    dev_key_persisted: Option<[u8; items::PUBLIC_KEY_LEN]>,
1910    last_status: Status,
1911    /// Monotonic generation of the device-domain node tables
1912    /// (`PROP_DEV_CHANNEL_KEYS`, `PROP_DEV_PEERS`). Bumped on every
1913    /// mutation, boot restore, `CMD_RESTORE`, and `CMD_RST`. The
1914    /// firmware compares it against a cached value to know when to
1915    /// re-sync the live device node's MAC (device-node plan increment
1916    /// 3); the session stays authoritative for the property surface and
1917    /// the firmware applies the change to its `MacHandle`.
1918    dev_domain_version: u32,
1919    /// `PROP_ALERT`: what the device is currently doing to make itself
1920    /// conspicuous, and when it gives up.
1921    ///
1922    /// Deliberately neither device-domain nor session state. Not the
1923    /// former because it is live physical behavior that is never saved
1924    /// and that `CMD_RST` must not silence; not the latter because a
1925    /// detach is exactly when an alert matters — the link to the
1926    /// searching host drops as soon as the searcher walks out of range.
1927    /// The deadline is the only thing that stops it unattended.
1928    alert: AlertState,
1929    alert_deadline_ms: Option<u64>,
1930    scratch: [u8; SCRATCH],
1931}
1932
1933impl<A: AesProvider, S: Sha256Provider, const TX: usize> Session<A, S, TX> {
1934    /// `boot_status` is the retained hardware reset cause, reported by
1935    /// the first `PROP_LAST_STATUS` get of the first session.
1936    pub fn new(config: SessionConfig, boot_status: Status, engine: CryptoEngine<A, S>) -> Self {
1937        debug_assert!(usize::from(config.mtu) <= MAX_MTU);
1938        debug_assert!(valid_device_name(config.default_device_name.as_bytes()));
1939        Self {
1940            config,
1941            engine,
1942            device: DeviceDomain::post_reset(&config),
1943            host: HostDomain::default(),
1944            session: SessionState::default(),
1945            attached: false,
1946            link_secure: false,
1947            saved: None,
1948            snapshot_rejected: false,
1949            dev_key: None,
1950            dev_key_persisted: None,
1951            last_status: boot_status,
1952            dev_domain_version: 0,
1953            alert: AlertState::None,
1954            alert_deadline_ms: None,
1955            scratch: [0; SCRATCH],
1956        }
1957    }
1958
1959    /// The active radio settings.
1960    pub fn settings(&self) -> RadioSettings {
1961        self.device.settings
1962    }
1963
1964    /// Current UTF-8 `PROP_DEV_NAME` value.
1965    pub fn device_name(&self) -> &str {
1966        core::str::from_utf8(&self.device.name[..self.device.name_len])
1967            .expect("validated device name")
1968    }
1969
1970    /// Payload of the transmit requested by [`Effect::StartTransmit`].
1971    pub fn tx_data(&self) -> &[u8] {
1972        self.session
1973            .pending
1974            .front()
1975            .map(|pending| pending.data.as_slice())
1976            .unwrap_or_default()
1977    }
1978
1979    /// Power selection for the pending transmit.
1980    pub fn tx_power(&self) -> TxPower {
1981        self.session
1982            .pending
1983            .front()
1984            .map(|pending| pending.power)
1985            .unwrap_or(TxPower::Default)
1986    }
1987
1988    /// The board's maximum transmit power, as configured. The concrete
1989    /// dBm value behind [`TxPower::Max`] when a transmit is staged.
1990    pub fn max_tx_power_dbm(&self) -> i8 {
1991        self.config.max_tx_power_dbm
1992    }
1993
1994    /// Whether the pending transmit requested `TX_FLAG_NOCCA` — skip the
1995    /// pre-transmit channel-activity check.
1996    pub fn tx_nocca(&self) -> bool {
1997        self.session
1998            .pending
1999            .front()
2000            .map(|pending| pending.nocca)
2001            .unwrap_or(false)
2002    }
2003
2004    /// Whether a transmit is awaiting [`Session::on_tx_result`].
2005    pub fn has_pending_tx(&self) -> bool {
2006        !self.session.pending.is_empty()
2007    }
2008
2009    /// Number of received frames currently waiting for the host.
2010    pub fn queued_frame_count(&self) -> usize {
2011        self.host.queue.len
2012    }
2013
2014    /// Monotonic generation of the device-domain node tables and the
2015    /// live device key. The firmware caches this and re-syncs the live
2016    /// device node's MAC whenever it changes (device-node plan increment
2017    /// 3).
2018    ///
2019    /// Identity provisioning bumps it too, even though a newly
2020    /// provisioned key only takes effect at the next boot
2021    /// (live-state-until-reboot, as with `CMD_CLEAR`): the *old* key
2022    /// stops being one the device claims immediately, and the node has
2023    /// to be told so it can stop originating traffic under it.
2024    pub fn dev_domain_version(&self) -> u32 {
2025        self.dev_domain_version
2026    }
2027
2028    /// Bump [`Session::dev_domain_version`]. Call after any change to
2029    /// the device channel-key or peer tables.
2030    fn bump_dev_domain(&mut self) {
2031        self.dev_domain_version = self.dev_domain_version.wrapping_add(1);
2032    }
2033
2034    /// The device identity's provisioned channel keys (raw symmetric
2035    /// keys, not the derived identifiers). The firmware joins each into
2036    /// the device node so it processes multicast on that channel.
2037    pub fn dev_channel_keys(&self) -> impl Iterator<Item = [u8; items::CHANNEL_KEY_LEN]> + '_ {
2038        self.device.channel_keys.iter().map(|entry| entry.key)
2039    }
2040
2041    /// The device identity's provisioned peer public keys. The firmware
2042    /// registers each with the device node's MAC.
2043    pub fn dev_peers(&self) -> impl Iterator<Item = [u8; items::PUBLIC_KEY_LEN]> + '_ {
2044        self.device.peers.iter().copied()
2045    }
2046
2047    /// `PROP_MAC_REPEATER_ENABLED`: whether the device node should
2048    /// autonomously forward overheard routable frames and advertise
2049    /// `NodeRole::Repeater`. Part of the device domain, so it changes
2050    /// [`Session::dev_domain_version`] and the firmware reconciles it
2051    /// against the live MAC on the next sync.
2052    pub fn repeater_enabled(&self) -> bool {
2053        self.device.repeater_enabled
2054    }
2055
2056    /// `PROP_MAC_REPEATER_REGIONS`: the flood-forwarding region filter,
2057    /// in wire order — concatenated 2-octet codes, empty for no
2058    /// restriction. This is also exactly the Supported Regions identity
2059    /// option payload, so a caller advertising the device's regions can
2060    /// forward these bytes unchanged.
2061    pub fn repeater_regions(&self) -> &[u8] {
2062        self.device.repeater_regions.as_slice()
2063    }
2064
2065    /// `PROP_MAC_REPEATER_DEFAULT_REGION`: the code inserted into an
2066    /// untagged flood packet, or `None` to never tag.
2067    pub fn repeater_default_region(&self) -> Option<[u8; REGION_CODE_LEN]> {
2068        self.device.repeater_default_region
2069    }
2070
2071    /// `PROP_MAC_REPEATER_MIN_RSSI`: minimum received RSSI in dBm for
2072    /// flood forwarding, or `None` for no threshold.
2073    pub fn repeater_min_rssi(&self) -> Option<i16> {
2074        self.device.repeater_min_rssi
2075    }
2076
2077    /// `PROP_MAC_REPEATER_MIN_SNR`: minimum received SNR in whole dB for
2078    /// flood forwarding, or `None` for no threshold.
2079    pub fn repeater_min_snr(&self) -> Option<i8> {
2080        self.device.repeater_min_snr
2081    }
2082
2083    /// The live `PROP_DEV_KEY` value. `None` once a factory reset
2084    /// (`CMD_CLEAR` + `CMD_RST`) completes — the firmware uses this
2085    /// edge to make a running device node dormant.
2086    pub fn dev_key(&self) -> Option<&[u8; items::PUBLIC_KEY_LEN]> {
2087        self.dev_key.as_ref()
2088    }
2089
2090    /// Reset all protocol state to post-reset values, announce the
2091    /// reset with the given reason, and return the radio effect
2092    /// applying the post-reset radio configuration.
2093    ///
2094    /// Used for `CMD_RST` (with [`Status::RESET_SOFTWARE`]). With a
2095    /// saved snapshot the post-reset value of every saved device-domain
2096    /// property is its saved value — including the PHY enable state; the
2097    /// documented defaults apply only when nothing is saved. The host
2098    /// domain always returns to its documented defaults: it is never
2099    /// saved. Queue contents and replay baselines are discarded either
2100    /// way.
2101    pub fn reset(&mut self, reason: Status, emit: &mut impl FnMut(&[u8])) -> Effect {
2102        self.device = DeviceDomain::post_reset(&self.config);
2103        // The device identity's post-reset value is the persisted one:
2104        // normally unchanged, gone after CMD_CLEAR (completing a
2105        // factory reset).
2106        self.dev_key = self.dev_key_persisted;
2107        self.host.reset(None);
2108        if self.saved.is_some() {
2109            self.apply_saved_device();
2110        }
2111        self.session = SessionState::default();
2112        // The device tables were rebuilt from post-reset (and possibly
2113        // the saved snapshot); the node must re-sync.
2114        self.bump_dev_domain();
2115        self.send_status(TID_UNSOLICITED, reason, emit);
2116        self.apply_radio()
2117    }
2118
2119    /// Build the [`Effect::ApplyRadio`] for the current settings,
2120    /// mirroring the modulation into the shared duty ledger so every
2121    /// radio client prices airtime against what is actually on the air.
2122    fn apply_radio(&self) -> Effect {
2123        let settings = self.device.settings;
2124        self.config
2125            .duty
2126            .set_phy(settings.sf, settings.bw_hz, settings.cr_denom);
2127        Effect::ApplyRadio(settings)
2128    }
2129
2130    /// Apply the saved device-domain configuration to the live domain.
2131    /// Duty accounting is dynamic state, not configuration: the caller
2132    /// decides whether it survives (restore) or restarts (reset, via
2133    /// `DeviceDomain::post_reset` beforehand).
2134    ///
2135    /// Properties land in [`SAVED_SCHEMA`] phase order, which is what
2136    /// keeps `PROP_PHY_ENABLED` from bringing the radio up before the
2137    /// PHY parameters it depends on are in place. Ordering is schema
2138    /// data, not the order of the statements below.
2139    fn apply_saved_device(&mut self) {
2140        let saved = self.saved.as_ref().expect("caller checked saved");
2141        for phase in ApplyPhase::ORDER {
2142            for entry in SAVED_SCHEMA.iter().filter(|entry| entry.phase == phase) {
2143                match u32::from(entry.number) {
2144                    // The PHY comes up only if this snapshot describes
2145                    // the node the device currently is. Restoring a
2146                    // repeater's saved domain onto replacement hardware
2147                    // — before its identity has been installed —
2148                    // otherwise puts it on the air advertising as the
2149                    // repeater under an auto-generated throwaway key.
2150                    // A snapshot that does not record its identity is
2151                    // treated as matching, since it cannot be checked.
2152                    prop::PHY_ENABLED => {
2153                        let identity_matches =
2154                            saved.dev_key.is_none_or(|key| Some(key) == self.dev_key);
2155                        self.device.settings.enabled = saved.settings.enabled && identity_matches;
2156                    }
2157                    // Provenance only: a restore never installs an
2158                    // identity, it only refuses to impersonate one.
2159                    prop::DEV_KEY => {}
2160                    prop::PHY_FREQ => self.device.settings.freq_khz = saved.settings.freq_khz,
2161                    prop::PHY_TX_POWER => {
2162                        self.device.settings.tx_power_dbm = saved.settings.tx_power_dbm
2163                    }
2164                    prop::PHY_LORA_BW => self.device.settings.bw_hz = saved.settings.bw_hz,
2165                    prop::PHY_LORA_SF => self.device.settings.sf = saved.settings.sf,
2166                    prop::PHY_LORA_CR => self.device.settings.cr_denom = saved.settings.cr_denom,
2167                    prop::DEV_CHANNEL_KEYS => self.device.channel_keys = saved.dev_channel_keys,
2168                    prop::DEV_PEERS => self.device.peers = saved.dev_peers,
2169                    prop::DEV_NAME => {
2170                        self.device.name = saved.name;
2171                        self.device.name_len = saved.name_len;
2172                    }
2173                    prop::MAC_REPEATER_ENABLED => {
2174                        self.device.repeater_enabled = saved.repeater_enabled
2175                    }
2176                    prop::IDENT_ROLE => self.device.ident_role = saved.ident_role,
2177                    prop::IDENT_MOBILE => self.device.ident_mobile = saved.ident_mobile,
2178                    prop::MAC_REPEATER_REGIONS => {
2179                        self.device.repeater_regions = saved.repeater_regions
2180                    }
2181                    prop::MAC_REPEATER_DEFAULT_REGION => {
2182                        self.device.repeater_default_region = saved.repeater_default_region
2183                    }
2184                    prop::MAC_REPEATER_MIN_RSSI => {
2185                        self.device.repeater_min_rssi = saved.repeater_min_rssi
2186                    }
2187                    prop::MAC_REPEATER_MIN_SNR => {
2188                        self.device.repeater_min_snr = saved.repeater_min_snr
2189                    }
2190                    prop::DEV_DISCOVERABLE => self.device.dev_discoverable = saved.dev_discoverable,
2191                    prop::ADVERT_INTERVAL => {
2192                        self.device.advert_interval_s = saved.advert_interval_s
2193                    }
2194                    prop::BEACON_INTERVAL => {
2195                        self.device.beacon_interval_s = saved.beacon_interval_s
2196                    }
2197                    prop::STARTUP_BEACON => self.device.startup_beacon = saved.startup_beacon,
2198                    // The receiver comes back up exactly as it was left.
2199                    // Unlike the PHY this needs no identity check: where
2200                    // the device is is a fact about the hardware, not a
2201                    // claim made under an identity.
2202                    prop::GNSS_ENABLED => self.device.gnss_enabled = saved.gnss_enabled,
2203                    prop::PHY_DUTY_LIMIT => self.config.duty.set_limit(saved.duty_limit),
2204                    prop::TZ_OFFSET => self.device.tz_offset_min = saved.tz_offset_min,
2205                    prop::GNSS_IDENT_UPDATE => {
2206                        self.device.gnss_ident_update = saved.gnss_ident_update
2207                    }
2208                    prop::GNSS_IDENT_PRECISION => {
2209                        self.device.gnss_ident_precision = saved.gnss_ident_precision
2210                    }
2211                    prop::GNSS_TIME_TRUST => self.device.gnss_time_trust = saved.gnss_time_trust,
2212                    _ => unreachable!("SAVED_SCHEMA row without an apply arm"),
2213                }
2214            }
2215        }
2216        self.bump_dev_domain();
2217    }
2218
2219    /// A host attached. Resets session state only (spec §Attach): the
2220    /// device and host domains — PHY configuration and enable state,
2221    /// device name, duty accounting, provisioning, and the inbound
2222    /// queue — are untouched, and nothing is emitted; the attach itself
2223    /// produces no notification. Accepted frames are delivered live
2224    /// from here on; queued frames wait for `CMD_QUEUE_DRAIN`.
2225    ///
2226    /// `link_secure` states whether this transport meets its security
2227    /// binding for key provisioning (spec §Provisioning Security):
2228    /// physical possession for serial transports, an encrypted bonded
2229    /// LESC link for BLE. Key-bearing writes are refused while false.
2230    pub fn attach(&mut self, link_secure: bool) {
2231        self.session = SessionState::default();
2232        self.attached = true;
2233        self.link_secure = link_secure;
2234    }
2235
2236    /// The host detached. Session state is discarded; the device and
2237    /// host domains keep operating detached: accepted frames are queued
2238    /// instead of delivered (delegated acknowledgement arrives with
2239    /// `CAP_HOST_AUTO_ACK`).
2240    pub fn detach(&mut self) {
2241        self.session = SessionState::default();
2242        self.attached = false;
2243        self.link_secure = false;
2244    }
2245
2246    /// Handle one decoded ULCP frame from the host.
2247    pub fn handle_frame(
2248        &mut self,
2249        bytes: &[u8],
2250        now_ms: u64,
2251        emit: &mut impl FnMut(&[u8]),
2252    ) -> Option<Effect> {
2253        // Malformed frames (bad flag, reserved bits, command MSB) are
2254        // ignored per the spec.
2255        let received = Frame::parse(bytes).ok()?;
2256        let tid = received.header.tid();
2257        match received.command() {
2258            Some(Cmd::Nop) => {
2259                self.complete(tid, Status::OK, emit);
2260                None
2261            }
2262            Some(Cmd::Reset) => Some(self.reset(Status::RESET_SOFTWARE, emit)),
2263            Some(Cmd::PropGet) => match PropPayload::parse(received.payload) {
2264                Ok(payload) => self.prop_get(tid, payload.key, now_ms, emit),
2265                Err(_) => {
2266                    self.complete(tid, Status::PARSE_ERROR, emit);
2267                    None
2268                }
2269            },
2270            Some(Cmd::PropSet) => match PropPayload::parse(received.payload) {
2271                Ok(payload) => self.prop_set(tid, payload.key, payload.value, now_ms, emit),
2272                Err(_) => {
2273                    self.complete(tid, Status::PARSE_ERROR, emit);
2274                    None
2275                }
2276            },
2277            Some(Cmd::StrSend) => match StreamPayload::parse(received.payload) {
2278                Ok(payload) => self.str_send(tid, &payload, now_ms, emit),
2279                Err(_) => {
2280                    self.complete(tid, Status::PARSE_ERROR, emit);
2281                    None
2282                }
2283            },
2284            Some(Cmd::PropInsert) => {
2285                match PropPayload::parse(received.payload) {
2286                    Ok(payload) => self.prop_insert(tid, payload.key, payload.value, emit),
2287                    Err(_) => self.complete(tid, Status::PARSE_ERROR, emit),
2288                }
2289                None
2290            }
2291            Some(Cmd::PropRemove) => {
2292                match PropPayload::parse(received.payload) {
2293                    Ok(payload) => self.prop_remove(tid, payload.key, payload.value, emit),
2294                    Err(_) => self.complete(tid, Status::PARSE_ERROR, emit),
2295                }
2296                None
2297            }
2298            // Deliver queued inbound frames. The payload MUST be
2299            // ignored. The drain covers exactly the frames queued now;
2300            // an empty queue succeeds immediately.
2301            Some(Cmd::QueueDrain) => {
2302                if self.session.drain.is_some() {
2303                    self.complete(tid, Status::BUSY, emit);
2304                    return None;
2305                }
2306                if self.host.queue.len == 0 {
2307                    self.complete(tid, Status::OK, emit);
2308                    return None;
2309                }
2310                self.session.drain = Some(DrainState {
2311                    tid,
2312                    remaining: self.host.queue.len,
2313                });
2314                Some(Effect::DrainQueue)
2315            }
2316            // Atomically persist the current device domain. The payload
2317            // MUST be ignored; success is reported only after the
2318            // durable write commits (respond_save).
2319            Some(Cmd::Save) => Some(Effect::SaveSnapshot { tid }),
2320            // Revert device-domain configuration to the saved snapshot,
2321            // reported in the spec's reset form: session state resets
2322            // and an unsolicited STATUS_RESET_RESTORED announces
2323            // completion (the TID is ignored, as with CMD_RST).
2324            //
2325            // The host domain is untouched — it is not in the snapshot,
2326            // so there is nothing to revert it to and no host-key
2327            // special case to apply. Queue contents and replay baselines
2328            // therefore survive a restore unconditionally.
2329            Some(Cmd::Restore) => {
2330                if self.saved.is_none() {
2331                    self.complete(tid, Status::INVALID_STATE, emit);
2332                    return None;
2333                }
2334                self.apply_saved_device();
2335                self.session = SessionState::default();
2336                self.send_status(TID_UNSOLICITED, Status::RESET_RESTORED, emit);
2337                Some(self.apply_radio())
2338            }
2339            // Erase all persisted provisioning. Live state, BLE bonds,
2340            // and the pairing PIN are unaffected; a subsequent CMD_RST
2341            // completes a factory reset. Base-protocol: succeeds even
2342            // with nothing saved (the erase is idempotent).
2343            Some(Cmd::Clear) => Some(Effect::ClearSaved { tid }),
2344            // Erase EVERY piece of mutable state — saved provisioning,
2345            // device identity, BLE bonds, pairing PIN, and any other
2346            // persisted journal — then reboot. Unlike CMD_CLEAR this is
2347            // not confined to the durable provisioning copy and does not
2348            // reply: the platform wipes storage and resets, so the link
2349            // drops. The TID is irrelevant (no response is sent).
2350            Some(Cmd::FactoryReset) => Some(Effect::FactoryReset),
2351            // device-to-host commands arriving from the host.
2352            Some(Cmd::PropIs | Cmd::StrRecv | Cmd::PropInserted | Cmd::PropRemoved) => {
2353                self.complete(tid, Status::INVALID_COMMAND, emit);
2354                None
2355            }
2356            None => {
2357                self.complete(tid, Status::INVALID_COMMAND, emit);
2358                None
2359            }
2360        }
2361    }
2362
2363    /// Report a frame received on air at `now_ms`. While a host is
2364    /// attached, accepted frames are emitted live as `CMD_STR_RECV`
2365    /// (promiscuous mode bypasses filtering for live delivery only);
2366    /// while detached, accepted frames are placed in the inbound queue,
2367    /// authenticated duplicates coalesce, and a qualifying frame may
2368    /// produce a delegated-acknowledgement transmit effect. Ignored
2369    /// while the PHY is disabled or the frame exceeds the MTU (an
2370    /// unstorable frame is never acknowledged).
2371    pub fn on_radio_rx(
2372        &mut self,
2373        data: &[u8],
2374        rssi_dbm: i16,
2375        snr_cb: i16,
2376        lqi: Option<core::num::NonZeroU8>,
2377        now_ms: u64,
2378        emit: &mut impl FnMut(&[u8]),
2379    ) -> Option<Effect> {
2380        if !self.device.settings.enabled || data.len() > usize::from(self.config.mtu) {
2381            return None;
2382        }
2383        if !self.attached {
2384            if !self.host.accepts_frame(data) {
2385                return None;
2386            }
2387            return match self.evaluate_detached_rx(data, now_ms) {
2388                SecureRx::Duplicate { ack, identity } => {
2389                    // Coalesced with the existing queue entry. A
2390                    // confirmed re-ack marks the original entry, which
2391                    // may still be queued unacked from a failed or
2392                    // refused earlier attempt.
2393                    let original =
2394                        identity.and_then(|identity| self.host.queue.seq_for_identity(&identity));
2395                    ack.and_then(|plan| self.stage_ack(plan, original, now_ms))
2396                }
2397                verdict => {
2398                    let (ack, identity) = match verdict {
2399                        SecureRx::New { ack, identity } => (ack, identity),
2400                        _ => (None, None),
2401                    };
2402                    // Entries start unacknowledged: RX_FLAG_ACKED is
2403                    // earned only when the ack transmission actually
2404                    // completes (on_tx_result). A refused or failed ack
2405                    // leaves the frame queued unacked and the sender's
2406                    // retransmission hits the re-ack window later.
2407                    let seq = self
2408                        .host
2409                        .queue
2410                        .push(data, rssi_dbm, snr_cb, lqi, now_ms, identity);
2411                    ack.and_then(|plan| self.stage_ack(plan, Some(seq), now_ms))
2412                }
2413            };
2414        }
2415        if !self.session.promiscuous && !self.host.accepts_live_frame(data) {
2416            return None;
2417        }
2418        let mut rx_meta = [0u8; RxMeta::WIRE_LEN];
2419        let meta_len = RxMeta {
2420            rssi_dbm: Some(rssi_dbm),
2421            lqi,
2422            snr_cb: Some(snr_cb),
2423        }
2424        .encode(&mut rx_meta)
2425        .expect("buffer sized with WIRE_LEN");
2426        if let Ok(len) = frame::str_recv(
2427            &mut self.scratch,
2428            stream::PHY_RAW,
2429            data,
2430            &rx_meta[..meta_len],
2431        ) {
2432            emit(&self.scratch[..len]);
2433        }
2434        None
2435    }
2436
2437    /// Authenticate a detached received frame against the provisioned
2438    /// host keys and update the source peer's replay window. Crypto
2439    /// runs on a scratch copy: the queue always holds the original wire
2440    /// bytes, exactly as the host would have received them live.
2441    fn evaluate_detached_rx(&mut self, data: &[u8], now_ms: u64) -> SecureRx {
2442        let Ok(header) = PacketHeader::parse(data) else {
2443            return SecureRx::Plain;
2444        };
2445        let Some(host_key) = &self.host.key else {
2446            return SecureRx::Plain;
2447        };
2448        let host_hint = NodeHint([host_key[0], host_key[1], host_key[2]]);
2449        let packet_type = header.fcf.packet_type();
2450        let wants_ack = packet_type.ack_requested();
2451
2452        let scratch = &mut self.scratch[..data.len()];
2453        scratch.copy_from_slice(data);
2454
2455        // Establish the frame's keys, destination, and source peer.
2456        let (keys, peer_index) = match packet_type {
2457            PacketType::Unicast | PacketType::UnicastAckReq => {
2458                if header.dst != Some(host_hint) {
2459                    return SecureRx::Plain;
2460                }
2461                let Some(index) = self.host.peer_keys.resolve_source(&header.source, data) else {
2462                    return SecureRx::Plain;
2463                };
2464                let entry = &self.host.peer_keys.entries[index]
2465                    .as_ref()
2466                    .expect("resolved index is populated")
2467                    .entry;
2468                (
2469                    PairwiseKeys {
2470                        k_enc: entry.k_enc,
2471                        k_mic: entry.k_mic,
2472                    },
2473                    index,
2474                )
2475            }
2476            PacketType::BlindUnicast | PacketType::BlindUnicastAckReq => {
2477                // BUAR/BUNI require the channel key both to reveal the
2478                // concealed addressing and to form the combined blind
2479                // payload keys.
2480                let Some(channel) = header.channel else {
2481                    return SecureRx::Plain;
2482                };
2483                let Some(channel_key) = self
2484                    .host
2485                    .channel_keys
2486                    .iter()
2487                    .find(|candidate| candidate.id == channel.0)
2488                    .map(|candidate| candidate.key)
2489                else {
2490                    return SecureRx::Plain;
2491                };
2492                let channel_keys = self.engine.derive_channel_keys(&ChannelKey(channel_key));
2493                let Ok((dst, source)) =
2494                    self.engine
2495                        .decrypt_blind_addr(scratch, &header, &channel_keys)
2496                else {
2497                    return SecureRx::Plain;
2498                };
2499                if dst != host_hint {
2500                    return SecureRx::Plain;
2501                }
2502                // The decrypted address block lives in the scratch copy.
2503                let Some(index) = self.host.peer_keys.resolve_source(&source, scratch) else {
2504                    return SecureRx::Plain;
2505                };
2506                let entry = &self.host.peer_keys.entries[index]
2507                    .as_ref()
2508                    .expect("resolved index is populated")
2509                    .entry;
2510                let pairwise = PairwiseKeys {
2511                    k_enc: entry.k_enc,
2512                    k_mic: entry.k_mic,
2513                };
2514                (
2515                    self.engine.derive_blind_keys(&pairwise, &channel_keys),
2516                    index,
2517                )
2518            }
2519            // Multicast the device holds the channel key for is
2520            // authenticated for queue-local duplicate coalescing only:
2521            // no per-sender counter state is retained and no ack is
2522            // ever delegated (multicast never requests one). Broadcast
2523            // and MAC acks carry no counter at all.
2524            PacketType::Multicast => {
2525                let Some(channel) = header.channel else {
2526                    return SecureRx::Plain;
2527                };
2528                let Some(channel_key) = self
2529                    .host
2530                    .channel_keys
2531                    .iter()
2532                    .find(|candidate| candidate.id == channel.0)
2533                    .map(|candidate| candidate.key)
2534                else {
2535                    return SecureRx::Plain;
2536                };
2537                let derived = self.engine.derive_channel_keys(&ChannelKey(channel_key));
2538                let channel_pairwise = PairwiseKeys {
2539                    k_enc: derived.k_enc,
2540                    k_mic: derived.k_mic,
2541                };
2542                if self
2543                    .engine
2544                    .open_packet(scratch, &header, &channel_pairwise)
2545                    .is_err()
2546                {
2547                    return SecureRx::Plain;
2548                }
2549                let Some(sec_info) = header.sec_info else {
2550                    return SecureRx::Plain;
2551                };
2552                let identity =
2553                    RxIdentity::new(sec_info.frame_counter, &data[header.mic_range.clone()]);
2554                let Some(identity) = identity else {
2555                    return SecureRx::Plain;
2556                };
2557                // A Route Retry form preserves the MIC and counter, so
2558                // it matches the original entry while that entry is
2559                // still queued; once drained or evicted, no replay
2560                // state is retained for multicast.
2561                return if self.host.queue.seq_for_identity(&identity).is_some() {
2562                    SecureRx::Duplicate {
2563                        ack: None,
2564                        identity: Some(identity),
2565                    }
2566                } else {
2567                    SecureRx::New {
2568                        ack: None,
2569                        identity: Some(identity),
2570                    }
2571                };
2572            }
2573            _ => return SecureRx::Plain,
2574        };
2575
2576        // Authenticate (and decrypt, in the scratch copy).
2577        let Ok(body_range) = self.engine.open_packet(scratch, &header, &keys) else {
2578            return SecureRx::Plain;
2579        };
2580        let Some(sec_info) = header.sec_info else {
2581            return SecureRx::Plain;
2582        };
2583        let counter = sec_info.frame_counter;
2584        let mic = &data[header.mic_range.clone()];
2585
2586        // The ack tag covers the plaintext body: recompute the full
2587        // CMAC over the decrypted scratch copy (spec §Ack Tag
2588        // Construction).
2589        let plan = wants_ack.then(|| {
2590            let mut cmac = self.engine.cmac_state(&keys.k_mic);
2591            umsh_core::feed_aad(&header, scratch, |chunk| cmac.update(chunk));
2592            cmac.update(&scratch[body_range.clone()]);
2593            let full_mac = cmac.finalize();
2594            AckPlan {
2595                trailer: self.engine.compute_ack_trailer(&full_mac, &keys.k_enc),
2596                // Flooded traffic gets a flood-return ack seeded from
2597                // the received frame's accumulated hop count, exactly
2598                // as the MAC routes acks from its learned flood routes.
2599                // A duplicate's plan uses the retransmission's own
2600                // routing state.
2601                flood_hops: header.flood_hops.map(|hops| hops.accumulated()),
2602            }
2603        });
2604        let identity = RxIdentity::new(counter, mic);
2605
2606        let window = &mut self.host.peer_keys.entries[peer_index]
2607            .as_mut()
2608            .expect("resolved index is populated")
2609            .window;
2610        match window.check(counter, mic, now_ms) {
2611            ReplayVerdict::Accept => {
2612                window.accept(counter, mic, now_ms);
2613                SecureRx::New {
2614                    ack: plan,
2615                    identity,
2616                }
2617            }
2618            ReplayVerdict::Replay => {
2619                // Same logical packet (Route Retry forms included: same
2620                // MIC and counter): coalesce, and re-ack only within
2621                // the idempotent duplicate-acknowledgement window.
2622                let ack = window
2623                    .is_acknowledgeable_duplicate(counter, mic, now_ms)
2624                    .then_some(())
2625                    .and(plan);
2626                SecureRx::Duplicate { ack, identity }
2627            }
2628            // A suspected replay outside the window is not identified
2629            // as a previously accepted frame; it is queued unacked and
2630            // never acknowledged (spec: MUST NOT ack farther behind).
2631            ReplayVerdict::OutOfWindow | ReplayVerdict::Stale => SecureRx::Plain,
2632        }
2633    }
2634
2635    /// Transmit a delegated MAC acknowledgement through the ordinary
2636    /// serialized radio path, subject to `PROP_HOST_AUTO_ACK`, the
2637    /// single-transmit radio path, and the duty limiter. Returns the
2638    /// transmit effect, or `None` when any gate refuses (the frame then
2639    /// simply remains unacknowledged). `ack_for` names the queue entry
2640    /// that earns `RX_FLAG_ACKED` when the transmission completes.
2641    fn stage_ack(&mut self, plan: AckPlan, ack_for: Option<u16>, now_ms: u64) -> Option<Effect> {
2642        if !self.host.auto_ack || !self.session.pending.is_empty() {
2643            return None;
2644        }
2645        let mut buf = [0u8; 24];
2646        let mut builder = PacketBuilder::new(&mut buf).mac_ack(plan.trailer);
2647        if let Some(hops) = plan.flood_hops {
2648            // Mirror the MAC's flood-return acks: seed the remaining
2649            // hops from the acknowledged frame's accumulated count,
2650            // clamped to a valid non-zero radius.
2651            builder = builder.flood_hops(hops.clamp(1, 15));
2652        }
2653        let frame_len = builder.build().ok()?.len();
2654        let airtime_ms = lora_airtime_ms(
2655            self.device.settings.sf,
2656            self.device.settings.bw_hz,
2657            self.device.settings.cr_denom,
2658            frame_len,
2659        );
2660        if self.config.duty.would_exceed(now_ms, airtime_ms) {
2661            return None;
2662        }
2663        let mut data = HeaplessVec::new();
2664        data.extend_from_slice(&buf[..frame_len]).ok()?;
2665        self.session
2666            .pending
2667            .push_back(PendingTx {
2668                data,
2669                tid: TID_UNSOLICITED,
2670                airtime_ms,
2671                power: TxPower::Default,
2672                autonomous: true,
2673                ack_for,
2674                // Delegated acks are immediate MAC acks: the channel was
2675                // clear when the acknowledged frame ended, and the ACK
2676                // protection interval reserves this window for them
2677                // (channel-access.md § Immediate ACK Transmission).
2678                nocca: true,
2679            })
2680            .ok()?;
2681        Some(Effect::StartTransmit)
2682    }
2683
2684    /// Report completion of the transmit started by
2685    /// [`Effect::StartTransmit`].
2686    pub fn on_tx_result(
2687        &mut self,
2688        outcome: TxOutcome,
2689        now_ms: u64,
2690        emit: &mut impl FnMut(&[u8]),
2691    ) -> Option<Effect> {
2692        let Some(pending) = self.session.pending.pop_front() else {
2693            return None;
2694        };
2695        match outcome {
2696            TxOutcome::Sent => {
2697                self.config.duty.record(now_ms, pending.airtime_ms);
2698                if pending.autonomous {
2699                    // device-initiated: PROP_LAST_STATUS is left alone so a
2700                    // pending reset code still reaches the next host. Only
2701                    // now — with the ack actually on the air — does the
2702                    // acknowledged frame earn RX_FLAG_ACKED. A handle whose
2703                    // entry has since been drained, evicted, or discarded
2704                    // marks nothing.
2705                    if let Some(seq) = pending.ack_for {
2706                        self.host.queue.mark_acked(seq);
2707                    }
2708                } else {
2709                    self.complete(pending.tid, Status::OK, emit);
2710                }
2711            }
2712            // The frame never left the radio: no duty accounting, no
2713            // RX_FLAG_ACKED. Hosts learn CCA refusals distinctly so they
2714            // can apply their own backoff (spec § STATUS_CCA_FAILURE).
2715            TxOutcome::ChannelBusy if !pending.autonomous => {
2716                self.complete(pending.tid, Status::CCA_FAILURE, emit);
2717            }
2718            TxOutcome::Failed if !pending.autonomous => {
2719                self.complete(pending.tid, Status::FAILURE, emit);
2720            }
2721            TxOutcome::ChannelBusy | TxOutcome::Failed => {}
2722        }
2723        (!self.session.pending.is_empty()).then_some(Effect::StartTransmit)
2724    }
2725
2726    // ─── Command implementations ─────────────────────────────────────
2727
2728    fn prop_get(
2729        &mut self,
2730        tid: u8,
2731        key: u32,
2732        now_ms: u64,
2733        emit: &mut impl FnMut(&[u8]),
2734    ) -> Option<Effect> {
2735        // PROP_PHY_RSSI is an instantaneous radio reading the session cannot
2736        // produce on its own. While the PHY is enabled (in RX), defer to the
2737        // caller to sample it; while disabled there is no ambient RSSI to read.
2738        //
2739        // The write-only properties must not disclose their values —
2740        // for the device private key, not even whether one is
2741        // configured (spec §PROP_DEV_PRIVATE_KEY).
2742        if key == prop::BLE_PAIRING_PIN || key == prop::DEV_PRIVATE_KEY {
2743            self.complete(tid, Status::UNIMPLEMENTED, emit);
2744            return None;
2745        }
2746        if key == prop::PHY_RSSI {
2747            if self.device.settings.enabled {
2748                return Some(Effect::SampleRssi { tid });
2749            }
2750            self.complete(tid, Status::INVALID_STATE, emit);
2751            return None;
2752        }
2753        // PROP_IDENT is a signature over the current identity, and the
2754        // session holds no signing key. Defer to the platform, which
2755        // builds the canonical payload from the same fields the Identity
2756        // Request responder advertises and signs it with the device
2757        // identity. Deliberately not cached: caching would impose
2758        // coherence work across the device key, role, mobility,
2759        // forwarding state, name, and every identity field added later,
2760        // to save a signature nobody reads in a loop.
2761        if key == prop::IDENT {
2762            return Some(Effect::SignIdentity { tid });
2763        }
2764        // PROP_BATTERY is a measurement, not stored state: when any field
2765        // is reported, defer to the platform's battery source so the
2766        // response reflects a sample taken now. With no reported fields
2767        // the empty (unsupported-reporting) value needs no sampling.
2768        if key == prop::BATTERY
2769            && let Some(fields) = self.config.battery
2770        {
2771            if fields.any() {
2772                return Some(Effect::SampleBattery { tid });
2773            }
2774            self.send_prop_is(tid, prop::BATTERY, &[], emit);
2775            return None;
2776        }
2777        // The wall clock belongs to the platform: only it knows whether
2778        // the clock has been set and how far it has run since. Deferring
2779        // is also what keeps "we do not know what time it is" honest —
2780        // the session has nothing to answer with, rather than a stale
2781        // reading it would have to decide the age of.
2782        if key == prop::TIME && self.config.time.is_some() {
2783            return Some(Effect::ReadTime { tid });
2784        }
2785        // Positioning telemetry is a measurement, not stored state.
2786        if gnss::is_positioning_property(key) && self.config.gnss.is_some() {
2787            return Some(Effect::SampleGnss { tid, key });
2788        }
2789        // Ambient light, likewise: the sensor is read on demand and the
2790        // session caches nothing.
2791        if key == prop::ILLUMINANCE && self.config.illuminance {
2792            return Some(Effect::SampleIlluminance { tid });
2793        }
2794        let mut value = [0u8; PROP_BUF];
2795        match self.encode_prop(key, now_ms, &mut value) {
2796            PropValue::Encoded(len) => self.send_prop_is(tid, key, &value[..len], emit),
2797            PropValue::Unimplemented => self.complete(tid, Status::UNIMPLEMENTED, emit),
2798            PropValue::Unknown => self.complete(tid, Status::PROP_NOT_FOUND, emit),
2799        }
2800        None
2801    }
2802
2803    /// Complete a deferred `PROP_PHY_RSSI` read requested via
2804    /// [`Effect::SampleRssi`]. `rssi` is the sampled value in dBm, or `Err` if
2805    /// the radio read failed. Quote the same `tid` the effect carried.
2806    pub fn respond_rssi(&mut self, tid: u8, rssi: Result<i16, ()>, emit: &mut impl FnMut(&[u8])) {
2807        match rssi {
2808            Ok(dbm) => {
2809                let clamped = dbm.clamp(i16::from(i8::MIN), i16::from(i8::MAX)) as i8;
2810                self.send_prop_is(tid, prop::PHY_RSSI, &[clamped as u8], emit);
2811            }
2812            Err(()) => self.complete(tid, Status::FAILURE, emit),
2813        }
2814    }
2815
2816    /// Complete a deferred `PROP_IDENT` read requested via
2817    /// [`Effect::SignIdentity`]. `blob` is the complete signed
2818    /// node-identity payload — the canonical unsigned encoding followed
2819    /// by its 64-octet detached signature — or `Err` if it could not be
2820    /// produced. Quote the same `tid` the effect carried.
2821    pub fn respond_identity_blob(
2822        &mut self,
2823        tid: u8,
2824        blob: Result<&[u8], ()>,
2825        emit: &mut impl FnMut(&[u8]),
2826    ) {
2827        match blob {
2828            Ok(bytes) => self.send_prop_is(tid, prop::IDENT, bytes, emit),
2829            Err(()) => self.complete(tid, Status::FAILURE, emit),
2830        }
2831    }
2832
2833    /// `PROP_IDENT_ROLE`: the advertised `ROLE` byte, or `None` to let
2834    /// the device derive it from its live forwarding state.
2835    pub fn ident_role(&self) -> Option<u8> {
2836        self.device.ident_role
2837    }
2838
2839    /// `PROP_IDENT_MOBILE`: whether the device identity advertises the
2840    /// `MOB` capability bit.
2841    pub fn ident_mobile(&self) -> bool {
2842        self.device.ident_mobile
2843    }
2844
2845    /// `PROP_DEV_DISCOVERABLE`: whether the device identity answers
2846    /// Identity Requests.
2847    pub fn dev_discoverable(&self) -> bool {
2848        self.device.dev_discoverable
2849    }
2850
2851    /// `PROP_ADVERT_INTERVAL`: seconds between unsolicited advertisements,
2852    /// 0 for none.
2853    pub fn advert_interval_s(&self) -> u32 {
2854        self.device.advert_interval_s
2855    }
2856
2857    /// `PROP_BEACON_INTERVAL`: seconds between unsolicited beacons, 0 for
2858    /// none.
2859    pub fn beacon_interval_s(&self) -> u32 {
2860        self.device.beacon_interval_s
2861    }
2862
2863    /// `PROP_STARTUP_BEACON`: whether one beacon goes out at bring-up.
2864    pub fn startup_beacon(&self) -> bool {
2865        self.device.startup_beacon
2866    }
2867
2868    /// `PROP_TZ_OFFSET`: minutes east of UTC.
2869    pub fn tz_offset_min(&self) -> i16 {
2870        self.device.tz_offset_min
2871    }
2872
2873    /// `PROP_GNSS_ENABLED`: whether the receiver should be powered.
2874    ///
2875    /// Always false on a board without `CAP_GNSS`, so a platform can act
2876    /// on it without first asking whether it has a receiver.
2877    pub fn gnss_enabled(&self) -> bool {
2878        self.config.gnss.is_some() && self.device.gnss_enabled
2879    }
2880
2881    /// `PROP_GNSS_IDENT_UPDATE`: whether fixes refresh the advertised
2882    /// node identity's location.
2883    pub fn gnss_ident_update(&self) -> bool {
2884        self.config.gnss.is_some() && self.device.gnss_ident_update
2885    }
2886
2887    /// `PROP_GNSS_IDENT_PRECISION`: the precision the advertised location
2888    /// is clamped to.
2889    pub fn gnss_ident_precision(&self) -> u8 {
2890        self.device.gnss_ident_precision
2891    }
2892
2893    /// `PROP_GNSS_TIME_TRUST`: whether receiver-derived time may set the
2894    /// wall clock.
2895    pub fn gnss_time_trust(&self) -> bool {
2896        self.device.gnss_time_trust
2897    }
2898
2899    /// `PROP_ALERT`: what the device is currently doing to draw
2900    /// attention to itself.
2901    pub fn alert(&self) -> AlertState {
2902        self.alert
2903    }
2904
2905    /// When the running alert gives itself up, as a monotonic
2906    /// millisecond deadline on the caller's clock, or `None` when no
2907    /// alert is running.
2908    ///
2909    /// The driver arms a timer on this and calls [`Session::poll_alert`]
2910    /// when it fires. Enforcing the bound centrally is what keeps the
2911    /// spec's "a device **MUST** bound how long it will remain in
2912    /// `ALERT_LOCATE`" from being a promise each board has to remember
2913    /// to keep.
2914    pub fn alert_deadline_ms(&self) -> Option<u64> {
2915        self.alert_deadline_ms
2916    }
2917
2918    /// Expire a running alert whose deadline has passed, returning the
2919    /// effect that stops the board's indication.
2920    ///
2921    /// Safe to call at any time: it does nothing until the deadline is
2922    /// actually reached, so a driver that polls it on every loop
2923    /// iteration behaves identically to one that arms a precise timer.
2924    pub fn poll_alert(&mut self, now_ms: u64, emit: &mut impl FnMut(&[u8])) -> Option<Effect> {
2925        match self.alert_deadline_ms {
2926            Some(deadline) if now_ms >= deadline => self.clear_alert(emit),
2927            _ => None,
2928        }
2929    }
2930
2931    /// Cancel a running alert from the device itself — the button press
2932    /// of whoever found the radio.
2933    ///
2934    /// Returns the effect that stops the indication, or `None` when no
2935    /// alert was running (so a board can use the return to decide
2936    /// whether the press was consumed).
2937    pub fn cancel_alert(&mut self, emit: &mut impl FnMut(&[u8])) -> Option<Effect> {
2938        self.clear_alert(emit)
2939    }
2940
2941    /// Flip `PROP_GNSS_ENABLED` from the device itself — a button on a
2942    /// board that offers the receiver as a user-facing switch.
2943    ///
2944    /// Returns the new state, or `None` on a device without `CAP_GNSS`
2945    /// (so a board can report a press unconditionally). No effect is
2946    /// returned: the switch reaches the platform through the
2947    /// device-domain mirror, the same path a host write, a boot restore
2948    /// and a `CMD_RST` all take.
2949    ///
2950    /// The transition is announced like any the host did not command.
2951    /// `PROP_GNSS_ENABLED` is not otherwise an asynchronous property —
2952    /// nothing else moves it behind the host's back — but a switch the
2953    /// operator can reach is exactly a thing that does.
2954    pub fn toggle_gnss(&mut self, emit: &mut impl FnMut(&[u8])) -> Option<bool> {
2955        if self.config.gnss.is_none() {
2956            return None;
2957        }
2958        let enabled = !self.device.gnss_enabled;
2959        self.device.gnss_enabled = enabled;
2960        self.bump_dev_domain();
2961        if self.attached {
2962            self.announce_prop_is(prop::GNSS_ENABLED, &[enabled as u8], emit);
2963        }
2964        Some(enabled)
2965    }
2966
2967    /// Return to `ALERT_NONE` for a reason the host did not command,
2968    /// announcing it with an unsolicited `CMD_PROP_IS`.
2969    fn clear_alert(&mut self, emit: &mut impl FnMut(&[u8])) -> Option<Effect> {
2970        if !self.alert.is_active() {
2971            return None;
2972        }
2973        self.alert = AlertState::None;
2974        self.alert_deadline_ms = None;
2975        // Every transition the host did not ask for is published; a host
2976        // that is not attached simply reads the current value when it
2977        // comes back.
2978        if self.attached {
2979            let mut value = [0u8; pui::MAX_LEN];
2980            let len = pui::encode(AlertState::None.code(), &mut value).unwrap_or(0);
2981            self.announce_prop_is(prop::ALERT, &value[..len], emit);
2982        }
2983        Some(Effect::ApplyAlert(AlertState::None))
2984    }
2985
2986    /// Publish an unsolicited `PROP_BATTERY` snapshot (spec
2987    /// §PROP_BATTERY, *Asynchronous Updates: Yes*).
2988    ///
2989    /// The platform decides *when* a measurement is worth announcing —
2990    /// it owns the sampling cadence and the charge-state edges, and it
2991    /// is the only layer that sees every sample. This publishes what it
2992    /// hands over, so the session keeps its rule that it never caches a
2993    /// reading: nothing here can answer a later `CMD_PROP_GET`.
2994    ///
2995    /// Returns whether a frame was emitted. Nothing is published while
2996    /// no host is attached (there is nobody to notify), and a snapshot
2997    /// populating a field the configured [`BatteryFields`] never claimed
2998    /// is dropped rather than sent — an unsolicited notification has no
2999    /// transaction to fail. A snapshot that merely omits an advertised
3000    /// field is published as-is: absence is how the device says the value
3001    /// is not knowable right now.
3002    pub fn publish_battery(&mut self, sample: BatteryStatus, emit: &mut impl FnMut(&[u8])) -> bool {
3003        if !self.attached {
3004            return false;
3005        }
3006        let Some(fields) = self.config.battery else {
3007            return false;
3008        };
3009        if !fields.matches(&sample) {
3010            return false;
3011        }
3012        let mut value = [0u8; battery::MAX_ENCODED_LEN];
3013        let Ok(len) = sample.encode(&mut value) else {
3014            return false;
3015        };
3016        self.announce_prop_is(prop::BATTERY, &value[..len], emit)
3017    }
3018
3019    /// Complete a deferred `PROP_BATTERY` read requested via
3020    /// [`Effect::SampleBattery`]. `sample` is the platform's snapshot, or
3021    /// `Err` if the measurement failed. Quote the same `tid` the effect
3022    /// carried.
3023    ///
3024    /// A snapshot populating a field the configured [`BatteryFields`]
3025    /// never claimed is refused as `STATUS_FAILURE`; one that omits an
3026    /// advertised field is answered as-is, since a field the platform
3027    /// cannot currently substantiate is reported by its absence.
3028    pub fn respond_battery(
3029        &mut self,
3030        tid: u8,
3031        sample: Result<BatteryStatus, ()>,
3032        emit: &mut impl FnMut(&[u8]),
3033    ) {
3034        let fields = self.config.battery.unwrap_or_default();
3035        match sample {
3036            Ok(snapshot) if fields.matches(&snapshot) => {
3037                let mut value = [0u8; battery::MAX_ENCODED_LEN];
3038                match snapshot.encode(&mut value) {
3039                    Ok(len) => self.send_prop_is(tid, prop::BATTERY, &value[..len], emit),
3040                    Err(_) => self.complete(tid, Status::FAILURE, emit),
3041                }
3042            }
3043            Ok(_) | Err(()) => self.complete(tid, Status::FAILURE, emit),
3044        }
3045    }
3046
3047    /// Complete a deferred `PROP_ILLUMINANCE` read requested via
3048    /// [`Effect::SampleIlluminance`]. `millilux` is the measurement, or
3049    /// `None` when the sensor could not be read. Quote the same `tid` the
3050    /// effect carried.
3051    ///
3052    /// A failed read is the empty value rather than an error status: the
3053    /// property is a measurement, and "no reading right now" is the same
3054    /// answer `PROP_TIME` gives for a clock that has never been set.
3055    pub fn respond_illuminance(
3056        &mut self,
3057        tid: u8,
3058        millilux: Option<u32>,
3059        emit: &mut impl FnMut(&[u8]),
3060    ) {
3061        match millilux {
3062            Some(value) => {
3063                self.send_prop_is(tid, prop::ILLUMINANCE, &value.to_le_bytes(), emit);
3064            }
3065            None => self.send_prop_is(tid, prop::ILLUMINANCE, &[], emit),
3066        }
3067    }
3068
3069    /// Complete a deferred `PROP_TIME` read requested via
3070    /// [`Effect::ReadTime`]. `epoch` is the platform's wall clock in Unix
3071    /// seconds, or `None` when the device does not know what time it is.
3072    /// Quote the same `tid` the effect carried.
3073    ///
3074    /// Not knowing is a legitimate answer, not a failure: it is reported
3075    /// as the empty value, which is precisely what tells a host — and a
3076    /// device's own display — that there is no clock to show.
3077    pub fn respond_time(&mut self, tid: u8, epoch: Option<u32>, emit: &mut impl FnMut(&[u8])) {
3078        match epoch {
3079            Some(seconds) => self.send_prop_is(tid, prop::TIME, &seconds.to_le_bytes(), emit),
3080            None => self.send_prop_is(tid, prop::TIME, &[], emit),
3081        }
3082    }
3083
3084    /// Publish an unsolicited `PROP_TIME` (spec §PROP_TIME,
3085    /// *Asynchronous Updates: Yes*).
3086    ///
3087    /// The platform decides what is worth announcing — it owns the clock
3088    /// and is the only layer that sees every source that touches it. A
3089    /// clock going from unknown to known is the announcement that matters
3090    /// most; a fresh fix agreeing with the clock to the second is not.
3091    ///
3092    /// Returns whether a frame was emitted; nothing is published while no
3093    /// host is attached.
3094    pub fn publish_time(&mut self, epoch: Option<u32>, emit: &mut impl FnMut(&[u8])) -> bool {
3095        if !self.attached || self.config.time.is_none() {
3096            return false;
3097        }
3098        match epoch {
3099            Some(seconds) => self.announce_prop_is(prop::TIME, &seconds.to_le_bytes(), emit),
3100            None => self.announce_prop_is(prop::TIME, &[], emit),
3101        }
3102    }
3103
3104    /// Complete a deferred positioning read requested via
3105    /// [`Effect::SampleGnss`]. `sample` is the receiver's current view, or
3106    /// `Err` if it could not be obtained. Quote the same `tid` and `key`
3107    /// the effect carried.
3108    ///
3109    /// A receiver that is off or still searching is not a failure — it
3110    /// reports [`GnssSnapshot::SEARCHING`], which answers zero for the
3111    /// facts it is sure of and empty for the position it does not have.
3112    pub fn respond_gnss(
3113        &mut self,
3114        tid: u8,
3115        key: u32,
3116        sample: Result<GnssSnapshot, ()>,
3117        emit: &mut impl FnMut(&[u8]),
3118    ) {
3119        let mut value = [0u8; gnss::MAX_VALUE_LEN];
3120        match sample.and_then(|snapshot| snapshot.encode(key, &mut value).map_err(|_| ())) {
3121            Ok(len) => self.send_prop_is(tid, key, &value[..len], emit),
3122            Err(()) => self.complete(tid, Status::FAILURE, emit),
3123        }
3124    }
3125
3126    /// Publish one positioning property as an unsolicited `CMD_PROP_IS`
3127    /// (spec §PROP_GNSS_LOCATION / §PROP_GNSS_FIX, *Asynchronous Updates:
3128    /// Yes*).
3129    ///
3130    /// The platform decides the cadence, as it does for `PROP_BATTERY`:
3131    /// it sees every sentence the receiver produces and is the only layer
3132    /// that can tell a meaningful change from a jittering last digit.
3133    ///
3134    /// Returns whether a frame was emitted. `key` must be a positioning
3135    /// property; anything else, and any publication while no host is
3136    /// attached, is dropped.
3137    pub fn publish_gnss(
3138        &mut self,
3139        key: u32,
3140        snapshot: &GnssSnapshot,
3141        emit: &mut impl FnMut(&[u8]),
3142    ) -> bool {
3143        if !self.attached || self.config.gnss.is_none() {
3144            return false;
3145        }
3146        let mut value = [0u8; gnss::MAX_VALUE_LEN];
3147        let Ok(len) = snapshot.encode(key, &mut value) else {
3148            return false;
3149        };
3150        self.announce_prop_is(key, &value[..len], emit)
3151    }
3152
3153    /// Advance the drain started by [`Effect::DrainQueue`] one step,
3154    /// emitting either the next covered frame (oldest first, as
3155    /// `CMD_STR_RECV` with buffered metadata) or, once the covered set
3156    /// is exhausted, the completion status. Returns `true` while
3157    /// another call is needed; flush the transport between calls.
3158    pub fn drain_step(&mut self, now_ms: u64, emit: &mut impl FnMut(&[u8])) -> bool {
3159        let Some(drain) = &mut self.session.drain else {
3160            return false;
3161        };
3162        if drain.remaining == 0 {
3163            let tid = drain.tid;
3164            self.session.drain = None;
3165            self.complete(tid, Status::OK, emit);
3166            return false;
3167        }
3168        drain.remaining -= 1;
3169        let Some(entry) = self.host.queue.pop_front() else {
3170            // The covered set outliving the queue means state was reset
3171            // mid-drain; complete rather than stall.
3172            let tid = drain.tid;
3173            self.session.drain = None;
3174            self.complete(tid, Status::OK, emit);
3175            return false;
3176        };
3177        let mut rx_meta = [0u8; BufferedRxMeta::WIRE_LEN];
3178        let meta_len = BufferedRxMeta {
3179            rx: RxMeta {
3180                rssi_dbm: Some(entry.rssi_dbm),
3181                lqi: entry.lqi,
3182                snr_cb: Some(entry.snr_cb),
3183            },
3184            flags: RX_FLAG_BUFFERED | if entry.acked { RX_FLAG_ACKED } else { 0 },
3185            age_s: u32::try_from(now_ms.saturating_sub(entry.rx_time_ms) / 1000)
3186                .unwrap_or(u32::MAX),
3187        }
3188        .encode(&mut rx_meta)
3189        .expect("buffer sized with WIRE_LEN");
3190        if let Ok(len) = frame::str_recv(
3191            &mut self.scratch,
3192            stream::PHY_RAW,
3193            entry.frame(),
3194            &rx_meta[..meta_len],
3195        ) {
3196            emit(&self.scratch[..len]);
3197        }
3198        true
3199    }
3200
3201    /// Encode the current device and host domains as a snapshot for
3202    /// [`Effect::SaveSnapshot`]. `out` must hold [`SNAPSHOT_MAX`]
3203    /// bytes.
3204    pub fn encode_snapshot(&self, out: &mut [u8]) -> Option<usize> {
3205        SavedState::capture(&self.device, self.config.duty.limit(), self.dev_key).encode(out)
3206    }
3207
3208    /// Restore a stored snapshot at boot, before any host command is
3209    /// processed. On success the saved configuration is applied — the
3210    /// returned effect re-enables the PHY if it was enabled when saved,
3211    /// and detached operation (filtering, queueing, delegation) begins
3212    /// immediately.
3213    ///
3214    /// The payload is decoded and validated into a candidate state
3215    /// first and committed in one step, so a malformed option arriving
3216    /// late in the decode cannot leave the device half-configured. On
3217    /// rejection nothing is modified and the caller should offer the
3218    /// next-older committed generation; see [`Session::note_snapshot_
3219    /// rejected`] for what the device reports when none decodes.
3220    pub fn restore_at_boot(&mut self, bytes: &[u8]) -> Result<Effect, SnapshotError> {
3221        let mut saved = SavedState::decode(&self.config, bytes)?;
3222        saved.derive_channel_ids(&self.engine);
3223        self.saved = Some(saved);
3224        self.apply_saved_device();
3225        Ok(self.apply_radio())
3226    }
3227
3228    /// Record that a stored generation was rejected at boot.
3229    ///
3230    /// Called once per rejected generation. If a later, older generation
3231    /// restores, `PROP_SAVED` reports [`SavedStatus::Fallback`] — the
3232    /// device is working but running on stale configuration, which is
3233    /// both more actionable and more urgent than "something was wrong".
3234    /// If none restores it reports [`SavedStatus::Unreadable`], which a
3235    /// host can tell apart from "nothing saved".
3236    pub fn note_snapshot_rejected(&mut self) {
3237        self.snapshot_rejected = true;
3238    }
3239
3240    /// What `PROP_SAVED` reports (spec §Saved State).
3241    pub fn saved_status(&self) -> SavedStatus {
3242        match (self.saved.is_some(), self.snapshot_rejected) {
3243            (true, false) => SavedStatus::Current,
3244            (true, true) => SavedStatus::Fallback,
3245            (false, true) => SavedStatus::Unreadable,
3246            (false, false) => SavedStatus::None,
3247        }
3248    }
3249
3250    /// Complete the durable write requested via
3251    /// [`Effect::SaveSnapshot`], quoting the same `tid`. On `Ok` the
3252    /// captured state becomes the post-reset baseline; on `Err` the
3253    /// previous snapshot (if any) must have been left intact by the
3254    /// caller and remains in effect.
3255    pub fn respond_save(&mut self, tid: u8, result: Result<(), ()>, emit: &mut impl FnMut(&[u8])) {
3256        match result {
3257            Ok(()) => {
3258                self.note_snapshot_saved();
3259                self.complete(tid, Status::OK, emit);
3260            }
3261            Err(()) => self.complete(tid, Status::FAILURE, emit),
3262        }
3263    }
3264
3265    /// Note that the live state was persisted without a host having
3266    /// asked — a device-initiated save, such as a switch the operator
3267    /// flipped at the board.
3268    ///
3269    /// Required after any such write. The session answers `CMD_RST` and
3270    /// `CMD_RESTORE` from its own copy of the snapshot rather than by
3271    /// re-reading flash, so a save it was not told about would leave the
3272    /// device restoring the values it had at boot and silently undoing
3273    /// what the operator did.
3274    pub fn note_snapshot_saved(&mut self) {
3275        self.saved = Some(SavedState::capture(
3276            &self.device,
3277            self.config.duty.limit(),
3278            self.dev_key,
3279        ));
3280    }
3281
3282    /// Complete the durable erase requested via [`Effect::ClearSaved`],
3283    /// quoting the same `tid`. Live state is unaffected either way: the
3284    /// live device identity in particular remains in effect until the
3285    /// `CMD_RST` that completes a factory reset.
3286    pub fn respond_clear(&mut self, tid: u8, result: Result<(), ()>, emit: &mut impl FnMut(&[u8])) {
3287        match result {
3288            Ok(()) => {
3289                self.saved = None;
3290                self.dev_key_persisted = None;
3291                self.complete(tid, Status::OK, emit);
3292            }
3293            Err(()) => self.complete(tid, Status::FAILURE, emit),
3294        }
3295    }
3296
3297    /// The staged `PROP_DEV_PRIVATE_KEY` provisioning awaiting
3298    /// [`Effect::ProvisionIdentity`] execution.
3299    pub fn identity_request(&self) -> Option<IdentitySource> {
3300        self.session
3301            .pending_identity
3302            .as_ref()
3303            .map(|pending| match pending.secret {
3304                Some(secret) => IdentitySource::Install(secret),
3305                None => IdentitySource::Generate,
3306            })
3307    }
3308
3309    /// Complete the device-identity provisioning requested via
3310    /// [`Effect::ProvisionIdentity`], quoting the same `tid`. `result`
3311    /// carries the new identity's *public* key once the keypair is
3312    /// durably stored — success is announced as `CMD_PROP_IS` for
3313    /// `PROP_DEV_KEY` and the private key is never emitted (spec
3314    /// §PROP_DEV_PRIVATE_KEY). On `Ok` the new identity is adopted even
3315    /// if the transaction was abandoned by a detach: the durable write
3316    /// already happened, and flash is the source of truth.
3317    pub fn respond_identity(
3318        &mut self,
3319        tid: u8,
3320        result: Result<[u8; items::PUBLIC_KEY_LEN], ()>,
3321        emit: &mut impl FnMut(&[u8]),
3322    ) {
3323        let matched = self
3324            .session
3325            .pending_identity
3326            .take_if(|pending| pending.tid == tid)
3327            .is_some();
3328        match result {
3329            Ok(public_key) => {
3330                self.dev_key = Some(public_key);
3331                self.dev_key_persisted = Some(public_key);
3332                // The device now claims a different identity than the
3333                // one a running node was brought up around. Publish it:
3334                // the node compares the live key against its own and
3335                // stops originating traffic it can no longer honestly
3336                // sign, until the boot that rebuilds it.
3337                self.bump_dev_domain();
3338                if matched {
3339                    self.send_prop_is(tid, prop::DEV_KEY, &public_key, emit);
3340                }
3341            }
3342            Err(()) if matched => self.complete(tid, Status::FAILURE, emit),
3343            Err(()) => {}
3344        }
3345    }
3346
3347    /// Install the independently persisted device identity's public
3348    /// key at boot, before any host command: the post-reset value of
3349    /// `PROP_DEV_KEY` is the persisted identity, snapshot or not.
3350    pub fn set_boot_identity(&mut self, public_key: [u8; items::PUBLIC_KEY_LEN]) {
3351        self.dev_key = Some(public_key);
3352        self.dev_key_persisted = Some(public_key);
3353    }
3354
3355    /// Complete a deferred write of the write-only BLE pairing PIN.
3356    pub fn respond_pin_set(
3357        &mut self,
3358        tid: u8,
3359        result: Result<(), ()>,
3360        emit: &mut impl FnMut(&[u8]),
3361    ) {
3362        self.complete(
3363            tid,
3364            if result.is_ok() {
3365                Status::OK
3366            } else {
3367                Status::INTERNAL_ERROR
3368            },
3369            emit,
3370        );
3371    }
3372
3373    fn prop_set(
3374        &mut self,
3375        tid: u8,
3376        key: u32,
3377        value: &[u8],
3378        now_ms: u64,
3379        emit: &mut impl FnMut(&[u8]),
3380    ) -> Option<Effect> {
3381        if key == prop::BLE_PAIRING_PIN {
3382            let pin = if value.is_empty() {
3383                None
3384            } else {
3385                match parse_u32(value) {
3386                    Ok(pin) if pin <= 999_999 => Some(pin),
3387                    _ => {
3388                        self.complete(tid, Status::INVALID_ARGUMENT, emit);
3389                        return None;
3390                    }
3391                }
3392            };
3393            return Some(Effect::SetPairingPin { tid, pin });
3394        }
3395        if key == prop::DEV_PRIVATE_KEY {
3396            // Both forms — installing a key and commanding on-device
3397            // generation — are key provisioning and require the
3398            // transport's security binding (spec §Provisioning
3399            // Security).
3400            if let Err(status) = self.require_secure_link() {
3401                self.complete(tid, status, emit);
3402                return None;
3403            }
3404            let secret = match value.len() {
3405                0 => None,
3406                PRIVATE_KEY_LEN => Some(value.try_into().expect("length checked")),
3407                _ => {
3408                    self.complete(tid, Status::INVALID_ARGUMENT, emit);
3409                    return None;
3410                }
3411            };
3412            if self.session.pending_identity.is_some() {
3413                self.complete(tid, Status::BUSY, emit);
3414                return None;
3415            }
3416            self.session.pending_identity = Some(PendingIdentity { tid, secret });
3417            return Some(Effect::ProvisionIdentity { tid });
3418        }
3419        if key == prop::HOST_KEY {
3420            let new_key = match value.len() {
3421                0 => None,
3422                items::PUBLIC_KEY_LEN => {
3423                    let mut key = [0; items::PUBLIC_KEY_LEN];
3424                    key.copy_from_slice(value);
3425                    Some(key)
3426                }
3427                _ => {
3428                    self.complete(tid, Status::INVALID_ARGUMENT, emit);
3429                    return None;
3430                }
3431            };
3432            // Setting the current value is idempotent and has no side
3433            // effects; a different value replaces the whole host domain
3434            // (spec §Host Replacement).
3435            //
3436            // The replacement is immediate and needs no durable
3437            // transaction: the host domain is not persisted, so there is
3438            // nothing on flash for a power cycle to resurrect. What was
3439            // a two-phase `WipeHostDomain` effect is now one assignment.
3440            if new_key != self.host.key {
3441                self.host.reset(new_key);
3442            }
3443            self.send_prop_is(tid, key, value, emit);
3444            return None;
3445        }
3446        // The locate alert drives physical hardware rather than session
3447        // state, so it completes with its own effect instead of going
3448        // through `apply_prop_set`.
3449        if key == prop::ALERT {
3450            let Some(config) = self.config.alert else {
3451                self.complete(tid, Status::PROP_NOT_FOUND, emit);
3452                return None;
3453            };
3454            let state = match pui::decode(value) {
3455                Ok((code, consumed)) if consumed == value.len() => AlertState::from_code(code),
3456                _ => None,
3457            };
3458            let Some(state) = state else {
3459                self.complete(tid, Status::INVALID_ARGUMENT, emit);
3460                return None;
3461            };
3462            self.alert = state;
3463            // Re-arming an alert that is already running restarts the
3464            // deadline rather than failing: that is how a host holds one
3465            // open for a search longer than the board's own bound.
3466            self.alert_deadline_ms = state
3467                .is_active()
3468                .then(|| now_ms.saturating_add(u64::from(config.timeout_ms)));
3469            let mut echo = [0u8; pui::MAX_LEN];
3470            let len = pui::encode(state.code(), &mut echo).unwrap_or(0);
3471            self.send_prop_is(tid, key, &echo[..len], emit);
3472            return Some(Effect::ApplyAlert(state));
3473        }
3474        // The wall clock lives in the platform, not in session state, so
3475        // a write completes with its own effect. The empty value is not a
3476        // malformed `UINT32_LE` — it is the host saying the device should
3477        // go back to not knowing what time it is.
3478        if key == prop::TIME && self.config.time.is_some() {
3479            let epoch = match value {
3480                [] => None,
3481                _ => match parse_u32(value) {
3482                    Ok(seconds) => Some(seconds),
3483                    Err(status) => {
3484                        self.complete(tid, status, emit);
3485                        return None;
3486                    }
3487                },
3488            };
3489            self.send_prop_is(tid, key, value, emit);
3490            return Some(Effect::ApplyTime { epoch });
3491        }
3492        if key == prop::DEV_NAME {
3493            if !valid_device_name(value) {
3494                self.complete(tid, Status::INVALID_ARGUMENT, emit);
3495                return None;
3496            }
3497            self.device.name[..value.len()].copy_from_slice(value);
3498            self.device.name_len = value.len();
3499            self.send_prop_is(tid, key, value, emit);
3500            return Some(Effect::DeviceNameChanged);
3501        }
3502        let radio_affecting = match self.apply_prop_set(key, value) {
3503            Ok(radio_affecting) => radio_affecting,
3504            Err(status) => {
3505                self.complete(tid, status, emit);
3506                return None;
3507            }
3508        };
3509        // Echo the authoritative value back from session state.
3510        let mut encoded = [0u8; PROP_BUF];
3511        if let PropValue::Encoded(len) = self.encode_prop(key, now_ms, &mut encoded) {
3512            self.send_prop_is(tid, key, &encoded[..len], emit);
3513        }
3514        radio_affecting.then(|| self.apply_radio())
3515    }
3516
3517    /// Validate and apply a property write. Returns whether the radio
3518    /// configuration changed.
3519    fn apply_prop_set(&mut self, key: u32, value: &[u8]) -> Result<bool, Status> {
3520        match key {
3521            prop::PHY_ENABLED => {
3522                self.device.settings.enabled = parse_bool(value)?;
3523                Ok(true)
3524            }
3525            prop::PHY_FREQ => {
3526                self.device.settings.freq_khz = validate_freq_khz(&self.config, value)?;
3527                Ok(true)
3528            }
3529            prop::PHY_TX_POWER => {
3530                self.device.settings.tx_power_dbm = clamp_tx_power(&self.config, value)?;
3531                Ok(true)
3532            }
3533            prop::PHY_LORA_BW => {
3534                self.device.settings.bw_hz = validate_bw_hz(value)?;
3535                Ok(true)
3536            }
3537            prop::PHY_LORA_SF => {
3538                self.device.settings.sf = validate_sf(value)?;
3539                Ok(true)
3540            }
3541            prop::PHY_LORA_CR => {
3542                self.device.settings.cr_denom = validate_cr(value)?;
3543                Ok(true)
3544            }
3545            prop::PHY_LORA_SW => {
3546                // v0: the sync word is fixed at build time; accept only
3547                // a write of the same value.
3548                if parse_u16(value)? != self.config.sync_word {
3549                    return Err(Status::INVALID_ARGUMENT);
3550                }
3551                Ok(false)
3552            }
3553            prop::PHY_DUTY_LIMIT => {
3554                self.config.duty.set_limit(parse_u16(value)?);
3555                Ok(false)
3556            }
3557            prop::MAC_PROMISCUOUS => {
3558                // Session-scoped: reverts to false on every attach.
3559                self.session.promiscuous = parse_bool(value)?;
3560                Ok(false)
3561            }
3562            prop::HOST_AUTO_ACK => {
3563                self.host.auto_ack = parse_bool(value)?;
3564                Ok(false)
3565            }
3566            // Whole-table replacement: the complete value is validated
3567            // into a candidate table before anything changes, so no
3568            // observer sees a mixture of old and new contents.
3569            prop::HOST_RX_FILTERS => {
3570                self.host.filters = FilterTable::parse_table(value)?;
3571                Ok(false)
3572            }
3573            // Key-bearing writes require the transport's security
3574            // binding (spec §Provisioning Security).
3575            prop::HOST_CHANNEL_KEYS => {
3576                self.require_secure_link()?;
3577                let mut table = ChannelKeyTable::default();
3578                for key in items::fixed_items::<{ items::CHANNEL_KEY_LEN }>(value)
3579                    .map_err(|_| Status::INVALID_ARGUMENT)?
3580                {
3581                    // Duplicate keys in a set value collapse.
3582                    match table.insert(self.channel_entry(key)) {
3583                        Ok(()) | Err(Status::ALREADY) => {}
3584                        Err(status) => return Err(status),
3585                    }
3586                }
3587                self.host.channel_keys = table;
3588                Ok(false)
3589            }
3590            // Reconcile, not rebuild: the complete value is validated
3591            // into a candidate list before anything changes, and then
3592            // the live table's entry *set* is replaced while peers
3593            // present in both keep the replay window keyed to their
3594            // identity. See `PeerKeyTable::reconcile`.
3595            prop::HOST_PEER_KEYS => {
3596                self.require_secure_link()?;
3597                let mut desired: HeaplessVec<items::PeerKeyEntry, MAX_PEER_KEYS> =
3598                    HeaplessVec::new();
3599                for item in items::fixed_items::<{ items::PeerKeyEntry::WIRE_LEN }>(value)
3600                    .map_err(|_| Status::INVALID_ARGUMENT)?
3601                {
3602                    let entry =
3603                        items::PeerKeyEntry::decode(item).map_err(|_| Status::INVALID_ARGUMENT)?;
3604                    // A repeated public key replaces the earlier entry.
3605                    match desired
3606                        .iter_mut()
3607                        .find(|existing| existing.public_key == entry.public_key)
3608                    {
3609                        Some(existing) => *existing = entry,
3610                        None => desired.push(entry).map_err(|_| Status::NOMEM)?,
3611                    }
3612                }
3613                self.host.peer_keys.reconcile(&desired);
3614                Ok(false)
3615            }
3616            prop::DEV_CHANNEL_KEYS => {
3617                self.require_secure_link()?;
3618                let mut table = ChannelKeyTable::default();
3619                for key in items::fixed_items::<{ items::CHANNEL_KEY_LEN }>(value)
3620                    .map_err(|_| Status::INVALID_ARGUMENT)?
3621                {
3622                    match table.insert(self.channel_entry(key)) {
3623                        Ok(()) | Err(Status::ALREADY) => {}
3624                        Err(status) => return Err(status),
3625                    }
3626                }
3627                self.device.channel_keys = table;
3628                self.bump_dev_domain();
3629                Ok(false)
3630            }
3631            // Peer public keys carry no secret material, so no
3632            // secure-link gate — like PROP_HOST_KEY itself.
3633            prop::DEV_PEERS => {
3634                self.device.peers = DevPeerTable::parse_table(value)?;
3635                self.bump_dev_domain();
3636                Ok(false)
3637            }
3638            // Device-domain forwarding switch. Accepted regardless of
3639            // whether a device identity exists yet: the flag is persisted
3640            // and takes effect the moment the device node is brought up
3641            // (store-and-defer). The firmware reconciles it against the
3642            // live MAC via the dev-domain version.
3643            prop::MAC_REPEATER_ENABLED => {
3644                self.device.repeater_enabled = parse_bool(value)?;
3645                self.bump_dev_domain();
3646                Ok(false)
3647            }
3648            // The advertised role. An empty value hands the choice back
3649            // to the device, which derives it from what it is actually
3650            // doing; any other value is advertised verbatim, including
3651            // combinations the device cannot infer — a mobile repeater,
3652            // a fixed tracker.
3653            prop::IDENT_ROLE => {
3654                self.device.ident_role = match value.len() {
3655                    0 => None,
3656                    1 => Some(value[0]),
3657                    _ => return Err(Status::INVALID_ARGUMENT),
3658                };
3659                self.bump_dev_domain();
3660                Ok(false)
3661            }
3662            prop::IDENT_MOBILE => {
3663                self.device.ident_mobile = parse_bool(value)?;
3664                self.bump_dev_domain();
3665                Ok(false)
3666            }
3667            prop::DEV_DISCOVERABLE => {
3668                self.device.dev_discoverable = parse_bool(value)?;
3669                self.bump_dev_domain();
3670                Ok(false)
3671            }
3672            // Advertisement policy. Each interval stands alone: a mesh
3673            // usually wants cheap beacons often and expensive identity
3674            // advertisements rarely, and a single knob could not say that.
3675            prop::ADVERT_INTERVAL => {
3676                self.device.advert_interval_s = validate_announce_interval(value)?;
3677                self.bump_dev_domain();
3678                Ok(false)
3679            }
3680            prop::BEACON_INTERVAL => {
3681                self.device.beacon_interval_s = validate_announce_interval(value)?;
3682                self.bump_dev_domain();
3683                Ok(false)
3684            }
3685            prop::STARTUP_BEACON => {
3686                self.device.startup_beacon = parse_bool(value)?;
3687                self.bump_dev_domain();
3688                Ok(false)
3689            }
3690            // The forwarding policy. All four are accepted while
3691            // forwarding is disabled and simply take effect when it is
3692            // enabled, so an administrator can stage a whole repeater
3693            // configuration and turn it on last. Empty means "no gate"
3694            // in every case, which is also the post-reset value.
3695            prop::MAC_REPEATER_REGIONS => {
3696                self.device.repeater_regions = RepeaterRegions::parse(value)?;
3697                self.bump_dev_domain();
3698                Ok(false)
3699            }
3700            // Not cross-checked against the region list: the two are
3701            // written separately and in either order, so enforcing
3702            // membership here would reject a legitimate write purely for
3703            // arriving first.
3704            prop::MAC_REPEATER_DEFAULT_REGION => {
3705                self.device.repeater_default_region = parse_region_code(value)?;
3706                self.bump_dev_domain();
3707                Ok(false)
3708            }
3709            prop::MAC_REPEATER_MIN_RSSI => {
3710                self.device.repeater_min_rssi = match value.is_empty() {
3711                    true => None,
3712                    false => Some(parse_i16(value)?),
3713                };
3714                self.bump_dev_domain();
3715                Ok(false)
3716            }
3717            prop::MAC_REPEATER_MIN_SNR => {
3718                self.device.repeater_min_snr = match value.is_empty() {
3719                    true => None,
3720                    false => Some(parse_i8(value)?),
3721                };
3722                self.bump_dev_domain();
3723                Ok(false)
3724            }
3725            // The receiver switch and the positioning policy. All reach
3726            // the platform through the device-domain mirror rather than
3727            // through an effect of their own, which is what makes a host
3728            // write, a boot restore, and a `CMD_RST` land identically.
3729            prop::TZ_OFFSET if self.config.time.is_some() => {
3730                self.device.tz_offset_min = validate_tz_offset(value)?;
3731                self.bump_dev_domain();
3732                Ok(false)
3733            }
3734            prop::GNSS_ENABLED if self.config.gnss.is_some() => {
3735                self.device.gnss_enabled = parse_bool(value)?;
3736                self.bump_dev_domain();
3737                Ok(false)
3738            }
3739            prop::GNSS_IDENT_UPDATE if self.config.gnss.is_some() => {
3740                self.device.gnss_ident_update = parse_bool(value)?;
3741                self.bump_dev_domain();
3742                Ok(false)
3743            }
3744            // Accepted while auto-update is off, like the repeater policy:
3745            // an administrator stages the whole configuration and turns it
3746            // on last.
3747            prop::GNSS_IDENT_PRECISION if self.config.gnss.is_some() => {
3748                self.device.gnss_ident_precision = validate_ident_precision(value)?;
3749                self.bump_dev_domain();
3750                Ok(false)
3751            }
3752            prop::GNSS_TIME_TRUST if self.config.gnss.is_some() => {
3753                self.device.gnss_time_trust = parse_bool(value)?;
3754                self.bump_dev_domain();
3755                Ok(false)
3756            }
3757            // This device's queue size is fixed; adjustment is optional in
3758            // the spec and unimplemented here.
3759            prop::HOST_RX_QUEUE_CAPACITY => Err(Status::UNIMPLEMENTED),
3760            // Known read-only properties. PROP_DEV_KEY changes only
3761            // through PROP_DEV_PRIVATE_KEY provisioning.
3762            prop::LAST_STATUS
3763            | prop::PROTOCOL_VERSION
3764            | prop::DEV_VERSION
3765            | prop::INTERFACE_TYPE
3766            | prop::CAPS
3767            | prop::PHY_RSSI
3768            | prop::PHY_MTU
3769            | prop::PHY_DUTY_NOW
3770            | prop::DEV_KEY
3771            | prop::HOST_RX_QUEUE_COUNT
3772            | prop::HOST_RX_QUEUE_DROPPED
3773            | prop::SAVED => Err(Status::INVALID_ARGUMENT),
3774            prop::BATTERY if self.config.battery.is_some() => Err(Status::INVALID_ARGUMENT),
3775            // Positioning telemetry reports what the receiver found and
3776            // is not writable. `PROP_GNSS_LOCATION` and
3777            // `PROP_GNSS_ALTITUDE` are the ones that could plausibly
3778            // become writable — a fixed node placed by hand — but that
3779            // needs a rule for which source wins over the other, so they
3780            // stay read-only until there is one.
3781            key if gnss::is_positioning_property(key) && self.config.gnss.is_some() => {
3782                Err(Status::INVALID_ARGUMENT)
3783            }
3784            prop::ILLUMINANCE if self.config.illuminance => Err(Status::INVALID_ARGUMENT),
3785            _ => Err(Status::PROP_NOT_FOUND),
3786        }
3787    }
3788
3789    /// `CMD_PROP_INSERT`: add one item (in item form, no length prefix)
3790    /// to a multi-value property.
3791    fn prop_insert(&mut self, tid: u8, key: u32, item: &[u8], emit: &mut impl FnMut(&[u8])) {
3792        match key {
3793            prop::HOST_RX_FILTERS => {
3794                let filter = match decode_filter(item) {
3795                    Ok(filter) => filter,
3796                    Err(status) => return self.complete(tid, status, emit),
3797                };
3798                match self.host.filters.insert(filter) {
3799                    Ok(()) => self.send_prop_inserted(tid, key, item, emit),
3800                    Err(status) => self.complete(tid, status, emit),
3801                }
3802            }
3803            // Key-bearing inserts require the transport's security
3804            // binding. The emitted digest never contains key material.
3805            prop::HOST_CHANNEL_KEYS => {
3806                let result = self.require_secure_link().and_then(|()| {
3807                    let key: &[u8; items::CHANNEL_KEY_LEN] =
3808                        item.try_into().map_err(|_| Status::INVALID_ARGUMENT)?;
3809                    let entry = self.channel_entry(key);
3810                    self.host.channel_keys.insert(entry).map(|()| entry.id)
3811                });
3812                match result {
3813                    Ok(id) => self.send_prop_inserted(tid, key, &id, emit),
3814                    Err(status) => self.complete(tid, status, emit),
3815                }
3816            }
3817            prop::HOST_PEER_KEYS => {
3818                let result = self.require_secure_link().and_then(|()| {
3819                    let entry =
3820                        items::PeerKeyEntry::decode(item).map_err(|_| Status::INVALID_ARGUMENT)?;
3821                    // A matching public key replaces the stored key
3822                    // material (never STATUS_ALREADY).
3823                    self.host.peer_keys.insert(entry).map(|()| entry.public_key)
3824                });
3825                match result {
3826                    Ok(public_key) => self.send_prop_inserted(tid, key, &public_key, emit),
3827                    Err(status) => self.complete(tid, status, emit),
3828                }
3829            }
3830            prop::DEV_CHANNEL_KEYS => {
3831                let result = self.require_secure_link().and_then(|()| {
3832                    let key: &[u8; items::CHANNEL_KEY_LEN] =
3833                        item.try_into().map_err(|_| Status::INVALID_ARGUMENT)?;
3834                    let entry = self.channel_entry(key);
3835                    self.device.channel_keys.insert(entry).map(|()| entry.id)
3836                });
3837                match result {
3838                    Ok(id) => {
3839                        self.bump_dev_domain();
3840                        self.send_prop_inserted(tid, key, &id, emit);
3841                    }
3842                    Err(status) => self.complete(tid, status, emit),
3843                }
3844            }
3845            prop::DEV_PEERS => {
3846                let result = item
3847                    .try_into()
3848                    .map_err(|_| Status::INVALID_ARGUMENT)
3849                    .and_then(|public_key: &[u8; items::PUBLIC_KEY_LEN]| {
3850                        self.device.peers.insert(*public_key)
3851                    });
3852                match result {
3853                    Ok(()) => {
3854                        self.bump_dev_domain();
3855                        self.send_prop_inserted(tid, key, item, emit);
3856                    }
3857                    Err(status) => self.complete(tid, status, emit),
3858                }
3859            }
3860            // A known property that is not a mutable multi-value
3861            // property cannot be inserted into.
3862            _ if self.known_prop(key) => self.complete(tid, Status::INVALID_ARGUMENT, emit),
3863            _ => self.complete(tid, Status::PROP_NOT_FOUND, emit),
3864        }
3865    }
3866
3867    /// `CMD_PROP_REMOVE`: remove the item matching the selector from a
3868    /// multi-value property.
3869    fn prop_remove(&mut self, tid: u8, key: u32, selector: &[u8], emit: &mut impl FnMut(&[u8])) {
3870        match key {
3871            prop::HOST_RX_FILTERS => {
3872                // The remove selector is the full item.
3873                let filter = match decode_filter(selector) {
3874                    Ok(filter) => filter,
3875                    Err(status) => return self.complete(tid, status, emit),
3876                };
3877                match self.host.filters.remove(filter) {
3878                    Ok(()) => self.send_prop_removed(tid, key, selector, emit),
3879                    Err(status) => self.complete(tid, status, emit),
3880                }
3881            }
3882            // The channel-key remove selector is the key itself; the
3883            // digest reported back is the derived channel identifier.
3884            prop::HOST_CHANNEL_KEYS => {
3885                let result = selector
3886                    .try_into()
3887                    .map_err(|_| Status::INVALID_ARGUMENT)
3888                    .and_then(|key: &[u8; items::CHANNEL_KEY_LEN]| {
3889                        self.host.channel_keys.remove(key)
3890                    });
3891                match result {
3892                    Ok(id) => self.send_prop_removed(tid, key, &id, emit),
3893                    Err(status) => self.complete(tid, status, emit),
3894                }
3895            }
3896            // The peer remove selector is the peer public key (already
3897            // the digest form).
3898            prop::HOST_PEER_KEYS => {
3899                let result = selector
3900                    .try_into()
3901                    .map_err(|_| Status::INVALID_ARGUMENT)
3902                    .and_then(|public_key: &[u8; items::PUBLIC_KEY_LEN]| {
3903                        self.host.peer_keys.remove(public_key)
3904                    });
3905                match result {
3906                    Ok(()) => self.send_prop_removed(tid, key, selector, emit),
3907                    Err(status) => self.complete(tid, status, emit),
3908                }
3909            }
3910            prop::DEV_CHANNEL_KEYS => {
3911                let result = selector
3912                    .try_into()
3913                    .map_err(|_| Status::INVALID_ARGUMENT)
3914                    .and_then(|key: &[u8; items::CHANNEL_KEY_LEN]| {
3915                        self.device.channel_keys.remove(key)
3916                    });
3917                match result {
3918                    Ok(id) => {
3919                        self.bump_dev_domain();
3920                        self.send_prop_removed(tid, key, &id, emit);
3921                    }
3922                    Err(status) => self.complete(tid, status, emit),
3923                }
3924            }
3925            prop::DEV_PEERS => {
3926                let result = selector
3927                    .try_into()
3928                    .map_err(|_| Status::INVALID_ARGUMENT)
3929                    .and_then(|public_key: &[u8; items::PUBLIC_KEY_LEN]| {
3930                        self.device.peers.remove(public_key)
3931                    });
3932                match result {
3933                    Ok(()) => {
3934                        self.bump_dev_domain();
3935                        self.send_prop_removed(tid, key, selector, emit);
3936                    }
3937                    Err(status) => self.complete(tid, status, emit),
3938                }
3939            }
3940            _ if self.known_prop(key) => self.complete(tid, Status::INVALID_ARGUMENT, emit),
3941            _ => self.complete(tid, Status::PROP_NOT_FOUND, emit),
3942        }
3943    }
3944
3945    /// Derive a channel key's identifier (its digest form and implicit
3946    /// receive filter).
3947    fn channel_entry(&self, key: &[u8; items::CHANNEL_KEY_LEN]) -> ChannelKeyEntry {
3948        ChannelKeyEntry {
3949            key: *key,
3950            id: self.engine.derive_channel_id(&ChannelKey(*key)).0,
3951        }
3952    }
3953
3954    /// Refuse key-bearing writes over a transport that does not meet
3955    /// its security binding (spec §Provisioning Security).
3956    fn require_secure_link(&self) -> Result<(), Status> {
3957        if self.link_secure {
3958            Ok(())
3959        } else {
3960            Err(Status::INVALID_STATE)
3961        }
3962    }
3963
3964    fn str_send(
3965        &mut self,
3966        tid: u8,
3967        payload: &StreamPayload<'_>,
3968        now_ms: u64,
3969        emit: &mut impl FnMut(&[u8]),
3970    ) -> Option<Effect> {
3971        if payload.stream != stream::PHY_RAW {
3972            self.complete(tid, Status::PROP_NOT_FOUND, emit);
3973            return None;
3974        }
3975        if !self.device.settings.enabled {
3976            self.complete(tid, Status::INVALID_STATE, emit);
3977            return None;
3978        }
3979        if payload.data.len() > usize::from(self.config.mtu) {
3980            self.complete(tid, Status::INVALID_ARGUMENT, emit);
3981            return None;
3982        }
3983        let Ok(tx_meta) = TxMeta::decode(payload.metadata) else {
3984            self.complete(tid, Status::PARSE_ERROR, emit);
3985            return None;
3986        };
3987        if self.session.pending.is_full() {
3988            self.complete(tid, Status::BUSY, emit);
3989            return None;
3990        }
3991
3992        let airtime_ms = lora_airtime_ms(
3993            self.device.settings.sf,
3994            self.device.settings.bw_hz,
3995            self.device.settings.cr_denom,
3996            payload.data.len(),
3997        );
3998        let projected_airtime_ms = self
3999            .session
4000            .pending
4001            .iter()
4002            .fold(airtime_ms, |total, pending| {
4003                total.saturating_add(pending.airtime_ms)
4004            });
4005        if tx_meta.flags & meta::TX_FLAG_NODUTY == 0
4006            && self.config.duty.would_exceed(now_ms, projected_airtime_ms)
4007        {
4008            self.complete(tid, Status::DUTY_LIMIT, emit);
4009            return None;
4010        }
4011        let was_empty = self.session.pending.is_empty();
4012        let mut data = HeaplessVec::new();
4013        // The MTU check above proves this fixed-capacity copy can succeed.
4014        data.extend_from_slice(payload.data)
4015            .expect("payload bounded by MAX_MTU");
4016        let queued = self.session.pending.push_back(PendingTx {
4017            data,
4018            tid,
4019            airtime_ms,
4020            // The per-frame override is clamped to the radio's range for
4021            // the same reason `PROP_PHY_TX_POWER` is: an unreachable
4022            // power transmits at the nearest reachable one.
4023            power: match tx_meta.power {
4024                meta::TX_POWER_DEFAULT => TxPower::Default,
4025                meta::TX_POWER_MAX => TxPower::Max,
4026                dbm => TxPower::Dbm(
4027                    dbm.clamp(self.config.min_tx_power_dbm, self.config.max_tx_power_dbm),
4028                ),
4029            },
4030            autonomous: false,
4031            ack_for: None,
4032            nocca: tx_meta.flags & meta::TX_FLAG_NOCCA != 0,
4033        });
4034        debug_assert!(queued.is_ok(), "queue fullness checked above");
4035        // Remember this frame's MIC prefix so its echoes — the returning
4036        // (destination-hintless) MAC ack, a repeater's onward copy — can be
4037        // recognized as ours.
4038        self.host.note_tx_mic(payload.data);
4039        was_empty.then_some(Effect::StartTransmit)
4040    }
4041
4042    // ─── Property encoding ───────────────────────────────────────────
4043
4044    /// Whether `key` names a property this session knows, including
4045    /// write-only (`PROP_BLE_PAIRING_PIN`) and deferred-read
4046    /// (`PROP_PHY_RSSI`) properties that `encode_prop` cannot produce.
4047    fn known_prop(&self, key: u32) -> bool {
4048        if key == prop::BATTERY {
4049            return self.config.battery.is_some();
4050        }
4051        if key == prop::ALERT {
4052            return self.config.alert.is_some();
4053        }
4054        if key == prop::ILLUMINANCE {
4055            return self.config.illuminance;
4056        }
4057        if matches!(key, prop::TIME | prop::TZ_OFFSET) {
4058            return self.config.time.is_some();
4059        }
4060        if gnss::is_positioning_property(key)
4061            || matches!(
4062                key,
4063                prop::GNSS_ENABLED
4064                    | prop::GNSS_IDENT_UPDATE
4065                    | prop::GNSS_IDENT_PRECISION
4066                    | prop::GNSS_TIME_TRUST
4067            )
4068        {
4069            return self.config.gnss.is_some();
4070        }
4071        matches!(
4072            key,
4073            prop::LAST_STATUS
4074                | prop::PROTOCOL_VERSION
4075                | prop::DEV_VERSION
4076                | prop::INTERFACE_TYPE
4077                | prop::CAPS
4078                | prop::PHY_ENABLED
4079                | prop::PHY_FREQ
4080                | prop::PHY_TX_POWER
4081                | prop::PHY_RSSI
4082                | prop::PHY_LORA_BW
4083                | prop::PHY_LORA_SF
4084                | prop::PHY_LORA_CR
4085                | prop::PHY_MTU
4086                | prop::PHY_LORA_SW
4087                | prop::DEV_NAME
4088                | prop::DEV_KEY
4089                | prop::DEV_PRIVATE_KEY
4090                | prop::DEV_CHANNEL_KEYS
4091                | prop::DEV_PEERS
4092                | prop::MAC_REPEATER_ENABLED
4093                | prop::MAC_REPEATER_REGIONS
4094                | prop::MAC_REPEATER_DEFAULT_REGION
4095                | prop::MAC_REPEATER_MIN_RSSI
4096                | prop::MAC_REPEATER_MIN_SNR
4097                | prop::IDENT
4098                | prop::IDENT_ROLE
4099                | prop::IDENT_MOBILE
4100                | prop::DEV_DISCOVERABLE
4101                | prop::ADVERT_INTERVAL
4102                | prop::BEACON_INTERVAL
4103                | prop::STARTUP_BEACON
4104                | prop::PHY_DUTY_NOW
4105                | prop::PHY_DUTY_LIMIT
4106                | prop::BLE_PAIRING_PIN
4107                | prop::MAC_PROMISCUOUS
4108                | prop::SAVED
4109                | prop::HOST_KEY
4110                | prop::HOST_RX_FILTERS
4111                | prop::HOST_CHANNEL_KEYS
4112                | prop::HOST_PEER_KEYS
4113                | prop::HOST_AUTO_ACK
4114                | prop::HOST_RX_QUEUE_COUNT
4115                | prop::HOST_RX_QUEUE_CAPACITY
4116                | prop::HOST_RX_QUEUE_DROPPED
4117        )
4118    }
4119
4120    fn encode_prop(&mut self, key: u32, now_ms: u64, out: &mut [u8; PROP_BUF]) -> PropValue {
4121        let len = match key {
4122            prop::LAST_STATUS => pui::encode(self.last_status.0, out).unwrap_or(0),
4123            prop::PROTOCOL_VERSION => {
4124                out[0] = ids::PROTOCOL_MAJOR_VERSION;
4125                out[1] = ids::PROTOCOL_MINOR_VERSION;
4126                2
4127            }
4128            prop::DEV_VERSION => {
4129                let bytes = self.config.dev_version.as_bytes();
4130                let len = bytes.len().min(out.len() - 1);
4131                out[..len].copy_from_slice(&bytes[..len]);
4132                out[len] = 0; // NUL terminator per spec
4133                len + 1
4134            }
4135            prop::INTERFACE_TYPE => pui::encode(ids::INTERFACE_TYPE, out).unwrap_or(0),
4136            prop::CAPS => {
4137                let mut len = 0;
4138                for capability in [
4139                    cap::WRITABLE_RAW_STREAM,
4140                    cap::PHY_DUTY_LIMIT,
4141                    cap::DEV_NAME,
4142                    cap::PHY_LORA,
4143                    cap::HOST_FILTER,
4144                    cap::HOST_RX_QUEUE,
4145                    cap::HOST_KEYS,
4146                    cap::HOST_AUTO_ACK,
4147                    cap::SAVE,
4148                    cap::DEV_IDENTITY,
4149                    cap::REPEATER,
4150                    cap::IDENT,
4151                    cap::ADVERT,
4152                ] {
4153                    len += pui::encode(capability, &mut out[len..]).unwrap_or(0);
4154                }
4155                if self.config.battery.is_some() {
4156                    len += pui::encode(cap::BATTERY, &mut out[len..]).unwrap_or(0);
4157                }
4158                if self.config.alert.is_some() {
4159                    len += pui::encode(cap::ALERT, &mut out[len..]).unwrap_or(0);
4160                }
4161                if self.config.time.is_some() {
4162                    len += pui::encode(cap::TIME, &mut out[len..]).unwrap_or(0);
4163                }
4164                if self.config.gnss.is_some() {
4165                    len += pui::encode(cap::GNSS, &mut out[len..]).unwrap_or(0);
4166                }
4167                if self.config.illuminance {
4168                    len += pui::encode(cap::ILLUMINANCE, &mut out[len..]).unwrap_or(0);
4169                }
4170                len
4171            }
4172            prop::PHY_ENABLED => {
4173                out[0] = self.device.settings.enabled as u8;
4174                1
4175            }
4176            prop::PHY_FREQ => put(out, &self.device.settings.freq_khz.to_le_bytes()),
4177            prop::PHY_TX_POWER => {
4178                out[0] = self.device.settings.tx_power_dbm as u8;
4179                1
4180            }
4181            prop::PHY_RSSI => return PropValue::Unimplemented,
4182            // Deferred-read like PHY_RSSI: prop_get intercepts and
4183            // samples; this arm is only a fallback.
4184            prop::BATTERY if self.config.battery.is_some() => return PropValue::Unimplemented,
4185            prop::ILLUMINANCE if self.config.illuminance => return PropValue::Unimplemented,
4186            prop::PHY_LORA_BW => put(out, &self.device.settings.bw_hz.to_le_bytes()),
4187            prop::PHY_LORA_SF => {
4188                out[0] = self.device.settings.sf;
4189                1
4190            }
4191            prop::PHY_LORA_CR => {
4192                out[0] = self.device.settings.cr_denom;
4193                1
4194            }
4195            prop::PHY_MTU => put(out, &self.config.mtu.to_le_bytes()),
4196            prop::PHY_LORA_SW => put(out, &self.config.sync_word.to_le_bytes()),
4197            prop::DEV_NAME => put(out, &self.device.name[..self.device.name_len]),
4198            prop::DEV_KEY => match &self.dev_key {
4199                Some(key) => put(out, key),
4200                None => 0,
4201            },
4202            prop::DEV_CHANNEL_KEYS => {
4203                let mut len = 0;
4204                for entry in self.device.channel_keys.iter() {
4205                    len += put(&mut out[len..], &entry.id);
4206                }
4207                len
4208            }
4209            prop::DEV_PEERS => {
4210                let mut len = 0;
4211                for public_key in self.device.peers.iter() {
4212                    len += put(&mut out[len..], public_key);
4213                }
4214                len
4215            }
4216            prop::MAC_REPEATER_ENABLED => {
4217                out[0] = self.device.repeater_enabled as u8;
4218                1
4219            }
4220            // Deferred-read like PHY_RSSI: prop_get intercepts and asks
4221            // the platform to sign; this arm is only a fallback.
4222            prop::IDENT => return PropValue::Unimplemented,
4223            prop::IDENT_ROLE => match self.device.ident_role {
4224                Some(role) => {
4225                    out[0] = role;
4226                    1
4227                }
4228                None => 0,
4229            },
4230            prop::IDENT_MOBILE => {
4231                out[0] = self.device.ident_mobile as u8;
4232                1
4233            }
4234            prop::DEV_DISCOVERABLE => {
4235                out[0] = self.device.dev_discoverable as u8;
4236                1
4237            }
4238            prop::ADVERT_INTERVAL => put(out, &self.device.advert_interval_s.to_le_bytes()),
4239            prop::BEACON_INTERVAL => put(out, &self.device.beacon_interval_s.to_le_bytes()),
4240            prop::STARTUP_BEACON => {
4241                out[0] = self.device.startup_beacon as u8;
4242                1
4243            }
4244            prop::ALERT if self.config.alert.is_some() => {
4245                pui::encode(self.alert.code(), out).unwrap_or(0)
4246            }
4247            // Deferred-read like PHY_RSSI: prop_get intercepts and asks
4248            // the platform; these arms are only fallbacks.
4249            prop::TIME if self.config.time.is_some() => return PropValue::Unimplemented,
4250            key if gnss::is_positioning_property(key) && self.config.gnss.is_some() => {
4251                return PropValue::Unimplemented;
4252            }
4253            prop::TZ_OFFSET if self.config.time.is_some() => {
4254                put(out, &self.device.tz_offset_min.to_le_bytes())
4255            }
4256            prop::GNSS_ENABLED if self.config.gnss.is_some() => {
4257                out[0] = self.device.gnss_enabled as u8;
4258                1
4259            }
4260            prop::GNSS_IDENT_UPDATE if self.config.gnss.is_some() => {
4261                out[0] = self.device.gnss_ident_update as u8;
4262                1
4263            }
4264            prop::GNSS_IDENT_PRECISION if self.config.gnss.is_some() => {
4265                out[0] = self.device.gnss_ident_precision;
4266                1
4267            }
4268            prop::GNSS_TIME_TRUST if self.config.gnss.is_some() => {
4269                out[0] = self.device.gnss_time_trust as u8;
4270                1
4271            }
4272            prop::MAC_REPEATER_REGIONS => put(out, self.device.repeater_regions.as_slice()),
4273            prop::MAC_REPEATER_DEFAULT_REGION => match &self.device.repeater_default_region {
4274                Some(code) => put(out, code),
4275                None => 0,
4276            },
4277            prop::MAC_REPEATER_MIN_RSSI => match self.device.repeater_min_rssi {
4278                Some(rssi) => put(out, &rssi.to_le_bytes()),
4279                None => 0,
4280            },
4281            prop::MAC_REPEATER_MIN_SNR => match self.device.repeater_min_snr {
4282                Some(snr) => {
4283                    out[0] = snr as u8;
4284                    1
4285                }
4286                None => 0,
4287            },
4288            prop::PHY_DUTY_NOW => put(out, &self.config.duty.usage(now_ms).to_le_bytes()),
4289            prop::PHY_DUTY_LIMIT => put(out, &self.config.duty.limit().to_le_bytes()),
4290            prop::MAC_PROMISCUOUS => {
4291                out[0] = self.session.promiscuous as u8;
4292                1
4293            }
4294            prop::SAVED => {
4295                out[0] = self.saved_status().as_octet();
4296                1
4297            }
4298            prop::HOST_KEY => match &self.host.key {
4299                Some(key) => put(out, key),
4300                None => 0,
4301            },
4302            // Key tables report digest forms only: derived channel
4303            // identifiers and peer public keys. Key material is never
4304            // read back (spec §Provisioning Security).
4305            prop::HOST_CHANNEL_KEYS => {
4306                let mut len = 0;
4307                for entry in self.host.channel_keys.iter() {
4308                    len += put(&mut out[len..], &entry.id);
4309                }
4310                len
4311            }
4312            prop::HOST_PEER_KEYS => {
4313                let mut len = 0;
4314                for slot in self.host.peer_keys.iter() {
4315                    len += put(&mut out[len..], &slot.entry.public_key);
4316                }
4317                len
4318            }
4319            prop::HOST_AUTO_ACK => {
4320                out[0] = self.host.auto_ack as u8;
4321                1
4322            }
4323            prop::HOST_RX_QUEUE_COUNT => put(out, &(self.host.queue.len as u16).to_le_bytes()),
4324            prop::HOST_RX_QUEUE_CAPACITY => put(out, &(RX_QUEUE_CAPACITY as u16).to_le_bytes()),
4325            prop::HOST_RX_QUEUE_DROPPED => put(out, &self.host.queue.dropped.to_le_bytes()),
4326            prop::HOST_RX_FILTERS => {
4327                // Digest form equals item form; items carry PUI length
4328                // prefixes in whole-table values.
4329                let mut len = 0;
4330                for filter in self.host.filters.iter() {
4331                    let mut item = [0u8; Filter::MAX_WIRE_LEN];
4332                    let item_len = filter.encode(&mut item).expect("MAX_WIRE_LEN sized");
4333                    len += items::encode_prefixed_item(&item[..item_len], &mut out[len..])
4334                        .expect("out sized for a full filter table");
4335                }
4336                len
4337            }
4338            _ => return PropValue::Unknown,
4339        };
4340        PropValue::Encoded(len)
4341    }
4342
4343    // ─── Emission helpers ────────────────────────────────────────────
4344
4345    /// Emit `CMD_PROP_IS` for `key` with `value` as a correlated
4346    /// response. Fire-and-forget commands (TID 0) receive nothing —
4347    /// the state change still happened.
4348    fn send_prop_is(&mut self, tid: u8, key: u32, value: &[u8], emit: &mut impl FnMut(&[u8])) {
4349        if tid == TID_UNSOLICITED {
4350            return;
4351        }
4352        let mut buf = [0u8; PROP_BUF + 16];
4353        if let Ok(len) = frame::prop_is(&mut buf, tid, key, value) {
4354            emit(&buf[..len]);
4355        }
4356    }
4357
4358    /// Emit an *unsolicited* `CMD_PROP_IS` (TID 0) for `key`: the device
4359    /// publishing a new authoritative value for a reason the host did not
4360    /// initiate. The counterpart to [`Self::send_prop_is`], which
4361    /// deliberately suppresses TID 0 because a correlated response to a
4362    /// fire-and-forget command is not owed.
4363    ///
4364    /// Returns whether the frame was emitted.
4365    fn announce_prop_is(&mut self, key: u32, value: &[u8], emit: &mut impl FnMut(&[u8])) -> bool {
4366        let mut buf = [0u8; PROP_BUF + 16];
4367        match frame::prop_is(&mut buf, TID_UNSOLICITED, key, value) {
4368            Ok(len) => {
4369                emit(&buf[..len]);
4370                true
4371            }
4372            Err(_) => false,
4373        }
4374    }
4375
4376    /// Emit `CMD_PROP_INSERTED` for `key` with the item's digest form,
4377    /// as a correlated response (suppressed for TID 0; an unsolicited
4378    /// TID-0 `CMD_PROP_INSERTED` is reserved for changes the device makes
4379    /// for its own reasons, which none of these are).
4380    fn send_prop_inserted(
4381        &mut self,
4382        tid: u8,
4383        key: u32,
4384        digest: &[u8],
4385        emit: &mut impl FnMut(&[u8]),
4386    ) {
4387        if tid == TID_UNSOLICITED {
4388            return;
4389        }
4390        let mut buf = [0u8; PROP_BUF + 16];
4391        if let Ok(len) = frame::prop_inserted(&mut buf, tid, key, digest) {
4392            emit(&buf[..len]);
4393        }
4394    }
4395
4396    /// Emit `CMD_PROP_REMOVED` for `key` with the item's digest form,
4397    /// as a correlated response (suppressed for TID 0).
4398    fn send_prop_removed(
4399        &mut self,
4400        tid: u8,
4401        key: u32,
4402        digest: &[u8],
4403        emit: &mut impl FnMut(&[u8]),
4404    ) {
4405        if tid == TID_UNSOLICITED {
4406            return;
4407        }
4408        let mut buf = [0u8; PROP_BUF + 16];
4409        if let Ok(len) = frame::prop_removed(&mut buf, tid, key, digest) {
4410            emit(&buf[..len]);
4411        }
4412    }
4413
4414    /// Emit `PROP_LAST_STATUS` unconditionally (success paths and
4415    /// unsolicited notices).
4416    fn send_status(&mut self, tid: u8, status: Status, emit: &mut impl FnMut(&[u8])) {
4417        self.last_status = status;
4418        let mut buf = [0u8; 16];
4419        if let Ok(len) = frame::last_status(&mut buf, tid, status) {
4420            emit(&buf[..len]);
4421        }
4422    }
4423
4424    /// Record a command's completion status, success or failure.
4425    /// Correlated commands get a `PROP_LAST_STATUS` response;
4426    /// fire-and-forget (TID 0) commands only update `PROP_LAST_STATUS`
4427    /// — the spec grants them no correlated response. Deliberate
4428    /// unsolicited notifications (reset notices, `STATUS_RESET_RESTORED`)
4429    /// bypass this via [`Self::send_status`] with `TID_UNSOLICITED`.
4430    fn complete(&mut self, tid: u8, status: Status, emit: &mut impl FnMut(&[u8])) {
4431        if tid == TID_UNSOLICITED {
4432            self.last_status = status;
4433        } else {
4434            self.send_status(tid, status, emit);
4435        }
4436    }
4437}
4438
4439fn put(out: &mut [u8], bytes: &[u8]) -> usize {
4440    out[..bytes.len()].copy_from_slice(bytes);
4441    bytes.len()
4442}
4443
4444fn parse_bool(value: &[u8]) -> Result<bool, Status> {
4445    match value {
4446        [0] => Ok(false),
4447        [1] => Ok(true),
4448        _ => Err(Status::INVALID_ARGUMENT),
4449    }
4450}
4451
4452fn parse_u8(value: &[u8]) -> Result<u8, Status> {
4453    match value {
4454        [byte] => Ok(*byte),
4455        _ => Err(Status::INVALID_ARGUMENT),
4456    }
4457}
4458
4459fn parse_i8(value: &[u8]) -> Result<i8, Status> {
4460    parse_u8(value).map(|byte| byte as i8)
4461}
4462
4463fn parse_i16(value: &[u8]) -> Result<i16, Status> {
4464    parse_u16(value).map(|half| half as i16)
4465}
4466
4467fn parse_u16(value: &[u8]) -> Result<u16, Status> {
4468    match value {
4469        [lo, hi] => Ok(u16::from_le_bytes([*lo, *hi])),
4470        _ => Err(Status::INVALID_ARGUMENT),
4471    }
4472}
4473
4474fn parse_u32(value: &[u8]) -> Result<u32, Status> {
4475    match value {
4476        [a, b, c, d] => Ok(u32::from_le_bytes([*a, *b, *c, *d])),
4477        _ => Err(Status::INVALID_ARGUMENT),
4478    }
4479}
4480
4481fn valid_device_name(value: &[u8]) -> bool {
4482    (1..=MAX_DEVICE_NAME_LEN).contains(&value.len())
4483        && !value.contains(&0)
4484        && core::str::from_utf8(value).is_ok()
4485}
4486
4487// ─── Shared property-value validators ───────────────────────────────────
4488//
4489// Used by both the live `CMD_PROP_SET` path and the snapshot decoder, so
4490// a value a host could never write is also a value a snapshot cannot
4491// smuggle in. They validate the value only: transport authorization is
4492// the caller's business, and the restore path deliberately has none.
4493//
4494// Values naming a discrete choice (a frequency, a modem setting) are
4495// rejected outright when unsupported, because the nearest supported
4496// value is a different choice and silently substituting it produces a
4497// radio that cannot talk to the network it was pointed at. Values
4498// expressing "as much as the hardware has" are clamped instead — see
4499// `clamp_tx_power`.
4500
4501fn validate_freq_khz(config: &SessionConfig, value: &[u8]) -> Result<u32, Status> {
4502    let freq_khz = parse_u32(value)?;
4503    if !(config.freq_khz_min..=config.freq_khz_max).contains(&freq_khz) {
4504        return Err(Status::INVALID_ARGUMENT);
4505    }
4506    Ok(freq_khz)
4507}
4508
4509/// Transmit power is a hardware capability, not a protocol choice: a
4510/// request the radio cannot reach is honored as closely as it can be,
4511/// and the `CMD_PROP_IS` echo of the stored value reports what the host
4512/// actually got. Nothing else advertises the achievable range, so that
4513/// echo is how a host discovers it. Only the width is an error.
4514fn clamp_tx_power(config: &SessionConfig, value: &[u8]) -> Result<i8, Status> {
4515    Ok(parse_i8(value)?.clamp(config.min_tx_power_dbm, config.max_tx_power_dbm))
4516}
4517
4518fn validate_bw_hz(value: &[u8]) -> Result<u32, Status> {
4519    let bw_hz = parse_u32(value)?;
4520    if !SUPPORTED_BW_HZ.contains(&bw_hz) {
4521        return Err(Status::INVALID_ARGUMENT);
4522    }
4523    Ok(bw_hz)
4524}
4525
4526fn validate_sf(value: &[u8]) -> Result<u8, Status> {
4527    let sf = parse_u8(value)?;
4528    if !(5..=12).contains(&sf) {
4529        return Err(Status::INVALID_ARGUMENT);
4530    }
4531    Ok(sf)
4532}
4533
4534fn validate_cr(value: &[u8]) -> Result<u8, Status> {
4535    let cr = parse_u8(value)?;
4536    if !(5..=8).contains(&cr) {
4537        return Err(Status::INVALID_ARGUMENT);
4538    }
4539    Ok(cr)
4540}
4541
4542/// `PROP_TZ_OFFSET`, in minutes east of UTC.
4543///
4544/// Bounded by the real range of civil offsets — UTC−12:00 through
4545/// UTC+14:00 — rather than the width of the field. Everything outside it
4546/// is a byte-order or unit mistake, and a device that accepted one would
4547/// display a confidently wrong local time.
4548fn validate_tz_offset(value: &[u8]) -> Result<i16, Status> {
4549    let minutes = parse_i16(value)?;
4550    if !(-12 * 60..=14 * 60).contains(&minutes) {
4551        return Err(Status::INVALID_ARGUMENT);
4552    }
4553    Ok(minutes)
4554}
4555
4556/// `PROP_GNSS_IDENT_PRECISION`, in location bytes.
4557///
4558/// Zero is rejected rather than read as "advertise nothing": switching the
4559/// advertisement off is what `PROP_GNSS_IDENT_UPDATE` is for, and a
4560/// precision that silently means the opposite of a precision would be a
4561/// trap.
4562fn validate_ident_precision(value: &[u8]) -> Result<u8, Status> {
4563    let precision = parse_u8(value)?;
4564    if !(1..=MAX_IDENT_PRECISION).contains(&precision) {
4565        return Err(Status::INVALID_ARGUMENT);
4566    }
4567    Ok(precision)
4568}
4569
4570/// `PROP_ADVERT_INTERVAL` / `PROP_BEACON_INTERVAL`, in seconds.
4571///
4572/// Zero is the off switch, so the bounds apply only above it. Neither is
4573/// an airtime control — the duty ledger is — but the two ends fail
4574/// differently: too short spends everyone's airtime on this device's
4575/// announcements, while too long is a schedule that has stopped being
4576/// one. Refusing both at the write is cheaper than discovering either on
4577/// the air.
4578fn validate_announce_interval(value: &[u8]) -> Result<u32, Status> {
4579    let seconds = parse_u32(value)?;
4580    if seconds != 0
4581        && !(MIN_AUTO_ANNOUNCE_INTERVAL_S..=MAX_AUTO_ANNOUNCE_INTERVAL_S).contains(&seconds)
4582    {
4583        return Err(Status::INVALID_ARGUMENT);
4584    }
4585    Ok(seconds)
4586}
4587
4588#[cfg(test)]
4589mod tests {
4590    use super::*;
4591    use umsh_crypto::software::{SoftwareAes, SoftwareSha256};
4592
4593    type TestSession = Session<SoftwareAes, SoftwareSha256>;
4594
4595    fn test_engine() -> CryptoEngine<SoftwareAes, SoftwareSha256> {
4596        CryptoEngine::new(SoftwareAes, SoftwareSha256)
4597    }
4598
4599    /// A session with a host attached over a secure transport (the
4600    /// normal state for command dispatch and live-delivery tests).
4601    /// Queueing tests detach it; gate tests re-attach insecurely.
4602    fn test_session() -> TestSession {
4603        let mut session = test_session_with_boot_status(Status::RESET_POWER_ON);
4604        session.attach(true);
4605        session
4606    }
4607
4608    fn test_session_with_boot_status(boot_status: Status) -> TestSession {
4609        let mut session = Session::new(test_config(), boot_status, test_engine());
4610        session.attach(true);
4611        session
4612    }
4613
4614    fn test_config() -> SessionConfig {
4615        SessionConfig {
4616            dev_version: "test-dev/0.1",
4617            default_device_name: "Test UMSH Device",
4618            mtu: 255,
4619            sync_word: 0x1424,
4620            min_tx_power_dbm: -9,
4621            max_tx_power_dbm: 22,
4622            freq_khz_min: 150_000,
4623            freq_khz_max: 960_000,
4624            defaults: RadioSettings {
4625                enabled: false,
4626                freq_khz: 910_525,
4627                bw_hz: 62_500,
4628                sf: 7,
4629                cr_denom: 5,
4630                tx_power_dbm: 14,
4631            },
4632            default_duty_limit: 0xFFFF,
4633            // Each test session gets its own leaked ledger so parallel
4634            // tests never share duty state.
4635            duty: Box::leak(Box::new(DutyLedger::new())),
4636            // Mixed support matrix: voltage and charge state without a
4637            // level, the same shape as the T-1000E profile.
4638            battery: Some(BatteryFields {
4639                voltage: true,
4640                level: false,
4641                charge_state: true,
4642            }),
4643            alert: Some(AlertConfig::DEFAULT),
4644            time: Some(TimeConfig),
4645            gnss: Some(GnssConfig::DEFAULT),
4646            illuminance: true,
4647        }
4648    }
4649
4650    /// A board with neither a clock nor a receiver, for the tests that
4651    /// check the capability gates actually hide the properties.
4652    fn timeless_config() -> SessionConfig {
4653        SessionConfig {
4654            time: None,
4655            gnss: None,
4656            ..test_config()
4657        }
4658    }
4659
4660    /// Drive `handle_frame` and collect emitted frames.
4661    fn dispatch<const TX: usize>(
4662        session: &mut Session<SoftwareAes, SoftwareSha256, TX>,
4663        request: &[u8],
4664        now_ms: u64,
4665    ) -> (Vec<Vec<u8>>, Option<Effect>) {
4666        let mut emitted = Vec::new();
4667        let effect = session.handle_frame(request, now_ms, &mut |bytes: &[u8]| {
4668            emitted.push(bytes.to_vec())
4669        });
4670        (emitted, effect)
4671    }
4672
4673    /// Parse an emitted frame as `CMD_PROP_IS` and return (tid, key, value).
4674    fn parse_prop_is(bytes: &[u8]) -> (u8, u32, Vec<u8>) {
4675        let parsed = Frame::parse(bytes).unwrap();
4676        assert_eq!(parsed.command(), Some(Cmd::PropIs));
4677        let payload = PropPayload::parse(parsed.payload).unwrap();
4678        (parsed.header.tid(), payload.key, payload.value.to_vec())
4679    }
4680
4681    fn expect_status(bytes: &[u8], tid: u8, status: Status) {
4682        let (response_tid, key, value) = parse_prop_is(bytes);
4683        assert_eq!(response_tid, tid);
4684        assert_eq!(key, prop::LAST_STATUS);
4685        assert_eq!(pui::decode(&value).unwrap().0, status.0);
4686    }
4687
4688    fn get(session: &mut TestSession, key: u32) -> Vec<u8> {
4689        let mut buf = [0u8; 16];
4690        let len = frame::prop_get(&mut buf, 1, key).unwrap();
4691        let (emitted, effect) = dispatch(session, &buf[..len], 0);
4692        assert!(effect.is_none());
4693        let (_, response_key, value) = parse_prop_is(&emitted[0]);
4694        assert_eq!(response_key, key);
4695        value
4696    }
4697
4698    fn set(session: &mut TestSession, key: u32, value: &[u8]) -> (Vec<Vec<u8>>, Option<Effect>) {
4699        let mut buf = [0u8; 640];
4700        let len = frame::prop_set(&mut buf, 2, key, value).unwrap();
4701        dispatch(session, &buf[..len], 0)
4702    }
4703
4704    fn send_packet<const TX: usize>(
4705        session: &mut Session<SoftwareAes, SoftwareSha256, TX>,
4706        tid: u8,
4707        data: &[u8],
4708        meta: &[u8],
4709        now_ms: u64,
4710    ) -> (Vec<Vec<u8>>, Option<Effect>) {
4711        let mut buf = [0u8; 320];
4712        let len = frame::str_send(&mut buf, tid, stream::PHY_RAW, data, meta).unwrap();
4713        dispatch(session, &buf[..len], now_ms)
4714    }
4715
4716    fn enable(session: &mut TestSession) {
4717        let (_, effect) = set(session, prop::PHY_ENABLED, &[1]);
4718        assert!(matches!(effect, Some(Effect::ApplyRadio(settings)) if settings.enabled));
4719    }
4720
4721    #[test]
4722    fn nop_replies_ok() {
4723        let mut session = test_session();
4724        let mut buf = [0u8; 4];
4725        let len = frame::nop(&mut buf, 3).unwrap();
4726        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
4727        assert!(effect.is_none());
4728        expect_status(&emitted[0], 3, Status::OK);
4729    }
4730
4731    #[test]
4732    fn reset_returns_to_defaults() {
4733        let mut session = test_session();
4734        enable(&mut session);
4735        set(&mut session, prop::PHY_LORA_SF, &[12]);
4736
4737        let mut buf = [0u8; 4];
4738        let len = frame::reset(&mut buf, 0).unwrap();
4739        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
4740        expect_status(&emitted[0], TID_UNSOLICITED, Status::RESET_SOFTWARE);
4741        let Some(Effect::ApplyRadio(settings)) = effect else {
4742            panic!("expected ApplyRadio, got {effect:?}");
4743        };
4744        assert!(!settings.enabled);
4745        assert_eq!(settings.sf, 7);
4746        assert_eq!(get(&mut session, prop::PHY_ENABLED), [0]);
4747    }
4748
4749    #[test]
4750    fn identity_properties() {
4751        let mut session = test_session();
4752        assert_eq!(get(&mut session, prop::PROTOCOL_VERSION), [6, 0]);
4753        assert_eq!(get(&mut session, prop::DEV_VERSION), b"test-dev/0.1\0");
4754        assert_eq!(get(&mut session, prop::DEV_NAME), b"Test UMSH Device");
4755        assert_eq!(get(&mut session, prop::PHY_MTU), 255u16.to_le_bytes());
4756        assert_eq!(
4757            pui::decode(&get(&mut session, prop::INTERFACE_TYPE))
4758                .unwrap()
4759                .0,
4760            ids::INTERFACE_TYPE
4761        );
4762        // Post-reset LAST_STATUS is the reset reason.
4763        assert_eq!(
4764            pui::decode(&get(&mut session, prop::LAST_STATUS))
4765                .unwrap()
4766                .0,
4767            Status::RESET_POWER_ON.0
4768        );
4769    }
4770
4771    #[test]
4772    fn caps_list_decodes() {
4773        let mut session = test_session();
4774        let raw = get(&mut session, prop::CAPS);
4775        let mut caps = Vec::new();
4776        let mut offset = 0;
4777        while offset < raw.len() {
4778            let (value, used) = pui::decode(&raw[offset..]).unwrap();
4779            caps.push(value);
4780            offset += used;
4781        }
4782        assert_eq!(
4783            caps,
4784            [
4785                cap::WRITABLE_RAW_STREAM,
4786                cap::PHY_DUTY_LIMIT,
4787                cap::DEV_NAME,
4788                cap::PHY_LORA,
4789                cap::HOST_FILTER,
4790                cap::HOST_RX_QUEUE,
4791                cap::HOST_KEYS,
4792                cap::HOST_AUTO_ACK,
4793                cap::SAVE,
4794                cap::DEV_IDENTITY,
4795                cap::REPEATER,
4796                cap::IDENT,
4797                cap::ADVERT,
4798                cap::BATTERY,
4799                cap::ALERT,
4800                cap::TIME,
4801                cap::GNSS,
4802                cap::ILLUMINANCE
4803            ]
4804        );
4805    }
4806
4807    /// The clock and the receiver are separate claims, and a board that
4808    /// makes neither must not have the properties at all.
4809    #[test]
4810    fn caps_omit_time_and_gnss_when_unconfigured() {
4811        let mut session: TestSession =
4812            Session::new(timeless_config(), Status::RESET_POWER_ON, test_engine());
4813        session.attach(true);
4814        let raw = get(&mut session, prop::CAPS);
4815        let mut caps = Vec::new();
4816        let mut offset = 0;
4817        while offset < raw.len() {
4818            let (value, used) = pui::decode(&raw[offset..]).unwrap();
4819            caps.push(value);
4820            offset += used;
4821        }
4822        assert!(!caps.contains(&cap::TIME));
4823        assert!(!caps.contains(&cap::GNSS));
4824
4825        for key in [
4826            prop::TIME,
4827            prop::TZ_OFFSET,
4828            prop::GNSS_ENABLED,
4829            prop::GNSS_LOCATION,
4830            prop::GNSS_ALTITUDE,
4831            prop::GNSS_FIX,
4832            prop::GNSS_PRECISION,
4833            prop::GNSS_SATELLITES,
4834            prop::GNSS_IDENT_UPDATE,
4835            prop::GNSS_IDENT_PRECISION,
4836            prop::GNSS_TIME_TRUST,
4837        ] {
4838            let mut buf = [0u8; 16];
4839            let len = frame::prop_get(&mut buf, 6, key).unwrap();
4840            let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
4841            assert_eq!(effect, None, "prop {key} produced an effect");
4842            let (_, status_key, value) = parse_prop_is(&emitted[0]);
4843            assert_eq!(status_key, prop::LAST_STATUS);
4844            assert_eq!(
4845                pui::decode(&value).unwrap().0,
4846                Status::PROP_NOT_FOUND.0,
4847                "prop {key} is visible without its capability"
4848            );
4849        }
4850    }
4851
4852    /// The clock is the platform's, not the session's: a get defers, a
4853    /// set hands the platform the new value, and "we do not know" is the
4854    /// empty value in both directions.
4855    #[test]
4856    fn time_reads_and_writes_defer_to_the_platform() {
4857        let mut session = test_session();
4858
4859        let mut buf = [0u8; 16];
4860        let len = frame::prop_get(&mut buf, 7, prop::TIME).unwrap();
4861        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
4862        assert!(emitted.is_empty(), "no response until the clock is read");
4863        assert_eq!(effect, Some(Effect::ReadTime { tid: 7 }));
4864
4865        let mut out = Vec::new();
4866        session.respond_time(7, Some(1_780_000_000), &mut |bytes: &[u8]| {
4867            out.push(bytes.to_vec())
4868        });
4869        let (tid, key, value) = parse_prop_is(&out[0]);
4870        assert_eq!((tid, key), (7, prop::TIME));
4871        assert_eq!(value, 1_780_000_000u32.to_le_bytes());
4872
4873        // A device that does not know what time it is says so with the
4874        // empty value rather than failing the read.
4875        let mut out = Vec::new();
4876        session.respond_time(4, None, &mut |bytes: &[u8]| out.push(bytes.to_vec()));
4877        let (_, _, value) = parse_prop_is(&out[0]);
4878        assert_eq!(value, Vec::<u8>::new());
4879
4880        let (_, effect) = set(&mut session, prop::TIME, &1_780_000_042u32.to_le_bytes());
4881        assert_eq!(
4882            effect,
4883            Some(Effect::ApplyTime {
4884                epoch: Some(1_780_000_042)
4885            })
4886        );
4887        // The empty write is not a malformed integer: it is the host
4888        // telling the device to forget what time it is.
4889        let (_, effect) = set(&mut session, prop::TIME, &[]);
4890        assert_eq!(effect, Some(Effect::ApplyTime { epoch: None }));
4891        // A width that is neither is still an error.
4892        let (emitted, effect) = set(&mut session, prop::TIME, &[0, 0]);
4893        assert_eq!(effect, None);
4894        let (_, _, value) = parse_prop_is(&emitted[0]);
4895        assert_eq!(pui::decode(&value).unwrap().0, Status::INVALID_ARGUMENT.0);
4896    }
4897
4898    /// A publication reaches an attached host and nobody else.
4899    #[test]
4900    fn time_publishes_only_while_attached() {
4901        let mut session = test_session();
4902        let mut out = Vec::new();
4903        assert!(
4904            session.publish_time(Some(1_780_000_000), &mut |bytes: &[u8]| {
4905                out.push(bytes.to_vec())
4906            })
4907        );
4908        let (tid, key, value) = parse_prop_is(&out[0]);
4909        assert_eq!((tid, key), (TID_UNSOLICITED, prop::TIME));
4910        assert_eq!(value, 1_780_000_000u32.to_le_bytes());
4911
4912        session.detach();
4913        let mut out = Vec::new();
4914        assert!(
4915            !session.publish_time(Some(1_780_000_000), &mut |bytes: &[u8]| {
4916                out.push(bytes.to_vec())
4917            })
4918        );
4919        assert!(out.is_empty());
4920    }
4921
4922    /// The time zone is configuration, so unlike the clock it always has
4923    /// a value, it is saved, and the session answers it directly.
4924    #[test]
4925    fn timezone_is_always_known_and_bounded() {
4926        let mut session = test_session();
4927        assert_eq!(get(&mut session, prop::TZ_OFFSET), [0, 0]);
4928        assert_eq!(session.tz_offset_min(), 0);
4929
4930        // UTC−08:00.
4931        set(&mut session, prop::TZ_OFFSET, &(-480i16).to_le_bytes());
4932        assert_eq!(get(&mut session, prop::TZ_OFFSET), (-480i16).to_le_bytes());
4933        assert_eq!(session.tz_offset_min(), -480);
4934
4935        // The extremes of the civil range are accepted; past them is a
4936        // unit or byte-order mistake, not a place.
4937        for minutes in [-12 * 60i16, 14 * 60] {
4938            let (_, effect) = set(&mut session, prop::TZ_OFFSET, &minutes.to_le_bytes());
4939            assert_eq!(effect, None);
4940            assert_eq!(session.tz_offset_min(), minutes);
4941        }
4942        for minutes in [-12 * 60i16 - 1, 14 * 60 + 1] {
4943            let (emitted, _) = set(&mut session, prop::TZ_OFFSET, &minutes.to_le_bytes());
4944            let (_, _, value) = parse_prop_is(&emitted[0]);
4945            assert_eq!(pui::decode(&value).unwrap().0, Status::INVALID_ARGUMENT.0);
4946        }
4947    }
4948
4949    /// Positioning telemetry is a measurement: every get samples, and the
4950    /// answer is whatever the platform reports right now.
4951    #[test]
4952    fn positioning_gets_sample_and_are_never_writable() {
4953        let mut session = test_session();
4954        let mut buf = [0u8; 16];
4955        let len = frame::prop_get(&mut buf, 5, prop::GNSS_LOCATION).unwrap();
4956        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
4957        assert!(emitted.is_empty());
4958        assert_eq!(
4959            effect,
4960            Some(Effect::SampleGnss {
4961                tid: 5,
4962                key: prop::GNSS_LOCATION
4963            })
4964        );
4965
4966        let mut snapshot = GnssSnapshot::SEARCHING;
4967        snapshot.fix = umsh_ulcp::gnss::FixKind::ThreeD;
4968        snapshot.altitude_m = Some(112);
4969        snapshot.accuracy_dm = Some(45);
4970        snapshot.sats_used = 8;
4971        snapshot.sats_in_view = Some(11);
4972        snapshot.set_location(&[0x8a, 0x1f, 0x4c, 0x00, 0xd3]);
4973        let mut out = Vec::new();
4974        session.respond_gnss(
4975            5,
4976            prop::GNSS_LOCATION,
4977            Ok(snapshot),
4978            &mut |bytes: &[u8]| out.push(bytes.to_vec()),
4979        );
4980        let (tid, key, value) = parse_prop_is(&out[0]);
4981        assert_eq!((tid, key), (5, prop::GNSS_LOCATION));
4982        assert_eq!(value, [0x8a, 0x1f, 0x4c, 0x00, 0xd3]);
4983
4984        // None of the five accepts a write.
4985        for key in [
4986            prop::GNSS_LOCATION,
4987            prop::GNSS_ALTITUDE,
4988            prop::GNSS_FIX,
4989            prop::GNSS_PRECISION,
4990            prop::GNSS_SATELLITES,
4991        ] {
4992            let (emitted, _) = set(&mut session, key, &[0]);
4993            let (_, _, value) = parse_prop_is(&emitted[0]);
4994            assert_eq!(
4995                pui::decode(&value).unwrap().0,
4996                Status::INVALID_ARGUMENT.0,
4997                "prop {key} accepted a write"
4998            );
4999        }
5000    }
5001
5002    /// A receiver that is off still answers the two questions it is sure
5003    /// of, and stays silent about the position it does not have.
5004    #[test]
5005    fn a_searching_receiver_reports_zero_rather_than_nothing() {
5006        let mut session = test_session();
5007        for (key, expected) in [
5008            (prop::GNSS_FIX, vec![0u8]),
5009            (prop::GNSS_SATELLITES, vec![0u8]),
5010            (prop::GNSS_LOCATION, vec![]),
5011            (prop::GNSS_ALTITUDE, vec![]),
5012            (prop::GNSS_PRECISION, vec![]),
5013        ] {
5014            let mut out = Vec::new();
5015            session.respond_gnss(
5016                3,
5017                key,
5018                Ok(GnssSnapshot::SEARCHING),
5019                &mut |bytes: &[u8]| out.push(bytes.to_vec()),
5020            );
5021            let (_, answered, value) = parse_prop_is(&out[0]);
5022            assert_eq!(answered, key);
5023            assert_eq!(value, expected, "prop {key}");
5024        }
5025    }
5026
5027    /// The receiver switch and the positioning policy are device-domain
5028    /// settings: readable, bounded, saved, and restored.
5029    #[test]
5030    fn gnss_settings_round_trip_and_are_bounded() {
5031        let mut session = test_session();
5032        assert_eq!(get(&mut session, prop::GNSS_ENABLED), [0]);
5033        assert_eq!(get(&mut session, prop::GNSS_IDENT_UPDATE), [0]);
5034        assert_eq!(
5035            get(&mut session, prop::GNSS_IDENT_PRECISION),
5036            [DEFAULT_IDENT_PRECISION]
5037        );
5038        assert_eq!(get(&mut session, prop::GNSS_TIME_TRUST), [1]);
5039        assert!(!session.gnss_enabled());
5040        assert!(session.gnss_time_trust());
5041
5042        set(&mut session, prop::GNSS_ENABLED, &[1]);
5043        set(&mut session, prop::GNSS_IDENT_UPDATE, &[1]);
5044        set(&mut session, prop::GNSS_IDENT_PRECISION, &[3]);
5045        set(&mut session, prop::GNSS_TIME_TRUST, &[0]);
5046        assert!(session.gnss_enabled());
5047        assert!(session.gnss_ident_update());
5048        assert_eq!(session.gnss_ident_precision(), 3);
5049        assert!(!session.gnss_time_trust());
5050
5051        // Precision names a location width; zero and past the maximum are
5052        // both outside it. Turning the advertisement off is a different
5053        // property's job.
5054        for precision in [0u8, MAX_IDENT_PRECISION + 1] {
5055            let (emitted, _) = set(&mut session, prop::GNSS_IDENT_PRECISION, &[precision]);
5056            let (_, _, value) = parse_prop_is(&emitted[0]);
5057            assert_eq!(pui::decode(&value).unwrap().0, Status::INVALID_ARGUMENT.0);
5058        }
5059        assert_eq!(session.gnss_ident_precision(), 3);
5060    }
5061
5062    /// A board whose job is to know where it is boots its receiver on,
5063    /// and every path that decides "what does unconfigured mean" agrees.
5064    ///
5065    /// The subtle one is the saved baseline. A snapshot omits nothing
5066    /// scalar, but the baseline is what an *older* snapshot's absent
5067    /// options decode against — so if `SavedState::defaults` kept saying
5068    /// `false` here, restoring such a snapshot would switch the receiver
5069    /// off on the one board that wants it on.
5070    #[test]
5071    fn a_board_can_boot_its_receiver_on() {
5072        let config = SessionConfig {
5073            gnss: Some(GnssConfig::ALWAYS_ON),
5074            ..test_config()
5075        };
5076        let always_on = || {
5077            let mut session = Session::new(config, Status::RESET_POWER_ON, test_engine());
5078            session.attach(true);
5079            session
5080        };
5081
5082        let mut session = always_on();
5083        assert_eq!(get(&mut session, prop::GNSS_ENABLED), [1]);
5084        assert!(session.gnss_enabled());
5085
5086        // Switching it off and saving means off — a board default is a
5087        // starting point, not a policy the operator has to fight.
5088        set(&mut session, prop::GNSS_ENABLED, &[0]);
5089        let mut buf = [0u8; 512];
5090        let len = session.encode_snapshot(&mut buf).expect("snapshot");
5091        let saved = SavedState::decode(&config, &buf[..len]).expect("decode");
5092        assert!(!saved.gnss_enabled);
5093
5094        // And `CMD_RST` returns to the board default, not the protocol's.
5095        let mut fresh = always_on();
5096        set(&mut fresh, prop::GNSS_ENABLED, &[0]);
5097        assert!(!fresh.gnss_enabled());
5098        fresh.reset(Status::RESET_SOFTWARE, &mut |_: &[u8]| {});
5099        assert!(fresh.gnss_enabled(), "reset dropped the board default");
5100    }
5101
5102    /// A switch the operator can reach moves the property, the mirror,
5103    /// and an attached host's view of it.
5104    #[test]
5105    fn a_local_toggle_flips_the_switch_and_announces_it() {
5106        let mut session = test_session();
5107        assert!(!session.gnss_enabled());
5108        let before = session.dev_domain_version();
5109
5110        let mut emitted = Vec::new();
5111        let enabled = session.toggle_gnss(&mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
5112        assert_eq!(enabled, Some(true));
5113        assert!(session.gnss_enabled());
5114        assert_eq!(get(&mut session, prop::GNSS_ENABLED), [1]);
5115        assert_ne!(
5116            session.dev_domain_version(),
5117            before,
5118            "the receiver never heard about it"
5119        );
5120        let (tid, key, value) = parse_prop_is(&emitted[0]);
5121        assert_eq!(
5122            (tid, key, value),
5123            (TID_UNSOLICITED, prop::GNSS_ENABLED, vec![1])
5124        );
5125
5126        // It is a toggle, not a set.
5127        emitted.clear();
5128        assert_eq!(
5129            session.toggle_gnss(&mut |bytes: &[u8]| emitted.push(bytes.to_vec())),
5130            Some(false)
5131        );
5132        assert!(!session.gnss_enabled());
5133    }
5134
5135    /// A press on a board with no receiver is nothing at all, so a board
5136    /// can report the press without knowing what it has.
5137    #[test]
5138    fn a_local_toggle_without_the_capability_is_inert() {
5139        let mut session: TestSession =
5140            Session::new(timeless_config(), Status::RESET_POWER_ON, test_engine());
5141        let mut emitted = Vec::new();
5142        assert_eq!(
5143            session.toggle_gnss(&mut |bytes: &[u8]| emitted.push(bytes.to_vec())),
5144            None
5145        );
5146        assert!(emitted.is_empty());
5147    }
5148
5149    /// A board without a receiver reports the switch as off rather than
5150    /// leaving the platform to ask whether it has one.
5151    #[test]
5152    fn gnss_accessors_are_false_without_the_capability() {
5153        let session: TestSession =
5154            Session::new(timeless_config(), Status::RESET_POWER_ON, test_engine());
5155        assert!(!session.gnss_enabled());
5156        assert!(!session.gnss_ident_update());
5157    }
5158
5159    #[test]
5160    fn advertisement_policy_round_trips_and_survives_a_reboot() {
5161        let mut session = test_session();
5162        assert_eq!(
5163            get(&mut session, prop::ADVERT_INTERVAL),
5164            DEFAULT_ADVERT_INTERVAL_S.to_le_bytes()
5165        );
5166        assert_eq!(
5167            get(&mut session, prop::BEACON_INTERVAL),
5168            DEFAULT_BEACON_INTERVAL_S.to_le_bytes()
5169        );
5170        assert_eq!(get(&mut session, prop::STARTUP_BEACON), [1]);
5171
5172        let (emitted, effect) = set(&mut session, prop::ADVERT_INTERVAL, &7200u32.to_le_bytes());
5173        assert!(effect.is_none());
5174        let (_, key, value) = parse_prop_is(&emitted[0]);
5175        assert_eq!(key, prop::ADVERT_INTERVAL);
5176        assert_eq!(value, 7200u32.to_le_bytes());
5177        // Zero is the off switch, and each interval moves alone.
5178        set(&mut session, prop::BEACON_INTERVAL, &0u32.to_le_bytes());
5179        set(&mut session, prop::STARTUP_BEACON, &[0]);
5180        assert_eq!(session.advert_interval_s(), 7200);
5181        assert_eq!(session.beacon_interval_s(), 0);
5182        assert!(!session.startup_beacon());
5183
5184        save(&mut session);
5185        let mut bytes = [0u8; SNAPSHOT_MAX];
5186        let len = session.encode_snapshot(&mut bytes).unwrap();
5187        let mut booted: TestSession =
5188            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
5189        booted.restore_at_boot(&bytes[..len]).unwrap();
5190        assert_eq!(booted.advert_interval_s(), 7200);
5191        assert_eq!(booted.beacon_interval_s(), 0);
5192        assert!(!booted.startup_beacon());
5193    }
5194
5195    /// The bounds exist to catch a mistyped interval, so they must not
5196    /// also catch the one value that legitimately means "never", and both
5197    /// ends themselves have to be reachable.
5198    #[test]
5199    fn announce_interval_holds_to_its_bounds_but_accepts_zero() {
5200        let mut session = test_session();
5201        for &key in &[prop::ADVERT_INTERVAL, prop::BEACON_INTERVAL] {
5202            for rejected in [
5203                MIN_AUTO_ANNOUNCE_INTERVAL_S - 1,
5204                MAX_AUTO_ANNOUNCE_INTERVAL_S + 1,
5205                u32::MAX,
5206            ] {
5207                let (emitted, _) = set(&mut session, key, &rejected.to_le_bytes());
5208                expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
5209            }
5210
5211            for accepted in [
5212                MIN_AUTO_ANNOUNCE_INTERVAL_S,
5213                MAX_AUTO_ANNOUNCE_INTERVAL_S,
5214                // Zero is the off switch, not a too-short interval.
5215                0,
5216            ] {
5217                let (emitted, _) = set(&mut session, key, &accepted.to_le_bytes());
5218                let (_, response_key, value) = parse_prop_is(&emitted[0]);
5219                assert_eq!(response_key, key);
5220                assert_eq!(value, accepted.to_le_bytes());
5221            }
5222        }
5223    }
5224
5225    /// A snapshot written before these properties existed carries none of
5226    /// them, and absence has to decode as the documented default rather
5227    /// than as zero — otherwise an upgrade would silently switch every
5228    /// automatic announcement off.
5229    #[test]
5230    fn a_snapshot_without_advertisement_options_restores_the_defaults() {
5231        let mut session = test_session();
5232        set(&mut session, prop::ADVERT_INTERVAL, &0u32.to_le_bytes());
5233        set(&mut session, prop::STARTUP_BEACON, &[0]);
5234        save(&mut session);
5235        let mut bytes = [0u8; SNAPSHOT_MAX];
5236        let len = session.encode_snapshot(&mut bytes).unwrap();
5237
5238        // Strip the three advertisement options, leaving what an older
5239        // writer would have produced.
5240        let stripped = strip_snapshot_options(
5241            &bytes[..len],
5242            &[
5243                prop::ADVERT_INTERVAL,
5244                prop::BEACON_INTERVAL,
5245                prop::STARTUP_BEACON,
5246            ],
5247        );
5248
5249        let mut booted: TestSession =
5250            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
5251        booted.restore_at_boot(&stripped).unwrap();
5252        assert_eq!(booted.advert_interval_s(), DEFAULT_ADVERT_INTERVAL_S);
5253        assert_eq!(booted.beacon_interval_s(), DEFAULT_BEACON_INTERVAL_S);
5254        assert!(booted.startup_beacon());
5255    }
5256
5257    /// Role, mobility and forwarding are three independent dimensions.
5258    /// The property surface has to keep them that way: forwarding is a
5259    /// fact the device reports, the role is what it presents itself as,
5260    /// and mobility is neither.
5261    #[test]
5262    fn identity_role_and_mobility_are_independent_of_forwarding() {
5263        let mut session = test_session();
5264        // Defaults: derive the role, not mobile.
5265        assert_eq!(get(&mut session, prop::IDENT_ROLE), Vec::<u8>::new());
5266        assert_eq!(get(&mut session, prop::IDENT_MOBILE), [0]);
5267
5268        // Enabling forwarding does not touch either property; the role
5269        // stays "derive it", and derivation happens where the identity
5270        // is actually built.
5271        set(&mut session, prop::MAC_REPEATER_ENABLED, &[1]);
5272        assert_eq!(get(&mut session, prop::IDENT_ROLE), Vec::<u8>::new());
5273
5274        // A mobile repeater: an explicit role plus the mobility bit,
5275        // with forwarding still on.
5276        let (emitted, effect) = set(&mut session, prop::IDENT_ROLE, &[1]);
5277        assert!(effect.is_none());
5278        let (_, key, value) = parse_prop_is(&emitted[0]);
5279        assert_eq!(key, prop::IDENT_ROLE);
5280        assert_eq!(value, [1]);
5281        set(&mut session, prop::IDENT_MOBILE, &[1]);
5282        assert_eq!(get(&mut session, prop::IDENT_ROLE), [1]);
5283        assert_eq!(get(&mut session, prop::IDENT_MOBILE), [1]);
5284        assert_eq!(get(&mut session, prop::MAC_REPEATER_ENABLED), [1]);
5285
5286        // Clearing the role returns it to derivation.
5287        set(&mut session, prop::IDENT_ROLE, &[]);
5288        assert_eq!(get(&mut session, prop::IDENT_ROLE), Vec::<u8>::new());
5289
5290        // Both are saved device-domain state.
5291        set(&mut session, prop::IDENT_ROLE, &[3]);
5292        save(&mut session);
5293        let mut bytes = [0u8; SNAPSHOT_MAX];
5294        let len = session.encode_snapshot(&mut bytes).unwrap();
5295        let mut booted: TestSession =
5296            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
5297        booted.restore_at_boot(&bytes[..len]).unwrap();
5298        assert_eq!(booted.ident_role(), Some(3));
5299        assert!(booted.ident_mobile());
5300        booted.attach(true);
5301        assert_eq!(get(&mut booted, prop::IDENT_ROLE), [3]);
5302        assert_eq!(get(&mut booted, prop::IDENT_MOBILE), [1]);
5303
5304        // Out-of-range values are refused rather than truncated.
5305        let (emitted, _) = set(&mut booted, prop::IDENT_ROLE, &[1, 2]);
5306        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
5307        let (emitted, _) = set(&mut booted, prop::IDENT_MOBILE, &[2]);
5308        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
5309    }
5310
5311    /// Discoverability defaults on — a deployed device being askable is
5312    /// most of the point — and the opt-out is saved device-domain state.
5313    #[test]
5314    fn dev_discoverable_defaults_on_and_the_opt_out_survives_reboot() {
5315        let mut session = test_session();
5316        assert_eq!(get(&mut session, prop::DEV_DISCOVERABLE), [1]);
5317        assert!(session.dev_discoverable());
5318
5319        let (emitted, effect) = set(&mut session, prop::DEV_DISCOVERABLE, &[0]);
5320        assert!(effect.is_none());
5321        let (_, key, value) = parse_prop_is(&emitted[0]);
5322        assert_eq!(key, prop::DEV_DISCOVERABLE);
5323        assert_eq!(value, [0]);
5324        assert!(!session.dev_discoverable());
5325
5326        save(&mut session);
5327        let mut bytes = [0u8; SNAPSHOT_MAX];
5328        let len = session.encode_snapshot(&mut bytes).unwrap();
5329        let mut booted: TestSession =
5330            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
5331        booted.restore_at_boot(&bytes[..len]).unwrap();
5332        assert!(!booted.dev_discoverable());
5333        booted.attach(true);
5334        assert_eq!(get(&mut booted, prop::DEV_DISCOVERABLE), [0]);
5335
5336        // Bool discipline: anything but 0 or 1 is refused.
5337        let (emitted, _) = set(&mut booted, prop::DEV_DISCOVERABLE, &[2]);
5338        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
5339    }
5340
5341    /// `PROP_IDENT` is a signature the session cannot produce, so the
5342    /// read defers to the platform and the platform's answer — success
5343    /// or failure — is what the host sees.
5344    #[test]
5345    fn prop_ident_defers_to_the_platform_for_signing() {
5346        let mut session = test_session();
5347        let mut buf = [0u8; 16];
5348        let len = frame::prop_get(&mut buf, 7, prop::IDENT).unwrap();
5349        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
5350        assert!(emitted.is_empty(), "nothing before the signature exists");
5351        assert_eq!(effect, Some(Effect::SignIdentity { tid: 7 }));
5352
5353        let blob = [0xAB; 96];
5354        let mut out = Vec::new();
5355        session.respond_identity_blob(7, Ok(&blob), &mut |bytes: &[u8]| out.push(bytes.to_vec()));
5356        let (tid, key, value) = parse_prop_is(&out[0]);
5357        assert_eq!((tid, key), (7, prop::IDENT));
5358        assert_eq!(value, blob);
5359
5360        // A board with no device node reports failure rather than an
5361        // empty or fabricated identity.
5362        let (_, effect) = dispatch(&mut session, &buf[..len], 0);
5363        assert_eq!(effect, Some(Effect::SignIdentity { tid: 7 }));
5364        let mut out = Vec::new();
5365        session.respond_identity_blob(7, Err(()), &mut |bytes: &[u8]| out.push(bytes.to_vec()));
5366        expect_status(&out[0], 7, Status::FAILURE);
5367    }
5368
5369    #[test]
5370    fn repeater_enable_round_trips_and_persists() {
5371        let mut session = test_session();
5372        // Defaults off, accepted before any identity is provisioned
5373        // (store-and-defer), and echoes the authoritative value back.
5374        assert_eq!(get(&mut session, prop::MAC_REPEATER_ENABLED), [0]);
5375        let (emitted, effect) = set(&mut session, prop::MAC_REPEATER_ENABLED, &[1]);
5376        let (_, key, value) = parse_prop_is(&emitted[0]);
5377        assert_eq!(key, prop::MAC_REPEATER_ENABLED);
5378        assert_eq!(value, [1]);
5379        // Not radio-affecting; no ApplyRadio effect.
5380        assert_eq!(effect, None);
5381        assert_eq!(get(&mut session, prop::MAC_REPEATER_ENABLED), [1]);
5382        assert!(session.repeater_enabled());
5383        // Only a boolean is accepted.
5384        let (emitted, _) = set(&mut session, prop::MAC_REPEATER_ENABLED, &[2]);
5385        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
5386
5387        // Survives save + boot-from-snapshot.
5388        save(&mut session);
5389        let mut bytes = [0u8; SNAPSHOT_MAX];
5390        let len = session.encode_snapshot(&mut bytes).unwrap();
5391        let mut booted: TestSession =
5392            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
5393        booted.restore_at_boot(&bytes[..len]).unwrap();
5394        assert!(booted.repeater_enabled());
5395        booted.attach(true);
5396        assert_eq!(get(&mut booted, prop::MAC_REPEATER_ENABLED), [1]);
5397
5398        // A fresh unprovisioned session defaults off.
5399        let fresh: TestSession = Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
5400        assert!(!fresh.repeater_enabled());
5401    }
5402
5403    /// The four forwarding gates are configurable while forwarding is
5404    /// off — an administrator stages the policy and enables it last —
5405    /// and each survives save and boot-from-snapshot.
5406    #[test]
5407    fn repeater_policy_round_trips_and_persists() {
5408        let mut session = test_session();
5409
5410        // Post-reset every gate is unset, which reads as empty.
5411        assert_eq!(
5412            get(&mut session, prop::MAC_REPEATER_REGIONS),
5413            Vec::<u8>::new()
5414        );
5415        assert_eq!(
5416            get(&mut session, prop::MAC_REPEATER_DEFAULT_REGION),
5417            Vec::<u8>::new()
5418        );
5419        assert_eq!(
5420            get(&mut session, prop::MAC_REPEATER_MIN_RSSI),
5421            Vec::<u8>::new()
5422        );
5423        assert_eq!(
5424            get(&mut session, prop::MAC_REPEATER_MIN_SNR),
5425            Vec::<u8>::new()
5426        );
5427
5428        // Written with forwarding still disabled.
5429        assert_eq!(get(&mut session, prop::MAC_REPEATER_ENABLED), [0]);
5430        let (emitted, effect) = set(
5431            &mut session,
5432            prop::MAC_REPEATER_REGIONS,
5433            &[0x78, 0x53, 0x31, 0xD9],
5434        );
5435        assert_eq!(effect, None, "policy is not radio-affecting");
5436        let (_, key, value) = parse_prop_is(&emitted[0]);
5437        assert_eq!(key, prop::MAC_REPEATER_REGIONS);
5438        assert_eq!(value, [0x78, 0x53, 0x31, 0xD9]);
5439        set(
5440            &mut session,
5441            prop::MAC_REPEATER_DEFAULT_REGION,
5442            &[0x78, 0x53],
5443        );
5444        // −115 dBm, little-endian.
5445        set(&mut session, prop::MAC_REPEATER_MIN_RSSI, &[0x8D, 0xFF]);
5446        // −7 dB.
5447        set(&mut session, prop::MAC_REPEATER_MIN_SNR, &[0xF9]);
5448
5449        assert_eq!(session.repeater_regions(), [0x78, 0x53, 0x31, 0xD9]);
5450        assert_eq!(session.repeater_default_region(), Some([0x78, 0x53]));
5451        assert_eq!(session.repeater_min_rssi(), Some(-115));
5452        assert_eq!(session.repeater_min_snr(), Some(-7));
5453
5454        // Survives save + boot-from-snapshot.
5455        save(&mut session);
5456        let mut bytes = [0u8; SNAPSHOT_MAX];
5457        let len = session.encode_snapshot(&mut bytes).unwrap();
5458        let mut booted: TestSession =
5459            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
5460        booted.restore_at_boot(&bytes[..len]).unwrap();
5461        assert_eq!(booted.repeater_regions(), [0x78, 0x53, 0x31, 0xD9]);
5462        assert_eq!(booted.repeater_default_region(), Some([0x78, 0x53]));
5463        assert_eq!(booted.repeater_min_rssi(), Some(-115));
5464        assert_eq!(booted.repeater_min_snr(), Some(-7));
5465        booted.attach(true);
5466        assert_eq!(
5467            get(&mut booted, prop::MAC_REPEATER_MIN_RSSI),
5468            [0x8D, 0xFF],
5469            "the reported value is the little-endian INT16 that was written"
5470        );
5471        assert_eq!(get(&mut booted, prop::MAC_REPEATER_MIN_SNR), [0xF9]);
5472    }
5473
5474    /// Every gate clears back to unset by writing it empty, and a
5475    /// snapshot taken with them unset carries no option at all — which
5476    /// is what makes absence and the default decode identically.
5477    #[test]
5478    fn repeater_policy_clears_back_to_unset() {
5479        let mut session = test_session();
5480        set(&mut session, prop::MAC_REPEATER_REGIONS, &[0x78, 0x53]);
5481        set(
5482            &mut session,
5483            prop::MAC_REPEATER_DEFAULT_REGION,
5484            &[0x78, 0x53],
5485        );
5486        set(&mut session, prop::MAC_REPEATER_MIN_RSSI, &[0x8D, 0xFF]);
5487        set(&mut session, prop::MAC_REPEATER_MIN_SNR, &[0xF9]);
5488
5489        set(&mut session, prop::MAC_REPEATER_REGIONS, &[]);
5490        set(&mut session, prop::MAC_REPEATER_DEFAULT_REGION, &[]);
5491        set(&mut session, prop::MAC_REPEATER_MIN_RSSI, &[]);
5492        set(&mut session, prop::MAC_REPEATER_MIN_SNR, &[]);
5493
5494        assert_eq!(session.repeater_regions(), Vec::<u8>::new());
5495        assert_eq!(session.repeater_default_region(), None);
5496        assert_eq!(session.repeater_min_rssi(), None);
5497        assert_eq!(session.repeater_min_snr(), None);
5498
5499        save(&mut session);
5500        let mut bytes = [0u8; SNAPSHOT_MAX];
5501        let len = session.encode_snapshot(&mut bytes).unwrap();
5502        let mut booted: TestSession =
5503            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
5504        booted.restore_at_boot(&bytes[..len]).unwrap();
5505        assert_eq!(booted.repeater_regions(), Vec::<u8>::new());
5506        assert_eq!(booted.repeater_default_region(), None);
5507        assert_eq!(booted.repeater_min_rssi(), None);
5508        assert_eq!(booted.repeater_min_snr(), None);
5509    }
5510
5511    /// A malformed gate is refused outright rather than truncated or
5512    /// rounded into range, so a host never believes it configured a
5513    /// policy the device did not accept.
5514    #[test]
5515    fn repeater_policy_rejects_malformed_values() {
5516        let mut session = test_session();
5517
5518        // Region lists are whole codes; an odd length is malformed, not
5519        // merely oversized.
5520        let (emitted, _) = set(
5521            &mut session,
5522            prop::MAC_REPEATER_REGIONS,
5523            &[0x78, 0x53, 0x31],
5524        );
5525        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
5526        // More codes than the device can hold is a capacity answer.
5527        let over_capacity = [0xAAu8; (MAX_REPEATER_REGIONS + 1) * REGION_CODE_LEN];
5528        let (emitted, _) = set(&mut session, prop::MAC_REPEATER_REGIONS, &over_capacity);
5529        expect_status(&emitted[0], 2, Status::NOMEM);
5530
5531        // The default region is exactly one code or nothing.
5532        let (emitted, _) = set(
5533            &mut session,
5534            prop::MAC_REPEATER_DEFAULT_REGION,
5535            &[0x78, 0x53, 0x31],
5536        );
5537        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
5538
5539        // The thresholds are fixed-width.
5540        let (emitted, _) = set(&mut session, prop::MAC_REPEATER_MIN_RSSI, &[0x8D]);
5541        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
5542        let (emitted, _) = set(&mut session, prop::MAC_REPEATER_MIN_SNR, &[0xF9, 0xFF]);
5543        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
5544
5545        // Nothing was partially applied.
5546        assert_eq!(session.repeater_regions(), Vec::<u8>::new());
5547        assert_eq!(session.repeater_default_region(), None);
5548        assert_eq!(session.repeater_min_rssi(), None);
5549        assert_eq!(session.repeater_min_snr(), None);
5550    }
5551
5552    /// The default region is not required to appear in the region list.
5553    /// The two are written separately and in either order, so a
5554    /// membership check here would reject a legitimate write purely for
5555    /// arriving first.
5556    #[test]
5557    fn repeater_default_region_is_not_cross_checked_against_the_region_list() {
5558        let mut session = test_session();
5559        // Default first, list second.
5560        set(
5561            &mut session,
5562            prop::MAC_REPEATER_DEFAULT_REGION,
5563            &[0x78, 0x53],
5564        );
5565        assert_eq!(session.repeater_default_region(), Some([0x78, 0x53]));
5566        set(&mut session, prop::MAC_REPEATER_REGIONS, &[0x31, 0xD9]);
5567        assert_eq!(session.repeater_regions(), [0x31, 0xD9]);
5568        assert_eq!(
5569            session.repeater_default_region(),
5570            Some([0x78, 0x53]),
5571            "a later region-list write must not silently drop the default"
5572        );
5573
5574        // A default with no list at all is equally allowed: filtering and
5575        // tagging are independent decisions.
5576        let mut session = test_session();
5577        set(
5578            &mut session,
5579            prop::MAC_REPEATER_DEFAULT_REGION,
5580            &[0xAB, 0xCD],
5581        );
5582        assert_eq!(session.repeater_regions(), Vec::<u8>::new());
5583        assert_eq!(session.repeater_default_region(), Some([0xAB, 0xCD]));
5584    }
5585
5586    #[test]
5587    fn device_name_round_trips_survives_attach_and_resets_to_default() {
5588        let mut session = test_session();
5589        let configured = "Field Radio 📻";
5590        let (emitted, effect) = set(&mut session, prop::DEV_NAME, configured.as_bytes());
5591        let (_, key, value) = parse_prop_is(&emitted[0]);
5592        assert_eq!(key, prop::DEV_NAME);
5593        assert_eq!(value, configured.as_bytes());
5594        assert_eq!(effect, Some(Effect::DeviceNameChanged));
5595        assert_eq!(session.device_name(), configured);
5596
5597        session.attach(true);
5598        assert_eq!(get(&mut session, prop::DEV_NAME), configured.as_bytes());
5599
5600        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_| {});
5601        assert_eq!(get(&mut session, prop::DEV_NAME), b"Test UMSH Device");
5602    }
5603
5604    #[test]
5605    fn attach_preserves_device_domain_and_emits_nothing() {
5606        let mut session = test_session_with_boot_status(Status::RESET_WATCHDOG);
5607
5608        // Configure and enable the PHY, adjust the duty limit, and
5609        // record duty usage.
5610        set(&mut session, prop::PHY_FREQ, &906_875u32.to_le_bytes());
5611        set(&mut session, prop::PHY_LORA_SF, &[9]);
5612        set(&mut session, prop::PHY_DUTY_LIMIT, &100u16.to_le_bytes());
5613        enable(&mut session);
5614        let settings_before = session.settings();
5615        assert!(settings_before.enabled);
5616        let (_, effect) = send_packet(&mut session, 1, &[0xAB; 8], &[], 0);
5617        assert_eq!(effect, Some(Effect::StartTransmit));
5618        let mut emitted = Vec::new();
5619        session.on_tx_result(TxOutcome::Sent, 0, &mut |bytes: &[u8]| {
5620            emitted.push(bytes.to_vec())
5621        });
5622        let duty_before = get(&mut session, prop::PHY_DUTY_NOW);
5623        assert_ne!(duty_before, 0u16.to_le_bytes());
5624
5625        // Attach must not reconfigure or disable the PHY, must not
5626        // touch the duty limit or accounting, and must emit nothing.
5627        session.attach(true);
5628        assert_eq!(session.settings(), settings_before);
5629        assert_eq!(get(&mut session, prop::PHY_ENABLED), [1]);
5630        assert_eq!(get(&mut session, prop::PHY_FREQ), 906_875u32.to_le_bytes());
5631        assert_eq!(
5632            get(&mut session, prop::PHY_DUTY_LIMIT),
5633            100u16.to_le_bytes()
5634        );
5635        assert_eq!(get(&mut session, prop::PHY_DUTY_NOW), duty_before);
5636    }
5637
5638    #[test]
5639    fn attach_retains_boot_status_for_first_query() {
5640        let mut session = test_session_with_boot_status(Status::RESET_WATCHDOG);
5641        session.attach(true);
5642        let raw = get(&mut session, prop::LAST_STATUS);
5643        assert_eq!(pui::decode(&raw).unwrap().0, Status::RESET_WATCHDOG.0);
5644    }
5645
5646    #[test]
5647    fn attach_resets_promiscuous_mode() {
5648        let mut session = test_session();
5649        set(&mut session, prop::MAC_PROMISCUOUS, &[1]);
5650        assert_eq!(get(&mut session, prop::MAC_PROMISCUOUS), [1]);
5651        session.attach(true);
5652        assert_eq!(get(&mut session, prop::MAC_PROMISCUOUS), [0]);
5653
5654        // Detach discards session state the same way.
5655        set(&mut session, prop::MAC_PROMISCUOUS, &[1]);
5656        session.detach();
5657        session.attach(true);
5658        assert_eq!(get(&mut session, prop::MAC_PROMISCUOUS), [0]);
5659
5660        // BOOL validation.
5661        let (emitted, _) = set(&mut session, prop::MAC_PROMISCUOUS, &[2]);
5662        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
5663    }
5664
5665    #[test]
5666    fn attach_clears_pending_transmit_correlation() {
5667        let mut session = test_session();
5668        enable(&mut session);
5669        let (_, effect) = send_packet(&mut session, 3, &[0x01; 4], &[], 0);
5670        assert_eq!(effect, Some(Effect::StartTransmit));
5671        assert!(session.has_pending_tx());
5672
5673        // The requesting session is gone; its TID correlation must not
5674        // leak into the successor.
5675        session.attach(true);
5676        assert!(!session.has_pending_tx());
5677        let mut emitted = Vec::new();
5678        session.on_tx_result(TxOutcome::Sent, 0, &mut |bytes: &[u8]| {
5679            emitted.push(bytes.to_vec())
5680        });
5681        assert!(emitted.is_empty());
5682
5683        // The new session is free to transmit (no stale BUSY).
5684        let (_, effect) = send_packet(&mut session, 4, &[0x02; 4], &[], 0);
5685        assert_eq!(effect, Some(Effect::StartTransmit));
5686    }
5687
5688    #[test]
5689    fn reset_restores_post_reset_values_and_announces() {
5690        let mut session = test_session();
5691        set(&mut session, prop::PHY_FREQ, &906_875u32.to_le_bytes());
5692        set(&mut session, prop::MAC_PROMISCUOUS, &[1]);
5693        enable(&mut session);
5694
5695        let mut emitted = Vec::new();
5696        let effect = session.reset(Status::RESET_SOFTWARE, &mut |bytes: &[u8]| {
5697            emitted.push(bytes.to_vec())
5698        });
5699        expect_status(&emitted[0], TID_UNSOLICITED, Status::RESET_SOFTWARE);
5700        assert!(matches!(effect, Effect::ApplyRadio(settings) if !settings.enabled));
5701        assert_eq!(get(&mut session, prop::PHY_FREQ), 910_525u32.to_le_bytes());
5702        assert_eq!(get(&mut session, prop::MAC_PROMISCUOUS), [0]);
5703    }
5704
5705    #[test]
5706    fn device_name_rejects_empty_invalid_nul_and_oversize_values() {
5707        let mut session = test_session();
5708        let oversize = [b'x'; MAX_DEVICE_NAME_LEN + 1];
5709        for bad in [&[][..], &[0xff][..], b"bad\0name", &oversize[..]] {
5710            let (emitted, effect) = set(&mut session, prop::DEV_NAME, bad);
5711            assert!(effect.is_none());
5712            expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
5713        }
5714        assert_eq!(session.device_name(), "Test UMSH Device");
5715    }
5716
5717    #[test]
5718    fn rf_property_round_trip() {
5719        let mut session = test_session();
5720        let (emitted, effect) = set(&mut session, prop::PHY_FREQ, &906_875u32.to_le_bytes());
5721        let (_, key, value) = parse_prop_is(&emitted[0]);
5722        assert_eq!(key, prop::PHY_FREQ);
5723        assert_eq!(value, 906_875u32.to_le_bytes());
5724        assert!(matches!(effect, Some(Effect::ApplyRadio(s)) if s.freq_khz == 906_875));
5725        assert_eq!(get(&mut session, prop::PHY_FREQ), 906_875u32.to_le_bytes());
5726    }
5727
5728    /// The test radio spans -9..=22 dBm. A request outside it succeeds
5729    /// at the nearest reachable power, and the echoed `CMD_PROP_IS` —
5730    /// the only place the range is visible — reports what was installed.
5731    #[test]
5732    fn tx_power_clamps_to_radio_range() {
5733        let mut session = test_session();
5734        for (requested, expected) in [(-20i8, -9i8), (40, 22), (-9, -9), (22, 22), (14, 14)] {
5735            let (emitted, effect) = set(&mut session, prop::PHY_TX_POWER, &[requested as u8]);
5736            let (_, key, value) = parse_prop_is(&emitted[0]);
5737            assert_eq!(key, prop::PHY_TX_POWER);
5738            assert_eq!(value, [expected as u8], "set {requested} dBm");
5739            assert!(
5740                matches!(effect, Some(Effect::ApplyRadio(s)) if s.tx_power_dbm == expected),
5741                "set {requested} dBm"
5742            );
5743            assert_eq!(get(&mut session, prop::PHY_TX_POWER), [expected as u8]);
5744        }
5745    }
5746
5747    /// The per-frame `TX_POWER` override obeys the same range as the
5748    /// property, so a host cannot route around it by staging a transmit.
5749    #[test]
5750    fn tx_power_override_clamps_to_radio_range() {
5751        for (requested, expected) in [(-20i8, -9i8), (40, 22)] {
5752            let mut session = test_session();
5753            enable(&mut session);
5754            let meta = [requested as u8, 0x00];
5755            let (_, effect) = send_packet(&mut session, 4, &[0u8; 8], &meta, 0);
5756            assert_eq!(effect, Some(Effect::StartTransmit));
5757            assert_eq!(session.tx_power(), TxPower::Dbm(expected), "tx {requested}");
5758        }
5759    }
5760
5761    #[test]
5762    fn invalid_values_rejected() {
5763        let mut session = test_session();
5764        for (key, bad) in [
5765            (prop::PHY_LORA_SF, &[4][..]),
5766            (prop::PHY_LORA_SF, &[13][..]),
5767            (prop::PHY_LORA_CR, &[9][..]),
5768            (prop::PHY_LORA_BW, &123_456u32.to_le_bytes()[..]),
5769            (prop::PHY_FREQ, &10_000u32.to_le_bytes()[..]),
5770            // Out-of-range TX power clamps rather than failing; only a
5771            // wrong-width value is an error.
5772            (prop::PHY_TX_POWER, &[14, 0][..]),
5773            (prop::PHY_ENABLED, &[2][..]),
5774            (prop::PHY_LORA_SW, &0xBEEFu16.to_le_bytes()[..]),
5775            // Wrong width.
5776            (prop::PHY_FREQ, &[1, 2][..]),
5777        ] {
5778            let (emitted, effect) = set(&mut session, key, bad);
5779            assert!(effect.is_none(), "key {key} accepted {bad:?}");
5780            expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
5781        }
5782    }
5783
5784    #[test]
5785    fn read_only_and_unknown_props() {
5786        let mut session = test_session();
5787        let (emitted, _) = set(&mut session, prop::PHY_MTU, &[0, 1]);
5788        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
5789
5790        let (emitted, _) = set(&mut session, 9_999, &[0]);
5791        expect_status(&emitted[0], 2, Status::PROP_NOT_FOUND);
5792
5793        let mut buf = [0u8; 16];
5794        let len = frame::prop_get(&mut buf, 1, 9_999).unwrap();
5795        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
5796        expect_status(&emitted[0], 1, Status::PROP_NOT_FOUND);
5797
5798        // PHY_RSSI while the PHY is disabled: no ambient RSSI to read.
5799        let len = frame::prop_get(&mut buf, 1, prop::PHY_RSSI).unwrap();
5800        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
5801        assert!(effect.is_none());
5802        expect_status(&emitted[0], 1, Status::INVALID_STATE);
5803    }
5804
5805    #[test]
5806    fn phy_rssi_defers_to_radio_when_enabled() {
5807        let mut session = test_session();
5808        enable(&mut session);
5809
5810        // A GET while enabled defers instead of answering inline.
5811        let mut buf = [0u8; 16];
5812        let len = frame::prop_get(&mut buf, 3, prop::PHY_RSSI).unwrap();
5813        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
5814        assert!(emitted.is_empty(), "no response until the radio is sampled");
5815        assert_eq!(effect, Some(Effect::SampleRssi { tid: 3 }));
5816
5817        // The caller feeds the sample back; the session emits PROP_IS.
5818        let mut out = Vec::new();
5819        session.respond_rssi(3, Ok(-91), &mut |bytes: &[u8]| out.push(bytes.to_vec()));
5820        let (tid, key, value) = parse_prop_is(&out[0]);
5821        assert_eq!(tid, 3);
5822        assert_eq!(key, prop::PHY_RSSI);
5823        assert_eq!(value, [(-91i8) as u8]);
5824
5825        // A failed radio read surfaces as STATUS_FAILURE.
5826        let mut out = Vec::new();
5827        session.respond_rssi(4, Err(()), &mut |bytes: &[u8]| out.push(bytes.to_vec()));
5828        expect_status(&out[0], 4, Status::FAILURE);
5829    }
5830
5831    #[test]
5832    fn battery_get_samples_on_request() {
5833        let mut session = test_session();
5834
5835        // A GET defers to the platform battery source; nothing is cached.
5836        let mut buf = [0u8; 16];
5837        let len = frame::prop_get(&mut buf, 7, prop::BATTERY).unwrap();
5838        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
5839        assert!(
5840            emitted.is_empty(),
5841            "no response until the battery is sampled"
5842        );
5843        assert_eq!(effect, Some(Effect::SampleBattery { tid: 7 }));
5844
5845        // The caller feeds the snapshot back; the session emits PROP_IS
5846        // with the exact wire form: flags 0b101, voltage LE, state PUI.
5847        let mut out = Vec::new();
5848        session.respond_battery(
5849            7,
5850            Ok(BatteryStatus {
5851                voltage_mv: Some(3987),
5852                level_percent: None,
5853                charge_state: Some(battery::BatteryChargeState::Charging),
5854            }),
5855            &mut |bytes: &[u8]| out.push(bytes.to_vec()),
5856        );
5857        let (tid, key, value) = parse_prop_is(&out[0]);
5858        assert_eq!(tid, 7);
5859        assert_eq!(key, prop::BATTERY);
5860        assert_eq!(value, [0b101, 0x93, 0x0F, 1]);
5861
5862        // A failed measurement surfaces as STATUS_FAILURE, never as an
5863        // empty (unsupported-reporting) value.
5864        let mut out = Vec::new();
5865        session.respond_battery(6, Err(()), &mut |bytes: &[u8]| out.push(bytes.to_vec()));
5866        expect_status(&out[0], 6, Status::FAILURE);
5867    }
5868
5869    #[test]
5870    fn battery_snapshot_must_match_configured_fields() {
5871        let mut session = test_session();
5872        // The test profile reports voltage + charge state; a source that
5873        // suddenly includes a level would change the advertised flags, so
5874        // the session refuses it.
5875        let mut out = Vec::new();
5876        session.respond_battery(
5877            5,
5878            Ok(BatteryStatus {
5879                voltage_mv: Some(4200),
5880                level_percent: Some(80),
5881                charge_state: Some(battery::BatteryChargeState::Charged),
5882            }),
5883            &mut |bytes: &[u8]| out.push(bytes.to_vec()),
5884        );
5885        expect_status(&out[0], 5, Status::FAILURE);
5886
5887        // Omitting an advertised field is the opposite case and is
5888        // allowed: it is how a platform says the value is not knowable
5889        // right now, which beats quoting one it knows to be wrong.
5890        let mut out = Vec::new();
5891        session.respond_battery(
5892            6,
5893            Ok(BatteryStatus {
5894                voltage_mv: Some(4200),
5895                level_percent: None,
5896                charge_state: None,
5897            }),
5898            &mut |bytes: &[u8]| out.push(bytes.to_vec()),
5899        );
5900        let (tid, key, value) = parse_prop_is(&out[0]);
5901        assert_eq!((tid, key), (6, prop::BATTERY));
5902        let decoded = BatteryStatus::decode(&value).unwrap();
5903        assert_eq!(decoded.voltage_mv, Some(4200));
5904        assert_eq!(decoded.charge_state, None);
5905    }
5906
5907    #[test]
5908    fn battery_without_capability_is_unknown() {
5909        let mut config = test_config();
5910        config.battery = None;
5911        let mut session = Session::new(config, Status::RESET_POWER_ON, test_engine());
5912        session.attach(true);
5913
5914        let raw = get(&mut session, prop::CAPS);
5915        let mut offset = 0;
5916        while offset < raw.len() {
5917            let (value, used) = pui::decode(&raw[offset..]).unwrap();
5918            assert_ne!(value, cap::BATTERY);
5919            offset += used;
5920        }
5921
5922        let mut buf = [0u8; 16];
5923        let len = frame::prop_get(&mut buf, 1, prop::BATTERY).unwrap();
5924        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
5925        assert!(effect.is_none());
5926        expect_status(&emitted[0], 1, Status::PROP_NOT_FOUND);
5927
5928        let (emitted, _) = set(&mut session, prop::BATTERY, &[0b001, 0, 0]);
5929        expect_status(&emitted[0], 2, Status::PROP_NOT_FOUND);
5930    }
5931
5932    #[test]
5933    fn battery_with_no_fields_answers_empty_without_sampling() {
5934        let mut config = test_config();
5935        config.battery = Some(BatteryFields::NONE);
5936        let mut session = Session::new(config, Status::RESET_POWER_ON, test_engine());
5937        session.attach(true);
5938
5939        // The empty (unsupported-reporting) value needs no measurement.
5940        assert_eq!(get(&mut session, prop::BATTERY), Vec::<u8>::new());
5941    }
5942
5943    #[test]
5944    fn battery_rejects_mutation() {
5945        let mut session = test_session();
5946        let (emitted, effect) = set(&mut session, prop::BATTERY, &[0b001, 0, 0]);
5947        assert!(effect.is_none());
5948        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
5949
5950        let mut buf = [0u8; 16];
5951        let len = frame::prop_insert(&mut buf, 3, prop::BATTERY, &[0]).unwrap();
5952        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
5953        expect_status(&emitted[0], 3, Status::INVALID_ARGUMENT);
5954
5955        let len = frame::prop_remove(&mut buf, 4, prop::BATTERY, &[0]).unwrap();
5956        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
5957        expect_status(&emitted[0], 4, Status::INVALID_ARGUMENT);
5958    }
5959
5960    #[test]
5961    fn illuminance_get_samples_on_request() {
5962        let mut session = test_session();
5963        let mut buf = [0u8; 16];
5964        let len = frame::prop_get(&mut buf, 5, prop::ILLUMINANCE).unwrap();
5965        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
5966        assert!(emitted.is_empty(), "no response until the sensor is read");
5967        assert_eq!(effect, Some(Effect::SampleIlluminance { tid: 5 }));
5968
5969        let mut out = Vec::new();
5970        session.respond_illuminance(5, Some(12_345), &mut |bytes: &[u8]| {
5971            out.push(bytes.to_vec())
5972        });
5973        let (tid, key, value) = parse_prop_is(&out[0]);
5974        assert_eq!((tid, key), (5, prop::ILLUMINANCE));
5975        assert_eq!(value, 12_345u32.to_le_bytes());
5976    }
5977
5978    /// A sensor that could not be read reports no reading, not a failure —
5979    /// the same shape `PROP_TIME` uses for a clock that was never set.
5980    #[test]
5981    fn illuminance_reports_a_failed_read_as_empty() {
5982        let mut session = test_session();
5983        let mut out = Vec::new();
5984        session.respond_illuminance(4, None, &mut |bytes: &[u8]| out.push(bytes.to_vec()));
5985        let (tid, key, value) = parse_prop_is(&out[0]);
5986        assert_eq!((tid, key, value), (4, prop::ILLUMINANCE, Vec::new()));
5987    }
5988
5989    #[test]
5990    fn illuminance_without_the_sensor_is_unknown() {
5991        let mut config = test_config();
5992        config.illuminance = false;
5993        let mut session = Session::new(config, Status::RESET_POWER_ON, test_engine());
5994        session.attach(true);
5995
5996        let raw = get(&mut session, prop::CAPS);
5997        let mut offset = 0;
5998        while offset < raw.len() {
5999            let (value, used) = pui::decode(&raw[offset..]).unwrap();
6000            assert_ne!(value, cap::ILLUMINANCE);
6001            offset += used;
6002        }
6003
6004        let mut buf = [0u8; 16];
6005        let len = frame::prop_get(&mut buf, 1, prop::ILLUMINANCE).unwrap();
6006        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6007        assert!(effect.is_none());
6008        expect_status(&emitted[0], 1, Status::PROP_NOT_FOUND);
6009    }
6010
6011    #[test]
6012    fn illuminance_rejects_mutation() {
6013        let mut session = test_session();
6014        let (emitted, effect) = set(&mut session, prop::ILLUMINANCE, &0u32.to_le_bytes());
6015        assert!(effect.is_none());
6016        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
6017    }
6018
6019    /// `CMD_PROP_SET` of `PROP_ALERT` at a chosen clock reading.
6020    fn set_alert_at(
6021        session: &mut TestSession,
6022        state: AlertState,
6023        now_ms: u64,
6024    ) -> (Vec<Vec<u8>>, Option<Effect>) {
6025        let mut value = [0u8; pui::MAX_LEN];
6026        let value_len = pui::encode(state.code(), &mut value).unwrap();
6027        let mut buf = [0u8; 16];
6028        let len = frame::prop_set(&mut buf, 2, prop::ALERT, &value[..value_len]).unwrap();
6029        dispatch(session, &buf[..len], now_ms)
6030    }
6031
6032    #[test]
6033    fn alert_starts_and_reports_the_new_state() {
6034        let mut session = test_session();
6035        assert_eq!(get(&mut session, prop::ALERT), vec![0]);
6036
6037        let (emitted, effect) = set_alert_at(&mut session, AlertState::Locate, 1_000);
6038        assert_eq!(effect, Some(Effect::ApplyAlert(AlertState::Locate)));
6039        let (tid, key, value) = parse_prop_is(&emitted[0]);
6040        assert_eq!((tid, key, value), (2, prop::ALERT, vec![1]));
6041        assert_eq!(session.alert(), AlertState::Locate);
6042        assert_eq!(get(&mut session, prop::ALERT), vec![1]);
6043    }
6044
6045    #[test]
6046    fn alert_rejects_unknown_states() {
6047        let mut session = test_session();
6048        let (emitted, effect) = set(&mut session, prop::ALERT, &[2]);
6049        assert!(effect.is_none());
6050        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
6051        // An empty value names no state at all.
6052        let (emitted, effect) = set(&mut session, prop::ALERT, &[]);
6053        assert!(effect.is_none());
6054        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
6055        assert_eq!(session.alert(), AlertState::None);
6056    }
6057
6058    #[test]
6059    fn alert_expires_at_its_deadline() {
6060        let mut session = test_session();
6061        let started = 10_000;
6062        set_alert_at(&mut session, AlertState::Locate, started);
6063        let deadline = started + u64::from(AlertConfig::DEFAULT.timeout_ms);
6064        assert_eq!(session.alert_deadline_ms(), Some(deadline));
6065
6066        // One millisecond early is still an alert.
6067        let mut emitted = Vec::new();
6068        let effect = session.poll_alert(deadline - 1, &mut |bytes: &[u8]| {
6069            emitted.push(bytes.to_vec())
6070        });
6071        assert!(effect.is_none());
6072        assert!(emitted.is_empty());
6073        assert_eq!(session.alert(), AlertState::Locate);
6074
6075        let effect = session.poll_alert(deadline, &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
6076        assert_eq!(effect, Some(Effect::ApplyAlert(AlertState::None)));
6077        assert_eq!(session.alert(), AlertState::None);
6078        assert_eq!(session.alert_deadline_ms(), None);
6079        // The transition the host did not command is announced.
6080        let (tid, key, value) = parse_prop_is(&emitted[0]);
6081        assert_eq!((tid, key, value), (TID_UNSOLICITED, prop::ALERT, vec![0]));
6082    }
6083
6084    #[test]
6085    fn re_arming_an_alert_restarts_the_deadline() {
6086        let mut session = test_session();
6087        set_alert_at(&mut session, AlertState::Locate, 1_000);
6088        let first = session.alert_deadline_ms().unwrap();
6089
6090        // The host holds the alert open for a longer search.
6091        let (_, effect) = set_alert_at(&mut session, AlertState::Locate, 60_000);
6092        assert_eq!(effect, Some(Effect::ApplyAlert(AlertState::Locate)));
6093        assert_eq!(
6094            session.alert_deadline_ms(),
6095            Some(60_000 + u64::from(AlertConfig::DEFAULT.timeout_ms))
6096        );
6097        assert!(session.alert_deadline_ms().unwrap() > first);
6098        // The originally scheduled expiry no longer ends it.
6099        let mut emitted = Vec::new();
6100        assert!(
6101            session
6102                .poll_alert(first, &mut |bytes: &[u8]| emitted.push(bytes.to_vec()))
6103                .is_none()
6104        );
6105        assert_eq!(session.alert(), AlertState::Locate);
6106    }
6107
6108    #[test]
6109    fn local_cancel_clears_and_announces_once() {
6110        let mut session = test_session();
6111        set_alert_at(&mut session, AlertState::Locate, 0);
6112
6113        let mut emitted = Vec::new();
6114        let effect = session.cancel_alert(&mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
6115        assert_eq!(effect, Some(Effect::ApplyAlert(AlertState::None)));
6116        let (tid, key, value) = parse_prop_is(&emitted[0]);
6117        assert_eq!((tid, key, value), (TID_UNSOLICITED, prop::ALERT, vec![0]));
6118
6119        // A second press has nothing to cancel and says nothing.
6120        emitted.clear();
6121        let effect = session.cancel_alert(&mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
6122        assert!(effect.is_none());
6123        assert!(emitted.is_empty());
6124    }
6125
6126    #[test]
6127    fn alert_survives_detach_and_reset() {
6128        let mut session = test_session();
6129        set_alert_at(&mut session, AlertState::Locate, 0);
6130
6131        // Detach must not silence it: the link drops as soon as the
6132        // searcher walks out of range, which is when it matters most.
6133        session.detach();
6134        assert_eq!(session.alert(), AlertState::Locate);
6135        assert!(session.alert_deadline_ms().is_some());
6136
6137        // Nor may CMD_RST, which resets session state and not the
6138        // device's physical behavior.
6139        session.attach(true);
6140        let mut buf = [0u8; 16];
6141        let len = frame::reset(&mut buf, 7).unwrap();
6142        dispatch(&mut session, &buf[..len], 0);
6143        assert_eq!(session.alert(), AlertState::Locate);
6144        assert_eq!(get(&mut session, prop::ALERT), vec![1]);
6145    }
6146
6147    #[test]
6148    fn cancelling_while_detached_emits_nothing() {
6149        let mut session = test_session();
6150        set_alert_at(&mut session, AlertState::Locate, 0);
6151        session.detach();
6152
6153        let mut emitted = Vec::new();
6154        let effect = session.cancel_alert(&mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
6155        // The indication still stops; there is simply nobody to tell.
6156        assert_eq!(effect, Some(Effect::ApplyAlert(AlertState::None)));
6157        assert!(emitted.is_empty());
6158        assert_eq!(session.alert(), AlertState::None);
6159    }
6160
6161    #[test]
6162    fn alert_is_absent_without_the_capability() {
6163        let mut config = test_config();
6164        config.alert = None;
6165        let mut session: TestSession = Session::new(config, Status::RESET_POWER_ON, test_engine());
6166        session.attach(true);
6167
6168        let mut buf = [0u8; 16];
6169        let len = frame::prop_get(&mut buf, 1, prop::ALERT).unwrap();
6170        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
6171        expect_status(&emitted[0], 1, Status::PROP_NOT_FOUND);
6172
6173        let (emitted, effect) = set(&mut session, prop::ALERT, &[1]);
6174        assert!(effect.is_none());
6175        expect_status(&emitted[0], 2, Status::PROP_NOT_FOUND);
6176
6177        let raw = get(&mut session, prop::CAPS);
6178        let mut offset = 0;
6179        while offset < raw.len() {
6180            let (value, used) = pui::decode(&raw[offset..]).unwrap();
6181            assert_ne!(value, cap::ALERT);
6182            offset += used;
6183        }
6184    }
6185
6186    #[test]
6187    fn alert_is_not_saved() {
6188        let mut session = test_session();
6189        set_alert_at(&mut session, AlertState::Locate, 0);
6190        let mut buf = [0u8; SNAPSHOT_MAX];
6191        let len = session.encode_snapshot(&mut buf).unwrap();
6192
6193        // Restoring a snapshot taken mid-alert onto a quiet device must
6194        // not start one: the alert is live state, not configuration.
6195        let mut fresh: TestSession =
6196            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
6197        fresh.attach(true);
6198        fresh.restore_at_boot(&buf[..len]).unwrap();
6199        assert_eq!(fresh.alert(), AlertState::None);
6200        assert_eq!(fresh.alert_deadline_ms(), None);
6201    }
6202
6203    #[test]
6204    fn battery_reads_still_sample_after_reset() {
6205        let mut session = test_session();
6206        let mut buf = [0u8; 16];
6207        let len = frame::reset(&mut buf, 0).unwrap();
6208        dispatch(&mut session, &buf[..len], 0);
6209
6210        // No battery state exists to reset or restore: a GET after reset
6211        // defers to a fresh sample exactly as before.
6212        let len = frame::prop_get(&mut buf, 5, prop::BATTERY).unwrap();
6213        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6214        assert!(emitted.is_empty());
6215        assert_eq!(effect, Some(Effect::SampleBattery { tid: 5 }));
6216    }
6217
6218    /// Drive `publish_battery` and collect emitted frames.
6219    fn publish(session: &mut TestSession, sample: BatteryStatus) -> (bool, Vec<Vec<u8>>) {
6220        let mut out = Vec::new();
6221        let published =
6222            session.publish_battery(sample, &mut |bytes: &[u8]| out.push(bytes.to_vec()));
6223        (published, out)
6224    }
6225
6226    fn matching_sample() -> BatteryStatus {
6227        BatteryStatus {
6228            voltage_mv: Some(3987),
6229            level_percent: None,
6230            charge_state: Some(battery::BatteryChargeState::Charging),
6231        }
6232    }
6233
6234    #[test]
6235    fn battery_publishes_unsolicited_snapshot() {
6236        let mut session = test_session();
6237        let (published, out) = publish(&mut session, matching_sample());
6238        assert!(published);
6239        let (tid, key, value) = parse_prop_is(&out[0]);
6240        // TID 0 marks it unsolicited: no transaction to correlate with.
6241        assert_eq!(tid, TID_UNSOLICITED);
6242        assert_eq!(key, prop::BATTERY);
6243        // The same wire form a GET response carries.
6244        assert_eq!(value, [0b101, 0x93, 0x0F, 1]);
6245    }
6246
6247    #[test]
6248    fn battery_publish_needs_an_attached_host() {
6249        let mut session = test_session();
6250        session.detach();
6251        let (published, out) = publish(&mut session, matching_sample());
6252        assert!(!published, "nobody to notify while detached");
6253        assert!(out.is_empty());
6254
6255        // Re-attaching restores publication without any rearming.
6256        session.attach(true);
6257        assert!(publish(&mut session, matching_sample()).0);
6258    }
6259
6260    #[test]
6261    fn battery_publish_enforces_configured_fields() {
6262        let mut session = test_session();
6263        // A level the profile never advertised cannot be encoded within
6264        // the flags this platform claims. An unsolicited notification has
6265        // no transaction to fail, so it is dropped outright.
6266        let (published, out) = publish(
6267            &mut session,
6268            BatteryStatus {
6269                voltage_mv: Some(4200),
6270                level_percent: Some(80),
6271                charge_state: Some(battery::BatteryChargeState::Charged),
6272            },
6273        );
6274        assert!(!published);
6275        assert!(out.is_empty());
6276
6277        // Omitting an advertised field goes out unchanged: a charging
6278        // pack whose level is not derivable still has a voltage worth
6279        // publishing, and silence would strand the host on the last
6280        // reading it saw.
6281        let (published, out) = publish(
6282            &mut session,
6283            BatteryStatus {
6284                voltage_mv: Some(4200),
6285                level_percent: None,
6286                charge_state: None,
6287            },
6288        );
6289        assert!(published);
6290        let (_, key, value) = parse_prop_is(&out[0]);
6291        assert_eq!(key, prop::BATTERY);
6292        assert_eq!(
6293            BatteryStatus::decode(&value).unwrap().voltage_mv,
6294            Some(4200)
6295        );
6296    }
6297
6298    #[test]
6299    fn battery_publish_is_silent_without_the_capability() {
6300        let mut config = test_config();
6301        config.battery = None;
6302        let mut session: TestSession = Session::new(config, Status::RESET_POWER_ON, test_engine());
6303        session.attach(true);
6304
6305        let (published, out) = publish(&mut session, matching_sample());
6306        assert!(!published);
6307        assert!(out.is_empty());
6308    }
6309
6310    #[test]
6311    fn battery_publish_does_not_disturb_last_status_or_reads() {
6312        let mut session = test_session();
6313        // A publication is not an operation: PROP_LAST_STATUS must still
6314        // hold the boot reason a freshly attached host needs to see.
6315        publish(&mut session, matching_sample());
6316        let status = get(&mut session, prop::LAST_STATUS);
6317        assert_eq!(pui::decode(&status).unwrap().0, Status::RESET_POWER_ON.0);
6318
6319        // And it caches nothing: the next GET still defers to a sample.
6320        let mut buf = [0u8; 16];
6321        let len = frame::prop_get(&mut buf, 3, prop::BATTERY).unwrap();
6322        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6323        assert!(emitted.is_empty());
6324        assert_eq!(effect, Some(Effect::SampleBattery { tid: 3 }));
6325    }
6326
6327    #[test]
6328    fn pairing_pin_set_clear_validate_and_defer() {
6329        let mut session = test_session();
6330
6331        let (emitted, effect) = set(
6332            &mut session,
6333            prop::BLE_PAIRING_PIN,
6334            &123_456u32.to_le_bytes(),
6335        );
6336        assert!(
6337            emitted.is_empty(),
6338            "PIN must not be acknowledged before apply"
6339        );
6340        assert_eq!(
6341            effect,
6342            Some(Effect::SetPairingPin {
6343                tid: 2,
6344                pin: Some(123_456)
6345            })
6346        );
6347
6348        let (emitted, effect) = set(&mut session, prop::BLE_PAIRING_PIN, &[]);
6349        assert!(emitted.is_empty());
6350        assert_eq!(effect, Some(Effect::SetPairingPin { tid: 2, pin: None }));
6351
6352        for bad in [&1_000_000u32.to_le_bytes()[..], &[1, 2, 3][..]] {
6353            let (emitted, effect) = set(&mut session, prop::BLE_PAIRING_PIN, bad);
6354            assert!(effect.is_none());
6355            expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
6356        }
6357    }
6358
6359    #[test]
6360    fn pairing_pin_completion_and_get_refusal() {
6361        let mut session = test_session();
6362        let mut emitted = Vec::new();
6363        session.respond_pin_set(7, Ok(()), &mut |frame| emitted.push(frame.to_vec()));
6364        expect_status(&emitted[0], 7, Status::OK);
6365        emitted.clear();
6366        session.respond_pin_set(6, Err(()), &mut |frame| emitted.push(frame.to_vec()));
6367        expect_status(&emitted[0], 6, Status::INTERNAL_ERROR);
6368
6369        let mut request = [0; 16];
6370        let len = frame::prop_get(&mut request, 5, prop::BLE_PAIRING_PIN).unwrap();
6371        let (emitted, effect) = dispatch(&mut session, &request[..len], 0);
6372        assert!(effect.is_none());
6373        expect_status(&emitted[0], 5, Status::UNIMPLEMENTED);
6374    }
6375
6376    #[test]
6377    fn reset_has_no_pairing_pin_effect() {
6378        let mut session = test_session();
6379        let mut request = [0; 4];
6380        let len = frame::reset(&mut request, 1).unwrap();
6381        let (_, effect) = dispatch(&mut session, &request[..len], 0);
6382        assert!(matches!(effect, Some(Effect::ApplyRadio(_))));
6383    }
6384
6385    #[test]
6386    fn transmit_lifecycle() {
6387        let mut session = test_session();
6388        enable(&mut session);
6389
6390        let packet = [0xAAu8; 32];
6391        let (emitted, effect) = send_packet(&mut session, 4, &packet, &[], 0);
6392        assert!(emitted.is_empty(), "no response until TX completes");
6393        assert_eq!(effect, Some(Effect::StartTransmit));
6394        assert_eq!(session.tx_data(), &packet);
6395        assert_eq!(session.tx_power(), TxPower::Default);
6396
6397        // A second confirmed send while busy fails with BUSY.
6398        let (emitted, effect) = send_packet(&mut session, 5, &packet, &[], 0);
6399        assert!(effect.is_none());
6400        expect_status(&emitted[0], 5, Status::BUSY);
6401
6402        // Completion emits OK with the original TID and records duty.
6403        let mut emitted = Vec::new();
6404        session.on_tx_result(TxOutcome::Sent, 0, &mut |bytes: &[u8]| {
6405            emitted.push(bytes.to_vec())
6406        });
6407        expect_status(&emitted[0], 4, Status::OK);
6408        assert!(!session.has_pending_tx());
6409        let duty = get(&mut session, prop::PHY_DUTY_NOW);
6410        assert!(u16::from_le_bytes([duty[0], duty[1]]) > 0);
6411    }
6412
6413    #[test]
6414    fn target_selected_transmit_queue_pipelines_frames() {
6415        type PipelinedSession = Session<SoftwareAes, SoftwareSha256, 3>;
6416        let mut session: PipelinedSession =
6417            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
6418        session.attach(true);
6419        let mut request = [0u8; 16];
6420        let len = frame::prop_set(&mut request, 1, prop::PHY_ENABLED, &[1]).unwrap();
6421        let (_, effect) = dispatch(&mut session, &request[..len], 0);
6422        assert!(matches!(effect, Some(Effect::ApplyRadio(settings)) if settings.enabled));
6423
6424        for tid in 2..=4 {
6425            let (emitted, effect) = send_packet(&mut session, tid, &[tid; 8], &[], 0);
6426            assert!(emitted.is_empty());
6427            assert_eq!(effect, (tid == 2).then_some(Effect::StartTransmit));
6428        }
6429        let (emitted, effect) = send_packet(&mut session, 5, &[5; 8], &[], 0);
6430        assert!(effect.is_none());
6431        expect_status(&emitted[0], 5, Status::BUSY);
6432
6433        for tid in 2..=4 {
6434            assert_eq!(session.tx_data(), &[tid; 8]);
6435            let mut emitted = Vec::new();
6436            let effect = session.on_tx_result(TxOutcome::Sent, 0, &mut |bytes| {
6437                emitted.push(bytes.to_vec())
6438            });
6439            expect_status(&emitted[0], tid, Status::OK);
6440            assert_eq!(effect, (tid != 4).then_some(Effect::StartTransmit));
6441        }
6442        assert!(!session.has_pending_tx());
6443    }
6444
6445    #[test]
6446    fn transmit_requires_enabled_phy() {
6447        let mut session = test_session();
6448        let (emitted, effect) = send_packet(&mut session, 4, &[0u8; 8], &[], 0);
6449        assert!(effect.is_none());
6450        expect_status(&emitted[0], 4, Status::INVALID_STATE);
6451    }
6452
6453    #[test]
6454    fn transmit_power_override() {
6455        let mut session = test_session();
6456        enable(&mut session);
6457        let meta = [22u8, 0x00];
6458        let (_, effect) = send_packet(&mut session, 4, &[0u8; 8], &meta, 0);
6459        assert_eq!(effect, Some(Effect::StartTransmit));
6460        assert_eq!(session.tx_power(), TxPower::Dbm(22));
6461    }
6462
6463    #[test]
6464    fn duty_limit_blocks_and_noduty_bypasses() {
6465        let mut session = test_session();
6466        enable(&mut session);
6467        // Slowest settings: one full frame is minutes of airtime.
6468        set(&mut session, prop::PHY_LORA_SF, &[12]);
6469        set(&mut session, prop::PHY_LORA_BW, &7_810u32.to_le_bytes());
6470        // 0.1% limit.
6471        set(&mut session, prop::PHY_DUTY_LIMIT, &65u16.to_le_bytes());
6472
6473        let packet = [0u8; 255];
6474        let (emitted, effect) = send_packet(&mut session, 3, &packet, &[], 0);
6475        assert!(effect.is_none());
6476        expect_status(&emitted[0], 3, Status::DUTY_LIMIT);
6477
6478        // NODUTY flag bypasses the limit.
6479        let meta = [meta::TX_POWER_DEFAULT as u8, meta::TX_FLAG_NODUTY];
6480        let (_, effect) = send_packet(&mut session, 3, &packet, &meta, 0);
6481        assert_eq!(effect, Some(Effect::StartTransmit));
6482    }
6483
6484    #[test]
6485    fn nocca_flag_controls_channel_sensing() {
6486        let mut session = test_session();
6487        enable(&mut session);
6488
6489        // Default (no flags): the transmit must be channel-sensed.
6490        let (_, effect) = send_packet(&mut session, 3, &[0u8; 8], &[], 0);
6491        assert_eq!(effect, Some(Effect::StartTransmit));
6492        assert!(!session.tx_nocca());
6493        session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {});
6494
6495        // TX_FLAG_NOCCA requests transmit without channel sensing.
6496        let meta = [meta::TX_POWER_DEFAULT as u8, meta::TX_FLAG_NOCCA];
6497        let (_, effect) = send_packet(&mut session, 4, &[0u8; 8], &meta, 0);
6498        assert_eq!(effect, Some(Effect::StartTransmit));
6499        assert!(session.tx_nocca());
6500    }
6501
6502    #[test]
6503    fn channel_busy_completes_host_send_with_cca_failure() {
6504        let mut session = test_session();
6505        enable(&mut session);
6506
6507        let (_, effect) = send_packet(&mut session, 7, &[0u8; 8], &[], 0);
6508        assert_eq!(effect, Some(Effect::StartTransmit));
6509
6510        // A busy channel refuses the transmit; the host learns it distinctly
6511        // from an ordinary failure so it can back off and retry.
6512        let mut emitted = Vec::new();
6513        session.on_tx_result(TxOutcome::ChannelBusy, 0, &mut |bytes: &[u8]| {
6514            emitted.push(bytes.to_vec())
6515        });
6516        expect_status(&emitted[0], 7, Status::CCA_FAILURE);
6517
6518        // The refused frame never left the radio: no duty was charged.
6519        assert_eq!(get(&mut session, prop::PHY_DUTY_NOW), 0u16.to_le_bytes());
6520    }
6521
6522    #[test]
6523    fn delegated_ack_transmits_without_channel_sensing() {
6524        // A delegated MAC ack owns its channel-access window the moment the
6525        // acknowledged frame ends (the ACK protection interval), so it must
6526        // transmit without a channel-activity check.
6527        let mut session = auto_ack_session();
6528        let keys = test_pairwise();
6529        let effect = rx_effect(&mut session, &sealed_unar(5, &keys, false), 0);
6530        assert_eq!(effect, Some(Effect::StartTransmit));
6531        assert!(
6532            session.tx_nocca(),
6533            "delegated ack must skip the channel-activity check"
6534        );
6535    }
6536
6537    /// The ledger is shared with every other radio client on the
6538    /// device (the device node). Airtime recorded by another client
6539    /// counts against the session's limit — host transmits refuse with
6540    /// STATUS_DUTY_LIMIT — and PROP_PHY_DUTY_NOW reports the combined
6541    /// figure, all without the session transmitting anything itself.
6542    #[test]
6543    fn foreign_client_airtime_counts_against_the_session() {
6544        let config = test_config();
6545        let ledger = config.duty;
6546        let mut session = Session::new(config, Status::RESET_POWER_ON, test_engine());
6547        session.attach(true);
6548        enable(&mut session);
6549        set(&mut session, prop::PHY_DUTY_LIMIT, &655u16.to_le_bytes());
6550
6551        assert_eq!(get(&mut session, prop::PHY_DUTY_NOW), 0u16.to_le_bytes());
6552        // The device node completes 36 s of transmission (≈1%).
6553        for _ in 0..36 {
6554            ledger.record(0, 1_000);
6555        }
6556        let duty_now = get(&mut session, prop::PHY_DUTY_NOW);
6557        assert!(u16::from_le_bytes([duty_now[0], duty_now[1]]) >= 655);
6558
6559        let (emitted, effect) = send_packet(&mut session, 3, &[0u8; 32], &[], 0);
6560        assert!(effect.is_none());
6561        expect_status(&emitted[0], 3, Status::DUTY_LIMIT);
6562
6563        // And the session's settings feed the ledger's modulation view,
6564        // so the node prices its frames at what is actually on the air.
6565        set(&mut session, prop::PHY_LORA_SF, &[12]);
6566        set(&mut session, prop::PHY_LORA_BW, &7_810u32.to_le_bytes());
6567        assert_eq!(
6568            ledger.airtime_ms(32),
6569            umsh_ulcp::airtime::lora_airtime_ms(12, 7_810, 5, 32)
6570        );
6571    }
6572
6573    #[test]
6574    fn fire_and_forget_failures_are_silent() {
6575        let mut session = test_session();
6576        // PHY disabled: a TID-0 send fails without emitting anything.
6577        let (emitted, effect) = send_packet(&mut session, 0, &[0u8; 4], &[], 0);
6578        assert!(effect.is_none());
6579        assert!(emitted.is_empty());
6580        // ... but LAST_STATUS records it.
6581        assert_eq!(
6582            pui::decode(&get(&mut session, prop::LAST_STATUS))
6583                .unwrap()
6584                .0,
6585            Status::INVALID_STATE.0
6586        );
6587    }
6588
6589    #[test]
6590    fn radio_rx_emits_str_recv() {
6591        let mut session = test_session();
6592        enable(&mut session);
6593        let mut emitted = Vec::new();
6594        session.on_radio_rx(&[1, 2, 3], -91, -53, None, 0, &mut |bytes: &[u8]| {
6595            emitted.push(bytes.to_vec())
6596        });
6597        let parsed = Frame::parse(&emitted[0]).unwrap();
6598        assert_eq!(parsed.command(), Some(Cmd::StrRecv));
6599        assert_eq!(parsed.header.tid(), TID_UNSOLICITED);
6600        let payload = StreamPayload::parse(parsed.payload).unwrap();
6601        assert_eq!(payload.data, &[1, 2, 3]);
6602        let rx_meta = RxMeta::decode(payload.metadata).unwrap();
6603        assert_eq!(rx_meta.rssi_dbm, Some(-91));
6604        assert_eq!(rx_meta.snr_cb, Some(-53));
6605    }
6606
6607    #[test]
6608    fn radio_rx_suppressed_while_disabled() {
6609        let mut session = test_session();
6610        let mut emitted = Vec::new();
6611        session.on_radio_rx(&[1, 2, 3], -91, -53, None, 0, &mut |bytes: &[u8]| {
6612            emitted.push(bytes.to_vec())
6613        });
6614        assert!(emitted.is_empty());
6615        // Nothing is queued either: the PHY is disabled.
6616        session.detach();
6617        session.on_radio_rx(&[1, 2, 3], -91, -53, None, 0, &mut |_: &[u8]| {});
6618        session.attach(true);
6619        assert_eq!(
6620            get(&mut session, prop::HOST_RX_QUEUE_COUNT),
6621            0u16.to_le_bytes()
6622        );
6623    }
6624
6625    #[test]
6626    fn unknown_command_rejected() {
6627        let mut session = test_session();
6628        let (emitted, _) = dispatch(&mut session, &[0x81, 42], 0);
6629        expect_status(&emitted[0], 1, Status::INVALID_COMMAND);
6630    }
6631
6632    #[test]
6633    fn insert_remove_reject_per_property_knowledge() {
6634        let mut session = test_session();
6635        let mut buf = [0u8; 80];
6636
6637        // A known single-value property is not insertable/removable.
6638        for known in [
6639            prop::PHY_FREQ,
6640            prop::BLE_PAIRING_PIN,
6641            prop::CAPS,
6642            prop::MAC_REPEATER_ENABLED,
6643            prop::IDENT_ROLE,
6644            prop::IDENT_MOBILE,
6645            prop::DEV_DISCOVERABLE,
6646            // The repeater policy is whole-value too: a region list is
6647            // replaced outright, never accumulated a code at a time.
6648            prop::MAC_REPEATER_REGIONS,
6649            prop::MAC_REPEATER_DEFAULT_REGION,
6650            prop::MAC_REPEATER_MIN_RSSI,
6651            prop::MAC_REPEATER_MIN_SNR,
6652            // Advertisement policy is likewise whole-value.
6653            prop::ADVERT_INTERVAL,
6654            prop::BEACON_INTERVAL,
6655            prop::STARTUP_BEACON,
6656        ] {
6657            let len = frame::prop_insert(&mut buf, 1, known, &[0; 4]).unwrap();
6658            let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6659            assert!(effect.is_none());
6660            expect_status(&emitted[0], 1, Status::INVALID_ARGUMENT);
6661        }
6662        // An unknown property is not found; 83 is still-spare space in
6663        // the advertisement sub-range.
6664        for unknown in [83, 1_234] {
6665            let len = frame::prop_remove(&mut buf, 2, unknown, &[0; 4]).unwrap();
6666            let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6667            assert!(effect.is_none());
6668            expect_status(&emitted[0], 2, Status::PROP_NOT_FOUND);
6669        }
6670        // A payload without a decodable property key is malformed.
6671        let (emitted, _) = dispatch(&mut session, &[0x81, Cmd::PropInsert as u8], 0);
6672        expect_status(&emitted[0], 1, Status::PARSE_ERROR);
6673    }
6674
6675    #[test]
6676    fn clear_defers_and_leaves_live_state_alone() {
6677        let mut session = test_session();
6678        let mut buf = [0u8; 8];
6679        // CMD_CLEAR is base-protocol: it defers to the durable erase
6680        // even with nothing saved (the erase is idempotent) and must
6681        // not disturb live state (the device name survives).
6682        set(&mut session, prop::DEV_NAME, b"kept name");
6683        let len = frame::clear(&mut buf, 4).unwrap();
6684        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6685        assert!(emitted.is_empty(), "no response before the erase commits");
6686        assert_eq!(effect, Some(Effect::ClearSaved { tid: 4 }));
6687        let mut emitted = Vec::new();
6688        session.respond_clear(4, Ok(()), &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
6689        expect_status(&emitted[0], 4, Status::OK);
6690        assert_eq!(session.device_name(), "kept name");
6691
6692        // A failed erase reports FAILURE.
6693        let (_, effect) = dispatch(&mut session, &buf[..len], 0);
6694        assert_eq!(effect, Some(Effect::ClearSaved { tid: 4 }));
6695        let mut emitted = Vec::new();
6696        session.respond_clear(4, Err(()), &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
6697        expect_status(&emitted[0], 4, Status::FAILURE);
6698    }
6699
6700    #[test]
6701    fn device_only_notifications_rejected_from_host() {
6702        let mut session = test_session();
6703        for cmd in [
6704            Cmd::PropInserted,
6705            Cmd::PropRemoved,
6706            Cmd::PropIs,
6707            Cmd::StrRecv,
6708        ] {
6709            let (emitted, effect) = dispatch(&mut session, &[0x81, cmd as u8], 0);
6710            assert!(effect.is_none());
6711            expect_status(&emitted[0], 1, Status::INVALID_COMMAND);
6712        }
6713    }
6714
6715    #[test]
6716    fn malformed_frames_ignored() {
6717        let mut session = test_session();
6718        for bad in [&[][..], &[0x81][..], &[0x00, 0x00][..], &[0xB8, 0x00][..]] {
6719            let (emitted, effect) = dispatch(&mut session, bad, 0);
6720            assert!(emitted.is_empty());
6721            assert!(effect.is_none());
6722        }
6723    }
6724
6725    // ─── CAP_HOST_FILTER gate ────────────────────────────────────────
6726
6727    use umsh_core::{ChannelId, NodeHint, PacketBuilder};
6728
6729    fn unicast_to(dst: [u8; 3]) -> Vec<u8> {
6730        let mut buf = [0u8; 64];
6731        PacketBuilder::new(&mut buf)
6732            .unicast(NodeHint(dst))
6733            .source_hint(NodeHint([9, 9, 9]))
6734            .frame_counter(1)
6735            .payload(&[1, 2, 3])
6736            .build()
6737            .unwrap()
6738            .as_bytes()
6739            .to_vec()
6740    }
6741
6742    fn multicast_on(channel: [u8; 2]) -> Vec<u8> {
6743        let mut buf = [0u8; 64];
6744        PacketBuilder::new(&mut buf)
6745            .multicast(ChannelId(channel))
6746            .source_hint(NodeHint([9, 9, 9]))
6747            .frame_counter(1)
6748            .payload(&[1, 2, 3])
6749            .build()
6750            .unwrap()
6751            .as_bytes()
6752            .to_vec()
6753    }
6754
6755    fn blind_unicast_on(channel: [u8; 2]) -> Vec<u8> {
6756        let mut buf = [0u8; 96];
6757        PacketBuilder::new(&mut buf)
6758            .blind_unicast(ChannelId(channel), NodeHint([7, 7, 7]))
6759            .source_hint(NodeHint([9, 9, 9]))
6760            .frame_counter(1)
6761            .payload(&[1, 2, 3])
6762            .build()
6763            .unwrap()
6764            .as_bytes()
6765            .to_vec()
6766    }
6767
6768    fn broadcast_frame() -> Vec<u8> {
6769        let mut buf = [0u8; 64];
6770        PacketBuilder::new(&mut buf)
6771            .broadcast()
6772            .source_hint(NodeHint([9, 9, 9]))
6773            .payload(&[1, 2, 3])
6774            .build()
6775            .unwrap()
6776            .to_vec()
6777    }
6778
6779    fn mac_ack_with_mic(ack_mic: [u8; 4]) -> Vec<u8> {
6780        let mut trailer = [0u8; 8];
6781        trailer[..4].copy_from_slice(&ack_mic);
6782        trailer[4..].copy_from_slice(&[0x5A; 4]); // arbitrary keyed-tag half
6783        let mut buf = [0u8; 32];
6784        PacketBuilder::new(&mut buf)
6785            .mac_ack(trailer)
6786            .build()
6787            .unwrap()
6788            .to_vec()
6789    }
6790
6791    /// Feed a radio frame in and report whether it was delivered.
6792    fn delivered(session: &mut TestSession, frame: &[u8]) -> bool {
6793        delivered_at(session, frame, 0)
6794    }
6795
6796    fn delivered_at(session: &mut TestSession, frame: &[u8], now_ms: u64) -> bool {
6797        let mut emitted = Vec::new();
6798        session.on_radio_rx(frame, -80, 40, None, now_ms, &mut |bytes: &[u8]| {
6799            emitted.push(bytes.to_vec())
6800        });
6801        !emitted.is_empty()
6802    }
6803
6804    fn insert_item(
6805        session: &mut TestSession,
6806        key: u32,
6807        item: &[u8],
6808    ) -> (Vec<Vec<u8>>, Option<Effect>) {
6809        let mut buf = [0u8; 96];
6810        let len = frame::prop_insert(&mut buf, 5, key, item).unwrap();
6811        dispatch(session, &buf[..len], 0)
6812    }
6813
6814    fn remove_item(
6815        session: &mut TestSession,
6816        key: u32,
6817        item: &[u8],
6818    ) -> (Vec<Vec<u8>>, Option<Effect>) {
6819        let mut buf = [0u8; 96];
6820        let len = frame::prop_remove(&mut buf, 6, key, item).unwrap();
6821        dispatch(session, &buf[..len], 0)
6822    }
6823
6824    /// Install a host key, completing the deferred durable wipe.
6825    fn install_host_key(session: &mut TestSession, key: &[u8; 32]) {
6826        let (emitted, effect) = set(session, prop::HOST_KEY, key);
6827        assert!(effect.is_none(), "host replacement needs no durable step");
6828        let (_, response_key, value) = parse_prop_is(&emitted[0]);
6829        assert_eq!(response_key, prop::HOST_KEY);
6830        assert_eq!(value, key);
6831    }
6832
6833    /// Parse an emitted frame as INSERTED/REMOVED and return (key, digest).
6834    fn parse_table_notice(bytes: &[u8], expected: Cmd, tid: u8) -> (u32, Vec<u8>) {
6835        let parsed = Frame::parse(bytes).unwrap();
6836        assert_eq!(parsed.command(), Some(expected));
6837        assert_eq!(parsed.header.tid(), tid);
6838        let payload = PropPayload::parse(parsed.payload).unwrap();
6839        (payload.key, payload.value.to_vec())
6840    }
6841
6842    #[test]
6843    fn factory_state_accepts_everything() {
6844        let mut session = test_session();
6845        enable(&mut session);
6846        // No host key, no filters: minimal-protocol behavior, including
6847        // frames that do not parse as UMSH at all.
6848        assert!(delivered(&mut session, &unicast_to([1, 2, 3])));
6849        assert!(delivered(&mut session, &broadcast_frame()));
6850        assert!(delivered(&mut session, &[0x00, 0x01, 0x02]));
6851    }
6852
6853    #[test]
6854    fn host_key_round_trip_and_implicit_dest_filter() {
6855        let mut session = test_session();
6856        enable(&mut session);
6857        assert_eq!(get(&mut session, prop::HOST_KEY), Vec::<u8>::new());
6858
6859        let key = [0xC4; 32];
6860        install_host_key(&mut session, &key);
6861        assert_eq!(get(&mut session, prop::HOST_KEY), key);
6862
6863        // The implicit destination-hint filter: unicast traffic to the
6864        // host's 3-byte prefix is accepted, everything else — including
6865        // unparseable frames — is not. A MAC ack carries no destination
6866        // hint; with nothing transmitted, its ack_mic matches no expected
6867        // send, so it is dropped (see mac_ack_accepted_only_when_expected).
6868        assert!(delivered(&mut session, &unicast_to([0xC4, 0xC4, 0xC4])));
6869        assert!(!delivered(
6870            &mut session,
6871            &mac_ack_with_mic([0xC4, 0xC4, 0xC4, 0xC4])
6872        ));
6873        assert!(!delivered(&mut session, &unicast_to([1, 2, 3])));
6874        // Broadcasts stay implicitly accepted for live delivery.
6875        assert!(delivered(&mut session, &broadcast_frame()));
6876        assert!(!delivered(&mut session, &[0x00, 0x01, 0x02]));
6877    }
6878
6879    #[test]
6880    fn mac_ack_accepted_only_when_expected() {
6881        let mut session = test_session();
6882        enable(&mut session);
6883        install_host_key(&mut session, &[0xC4; 32]); // configure filtering
6884
6885        // Before sending anything, no ack is expected.
6886        assert!(!delivered(
6887            &mut session,
6888            &mac_ack_with_mic([0x11, 0x22, 0x33, 0x44])
6889        ));
6890
6891        // Transmit an ack-requested frame; its MIC prefix is now expected.
6892        let frame = sealed_unar(7, &test_pairwise(), false);
6893        let header = PacketHeader::parse(&frame).unwrap();
6894        let mic = &frame[header.mic_range.clone()];
6895        let ack_mic = [mic[0], mic[1], mic[2], mic[3]];
6896        let (_emitted, effect) = send_packet(&mut session, 4, &frame, &[], 0);
6897        assert_eq!(effect, Some(Effect::StartTransmit));
6898        session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {});
6899
6900        // An ack echoing that ack_mic is now accepted...
6901        assert!(delivered(&mut session, &mac_ack_with_mic(ack_mic)));
6902        // ...and is NOT evicted on match: a duplicate arriving over another
6903        // route still passes (lazy eviction).
6904        assert!(delivered(&mut session, &mac_ack_with_mic(ack_mic)));
6905        // An ack for a different (unsent) frame is still rejected.
6906        assert!(!delivered(
6907            &mut session,
6908            &mac_ack_with_mic([0x99, 0x88, 0x77, 0x66])
6909        ));
6910    }
6911
6912    /// A repeater's onward copy of a frame the host transmitted passes the
6913    /// filter even though its destination hint names the remote peer: the
6914    /// MIC prefix marks it as an echo of our own send, which is exactly what
6915    /// the host's forwarding-confirmation machinery waits to overhear.
6916    /// Without this rule a bridged host retries every hop send it makes,
6917    /// because the confirmation can never reach it.
6918    #[test]
6919    fn repeat_of_a_transmitted_frame_passes_the_filter() {
6920        let mut session = test_session();
6921        enable(&mut session);
6922        install_host_key(&mut session, &HOST_PUB);
6923
6924        // A non-ack unicast from the host out to a remote peer, flooding.
6925        let mut buf = [0u8; 96];
6926        let mut packet = PacketBuilder::new(&mut buf)
6927            .unicast(NodeHint([PEER_PUB[0], PEER_PUB[1], PEER_PUB[2]]))
6928            .source_hint(NodeHint([HOST_PUB[0], HOST_PUB[1], HOST_PUB[2]]))
6929            .frame_counter(9)
6930            .flood_hops(5)
6931            .mic_size(MicSize::Mic8)
6932            .payload(&[4, 5, 6])
6933            .build()
6934            .unwrap();
6935        test_engine()
6936            .seal_packet(&mut packet, &test_pairwise())
6937            .unwrap();
6938        let frame = packet.as_bytes().to_vec();
6939
6940        // The repeat: mutable routing state rewritten, MIC untouched — what
6941        // a repeater is permitted to do.
6942        let header = PacketHeader::parse(&frame).unwrap();
6943        let mut repeat = frame.clone();
6944        repeat[1] = header.flood_hops.unwrap().decremented().0;
6945        assert_ne!(repeat, frame);
6946
6947        // Before the host transmits, the same bytes are just somebody
6948        // else's unicast.
6949        assert!(!delivered(&mut session, &repeat));
6950
6951        let (_emitted, effect) = send_packet(&mut session, 4, &frame, &[], 0);
6952        assert_eq!(effect, Some(Effect::StartTransmit));
6953        session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {});
6954
6955        // Now it is an echo of our own send: accepted, and — like a
6956        // returning ack — not evicted on match, so a second repeater's copy
6957        // passes too.
6958        assert!(delivered(&mut session, &repeat));
6959        assert!(delivered(&mut session, &repeat));
6960    }
6961
6962    #[test]
6963    fn explicit_pkt_type_filter_accepts_unexpected_mac_ack() {
6964        // The implicit ack_mic match is one arm of the union of filters; an
6965        // explicit FILTER_PKT_TYPE for MacAck must still accept an ack whose
6966        // mic we never recorded.
6967        let mut session = test_session();
6968        enable(&mut session);
6969        install_host_key(&mut session, &[0xC4; 32]); // configure filtering
6970
6971        // No matching send, so the implicit ack_mic filter rejects it.
6972        assert!(!delivered(
6973            &mut session,
6974            &mac_ack_with_mic([0x11, 0x22, 0x33, 0x44])
6975        ));
6976
6977        // Explicitly request MacAck frames by type.
6978        insert_item(
6979            &mut session,
6980            prop::HOST_RX_FILTERS,
6981            &[items::FILTER_PKT_TYPE, PacketType::MacAck as u8],
6982        );
6983
6984        // Now the same unexpected ack is accepted via the explicit filter.
6985        assert!(delivered(
6986            &mut session,
6987            &mac_ack_with_mic([0x11, 0x22, 0x33, 0x44])
6988        ));
6989    }
6990
6991    #[test]
6992    fn host_key_rejects_bad_lengths() {
6993        let mut session = test_session();
6994        for bad in [&[0u8; 31][..], &[0u8; 33][..], &[1u8][..]] {
6995            let (emitted, effect) = set(&mut session, prop::HOST_KEY, bad);
6996            assert!(effect.is_none());
6997            expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
6998        }
6999    }
7000
7001    #[test]
7002    fn host_key_set_is_idempotent_for_current_value() {
7003        let mut session = test_session();
7004        // Empty -> empty: no replacement, immediate echo.
7005        let (emitted, effect) = set(&mut session, prop::HOST_KEY, &[]);
7006        assert!(effect.is_none());
7007        let (_, key, value) = parse_prop_is(&emitted[0]);
7008        assert_eq!(key, prop::HOST_KEY);
7009        assert!(value.is_empty());
7010
7011        let host_key = [0xC4; 32];
7012        install_host_key(&mut session, &host_key);
7013        insert_item(
7014            &mut session,
7015            prop::HOST_RX_FILTERS,
7016            &[items::FILTER_PKT_TYPE, 0],
7017        );
7018
7019        // Same key again: no wipe, and the filter table survives.
7020        let (emitted, effect) = set(&mut session, prop::HOST_KEY, &host_key);
7021        assert!(effect.is_none());
7022        let (_, key, value) = parse_prop_is(&emitted[0]);
7023        assert_eq!(key, prop::HOST_KEY);
7024        assert_eq!(value, host_key);
7025        assert!(!get(&mut session, prop::HOST_RX_FILTERS).is_empty());
7026    }
7027
7028    /// Host replacement is one immediate assignment now that the host
7029    /// domain is never persisted: there is no durable transaction to
7030    /// stage, fail, or leave in flight.
7031    #[test]
7032    fn host_replacement_clears_the_host_domain_immediately() {
7033        let mut session = test_session();
7034        install_host_key(&mut session, &[0xAA; 32]);
7035        insert_item(
7036            &mut session,
7037            prop::HOST_RX_FILTERS,
7038            &[items::FILTER_PKT_TYPE, 0],
7039        );
7040
7041        install_host_key(&mut session, &[0xBB; 32]);
7042        assert!(get(&mut session, prop::HOST_RX_FILTERS).is_empty());
7043
7044        // Clearing the key (set to empty) is also a replacement.
7045        insert_item(
7046            &mut session,
7047            prop::HOST_RX_FILTERS,
7048            &[items::FILTER_PKT_TYPE, 0],
7049        );
7050        let (emitted, effect) = set(&mut session, prop::HOST_KEY, &[]);
7051        assert!(effect.is_none());
7052        let (_, key, value) = parse_prop_is(&emitted[0]);
7053        assert_eq!(key, prop::HOST_KEY);
7054        assert!(value.is_empty());
7055        assert_eq!(get(&mut session, prop::HOST_KEY), Vec::<u8>::new());
7056        assert!(get(&mut session, prop::HOST_RX_FILTERS).is_empty());
7057    }
7058
7059    #[test]
7060    fn cmd_rst_clears_host_domain() {
7061        let mut session = test_session();
7062        install_host_key(&mut session, &[0xAA; 32]);
7063        insert_item(
7064            &mut session,
7065            prop::HOST_RX_FILTERS,
7066            &[items::FILTER_PKT_TYPE, 0],
7067        );
7068        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_| {});
7069        assert_eq!(get(&mut session, prop::HOST_KEY), Vec::<u8>::new());
7070        assert!(get(&mut session, prop::HOST_RX_FILTERS).is_empty());
7071    }
7072
7073    #[test]
7074    fn filter_insert_remove_lifecycle() {
7075        let mut session = test_session();
7076        let item = [items::FILTER_DEST_HINT, 0x11, 0x22, 0x33];
7077
7078        let (emitted, effect) = insert_item(&mut session, prop::HOST_RX_FILTERS, &item);
7079        assert!(effect.is_none());
7080        let (key, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
7081        assert_eq!(key, prop::HOST_RX_FILTERS);
7082        assert_eq!(digest, item);
7083
7084        // Duplicate insert fails with ALREADY.
7085        let (emitted, _) = insert_item(&mut session, prop::HOST_RX_FILTERS, &item);
7086        expect_status(&emitted[0], 5, Status::ALREADY);
7087
7088        // GET returns the whole table with item length prefixes.
7089        let table = get(&mut session, prop::HOST_RX_FILTERS);
7090        assert_eq!(table, [&[4u8][..], &item[..]].concat());
7091
7092        let (emitted, _) = remove_item(&mut session, prop::HOST_RX_FILTERS, &item);
7093        let (key, digest) = parse_table_notice(&emitted[0], Cmd::PropRemoved, 6);
7094        assert_eq!(key, prop::HOST_RX_FILTERS);
7095        assert_eq!(digest, item);
7096        assert!(get(&mut session, prop::HOST_RX_FILTERS).is_empty());
7097
7098        // Removing a missing item fails with ITEM_NOT_FOUND.
7099        let (emitted, _) = remove_item(&mut session, prop::HOST_RX_FILTERS, &item);
7100        expect_status(&emitted[0], 6, Status::ITEM_NOT_FOUND);
7101    }
7102
7103    #[test]
7104    fn filter_insert_rejects_invalid_entries() {
7105        let mut session = test_session();
7106        for bad in [
7107            &[][..],                                  // empty item
7108            &[3, 0][..],                              // unknown FILTER_TYPE
7109            &[items::FILTER_DEST_HINT, 1, 2][..],     // wrong value length
7110            &[items::FILTER_CHANNEL_ID, 1, 2, 3][..], // wrong value length
7111            &[items::FILTER_PKT_TYPE, 8][..],         // packet type out of range
7112        ] {
7113            let (emitted, effect) = insert_item(&mut session, prop::HOST_RX_FILTERS, bad);
7114            assert!(effect.is_none());
7115            expect_status(&emitted[0], 5, Status::INVALID_ARGUMENT);
7116        }
7117        assert!(get(&mut session, prop::HOST_RX_FILTERS).is_empty());
7118    }
7119
7120    #[test]
7121    fn filter_table_capacity_is_bounded() {
7122        let mut session = test_session();
7123        for index in 0..MAX_RX_FILTERS as u8 {
7124            let (emitted, _) = insert_item(
7125                &mut session,
7126                prop::HOST_RX_FILTERS,
7127                &[items::FILTER_DEST_HINT, index, 0, 0],
7128            );
7129            parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
7130        }
7131        let (emitted, _) = insert_item(
7132            &mut session,
7133            prop::HOST_RX_FILTERS,
7134            &[items::FILTER_DEST_HINT, 0xFF, 0, 0],
7135        );
7136        expect_status(&emitted[0], 5, Status::NOMEM);
7137    }
7138
7139    #[test]
7140    fn whole_table_set_is_atomic() {
7141        let mut session = test_session();
7142        let good_a = [items::FILTER_DEST_HINT, 1, 2, 3];
7143        let good_b = [items::FILTER_PKT_TYPE, 0];
7144
7145        let mut table = Vec::new();
7146        for item in [&good_a[..], &good_b[..]] {
7147            table.push(item.len() as u8);
7148            table.extend_from_slice(item);
7149        }
7150        let (emitted, effect) = set(&mut session, prop::HOST_RX_FILTERS, &table);
7151        assert!(effect.is_none());
7152        let (_, key, value) = parse_prop_is(&emitted[0]);
7153        assert_eq!(key, prop::HOST_RX_FILTERS);
7154        assert_eq!(value, table);
7155
7156        // A set containing any invalid item fails without applying
7157        // anything: the previous table is fully retained.
7158        let mut bad_table = table.clone();
7159        bad_table.extend_from_slice(&[2, 3, 0]); // unknown FILTER_TYPE 3
7160        let (emitted, _) = set(&mut session, prop::HOST_RX_FILTERS, &bad_table);
7161        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
7162        assert_eq!(get(&mut session, prop::HOST_RX_FILTERS), table);
7163
7164        // A value that cannot be split into items is malformed.
7165        let (emitted, _) = set(&mut session, prop::HOST_RX_FILTERS, &[9, 1]);
7166        expect_status(&emitted[0], 2, Status::PARSE_ERROR);
7167        assert_eq!(get(&mut session, prop::HOST_RX_FILTERS), table);
7168
7169        // Duplicates in the value collapse (a set, not a list).
7170        let mut doubled = table.clone();
7171        doubled.extend_from_slice(&table);
7172        let (emitted, _) = set(&mut session, prop::HOST_RX_FILTERS, &doubled);
7173        let (_, _, value) = parse_prop_is(&emitted[0]);
7174        assert_eq!(value, table);
7175
7176        // Setting an empty value clears the table.
7177        let (emitted, _) = set(&mut session, prop::HOST_RX_FILTERS, &[]);
7178        let (_, _, value) = parse_prop_is(&emitted[0]);
7179        assert!(value.is_empty());
7180        assert!(get(&mut session, prop::HOST_RX_FILTERS).is_empty());
7181    }
7182
7183    #[test]
7184    fn explicit_filters_match_each_type() {
7185        let mut session = test_session();
7186        enable(&mut session);
7187
7188        // Destination-hint filter.
7189        insert_item(
7190            &mut session,
7191            prop::HOST_RX_FILTERS,
7192            &[items::FILTER_DEST_HINT, 0x11, 0x22, 0x33],
7193        );
7194        assert!(delivered(&mut session, &unicast_to([0x11, 0x22, 0x33])));
7195        // A MAC ack for a frame we never sent is dropped.
7196        assert!(!delivered(
7197            &mut session,
7198            &mac_ack_with_mic([0x11, 0x22, 0x33, 0x44])
7199        ));
7200        assert!(!delivered(&mut session, &unicast_to([4, 5, 6])));
7201        // Broadcasts are implicitly accepted for live delivery even
7202        // though no explicit filter selects them.
7203        assert!(delivered(&mut session, &broadcast_frame()));
7204
7205        // Channel filter: matches multicast and blind unicast on the
7206        // channel (a blind unicast's destination hint is concealed).
7207        insert_item(
7208            &mut session,
7209            prop::HOST_RX_FILTERS,
7210            &[items::FILTER_CHANNEL_ID, 0xAB, 0xCD],
7211        );
7212        assert!(delivered(&mut session, &multicast_on([0xAB, 0xCD])));
7213        assert!(delivered(&mut session, &blind_unicast_on([0xAB, 0xCD])));
7214        assert!(!delivered(&mut session, &multicast_on([0x00, 0x01])));
7215
7216        // Packet-type filter: a MAC ack that matches no recorded
7217        // ack_mic was rejected above, but an explicit entry admits it.
7218        insert_item(
7219            &mut session,
7220            prop::HOST_RX_FILTERS,
7221            &[items::FILTER_PKT_TYPE, PacketType::MacAck as u8],
7222        );
7223        assert!(delivered(
7224            &mut session,
7225            &mac_ack_with_mic([0x11, 0x22, 0x33, 0x44])
7226        ));
7227        // Still rejects frames matching no filter.
7228        assert!(!delivered(&mut session, &unicast_to([4, 5, 6])));
7229        assert!(!delivered(&mut session, &[0x00, 0x01, 0x02]));
7230    }
7231
7232    #[test]
7233    fn promiscuous_bypasses_filtering_for_live_delivery() {
7234        let mut session = test_session();
7235        enable(&mut session);
7236        insert_item(
7237            &mut session,
7238            prop::HOST_RX_FILTERS,
7239            &[items::FILTER_DEST_HINT, 0x11, 0x22, 0x33],
7240        );
7241        assert!(!delivered(&mut session, &unicast_to([4, 5, 6])));
7242
7243        set(&mut session, prop::MAC_PROMISCUOUS, &[1]);
7244        assert!(delivered(&mut session, &unicast_to([4, 5, 6])));
7245        assert!(delivered(&mut session, &[0x00, 0x01, 0x02]));
7246
7247        // Attach reverts promiscuous mode; filtering applies again.
7248        session.attach(true);
7249        assert!(!delivered(&mut session, &unicast_to([4, 5, 6])));
7250    }
7251
7252    #[test]
7253    fn filters_survive_attach() {
7254        let mut session = test_session();
7255        enable(&mut session);
7256        install_host_key(&mut session, &[0xC4; 32]);
7257        insert_item(
7258            &mut session,
7259            prop::HOST_RX_FILTERS,
7260            &[items::FILTER_PKT_TYPE, 0],
7261        );
7262        session.attach(true);
7263        assert_eq!(get(&mut session, prop::HOST_KEY), [0xC4; 32]);
7264        assert!(delivered(&mut session, &broadcast_frame()));
7265        assert!(delivered(&mut session, &unicast_to([0xC4, 0xC4, 0xC4])));
7266        assert!(!delivered(&mut session, &unicast_to([1, 2, 3])));
7267    }
7268
7269    #[test]
7270    fn host_key_insert_remove_is_invalid_argument() {
7271        let mut session = test_session();
7272        let (emitted, _) = insert_item(&mut session, prop::HOST_KEY, &[0; 32]);
7273        expect_status(&emitted[0], 5, Status::INVALID_ARGUMENT);
7274        let (emitted, _) = remove_item(&mut session, prop::HOST_KEY, &[0; 32]);
7275        expect_status(&emitted[0], 6, Status::INVALID_ARGUMENT);
7276    }
7277
7278    // ─── CAP_HOST_RX_QUEUE gate ──────────────────────────────────────
7279
7280    /// Feed a frame while detached at `now_ms` (asserting it is not
7281    /// delivered live).
7282    fn receive_detached(session: &mut TestSession, frame: &[u8], now_ms: u64) {
7283        assert!(!delivered_at(session, frame, now_ms));
7284    }
7285
7286    fn queue_count(session: &mut TestSession) -> u16 {
7287        let raw = get(session, prop::HOST_RX_QUEUE_COUNT);
7288        u16::from_le_bytes([raw[0], raw[1]])
7289    }
7290
7291    /// Issue CMD_QUEUE_DRAIN and run it to completion, returning the
7292    /// drained (frame, metadata) pairs. Asserts correct completion.
7293    fn drain(session: &mut TestSession, now_ms: u64) -> Vec<(Vec<u8>, BufferedRxMeta)> {
7294        let mut buf = [0u8; 4];
7295        let len = frame::queue_drain(&mut buf, 7).unwrap();
7296        let (emitted, effect) = dispatch(session, &buf[..len], now_ms);
7297        if effect.is_none() {
7298            // Empty queue: immediate success, nothing drained.
7299            expect_status(&emitted[0], 7, Status::OK);
7300            return Vec::new();
7301        }
7302        assert_eq!(effect, Some(Effect::DrainQueue));
7303        assert!(emitted.is_empty());
7304        let mut steps = Vec::new();
7305        loop {
7306            let mut emitted = Vec::new();
7307            let more = session.drain_step(now_ms, &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
7308            assert_eq!(emitted.len(), 1, "each step emits exactly one frame");
7309            if !more {
7310                expect_status(&emitted[0], 7, Status::OK);
7311                return steps;
7312            }
7313            let parsed = Frame::parse(&emitted[0]).unwrap();
7314            assert_eq!(parsed.command(), Some(Cmd::StrRecv));
7315            let payload = StreamPayload::parse(parsed.payload).unwrap();
7316            steps.push((
7317                payload.data.to_vec(),
7318                BufferedRxMeta::decode(payload.metadata).unwrap(),
7319            ));
7320        }
7321    }
7322
7323    #[test]
7324    fn detached_receive_then_attach_count_drain() {
7325        let mut session = test_session();
7326        enable(&mut session);
7327        session.detach();
7328        receive_detached(&mut session, &unicast_to([1, 2, 3]), 1_000);
7329        receive_detached(&mut session, &broadcast_frame(), 3_000);
7330
7331        // Attach does not flush the queue; live delivery resumes while
7332        // the backlog waits for an explicit drain.
7333        session.attach(true);
7334        assert_eq!(queue_count(&mut session), 2);
7335        assert!(delivered(&mut session, &unicast_to([7, 7, 7])));
7336        assert_eq!(queue_count(&mut session), 2);
7337
7338        let drained = drain(&mut session, 8_000);
7339        assert_eq!(drained.len(), 2);
7340        // Oldest first, with buffered metadata: flags, one-second age
7341        // granularity, and the recorded RSSI/SNR.
7342        assert_eq!(drained[0].0, unicast_to([1, 2, 3]));
7343        assert_eq!(drained[1].0, broadcast_frame());
7344        for (_, meta) in &drained {
7345            assert_eq!(meta.flags, RX_FLAG_BUFFERED);
7346            assert_eq!(meta.rx.rssi_dbm, Some(-80));
7347            assert_eq!(meta.rx.snr_cb, Some(40));
7348        }
7349        assert_eq!((drained[0].1.age_s, drained[1].1.age_s), (7, 5));
7350
7351        assert_eq!(queue_count(&mut session), 0);
7352        // Draining an empty queue succeeds immediately.
7353        assert!(drain(&mut session, 9_000).is_empty());
7354    }
7355
7356    #[test]
7357    fn queue_overflow_evicts_oldest_and_counts_dropped() {
7358        let mut session = test_session();
7359        enable(&mut session);
7360        session.detach();
7361        // Overfill by three: the queue keeps the most recent traffic.
7362        for index in 0..(RX_QUEUE_CAPACITY + 3) as u8 {
7363            receive_detached(&mut session, &unicast_to([index, 0, 0]), 0);
7364        }
7365        session.attach(true);
7366        assert_eq!(queue_count(&mut session), RX_QUEUE_CAPACITY as u16);
7367        assert_eq!(
7368            get(&mut session, prop::HOST_RX_QUEUE_DROPPED),
7369            3u32.to_le_bytes()
7370        );
7371        let drained = drain(&mut session, 0);
7372        assert_eq!(drained[0].0, unicast_to([3, 0, 0]));
7373        assert_eq!(
7374            drained.last().unwrap().0,
7375            unicast_to([(RX_QUEUE_CAPACITY + 2) as u8, 0, 0])
7376        );
7377    }
7378
7379    #[test]
7380    fn queue_respects_receive_filtering() {
7381        let mut session = test_session();
7382        enable(&mut session);
7383        insert_item(
7384            &mut session,
7385            prop::HOST_RX_FILTERS,
7386            &[items::FILTER_DEST_HINT, 0x11, 0x22, 0x33],
7387        );
7388        session.detach();
7389        receive_detached(&mut session, &unicast_to([0x11, 0x22, 0x33]), 0);
7390        receive_detached(&mut session, &unicast_to([4, 5, 6]), 0); // rejected
7391        receive_detached(&mut session, &[0xFF, 0xFE], 0); // unparseable
7392        session.attach(true);
7393        assert_eq!(queue_count(&mut session), 1);
7394    }
7395
7396    #[test]
7397    fn broadcasts_are_implicit_live_but_follow_filters_when_queued() {
7398        let mut session = test_session();
7399        enable(&mut session);
7400        // A configured host key means filtering is active, yet a live
7401        // broadcast is still delivered: every node is a broadcast's
7402        // addressee, the host included.
7403        install_host_key(&mut session, &[0xC4; 32]);
7404        assert!(delivered(&mut session, &broadcast_frame()));
7405        // While detached the implicit rule does not apply — ambient
7406        // broadcast traffic must not displace queued unicast frames.
7407        session.detach();
7408        receive_detached(&mut session, &broadcast_frame(), 0);
7409        session.attach(true);
7410        assert_eq!(queue_count(&mut session), 0);
7411    }
7412
7413    #[test]
7414    fn unauthenticated_duplicates_occupy_separate_entries() {
7415        // No keys are provisioned before CAP_HOST_KEYS, so no
7416        // protocol-defined duplicate detection applies.
7417        let mut session = test_session();
7418        enable(&mut session);
7419        session.detach();
7420        let frame = unicast_to([1, 2, 3]);
7421        receive_detached(&mut session, &frame, 0);
7422        receive_detached(&mut session, &frame, 0);
7423        session.attach(true);
7424        assert_eq!(queue_count(&mut session), 2);
7425    }
7426
7427    #[test]
7428    fn live_arrivals_interleave_with_a_drain() {
7429        let mut session = test_session();
7430        enable(&mut session);
7431        session.detach();
7432        receive_detached(&mut session, &unicast_to([1, 0, 0]), 0);
7433        receive_detached(&mut session, &unicast_to([2, 0, 0]), 0);
7434        session.attach(true);
7435
7436        let mut buf = [0u8; 4];
7437        let len = frame::queue_drain(&mut buf, 7).unwrap();
7438        let (_, effect) = dispatch(&mut session, &buf[..len], 10_000);
7439        assert_eq!(effect, Some(Effect::DrainQueue));
7440
7441        // First covered frame.
7442        let mut emitted = Vec::new();
7443        assert!(session.drain_step(10_000, &mut |bytes: &[u8]| emitted.push(bytes.to_vec())));
7444
7445        // A live arrival mid-drain is delivered immediately and is not
7446        // part of the covered set.
7447        assert!(delivered_at(&mut session, &unicast_to([3, 0, 0]), 10_000));
7448
7449        // The drain still covers exactly the original two frames.
7450        let mut frames = 0;
7451        loop {
7452            let mut emitted = Vec::new();
7453            let more = session.drain_step(10_000, &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
7454            if !more {
7455                expect_status(&emitted[0], 7, Status::OK);
7456                break;
7457            }
7458            frames += 1;
7459        }
7460        assert_eq!(frames, 1);
7461        assert_eq!(queue_count(&mut session), 0);
7462    }
7463
7464    #[test]
7465    fn second_drain_while_in_progress_is_busy() {
7466        let mut session = test_session();
7467        enable(&mut session);
7468        session.detach();
7469        receive_detached(&mut session, &unicast_to([1, 0, 0]), 0);
7470        session.attach(true);
7471
7472        let mut buf = [0u8; 4];
7473        let len = frame::queue_drain(&mut buf, 7).unwrap();
7474        let (_, effect) = dispatch(&mut session, &buf[..len], 0);
7475        assert_eq!(effect, Some(Effect::DrainQueue));
7476
7477        let len = frame::queue_drain(&mut buf, 6).unwrap();
7478        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
7479        assert!(effect.is_none());
7480        expect_status(&emitted[0], 6, Status::BUSY);
7481    }
7482
7483    #[test]
7484    fn reset_and_host_replacement_discard_the_queue() {
7485        let mut session = test_session();
7486        enable(&mut session);
7487        session.detach();
7488        for _ in 0..(RX_QUEUE_CAPACITY + 1) {
7489            receive_detached(&mut session, &unicast_to([1, 2, 3]), 0);
7490        }
7491        session.attach(true);
7492        assert_ne!(queue_count(&mut session), 0);
7493
7494        // Host replacement discards the queue and its counters as part
7495        // of the host domain.
7496        install_host_key(&mut session, &[0xAA; 32]);
7497        assert_eq!(queue_count(&mut session), 0);
7498        assert_eq!(
7499            get(&mut session, prop::HOST_RX_QUEUE_DROPPED),
7500            0u32.to_le_bytes()
7501        );
7502
7503        // CMD_RST does too. (Refill first; the host key now filters, so
7504        // address the host.)
7505        enable(&mut session);
7506        session.detach();
7507        receive_detached(&mut session, &unicast_to([0xAA, 0xAA, 0xAA]), 0);
7508        session.attach(true);
7509        assert_eq!(queue_count(&mut session), 1);
7510        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_| {});
7511        assert_eq!(queue_count(&mut session), 0);
7512    }
7513
7514    // ─── CAP_HOST_KEYS gate ──────────────────────────────────────────
7515
7516    /// Insert a channel key, returning its derived identifier digest.
7517    fn install_channel_key(session: &mut TestSession, key: &[u8; 32]) -> [u8; 2] {
7518        let (emitted, effect) = insert_item(session, prop::HOST_CHANNEL_KEYS, key);
7519        assert!(effect.is_none());
7520        let (prop_key, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
7521        assert_eq!(prop_key, prop::HOST_CHANNEL_KEYS);
7522        digest.try_into().expect("channel digest is 2 bytes")
7523    }
7524
7525    fn peer_entry(seed: u8) -> [u8; 64] {
7526        let mut item = [0u8; 64];
7527        item[..32].fill(seed);
7528        item[32..48].fill(0xE0 | (seed & 0x0F));
7529        item[48..].fill(0x50 | (seed & 0x0F));
7530        item
7531    }
7532
7533    #[test]
7534    fn channel_key_lifecycle_and_digest_is_derived_id() {
7535        let mut session = test_session();
7536        let key = [0x42; 32];
7537        let expected_id = test_engine().derive_channel_id(&ChannelKey(key)).0;
7538
7539        let digest = install_channel_key(&mut session, &key);
7540        assert_eq!(digest, expected_id);
7541        assert_eq!(get(&mut session, prop::HOST_CHANNEL_KEYS), expected_id);
7542
7543        // Duplicate channel key fails with ALREADY.
7544        let (emitted, _) = insert_item(&mut session, prop::HOST_CHANNEL_KEYS, &key);
7545        expect_status(&emitted[0], 5, Status::ALREADY);
7546
7547        // Remove selector is the key; the digest reported is the id.
7548        let (emitted, _) = remove_item(&mut session, prop::HOST_CHANNEL_KEYS, &key);
7549        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropRemoved, 6);
7550        assert_eq!(digest, expected_id);
7551        assert!(get(&mut session, prop::HOST_CHANNEL_KEYS).is_empty());
7552
7553        let (emitted, _) = remove_item(&mut session, prop::HOST_CHANNEL_KEYS, &key);
7554        expect_status(&emitted[0], 6, Status::ITEM_NOT_FOUND);
7555
7556        // Wrong-size items are invalid.
7557        for bad in [&[0u8; 31][..], &[0u8; 33][..], &[][..]] {
7558            let (emitted, _) = insert_item(&mut session, prop::HOST_CHANNEL_KEYS, bad);
7559            expect_status(&emitted[0], 5, Status::INVALID_ARGUMENT);
7560        }
7561    }
7562
7563    #[test]
7564    fn channel_key_capacity_is_bounded() {
7565        let mut session = test_session();
7566        for seed in 0..MAX_CHANNEL_KEYS as u8 {
7567            install_channel_key(&mut session, &[seed; 32]);
7568        }
7569        let (emitted, _) = insert_item(&mut session, prop::HOST_CHANNEL_KEYS, &[0xFF; 32]);
7570        expect_status(&emitted[0], 5, Status::NOMEM);
7571    }
7572
7573    #[test]
7574    fn peer_key_lifecycle_replacement_and_secret_free_digests() {
7575        let mut session = test_session();
7576        let entry = peer_entry(0xA1);
7577
7578        let (emitted, _) = insert_item(&mut session, prop::HOST_PEER_KEYS, &entry);
7579        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
7580        assert_eq!(digest, entry[..32]);
7581        // No emitted frame may carry the pairwise key material.
7582        for frame in &emitted {
7583            assert!(!frame.windows(16).any(|window| window == &entry[32..48]));
7584            assert!(!frame.windows(16).any(|window| window == &entry[48..]));
7585        }
7586
7587        // GET reports public keys only.
7588        assert_eq!(get(&mut session, prop::HOST_PEER_KEYS), entry[..32]);
7589
7590        // Inserting the same public key with new key material replaces
7591        // the entry (never ALREADY) and does not grow the table.
7592        let mut replacement = entry;
7593        replacement[32..].fill(0x77);
7594        let (emitted, _) = insert_item(&mut session, prop::HOST_PEER_KEYS, &replacement);
7595        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
7596        assert_eq!(digest, entry[..32]);
7597        assert_eq!(get(&mut session, prop::HOST_PEER_KEYS), entry[..32]);
7598
7599        // Remove selector is the public key.
7600        let (emitted, _) = remove_item(&mut session, prop::HOST_PEER_KEYS, &entry[..32]);
7601        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropRemoved, 6);
7602        assert_eq!(digest, entry[..32]);
7603        assert!(get(&mut session, prop::HOST_PEER_KEYS).is_empty());
7604
7605        let (emitted, _) = remove_item(&mut session, prop::HOST_PEER_KEYS, &entry[..32]);
7606        expect_status(&emitted[0], 6, Status::ITEM_NOT_FOUND);
7607
7608        // Malformed entries are invalid.
7609        let (emitted, _) = insert_item(&mut session, prop::HOST_PEER_KEYS, &entry[..63]);
7610        expect_status(&emitted[0], 5, Status::INVALID_ARGUMENT);
7611    }
7612
7613    #[test]
7614    fn key_table_whole_set_is_atomic_and_collapses_duplicates() {
7615        let mut session = test_session();
7616
7617        // Channels: duplicates collapse; a short trailing item fails
7618        // the whole set, leaving the table unchanged.
7619        let key_a = [0xA0; 32];
7620        let key_b = [0xB0; 32];
7621        let mut table = Vec::new();
7622        table.extend_from_slice(&key_a);
7623        table.extend_from_slice(&key_b);
7624        table.extend_from_slice(&key_a);
7625        let (emitted, _) = set(&mut session, prop::HOST_CHANNEL_KEYS, &table);
7626        let (_, key, value) = parse_prop_is(&emitted[0]);
7627        assert_eq!(key, prop::HOST_CHANNEL_KEYS);
7628        assert_eq!(value.len(), 4, "two unique channels, 2-byte ids");
7629
7630        let (emitted, _) = set(&mut session, prop::HOST_CHANNEL_KEYS, &table[..40]);
7631        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
7632        assert_eq!(get(&mut session, prop::HOST_CHANNEL_KEYS).len(), 4);
7633
7634        // Peers: a repeated public key replaces the earlier entry.
7635        let mut peers = Vec::new();
7636        peers.extend_from_slice(&peer_entry(0x01));
7637        let mut updated = peer_entry(0x01);
7638        updated[32..].fill(0x99);
7639        peers.extend_from_slice(&updated);
7640        let (emitted, _) = set(&mut session, prop::HOST_PEER_KEYS, &peers);
7641        let (_, _, value) = parse_prop_is(&emitted[0]);
7642        assert_eq!(value, peer_entry(0x01)[..32], "one entry, digest form");
7643
7644        // Empty set clears; oversized set fails atomically.
7645        let (emitted, _) = set(&mut session, prop::HOST_PEER_KEYS, &[]);
7646        let (_, _, value) = parse_prop_is(&emitted[0]);
7647        assert!(value.is_empty());
7648        let mut oversized = Vec::new();
7649        for seed in 0..(MAX_PEER_KEYS + 1) as u8 {
7650            oversized.extend_from_slice(&peer_entry(seed));
7651        }
7652        let (emitted, _) = set(&mut session, prop::HOST_PEER_KEYS, &oversized);
7653        expect_status(&emitted[0], 2, Status::NOMEM);
7654        assert!(get(&mut session, prop::HOST_PEER_KEYS).is_empty());
7655    }
7656
7657    #[test]
7658    fn insecure_transport_refuses_key_writes() {
7659        let mut session = test_session();
7660        session.attach(false); // e.g. a bare UART with no possession story
7661
7662        for (key, item) in [
7663            (prop::HOST_CHANNEL_KEYS, &[0x42u8; 32][..]),
7664            (prop::HOST_PEER_KEYS, &peer_entry(0x01)[..]),
7665        ] {
7666            let (emitted, effect) = set(&mut session, key, item);
7667            assert!(effect.is_none());
7668            expect_status(&emitted[0], 2, Status::INVALID_STATE);
7669            let (emitted, _) = insert_item(&mut session, key, item);
7670            expect_status(&emitted[0], 5, Status::INVALID_STATE);
7671            assert!(get(&mut session, key).is_empty(), "table must stay empty");
7672        }
7673
7674        // Non-key properties are unaffected by the gate.
7675        let (emitted, _) = set(&mut session, prop::PHY_DUTY_LIMIT, &100u16.to_le_bytes());
7676        let (_, key, _) = parse_prop_is(&emitted[0]);
7677        assert_eq!(key, prop::PHY_DUTY_LIMIT);
7678
7679        // Re-attaching over a secure transport unlocks provisioning.
7680        session.attach(true);
7681        install_channel_key(&mut session, &[0x42; 32]);
7682    }
7683
7684    #[test]
7685    fn provisioned_channel_id_is_an_implicit_filter() {
7686        let mut session = test_session();
7687        enable(&mut session);
7688        // Only a channel key is provisioned: filtering becomes
7689        // configured (compatibility rule) and the derived id matches
7690        // multicast and blind unicast on that channel.
7691        let id = install_channel_key(&mut session, &[0x42; 32]);
7692        assert!(delivered(&mut session, &multicast_on(id)));
7693        assert!(delivered(&mut session, &blind_unicast_on(id)));
7694        let other = [id[0] ^ 0xFF, id[1]];
7695        assert!(!delivered(&mut session, &multicast_on(other)));
7696        // Broadcasts stay implicitly accepted for live delivery.
7697        assert!(delivered(&mut session, &broadcast_frame()));
7698        assert!(!delivered(&mut session, &[0x00, 0x01, 0x02]));
7699
7700        // Detached queueing honors the same implicit filter.
7701        session.detach();
7702        receive_detached(&mut session, &multicast_on(id), 0);
7703        receive_detached(&mut session, &multicast_on(other), 0);
7704        session.attach(true);
7705        assert_eq!(queue_count(&mut session), 1);
7706    }
7707
7708    #[test]
7709    fn host_replacement_clears_key_tables() {
7710        let mut session = test_session();
7711        install_channel_key(&mut session, &[0x42; 32]);
7712        insert_item(&mut session, prop::HOST_PEER_KEYS, &peer_entry(0x01));
7713
7714        install_host_key(&mut session, &[0xAA; 32]);
7715        assert!(get(&mut session, prop::HOST_CHANNEL_KEYS).is_empty());
7716        assert!(get(&mut session, prop::HOST_PEER_KEYS).is_empty());
7717
7718        // CMD_RST clears them too.
7719        install_channel_key(&mut session, &[0x42; 32]);
7720        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_| {});
7721        assert!(get(&mut session, prop::HOST_CHANNEL_KEYS).is_empty());
7722    }
7723
7724    // ─── CAP_HOST_AUTO_ACK gate ──────────────────────────────────────
7725
7726    use umsh_core::{MicSize, PublicKey};
7727
7728    const HOST_PUB: [u8; 32] = [0xC4; 32];
7729    const PEER_PUB: [u8; 32] = [0x0A; 32];
7730
7731    fn test_pairwise() -> PairwiseKeys {
7732        PairwiseKeys {
7733            k_enc: [0x5E; 16],
7734            k_mic: [0x5F; 16],
7735        }
7736    }
7737
7738    fn peer_item(public_key: &[u8; 32], keys: &PairwiseKeys) -> [u8; 64] {
7739        let mut item = [0u8; 64];
7740        item[..32].copy_from_slice(public_key);
7741        item[32..48].copy_from_slice(&keys.k_enc);
7742        item[48..].copy_from_slice(&keys.k_mic);
7743        item
7744    }
7745
7746    /// Detached session provisioned for delegated acknowledgement:
7747    /// host key, one peer, auto-ACK on, PHY enabled.
7748    fn auto_ack_session() -> TestSession {
7749        let mut session = test_session();
7750        enable(&mut session);
7751        install_host_key(&mut session, &HOST_PUB);
7752        insert_item(
7753            &mut session,
7754            prop::HOST_PEER_KEYS,
7755            &peer_item(&PEER_PUB, &test_pairwise()),
7756        );
7757        set(&mut session, prop::HOST_AUTO_ACK, &[1]);
7758        session.detach();
7759        session
7760    }
7761
7762    /// A sealed UNAR from the test peer to the host (unencrypted body,
7763    /// 8-byte MIC), authenticated with `keys`.
7764    fn sealed_unar(counter: u32, keys: &PairwiseKeys, full_source: bool) -> Vec<u8> {
7765        let mut buf = [0u8; 96];
7766        let builder = PacketBuilder::new(&mut buf).unicast(NodeHint([0xC4, 0xC4, 0xC4]));
7767        let builder = if full_source {
7768            builder.source_full(&PublicKey(PEER_PUB))
7769        } else {
7770            builder.source_hint(NodeHint([0x0A, 0x0A, 0x0A]))
7771        };
7772        let mut packet = builder
7773            .frame_counter(counter)
7774            .ack_requested()
7775            .mic_size(MicSize::Mic8)
7776            .payload(&[3, 1, 2])
7777            .build()
7778            .unwrap();
7779        test_engine().seal_packet(&mut packet, keys).unwrap();
7780        packet.as_bytes().to_vec()
7781    }
7782
7783    /// A sealed BUAR from the test peer to the host through `channel_key`.
7784    fn sealed_buar(counter: u32, channel_key: &[u8; 32]) -> Vec<u8> {
7785        let engine = test_engine();
7786        let channel_keys = engine.derive_channel_keys(&ChannelKey(*channel_key));
7787        let mut buf = [0u8; 96];
7788        let mut packet = PacketBuilder::new(&mut buf)
7789            .blind_unicast(channel_keys.channel_id, NodeHint([0xC4, 0xC4, 0xC4]))
7790            .source_hint(NodeHint([0x0A, 0x0A, 0x0A]))
7791            .frame_counter(counter)
7792            .ack_requested()
7793            .encrypted()
7794            .mic_size(MicSize::Mic8)
7795            .payload(&[3, 9, 9])
7796            .build()
7797            .unwrap();
7798        let blind = engine.derive_blind_keys(&test_pairwise(), &channel_keys);
7799        engine
7800            .seal_blind_packet(&mut packet, &blind, &channel_keys)
7801            .unwrap();
7802        packet.as_bytes().to_vec()
7803    }
7804
7805    /// Feed a detached frame; detached processing must emit nothing.
7806    fn rx_effect(session: &mut TestSession, frame: &[u8], now_ms: u64) -> Option<Effect> {
7807        session.on_radio_rx(frame, -80, 40, None, now_ms, &mut |_: &[u8]| {
7808            panic!("detached receive must not emit")
7809        })
7810    }
7811
7812    /// The expected 8-byte ack trailer (`ack_mic || ack_tag`) for an
7813    /// unencrypted sealed frame.
7814    fn expected_ack_trailer(frame: &[u8], keys: &PairwiseKeys) -> [u8; 8] {
7815        let engine = test_engine();
7816        let header = PacketHeader::parse(frame).unwrap();
7817        let mut cmac = engine.cmac_state(&keys.k_mic);
7818        umsh_core::feed_aad(&header, frame, |chunk| cmac.update(chunk));
7819        cmac.update(&frame[header.body_range.clone()]);
7820        engine.compute_ack_trailer(&cmac.finalize(), &keys.k_enc)
7821    }
7822
7823    /// Assert the staged transmit is a MAC ack (which carries no
7824    /// destination hint), and complete it.
7825    fn expect_ack_transmit(
7826        session: &mut TestSession,
7827        effect: Option<Effect>,
7828        trailer: Option<[u8; 8]>,
7829    ) {
7830        assert_eq!(effect, Some(Effect::StartTransmit));
7831        let header = PacketHeader::parse(session.tx_data()).unwrap();
7832        assert_eq!(header.fcf.packet_type(), PacketType::MacAck);
7833        if let Some(trailer) = trailer {
7834            assert_eq!(session.tx_data()[header.mic_range.clone()], trailer);
7835        }
7836        session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {
7837            panic!("autonomous ack must be silent")
7838        });
7839    }
7840
7841    #[test]
7842    fn unar_success_acks_queues_and_reports_acked() {
7843        let mut session = auto_ack_session();
7844        let frame = sealed_unar(100, &test_pairwise(), false);
7845        let effect = rx_effect(&mut session, &frame, 1_000);
7846        expect_ack_transmit(
7847            &mut session,
7848            effect,
7849            Some(expected_ack_trailer(&frame, &test_pairwise())),
7850        );
7851
7852        // The autonomous ack leaves PROP_LAST_STATUS alone: the boot
7853        // reason must still reach the next host.
7854        session.attach(true);
7855        assert_eq!(
7856            pui::decode(&get(&mut session, prop::LAST_STATUS))
7857                .unwrap()
7858                .0,
7859            Status::RESET_POWER_ON.0
7860        );
7861        assert_eq!(queue_count(&mut session), 1);
7862        let drained = drain(&mut session, 1_000);
7863        assert_eq!(
7864            drained[0].0, frame,
7865            "the queue holds the original wire bytes"
7866        );
7867        assert_eq!(drained[0].1.flags, RX_FLAG_BUFFERED | RX_FLAG_ACKED);
7868    }
7869
7870    #[test]
7871    fn buar_success_and_missing_channel_key() {
7872        let channel_key = [0x42; 32];
7873        let mut session = auto_ack_session();
7874        // Without the channel key the frame does not even pass
7875        // filtering (its destination hint is concealed).
7876        let frame = sealed_buar(7, &channel_key);
7877        assert!(rx_effect(&mut session, &frame, 0).is_none());
7878        session.attach(true);
7879        assert_eq!(queue_count(&mut session), 0);
7880
7881        // With the channel key provisioned it is accepted,
7882        // authenticated with the combined blind keys, and acked.
7883        install_channel_key(&mut session, &channel_key);
7884        session.detach();
7885        let effect = rx_effect(&mut session, &frame, 0);
7886        expect_ack_transmit(&mut session, effect, None);
7887        session.attach(true);
7888        assert_eq!(queue_count(&mut session), 1);
7889        let drained = drain(&mut session, 0);
7890        assert_eq!(drained[0].1.flags, RX_FLAG_BUFFERED | RX_FLAG_ACKED);
7891    }
7892
7893    #[test]
7894    fn unprovisioned_source_and_bad_mic_queue_unacked() {
7895        let mut session = auto_ack_session();
7896
7897        // Sealed with keys the device does not hold: authentication fails,
7898        // but filtering accepted it (host destination hint), so it is
7899        // queued for the host — unacknowledged.
7900        let wrong_keys = PairwiseKeys {
7901            k_enc: [1; 16],
7902            k_mic: [2; 16],
7903        };
7904        assert!(rx_effect(&mut session, &sealed_unar(5, &wrong_keys, false), 0).is_none());
7905
7906        // A corrupted MIC likewise fails closed without an ack and
7907        // without disturbing the peer's replay baseline.
7908        let mut corrupted = sealed_unar(6, &test_pairwise(), false);
7909        let last = corrupted.len() - 1;
7910        corrupted[last] ^= 0xFF;
7911        assert!(rx_effect(&mut session, &corrupted, 0).is_none());
7912
7913        // First-contact baseline is unset: an early counter still
7914        // authenticates and establishes the baseline at face value.
7915        let effect = rx_effect(&mut session, &sealed_unar(1, &test_pairwise(), false), 0);
7916        expect_ack_transmit(&mut session, effect, None);
7917
7918        session.attach(true);
7919        assert_eq!(queue_count(&mut session), 3);
7920        let drained = drain(&mut session, 0);
7921        assert_eq!(drained[0].1.flags, RX_FLAG_BUFFERED);
7922        assert_eq!(drained[1].1.flags, RX_FLAG_BUFFERED);
7923        assert_eq!(drained[2].1.flags, RX_FLAG_BUFFERED | RX_FLAG_ACKED);
7924    }
7925
7926    #[test]
7927    fn ambiguous_source_hint_is_never_acked_but_full_key_resolves() {
7928        let mut session = auto_ack_session();
7929        // A second provisioned peer shares the 3-byte prefix (key
7930        // writes need the secure attached link).
7931        let mut twin = PEER_PUB;
7932        twin[31] ^= 0xFF;
7933        session.attach(true);
7934        insert_item(
7935            &mut session,
7936            prop::HOST_PEER_KEYS,
7937            &peer_item(&twin, &test_pairwise()),
7938        );
7939        session.detach();
7940
7941        // Hint form: ambiguous, does not resolve, no ack.
7942        assert!(rx_effect(&mut session, &sealed_unar(4, &test_pairwise(), false), 0).is_none());
7943
7944        // Full-key form (S flag): resolves and acks.
7945        let effect = rx_effect(&mut session, &sealed_unar(4, &test_pairwise(), true), 0);
7946        expect_ack_transmit(&mut session, effect, None);
7947    }
7948
7949    #[test]
7950    fn duplicates_coalesce_and_reack_only_within_window() {
7951        let mut session = auto_ack_session();
7952        let keys = test_pairwise();
7953
7954        let first = sealed_unar(5, &keys, false);
7955        let effect = rx_effect(&mut session, &first, 0);
7956        expect_ack_transmit(
7957            &mut session,
7958            effect,
7959            Some(expected_ack_trailer(&first, &keys)),
7960        );
7961
7962        // Exact retransmission: coalesced (no new entry) and re-acked.
7963        let effect = rx_effect(&mut session, &first, 10);
7964        expect_ack_transmit(
7965            &mut session,
7966            effect,
7967            Some(expected_ack_trailer(&first, &keys)),
7968        );
7969
7970        // Advance the baseline well past the re-ack window.
7971        for counter in 6..=14 {
7972            let effect = rx_effect(&mut session, &sealed_unar(counter, &keys, false), 20);
7973            expect_ack_transmit(&mut session, effect, None);
7974        }
7975        // counter 5 is now 9 behind: MUST NOT be acknowledged.
7976        assert!(rx_effect(&mut session, &first, 30).is_none());
7977
7978        // The re-ack did not advance the baseline: the next counter is
7979        // still accepted normally.
7980        let effect = rx_effect(&mut session, &sealed_unar(15, &keys, false), 40);
7981        expect_ack_transmit(&mut session, effect, None);
7982
7983        session.attach(true);
7984        // 5, 6..=14, the out-of-window copy of 5, and 15: the exact
7985        // duplicate of 5 consumed no slot.
7986        assert_eq!(queue_count(&mut session), 12);
7987    }
7988
7989    #[test]
7990    fn attached_host_suppresses_delegation() {
7991        let mut session = auto_ack_session();
7992        session.attach(true);
7993        let frame = sealed_unar(5, &test_pairwise(), false);
7994        let mut emitted = Vec::new();
7995        let effect = session.on_radio_rx(&frame, -80, 40, None, 0, &mut |bytes: &[u8]| {
7996            emitted.push(bytes.to_vec())
7997        });
7998        // Delivered live, never acknowledged on the host's behalf.
7999        assert!(effect.is_none());
8000        assert_eq!(emitted.len(), 1);
8001    }
8002
8003    #[test]
8004    fn auto_ack_disabled_and_duty_limit_leave_frames_unacked() {
8005        let mut session = auto_ack_session();
8006        session.attach(true);
8007        set(&mut session, prop::HOST_AUTO_ACK, &[0]);
8008        session.detach();
8009        assert!(rx_effect(&mut session, &sealed_unar(5, &test_pairwise(), false), 0).is_none());
8010
8011        // Re-enable delegation but exhaust the duty budget: the ack is
8012        // prohibited and the frame stays queued unacked.
8013        session.attach(true);
8014        set(&mut session, prop::HOST_AUTO_ACK, &[1]);
8015        set(&mut session, prop::PHY_DUTY_LIMIT, &0u16.to_le_bytes());
8016        session.detach();
8017        assert!(rx_effect(&mut session, &sealed_unar(6, &test_pairwise(), false), 0).is_none());
8018
8019        session.attach(true);
8020        assert_eq!(queue_count(&mut session), 2);
8021        for (_, meta) in drain(&mut session, 0) {
8022            assert_eq!(meta.flags, RX_FLAG_BUFFERED);
8023        }
8024    }
8025
8026    #[test]
8027    fn auto_ack_property_round_trips_and_resets() {
8028        let mut session = test_session();
8029        assert_eq!(get(&mut session, prop::HOST_AUTO_ACK), [0]);
8030        set(&mut session, prop::HOST_AUTO_ACK, &[1]);
8031        assert_eq!(get(&mut session, prop::HOST_AUTO_ACK), [1]);
8032
8033        // Survives attach; cleared by host replacement.
8034        session.attach(true);
8035        assert_eq!(get(&mut session, prop::HOST_AUTO_ACK), [1]);
8036        install_host_key(&mut session, &[0xBB; 32]);
8037        assert_eq!(get(&mut session, prop::HOST_AUTO_ACK), [0]);
8038
8039        let (emitted, _) = set(&mut session, prop::HOST_AUTO_ACK, &[2]);
8040        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8041    }
8042
8043    #[test]
8044    fn peer_key_replacement_preserves_replay_baseline() {
8045        let mut session = auto_ack_session();
8046        let old_keys = test_pairwise();
8047        let effect = rx_effect(&mut session, &sealed_unar(5, &old_keys, false), 0);
8048        expect_ack_transmit(&mut session, effect, None);
8049
8050        // Replace the peer's key material (secure link required).
8051        session.attach(true);
8052        let new_keys = PairwiseKeys {
8053            k_enc: [0x77; 16],
8054            k_mic: [0x78; 16],
8055        };
8056        insert_item(
8057            &mut session,
8058            prop::HOST_PEER_KEYS,
8059            &peer_item(&PEER_PUB, &new_keys),
8060        );
8061        session.detach();
8062
8063        // The baseline survived the replacement: a fresh frame reusing
8064        // counter 5 under the new keys is a suspected replay and is
8065        // not acknowledged, while counter 6 proceeds normally.
8066        assert!(rx_effect(&mut session, &sealed_unar(5, &new_keys, false), 10).is_none());
8067        let effect = rx_effect(&mut session, &sealed_unar(6, &new_keys, false), 20);
8068        expect_ack_transmit(&mut session, effect, None);
8069    }
8070
8071    // ─── CAP_SAVE gate ───────────────────────────────────────────────
8072
8073    /// Re-encode a snapshot with the named options left out, standing in
8074    /// for one written by a firmware that did not have them yet.
8075    fn strip_snapshot_options(bytes: &[u8], drop: &[u32]) -> Vec<u8> {
8076        let (format, options) = bytes.split_first().unwrap();
8077        let mut out = vec![0u8; bytes.len()];
8078        out[0] = *format;
8079        let mut encoder = OptionEncoder::new(&mut out[1..]);
8080        for item in OptionDecoder::new(options) {
8081            let (number, value) = item.unwrap();
8082            if drop.contains(&u32::from(number)) {
8083                continue;
8084            }
8085            encoder.put(number, value).unwrap();
8086        }
8087        let len = 1 + encoder.finish();
8088        out.truncate(len);
8089        out
8090    }
8091
8092    /// Issue CMD_SAVE and complete the durable write successfully.
8093    fn save(session: &mut TestSession) {
8094        let mut buf = [0u8; 4];
8095        let len = frame::save(&mut buf, 3).unwrap();
8096        let (emitted, effect) = dispatch(session, &buf[..len], 0);
8097        assert!(emitted.is_empty(), "no response before the write commits");
8098        assert_eq!(effect, Some(Effect::SaveSnapshot { tid: 3 }));
8099        let mut emitted = Vec::new();
8100        session.respond_save(3, Ok(()), &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
8101        expect_status(&emitted[0], 3, Status::OK);
8102    }
8103
8104    /// Issue CMD_RESTORE, expecting the reset completion form.
8105    fn restore(session: &mut TestSession) -> Option<Effect> {
8106        let mut buf = [0u8; 4];
8107        let len = frame::restore(&mut buf, 5).unwrap();
8108        let (emitted, effect) = dispatch(session, &buf[..len], 0);
8109        expect_status(&emitted[0], TID_UNSOLICITED, Status::RESET_RESTORED);
8110        effect
8111    }
8112
8113    /// A provisioned session worth saving: PHY enabled on a custom
8114    /// frequency, custom name, host key, one filter, channel key, peer.
8115    fn provisioned_session() -> TestSession {
8116        let mut session = test_session();
8117        set(&mut session, prop::PHY_FREQ, &906_875u32.to_le_bytes());
8118        enable(&mut session);
8119        set(&mut session, prop::DEV_NAME, b"saved name");
8120        install_host_key(&mut session, &HOST_PUB);
8121        insert_item(
8122            &mut session,
8123            prop::HOST_RX_FILTERS,
8124            &[items::FILTER_PKT_TYPE, 0],
8125        );
8126        install_channel_key(&mut session, &[0x42; 32]);
8127        insert_item(
8128            &mut session,
8129            prop::HOST_PEER_KEYS,
8130            &peer_item(&PEER_PUB, &test_pairwise()),
8131        );
8132        set(&mut session, prop::HOST_AUTO_ACK, &[1]);
8133        session
8134    }
8135
8136    #[test]
8137    fn save_sets_prop_saved_and_failure_rolls_back() {
8138        let mut session = test_session();
8139        assert_eq!(get(&mut session, prop::SAVED), [0]);
8140
8141        // A failed durable write leaves nothing saved.
8142        let mut buf = [0u8; 4];
8143        let len = frame::save(&mut buf, 3).unwrap();
8144        let (_, effect) = dispatch(&mut session, &buf[..len], 0);
8145        assert_eq!(effect, Some(Effect::SaveSnapshot { tid: 3 }));
8146        let mut emitted = Vec::new();
8147        session.respond_save(3, Err(()), &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
8148        expect_status(&emitted[0], 3, Status::FAILURE);
8149        assert_eq!(get(&mut session, prop::SAVED), [0]);
8150
8151        save(&mut session);
8152        assert_eq!(get(&mut session, prop::SAVED), [1]);
8153
8154        // PROP_SAVED is read-only.
8155        let (emitted, _) = set(&mut session, prop::SAVED, &[0]);
8156        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8157    }
8158
8159    #[test]
8160    fn restore_without_snapshot_is_invalid_state() {
8161        let mut session = test_session();
8162        let mut buf = [0u8; 4];
8163        let len = frame::restore(&mut buf, 5).unwrap();
8164        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
8165        assert!(effect.is_none());
8166        expect_status(&emitted[0], 5, Status::INVALID_STATE);
8167    }
8168
8169    #[test]
8170    fn snapshot_round_trips_through_the_wire_encoding() {
8171        let session = provisioned_session();
8172        let mut bytes = [0u8; SNAPSHOT_MAX];
8173        let len = session.encode_snapshot(&mut bytes).unwrap();
8174
8175        // A fresh session boots from those bytes into the saved
8176        // configuration, with the PHY re-enabled, before any host
8177        // command.
8178        let mut booted: TestSession =
8179            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
8180        let effect = booted.restore_at_boot(&bytes[..len]).unwrap();
8181        assert!(matches!(effect, Effect::ApplyRadio(s) if s.enabled && s.freq_khz == 906_875));
8182        assert_eq!(booted.device_name(), "saved name");
8183        // The device domain came back; the host domain did not, and a
8184        // multicast on the host's saved channel is no longer accepted
8185        // because nothing was provisioned to accept it.
8186        let id = test_engine().derive_channel_id(&ChannelKey([0x42; 32])).0;
8187        booted.on_radio_rx(&multicast_on(id), -80, 40, None, 0, &mut |_: &[u8]| {
8188            panic!("detached boot must not emit")
8189        });
8190        booted.attach(true);
8191        assert_eq!(get(&mut booted, prop::SAVED), [ids::saved::CURRENT]);
8192        assert_eq!(get(&mut booted, prop::HOST_KEY), Vec::<u8>::new());
8193        assert_eq!(get(&mut booted, prop::HOST_AUTO_ACK), [0]);
8194        // With no host key and no filters the domain is unprovisioned,
8195        // which filters nothing — the frame is queued, but for nobody in
8196        // particular, and it was never acknowledged on anyone's behalf.
8197        assert_eq!(queue_count(&mut booted), 1);
8198
8199        // A truncated payload and a foreign format byte are both
8200        // rejected, and rejection is reported rather than looking like
8201        // "nothing saved".
8202        let mut fresh: TestSession =
8203            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
8204        assert_eq!(
8205            fresh.restore_at_boot(&bytes[..len - 1]),
8206            Err(SnapshotError::Malformed)
8207        );
8208        let mut wrong_format = bytes;
8209        wrong_format[0] = SNAPSHOT_FORMAT + 1;
8210        assert_eq!(
8211            fresh.restore_at_boot(&wrong_format[..len]),
8212            Err(SnapshotError::UnknownFormat)
8213        );
8214        // The retired positional format is rejected too, even though its
8215        // leading 0x03 reads as a well-formed option header.
8216        let mut legacy = bytes;
8217        legacy[0] = 3;
8218        assert_eq!(
8219            fresh.restore_at_boot(&legacy[..len]),
8220            Err(SnapshotError::UnknownFormat)
8221        );
8222        fresh.attach(true);
8223        assert_eq!(get(&mut fresh, prop::SAVED), [ids::saved::NONE]);
8224        fresh.note_snapshot_rejected();
8225        assert_eq!(get(&mut fresh, prop::SAVED), [ids::saved::UNREADABLE]);
8226    }
8227
8228    /// The receiver comes back up as it was left, and so does the whole
8229    /// positioning policy. `PROP_TIME` deliberately does not: an epoch
8230    /// written to flash accumulates unbounded error while the device is
8231    /// off, so the clock is restored from a real time source or not at
8232    /// all.
8233    #[test]
8234    fn positioning_settings_survive_a_save_and_the_clock_does_not() {
8235        let mut session = test_session();
8236        set(&mut session, prop::GNSS_ENABLED, &[1]);
8237        set(&mut session, prop::TZ_OFFSET, &(-300i16).to_le_bytes());
8238        set(&mut session, prop::GNSS_IDENT_UPDATE, &[1]);
8239        set(&mut session, prop::GNSS_IDENT_PRECISION, &[6]);
8240        set(&mut session, prop::GNSS_TIME_TRUST, &[0]);
8241        set(&mut session, prop::TIME, &1_780_000_000u32.to_le_bytes());
8242
8243        let mut bytes = [0u8; SNAPSHOT_MAX];
8244        let len = session.encode_snapshot(&mut bytes).unwrap();
8245
8246        let mut booted: TestSession =
8247            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
8248        booted.restore_at_boot(&bytes[..len]).unwrap();
8249        assert!(booted.gnss_enabled());
8250        assert_eq!(booted.tz_offset_min(), -300);
8251        assert!(booted.gnss_ident_update());
8252        assert_eq!(booted.gnss_ident_precision(), 6);
8253        assert!(!booted.gnss_time_trust());
8254        booted.attach(true);
8255        assert_eq!(get(&mut booted, prop::GNSS_ENABLED), [1]);
8256        assert_eq!(get(&mut booted, prop::GNSS_TIME_TRUST), [0]);
8257        // The clock is the platform's; a restore has nothing to say about
8258        // it, so a get still defers.
8259        let mut buf = [0u8; 16];
8260        let get_len = frame::prop_get(&mut buf, 3, prop::TIME).unwrap();
8261        let (_, effect) = dispatch(&mut booted, &buf[..get_len], 0);
8262        assert_eq!(effect, Some(Effect::ReadTime { tid: 3 }));
8263
8264        // A reset reverts to the saved snapshot rather than to the
8265        // factory values, so the receiver stays on across it.
8266        let mut out = Vec::new();
8267        booted.reset(Status::RESET_SOFTWARE, &mut |bytes: &[u8]| {
8268            out.push(bytes.to_vec())
8269        });
8270        assert!(booted.gnss_enabled());
8271        assert_eq!(booted.tz_offset_min(), -300);
8272    }
8273
8274    /// Restoring a saved repeater domain onto different hardware must
8275    /// not put the PHY on the air under the replacement's throwaway
8276    /// identity. Everything else restores; only the enable is withheld,
8277    /// and installing the expected identity first makes it stick.
8278    #[test]
8279    fn restore_under_a_different_identity_leaves_the_phy_disabled() {
8280        let mut session = provisioned_session();
8281        session.set_boot_identity([0xAA; 32]);
8282        let mut bytes = [0u8; SNAPSHOT_MAX];
8283        let len = session.encode_snapshot(&mut bytes).unwrap();
8284
8285        // Replacement hardware: a different auto-generated identity.
8286        let mut replacement: TestSession =
8287            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
8288        replacement.set_boot_identity([0xBB; 32]);
8289        let effect = replacement.restore_at_boot(&bytes[..len]).unwrap();
8290        assert!(matches!(effect, Effect::ApplyRadio(s) if !s.enabled));
8291        // The rest of the domain did restore.
8292        assert_eq!(replacement.device_name(), "saved name");
8293        assert_eq!(replacement.settings().freq_khz, 906_875);
8294
8295        // With the expected identity installed, the same snapshot
8296        // restores the enable too.
8297        let mut same: TestSession =
8298            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
8299        same.set_boot_identity([0xAA; 32]);
8300        let effect = same.restore_at_boot(&bytes[..len]).unwrap();
8301        assert!(matches!(effect, Effect::ApplyRadio(s) if s.enabled));
8302    }
8303
8304    /// Apply order is schema metadata, not the identifier ordering the
8305    /// option encoder imposes. Both constraints are real and they
8306    /// disagree: `PROP_PHY_ENABLED` sorts first by number and must apply
8307    /// last.
8308    #[test]
8309    fn saved_schema_orders_by_identifier_and_applies_by_phase() {
8310        assert!(
8311            SAVED_SCHEMA
8312                .windows(2)
8313                .all(|pair| pair[0].number < pair[1].number),
8314            "the option encoder rejects a number below the last one written"
8315        );
8316        let enable = SAVED_SCHEMA
8317            .iter()
8318            .find(|entry| u32::from(entry.number) == prop::PHY_ENABLED)
8319            .expect("PHY_ENABLED is saved");
8320        assert_eq!(enable.phase, ApplyPhase::Enable);
8321        assert_eq!(*ApplyPhase::ORDER.last().unwrap(), ApplyPhase::Enable);
8322        assert!(
8323            SAVED_SCHEMA
8324                .iter()
8325                .filter(|entry| entry.phase == ApplyPhase::Enable)
8326                .count()
8327                == 1,
8328            "only the PHY enable belongs in the last phase"
8329        );
8330        // And the numeric order really would get it wrong.
8331        assert!(u32::from(enable.number) < prop::PHY_FREQ);
8332    }
8333
8334    /// Absent options take documented defaults, which is the whole of
8335    /// forward compatibility (decision 13): a snapshot written by a
8336    /// build that did not know a property must not corrupt it.
8337    #[test]
8338    fn absent_options_decode_to_post_reset_defaults() {
8339        let mut bare = [0u8; SNAPSHOT_MAX];
8340        bare[0] = SNAPSHOT_FORMAT;
8341        let mut booted: TestSession =
8342            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
8343        let effect = booted.restore_at_boot(&bare[..1]).unwrap();
8344        let defaults = test_config().defaults;
8345        assert!(matches!(
8346            effect,
8347            Effect::ApplyRadio(s) if !s.enabled
8348                && s.freq_khz == defaults.freq_khz
8349                && s.sf == defaults.sf
8350        ));
8351        assert_eq!(booted.device_name(), test_config().default_device_name);
8352        booted.attach(true);
8353        assert_eq!(get(&mut booted, prop::SAVED), [ids::saved::CURRENT]);
8354        assert_eq!(get(&mut booted, prop::MAC_REPEATER_ENABLED), [0]);
8355        assert_eq!(get(&mut booted, prop::HOST_KEY), [] as [u8; 0]);
8356    }
8357
8358    /// Unknown option numbers are skipped rather than rejected: a newer
8359    /// writer's property, or one this build has retired, must not take
8360    /// the device to a bare boot.
8361    #[test]
8362    fn unknown_options_are_skipped() {
8363        let session = provisioned_session();
8364        let mut bytes = [0u8; SNAPSHOT_MAX];
8365        let len = session.encode_snapshot(&mut bytes).unwrap();
8366        // Append an option numbered above every allocated property.
8367        let mut extended = bytes;
8368        let mut encoder =
8369            OptionEncoder::with_last_number(&mut extended[len..], prop::PHY_DUTY_LIMIT as u16);
8370        encoder.put(60000, &[1, 2, 3]).unwrap();
8371        let extra = encoder.finish();
8372
8373        let mut booted: TestSession =
8374            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
8375        booted.restore_at_boot(&extended[..len + extra]).unwrap();
8376        assert_eq!(booted.device_name(), "saved name");
8377    }
8378
8379    /// A single-valued property appearing twice is corruption, not a
8380    /// last-writer-wins update.
8381    #[test]
8382    fn a_repeated_single_valued_property_is_rejected() {
8383        let mut bytes = [0u8; SNAPSHOT_MAX];
8384        bytes[0] = SNAPSHOT_FORMAT;
8385        let mut encoder = OptionEncoder::new(&mut bytes[1..]);
8386        encoder.put(prop::PHY_LORA_SF as u16, &[9]).unwrap();
8387        encoder.put(prop::PHY_LORA_SF as u16, &[10]).unwrap();
8388        let len = 1 + encoder.finish();
8389
8390        let mut booted: TestSession =
8391            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
8392        assert_eq!(
8393            booted.restore_at_boot(&bytes[..len]),
8394            Err(SnapshotError::Malformed)
8395        );
8396    }
8397
8398    /// Snapshot values pass the same validators a host write does, so a
8399    /// corrupted or foreign snapshot cannot install a setting the
8400    /// property surface would refuse.
8401    #[test]
8402    fn out_of_range_snapshot_values_are_rejected() {
8403        for (number, value) in [
8404            (prop::PHY_LORA_SF, &[13u8][..]),
8405            (prop::PHY_LORA_CR, &[4][..]),
8406            (prop::PHY_LORA_BW, &7_000u32.to_le_bytes()[..]),
8407            (prop::PHY_ENABLED, &[2][..]),
8408            (prop::DEV_NAME, &[][..]),
8409        ] {
8410            let mut bytes = [0u8; SNAPSHOT_MAX];
8411            bytes[0] = SNAPSHOT_FORMAT;
8412            let mut encoder = OptionEncoder::new(&mut bytes[1..]);
8413            encoder.put(number as u16, value).unwrap();
8414            let len = 1 + encoder.finish();
8415
8416            let mut booted: TestSession =
8417                Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
8418            assert_eq!(
8419                booted.restore_at_boot(&bytes[..len]),
8420                Err(SnapshotError::InvalidValue),
8421                "property {number} accepted an out-of-range value"
8422            );
8423        }
8424    }
8425
8426    /// The buffer every caller sizes from `SNAPSHOT_MAX` has to hold a
8427    /// snapshot with every table full — the option framing costs about
8428    /// two octets an entry over the retired positional format.
8429    #[test]
8430    fn snapshot_at_capacity_fits_the_buffer() {
8431        let mut session = provisioned_session();
8432        for seed in 0..MAX_CHANNEL_KEYS as u8 {
8433            let _ = session.device.channel_keys.insert(ChannelKeyEntry {
8434                key: [seed; items::CHANNEL_KEY_LEN],
8435                id: [seed, seed],
8436            });
8437        }
8438        for seed in 0..MAX_DEV_PEERS as u8 {
8439            let _ = session.device.peers.insert([seed; items::PUBLIC_KEY_LEN]);
8440        }
8441        session.device.name = [b'n'; MAX_DEVICE_NAME_LEN];
8442        session.device.name_len = MAX_DEVICE_NAME_LEN;
8443        session.set_boot_identity([0xEE; 32]);
8444
8445        let mut bytes = [0u8; SNAPSHOT_MAX];
8446        let len = session
8447            .encode_snapshot(&mut bytes)
8448            .expect("a full snapshot must fit SNAPSHOT_MAX");
8449        assert!(len <= SNAPSHOT_MAX);
8450
8451        let mut booted: TestSession =
8452            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
8453        booted.set_boot_identity([0xEE; 32]);
8454        booted.restore_at_boot(&bytes[..len]).unwrap();
8455        assert_eq!(booted.device.channel_keys.len, MAX_CHANNEL_KEYS);
8456        assert_eq!(booted.device.peers.len, MAX_DEV_PEERS);
8457    }
8458
8459    #[test]
8460    fn reset_post_reset_values_come_from_the_snapshot() {
8461        let mut session = provisioned_session();
8462        save(&mut session);
8463
8464        // Diverge from the saved configuration, then CMD_RST.
8465        set(&mut session, prop::PHY_FREQ, &915_000u32.to_le_bytes());
8466        set(&mut session, prop::DEV_NAME, b"diverged");
8467        let mut emitted = Vec::new();
8468        let effect = session.reset(Status::RESET_SOFTWARE, &mut |bytes: &[u8]| {
8469            emitted.push(bytes.to_vec())
8470        });
8471        // Post-reset values are the saved ones — including the PHY
8472        // enable state, which comes back up.
8473        assert!(matches!(effect, Effect::ApplyRadio(s) if s.enabled && s.freq_khz == 906_875));
8474        assert_eq!(session.device_name(), "saved name");
8475        // The host domain is not saved, so a reset returns it to
8476        // defaults whatever the snapshot held when it was written.
8477        assert_eq!(get(&mut session, prop::HOST_KEY), Vec::<u8>::new());
8478
8479        // Factory defaults need CMD_CLEAR + CMD_RST.
8480        let mut buf = [0u8; 4];
8481        let len = frame::clear(&mut buf, 4).unwrap();
8482        let (_, effect) = dispatch(&mut session, &buf[..len], 0);
8483        assert_eq!(effect, Some(Effect::ClearSaved { tid: 4 }));
8484        session.respond_clear(4, Ok(()), &mut |_: &[u8]| {});
8485        let effect = session.reset(Status::RESET_SOFTWARE, &mut |_: &[u8]| {});
8486        assert!(matches!(effect, Effect::ApplyRadio(s) if !s.enabled && s.freq_khz == 910_525));
8487        assert_eq!(session.device_name(), "Test UMSH Device");
8488        assert_eq!(get(&mut session, prop::HOST_KEY), Vec::<u8>::new());
8489    }
8490
8491    #[test]
8492    fn restore_reverts_config_but_preserves_queue_and_baselines() {
8493        let mut session = provisioned_session();
8494        save(&mut session);
8495
8496        // Accumulate dynamic state: a queued frame and an advanced
8497        // replay baseline (counter 5 acknowledged).
8498        session.detach();
8499        let first = sealed_unar(5, &test_pairwise(), false);
8500        let effect = rx_effect(&mut session, &first, 0);
8501        expect_ack_transmit(&mut session, effect, None);
8502        session.attach(true);
8503        assert_eq!(queue_count(&mut session), 1);
8504
8505        // Diverge the configuration, in both domains.
8506        set(&mut session, prop::PHY_DUTY_LIMIT, &77u16.to_le_bytes());
8507        insert_item(
8508            &mut session,
8509            prop::HOST_RX_FILTERS,
8510            &[items::FILTER_DEST_HINT, 9, 9, 9],
8511        );
8512
8513        // Restore (reset form): device-domain configuration reverts, the
8514        // radio is re-applied, and the queue survives.
8515        let effect = restore(&mut session);
8516        assert!(matches!(effect, Some(Effect::ApplyRadio(s)) if s.enabled));
8517        assert_eq!(
8518            get(&mut session, prop::PHY_DUTY_LIMIT),
8519            0xFFFFu16.to_le_bytes()
8520        );
8521        // Host-domain state is outside the snapshot, so the added filter
8522        // is still there: a restore has nothing to revert it to.
8523        let filters = get(&mut session, prop::HOST_RX_FILTERS);
8524        assert_eq!(
8525            filters,
8526            [2, items::FILTER_PKT_TYPE, 0, 4, 0, 9, 9, 9],
8527            "host domain untouched by a restore"
8528        );
8529        assert_eq!(queue_count(&mut session), 1);
8530
8531        // The replay baseline survived too: replaying the pre-restore
8532        // frame is still an identified duplicate (coalesced, re-acked),
8533        // not a first-contact acceptance.
8534        session.detach();
8535        let effect = rx_effect(&mut session, &first, 10);
8536        expect_ack_transmit(&mut session, effect, None);
8537        session.attach(true);
8538        assert_eq!(queue_count(&mut session), 1);
8539    }
8540
8541    /// The host domain is outside the snapshot, so a restore leaves it
8542    /// alone entirely: no host-key special case, and queued traffic and
8543    /// replay baselines survive unconditionally.
8544    #[test]
8545    fn restore_leaves_the_host_domain_untouched() {
8546        let mut session = provisioned_session();
8547        save(&mut session);
8548
8549        // A different host takes over after the save and queues
8550        // detached traffic.
8551        install_host_key(&mut session, &[0xBB; 32]);
8552        assert_eq!(get(&mut session, prop::SAVED), [ids::saved::CURRENT]);
8553        session.detach();
8554        assert!(!delivered_at(
8555            &mut session,
8556            &unicast_to([0xBB, 0xBB, 0xBB]),
8557            0
8558        ));
8559        session.attach(true);
8560        assert_eq!(queue_count(&mut session), 1);
8561
8562        let effect = restore(&mut session);
8563        assert!(matches!(effect, Some(Effect::ApplyRadio(_))));
8564        // Device domain reverts from the snapshot...
8565        assert_eq!(session.device_name(), "saved name");
8566        // ...and the current host keeps its key and its queue.
8567        assert_eq!(get(&mut session, prop::HOST_KEY), [0xBB; 32]);
8568        assert_eq!(queue_count(&mut session), 1);
8569    }
8570
8571    /// The host domain is not persisted, so a reboot forgets it while
8572    /// the device domain comes back intact. This is what makes host
8573    /// provisioning a per-attach concern rather than a durable one.
8574    #[test]
8575    fn a_saved_snapshot_carries_no_host_domain_across_a_reboot() {
8576        let mut session = provisioned_session();
8577        save(&mut session);
8578        let mut bytes = [0u8; SNAPSHOT_MAX];
8579        let len = session.encode_snapshot(&mut bytes).unwrap();
8580
8581        let mut booted: TestSession =
8582            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
8583        booted.restore_at_boot(&bytes[..len]).unwrap();
8584        booted.attach(true);
8585        assert_eq!(booted.device_name(), "saved name");
8586        assert_eq!(get(&mut booted, prop::HOST_KEY), Vec::<u8>::new());
8587        assert!(get(&mut booted, prop::HOST_CHANNEL_KEYS).is_empty());
8588        assert!(get(&mut booted, prop::HOST_PEER_KEYS).is_empty());
8589        assert!(get(&mut booted, prop::HOST_RX_FILTERS).is_empty());
8590        assert_eq!(get(&mut booted, prop::HOST_AUTO_ACK), [0]);
8591    }
8592
8593    /// Re-provisioning a peer that is already present must not restart
8594    /// its replay baseline: decision 6 has the host re-assert its whole
8595    /// table on every attach, many times a day, where the documented
8596    /// resynchronization path assumes reboot frequency.
8597    #[test]
8598    fn a_whole_table_peer_set_reconciles_rather_than_rebuilding() {
8599        let mut session = test_session();
8600        enable(&mut session);
8601        install_host_key(&mut session, &HOST_PUB);
8602        install_channel_key(&mut session, &[0x42; 32]);
8603        set(
8604            &mut session,
8605            prop::HOST_PEER_KEYS,
8606            &peer_item(&PEER_PUB, &test_pairwise()),
8607        );
8608        set(&mut session, prop::HOST_AUTO_ACK, &[1]);
8609
8610        // Establish a replay baseline for PEER_PUB.
8611        session.detach();
8612        let frame = sealed_unar(9, &test_pairwise(), false);
8613        let effect = rx_effect(&mut session, &frame, 0);
8614        expect_ack_transmit(&mut session, effect, None);
8615        session.attach(true);
8616        assert_eq!(queue_count(&mut session), 1);
8617        let _ = drain(&mut session, 0);
8618
8619        // Re-assert the same table plus a second peer, exactly as an
8620        // attaching host does. The existing peer keeps its window, so
8621        // the replayed frame is still an identified duplicate.
8622        let mut table = peer_item(&PEER_PUB, &test_pairwise()).to_vec();
8623        table.extend_from_slice(&peer_item(&[0x77; 32], &test_pairwise()));
8624        set(&mut session, prop::HOST_PEER_KEYS, &table);
8625        assert_eq!(get(&mut session, prop::HOST_PEER_KEYS).len(), 64);
8626
8627        session.detach();
8628        let effect = rx_effect(&mut session, &frame, 10);
8629        expect_ack_transmit(&mut session, effect, None);
8630        session.attach(true);
8631        assert_eq!(
8632            queue_count(&mut session),
8633            0,
8634            "a preserved baseline coalesces the replay instead of queueing it again"
8635        );
8636
8637        // Omitting a peer still removes it: reconcile replaces the entry
8638        // set even though it preserves per-entry state.
8639        set(
8640            &mut session,
8641            prop::HOST_PEER_KEYS,
8642            &peer_item(&[0x77; 32], &test_pairwise()),
8643        );
8644        assert_eq!(get(&mut session, prop::HOST_PEER_KEYS), [0x77; 32]);
8645    }
8646
8647    // ─── Review-fix regressions: ack confirmation, flood return, ─────
8648    // ─── multicast coalescing, TID-zero semantics ────────────────────
8649
8650    /// Drain and return each entry's RX_FLAGS.
8651    fn drained_flags(session: &mut TestSession) -> Vec<u8> {
8652        drain(session, 0)
8653            .into_iter()
8654            .map(|(_, meta)| meta.flags)
8655            .collect()
8656    }
8657
8658    #[test]
8659    fn ack_flag_requires_confirmed_transmission() {
8660        let mut session = auto_ack_session();
8661        let keys = test_pairwise();
8662        let frame = sealed_unar(5, &keys, false);
8663
8664        // The ack is staged but the radio transmission fails: the entry
8665        // must not claim an ack that never went out.
8666        let effect = rx_effect(&mut session, &frame, 0);
8667        assert_eq!(effect, Some(Effect::StartTransmit));
8668        session.on_tx_result(TxOutcome::Failed, 0, &mut |_: &[u8]| {
8669            panic!("autonomous ack is silent")
8670        });
8671
8672        // The sender retransmits; the duplicate re-ack completes, which
8673        // marks the original (still queued, still unacked) entry.
8674        let effect = rx_effect(&mut session, &frame, 10);
8675        assert_eq!(
8676            effect,
8677            Some(Effect::StartTransmit),
8678            "re-ack after failed TX"
8679        );
8680        session.on_tx_result(TxOutcome::Sent, 10, &mut |_: &[u8]| {
8681            panic!("autonomous ack is silent")
8682        });
8683
8684        session.attach(true);
8685        assert_eq!(queue_count(&mut session), 1, "duplicate coalesced");
8686        assert_eq!(
8687            drained_flags(&mut session),
8688            [RX_FLAG_BUFFERED | RX_FLAG_ACKED]
8689        );
8690    }
8691
8692    #[test]
8693    fn failed_ack_leaves_flag_clear_and_eviction_is_handle_safe() {
8694        let mut session = auto_ack_session();
8695        let keys = test_pairwise();
8696
8697        // Frame 1's ack fails; its entry stays unacked.
8698        let effect = rx_effect(&mut session, &sealed_unar(1, &keys, false), 0);
8699        assert_eq!(effect, Some(Effect::StartTransmit));
8700        session.on_tx_result(TxOutcome::Failed, 0, &mut |_: &[u8]| {});
8701
8702        // Evict frame 1 with newer traffic while an ack for frame 2 is
8703        // in flight, then confirm it: the stale handle for the evicted
8704        // entry must mark nothing, and the confirmed handle must mark
8705        // exactly frame 2's entry even though the queue rotated
8706        // underneath it.
8707        let effect = rx_effect(&mut session, &sealed_unar(2, &keys, false), 0);
8708        assert_eq!(effect, Some(Effect::StartTransmit));
8709        for counter in 3..(2 + RX_QUEUE_CAPACITY as u32) {
8710            // Radio busy: these queue unacked, no effect.
8711            assert!(rx_effect(&mut session, &sealed_unar(counter, &keys, false), 0).is_none());
8712        }
8713        session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {});
8714
8715        session.attach(true);
8716        assert_eq!(queue_count(&mut session), RX_QUEUE_CAPACITY as u16);
8717        let flags = drained_flags(&mut session);
8718        // Frame 1 was evicted; the oldest remaining entry is frame 2 —
8719        // the only acknowledged one.
8720        assert_eq!(flags[0], RX_FLAG_BUFFERED | RX_FLAG_ACKED);
8721        assert!(
8722            flags[1..].iter().all(|flags| *flags == RX_FLAG_BUFFERED),
8723            "no other entry may borrow the confirmation"
8724        );
8725    }
8726
8727    /// Build a sealed UNAR carrying flood-hop state with the given
8728    /// accumulated count. FHOPS is dynamic (excluded from the AAD), so
8729    /// rewriting it after sealing preserves the MIC — exactly as a
8730    /// relaying node would.
8731    fn sealed_flooded_unar(counter: u32, keys: &PairwiseKeys, accumulated: u8) -> Vec<u8> {
8732        let mut buf = [0u8; 96];
8733        let mut packet = PacketBuilder::new(&mut buf)
8734            .unicast(NodeHint([0xC4, 0xC4, 0xC4]))
8735            .source_hint(NodeHint([0x0A, 0x0A, 0x0A]))
8736            .frame_counter(counter)
8737            .ack_requested()
8738            .mic_size(MicSize::Mic8)
8739            .flood_hops(15)
8740            .payload(&[3, 1, 2])
8741            .build()
8742            .unwrap();
8743        test_engine().seal_packet(&mut packet, keys).unwrap();
8744        let mut frame = packet.as_bytes().to_vec();
8745        frame[1] = umsh_core::FloodHops::new(15 - accumulated, accumulated)
8746            .unwrap()
8747            .0;
8748        frame
8749    }
8750
8751    #[test]
8752    fn flooded_traffic_gets_flood_return_acks() {
8753        let mut session = auto_ack_session();
8754        let keys = test_pairwise();
8755
8756        // Direct traffic: direct ack (no FHOPS on the wire).
8757        let effect = rx_effect(&mut session, &sealed_unar(1, &keys, false), 0);
8758        assert_eq!(effect, Some(Effect::StartTransmit));
8759        let header = PacketHeader::parse(session.tx_data()).unwrap();
8760        assert_eq!(header.flood_hops, None);
8761        session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {});
8762
8763        // Flooded traffic: the ack's remaining hops seed from the
8764        // received frame's accumulated count.
8765        for (accumulated, expected_remaining) in [(3u8, 3u8), (0, 1), (15, 15)] {
8766            let frame = sealed_flooded_unar(u32::from(accumulated) + 10, &keys, accumulated);
8767            let effect = rx_effect(&mut session, &frame, 0);
8768            assert_eq!(
8769                effect,
8770                Some(Effect::StartTransmit),
8771                "accumulated={accumulated}"
8772            );
8773            let header = PacketHeader::parse(session.tx_data()).unwrap();
8774            assert_eq!(header.fcf.packet_type(), PacketType::MacAck);
8775            let hops = header.flood_hops.expect("flood-return ack");
8776            assert_eq!(
8777                hops.remaining(),
8778                expected_remaining,
8779                "accumulated={accumulated}"
8780            );
8781            session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {});
8782        }
8783
8784        // A duplicate re-ack routes from the retransmission itself: the
8785        // same logical packet (counter 25, the current baseline)
8786        // arriving with a different accumulated count gets a return
8787        // flood sized for the path it actually took.
8788        let retransmission = sealed_flooded_unar(25, &keys, 7);
8789        let effect = rx_effect(&mut session, &retransmission, 5);
8790        assert_eq!(effect, Some(Effect::StartTransmit));
8791        let header = PacketHeader::parse(session.tx_data()).unwrap();
8792        assert_eq!(
8793            header.flood_hops.expect("flood-return re-ack").remaining(),
8794            7
8795        );
8796    }
8797
8798    /// A sealed multicast frame on `channel_key` (channel keys act as
8799    /// the pairwise keys for multicast sealing).
8800    fn sealed_multicast(counter: u32, channel_key: &[u8; 32], fill: u8) -> Vec<u8> {
8801        let engine = test_engine();
8802        let derived = engine.derive_channel_keys(&ChannelKey(*channel_key));
8803        let mut buf = [0u8; 96];
8804        let mut packet = PacketBuilder::new(&mut buf)
8805            .multicast(derived.channel_id)
8806            .source_hint(NodeHint([0x0A, 0x0A, 0x0A]))
8807            .frame_counter(counter)
8808            .mic_size(MicSize::Mic8)
8809            .payload(&[3, fill])
8810            .build()
8811            .unwrap();
8812        let keys = PairwiseKeys {
8813            k_enc: derived.k_enc,
8814            k_mic: derived.k_mic,
8815        };
8816        engine.seal_packet(&mut packet, &keys).unwrap();
8817        packet.as_bytes().to_vec()
8818    }
8819
8820    #[test]
8821    fn authenticated_multicast_duplicates_coalesce_queue_locally() {
8822        let channel_key = [0x42u8; 32];
8823        let mut session = auto_ack_session();
8824        session.attach(true);
8825        install_channel_key(&mut session, &channel_key);
8826        session.detach();
8827
8828        // Exact retransmissions of an authenticated multicast frame
8829        // coalesce; no ack is ever generated for multicast.
8830        let frame = sealed_multicast(9, &channel_key, 0x11);
8831        assert!(rx_effect(&mut session, &frame, 0).is_none());
8832        assert!(rx_effect(&mut session, &frame, 5).is_none());
8833        // Different counter or different content queue separately.
8834        assert!(rx_effect(&mut session, &sealed_multicast(10, &channel_key, 0x11), 0).is_none());
8835        assert!(rx_effect(&mut session, &sealed_multicast(11, &channel_key, 0x22), 0).is_none());
8836
8837        session.attach(true);
8838        assert_eq!(queue_count(&mut session), 3);
8839        for flags in drained_flags(&mut session) {
8840            assert_eq!(flags, RX_FLAG_BUFFERED, "multicast is never acked");
8841        }
8842    }
8843
8844    #[test]
8845    fn unauthenticated_multicast_duplicates_occupy_separate_entries() {
8846        // Accepted via an explicit packet-type filter with no channel
8847        // key: the device cannot authenticate, so no protocol-defined
8848        // duplicate detection applies.
8849        let mut session = test_session();
8850        enable(&mut session);
8851        insert_item(
8852            &mut session,
8853            prop::HOST_RX_FILTERS,
8854            &[items::FILTER_PKT_TYPE, PacketType::Multicast as u8],
8855        );
8856        session.detach();
8857        let frame = sealed_multicast(9, &[0x42; 32], 0x11);
8858        receive_detached(&mut session, &frame, 0);
8859        receive_detached(&mut session, &frame, 0);
8860        session.attach(true);
8861        assert_eq!(queue_count(&mut session), 2);
8862    }
8863
8864    // ─── CAP_DEV_IDENTITY gate ───────────────────────────────────────
8865
8866    /// Derive the public key a firmware would persist for this secret.
8867    fn public_of(secret: &[u8; 32]) -> [u8; 32] {
8868        use umsh_crypto::NodeIdentity;
8869        umsh_crypto::software::SoftwareIdentity::from_secret_bytes(secret)
8870            .public_key()
8871            .0
8872    }
8873
8874    /// Set `PROP_DEV_PRIVATE_KEY` and execute the provisioning effect
8875    /// the way firmware would: derive the keypair, "persist" it, and
8876    /// respond with the public key. Returns that public key.
8877    fn provision_identity(session: &mut TestSession, tid: u8, secret: &[u8; 32]) -> [u8; 32] {
8878        let mut buf = [0u8; 64];
8879        let len = frame::prop_set(&mut buf, tid, prop::DEV_PRIVATE_KEY, secret).unwrap();
8880        let (emitted, effect) = dispatch(session, &buf[..len], 0);
8881        assert!(
8882            emitted.is_empty(),
8883            "no response before the identity is stored"
8884        );
8885        assert_eq!(effect, Some(Effect::ProvisionIdentity { tid }));
8886        let Some(IdentitySource::Install(staged)) = session.identity_request() else {
8887            panic!("staged request must carry the installed secret");
8888        };
8889        assert_eq!(staged, *secret);
8890        let public_key = public_of(&staged);
8891        let mut emitted = Vec::new();
8892        session.respond_identity(tid, Ok(public_key), &mut |bytes: &[u8]| {
8893            emitted.push(bytes.to_vec())
8894        });
8895        let (response_tid, key, value) = parse_prop_is(&emitted[0]);
8896        assert_eq!(response_tid, tid);
8897        assert_eq!(key, prop::DEV_KEY, "success is announced as the public key");
8898        assert_eq!(value, public_key);
8899        public_key
8900    }
8901
8902    #[test]
8903    fn device_identity_provisioning_lifecycle() {
8904        let mut session = test_session();
8905        // Unconfigured: PROP_DEV_KEY is empty, and the write-only
8906        // private key discloses nothing — not even whether one exists.
8907        assert!(get(&mut session, prop::DEV_KEY).is_empty());
8908        let mut buf = [0u8; 16];
8909        let len = frame::prop_get(&mut buf, 4, prop::DEV_PRIVATE_KEY).unwrap();
8910        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
8911        expect_status(&emitted[0], 4, Status::UNIMPLEMENTED);
8912
8913        // PROP_DEV_KEY is read-only.
8914        let (emitted, _) = set(&mut session, prop::DEV_KEY, &[0x55; 32]);
8915        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8916
8917        // Wrong-size private keys are invalid.
8918        let (emitted, effect) = set(&mut session, prop::DEV_PRIVATE_KEY, &[0x11; 31]);
8919        assert!(effect.is_none());
8920        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8921
8922        let public_key = provision_identity(&mut session, 7, &[0x11; 32]);
8923        assert_eq!(get(&mut session, prop::DEV_KEY), public_key);
8924
8925        // The identity survives CMD_RST: its post-reset value is the
8926        // persisted one.
8927        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_: &[u8]| {});
8928        assert_eq!(get(&mut session, prop::DEV_KEY), public_key);
8929
8930        // Replacing the identity is permitted; peer list and channel
8931        // keys survive the replacement (they are not derived from it).
8932        insert_item(&mut session, prop::DEV_PEERS, &[0xD0; 32]);
8933        let replaced = provision_identity(&mut session, 3, &[0x22; 32]);
8934        assert_ne!(replaced, public_key);
8935        assert_eq!(get(&mut session, prop::DEV_KEY), replaced);
8936        assert_eq!(get(&mut session, prop::DEV_PEERS), [0xD0; 32]);
8937    }
8938
8939    #[test]
8940    fn identity_generation_stages_and_concurrent_writes_are_busy() {
8941        let mut session = test_session();
8942        // An empty value commands on-device generation.
8943        let (emitted, effect) = set(&mut session, prop::DEV_PRIVATE_KEY, &[]);
8944        assert!(emitted.is_empty());
8945        assert_eq!(effect, Some(Effect::ProvisionIdentity { tid: 2 }));
8946        assert!(matches!(
8947            session.identity_request(),
8948            Some(IdentitySource::Generate)
8949        ));
8950
8951        // A second write while the durable store is in flight is BUSY.
8952        let (emitted, effect) = set(&mut session, prop::DEV_PRIVATE_KEY, &[0x33; 32]);
8953        assert!(effect.is_none());
8954        expect_status(&emitted[0], 2, Status::BUSY);
8955
8956        // The firmware generates the secret itself and reports the
8957        // resulting public key.
8958        let generated = public_of(&[0x5A; 32]);
8959        let mut emitted = Vec::new();
8960        session.respond_identity(2, Ok(generated), &mut |bytes: &[u8]| {
8961            emitted.push(bytes.to_vec())
8962        });
8963        let (_, key, value) = parse_prop_is(&emitted[0]);
8964        assert_eq!(key, prop::DEV_KEY);
8965        assert_eq!(value, generated);
8966        assert!(session.identity_request().is_none());
8967    }
8968
8969    #[test]
8970    fn identity_provisioning_failure_leaves_the_identity_unchanged() {
8971        let mut session = test_session();
8972        let original = provision_identity(&mut session, 7, &[0x11; 32]);
8973
8974        let (_, effect) = set(&mut session, prop::DEV_PRIVATE_KEY, &[0x22; 32]);
8975        assert_eq!(effect, Some(Effect::ProvisionIdentity { tid: 2 }));
8976        let mut emitted = Vec::new();
8977        session.respond_identity(2, Err(()), &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
8978        expect_status(&emitted[0], 2, Status::FAILURE);
8979        assert_eq!(get(&mut session, prop::DEV_KEY), original);
8980        assert!(session.identity_request().is_none());
8981    }
8982
8983    #[test]
8984    fn identity_and_dev_channel_writes_require_a_secure_link() {
8985        let mut session = test_session();
8986        session.attach(false);
8987
8988        // Installing and generating both count as key provisioning.
8989        for value in [&[0x11u8; 32][..], &[][..]] {
8990            let (emitted, effect) = set(&mut session, prop::DEV_PRIVATE_KEY, value);
8991            assert!(effect.is_none());
8992            expect_status(&emitted[0], 2, Status::INVALID_STATE);
8993        }
8994        let (emitted, _) = insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &[0x42; 32]);
8995        expect_status(&emitted[0], 5, Status::INVALID_STATE);
8996        let (emitted, _) = set(&mut session, prop::DEV_CHANNEL_KEYS, &[0x42; 32]);
8997        expect_status(&emitted[0], 2, Status::INVALID_STATE);
8998
8999        // Peer public keys carry no secret material: no gate, like
9000        // PROP_HOST_KEY itself.
9001        let (emitted, _) = insert_item(&mut session, prop::DEV_PEERS, &[0xD0; 32]);
9002        let (key, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
9003        assert_eq!(key, prop::DEV_PEERS);
9004        assert_eq!(digest, [0xD0; 32]);
9005    }
9006
9007    #[test]
9008    fn dev_channel_keys_and_peers_lifecycle() {
9009        let mut session = test_session();
9010        let dev_channel = [0x66u8; 32];
9011        let expected_id = test_engine().derive_channel_id(&ChannelKey(dev_channel)).0;
9012
9013        // Channel keys report the derived identifier as their digest;
9014        // the key itself is never read back.
9015        let (emitted, _) = insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
9016        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
9017        assert_eq!(digest, expected_id);
9018        assert_eq!(get(&mut session, prop::DEV_CHANNEL_KEYS), expected_id);
9019        let (emitted, _) = insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
9020        expect_status(&emitted[0], 5, Status::ALREADY);
9021
9022        // Peers: digest form is the item itself; duplicates collapse
9023        // on whole-table set and fail an insert.
9024        let (emitted, _) = insert_item(&mut session, prop::DEV_PEERS, &[0xD0; 32]);
9025        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
9026        assert_eq!(digest, [0xD0; 32]);
9027        let (emitted, _) = insert_item(&mut session, prop::DEV_PEERS, &[0xD0; 32]);
9028        expect_status(&emitted[0], 5, Status::ALREADY);
9029        let mut two = Vec::new();
9030        two.extend_from_slice(&[0xD1; 32]);
9031        two.extend_from_slice(&[0xD1; 32]);
9032        let (emitted, _) = set(&mut session, prop::DEV_PEERS, &two);
9033        let (_, key, value) = parse_prop_is(&emitted[0]);
9034        assert_eq!(key, prop::DEV_PEERS);
9035        assert_eq!(value, [0xD1; 32], "duplicate items collapse");
9036
9037        // Remove by full item; a missing item is ITEM_NOT_FOUND.
9038        let (emitted, _) = remove_item(&mut session, prop::DEV_PEERS, &[0xD1; 32]);
9039        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropRemoved, 6);
9040        assert_eq!(digest, [0xD1; 32]);
9041        let (emitted, _) = remove_item(&mut session, prop::DEV_PEERS, &[0xD1; 32]);
9042        expect_status(&emitted[0], 6, Status::ITEM_NOT_FOUND);
9043        let (emitted, _) = remove_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
9044        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropRemoved, 6);
9045        assert_eq!(digest, expected_id);
9046
9047        // Capacity bounds.
9048        for seed in 0..MAX_DEV_PEERS as u8 {
9049            insert_item(&mut session, prop::DEV_PEERS, &[seed; 32]);
9050        }
9051        let (emitted, _) = insert_item(&mut session, prop::DEV_PEERS, &[0xFF; 32]);
9052        expect_status(&emitted[0], 5, Status::NOMEM);
9053    }
9054
9055    #[test]
9056    fn dev_domain_version_tracks_node_table_changes() {
9057        let mut session = test_session();
9058        assert_eq!(session.dev_domain_version(), 0);
9059        assert_eq!(session.dev_channel_keys().count(), 0);
9060        assert_eq!(session.dev_peers().count(), 0);
9061        assert!(session.dev_key().is_none());
9062
9063        // Every successful device-table mutation moves the version and
9064        // is visible through the node-sync accessors.
9065        let dev_channel = [0x66u8; 32];
9066        insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
9067        assert_eq!(session.dev_domain_version(), 1);
9068        assert_eq!(
9069            session.dev_channel_keys().collect::<Vec<_>>(),
9070            [dev_channel]
9071        );
9072        insert_item(&mut session, prop::DEV_PEERS, &[0xD0; 32]);
9073        assert_eq!(session.dev_domain_version(), 2);
9074        assert_eq!(session.dev_peers().collect::<Vec<_>>(), [[0xD0; 32]]);
9075
9076        // Failed mutations do not: the node has nothing to re-sync.
9077        insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
9078        remove_item(&mut session, prop::DEV_PEERS, &[0xEE; 32]);
9079        assert_eq!(session.dev_domain_version(), 2);
9080
9081        // Neither do host-domain mutations — device and host tables are
9082        // independent surfaces.
9083        insert_item(&mut session, prop::HOST_CHANNEL_KEYS, &[0x42; 32]);
9084        assert_eq!(session.dev_domain_version(), 2);
9085
9086        // Whole-table set and remove bump.
9087        set(&mut session, prop::DEV_PEERS, &[0xD1; 32]);
9088        assert_eq!(session.dev_domain_version(), 3);
9089        remove_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
9090        assert_eq!(session.dev_domain_version(), 4);
9091
9092        // CMD_RST rebuilds the tables (from the snapshot when one is
9093        // saved, post-reset defaults otherwise) — always a re-sync.
9094        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_: &[u8]| {});
9095        assert_eq!(session.dev_domain_version(), 5);
9096        assert_eq!(session.dev_channel_keys().count(), 0);
9097        assert_eq!(session.dev_peers().count(), 0);
9098
9099        // A boot restore replays the saved tables into a fresh session:
9100        // the version moves off its initial value so the firmware
9101        // publishes the restored tables to the node.
9102        insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
9103        save(&mut session);
9104        let mut bytes = [0u8; SNAPSHOT_MAX];
9105        let len = session.encode_snapshot(&mut bytes).unwrap();
9106        let mut booted: TestSession =
9107            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
9108        assert_eq!(booted.dev_domain_version(), 0);
9109        booted.restore_at_boot(&bytes[..len]).unwrap();
9110        assert_ne!(booted.dev_domain_version(), 0);
9111        assert_eq!(booted.dev_channel_keys().collect::<Vec<_>>(), [dev_channel]);
9112    }
9113
9114    #[test]
9115    fn dev_channel_keys_do_not_create_host_receive_filters() {
9116        let mut session = test_session();
9117        enable(&mut session);
9118        // Host filtering is configured (host key present), and the
9119        // device identity participates in its own channel.
9120        install_host_key(&mut session, &HOST_PUB);
9121        let dev_channel = [0x66u8; 32];
9122        insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
9123        let dev_id = test_engine().derive_channel_id(&ChannelKey(dev_channel)).0;
9124
9125        // Traffic on the device channel reaches the host only through
9126        // the host's own filtering — it is not queued.
9127        session.detach();
9128        receive_detached(&mut session, &multicast_on(dev_id), 0);
9129        session.attach(true);
9130        assert_eq!(queue_count(&mut session), 0);
9131
9132        // The same frame with a matching *host* channel key queues.
9133        install_channel_key(&mut session, &dev_channel);
9134        session.detach();
9135        receive_detached(&mut session, &multicast_on(dev_id), 0);
9136        session.attach(true);
9137        assert_eq!(queue_count(&mut session), 1);
9138    }
9139
9140    #[test]
9141    fn snapshot_carries_device_tables_but_never_the_identity() {
9142        let mut session = test_session();
9143        let dev_channel = [0x66u8; 32];
9144        let dev_id = test_engine().derive_channel_id(&ChannelKey(dev_channel)).0;
9145        insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
9146        insert_item(&mut session, prop::DEV_PEERS, &[0xD0; 32]);
9147        let public_key = provision_identity(&mut session, 7, &[0x11; 32]);
9148        save(&mut session);
9149
9150        // Divergence reverts on CMD_RST (post-reset values come from
9151        // the snapshot).
9152        remove_item(&mut session, prop::DEV_PEERS, &[0xD0; 32]);
9153        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_: &[u8]| {});
9154        assert_eq!(get(&mut session, prop::DEV_PEERS), [0xD0; 32]);
9155
9156        // A boot from the snapshot restores the tables — but not the
9157        // identity, which is persisted (and installed) independently.
9158        let mut bytes = [0u8; SNAPSHOT_MAX];
9159        let len = session.encode_snapshot(&mut bytes).unwrap();
9160        let mut booted: TestSession =
9161            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
9162        booted.restore_at_boot(&bytes[..len]).unwrap();
9163        booted.attach(true);
9164        assert_eq!(get(&mut booted, prop::DEV_CHANNEL_KEYS), dev_id);
9165        assert_eq!(get(&mut booted, prop::DEV_PEERS), [0xD0; 32]);
9166        assert!(get(&mut booted, prop::DEV_KEY).is_empty());
9167        booted.set_boot_identity(public_key);
9168        assert_eq!(get(&mut booted, prop::DEV_KEY), public_key);
9169
9170        // Host replacement never touches the device domain.
9171        install_host_key(&mut booted, &[0xBB; 32]);
9172        assert_eq!(get(&mut booted, prop::DEV_CHANNEL_KEYS), dev_id);
9173        assert_eq!(get(&mut booted, prop::DEV_PEERS), [0xD0; 32]);
9174        assert_eq!(get(&mut booted, prop::DEV_KEY), public_key);
9175    }
9176
9177    #[test]
9178    fn restore_never_reverts_the_identity_and_clear_plus_reset_erases_it() {
9179        let mut session = test_session();
9180        let first = provision_identity(&mut session, 7, &[0x11; 32]);
9181        save(&mut session);
9182
9183        // CMD_RESTORE reverts configuration but the identity — outside
9184        // the snapshot — keeps its newest value.
9185        let second = provision_identity(&mut session, 3, &[0x22; 32]);
9186        assert_ne!(second, first);
9187        let _ = restore(&mut session);
9188        assert_eq!(get(&mut session, prop::DEV_KEY), second);
9189
9190        // CMD_CLEAR erases the durable identity but not the live one;
9191        // the CMD_RST completing the factory reset loses it.
9192        let mut buf = [0u8; 4];
9193        let len = frame::clear(&mut buf, 4).unwrap();
9194        let (_, effect) = dispatch(&mut session, &buf[..len], 0);
9195        assert_eq!(effect, Some(Effect::ClearSaved { tid: 4 }));
9196        session.respond_clear(4, Ok(()), &mut |_: &[u8]| {});
9197        assert_eq!(get(&mut session, prop::DEV_KEY), second);
9198        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_: &[u8]| {});
9199        assert!(get(&mut session, prop::DEV_KEY).is_empty());
9200    }
9201
9202    #[test]
9203    fn tid_zero_identity_provisioning_is_silent_but_applies() {
9204        let mut session = test_session();
9205        let mut buf = [0u8; 64];
9206        let len = frame::prop_set(&mut buf, 0, prop::DEV_PRIVATE_KEY, &[0x11; 32]).unwrap();
9207        let effect = dispatch_tid0_silent(&mut session, &buf[..len].to_vec());
9208        assert_eq!(effect, Some(Effect::ProvisionIdentity { tid: 0 }));
9209        let public_key = public_of(&[0x11; 32]);
9210        session.respond_identity(0, Ok(public_key), &mut |_: &[u8]| {
9211            panic!("tid-0 provisioning must be silent")
9212        });
9213        assert_eq!(get(&mut session, prop::DEV_KEY), public_key);
9214    }
9215
9216    /// Dispatch a frame built with TID zero and assert total silence.
9217    fn dispatch_tid0_silent(session: &mut TestSession, bytes: &[u8]) -> Option<Effect> {
9218        let (emitted, effect) = dispatch(session, bytes, 0);
9219        assert!(
9220            emitted.is_empty(),
9221            "fire-and-forget commands receive no correlated response"
9222        );
9223        effect
9224    }
9225
9226    fn last_status_of(session: &mut TestSession) -> u32 {
9227        pui::decode(&get(session, prop::LAST_STATUS)).unwrap().0
9228    }
9229
9230    #[test]
9231    fn tid_zero_commands_are_fire_and_forget() {
9232        let mut session = test_session();
9233        let mut buf = [0u8; 640];
9234
9235        // NOP, empty drain, save, and clear: silent success, recorded
9236        // in PROP_LAST_STATUS only.
9237        let len = frame::nop(&mut buf, 0).unwrap();
9238        assert!(dispatch_tid0_silent(&mut session, &buf[..len].to_vec()).is_none());
9239        assert_eq!(last_status_of(&mut session), Status::OK.0);
9240
9241        let len = frame::queue_drain(&mut buf, 0).unwrap();
9242        assert!(dispatch_tid0_silent(&mut session, &buf[..len].to_vec()).is_none());
9243
9244        let len = frame::save(&mut buf, 0).unwrap();
9245        let effect = dispatch_tid0_silent(&mut session, &buf[..len].to_vec());
9246        assert_eq!(effect, Some(Effect::SaveSnapshot { tid: 0 }));
9247        session.respond_save(0, Ok(()), &mut |_: &[u8]| {
9248            panic!("tid-0 save must be silent")
9249        });
9250        assert_eq!(get(&mut session, prop::SAVED), [1]);
9251
9252        // A TID-zero failure is recorded silently.
9253        let len = frame::clear(&mut buf, 0).unwrap();
9254        let effect = dispatch_tid0_silent(&mut session, &buf[..len].to_vec());
9255        assert_eq!(effect, Some(Effect::ClearSaved { tid: 0 }));
9256        session.respond_clear(0, Err(()), &mut |_: &[u8]| {
9257            panic!("tid-0 clear must be silent")
9258        });
9259        assert_eq!(last_status_of(&mut session), Status::FAILURE.0);
9260        assert_eq!(
9261            get(&mut session, prop::SAVED),
9262            [1],
9263            "failed clear rolls back nothing"
9264        );
9265
9266        // TID-zero SET and INSERT mutate state without a correlated
9267        // response.
9268        let len = frame::prop_set(&mut buf, 0, prop::PHY_DUTY_LIMIT, &99u16.to_le_bytes()).unwrap();
9269        assert!(dispatch_tid0_silent(&mut session, &buf[..len].to_vec()).is_none());
9270        assert_eq!(get(&mut session, prop::PHY_DUTY_LIMIT), 99u16.to_le_bytes());
9271
9272        let item = [items::FILTER_PKT_TYPE, 0];
9273        let len = frame::prop_insert(&mut buf, 0, prop::HOST_RX_FILTERS, &item).unwrap();
9274        assert!(dispatch_tid0_silent(&mut session, &buf[..len].to_vec()).is_none());
9275        assert_eq!(
9276            get(&mut session, prop::HOST_RX_FILTERS),
9277            [2, items::FILTER_PKT_TYPE, 0]
9278        );
9279
9280        let len = frame::prop_remove(&mut buf, 0, prop::HOST_RX_FILTERS, &item).unwrap();
9281        assert!(dispatch_tid0_silent(&mut session, &buf[..len].to_vec()).is_none());
9282        assert!(get(&mut session, prop::HOST_RX_FILTERS).is_empty());
9283
9284        // TID-zero GET expects no response either.
9285        let len = frame::prop_get(&mut buf, 0, prop::PHY_MTU).unwrap();
9286        assert!(dispatch_tid0_silent(&mut session, &buf[..len].to_vec()).is_none());
9287    }
9288
9289    #[test]
9290    fn tid_zero_drain_delivers_frames_but_no_completion() {
9291        let mut session = test_session();
9292        enable(&mut session);
9293        session.detach();
9294        receive_detached(&mut session, &unicast_to([1, 2, 3]), 0);
9295        session.attach(true);
9296
9297        let mut buf = [0u8; 4];
9298        let len = frame::queue_drain(&mut buf, 0).unwrap();
9299        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
9300        assert!(emitted.is_empty());
9301        assert_eq!(effect, Some(Effect::DrainQueue));
9302
9303        // First step: the buffered frame. Final step: silence.
9304        let mut emitted = Vec::new();
9305        assert!(session.drain_step(0, &mut |bytes: &[u8]| emitted.push(bytes.to_vec())));
9306        assert_eq!(emitted.len(), 1);
9307        assert_eq!(
9308            Frame::parse(&emitted[0]).unwrap().command(),
9309            Some(Cmd::StrRecv)
9310        );
9311        let mut emitted = Vec::new();
9312        assert!(!session.drain_step(0, &mut |bytes: &[u8]| emitted.push(bytes.to_vec())));
9313        assert!(
9314            emitted.is_empty(),
9315            "TID-zero drain has no completion response"
9316        );
9317        assert_eq!(last_status_of(&mut session), Status::OK.0);
9318    }
9319
9320    #[test]
9321    fn queue_properties_are_read_only_and_capacity_fixed() {
9322        let mut session = test_session();
9323        assert_eq!(
9324            get(&mut session, prop::HOST_RX_QUEUE_CAPACITY),
9325            (RX_QUEUE_CAPACITY as u16).to_le_bytes()
9326        );
9327        for (key, status) in [
9328            (prop::HOST_RX_QUEUE_COUNT, Status::INVALID_ARGUMENT),
9329            (prop::HOST_RX_QUEUE_DROPPED, Status::INVALID_ARGUMENT),
9330            (prop::HOST_RX_QUEUE_CAPACITY, Status::UNIMPLEMENTED),
9331        ] {
9332            let (emitted, effect) = set(&mut session, key, &0u16.to_le_bytes());
9333            assert!(effect.is_none());
9334            expect_status(&emitted[0], 2, status);
9335        }
9336    }
9337}