umsh_ulcp_device/
session.rs

1//! The ULCP session state machine.
2
3use core::str::FromStr;
4
5use heapless::{Deque, Vec as HeaplessVec};
6use umsh_core::options::{OptionDecoder, OptionEncoder};
7use umsh_core::{
8    ChannelKey, EncodeError, NodeHint, PacketBuilder, PacketHeader, PacketType, RegionCode,
9    SourceAddrRef,
10};
11use umsh_crypto::replay::{ReplayVerdict, ReplayWindow};
12use umsh_crypto::{AesProvider, CryptoEngine, PairwiseKeys, Sha256Provider};
13use umsh_ulcp::Status;
14use umsh_ulcp::airtime::lora_airtime_ms;
15use umsh_ulcp::alert::AlertState;
16use umsh_ulcp::battery::{self, BatteryStatus};
17use umsh_ulcp::ble::BleLinkState;
18use umsh_ulcp::frame::{
19    self, Cmd, Frame, Header, MultiEntries, PropPayload, StreamPayload, TID_UNSOLICITED,
20};
21use umsh_ulcp::gnss::{self, GnssSnapshot};
22use umsh_ulcp::ids::{
23    self, DEFAULT_ADVERT_INTERVAL_S, DEFAULT_BEACON_INTERVAL_S, MAX_AUTO_ANNOUNCE_INTERVAL_S,
24    MIN_AUTO_ANNOUNCE_INTERVAL_S, admin_reachable, cap, prop, stream,
25};
26pub use umsh_ulcp::items::REGION_STRING_MAX_LEN;
27use umsh_ulcp::items::{self, Filter, ItemError, REGION_CODE_LEN};
28use umsh_ulcp::meta::{
29    self, BufferedRxMeta, RX_FLAG_ACKED, RX_FLAG_BUFFERED, RX_FLAG_SELF_TX, RxMeta, TxMeta,
30};
31use umsh_ulcp::pui;
32use umsh_ulcp::sint;
33use umsh_ulcp::stats::{Counter, StatsLedger};
34
35use crate::duty::DutyLedger;
36
37/// Largest radio payload the session can carry (SX126x-class limit).
38pub const MAX_MTU: usize = 255;
39
40/// Maximum UTF-8 byte length of `PROP_DEV_NAME`.
41pub const MAX_DEVICE_NAME_LEN: usize = 64;
42
43/// Room for a `CMD_STR_RECV` frame around a full-MTU payload.
44const SCRATCH: usize = MAX_MTU + 24;
45
46/// Largest encoded property value the session produces (bounded by
47/// `PROP_HOST_PEER_KEYS`' digest form: one public key per entry).
48const PROP_BUF: usize = MAX_PEER_KEYS * items::PUBLIC_KEY_LEN + 16;
49
50/// Radio configuration owned by the session and pushed to the radio
51/// via [`Effect::ApplyRadio`].
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub struct RadioSettings {
54    pub enabled: bool,
55    pub freq_khz: u32,
56    pub bw_hz: u32,
57    pub sf: u8,
58    pub cr_denom: u8,
59    pub tx_power_dbm: i8,
60}
61
62/// Transmit power selection for one pending transmit.
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub enum TxPower {
65    /// Use the configured `PROP_PHY_TX_POWER`.
66    Default,
67    /// Transmit at the radio's maximum power.
68    Max,
69    /// Explicit per-frame override in dBm.
70    Dbm(i8),
71}
72
73/// Which battery measurements the platform is *capable* of reporting
74/// through `PROP_BATTERY`. Fixed for the life of a session: these bits
75/// bound the field-flags octet of every snapshot.
76///
77/// An individual sample may populate fewer fields than are advertised —
78/// a level estimated from resting terminal voltage has no value while the
79/// pack is charging, and the spec would rather see the field omitted than
80/// a number the device knows to be wrong. The reverse is refused: a
81/// sample carrying a field the platform never claimed cannot be encoded
82/// honestly, so it is rejected.
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
84pub struct BatteryFields {
85    pub voltage: bool,
86    pub level: bool,
87    pub charge_state: bool,
88}
89
90impl BatteryFields {
91    /// Battery-powered operation with no reporting support: `CAP_BATTERY`
92    /// is advertised and `PROP_BATTERY` answers the empty value.
93    pub const NONE: Self = Self {
94        voltage: false,
95        level: false,
96        charge_state: false,
97    };
98
99    /// Whether any measurement is reported (a `GET` must sample).
100    pub const fn any(self) -> bool {
101        self.voltage || self.level || self.charge_state
102    }
103
104    /// Whether `snapshot` populates only fields this platform advertises.
105    fn matches(self, snapshot: &BatteryStatus) -> bool {
106        (self.voltage || snapshot.voltage_mv.is_none())
107            && (self.level || snapshot.level_percent.is_none())
108            && (self.charge_state || snapshot.charge_state.is_none())
109    }
110}
111
112/// How this board serves `PROP_ALERT`.
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub struct AlertConfig {
115    /// How long `ALERT_LOCATE` runs before the device returns itself to
116    /// `ALERT_NONE`. The spec requires *some* bound and recommends a few
117    /// minutes: a lost radio is usually a nearly-flat radio, and the host
118    /// that armed the alert is by definition somewhere else.
119    pub timeout_ms: u32,
120}
121
122impl AlertConfig {
123    /// The recommended bound — five minutes.
124    pub const DEFAULT: Self = Self {
125        timeout_ms: 5 * 60 * 1000,
126    };
127}
128
129/// That this board keeps a wall clock (`CAP_TIME`).
130///
131/// A marker: the capability's whole statement is that `PROP_TIME` and
132/// `PROP_TZ_OFFSET` exist, and it says nothing about where the time comes
133/// from or how much of a power cycle it survives. Both of those are
134/// platform business, and neither is anything the session could answer.
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
136pub struct TimeConfig;
137
138/// That this board has a GNSS receiver (`CAP_GNSS`).
139///
140/// Dependent on [`TimeConfig`]: a board that advertises this without a
141/// wall clock would be claiming a time source for a clock it does not
142/// have.
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub struct GnssConfig {
145    /// Post-reset value of `PROP_GNSS_ENABLED`.
146    ///
147    /// Off almost everywhere, because on a battery a receiver nobody
148    /// asked for is the largest thing on the bill. The exception is a
149    /// board whose whole job is to sit outdoors and know where it is —
150    /// there, off is the surprising answer, and a fixed node that has to
151    /// be told to find itself after every reset is a worse default than
152    /// the power it costs.
153    ///
154    /// This is the *post-reset* value, so it decides only what an
155    /// unconfigured board does. A saved snapshot overrides it in either
156    /// direction, and `CMD_RST` returns to it.
157    pub default_enabled: bool,
158}
159
160impl GnssConfig {
161    /// A receiver that stays off until asked.
162    pub const DEFAULT: Self = Self {
163        default_enabled: false,
164    };
165
166    /// A receiver that runs unless switched off.
167    pub const ALWAYS_ON: Self = Self {
168        default_enabled: true,
169    };
170}
171
172/// Post-reset value of `PROP_GNSS_IDENT_PRECISION`: a ~38 × 19 m cell,
173/// fine enough to place a node on a street and coarse enough not to place
174/// it in a room.
175pub const DEFAULT_IDENT_PRECISION: u8 = 5;
176
177/// Largest `PROP_GNSS_IDENT_PRECISION`, matching the location encoding's
178/// own maximum.
179pub const MAX_IDENT_PRECISION: u8 = gnss::MAX_LOCATION_LEN as u8;
180
181/// Fixed properties of the device this session runs on.
182#[derive(Clone, Copy, Debug)]
183pub struct SessionConfig {
184    /// `PROP_DEV_VERSION` string (without NUL terminator).
185    pub dev_version: &'static str,
186    /// `PROP_DEV_MODEL` string (without NUL terminator), naming the
187    /// hardware this firmware runs on. `None` on a device with no fixed
188    /// model — a simulator, or a board brought up before it has a name —
189    /// and the property is then absent rather than empty.
190    pub dev_model: Option<&'static str>,
191    /// Factory/post-reset value of `PROP_DEV_NAME`.
192    pub default_device_name: &'static str,
193    /// `PROP_PHY_MTU`; must not exceed [`MAX_MTU`].
194    pub mtu: u16,
195    /// The only sync word this firmware can use; `PROP_PHY_LORA_SW`
196    /// sets must match it (v0 limitation).
197    pub sync_word: u16,
198    /// Lowest transmit power the radio supports, in dBm.
199    pub min_tx_power_dbm: i8,
200    /// Highest transmit power the radio supports, in dBm.
201    pub max_tx_power_dbm: i8,
202    /// Tunable frequency range in kHz, inclusive.
203    pub freq_khz_min: u32,
204    pub freq_khz_max: u32,
205    /// Post-reset radio settings. `enabled` is forced off on reset per
206    /// the spec regardless of what this carries.
207    pub defaults: RadioSettings,
208    /// Post-reset `PROP_PHY_DUTY_LIMIT`.
209    pub default_duty_limit: u16,
210    /// The shared duty ledger. The session owns the limit's lifecycle
211    /// and records its own transmissions here, but the ledger is
212    /// consulted by every radio client on the device (the device node's
213    /// TX path draws from the same budget), so `PROP_PHY_DUTY_NOW`
214    /// reports the combined figure and `PROP_PHY_DUTY_LIMIT` bounds the
215    /// combined airtime.
216    pub duty: &'static DutyLedger,
217    /// `None`: not battery powered; `CAP_BATTERY` is absent and
218    /// `PROP_BATTERY` is unknown. `Some`: the capability is advertised
219    /// and the fields say which measurements the platform reports.
220    pub battery: Option<BatteryFields>,
221    /// `None`: the board has no way to make itself conspicuous;
222    /// `CAP_ALERT` is absent and `PROP_ALERT` is unknown. `Some`: the
223    /// capability is advertised and the config carries the deadline.
224    pub alert: Option<AlertConfig>,
225    /// `None`: the board keeps no wall clock; `CAP_TIME` is absent and
226    /// `PROP_TIME` / `PROP_TZ_OFFSET` are unknown.
227    pub time: Option<TimeConfig>,
228    /// `None`: no GNSS receiver; `CAP_GNSS` is absent and the positioning
229    /// properties are unknown. Meaningful only alongside
230    /// [`time`](Self::time) — see [`GnssConfig`].
231    pub gnss: Option<GnssConfig>,
232    /// Whether an ambient light sensor is fitted. When set,
233    /// `CAP_ILLUMINANCE` is advertised and `PROP_ILLUMINANCE` samples on
234    /// every read; otherwise the property is unknown.
235    pub illuminance: bool,
236    /// Whether the device has a Bluetooth transport it can make
237    /// unreachable on demand. When set, `CAP_BLE` is advertised and
238    /// `PROP_BLE_ENABLED` and `PROP_BLE_LINK` exist; otherwise both
239    /// properties are unknown.
240    pub ble: bool,
241    /// Whether the platform manages its own Bluetooth bonds. When set,
242    /// `PROP_BLE_BOND_COUNT` and `PROP_BLE_PAIRING` exist and
243    /// `CMD_BLE_CLEAR_BONDS` reaches its effect; otherwise the command
244    /// answers `STATUS_UNIMPLEMENTED` and both properties are unknown.
245    /// Meaningful only alongside [`ble`](Self::ble).
246    ///
247    /// Deliberately not a capability of its own. `CAP_BLE` is the only
248    /// Bluetooth capability there is, and a host that wants to know
249    /// whether this device manages its bonds asks for the count and reads
250    /// the refusal — a question it has to be able to answer anyway, since
251    /// any property may be refused by firmware older than the host.
252    pub ble_pairing: bool,
253    /// Whether the platform can restart the hardware on command. When
254    /// set, `CAP_REBOOT` is advertised and `CMD_REBOOT` reaches
255    /// [`Effect::Reboot`]; otherwise the command answers
256    /// `STATUS_UNIMPLEMENTED`. Every firmware sets this; a session that
257    /// is a process rather than a board must not.
258    pub reboot: bool,
259    /// The shared traffic ledger. `None`: the device keeps no counters;
260    /// `CAP_STATS` is absent and the `PROP_STAT_*` properties are
261    /// unknown. `Some`: the capability is advertised and the counters
262    /// are read and cleared through it.
263    ///
264    /// Shared for the same reason the duty ledger is — the session is
265    /// one of several radio clients and none of them sees the whole
266    /// picture — and read straight out of `encode_prop` rather than
267    /// fetched through an effect, because a host asks for all of these
268    /// at once and nine deferred round trips to answer one screen would
269    /// be absurd.
270    ///
271    /// The four counters the MAC feeds
272    /// ([`Counter::needs_node`]) additionally require
273    /// [`mac_node`](Self::mac_node).
274    pub stats: Option<&'static StatsLedger>,
275    /// Whether a mesh node runs behind this session — a MAC of the
276    /// device's own that can repeat and that a backhauled host can sit
277    /// point-to-point behind. Every firmware sets this; a simulated
278    /// device with no node behind it must not, or it would advertise
279    /// `CAP_MAC_BACKHAUL` while `Effect::ApplyBackhaul` connects the
280    /// host to nothing. When unset, `CAP_REPEATER` and
281    /// `CAP_MAC_BACKHAUL` are absent and the repeater and backhaul
282    /// properties are unknown.
283    pub mac_node: bool,
284}
285
286/// Physical-radio outcome of the transmit started by
287/// [`Effect::StartTransmit`], reported via [`Session::on_tx_result`].
288#[derive(Clone, Copy, Debug, PartialEq, Eq)]
289pub enum TxOutcome {
290    /// The frame left the radio.
291    Sent,
292    /// The pre-transmit channel-activity check found the channel busy;
293    /// the frame was never transmitted. Completes a host transmit with
294    /// `STATUS_CCA_FAILURE`.
295    ChannelBusy,
296    /// The radio failed to transmit the frame.
297    Failed,
298}
299
300/// A radio side effect for the caller to execute.
301#[derive(Clone, Copy, Debug, PartialEq, Eq)]
302pub enum Effect {
303    /// The radio configuration changed; (re)apply it.
304    ApplyRadio(RadioSettings),
305    /// Begin transmitting [`Session::tx_data`] at
306    /// [`Session::tx_power`]; report completion with
307    /// [`Session::on_tx_result`].
308    StartTransmit,
309    /// Build and sign the device identity's node-identity blob and feed
310    /// it back with [`Session::respond_identity_blob`], quoting this
311    /// `tid`. The platform owns the signing key and the advertised
312    /// profile; the session owns only the properties that shape it.
313    SignIdentity { tid: u8 },
314    /// Sample the current instantaneous RSSI from the radio and feed the
315    /// result back with [`Session::respond_rssi`], quoting this `tid`. Emitted
316    /// for a `PROP_PHY_RSSI` get while the PHY is enabled, because the session
317    /// itself has no live view of the radio.
318    SampleRssi { tid: u8 },
319    /// Obtain a battery status snapshot from the platform's battery
320    /// source and feed it back with [`Session::respond_battery`], quoting
321    /// this `tid`. Emitted for a `PROP_BATTERY` get when at least one
322    /// measurement is reported; the session never caches readings, so
323    /// every get samples.
324    SampleBattery { tid: u8 },
325    /// Take an ambient light measurement and feed it back with
326    /// [`Session::respond_illuminance`], quoting this `tid`. Emitted for a
327    /// `PROP_ILLUMINANCE` get; like the battery, nothing is cached, so
328    /// every get samples.
329    SampleIlluminance { tid: u8 },
330    /// Apply and persist a new BLE pairing PIN, then complete the deferred
331    /// property transaction with [`Session::respond_pin_set`].
332    SetPairingPin { tid: u8, pin: Option<u32> },
333    /// Read the platform's wall clock and feed it back with
334    /// [`Session::respond_time`], quoting this `tid`. Emitted for a
335    /// `PROP_TIME` get: the clock belongs to the platform, which is the
336    /// only layer that knows whether it has been set and how far it has
337    /// advanced since.
338    ReadTime { tid: u8 },
339    /// A `PROP_TIME` write. `Some` sets the platform wall clock to that
340    /// Unix second; `None` returns it to not knowing what time it is,
341    /// which is what stops a device with a screen from displaying a clock.
342    ///
343    /// A manual set outranks every receiver-derived one — the operator is
344    /// the more authoritative source by definition — so the platform
345    /// applies this unconditionally, including while
346    /// `PROP_GNSS_TIME_TRUST` is clear.
347    ApplyTime { epoch: Option<u32> },
348    /// Sample the receiver's current view of position and constellation
349    /// and feed it back with [`Session::respond_gnss`], quoting this `tid`
350    /// and `key`. Emitted for a get of any positioning property; the
351    /// session never caches a reading, so every get samples.
352    SampleGnss { tid: u8, key: u32 },
353    /// A `PROP_MAC_BACKHAUL` write. While enabled, the host's frames go
354    /// to the device's own node instead of the air, and the node's
355    /// repeater carries them the rest of the way; the host stops hearing
356    /// the medium directly. Applied by whatever shares the radio, which
357    /// on a device with a node of its own is the multiplexer.
358    ApplyBackhaul { enabled: bool },
359    /// The live human-readable device name changed. Transports that expose a
360    /// name should refresh it without disrupting the active session.
361    DeviceNameChanged,
362    /// A `CMD_QUEUE_DRAIN` accepted a non-empty queue. Repeatedly call
363    /// [`Session::drain_step`] until it returns `false`, flushing the
364    /// emitted frame to the transport between calls (each step emits at
365    /// most one frame, so a bounded emitter never overflows and the
366    /// transport can apply backpressure).
367    DrainQueue,
368    /// `CMD_SAVE`: durably store the bytes produced by
369    /// [`Session::encode_snapshot`], replacing any previous snapshot,
370    /// then complete with [`Session::respond_save`]. Success must not
371    /// be reported before the write has committed.
372    SaveSnapshot { tid: u8 },
373    /// `CMD_CLEAR`: erase the stored snapshot and all other persisted
374    /// provisioning — including the independently persisted device
375    /// identity — then complete with [`Session::respond_clear`]. Live
376    /// state, BLE bonds, and the pairing PIN are unaffected.
377    ClearSaved { tid: u8 },
378    /// A `PROP_DEV_PRIVATE_KEY` write is provisioning the device
379    /// identity. Read the staged request with
380    /// [`Session::identity_request`], build the keypair (drawing the
381    /// secret from a cryptographically secure RNG when the request is
382    /// [`IdentitySource::Generate`]), persist it durably, and complete
383    /// with [`Session::respond_identity`]. Success must not be
384    /// reported before the identity is durably stored (spec
385    /// §PROP_DEV_PRIVATE_KEY).
386    ProvisionIdentity { tid: u8 },
387    /// The locate alert changed; start or stop the board's physical
388    /// indication. Carries the authoritative new state, so a board can
389    /// treat it as idempotent — it is emitted for a host write, a local
390    /// cancellation, and the deadline alike.
391    ApplyAlert(AlertState),
392    /// `CMD_FACTORY_RESET`: erase ALL mutable state — every persisted
393    /// journal (saved snapshot, device identity, frame-counter
394    /// boundaries, BLE bonds, pairing PIN) — and reboot. The platform
395    /// performs the wipe and reset; nothing is emitted and no `respond_*`
396    /// completion follows, because the reboot drops the link. In-RAM
397    /// session state is discarded by the reset itself.
398    FactoryReset,
399    /// `CMD_REBOOT`: restart the hardware, keeping every persisted
400    /// journal. The platform performs the reset; nothing is emitted and
401    /// no `respond_*` completion follows, because the reboot drops the
402    /// link. What comes back is the same device with the same identity,
403    /// announcing its power-on reset.
404    Reboot,
405    /// `CMD_BLE_CLEAR_BONDS`: delete every stored bond, the pairing PIN,
406    /// and the pairing failure lockout, then open a pairing window so the
407    /// device can be paired again. Complete with
408    /// [`Session::respond_ble_clear_bonds`] once the deletion is durable —
409    /// over Bluetooth that reply is the last thing the sender hears, since
410    /// dropping its bond drops its link.
411    BleClearBonds { tid: u8 },
412    /// A `PROP_BLE_PAIRING` write: open (or renew) the pairing window, or
413    /// close it. Complete with [`Session::respond_ble_pairing`], quoting
414    /// the state the transport is actually in — a device that cannot open
415    /// a window right now (locked out after repeated pairing failures, or
416    /// Bluetooth disabled) refuses rather than echoing a window that is
417    /// not there.
418    SetBlePairing { tid: u8, open: bool },
419}
420
421/// A staged `PROP_DEV_PRIVATE_KEY` provisioning request (see
422/// [`Effect::ProvisionIdentity`]).
423#[derive(Clone, Copy)]
424pub enum IdentitySource {
425    /// Install this Ed25519 private key.
426    Install([u8; PRIVATE_KEY_LEN]),
427    /// Generate a fresh private key on-device; it must come from a
428    /// cryptographically secure random number generator and never
429    /// leave the device.
430    Generate,
431}
432
433struct PendingTx {
434    data: HeaplessVec<u8, MAX_MTU>,
435    tid: u8,
436    airtime_ms: u32,
437    power: TxPower,
438    /// True for device-initiated transmissions (delegated MAC acks):
439    /// completion must not disturb `PROP_LAST_STATUS`, which may still
440    /// hold a reset code the next host needs to see.
441    autonomous: bool,
442    /// Queue-entry sequence handle of the frame this transmission
443    /// acknowledges. Only on confirmed transmission does the entry earn
444    /// `RX_FLAG_ACKED` — the host MUST NOT re-ack a flagged frame, so
445    /// the flag must never assert an ack that was not actually sent.
446    ack_for: Option<u16>,
447    /// `TX_FLAG_NOCCA`: transmit without the pre-transmit
448    /// channel-activity check.
449    nocca: bool,
450}
451
452/// A delegated MAC acknowledgement ready to transmit.
453struct AckPlan {
454    /// The 8-byte ack trailer (`ack_mic || ack_tag`). The ack carries no
455    /// destination hint — it is correlated by this trailer.
456    trailer: [u8; 8],
457    /// Flood-return radius when the acknowledged frame arrived by
458    /// flood: its accumulated hop count seeds the ack's remaining hops
459    /// (mirroring the MAC's cached flood-route behavior). `None` for
460    /// direct traffic — the ack is then direct too.
461    flood_hops: Option<u8>,
462}
463
464/// Outcome of evaluating a detached received frame against the
465/// provisioned keys (spec §Inbound Queueing, §Acknowledgement
466/// Delegation).
467enum SecureRx {
468    /// Not authenticated (no keys, ambiguous source, bad MIC, or a
469    /// suspected replay outside the window): queue it unacknowledged —
470    /// hints only over-accept and the host MAC remains authoritative.
471    Plain,
472    /// Authenticated and new. `ack` is present when the frame requests
473    /// acknowledgement (never for multicast); `identity` keys later
474    /// duplicate coalescing and deferred ack marking.
475    New {
476        ack: Option<AckPlan>,
477        identity: Option<RxIdentity>,
478    },
479    /// Authenticated duplicate of a previously accepted frame: it is
480    /// coalesced rather than queued again. `ack` is present when the
481    /// idempotent re-acknowledgement window permits retransmitting its
482    /// ack; `identity` locates the original entry so a confirmed re-ack
483    /// can mark it.
484    Duplicate {
485        ack: Option<AckPlan>,
486        identity: Option<RxIdentity>,
487    },
488}
489
490/// Outcome of dispatching a property key for encoding.
491enum PropValue {
492    Encoded(usize),
493    Unimplemented,
494    Unknown,
495}
496
497/// State belonging to the device itself, independent of which
498/// host is attached (spec §State Classes, device domain). Survives
499/// attach and host replacement; `CMD_RST` restores its post-reset
500/// values.
501struct DeviceDomain {
502    settings: RadioSettings,
503    name: [u8; MAX_DEVICE_NAME_LEN],
504    name_len: usize,
505    /// `PROP_DEV_CHANNEL_KEYS`: the device identity's own channels.
506    /// Independent of the host domain — they survive host replacement
507    /// and never create implicit host receive filters.
508    channel_keys: ChannelKeyTable,
509    /// `PROP_DEV_PEERS`: peer public keys the device node recognizes.
510    peers: DevPeerTable,
511    /// `PROP_DEV_ADMINS`: nodes authorized to manage this device over
512    /// the mesh. Empty by default — a device answers node management
513    /// only from keys someone deliberately put here.
514    admins: DevAdminTable,
515    /// `PROP_MAC_REPEATER_ENABLED`: when set, the device identity's
516    /// on-board MAC autonomously forwards overheard routable frames.
517    /// Persisted device-domain state; takes effect only once a device
518    /// identity is provisioned (store-and-defer otherwise).
519    repeater_enabled: bool,
520    /// `PROP_MAC_REPEATER_REGIONS`: the flood-forwarding region filter.
521    /// Empty imposes no regional restriction. Configurable while
522    /// forwarding is disabled; it simply takes effect when enabled.
523    repeater_regions: RepeaterRegions,
524    /// `PROP_MAC_REPEATER_DEFAULT_REGION`: the code inserted into an
525    /// untagged flood packet, or `None` to never tag.
526    ///
527    /// Deliberately independent of `repeater_regions`: the filter says
528    /// what this device is willing to carry, while tagging asserts where
529    /// the packet is. A device can do either without the other.
530    repeater_default_region: Option<[u8; REGION_CODE_LEN]>,
531    /// `PROP_MAC_REPEATER_MIN_RSSI`: minimum received RSSI in dBm for
532    /// flood forwarding, or `None` for no threshold.
533    repeater_min_rssi: Option<i16>,
534    /// `PROP_MAC_REPEATER_MIN_SNR`: minimum received SNR in whole dB for
535    /// flood forwarding, or `None` for no threshold.
536    repeater_min_snr: Option<i8>,
537    /// `PROP_IDENT_ROLE`: the advertised `ROLE` byte, or `None` to
538    /// derive it from what the device is actually doing.
539    ///
540    /// Role and forwarding are separate dimensions: a mobile repeater
541    /// and a fixed tracker must both be representable. Forwarding
542    /// remains a *fact* the device reports through the `REP` capability
543    /// bit; the role is what it presents itself as, which is
544    /// configuration.
545    ident_role: Option<u8>,
546    /// `PROP_IDENT_MOBILE`: whether the device identity advertises the
547    /// `MOB` capability bit. Orthogonal to tethered versus standalone,
548    /// which is a transient local relationship and appears in no node
549    /// identity at all.
550    ident_mobile: bool,
551    /// `PROP_IDENT_LOCATION`: the position the advertised node identity
552    /// carries, in the variable-precision encoding, or empty for none.
553    ///
554    /// The one place the advertised position lives, whether a fix wrote
555    /// it or an administrator did. Distinct from the receiver's current
556    /// fix, which a device without a receiver does not have and this
557    /// still can.
558    ident_location: HeaplessVec<u8, { gnss::MAX_LOCATION_LEN }>,
559    /// `PROP_IDENT_ALTITUDE`: meters above the WGS-84 ellipsoid, or
560    /// `None`. Held decoded so every read reports the minimal encoding
561    /// regardless of the width it arrived in.
562    ident_altitude_m: Option<i32>,
563    /// `PROP_DEV_DISCOVERABLE`: whether the device identity answers
564    /// Identity Requests. On by default — a deployed device is
565    /// infrastructure, and being askable is most of the point; the
566    /// property is the opt-out.
567    dev_discoverable: bool,
568    /// `PROP_ADVERT_INTERVAL`: seconds between unsolicited advertisements,
569    /// 0 for none. Independent of `dev_discoverable`, which governs only
570    /// whether the device answers when asked.
571    advert_interval_s: u32,
572    /// `PROP_BEACON_INTERVAL`: seconds between unsolicited beacons, 0 for
573    /// none. Separate from the advertisement interval because the two
574    /// announce different things at different costs — a beacon is a path,
575    /// an advertisement is an identity — and a mesh usually wants the
576    /// cheap one far more often than the expensive one.
577    beacon_interval_s: u32,
578    /// `PROP_STARTUP_BEACON`: whether one beacon goes out once the device
579    /// comes up. On by default: a node that has just rebooted is exactly
580    /// the node whose neighbours' cached paths are most likely stale.
581    startup_beacon: bool,
582    /// `PROP_TZ_OFFSET`: minutes east of UTC. Configuration rather than
583    /// measurement — where a device is meant to be is known even when the
584    /// time is not — so unlike `PROP_TIME` it always has a value.
585    tz_offset_min: i16,
586    /// `PROP_GNSS_ENABLED`: whether the receiver is powered.
587    ///
588    /// Off by default on most boards. A receiver is the largest
589    /// continuous load on a battery, and a device that has never been
590    /// told to care where it is should not be spending one finding out.
591    /// A board whose job is to know where it is says otherwise through
592    /// [`GnssConfig::default_enabled`].
593    gnss_enabled: bool,
594    /// `PROP_GNSS_IDENT_UPDATE`: whether fixes refresh the advertised node
595    /// identity's location. Off by default: broadcasting where you are is
596    /// a decision, not a default.
597    gnss_ident_update: bool,
598    /// `PROP_GNSS_IDENT_PRECISION`: how far the advertised location is
599    /// clamped down from what the receiver actually knows.
600    gnss_ident_precision: u8,
601    /// `PROP_GNSS_TIME_TRUST`: whether receiver-derived time may set the
602    /// wall clock. On by default — the sky is normally the best clock a
603    /// board has — and the opt-out for when it demonstrably is not.
604    gnss_time_trust: bool,
605    /// `PROP_BLE_ENABLED`: whether the device is reachable over
606    /// Bluetooth. On by default — a device nobody can attach to is a
607    /// device nobody can configure, and on most boards the menu that
608    /// clears this is reached over the very link it drops.
609    ble_enabled: bool,
610}
611
612impl DeviceDomain {
613    fn post_reset(config: &SessionConfig) -> Self {
614        let mut settings = config.defaults;
615        settings.enabled = false;
616        let mut name = [0; MAX_DEVICE_NAME_LEN];
617        let name_len = config.default_device_name.len();
618        name[..name_len].copy_from_slice(config.default_device_name.as_bytes());
619        // Duty accounting restarts with the domain; the limit and the
620        // ledger's modulation view return to the configured defaults.
621        config.duty.reset_accounting();
622        config.duty.set_limit(config.default_duty_limit);
623        config
624            .duty
625            .set_phy(settings.sf, settings.bw_hz, settings.cr_denom);
626        Self {
627            settings,
628            name,
629            name_len,
630            channel_keys: ChannelKeyTable::default(),
631            peers: DevPeerTable::default(),
632            admins: DevAdminTable::default(),
633            repeater_enabled: false,
634            repeater_regions: RepeaterRegions::default(),
635            repeater_default_region: None,
636            repeater_min_rssi: None,
637            repeater_min_snr: None,
638            ident_role: None,
639            ident_mobile: false,
640            ident_location: HeaplessVec::new(),
641            ident_altitude_m: None,
642            dev_discoverable: true,
643            advert_interval_s: DEFAULT_ADVERT_INTERVAL_S,
644            beacon_interval_s: DEFAULT_BEACON_INTERVAL_S,
645            startup_beacon: true,
646            tz_offset_min: 0,
647            gnss_enabled: config.gnss.is_some_and(|gnss| gnss.default_enabled),
648            gnss_ident_update: false,
649            gnss_ident_precision: DEFAULT_IDENT_PRECISION,
650            gnss_time_trust: true,
651            ble_enabled: true,
652        }
653    }
654}
655
656/// Maximum number of explicit `PROP_HOST_RX_FILTERS` entries.
657pub const MAX_RX_FILTERS: usize = 16;
658
659/// The explicit receive filter table: an unordered set with fixed
660/// capacity. Whole-table replacement builds a candidate table first so
661/// a failed set never leaves a partial mixture (spec §Mutation
662/// Atomicity).
663#[derive(Clone, Copy)]
664struct FilterTable {
665    entries: [Filter; MAX_RX_FILTERS],
666    len: usize,
667}
668
669impl Default for FilterTable {
670    fn default() -> Self {
671        Self {
672            entries: [Filter::PktType(0); MAX_RX_FILTERS],
673            len: 0,
674        }
675    }
676}
677
678impl FilterTable {
679    fn iter(&self) -> impl Iterator<Item = &Filter> {
680        self.entries[..self.len].iter()
681    }
682
683    fn is_empty(&self) -> bool {
684        self.len == 0
685    }
686
687    /// Add a filter; duplicates fail with `STATUS_ALREADY`, a full
688    /// table with `STATUS_NOMEM`.
689    fn insert(&mut self, filter: Filter) -> Result<(), Status> {
690        if self.iter().any(|existing| *existing == filter) {
691            return Err(Status::ALREADY);
692        }
693        if self.len == MAX_RX_FILTERS {
694            return Err(Status::NOMEM);
695        }
696        self.entries[self.len] = filter;
697        self.len += 1;
698        Ok(())
699    }
700
701    /// Remove the filter matching `filter` (the selector is the full
702    /// item); a missing item fails with `STATUS_ITEM_NOT_FOUND`.
703    fn remove(&mut self, filter: Filter) -> Result<(), Status> {
704        let Some(index) = self.iter().position(|existing| *existing == filter) else {
705            return Err(Status::ITEM_NOT_FOUND);
706        };
707        self.len -= 1;
708        self.entries[index] = self.entries[self.len];
709        Ok(())
710    }
711
712    /// Parse a whole-table `CMD_PROP_SET` value (PUI-length-prefixed
713    /// filter entries) into a complete replacement table, validating
714    /// everything before the caller commits it. Duplicate items in the
715    /// value collapse, matching the property's set semantics.
716    fn parse_table(value: &[u8]) -> Result<Self, Status> {
717        let mut table = Self::default();
718        for item in items::prefixed_items(value) {
719            let filter = decode_filter(item.map_err(table_error)?)?;
720            match table.insert(filter) {
721                Ok(()) | Err(Status::ALREADY) => {}
722                Err(status) => return Err(status),
723            }
724        }
725        Ok(table)
726    }
727}
728
729/// Decode and validate one filter item. Unrecognized types, mismatched
730/// value lengths, and out-of-range packet types are invalid arguments
731/// per the `PROP_HOST_RX_FILTERS` spec.
732fn decode_filter(item: &[u8]) -> Result<Filter, Status> {
733    let filter = Filter::decode(item).map_err(|_| Status::INVALID_ARGUMENT)?;
734    if matches!(filter, Filter::PktType(pkt_type) if pkt_type > 7) {
735        return Err(Status::INVALID_ARGUMENT);
736    }
737    Ok(filter)
738}
739
740/// Map a table-structure decoding failure (bad or truncated item
741/// length prefix) to a status. Entry-level problems are invalid
742/// arguments; a value that cannot be split into items at all is
743/// malformed.
744fn table_error(error: ItemError) -> Status {
745    match error {
746        ItemError::BadPrefix | ItemError::Truncated => Status::PARSE_ERROR,
747        _ => Status::INVALID_ARGUMENT,
748    }
749}
750
751/// `PROP_HOST_RX_QUEUE_CAPACITY`: the fixed size of the inbound queue.
752pub const RX_QUEUE_CAPACITY: usize = 16;
753
754/// The logical identity of an authenticated received packet: the frame
755/// counter plus the verified MIC (which covers the channel or pairwise
756/// keys, the addressing, and the body). A Route Retry form preserves
757/// both, so it matches its original. Unauthenticated frames have no
758/// identity and are never coalesced.
759#[derive(Clone, Copy, PartialEq, Eq)]
760struct RxIdentity {
761    counter: u32,
762    mic: [u8; 16],
763    mic_len: u8,
764}
765
766impl RxIdentity {
767    fn new(counter: u32, mic: &[u8]) -> Option<Self> {
768        if mic.is_empty() || mic.len() > 16 {
769            return None;
770        }
771        let mut padded = [0u8; 16];
772        padded[..mic.len()].copy_from_slice(mic);
773        Some(Self {
774            counter,
775            mic: padded,
776            mic_len: mic.len() as u8,
777        })
778    }
779}
780
781/// What arrived with one frame handed to [`Session::on_radio_rx`].
782///
783/// The signal fields are optional because not every frame the session
784/// sees was received: a frame the device transmitted itself comes back
785/// with nothing measured, and inventing a reading for it would put a
786/// number the radio never produced on the wire to the host.
787#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
788pub struct RadioRxInfo {
789    pub rssi_dbm: Option<i16>,
790    pub snr_cb: Option<i16>,
791    pub lqi: Option<core::num::NonZeroU8>,
792    /// The device transmitted this frame and is delivering its own copy.
793    pub self_tx: bool,
794}
795
796impl RadioRxInfo {
797    /// A reception off the air, with what the radio reported.
798    pub fn measured(rssi_dbm: i16, snr_cb: i16, lqi: Option<core::num::NonZeroU8>) -> Self {
799        Self {
800            rssi_dbm: Some(rssi_dbm),
801            snr_cb: Some(snr_cb),
802            lqi,
803            self_tx: false,
804        }
805    }
806
807    /// A copy of a frame this device just transmitted.
808    pub fn self_transmitted() -> Self {
809        Self {
810            self_tx: true,
811            ..Self::default()
812        }
813    }
814}
815
816/// One inbound-queue entry: the frame, its receive metadata, the time
817/// of reception, whether the device acknowledged it on the host's behalf,
818/// and — for authenticated frames — the logical packet identity used
819/// for duplicate coalescing and deferred ack marking.
820#[derive(Clone, Copy)]
821struct QueueEntry {
822    data: [u8; MAX_MTU],
823    len: u16,
824    rssi_dbm: Option<i16>,
825    snr_cb: Option<i16>,
826    lqi: Option<core::num::NonZeroU8>,
827    self_tx: bool,
828    rx_time_ms: u64,
829    acked: bool,
830    /// Monotonic (wrapping) sequence number: a stable handle that a
831    /// pending ack transmission can use to mark this exact entry
832    /// later, immune to queue rotation and eviction.
833    seq: u16,
834    identity: Option<RxIdentity>,
835}
836
837impl QueueEntry {
838    const EMPTY: Self = Self {
839        data: [0; MAX_MTU],
840        len: 0,
841        rssi_dbm: None,
842        snr_cb: None,
843        lqi: None,
844        self_tx: false,
845        rx_time_ms: 0,
846        acked: false,
847        seq: 0,
848        identity: None,
849    };
850
851    fn frame(&self) -> &[u8] {
852        &self.data[..usize::from(self.len)]
853    }
854}
855
856/// The circular FIFO inbound queue (spec §Inbound Queueing). When full,
857/// accepting a new frame evicts the oldest entry and counts it in
858/// `PROP_HOST_RX_QUEUE_DROPPED`, so the queue always holds the most
859/// recent accepted traffic.
860struct RxQueue {
861    entries: [QueueEntry; RX_QUEUE_CAPACITY],
862    /// Index of the oldest entry.
863    head: usize,
864    len: usize,
865    dropped: u32,
866    /// Next entry sequence number. Never reset — a stale ack handle
867    /// from before a queue reset must not match a new entry.
868    next_seq: u16,
869}
870
871impl Default for RxQueue {
872    fn default() -> Self {
873        Self {
874            entries: [QueueEntry::EMPTY; RX_QUEUE_CAPACITY],
875            head: 0,
876            len: 0,
877            dropped: 0,
878            next_seq: 0,
879        }
880    }
881}
882
883impl RxQueue {
884    /// Reset to empty without constructing a fresh entry array (the
885    /// array is several KB; hosts of this crate include embedded
886    /// stacks). The sequence counter deliberately survives.
887    fn clear(&mut self) {
888        self.head = 0;
889        self.len = 0;
890        self.dropped = 0;
891        for entry in &mut self.entries {
892            entry.identity = None;
893        }
894    }
895
896    /// Append an entry (evicting the oldest when full) and return its
897    /// sequence handle.
898    fn push(
899        &mut self,
900        data: &[u8],
901        info: &RadioRxInfo,
902        rx_time_ms: u64,
903        identity: Option<RxIdentity>,
904    ) -> u16 {
905        debug_assert!(data.len() <= MAX_MTU);
906        if self.len == RX_QUEUE_CAPACITY {
907            self.head = (self.head + 1) % RX_QUEUE_CAPACITY;
908            self.len -= 1;
909            self.dropped = self.dropped.wrapping_add(1);
910        }
911        let seq = self.next_seq;
912        self.next_seq = self.next_seq.wrapping_add(1);
913        let slot = (self.head + self.len) % RX_QUEUE_CAPACITY;
914        let entry = &mut self.entries[slot];
915        entry.data[..data.len()].copy_from_slice(data);
916        entry.len = data.len() as u16;
917        entry.rssi_dbm = info.rssi_dbm;
918        entry.snr_cb = info.snr_cb;
919        entry.lqi = info.lqi;
920        entry.self_tx = info.self_tx;
921        entry.rx_time_ms = rx_time_ms;
922        entry.acked = false;
923        entry.seq = seq;
924        entry.identity = identity;
925        self.len += 1;
926        seq
927    }
928
929    fn pop_front(&mut self) -> Option<QueueEntry> {
930        if self.len == 0 {
931            return None;
932        }
933        let entry = self.entries[self.head];
934        self.head = (self.head + 1) % RX_QUEUE_CAPACITY;
935        self.len -= 1;
936        Some(entry)
937    }
938
939    fn iter(&self) -> impl Iterator<Item = &QueueEntry> {
940        (0..self.len).map(|offset| &self.entries[(self.head + offset) % RX_QUEUE_CAPACITY])
941    }
942
943    /// The sequence handle of the queued entry holding this logical
944    /// packet, if it is still queued.
945    fn seq_for_identity(&self, identity: &RxIdentity) -> Option<u16> {
946        self.iter()
947            .find(|entry| entry.identity.as_ref() == Some(identity))
948            .map(|entry| entry.seq)
949    }
950
951    /// Mark the entry with this sequence handle acknowledged. A handle
952    /// whose entry was drained, evicted, or discarded matches nothing.
953    fn mark_acked(&mut self, seq: u16) {
954        let Some(offset) = (0..self.len)
955            .find(|offset| self.entries[(self.head + offset) % RX_QUEUE_CAPACITY].seq == seq)
956        else {
957            return;
958        };
959        self.entries[(self.head + offset) % RX_QUEUE_CAPACITY].acked = true;
960    }
961}
962
963/// Maximum number of `PROP_HOST_CHANNEL_KEYS` entries.
964pub const MAX_CHANNEL_KEYS: usize = 8;
965/// Maximum number of `PROP_HOST_PEER_KEYS` entries.
966pub const MAX_PEER_KEYS: usize = 8;
967
968/// One provisioned host channel key with its derived channel
969/// identifier (the digest form, and an implicit receive filter).
970#[derive(Clone, Copy)]
971struct ChannelKeyEntry {
972    key: [u8; items::CHANNEL_KEY_LEN],
973    id: [u8; items::CHANNEL_ID_LEN],
974}
975
976/// `PROP_HOST_CHANNEL_KEYS`: an unordered set of channel keys. The
977/// remove selector is the key; the digest form is the derived channel
978/// identifier.
979#[derive(Clone, Copy, Default)]
980struct ChannelKeyTable {
981    entries: [Option<ChannelKeyEntry>; MAX_CHANNEL_KEYS],
982    len: usize,
983}
984
985impl ChannelKeyTable {
986    fn iter(&self) -> impl Iterator<Item = &ChannelKeyEntry> {
987        self.entries[..self.len]
988            .iter()
989            .map(|entry| entry.as_ref().expect("entries below len are populated"))
990    }
991
992    fn insert(&mut self, entry: ChannelKeyEntry) -> Result<(), Status> {
993        if self.iter().any(|existing| existing.key == entry.key) {
994            return Err(Status::ALREADY);
995        }
996        if self.len == MAX_CHANNEL_KEYS {
997            return Err(Status::NOMEM);
998        }
999        self.entries[self.len] = Some(entry);
1000        self.len += 1;
1001        Ok(())
1002    }
1003
1004    /// Remove by channel key, returning the removed entry's derived
1005    /// identifier (the digest form).
1006    fn remove(&mut self, key: &[u8; items::CHANNEL_KEY_LEN]) -> Result<[u8; 2], Status> {
1007        let Some(index) = self.iter().position(|existing| existing.key == *key) else {
1008            return Err(Status::ITEM_NOT_FOUND);
1009        };
1010        let id = self.entries[index].expect("populated").id;
1011        self.len -= 1;
1012        self.entries[index] = self.entries[self.len];
1013        self.entries[self.len] = None;
1014        Ok(id)
1015    }
1016}
1017
1018/// One provisioned peer: the host-derived pairwise key material plus
1019/// this peer's replay window. The window is keyed by the peer's
1020/// identity — replacing the key material leaves it untouched (spec
1021/// §PROP_HOST_PEER_KEYS), and it is never saved (spec §Saved State).
1022struct PeerSlot {
1023    entry: items::PeerKeyEntry,
1024    window: ReplayWindow,
1025}
1026
1027/// `PROP_HOST_PEER_KEYS`: pairwise key material for provisioned peers.
1028/// Keyed by peer public key (the digest form and remove selector);
1029/// inserting a matching public key replaces the stored key material.
1030#[derive(Default)]
1031struct PeerKeyTable {
1032    entries: [Option<PeerSlot>; MAX_PEER_KEYS],
1033    len: usize,
1034}
1035
1036impl PeerKeyTable {
1037    fn iter(&self) -> impl Iterator<Item = &PeerSlot> {
1038        self.entries[..self.len]
1039            .iter()
1040            .map(|slot| slot.as_ref().expect("entries below len are populated"))
1041    }
1042
1043    /// Insert or replace (by public key). Replacement updates only the
1044    /// stored key material per the spec: the peer's replay window and
1045    /// anything else keyed by its identity are unaffected.
1046    fn insert(&mut self, entry: items::PeerKeyEntry) -> Result<(), Status> {
1047        if let Some(existing) = self.entries[..self.len]
1048            .iter_mut()
1049            .flatten()
1050            .find(|existing| existing.entry.public_key == entry.public_key)
1051        {
1052            existing.entry = entry;
1053            return Ok(());
1054        }
1055        if self.len == MAX_PEER_KEYS {
1056            return Err(Status::NOMEM);
1057        }
1058        self.entries[self.len] = Some(PeerSlot {
1059            entry,
1060            window: ReplayWindow::new(),
1061        });
1062        self.len += 1;
1063        Ok(())
1064    }
1065
1066    /// Replace the *entry set* with `desired` while preserving
1067    /// *per-entry state*: peers present in both keep their replay
1068    /// window, peers `desired` omits are removed, peers it adds start at
1069    /// first contact.
1070    ///
1071    /// This is what a whole-table `CMD_PROP_SET` means, and the
1072    /// distinction is load-bearing rather than stylistic. A host
1073    /// re-asserts its complete desired table on every attach, many times
1074    /// a day; building a fresh table and swapping it in would reset
1075    /// every peer's replay baseline that often, where the documented
1076    /// resynchronization path assumes reboot frequency.
1077    ///
1078    /// "Merge" would be the wrong word for it: entries the value omits
1079    /// do not survive.
1080    fn reconcile(&mut self, desired: &[items::PeerKeyEntry]) {
1081        let mut index = 0;
1082        while index < self.len {
1083            let public_key = self.entries[index]
1084                .as_ref()
1085                .expect("entries below len are populated")
1086                .entry
1087                .public_key;
1088            if desired.iter().any(|entry| entry.public_key == public_key) {
1089                index += 1;
1090                continue;
1091            }
1092            self.len -= 1;
1093            self.entries[index] = self.entries[self.len].take();
1094        }
1095        // Removals ran first and `desired` is bounded by capacity, so no
1096        // insert here can fail. `insert` leaves a matching peer's window
1097        // untouched, which is the whole point.
1098        for entry in desired {
1099            let _ = self.insert(*entry);
1100        }
1101    }
1102
1103    /// Remove by peer public key. The peer's replay window goes with
1104    /// it; re-provisioning starts over at first contact.
1105    fn remove(&mut self, public_key: &[u8; items::PUBLIC_KEY_LEN]) -> Result<(), Status> {
1106        let Some(index) = self
1107            .iter()
1108            .position(|existing| existing.entry.public_key == *public_key)
1109        else {
1110            return Err(Status::ITEM_NOT_FOUND);
1111        };
1112        self.len -= 1;
1113        self.entries[index] = self.entries[self.len].take();
1114        Ok(())
1115    }
1116
1117    /// Resolve a received source address to a provisioned peer index:
1118    /// by full public key when present, otherwise by **unique** 3-byte
1119    /// prefix match (spec §Acknowledgement Delegation; an ambiguous
1120    /// hint does not resolve).
1121    fn resolve_source(&self, source: &SourceAddrRef, frame: &[u8]) -> Option<usize> {
1122        match source {
1123            SourceAddrRef::FullKeyAt { offset } => {
1124                let key = frame.get(*offset..*offset + items::PUBLIC_KEY_LEN)?;
1125                self.iter().position(|slot| slot.entry.public_key == *key)
1126            }
1127            SourceAddrRef::Hint(hint) => {
1128                let mut matches = self
1129                    .iter()
1130                    .enumerate()
1131                    .filter(|(_, slot)| slot.entry.public_key[..3] == hint.0);
1132                let (index, _) = matches.next()?;
1133                matches.next().is_none().then_some(index)
1134            }
1135            _ => None,
1136        }
1137    }
1138}
1139
1140/// Ed25519 private keys are 32 octets, like public keys.
1141pub const PRIVATE_KEY_LEN: usize = 32;
1142
1143/// Maximum number of `PROP_DEV_PEERS` entries.
1144pub const MAX_DEV_PEERS: usize = 8;
1145
1146/// Maximum number of `PROP_DEV_ADMINS` entries.
1147pub const MAX_DEV_ADMINS: usize = 8;
1148
1149/// An unordered set of public keys held as a device-domain property.
1150///
1151/// Neither property built on this carries key material, so the digest
1152/// form and the remove selector are both the item itself.
1153///
1154/// - `PROP_DEV_PEERS`: peers the device identity recognizes. The device
1155///   holds its own private key and performs its own key agreement.
1156/// - `PROP_DEV_ADMINS`: nodes authorized to manage this device over the
1157///   mesh.
1158#[derive(Clone, Copy)]
1159struct PublicKeyTable<const N: usize> {
1160    entries: [[u8; items::PUBLIC_KEY_LEN]; N],
1161    len: usize,
1162}
1163
1164/// `PROP_DEV_PEERS`.
1165type DevPeerTable = PublicKeyTable<MAX_DEV_PEERS>;
1166/// `PROP_DEV_ADMINS`.
1167type DevAdminTable = PublicKeyTable<MAX_DEV_ADMINS>;
1168
1169// `[[u8; 32]; N]` does not derive `Default` for an arbitrary const N.
1170impl<const N: usize> Default for PublicKeyTable<N> {
1171    fn default() -> Self {
1172        Self {
1173            entries: [[0; items::PUBLIC_KEY_LEN]; N],
1174            len: 0,
1175        }
1176    }
1177}
1178
1179impl<const N: usize> PublicKeyTable<N> {
1180    fn iter(&self) -> impl Iterator<Item = &[u8; items::PUBLIC_KEY_LEN]> {
1181        self.entries[..self.len].iter()
1182    }
1183
1184    /// Add a key; duplicates fail with `STATUS_ALREADY`, a full table
1185    /// with `STATUS_NOMEM`.
1186    fn insert(&mut self, public_key: [u8; items::PUBLIC_KEY_LEN]) -> Result<(), Status> {
1187        if self.iter().any(|existing| *existing == public_key) {
1188            return Err(Status::ALREADY);
1189        }
1190        if self.len == N {
1191            return Err(Status::NOMEM);
1192        }
1193        self.entries[self.len] = public_key;
1194        self.len += 1;
1195        Ok(())
1196    }
1197
1198    /// Remove by public key (the full item is the selector); a missing
1199    /// item fails with `STATUS_ITEM_NOT_FOUND`.
1200    fn remove(&mut self, public_key: &[u8; items::PUBLIC_KEY_LEN]) -> Result<(), Status> {
1201        let Some(index) = self.iter().position(|existing| existing == public_key) else {
1202            return Err(Status::ITEM_NOT_FOUND);
1203        };
1204        self.len -= 1;
1205        self.entries[index] = self.entries[self.len];
1206        Ok(())
1207    }
1208
1209    /// Parse a whole-table `CMD_PROP_SET` value (fixed 32-octet items)
1210    /// into a complete replacement table; duplicate items collapse.
1211    fn parse_table(value: &[u8]) -> Result<Self, Status> {
1212        let mut table = Self::default();
1213        for item in items::fixed_items::<{ items::PUBLIC_KEY_LEN }>(value)
1214            .map_err(|_| Status::INVALID_ARGUMENT)?
1215        {
1216            match table.insert(*item) {
1217                Ok(()) | Err(Status::ALREADY) => {}
1218                Err(status) => return Err(status),
1219            }
1220        }
1221        Ok(table)
1222    }
1223}
1224
1225/// Maximum number of `PROP_MAC_REPEATER_REGIONS` entries. Matches the
1226/// MAC's own repeater region capacity, so any value this session accepts
1227/// fits the forwarding policy it ends up configuring.
1228pub const MAX_REPEATER_REGIONS: usize = 8;
1229
1230/// One configured region: the string the operator wrote, and the code it
1231/// derives to.
1232///
1233/// Both are kept because they answer different questions. The string is
1234/// what the device advertises and what an administrator reads back, and it
1235/// cannot be recovered from a hash-derived code. The code is what the
1236/// forwarding filter compares against on every flooded packet, and
1237/// re-deriving it there would mean hashing on the receive path.
1238#[derive(Clone, Copy)]
1239struct RegionEntry {
1240    text: [u8; REGION_STRING_MAX_LEN],
1241    text_len: usize,
1242    code: [u8; REGION_CODE_LEN],
1243}
1244
1245impl RegionEntry {
1246    fn text(&self) -> &[u8] {
1247        &self.text[..self.text_len]
1248    }
1249}
1250
1251/// `PROP_MAC_REPEATER_REGIONS`: the regions the device identity
1252/// flood-forwards for, as written and as derived.
1253#[derive(Clone, Copy, Default)]
1254struct RepeaterRegions {
1255    entries: [Option<RegionEntry>; MAX_REPEATER_REGIONS],
1256    len: usize,
1257}
1258
1259impl RepeaterRegions {
1260    fn iter(&self) -> impl Iterator<Item = &RegionEntry> {
1261        self.entries[..self.len].iter().filter_map(Option::as_ref)
1262    }
1263
1264    /// Where the region `text` denotes is held, if it is held at all.
1265    ///
1266    /// Regions compare without ASCII case, because that is how their codes
1267    /// derive: two spellings that differ only in case are one region
1268    /// (packet-options.md § Region Code Encoding).
1269    fn position(&self, text: &[u8]) -> Option<usize> {
1270        self.iter()
1271            .position(|entry| entry.text().eq_ignore_ascii_case(text))
1272    }
1273
1274    /// Add a region, or respell one already held.
1275    fn push(&mut self, entry: RegionEntry) -> Result<(), Status> {
1276        let Some(index) = self.position(entry.text()) else {
1277            if self.len == MAX_REPEATER_REGIONS {
1278                return Err(Status::NOMEM);
1279            }
1280            self.entries[self.len] = Some(entry);
1281            self.len += 1;
1282            return Ok(());
1283        };
1284        // The region is already held. The same string changes nothing; a
1285        // different capitalization of it respells the entry, which is the
1286        // only way to correct a spelling in place. The derived code is the
1287        // same either way, so the forwarding filter does not move.
1288        if self.entries[index].is_some_and(|held| held.text() == entry.text()) {
1289            return Err(Status::ALREADY);
1290        }
1291        self.entries[index] = Some(entry);
1292        Ok(())
1293    }
1294
1295    fn remove(&mut self, text: &[u8]) -> Result<(), Status> {
1296        let index = self.position(text).ok_or(Status::ITEM_NOT_FOUND)?;
1297        // Order is not meaningful, so the tail fills the hole.
1298        self.len -= 1;
1299        self.entries[index] = self.entries[self.len];
1300        self.entries[self.len] = None;
1301        Ok(())
1302    }
1303
1304    /// Parse a whole-table write of length-prefixed string items.
1305    ///
1306    /// The whole value is validated before anything is committed, so a
1307    /// rejected write leaves the previous table exactly as it was. A
1308    /// repeated region collapses, matching the property's set semantics,
1309    /// and the last spelling written is the one kept.
1310    fn parse_table(value: &[u8]) -> Result<Self, Status> {
1311        let mut regions = Self::default();
1312        for item in items::prefixed_items(value) {
1313            let entry = region_entry(item.map_err(|_| Status::INVALID_ARGUMENT)?)?;
1314            match regions.push(entry) {
1315                Ok(()) | Err(Status::ALREADY) => {}
1316                Err(status) => return Err(status),
1317            }
1318        }
1319        Ok(regions)
1320    }
1321}
1322
1323/// Validate one region item and derive its forwarding code.
1324///
1325/// The derivation itself is total over every string this accepts — a
1326/// short code, a name, or a literal `0x` code — so the only failures are
1327/// the bounds the property sets: 1 to 24 octets of UTF-8
1328/// (ulcp-device.md § PROP_MAC_REPEATER_REGIONS).
1329fn region_entry(item: &[u8]) -> Result<RegionEntry, Status> {
1330    if !(1..=REGION_STRING_MAX_LEN).contains(&item.len()) {
1331        return Err(Status::INVALID_ARGUMENT);
1332    }
1333    let text = core::str::from_utf8(item).map_err(|_| Status::INVALID_ARGUMENT)?;
1334    let code = RegionCode::from_str(text).map_err(|_| Status::INVALID_ARGUMENT)?;
1335    let mut entry = RegionEntry {
1336        text: [0; REGION_STRING_MAX_LEN],
1337        text_len: item.len(),
1338        code: code.to_bytes(),
1339    };
1340    entry.text[..item.len()].copy_from_slice(item);
1341    Ok(entry)
1342}
1343
1344/// Parse a `PROP_MAC_REPEATER_DEFAULT_REGION` value: one region code, or
1345/// empty for "never tag".
1346fn parse_region_code(value: &[u8]) -> Result<Option<[u8; REGION_CODE_LEN]>, Status> {
1347    match value.len() {
1348        0 => Ok(None),
1349        REGION_CODE_LEN => Ok(Some([value[0], value[1]])),
1350        _ => Err(Status::INVALID_ARGUMENT),
1351    }
1352}
1353
1354/// Number of recently-transmitted frames whose MIC prefixes we remember for
1355/// receive filtering. Sized to cover what can still produce an echo — a
1356/// returning ack over a round trip, a repeat within the confirmation window;
1357/// 4 bytes each, so the whole ring is tiny.
1358const TRANSMITTED_MIC_SLOTS: usize = 16;
1359
1360/// A small ring of 4-byte MIC prefixes for frames this radio has
1361/// transmitted. Two kinds of returning traffic identify themselves by such a
1362/// prefix and nothing else the filter can hold on to:
1363///
1364/// - a **MAC ack**, which carries no destination hint; its public `ack_mic`
1365///   is defined as the first 4 bytes of the acknowledged frame's MIC
1366/// - a **repeat** of our own frame carried onward by a repeater, whose
1367///   destination hint is the remote peer's; the rewrite may touch only
1368///   mutable routing state, so the MIC rides through unchanged — the same
1369///   identity the host's forwarding-confirmation machinery keys on
1370///
1371/// One table serves both: whatever the packet type, a trailer opening with a
1372/// remembered prefix is an echo of something we sent.
1373///
1374/// Eviction is **lazy**: entries are displaced oldest-first only when the
1375/// ring fills, and are *never* removed on a match. A single send can be
1376/// echoed several times — acks arriving over different routes, repeats from
1377/// different repeaters — each carrying distinct routing state; keeping the
1378/// entry live lets the host collect all of them.
1379#[derive(Default)]
1380struct TransmittedMics {
1381    slots: [[u8; 4]; TRANSMITTED_MIC_SLOTS],
1382    /// Number of populated slots, saturating at `TRANSMITTED_MIC_SLOTS`.
1383    filled: usize,
1384    /// Next write position (ring cursor).
1385    cursor: usize,
1386}
1387
1388impl TransmittedMics {
1389    /// Record a transmitted frame's MIC prefix, skipping duplicates so
1390    /// retransmissions of the same frame don't crowd out other entries.
1391    fn note(&mut self, mic: [u8; 4]) {
1392        if self.contains(&mic) {
1393            return;
1394        }
1395        self.slots[self.cursor] = mic;
1396        self.cursor = (self.cursor + 1) % TRANSMITTED_MIC_SLOTS;
1397        if self.filled < TRANSMITTED_MIC_SLOTS {
1398            self.filled += 1;
1399        }
1400    }
1401
1402    /// Whether `mic` matches a still-remembered transmitted frame.
1403    fn contains(&self, mic: &[u8; 4]) -> bool {
1404        self.slots[..self.filled].iter().any(|slot| slot == mic)
1405    }
1406}
1407
1408/// State belonging to the configured tethered host identity (spec
1409/// §State Classes, host domain): host key, key tables, filters,
1410/// auto-ACK policy, and the inbound queue. The `CAP_HOST_AUTO_ACK`
1411/// increment extends it; host replacement resets it as one unit.
1412#[derive(Default)]
1413struct HostDomain {
1414    /// `PROP_HOST_KEY`; `None` means no host identity is configured.
1415    key: Option<[u8; items::PUBLIC_KEY_LEN]>,
1416    /// `PROP_HOST_RX_FILTERS`.
1417    filters: FilterTable,
1418    /// `PROP_HOST_CHANNEL_KEYS`.
1419    channel_keys: ChannelKeyTable,
1420    /// `PROP_HOST_PEER_KEYS`.
1421    peer_keys: PeerKeyTable,
1422    /// `PROP_HOST_AUTO_ACK`: acknowledge qualifying frames on the
1423    /// host's behalf while detached.
1424    auto_ack: bool,
1425    /// The inbound queue, populated while the host is detached.
1426    queue: RxQueue,
1427    /// MIC prefixes of frames we have transmitted, used to recognize
1428    /// returning echoes: MAC acks (which carry no destination hint) and
1429    /// repeats of our own sends (whose destination hint is the peer's).
1430    transmitted_mics: TransmittedMics,
1431}
1432
1433impl HostDomain {
1434    /// Reset the whole domain to defaults with `key` installed,
1435    /// in place: the domain embeds the multi-KB queue array, and a
1436    /// wholesale struct replacement would stage that array on the
1437    /// caller's stack.
1438    fn reset(&mut self, key: Option<[u8; items::PUBLIC_KEY_LEN]>) {
1439        self.key = key;
1440        self.filters = FilterTable::default();
1441        self.channel_keys = ChannelKeyTable::default();
1442        self.peer_keys = PeerKeyTable::default();
1443        self.auto_ack = false;
1444        self.queue.clear();
1445        self.transmitted_mics = TransmittedMics::default();
1446    }
1447
1448    /// Record the MIC prefix of a frame we are about to transmit, so its
1449    /// echoes — a returning MAC ack, a repeater's onward copy — can be
1450    /// recognized as ours. MAC acks we emit ourselves are skipped: their
1451    /// trailer names the *other* side's frame, which needs no pass-through.
1452    fn note_tx_mic(&mut self, frame: &[u8]) {
1453        let Ok(header) = PacketHeader::parse(frame) else {
1454            return;
1455        };
1456        if header.fcf.packet_type() == PacketType::MacAck {
1457            return;
1458        }
1459        if let Some(mic) = frame.get(header.mic_range.clone())
1460            && mic.len() >= 4
1461        {
1462            self.transmitted_mics.note([mic[0], mic[1], mic[2], mic[3]]);
1463        }
1464    }
1465
1466    /// Spec §Receive Filtering compatibility rule: with no host key, no
1467    /// host channel keys, and an empty explicit table, filtering is
1468    /// unconfigured and every received frame is accepted.
1469    fn filtering_configured(&self) -> bool {
1470        self.key.is_some() || !self.filters.is_empty() || self.channel_keys.len != 0
1471    }
1472
1473    /// Whether receive filtering accepts this frame for live delivery:
1474    /// a Broadcast packet is addressed to every node — the host
1475    /// included — so it is implicitly accepted. The broadcast rule is
1476    /// live-only: while the host is detached, ambient broadcast
1477    /// traffic must not displace queued unicast frames, so the queue
1478    /// path uses [`accepts_frame`](Self::accepts_frame) directly.
1479    fn accepts_live_frame(&self, data: &[u8]) -> bool {
1480        if PacketHeader::parse(data)
1481            .is_ok_and(|header| header.fcf.packet_type() == PacketType::Broadcast)
1482        {
1483            return true;
1484        }
1485        self.accepts_frame(data)
1486    }
1487
1488    /// Whether receive filtering accepts this frame: any explicit
1489    /// filter or the implicit destination-hint filter for the host key
1490    /// matches. Hints are prefilters — over-acceptance is fine, the
1491    /// host verifies cryptographically. A frame that does not parse as
1492    /// UMSH can match no filter.
1493    fn accepts_frame(&self, data: &[u8]) -> bool {
1494        if !self.filtering_configured() {
1495            return true;
1496        }
1497        let Ok(header) = PacketHeader::parse(data) else {
1498            return false;
1499        };
1500        // A frame whose trailer opens with the MIC prefix of something we
1501        // transmitted is an echo of our own send, accepted regardless of
1502        // packet type: a MAC ack's public ack_mic is defined as those 4
1503        // bytes, and a repeater's onward copy carries the MIC verbatim.
1504        // Neither is addressed to us — the ack has no destination hint at
1505        // all, the repeat names the remote peer — so without this rule the
1506        // host could never see its ack arrive or its frame carried onward,
1507        // and its forwarding-confirmation machinery would retry sends the
1508        // mesh already accepted. Entries evict lazily, so multiple echoes of
1509        // one send — acks over different routes, repeats from different
1510        // repeaters — all pass. A miss falls through to the explicit filters
1511        // below (a FILTER_PKT_TYPE entry for MacAck must still be honored),
1512        // preserving the union-of-filters rule.
1513        if let Some(mic) = data.get(header.mic_range.start..header.mic_range.start + 4)
1514            && self
1515                .transmitted_mics
1516                .contains(&[mic[0], mic[1], mic[2], mic[3]])
1517        {
1518            return true;
1519        }
1520        let dst = header.dst.map(|hint| hint.0);
1521        if let Some(key) = &self.key
1522            && dst == Some([key[0], key[1], key[2]])
1523        {
1524            return true;
1525        }
1526        let channel = header.channel.map(|channel| channel.0);
1527        // Each provisioned host channel key's derived identifier is an
1528        // implicit channel filter.
1529        if channel.is_some()
1530            && self
1531                .channel_keys
1532                .iter()
1533                .any(|entry| channel == Some(entry.id))
1534        {
1535            return true;
1536        }
1537        let pkt_type = header.fcf.packet_type() as u8;
1538        self.filters.iter().any(|filter| match filter {
1539            Filter::DestHint(hint) => dst == Some(*hint),
1540            Filter::ChannelId(id) => channel == Some(*id),
1541            Filter::PktType(filtered) => pkt_type == *filtered,
1542        })
1543    }
1544}
1545
1546/// Largest encoded snapshot the session produces (see
1547/// [`Session::encode_snapshot`]); sized for every table at capacity
1548/// with headroom for future properties.
1549///
1550/// Every table at capacity currently encodes to 972 octets, option
1551/// framing included (`snapshot_at_capacity_fits_the_buffer` pins it);
1552/// the rest is headroom for properties not yet allocated. A persisted
1553/// table of public keys costs about 272 octets at capacity, so there is
1554/// room for three more before this has to grow, and it can only grow to
1555/// `umsh_journal_store::proto::MAX_PAYLOAD` — 2029 — before the journal
1556/// record format itself has to change.
1557pub const SNAPSHOT_MAX: usize = 1792;
1558
1559/// Snapshot payload format discriminator.
1560///
1561/// Not a version in the usual sense: the option list behind it evolves
1562/// by allocating property numbers, so this byte changes only if the
1563/// *framing* changes. It exists because a retired positional payload
1564/// does not reliably fail the option decoder — the leading `0x03` of the
1565/// last positional format reads as a well-formed option header (delta 0,
1566/// length 3) — so without a discriminator a stale snapshot would
1567/// mis-decode into a plausible-looking domain instead of being rejected.
1568/// Values 1–3 are the retired positional formats and are never decoded;
1569/// re-provisioning replaces them.
1570const SNAPSHOT_FORMAT: u8 = 4;
1571
1572/// When a saved property lands during a restore.
1573///
1574/// Apply order is schema metadata and deliberately independent of the
1575/// property numbering, which was not allocated with ordering in mind:
1576/// `PROP_PHY_ENABLED` is 32 and so precedes every PHY parameter it
1577/// depends on, and applying in identifier order would bring the radio up
1578/// before configuring it.
1579#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1580enum ApplyPhase {
1581    /// Key material and identity tables. Nothing in the snapshot
1582    /// depends on anything else in this phase.
1583    Keys,
1584    /// PHY parameters, device behavior, and everything else that must
1585    /// be in place before the radio comes up.
1586    Config,
1587    /// Bring-up, after the radio is fully configured.
1588    Enable,
1589}
1590
1591impl ApplyPhase {
1592    const ORDER: [Self; 3] = [Self::Keys, Self::Config, Self::Enable];
1593}
1594
1595/// One saved property: its ULCP identifier, when it applies, and
1596/// whether the snapshot may carry more than one of it.
1597struct SavedProperty {
1598    number: u16,
1599    phase: ApplyPhase,
1600    /// Table-valued: one option per entry, so repeats are expected.
1601    /// Single-valued properties reject a second occurrence.
1602    repeatable: bool,
1603}
1604
1605/// Build a schema row, narrowing the identifier deliberately: property
1606/// identifiers are `u32` in [`ids`] and reach 4864, while the option
1607/// codec numbers options in `u16`. Everything saved fits.
1608const fn saved(number: u32, phase: ApplyPhase, repeatable: bool) -> SavedProperty {
1609    assert!(
1610        number <= u16::MAX as u32,
1611        "saved property number must fit u16"
1612    );
1613    SavedProperty {
1614        number: number as u16,
1615        phase,
1616        repeatable,
1617    }
1618}
1619
1620/// Every property `CMD_SAVE` persists.
1621///
1622/// **Rows must be in ascending identifier order.** The option encoder
1623/// emits deltas and refuses a number below the last one written, so this
1624/// ordering is a codec requirement — and precisely why it cannot also
1625/// carry the apply order, which comes from `phase`.
1626///
1627/// Adding a saved property is one row plus its arm in
1628/// [`SavedState::encode_into`] / [`SavedState::absorb_option`]. Removing
1629/// one is deleting its row: the number is retired and never reused, and
1630/// an older snapshot that still carries it decodes with the option
1631/// skipped.
1632///
1633/// **The host domain is deliberately absent.** It is volatile across
1634/// reboot by design — a detached radio keeps filtering, queueing and
1635/// acknowledging for its host while powered, and forgets on power cycle
1636/// — so 96 (`PROP_HOST_KEY`), 97, 98, 99 and 100 are retired numbers
1637/// here, not omissions. An older snapshot that still carries them
1638/// decodes with those options skipped, which is exactly the wanted
1639/// behavior and needed no migration code.
1640const SAVED_SCHEMA: &[SavedProperty] = &[
1641    saved(prop::PHY_ENABLED, ApplyPhase::Enable, false),
1642    saved(prop::PHY_FREQ, ApplyPhase::Config, false),
1643    saved(prop::PHY_TX_POWER, ApplyPhase::Config, false),
1644    saved(prop::PHY_LORA_BW, ApplyPhase::Config, false),
1645    saved(prop::PHY_LORA_SF, ApplyPhase::Config, false),
1646    saved(prop::PHY_LORA_CR, ApplyPhase::Config, false),
1647    saved(prop::DEV_KEY, ApplyPhase::Keys, false),
1648    saved(prop::DEV_CHANNEL_KEYS, ApplyPhase::Keys, true),
1649    saved(prop::DEV_PEERS, ApplyPhase::Keys, true),
1650    saved(prop::DEV_NAME, ApplyPhase::Config, false),
1651    saved(prop::MAC_REPEATER_ENABLED, ApplyPhase::Config, false),
1652    saved(prop::IDENT_ROLE, ApplyPhase::Config, false),
1653    saved(prop::IDENT_MOBILE, ApplyPhase::Config, false),
1654    saved(prop::MAC_REPEATER_REGIONS, ApplyPhase::Config, true),
1655    saved(prop::MAC_REPEATER_DEFAULT_REGION, ApplyPhase::Config, false),
1656    saved(prop::MAC_REPEATER_MIN_RSSI, ApplyPhase::Config, false),
1657    saved(prop::MAC_REPEATER_MIN_SNR, ApplyPhase::Config, false),
1658    saved(prop::DEV_DISCOVERABLE, ApplyPhase::Config, false),
1659    saved(prop::ADVERT_INTERVAL, ApplyPhase::Config, false),
1660    saved(prop::BEACON_INTERVAL, ApplyPhase::Config, false),
1661    saved(prop::STARTUP_BEACON, ApplyPhase::Config, false),
1662    saved(prop::IDENT_LOCATION, ApplyPhase::Config, false),
1663    saved(prop::IDENT_ALTITUDE, ApplyPhase::Config, false),
1664    saved(prop::GNSS_ENABLED, ApplyPhase::Config, false),
1665    saved(prop::PHY_DUTY_LIMIT, ApplyPhase::Config, false),
1666    saved(prop::DEV_ADMINS, ApplyPhase::Keys, true),
1667    saved(prop::TZ_OFFSET, ApplyPhase::Config, false),
1668    saved(prop::GNSS_IDENT_UPDATE, ApplyPhase::Config, false),
1669    saved(prop::GNSS_IDENT_PRECISION, ApplyPhase::Config, false),
1670    saved(prop::GNSS_TIME_TRUST, ApplyPhase::Config, false),
1671    saved(prop::BLE_ENABLED, ApplyPhase::Config, false),
1672];
1673
1674/// [`SavedState::decode`] tracks which single-valued properties it has
1675/// already seen in one `u32` of schema-index bits, so the schema cannot
1676/// outgrow that word without the repeat check silently going blind.
1677const _: () = assert!(
1678    SAVED_SCHEMA.len() <= u32::BITS as usize,
1679    "SAVED_SCHEMA outgrew the duplicate-detection bitmask"
1680);
1681
1682/// The snapshot is a delta-encoded option list, so the schema has to be
1683/// in ascending property order. A row in the wrong place still compiles
1684/// and still passes every test that does not save — it fails at the
1685/// encoder, at runtime, on every device at once — so the order is
1686/// checked here instead.
1687const _: () = {
1688    let mut index = 1;
1689    while index < SAVED_SCHEMA.len() {
1690        assert!(
1691            SAVED_SCHEMA[index - 1].number < SAVED_SCHEMA[index].number,
1692            "SAVED_SCHEMA is not in ascending property order"
1693        );
1694        index += 1;
1695    }
1696};
1697
1698/// Why a stored snapshot payload was rejected. Rejection is never
1699/// silent: the boot path walks back a generation and reports through
1700/// `PROP_SAVED` (spec §Saved State).
1701#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1702pub enum SnapshotError {
1703    /// The leading discriminator is not a format this firmware reads.
1704    /// Retired positional payloads land here.
1705    UnknownFormat,
1706    /// The option block is truncated, out of order, or repeats a
1707    /// single-valued property.
1708    Malformed,
1709    /// A known option carried a value this firmware refuses: out of
1710    /// range, wrong length, or a table over capacity.
1711    InvalidValue,
1712}
1713
1714/// What `PROP_SAVED` reports about the stored snapshot.
1715#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1716pub enum SavedStatus {
1717    /// Nothing is saved.
1718    #[default]
1719    None,
1720    /// The newest saved generation is in effect.
1721    Current,
1722    /// A newer generation was rejected and an older one is in effect —
1723    /// the device is running on generation N−1 and needs attention.
1724    Fallback,
1725    /// A snapshot exists but no generation could be read; the device
1726    /// booted bare.
1727    Unreadable,
1728}
1729
1730impl SavedStatus {
1731    /// The `PROP_SAVED` octet.
1732    pub const fn as_octet(self) -> u8 {
1733        match self {
1734            Self::None => ids::saved::NONE,
1735            Self::Current => ids::saved::CURRENT,
1736            Self::Fallback => ids::saved::FALLBACK,
1737            Self::Unreadable => ids::saved::UNREADABLE,
1738        }
1739    }
1740}
1741
1742/// The saved-state subset of the **device domain** (spec §Saved State):
1743/// everything `CMD_SAVE` persists and `CMD_RESTORE`/`CMD_RST` revert to.
1744///
1745/// The host domain is not here. It is volatile across reboot and only
1746/// across reboot: a detached radio keeps its host's keys, filters and
1747/// queue while powered — that is the entire value of the host domain —
1748/// and forgets them on power cycle, when the host re-provisions in full.
1749/// Also excluded: queue contents, per-peer replay baselines, and the
1750/// independently persisted device identity keypair.
1751#[derive(Clone)]
1752struct SavedState {
1753    settings: RadioSettings,
1754    duty_limit: u16,
1755    name: [u8; MAX_DEVICE_NAME_LEN],
1756    name_len: usize,
1757    /// `PROP_DEV_KEY` at the time of the save: which node this snapshot
1758    /// describes.
1759    ///
1760    /// Saved as provenance, not as configuration — the identity keypair
1761    /// lives in its own journal and a restore never installs it. It
1762    /// exists so that restoring a repeater's saved domain onto
1763    /// replacement hardware cannot bring the PHY up under the
1764    /// replacement's throwaway identity, advertising as a node it is
1765    /// not. `None` means the snapshot does not say, which is permissive.
1766    dev_key: Option<[u8; items::PUBLIC_KEY_LEN]>,
1767    dev_channel_keys: ChannelKeyTable,
1768    dev_peers: DevPeerTable,
1769    dev_admins: DevAdminTable,
1770    repeater_enabled: bool,
1771    repeater_regions: RepeaterRegions,
1772    repeater_default_region: Option<[u8; REGION_CODE_LEN]>,
1773    repeater_min_rssi: Option<i16>,
1774    repeater_min_snr: Option<i8>,
1775    ident_role: Option<u8>,
1776    ident_mobile: bool,
1777    ident_location: HeaplessVec<u8, { gnss::MAX_LOCATION_LEN }>,
1778    ident_altitude_m: Option<i32>,
1779    dev_discoverable: bool,
1780    advert_interval_s: u32,
1781    beacon_interval_s: u32,
1782    startup_beacon: bool,
1783    tz_offset_min: i16,
1784    gnss_enabled: bool,
1785    gnss_ident_update: bool,
1786    gnss_ident_precision: u8,
1787    gnss_time_trust: bool,
1788    ble_enabled: bool,
1789}
1790
1791impl SavedState {
1792    /// Capture the saveable subset of the live device domain.
1793    fn capture(
1794        device: &DeviceDomain,
1795        duty_limit: u16,
1796        dev_key: Option<[u8; items::PUBLIC_KEY_LEN]>,
1797    ) -> Self {
1798        Self {
1799            settings: device.settings,
1800            duty_limit,
1801            name: device.name,
1802            name_len: device.name_len,
1803            dev_key,
1804            dev_channel_keys: device.channel_keys,
1805            dev_peers: device.peers,
1806            dev_admins: device.admins,
1807            repeater_enabled: device.repeater_enabled,
1808            repeater_regions: device.repeater_regions,
1809            repeater_default_region: device.repeater_default_region,
1810            repeater_min_rssi: device.repeater_min_rssi,
1811            repeater_min_snr: device.repeater_min_snr,
1812            ident_role: device.ident_role,
1813            ident_mobile: device.ident_mobile,
1814            ident_location: device.ident_location.clone(),
1815            ident_altitude_m: device.ident_altitude_m,
1816            dev_discoverable: device.dev_discoverable,
1817            advert_interval_s: device.advert_interval_s,
1818            beacon_interval_s: device.beacon_interval_s,
1819            startup_beacon: device.startup_beacon,
1820            tz_offset_min: device.tz_offset_min,
1821            gnss_enabled: device.gnss_enabled,
1822            gnss_ident_update: device.gnss_ident_update,
1823            gnss_ident_precision: device.gnss_ident_precision,
1824            gnss_time_trust: device.gnss_time_trust,
1825            ble_enabled: device.ble_enabled,
1826        }
1827    }
1828
1829    /// The state a snapshot carrying no options at all decodes to:
1830    /// every saved property at its documented post-reset value. Forward
1831    /// compatibility rests on this — an option an older writer never
1832    /// emitted is simply absent, and absence is the default.
1833    fn defaults(config: &SessionConfig) -> Self {
1834        let mut settings = config.defaults;
1835        settings.enabled = false;
1836        let mut name = [0u8; MAX_DEVICE_NAME_LEN];
1837        let name_len = config.default_device_name.len();
1838        name[..name_len].copy_from_slice(config.default_device_name.as_bytes());
1839        Self {
1840            settings,
1841            duty_limit: config.default_duty_limit,
1842            name,
1843            name_len,
1844            dev_key: None,
1845            dev_channel_keys: ChannelKeyTable::default(),
1846            dev_peers: DevPeerTable::default(),
1847            dev_admins: DevAdminTable::default(),
1848            repeater_enabled: false,
1849            repeater_regions: RepeaterRegions::default(),
1850            repeater_default_region: None,
1851            repeater_min_rssi: None,
1852            repeater_min_snr: None,
1853            ident_role: None,
1854            ident_mobile: false,
1855            ident_location: HeaplessVec::new(),
1856            ident_altitude_m: None,
1857            dev_discoverable: true,
1858            advert_interval_s: DEFAULT_ADVERT_INTERVAL_S,
1859            beacon_interval_s: DEFAULT_BEACON_INTERVAL_S,
1860            startup_beacon: true,
1861            tz_offset_min: 0,
1862            // Must track `DeviceDomain::post_reset`: this is the baseline
1863            // a snapshot's absent options decode against, so a board that
1864            // boots its receiver on has to see that here too, or a
1865            // snapshot saved while it was on would restore it off.
1866            gnss_enabled: config.gnss.is_some_and(|gnss| gnss.default_enabled),
1867            gnss_ident_update: false,
1868            gnss_ident_precision: DEFAULT_IDENT_PRECISION,
1869            gnss_time_trust: true,
1870            ble_enabled: true,
1871        }
1872    }
1873
1874    /// Encode as a format byte followed by an option list keyed by ULCP
1875    /// property identifier, in [`SAVED_SCHEMA`] order.
1876    ///
1877    /// Tables emit one option per entry; a value equal to its default —
1878    /// an absent host key, an empty table — is omitted, since absence
1879    /// and the default decode identically. Scalars are always written,
1880    /// because omitting one would silently mean "whatever this firmware
1881    /// build calls the default" rather than the value that was saved.
1882    fn encode(&self, out: &mut [u8]) -> Option<usize> {
1883        let (format, rest) = out.split_first_mut()?;
1884        *format = SNAPSHOT_FORMAT;
1885        let mut encoder = OptionEncoder::new(rest);
1886        for entry in SAVED_SCHEMA {
1887            self.encode_into(&mut encoder, entry.number).ok()?;
1888        }
1889        Some(1 + encoder.finish())
1890    }
1891
1892    /// Emit every option for one saved property. Table properties emit
1893    /// zero or more; single-valued properties emit zero or one.
1894    fn encode_into(&self, encoder: &mut OptionEncoder<'_>, number: u16) -> Result<(), EncodeError> {
1895        match u32::from(number) {
1896            prop::PHY_ENABLED => encoder.put(number, &[self.settings.enabled as u8]),
1897            prop::PHY_FREQ => encoder.put(number, &self.settings.freq_khz.to_le_bytes()),
1898            prop::PHY_TX_POWER => encoder.put(number, &[self.settings.tx_power_dbm as u8]),
1899            prop::PHY_LORA_BW => encoder.put(number, &self.settings.bw_hz.to_le_bytes()),
1900            prop::PHY_LORA_SF => encoder.put(number, &[self.settings.sf]),
1901            prop::PHY_LORA_CR => encoder.put(number, &[self.settings.cr_denom]),
1902            // Channel-key options carry the key itself, not the derived
1903            // identifier the GET form reports: a snapshot has to restore
1904            // the device to working order, and the digest form cannot.
1905            prop::DEV_KEY => match &self.dev_key {
1906                Some(key) => encoder.put(number, key),
1907                None => Ok(()),
1908            },
1909            prop::DEV_CHANNEL_KEYS => {
1910                for entry in self.dev_channel_keys.iter() {
1911                    encoder.put(number, &entry.key)?;
1912                }
1913                Ok(())
1914            }
1915            prop::DEV_PEERS => {
1916                for public_key in self.dev_peers.iter() {
1917                    encoder.put(number, public_key)?;
1918                }
1919                Ok(())
1920            }
1921            prop::DEV_NAME => encoder.put(number, &self.name[..self.name_len]),
1922            prop::MAC_REPEATER_ENABLED => encoder.put(number, &[self.repeater_enabled as u8]),
1923            prop::IDENT_ROLE => match self.ident_role {
1924                Some(role) => encoder.put(number, &[role]),
1925                None => Ok(()),
1926            },
1927            prop::IDENT_MOBILE => encoder.put(number, &[self.ident_mobile as u8]),
1928            // The unset forms of the repeater policy are all "empty", and
1929            // empty is the default, so an unset gate is omitted outright
1930            // rather than written as a zero-length option.
1931            // Repeatable: one option per region string. The derived codes
1932            // are not persisted — they follow from the strings, and a
1933            // stored copy could only ever disagree with them.
1934            prop::MAC_REPEATER_REGIONS => {
1935                for entry in self.repeater_regions.iter() {
1936                    encoder.put(number, entry.text())?;
1937                }
1938                Ok(())
1939            }
1940            prop::MAC_REPEATER_DEFAULT_REGION => match &self.repeater_default_region {
1941                Some(code) => encoder.put(number, code),
1942                None => Ok(()),
1943            },
1944            prop::MAC_REPEATER_MIN_RSSI => match self.repeater_min_rssi {
1945                Some(rssi) => encoder.put(number, &rssi.to_le_bytes()),
1946                None => Ok(()),
1947            },
1948            prop::MAC_REPEATER_MIN_SNR => match self.repeater_min_snr {
1949                Some(snr) => encoder.put(number, &[snr as u8]),
1950                None => Ok(()),
1951            },
1952            prop::DEV_DISCOVERABLE => encoder.put(number, &[self.dev_discoverable as u8]),
1953            prop::ADVERT_INTERVAL => encoder.put(number, &self.advert_interval_s.to_le_bytes()),
1954            prop::BEACON_INTERVAL => encoder.put(number, &self.beacon_interval_s.to_le_bytes()),
1955            prop::STARTUP_BEACON => encoder.put(number, &[self.startup_beacon as u8]),
1956            // Empty is the default for both, so an unadvertised position
1957            // is omitted rather than written as a zero-length option.
1958            prop::IDENT_LOCATION => match self.ident_location.is_empty() {
1959                true => Ok(()),
1960                false => encoder.put(number, &self.ident_location),
1961            },
1962            prop::IDENT_ALTITUDE => match self.ident_altitude_m {
1963                Some(meters) => {
1964                    let mut buf = [0u8; sint::MAX_LEN];
1965                    let len = sint::encode(meters, &mut buf).expect("buffer holds any i32");
1966                    encoder.put(number, &buf[..len])
1967                }
1968                None => Ok(()),
1969            },
1970            prop::GNSS_ENABLED => encoder.put(number, &[self.gnss_enabled as u8]),
1971            prop::PHY_DUTY_LIMIT => encoder.put(number, &self.duty_limit.to_le_bytes()),
1972            prop::DEV_ADMINS => {
1973                for public_key in self.dev_admins.iter() {
1974                    encoder.put(number, public_key)?;
1975                }
1976                Ok(())
1977            }
1978            prop::TZ_OFFSET => encoder.put(number, &self.tz_offset_min.to_le_bytes()),
1979            prop::GNSS_IDENT_UPDATE => encoder.put(number, &[self.gnss_ident_update as u8]),
1980            prop::GNSS_IDENT_PRECISION => encoder.put(number, &[self.gnss_ident_precision]),
1981            prop::GNSS_TIME_TRUST => encoder.put(number, &[self.gnss_time_trust as u8]),
1982            prop::BLE_ENABLED => encoder.put(number, &[self.ble_enabled as u8]),
1983            _ => unreachable!("SAVED_SCHEMA row without an encoder arm"),
1984        }
1985    }
1986
1987    /// Decode a stored snapshot into a candidate state, without touching
1988    /// any live domain. Values are checked with the same validators the
1989    /// live property setters use, but transport authorization is
1990    /// deliberately bypassed: at boot there is no attached link to
1991    /// authorize anything, and the snapshot's provenance is the device's
1992    /// own flash.
1993    ///
1994    /// Channel identifiers are left unset here and re-derived by
1995    /// [`SavedState::derive_channel_ids`] rather than trusted from
1996    /// storage, which is what keeps this step free of the crypto engine
1997    /// — so a caller can ask whether a stored generation is readable
1998    /// without building one.
1999    ///
2000    /// Unknown options are skipped (a newer writer's property, or a
2001    /// retired one). Everything else — a bad format byte, a malformed
2002    /// option block, a repeated single-valued property, an out-of-range
2003    /// value, a table over capacity — rejects the whole payload, leaving
2004    /// the caller to fall back to an older generation.
2005    fn decode(config: &SessionConfig, bytes: &[u8]) -> Result<Self, SnapshotError> {
2006        let (format, options) = bytes.split_first().ok_or(SnapshotError::Malformed)?;
2007        if *format != SNAPSHOT_FORMAT {
2008            return Err(SnapshotError::UnknownFormat);
2009        }
2010        let mut state = Self::defaults(config);
2011        let mut seen: u32 = 0;
2012        for item in OptionDecoder::new(options) {
2013            let (number, value) = item.map_err(|_| SnapshotError::Malformed)?;
2014            let Some(index) = SAVED_SCHEMA.iter().position(|entry| entry.number == number) else {
2015                continue;
2016            };
2017            if !SAVED_SCHEMA[index].repeatable {
2018                let bit = 1u32 << index;
2019                if seen & bit != 0 {
2020                    return Err(SnapshotError::Malformed);
2021                }
2022                seen |= bit;
2023            }
2024            state.absorb_option(config, number, value)?;
2025        }
2026        Ok(state)
2027    }
2028
2029    /// Validate and store one decoded option.
2030    fn absorb_option(
2031        &mut self,
2032        config: &SessionConfig,
2033        number: u16,
2034        value: &[u8],
2035    ) -> Result<(), SnapshotError> {
2036        let invalid = |_| SnapshotError::InvalidValue;
2037        match u32::from(number) {
2038            prop::PHY_ENABLED => self.settings.enabled = parse_bool(value).map_err(invalid)?,
2039            prop::PHY_FREQ => {
2040                self.settings.freq_khz = validate_freq_khz(config, value).map_err(invalid)?
2041            }
2042            // A snapshot carrying a power this radio cannot reach — one
2043            // restored across a hardware change — clamps like a live
2044            // write rather than failing the whole restore.
2045            prop::PHY_TX_POWER => {
2046                self.settings.tx_power_dbm = clamp_tx_power(config, value).map_err(invalid)?
2047            }
2048            prop::PHY_LORA_BW => self.settings.bw_hz = validate_bw_hz(value).map_err(invalid)?,
2049            prop::PHY_LORA_SF => self.settings.sf = validate_sf(value).map_err(invalid)?,
2050            prop::PHY_LORA_CR => self.settings.cr_denom = validate_cr(value).map_err(invalid)?,
2051            prop::DEV_KEY => {
2052                self.dev_key = Some(value.try_into().map_err(|_| SnapshotError::InvalidValue)?);
2053            }
2054            prop::DEV_CHANNEL_KEYS => {
2055                let key = channel_key_item(value)?;
2056                self.dev_channel_keys
2057                    .insert(ChannelKeyEntry {
2058                        key,
2059                        id: [0; items::CHANNEL_ID_LEN],
2060                    })
2061                    .map_err(invalid)?;
2062            }
2063            prop::DEV_PEERS => {
2064                let public_key: [u8; items::PUBLIC_KEY_LEN] =
2065                    value.try_into().map_err(|_| SnapshotError::InvalidValue)?;
2066                self.dev_peers.insert(public_key).map_err(invalid)?;
2067            }
2068            prop::DEV_NAME => {
2069                if !valid_device_name(value) {
2070                    return Err(SnapshotError::InvalidValue);
2071                }
2072                self.name = [0; MAX_DEVICE_NAME_LEN];
2073                self.name[..value.len()].copy_from_slice(value);
2074                self.name_len = value.len();
2075            }
2076            prop::MAC_REPEATER_ENABLED => {
2077                self.repeater_enabled = parse_bool(value).map_err(invalid)?
2078            }
2079            prop::IDENT_ROLE => self.ident_role = Some(parse_u8(value).map_err(invalid)?),
2080            prop::IDENT_MOBILE => self.ident_mobile = parse_bool(value).map_err(invalid)?,
2081            prop::MAC_REPEATER_REGIONS => {
2082                // One option per entry, re-validated and re-derived: a
2083                // stored region that no longer parses is dropped rather
2084                // than allowed to fail the whole restore.
2085                if let Ok(entry) = region_entry(value) {
2086                    let _ = self.repeater_regions.push(entry);
2087                }
2088            }
2089            prop::MAC_REPEATER_DEFAULT_REGION => {
2090                self.repeater_default_region = parse_region_code(value).map_err(invalid)?
2091            }
2092            prop::MAC_REPEATER_MIN_RSSI => {
2093                self.repeater_min_rssi = Some(parse_i16(value).map_err(invalid)?)
2094            }
2095            prop::MAC_REPEATER_MIN_SNR => {
2096                self.repeater_min_snr = Some(parse_i8(value).map_err(invalid)?)
2097            }
2098            prop::DEV_DISCOVERABLE => self.dev_discoverable = parse_bool(value).map_err(invalid)?,
2099            prop::ADVERT_INTERVAL => {
2100                self.advert_interval_s = validate_announce_interval(value).map_err(invalid)?
2101            }
2102            prop::BEACON_INTERVAL => {
2103                self.beacon_interval_s = validate_announce_interval(value).map_err(invalid)?
2104            }
2105            prop::STARTUP_BEACON => self.startup_beacon = parse_bool(value).map_err(invalid)?,
2106            prop::IDENT_LOCATION => {
2107                self.ident_location = validate_ident_location(value).map_err(invalid)?
2108            }
2109            prop::IDENT_ALTITUDE => {
2110                self.ident_altitude_m = validate_ident_altitude(value).map_err(invalid)?
2111            }
2112            prop::GNSS_ENABLED => self.gnss_enabled = parse_bool(value).map_err(invalid)?,
2113            prop::PHY_DUTY_LIMIT => self.duty_limit = parse_u16(value).map_err(invalid)?,
2114            prop::DEV_ADMINS => {
2115                let public_key: [u8; items::PUBLIC_KEY_LEN] =
2116                    value.try_into().map_err(|_| SnapshotError::InvalidValue)?;
2117                self.dev_admins.insert(public_key).map_err(invalid)?;
2118            }
2119            prop::TZ_OFFSET => self.tz_offset_min = validate_tz_offset(value).map_err(invalid)?,
2120            prop::GNSS_IDENT_UPDATE => {
2121                self.gnss_ident_update = parse_bool(value).map_err(invalid)?
2122            }
2123            prop::GNSS_IDENT_PRECISION => {
2124                self.gnss_ident_precision = validate_ident_precision(value).map_err(invalid)?
2125            }
2126            prop::GNSS_TIME_TRUST => self.gnss_time_trust = parse_bool(value).map_err(invalid)?,
2127            prop::BLE_ENABLED => self.ble_enabled = parse_bool(value).map_err(invalid)?,
2128            _ => unreachable!("SAVED_SCHEMA row without a decoder arm"),
2129        }
2130        Ok(())
2131    }
2132
2133    /// Re-derive the channel identifiers left unset by
2134    /// [`SavedState::decode`]. Deriving rather than storing keeps the
2135    /// identifier a function of the key, so a stored snapshot cannot
2136    /// assert a mismatched pair.
2137    fn derive_channel_ids<A: AesProvider, S: Sha256Provider>(
2138        &mut self,
2139        engine: &CryptoEngine<A, S>,
2140    ) {
2141        let table = &mut self.dev_channel_keys;
2142        for entry in table.entries[..table.len].iter_mut().flatten() {
2143            entry.id = engine.derive_channel_id(&ChannelKey(entry.key)).0;
2144        }
2145    }
2146}
2147
2148/// A channel-key option value: the raw symmetric key.
2149fn channel_key_item(value: &[u8]) -> Result<[u8; items::CHANNEL_KEY_LEN], SnapshotError> {
2150    value.try_into().map_err(|_| SnapshotError::InvalidValue)
2151}
2152
2153/// State that exists only while a host is attached (spec §State
2154/// Classes): transaction correlation and session-scoped properties.
2155/// Reset on every attach without touching the radio.
2156struct SessionState<const TX: usize> {
2157    /// `PROP_MAC_PROMISCUOUS`.
2158    promiscuous: bool,
2159    /// `PROP_MAC_BACKHAUL` — the host is a point-to-point neighbor of the
2160    /// device's own node rather than another listener on the medium.
2161    backhaul: bool,
2162    /// Accepted host transmissions, including the one currently owned by the
2163    /// physical radio. Keeping this queue in the device lets a host pipeline
2164    /// fragmented messages without waiting one LoRa round trip per fragment.
2165    pending: Deque<PendingTx, TX>,
2166    /// A drain in progress ([`Effect::DrainQueue`]). Covers exactly the
2167    /// frames queued when `CMD_QUEUE_DRAIN` arrived; an attach or
2168    /// detach abandons the drain, leaving undelivered frames queued.
2169    drain: Option<DrainState>,
2170    /// A device-identity provisioning awaiting its durable write
2171    /// ([`Effect::ProvisionIdentity`]). A detach mid-flight abandons
2172    /// the transaction; flash remains the source of truth either way
2173    /// (see [`Session::respond_identity`]).
2174    pending_identity: Option<PendingIdentity>,
2175}
2176
2177impl<const TX: usize> Default for SessionState<TX> {
2178    fn default() -> Self {
2179        Self {
2180            promiscuous: false,
2181            backhaul: false,
2182            pending: Deque::new(),
2183            drain: None,
2184            pending_identity: None,
2185        }
2186    }
2187}
2188
2189struct PendingIdentity {
2190    tid: u8,
2191    /// The private key to install, or `None` to generate one on-device.
2192    secret: Option<[u8; PRIVATE_KEY_LEN]>,
2193}
2194
2195struct DrainState {
2196    tid: u8,
2197    remaining: usize,
2198}
2199
2200pub struct Session<A: AesProvider, S: Sha256Provider, const TX: usize = 1> {
2201    config: SessionConfig,
2202    /// Protocol crypto (channel-identifier derivation now; packet
2203    /// authentication and delegated acknowledgement with
2204    /// `CAP_HOST_AUTO_ACK`).
2205    engine: CryptoEngine<A, S>,
2206    device: DeviceDomain,
2207    host: HostDomain,
2208    session: SessionState<TX>,
2209    /// Whether a host is currently attached: accepted frames are
2210    /// delivered live when true and queued when false. Starts detached;
2211    /// the transport binding reports attach/detach edges.
2212    attached: bool,
2213    /// Whether the attached transport meets its security binding for
2214    /// key provisioning (spec §Provisioning Security): physical
2215    /// possession for serial, an encrypted bonded LESC link for BLE.
2216    link_secure: bool,
2217    /// RAM mirror of the durably saved snapshot (`None` when nothing
2218    /// is saved). Post-reset values and `CMD_RESTORE` come from here;
2219    /// the firmware keeps the flash journal in sync through the
2220    /// save/clear/wipe effects.
2221    saved: Option<SavedState>,
2222    /// Whether any stored generation was rejected at boot. Latched for
2223    /// the life of the boot: a device running on generation N−1, or on
2224    /// nothing at all because every generation was unreadable, must stay
2225    /// distinguishable from one that simply has nothing saved.
2226    snapshot_rejected: bool,
2227    /// `PROP_DEV_KEY`: the live device identity public key.
2228    dev_key: Option<[u8; items::PUBLIC_KEY_LEN]>,
2229    /// RAM mirror of the *independently persisted* identity — the
2230    /// value `CMD_RST` reverts to. Identical to `dev_key` except
2231    /// between a `CMD_CLEAR` (which erases only the durable copy; live
2232    /// state is unaffected) and the reset that completes the factory
2233    /// wipe. Never part of the snapshot: `CMD_RESTORE` cannot revert
2234    /// the identity.
2235    dev_key_persisted: Option<[u8; items::PUBLIC_KEY_LEN]>,
2236    last_status: Status,
2237    /// Monotonic generation of the device-domain node tables
2238    /// (`PROP_DEV_CHANNEL_KEYS`, `PROP_DEV_PEERS`). Bumped on every
2239    /// mutation, boot restore, `CMD_RESTORE`, and `CMD_RST`. The
2240    /// firmware compares it against a cached value to know when to
2241    /// re-sync the live device node's MAC (device-node plan increment
2242    /// 3); the session stays authoritative for the property surface and
2243    /// the firmware applies the change to its `MacHandle`.
2244    dev_domain_version: u32,
2245    /// `PROP_ALERT`: what the device is currently doing to make itself
2246    /// conspicuous, and when it gives up.
2247    ///
2248    /// Deliberately neither device-domain nor session state. Not the
2249    /// former because it is live physical behavior that is never saved
2250    /// and that `CMD_RST` must not silence; not the latter because a
2251    /// detach is exactly when an alert matters — the link to the
2252    /// searching host drops as soon as the searcher walks out of range.
2253    /// The deadline is the only thing that stops it unattended.
2254    alert: AlertState,
2255    alert_deadline_ms: Option<u64>,
2256    /// `PROP_BLE_BOND_COUNT` and `PROP_BLE_LINK`: how many hosts the
2257    /// Bluetooth transport has enrolled, and what it is doing with one
2258    /// right now.
2259    ///
2260    /// Live transport state, held here for the same reason the alert is:
2261    /// neither is device-domain configuration, so neither is saved and
2262    /// `CMD_RST` must not invent a value for it. A reset that reported
2263    /// zero bonds would be describing a device that still trusts every
2264    /// host it did before. The transport is authoritative for both and
2265    /// reports them through [`Session::set_ble_bond_count`] and
2266    /// [`Session::set_ble_link`].
2267    ble_bond_count: u8,
2268    ble_link: BleLinkState,
2269    /// `PROP_BLE_PAIRING`: whether a pairing window is open. Live like
2270    /// its two neighbors, and doubly so: the window closes on its own —
2271    /// a new bond, a timeout — and the transport reports every
2272    /// transition through [`Session::set_ble_pairing`].
2273    ble_pairing_open: bool,
2274    /// A `CMD_PROP_MULTI_GET` or `CMD_PROP_MULTI_SET` part-way through
2275    /// its entries. Present only between the arrival of that frame and
2276    /// the `CMD_PROP_ARE` answering it, which is why it belongs to no
2277    /// state class: it does not outlive the exchange that created it.
2278    multi: Option<MultiState>,
2279    /// Which binding the frame being served arrived over. Set by the
2280    /// entry point and held across deferred platform round trips, so a
2281    /// `respond_*` completing an admin exchange still answers by the
2282    /// admin binding's rules.
2283    binding: Binding,
2284    scratch: [u8; SCRATCH],
2285}
2286
2287/// Which binding a frame arrived over.
2288///
2289/// The command grammar and the property surface are the same either way;
2290/// what differs is who is asking. A local host is tethered to the device
2291/// and owns the session and host domains; a mesh administrator reaches
2292/// the device domain and nothing else (spec §Node Management —
2293/// Authorization).
2294#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
2295pub enum Binding {
2296    /// The attached host's transport: USB-CDC, UART, or bonded BLE.
2297    #[default]
2298    Local,
2299    /// A Node Management Request from a listed administrator, arriving
2300    /// over the mesh.
2301    Admin,
2302}
2303
2304/// Largest multi-property request the session accepts, and the most it
2305/// will ever accumulate into a `CMD_PROP_ARE`, both bounded by what one
2306/// transport frame carries.
2307pub const MULTI_MAX: usize = 300;
2308
2309/// A multi-property command being served entry by entry.
2310///
2311/// The request is kept verbatim rather than pre-decoded: the same bytes
2312/// serve `CMD_PROP_MULTI_GET`'s bare key list and `CMD_PROP_MULTI_SET`'s
2313/// length-prefixed entries, and holding them lets a value be handed
2314/// straight to the ordinary single-property paths without a copy.
2315struct MultiState {
2316    tid: u8,
2317    /// Whether entries are writes (`CMD_PROP_MULTI_SET`) rather than
2318    /// bare keys (`CMD_PROP_MULTI_GET`).
2319    writes: bool,
2320    request: HeaplessVec<u8, MULTI_MAX>,
2321    /// How far into `request` the next entry begins.
2322    offset: usize,
2323    /// The `CMD_PROP_ARE` accumulated so far, header included.
2324    reply: HeaplessVec<u8, MULTI_MAX>,
2325    /// How large the finished reply frame may be. The local bindings
2326    /// pass their transport ceiling; a binding whose responses travel in
2327    /// something smaller passes that instead, and the sequence stops
2328    /// where the smaller budget runs out.
2329    reply_budget: usize,
2330    /// Set when an entry reported an error, which ends a write sequence.
2331    failed: bool,
2332    /// Set when an entry's reply would not fit, which ends the sequence
2333    /// without executing it.
2334    truncated: bool,
2335}
2336
2337impl MultiState {
2338    fn new(tid: u8, writes: bool, payload: &[u8], reply_budget: usize) -> Option<Self> {
2339        let mut request = HeaplessVec::new();
2340        request.extend_from_slice(payload).ok()?;
2341        let mut reply = HeaplessVec::new();
2342        let header = Header::new(tid)?;
2343        reply.push(header.to_byte()).ok()?;
2344        reply.push(Cmd::PropAre as u8).ok()?;
2345        Some(Self {
2346            tid,
2347            writes,
2348            request,
2349            offset: 0,
2350            reply,
2351            reply_budget: reply_budget.min(MULTI_MAX),
2352            failed: false,
2353            truncated: false,
2354        })
2355    }
2356
2357    fn room(&self) -> usize {
2358        self.reply_budget.saturating_sub(self.reply.len())
2359    }
2360
2361    /// Append one entry, reporting whether it fit.
2362    fn push_entry(&mut self, key: u32, value: &[u8]) -> bool {
2363        let Some(needed) = frame::entry_len(key, value.len()) else {
2364            return false;
2365        };
2366        if needed > self.room() {
2367            return false;
2368        }
2369        let mut header = [0u8; pui::MAX_LEN * 2];
2370        let body = pui::encoded_len(key) + value.len();
2371        let mut written = pui::encode(body as u32, &mut header).unwrap_or(0);
2372        written += pui::encode(key, &mut header[written..]).unwrap_or(0);
2373        self.reply.extend_from_slice(&header[..written]).is_ok()
2374            && self.reply.extend_from_slice(value).is_ok()
2375    }
2376
2377    fn push_status(&mut self, status: Status) -> bool {
2378        let mut value = [0u8; pui::MAX_LEN];
2379        let len = pui::encode(status.0, &mut value).unwrap_or(0);
2380        self.push_entry(prop::LAST_STATUS, &value[..len])
2381    }
2382}
2383
2384impl<A: AesProvider, S: Sha256Provider, const TX: usize> Session<A, S, TX> {
2385    /// `boot_status` is the retained hardware reset cause, reported by
2386    /// the first `PROP_LAST_STATUS` get of the first session.
2387    pub fn new(config: SessionConfig, boot_status: Status, engine: CryptoEngine<A, S>) -> Self {
2388        debug_assert!(usize::from(config.mtu) <= MAX_MTU);
2389        debug_assert!(valid_device_name(config.default_device_name.as_bytes()));
2390        Self {
2391            config,
2392            engine,
2393            device: DeviceDomain::post_reset(&config),
2394            host: HostDomain::default(),
2395            session: SessionState::default(),
2396            attached: false,
2397            link_secure: false,
2398            saved: None,
2399            snapshot_rejected: false,
2400            dev_key: None,
2401            dev_key_persisted: None,
2402            last_status: boot_status,
2403            dev_domain_version: 0,
2404            alert: AlertState::None,
2405            alert_deadline_ms: None,
2406            ble_bond_count: 0,
2407            ble_link: BleLinkState::None,
2408            ble_pairing_open: false,
2409            multi: None,
2410            binding: Binding::Local,
2411            scratch: [0; SCRATCH],
2412        }
2413    }
2414
2415    /// The active radio settings.
2416    pub fn settings(&self) -> RadioSettings {
2417        self.device.settings
2418    }
2419
2420    /// Current UTF-8 `PROP_DEV_NAME` value.
2421    pub fn device_name(&self) -> &str {
2422        core::str::from_utf8(&self.device.name[..self.device.name_len])
2423            .expect("validated device name")
2424    }
2425
2426    /// Payload of the transmit requested by [`Effect::StartTransmit`].
2427    pub fn tx_data(&self) -> &[u8] {
2428        self.session
2429            .pending
2430            .front()
2431            .map(|pending| pending.data.as_slice())
2432            .unwrap_or_default()
2433    }
2434
2435    /// Power selection for the pending transmit.
2436    pub fn tx_power(&self) -> TxPower {
2437        self.session
2438            .pending
2439            .front()
2440            .map(|pending| pending.power)
2441            .unwrap_or(TxPower::Default)
2442    }
2443
2444    /// The board's maximum transmit power, as configured. The concrete
2445    /// dBm value behind [`TxPower::Max`] when a transmit is staged.
2446    pub fn max_tx_power_dbm(&self) -> i8 {
2447        self.config.max_tx_power_dbm
2448    }
2449
2450    /// Whether the pending transmit requested `TX_FLAG_NOCCA` — skip the
2451    /// pre-transmit channel-activity check.
2452    pub fn tx_nocca(&self) -> bool {
2453        self.session
2454            .pending
2455            .front()
2456            .map(|pending| pending.nocca)
2457            .unwrap_or(false)
2458    }
2459
2460    /// Whether a transmit is awaiting [`Session::on_tx_result`].
2461    pub fn has_pending_tx(&self) -> bool {
2462        !self.session.pending.is_empty()
2463    }
2464
2465    /// Number of received frames currently waiting for the host.
2466    pub fn queued_frame_count(&self) -> usize {
2467        self.host.queue.len
2468    }
2469
2470    /// Monotonic generation of the device-domain node tables and the
2471    /// live device key. The firmware caches this and re-syncs the live
2472    /// device node's MAC whenever it changes (device-node plan increment
2473    /// 3).
2474    ///
2475    /// Identity provisioning bumps it too, even though a newly
2476    /// provisioned key only takes effect at the next boot
2477    /// (live-state-until-reboot, as with `CMD_CLEAR`): the *old* key
2478    /// stops being one the device claims immediately, and the node has
2479    /// to be told so it can stop originating traffic under it.
2480    pub fn dev_domain_version(&self) -> u32 {
2481        self.dev_domain_version
2482    }
2483
2484    /// Bump [`Session::dev_domain_version`]. Call after any change to
2485    /// the device channel-key or peer tables.
2486    fn bump_dev_domain(&mut self) {
2487        self.dev_domain_version = self.dev_domain_version.wrapping_add(1);
2488    }
2489
2490    /// The device identity's provisioned channel keys (raw symmetric
2491    /// keys, not the derived identifiers). The firmware joins each into
2492    /// the device node so it processes multicast on that channel.
2493    pub fn dev_channel_keys(&self) -> impl Iterator<Item = [u8; items::CHANNEL_KEY_LEN]> + '_ {
2494        self.device.channel_keys.iter().map(|entry| entry.key)
2495    }
2496
2497    /// The device identity's provisioned peer public keys. The firmware
2498    /// registers each with the device node's MAC.
2499    pub fn dev_peers(&self) -> impl Iterator<Item = [u8; items::PUBLIC_KEY_LEN]> + '_ {
2500        self.device.peers.iter().copied()
2501    }
2502
2503    /// The nodes authorized to manage this device over the mesh. The
2504    /// firmware mirrors these to whatever answers Node Management
2505    /// Requests, which admits an exchange only from a key in this set.
2506    pub fn dev_admins(&self) -> impl Iterator<Item = [u8; items::PUBLIC_KEY_LEN]> + '_ {
2507        self.device.admins.iter().copied()
2508    }
2509
2510    /// `PROP_MAC_REPEATER_ENABLED`: whether the device node should
2511    /// autonomously forward overheard routable frames and advertise
2512    /// `NodeRole::Repeater`. Part of the device domain, so it changes
2513    /// [`Session::dev_domain_version`] and the firmware reconciles it
2514    /// against the live MAC on the next sync.
2515    pub fn repeater_enabled(&self) -> bool {
2516        self.device.repeater_enabled
2517    }
2518
2519    /// `PROP_MAC_REPEATER_REGIONS`, as the forwarding filter needs it: the
2520    /// 2-octet code derived from each configured region. Empty means no
2521    /// restriction.
2522    pub fn repeater_region_codes(&self) -> impl Iterator<Item = [u8; REGION_CODE_LEN]> + '_ {
2523        self.device.repeater_regions.iter().map(|entry| entry.code)
2524    }
2525
2526    /// `PROP_MAC_REPEATER_REGIONS`, as the identity advertises it: the
2527    /// string form of each configured region, in the order it was written.
2528    pub fn repeater_region_names(&self) -> impl Iterator<Item = &str> + '_ {
2529        self.device
2530            .repeater_regions
2531            .iter()
2532            // Validated as UTF-8 on the way in, so this cannot fail.
2533            .filter_map(|entry| core::str::from_utf8(entry.text()).ok())
2534    }
2535
2536    /// `PROP_MAC_REPEATER_DEFAULT_REGION`: the code inserted into an
2537    /// untagged flood packet, or `None` to never tag.
2538    pub fn repeater_default_region(&self) -> Option<[u8; REGION_CODE_LEN]> {
2539        self.device.repeater_default_region
2540    }
2541
2542    /// `PROP_MAC_REPEATER_MIN_RSSI`: minimum received RSSI in dBm for
2543    /// flood forwarding, or `None` for no threshold.
2544    pub fn repeater_min_rssi(&self) -> Option<i16> {
2545        self.device.repeater_min_rssi
2546    }
2547
2548    /// `PROP_MAC_REPEATER_MIN_SNR`: minimum received SNR in whole dB for
2549    /// flood forwarding, or `None` for no threshold.
2550    pub fn repeater_min_snr(&self) -> Option<i8> {
2551        self.device.repeater_min_snr
2552    }
2553
2554    /// The live `PROP_DEV_KEY` value. `None` once a factory reset
2555    /// (`CMD_CLEAR` + `CMD_RST`) completes — the firmware uses this
2556    /// edge to make a running device node dormant.
2557    pub fn dev_key(&self) -> Option<&[u8; items::PUBLIC_KEY_LEN]> {
2558        self.dev_key.as_ref()
2559    }
2560
2561    /// Reset all protocol state to post-reset values, announce the
2562    /// reset with the given reason, and return the radio effect
2563    /// applying the post-reset radio configuration.
2564    ///
2565    /// Used for `CMD_RST` (with [`Status::RESET_SOFTWARE`]). With a
2566    /// saved snapshot the post-reset value of every saved device-domain
2567    /// property is its saved value — including the PHY enable state; the
2568    /// documented defaults apply only when nothing is saved. The host
2569    /// domain always returns to its documented defaults: it is never
2570    /// saved. Queue contents and replay baselines are discarded either
2571    /// way.
2572    pub fn reset(&mut self, reason: Status, emit: &mut impl FnMut(&[u8])) -> Effect {
2573        self.device = DeviceDomain::post_reset(&self.config);
2574        // The device identity's post-reset value is the persisted one:
2575        // normally unchanged, gone after CMD_CLEAR (completing a
2576        // factory reset).
2577        self.dev_key = self.dev_key_persisted;
2578        self.host.reset(None);
2579        if self.saved.is_some() {
2580            self.apply_saved_device();
2581        }
2582        self.session = SessionState::default();
2583        // The device tables were rebuilt from post-reset (and possibly
2584        // the saved snapshot); the node must re-sync.
2585        self.bump_dev_domain();
2586        self.announce_status(reason, emit);
2587        self.apply_radio()
2588    }
2589
2590    /// Build the [`Effect::ApplyRadio`] for the current settings,
2591    /// mirroring the modulation into the shared duty ledger so every
2592    /// radio client prices airtime against what is actually on the air.
2593    fn apply_radio(&self) -> Effect {
2594        let settings = self.device.settings;
2595        self.config
2596            .duty
2597            .set_phy(settings.sf, settings.bw_hz, settings.cr_denom);
2598        Effect::ApplyRadio(settings)
2599    }
2600
2601    /// Apply the saved device-domain configuration to the live domain.
2602    /// Duty accounting is dynamic state, not configuration: the caller
2603    /// decides whether it survives (restore) or restarts (reset, via
2604    /// `DeviceDomain::post_reset` beforehand).
2605    ///
2606    /// Properties land in [`SAVED_SCHEMA`] phase order, which is what
2607    /// keeps `PROP_PHY_ENABLED` from bringing the radio up before the
2608    /// PHY parameters it depends on are in place. Ordering is schema
2609    /// data, not the order of the statements below.
2610    fn apply_saved_device(&mut self) {
2611        let saved = self.saved.as_ref().expect("caller checked saved");
2612        for phase in ApplyPhase::ORDER {
2613            for entry in SAVED_SCHEMA.iter().filter(|entry| entry.phase == phase) {
2614                match u32::from(entry.number) {
2615                    // The PHY comes up only if this snapshot describes
2616                    // the node the device currently is. Restoring a
2617                    // repeater's saved domain onto replacement hardware
2618                    // — before its identity has been installed —
2619                    // otherwise puts it on the air advertising as the
2620                    // repeater under an auto-generated throwaway key.
2621                    // A snapshot that does not record its identity is
2622                    // treated as matching, since it cannot be checked.
2623                    prop::PHY_ENABLED => {
2624                        let identity_matches =
2625                            saved.dev_key.is_none_or(|key| Some(key) == self.dev_key);
2626                        self.device.settings.enabled = saved.settings.enabled && identity_matches;
2627                    }
2628                    // Provenance only: a restore never installs an
2629                    // identity, it only refuses to impersonate one.
2630                    prop::DEV_KEY => {}
2631                    prop::PHY_FREQ => self.device.settings.freq_khz = saved.settings.freq_khz,
2632                    prop::PHY_TX_POWER => {
2633                        self.device.settings.tx_power_dbm = saved.settings.tx_power_dbm
2634                    }
2635                    prop::PHY_LORA_BW => self.device.settings.bw_hz = saved.settings.bw_hz,
2636                    prop::PHY_LORA_SF => self.device.settings.sf = saved.settings.sf,
2637                    prop::PHY_LORA_CR => self.device.settings.cr_denom = saved.settings.cr_denom,
2638                    prop::DEV_CHANNEL_KEYS => self.device.channel_keys = saved.dev_channel_keys,
2639                    prop::DEV_PEERS => self.device.peers = saved.dev_peers,
2640                    prop::DEV_ADMINS => self.device.admins = saved.dev_admins,
2641                    prop::DEV_NAME => {
2642                        self.device.name = saved.name;
2643                        self.device.name_len = saved.name_len;
2644                    }
2645                    prop::MAC_REPEATER_ENABLED => {
2646                        self.device.repeater_enabled = saved.repeater_enabled
2647                    }
2648                    prop::IDENT_ROLE => self.device.ident_role = saved.ident_role,
2649                    prop::IDENT_MOBILE => self.device.ident_mobile = saved.ident_mobile,
2650                    prop::MAC_REPEATER_REGIONS => {
2651                        self.device.repeater_regions = saved.repeater_regions
2652                    }
2653                    prop::MAC_REPEATER_DEFAULT_REGION => {
2654                        self.device.repeater_default_region = saved.repeater_default_region
2655                    }
2656                    prop::MAC_REPEATER_MIN_RSSI => {
2657                        self.device.repeater_min_rssi = saved.repeater_min_rssi
2658                    }
2659                    prop::MAC_REPEATER_MIN_SNR => {
2660                        self.device.repeater_min_snr = saved.repeater_min_snr
2661                    }
2662                    prop::DEV_DISCOVERABLE => self.device.dev_discoverable = saved.dev_discoverable,
2663                    prop::ADVERT_INTERVAL => {
2664                        self.device.advert_interval_s = saved.advert_interval_s
2665                    }
2666                    prop::BEACON_INTERVAL => {
2667                        self.device.beacon_interval_s = saved.beacon_interval_s
2668                    }
2669                    prop::STARTUP_BEACON => self.device.startup_beacon = saved.startup_beacon,
2670                    // A position the operator placed is not a claim about
2671                    // hardware, so it survives a restore under any
2672                    // identity — the same reasoning as the receiver
2673                    // switch below.
2674                    prop::IDENT_LOCATION => {
2675                        self.device.ident_location = saved.ident_location.clone()
2676                    }
2677                    prop::IDENT_ALTITUDE => self.device.ident_altitude_m = saved.ident_altitude_m,
2678                    // The receiver comes back up exactly as it was left.
2679                    // Unlike the PHY this needs no identity check: where
2680                    // the device is is a fact about the hardware, not a
2681                    // claim made under an identity.
2682                    prop::GNSS_ENABLED => self.device.gnss_enabled = saved.gnss_enabled,
2683                    prop::PHY_DUTY_LIMIT => self.config.duty.set_limit(saved.duty_limit),
2684                    prop::TZ_OFFSET => self.device.tz_offset_min = saved.tz_offset_min,
2685                    prop::GNSS_IDENT_UPDATE => {
2686                        self.device.gnss_ident_update = saved.gnss_ident_update
2687                    }
2688                    prop::GNSS_IDENT_PRECISION => {
2689                        self.device.gnss_ident_precision = saved.gnss_ident_precision
2690                    }
2691                    prop::GNSS_TIME_TRUST => self.device.gnss_time_trust = saved.gnss_time_trust,
2692                    prop::BLE_ENABLED => self.device.ble_enabled = saved.ble_enabled,
2693                    _ => unreachable!("SAVED_SCHEMA row without an apply arm"),
2694                }
2695            }
2696        }
2697        self.bump_dev_domain();
2698    }
2699
2700    /// A host attached. Resets session state only (spec §Attach): the
2701    /// device and host domains — PHY configuration and enable state,
2702    /// device name, duty accounting, provisioning, and the inbound
2703    /// queue — are untouched, and nothing is emitted; the attach itself
2704    /// produces no notification. Accepted frames are delivered live
2705    /// from here on; queued frames wait for `CMD_QUEUE_DRAIN`.
2706    ///
2707    /// `link_secure` states whether this transport meets its security
2708    /// binding for key provisioning (spec §Provisioning Security):
2709    /// physical possession for serial transports, an encrypted bonded
2710    /// LESC link for BLE. Key-bearing writes are refused while false.
2711    pub fn attach(&mut self, link_secure: bool) {
2712        self.session = SessionState::default();
2713        self.attached = true;
2714        self.link_secure = link_secure;
2715    }
2716
2717    /// The host detached. Session state is discarded; the device and
2718    /// host domains keep operating detached: accepted frames are queued
2719    /// instead of delivered (delegated acknowledgement arrives with
2720    /// `CAP_HOST_AUTO_ACK`).
2721    pub fn detach(&mut self) {
2722        self.session = SessionState::default();
2723        self.attached = false;
2724        self.link_secure = false;
2725    }
2726
2727    /// Handle one decoded ULCP frame from the host.
2728    pub fn handle_frame(
2729        &mut self,
2730        bytes: &[u8],
2731        now_ms: u64,
2732        emit: &mut impl FnMut(&[u8]),
2733    ) -> Option<Effect> {
2734        self.handle_frame_budgeted(bytes, now_ms, MULTI_MAX, emit)
2735    }
2736
2737    /// Handle one decoded ULCP frame whose response must fit
2738    /// `reply_budget` octets.
2739    ///
2740    /// Only the multi-property commands consult the budget, and only to
2741    /// decide where a sequence stops; every other response is bounded by
2742    /// its own property's value.
2743    pub fn handle_frame_budgeted(
2744        &mut self,
2745        bytes: &[u8],
2746        now_ms: u64,
2747        reply_budget: usize,
2748        emit: &mut impl FnMut(&[u8]),
2749    ) -> Option<Effect> {
2750        self.binding = Binding::Local;
2751        self.dispatch_frame(bytes, now_ms, reply_budget, emit)
2752    }
2753
2754    /// Handle one ULCP frame carried in a Node Management Request from a
2755    /// listed administrator, whose response must fit `reply_budget`
2756    /// octets.
2757    ///
2758    /// The caller has already established what this binding requires and
2759    /// the session cannot see: that the packet arrived by unicast or
2760    /// blind unicast, that its source is authenticated, and that the
2761    /// source key is listed in `PROP_DEV_ADMINS`. Everything else the
2762    /// binding changes is here — the property surface an administrator
2763    /// reaches, the commands it may not use, and the fact that responses
2764    /// are correlated by token rather than by TID, so a frame whose TID
2765    /// is zero (as this binding requires) is still answered.
2766    ///
2767    /// The binding stays in effect across the deferred round trips of
2768    /// this exchange, which is what lets the ordinary `respond_*` methods
2769    /// complete it. Call [`Session::end_admin_exchange`] when the
2770    /// exchange is over.
2771    pub fn handle_admin_frame(
2772        &mut self,
2773        bytes: &[u8],
2774        now_ms: u64,
2775        reply_budget: usize,
2776        emit: &mut impl FnMut(&[u8]),
2777    ) -> Option<Effect> {
2778        self.binding = Binding::Admin;
2779        // A local binding ignores a frame it cannot parse — the host
2780        // will notice its own transport went wrong. An administrator is
2781        // owed an answer, because silence over the mesh is
2782        // indistinguishable from a lost packet and it would retransmit
2783        // the same unparseable frame until it gave up.
2784        if Frame::parse(bytes).is_err() {
2785            self.send_status(TID_UNSOLICITED, Status::PARSE_ERROR, emit);
2786            return None;
2787        }
2788        self.dispatch_frame(bytes, now_ms, reply_budget, emit)
2789    }
2790
2791    /// Return to serving the local binding after an administrative
2792    /// exchange, deferred round trips included.
2793    pub fn end_admin_exchange(&mut self) {
2794        self.binding = Binding::Local;
2795    }
2796
2797    /// Whether the frame in flight arrived over the mesh administrative
2798    /// binding.
2799    fn is_admin(&self) -> bool {
2800        self.binding == Binding::Admin
2801    }
2802
2803    fn dispatch_frame(
2804        &mut self,
2805        bytes: &[u8],
2806        now_ms: u64,
2807        reply_budget: usize,
2808        emit: &mut impl FnMut(&[u8]),
2809    ) -> Option<Effect> {
2810        // Malformed frames (bad flag, reserved bits, command MSB) are
2811        // ignored per the spec.
2812        let received = Frame::parse(bytes).ok()?;
2813        let tid = received.header.tid();
2814        match received.command() {
2815            Some(Cmd::Nop) => {
2816                self.complete(tid, Status::OK, emit);
2817                None
2818            }
2819            Some(Cmd::Reset) => Some(self.reset(Status::RESET_SOFTWARE, emit)),
2820            Some(Cmd::PropGet) => match PropPayload::parse(received.payload) {
2821                Ok(payload) => self.prop_get(tid, payload.key, now_ms, emit),
2822                Err(_) => {
2823                    self.complete(tid, Status::PARSE_ERROR, emit);
2824                    None
2825                }
2826            },
2827            Some(Cmd::PropSet) => match PropPayload::parse(received.payload) {
2828                Ok(payload) => self.prop_set(tid, payload.key, payload.value, now_ms, emit),
2829                Err(_) => {
2830                    self.complete(tid, Status::PARSE_ERROR, emit);
2831                    None
2832                }
2833            },
2834            // The raw PHY stream and the host-facing receive queue are
2835            // the tethered host's, and an administrator is not one.
2836            // Answered as unrecognized commands rather than refused ones
2837            // (spec §Node Management — Authorization).
2838            Some(Cmd::StrSend | Cmd::QueueDrain) if self.is_admin() => {
2839                self.complete(tid, Status::INVALID_COMMAND, emit);
2840                None
2841            }
2842            Some(Cmd::StrSend) => match StreamPayload::parse(received.payload) {
2843                Ok(payload) => self.str_send(tid, &payload, now_ms, emit),
2844                Err(_) => {
2845                    self.complete(tid, Status::PARSE_ERROR, emit);
2846                    None
2847                }
2848            },
2849            Some(Cmd::PropInsert) => {
2850                match PropPayload::parse(received.payload) {
2851                    Ok(payload) => self.prop_insert(tid, payload.key, payload.value, emit),
2852                    Err(_) => self.complete(tid, Status::PARSE_ERROR, emit),
2853                }
2854                None
2855            }
2856            Some(Cmd::PropRemove) => {
2857                match PropPayload::parse(received.payload) {
2858                    Ok(payload) => self.prop_remove(tid, payload.key, payload.value, emit),
2859                    Err(_) => self.complete(tid, Status::PARSE_ERROR, emit),
2860                }
2861                None
2862            }
2863            // Deliver queued inbound frames. The payload MUST be
2864            // ignored. The drain covers exactly the frames queued now;
2865            // an empty queue succeeds immediately.
2866            Some(Cmd::QueueDrain) => {
2867                if self.session.drain.is_some() {
2868                    self.complete(tid, Status::BUSY, emit);
2869                    return None;
2870                }
2871                if self.host.queue.len == 0 {
2872                    self.complete(tid, Status::OK, emit);
2873                    return None;
2874                }
2875                self.session.drain = Some(DrainState {
2876                    tid,
2877                    remaining: self.host.queue.len,
2878                });
2879                Some(Effect::DrainQueue)
2880            }
2881            // Atomically persist the current device domain. The payload
2882            // MUST be ignored; success is reported only after the
2883            // durable write commits (respond_save).
2884            Some(Cmd::Save) => Some(Effect::SaveSnapshot { tid }),
2885            // Revert device-domain configuration to the saved snapshot,
2886            // reported in the spec's reset form: session state resets
2887            // and an unsolicited STATUS_RESET_RESTORED announces
2888            // completion (the TID is ignored, as with CMD_RST).
2889            //
2890            // The host domain is untouched — it is not in the snapshot,
2891            // so there is nothing to revert it to and no host-key
2892            // special case to apply. Queue contents and replay baselines
2893            // therefore survive a restore unconditionally.
2894            Some(Cmd::Restore) => {
2895                if self.saved.is_none() {
2896                    self.complete(tid, Status::INVALID_STATE, emit);
2897                    return None;
2898                }
2899                self.apply_saved_device();
2900                self.session = SessionState::default();
2901                self.announce_status(Status::RESET_RESTORED, emit);
2902                Some(self.apply_radio())
2903            }
2904            // Erase all persisted provisioning. Live state, BLE bonds,
2905            // and the pairing PIN are unaffected; a subsequent CMD_RST
2906            // completes a factory reset. Base-protocol: succeeds even
2907            // with nothing saved (the erase is idempotent).
2908            Some(Cmd::Clear) => Some(Effect::ClearSaved { tid }),
2909            // Erase EVERY piece of mutable state — saved provisioning,
2910            // device identity, BLE bonds, pairing PIN, and any other
2911            // persisted journal — then reboot. Unlike CMD_CLEAR this is
2912            // not confined to the durable provisioning copy and does not
2913            // reply: the platform wipes storage and resets, so the link
2914            // drops. The TID is irrelevant (no response is sent).
2915            Some(Cmd::FactoryReset) => Some(Effect::FactoryReset),
2916            // Restart the hardware, keeping everything persisted. Like
2917            // CMD_FACTORY_RESET this does not reply — the reboot drops
2918            // the link, and the TID is irrelevant. A board that cannot
2919            // reset itself says so instead, which is the one answer this
2920            // command ever produces.
2921            Some(Cmd::Reboot) => {
2922                if !self.config.reboot {
2923                    self.complete(tid, Status::UNIMPLEMENTED, emit);
2924                    return None;
2925                }
2926                Some(Effect::Reboot)
2927            }
2928            // Bond management. Defers: the answer reports what the
2929            // platform actually did, and it is not safe to acknowledge
2930            // before the deletion is durable — a clear that replied first
2931            // and then failed would leave a host believing it had been
2932            // forgotten.
2933            Some(Cmd::BleClearBonds) => {
2934                if !self.config.ble_pairing {
2935                    self.complete(tid, Status::UNIMPLEMENTED, emit);
2936                    return None;
2937                }
2938                Some(Effect::BleClearBonds { tid })
2939            }
2940            // Several properties in one exchange. Both commands are
2941            // served entry by entry through the ordinary single-property
2942            // paths, so a value that needs a platform round trip defers
2943            // exactly as a lone get or set would and the multi-property
2944            // reply waits for it.
2945            Some(Cmd::PropMultiGet) => {
2946                self.begin_multi(tid, false, received.payload, now_ms, reply_budget, emit)
2947            }
2948            Some(Cmd::PropMultiSet) => {
2949                self.begin_multi(tid, true, received.payload, now_ms, reply_budget, emit)
2950            }
2951            // device-to-host commands arriving from the host.
2952            Some(
2953                Cmd::PropIs | Cmd::StrRecv | Cmd::PropInserted | Cmd::PropRemoved | Cmd::PropAre,
2954            ) => {
2955                self.complete(tid, Status::INVALID_COMMAND, emit);
2956                None
2957            }
2958            None => {
2959                self.complete(tid, Status::INVALID_COMMAND, emit);
2960                None
2961            }
2962        }
2963    }
2964
2965    /// Report a frame received on air at `now_ms`. While a host is
2966    /// attached, accepted frames are emitted live as `CMD_STR_RECV`
2967    /// (promiscuous mode bypasses filtering for live delivery only);
2968    /// while detached, accepted frames are placed in the inbound queue,
2969    /// authenticated duplicates coalesce, and a qualifying frame may
2970    /// produce a delegated-acknowledgement transmit effect. Ignored
2971    /// while the PHY is disabled or the frame exceeds the MTU (an
2972    /// unstorable frame is never acknowledged).
2973    pub fn on_radio_rx(
2974        &mut self,
2975        data: &[u8],
2976        info: &RadioRxInfo,
2977        now_ms: u64,
2978        emit: &mut impl FnMut(&[u8]),
2979    ) -> Option<Effect> {
2980        if !self.device.settings.enabled || data.len() > usize::from(self.config.mtu) {
2981            return None;
2982        }
2983        if !self.attached {
2984            if !self.host.accepts_frame(data) {
2985                return None;
2986            }
2987            return match self.evaluate_detached_rx(data, now_ms) {
2988                SecureRx::Duplicate { ack, identity } => {
2989                    // Coalesced with the existing queue entry. A
2990                    // confirmed re-ack marks the original entry, which
2991                    // may still be queued unacked from a failed or
2992                    // refused earlier attempt.
2993                    let original =
2994                        identity.and_then(|identity| self.host.queue.seq_for_identity(&identity));
2995                    ack.and_then(|plan| self.stage_ack(plan, original, now_ms))
2996                }
2997                verdict => {
2998                    let (ack, identity) = match verdict {
2999                        SecureRx::New { ack, identity } => (ack, identity),
3000                        _ => (None, None),
3001                    };
3002                    // Entries start unacknowledged: RX_FLAG_ACKED is
3003                    // earned only when the ack transmission actually
3004                    // completes (on_tx_result). A refused or failed ack
3005                    // leaves the frame queued unacked and the sender's
3006                    // retransmission hits the re-ack window later.
3007                    let seq = self.host.queue.push(data, info, now_ms, identity);
3008                    ack.and_then(|plan| self.stage_ack(plan, Some(seq), now_ms))
3009                }
3010            };
3011        }
3012        if !self.session.promiscuous && !self.host.accepts_live_frame(data) {
3013            return None;
3014        }
3015        let rx = RxMeta {
3016            rssi_dbm: info.rssi_dbm,
3017            lqi: info.lqi,
3018            snr_cb: info.snr_cb,
3019        };
3020        // The flags byte costs five bytes a frame, so a live delivery
3021        // only grows to the buffered layout when it has something to say
3022        // — which today means the frame is one the device sent itself.
3023        let mut rx_meta = [0u8; BufferedRxMeta::WIRE_LEN];
3024        let meta_len = if info.self_tx {
3025            BufferedRxMeta {
3026                rx,
3027                flags: RX_FLAG_SELF_TX,
3028                age_s: 0,
3029            }
3030            .encode(&mut rx_meta)
3031        } else {
3032            rx.encode(&mut rx_meta)
3033        }
3034        .expect("buffer sized with WIRE_LEN");
3035        if let Ok(len) = frame::str_recv(
3036            &mut self.scratch,
3037            stream::PHY_RAW,
3038            data,
3039            &rx_meta[..meta_len],
3040        ) {
3041            emit(&self.scratch[..len]);
3042        }
3043        None
3044    }
3045
3046    /// Authenticate a detached received frame against the provisioned
3047    /// host keys and update the source peer's replay window. Crypto
3048    /// runs on a scratch copy: the queue always holds the original wire
3049    /// bytes, exactly as the host would have received them live.
3050    fn evaluate_detached_rx(&mut self, data: &[u8], now_ms: u64) -> SecureRx {
3051        let Ok(header) = PacketHeader::parse(data) else {
3052            return SecureRx::Plain;
3053        };
3054        let Some(host_key) = &self.host.key else {
3055            return SecureRx::Plain;
3056        };
3057        let host_hint = NodeHint([host_key[0], host_key[1], host_key[2]]);
3058        let packet_type = header.fcf.packet_type();
3059        let wants_ack = packet_type.ack_requested();
3060
3061        let scratch = &mut self.scratch[..data.len()];
3062        scratch.copy_from_slice(data);
3063
3064        // Establish the frame's keys, destination, and source peer.
3065        let (keys, peer_index) = match packet_type {
3066            PacketType::Unicast | PacketType::UnicastAckReq => {
3067                if header.dst != Some(host_hint) {
3068                    return SecureRx::Plain;
3069                }
3070                let Some(index) = self.host.peer_keys.resolve_source(&header.source, data) else {
3071                    return SecureRx::Plain;
3072                };
3073                let entry = &self.host.peer_keys.entries[index]
3074                    .as_ref()
3075                    .expect("resolved index is populated")
3076                    .entry;
3077                (
3078                    PairwiseKeys {
3079                        k_enc: entry.k_enc,
3080                        k_mic: entry.k_mic,
3081                    },
3082                    index,
3083                )
3084            }
3085            PacketType::BlindUnicast | PacketType::BlindUnicastAckReq => {
3086                // BUAR/BUNI require the channel key both to reveal the
3087                // concealed addressing and to form the combined blind
3088                // payload keys.
3089                let Some(channel) = header.channel else {
3090                    return SecureRx::Plain;
3091                };
3092                let Some(channel_key) = self
3093                    .host
3094                    .channel_keys
3095                    .iter()
3096                    .find(|candidate| candidate.id == channel.0)
3097                    .map(|candidate| candidate.key)
3098                else {
3099                    return SecureRx::Plain;
3100                };
3101                let channel_keys = self.engine.derive_channel_keys(&ChannelKey(channel_key));
3102                let Ok((dst, source)) =
3103                    self.engine
3104                        .decrypt_blind_addr(scratch, &header, &channel_keys)
3105                else {
3106                    return SecureRx::Plain;
3107                };
3108                if dst != host_hint {
3109                    return SecureRx::Plain;
3110                }
3111                // The decrypted address block lives in the scratch copy.
3112                let Some(index) = self.host.peer_keys.resolve_source(&source, scratch) else {
3113                    return SecureRx::Plain;
3114                };
3115                let entry = &self.host.peer_keys.entries[index]
3116                    .as_ref()
3117                    .expect("resolved index is populated")
3118                    .entry;
3119                let pairwise = PairwiseKeys {
3120                    k_enc: entry.k_enc,
3121                    k_mic: entry.k_mic,
3122                };
3123                (
3124                    self.engine.derive_blind_keys(&pairwise, &channel_keys),
3125                    index,
3126                )
3127            }
3128            // Multicast the device holds the channel key for is
3129            // authenticated for queue-local duplicate coalescing only:
3130            // no per-sender counter state is retained and no ack is
3131            // ever delegated (multicast never requests one). Broadcast
3132            // and MAC acks carry no counter at all.
3133            PacketType::Multicast => {
3134                let Some(channel) = header.channel else {
3135                    return SecureRx::Plain;
3136                };
3137                let Some(channel_key) = self
3138                    .host
3139                    .channel_keys
3140                    .iter()
3141                    .find(|candidate| candidate.id == channel.0)
3142                    .map(|candidate| candidate.key)
3143                else {
3144                    return SecureRx::Plain;
3145                };
3146                let derived = self.engine.derive_channel_keys(&ChannelKey(channel_key));
3147                let channel_pairwise = PairwiseKeys {
3148                    k_enc: derived.k_enc,
3149                    k_mic: derived.k_mic,
3150                };
3151                if self
3152                    .engine
3153                    .open_packet(scratch, &header, &channel_pairwise)
3154                    .is_err()
3155                {
3156                    return SecureRx::Plain;
3157                }
3158                let Some(sec_info) = header.sec_info else {
3159                    return SecureRx::Plain;
3160                };
3161                let identity =
3162                    RxIdentity::new(sec_info.frame_counter, &data[header.mic_range.clone()]);
3163                let Some(identity) = identity else {
3164                    return SecureRx::Plain;
3165                };
3166                // A Route Retry form preserves the MIC and counter, so
3167                // it matches the original entry while that entry is
3168                // still queued; once drained or evicted, no replay
3169                // state is retained for multicast.
3170                return if self.host.queue.seq_for_identity(&identity).is_some() {
3171                    SecureRx::Duplicate {
3172                        ack: None,
3173                        identity: Some(identity),
3174                    }
3175                } else {
3176                    SecureRx::New {
3177                        ack: None,
3178                        identity: Some(identity),
3179                    }
3180                };
3181            }
3182            _ => return SecureRx::Plain,
3183        };
3184
3185        // Authenticate (and decrypt, in the scratch copy).
3186        let Ok(body_range) = self.engine.open_packet(scratch, &header, &keys) else {
3187            return SecureRx::Plain;
3188        };
3189        let Some(sec_info) = header.sec_info else {
3190            return SecureRx::Plain;
3191        };
3192        let counter = sec_info.frame_counter;
3193        let mic = &data[header.mic_range.clone()];
3194
3195        // The ack tag covers the plaintext body: recompute the full
3196        // S2V tag over the decrypted scratch copy (spec §Ack Tag
3197        // Construction).
3198        let plan = wants_ack.then(|| {
3199            let full_mac = self.engine.s2v_tag(
3200                &keys.k_mic,
3201                |cmac| umsh_core::feed_aad(&header, scratch, |chunk| cmac.update(chunk)),
3202                &scratch[body_range.clone()],
3203            );
3204            AckPlan {
3205                trailer: self.engine.compute_ack_trailer(&full_mac, &keys.k_enc),
3206                // Flooded traffic gets a flood-return ack seeded from
3207                // the received frame's accumulated hop count, exactly
3208                // as the MAC routes acks from its learned flood routes.
3209                // A duplicate's plan uses the retransmission's own
3210                // routing state.
3211                flood_hops: header.flood_hops.map(|hops| hops.accumulated()),
3212            }
3213        });
3214        let identity = RxIdentity::new(counter, mic);
3215
3216        let window = &mut self.host.peer_keys.entries[peer_index]
3217            .as_mut()
3218            .expect("resolved index is populated")
3219            .window;
3220        match window.check(counter, mic, now_ms) {
3221            ReplayVerdict::Accept => {
3222                window.accept(counter, mic, now_ms);
3223                SecureRx::New {
3224                    ack: plan,
3225                    identity,
3226                }
3227            }
3228            ReplayVerdict::Replay => {
3229                // Same logical packet (Route Retry forms included: same
3230                // MIC and counter): coalesce, and re-ack only within
3231                // the idempotent duplicate-acknowledgement window — and
3232                // at most once per holdoff, so flood copies of one
3233                // transmission share a single ack. The holdoff stands in
3234                // for the MAC's forwarding-confirmation window,
3235                // `2 × T_frame + W_max + W_jitter + D_ack` = 2.85 × T_frame.
3236                let holdoff_ms = u64::from(lora_airtime_ms(
3237                    self.device.settings.sf,
3238                    self.device.settings.bw_hz,
3239                    self.device.settings.cr_denom,
3240                    data.len(),
3241                )) * 285
3242                    / 100;
3243                let ack = window
3244                    .note_acknowledgeable_duplicate(counter, mic, now_ms, holdoff_ms)
3245                    .then_some(())
3246                    .and(plan);
3247                SecureRx::Duplicate { ack, identity }
3248            }
3249            // A suspected replay outside the window is not identified
3250            // as a previously accepted frame; it is queued unacked and
3251            // never acknowledged (spec: MUST NOT ack farther behind).
3252            ReplayVerdict::OutOfWindow | ReplayVerdict::Stale => SecureRx::Plain,
3253        }
3254    }
3255
3256    /// Transmit a delegated MAC acknowledgement through the ordinary
3257    /// serialized radio path, subject to `PROP_HOST_AUTO_ACK`, the
3258    /// single-transmit radio path, and the duty limiter. Returns the
3259    /// transmit effect, or `None` when any gate refuses (the frame then
3260    /// simply remains unacknowledged). `ack_for` names the queue entry
3261    /// that earns `RX_FLAG_ACKED` when the transmission completes.
3262    fn stage_ack(&mut self, plan: AckPlan, ack_for: Option<u16>, now_ms: u64) -> Option<Effect> {
3263        if !self.host.auto_ack || !self.session.pending.is_empty() {
3264            return None;
3265        }
3266        let mut buf = [0u8; 24];
3267        let mut builder = PacketBuilder::new(&mut buf).mac_ack(plan.trailer);
3268        if let Some(hops) = plan.flood_hops {
3269            // Mirror the MAC's flood-return acks: seed the remaining
3270            // hops from the acknowledged frame's accumulated count,
3271            // clamped to a valid non-zero radius.
3272            builder = builder.flood_hops(hops.clamp(1, 15));
3273        }
3274        let frame_len = builder.build().ok()?.len();
3275        let airtime_ms = lora_airtime_ms(
3276            self.device.settings.sf,
3277            self.device.settings.bw_hz,
3278            self.device.settings.cr_denom,
3279            frame_len,
3280        );
3281        if self.config.duty.would_exceed(now_ms, airtime_ms) {
3282            return None;
3283        }
3284        let mut data = HeaplessVec::new();
3285        data.extend_from_slice(&buf[..frame_len]).ok()?;
3286        self.session
3287            .pending
3288            .push_back(PendingTx {
3289                data,
3290                tid: TID_UNSOLICITED,
3291                airtime_ms,
3292                power: TxPower::Default,
3293                autonomous: true,
3294                ack_for,
3295                // Delegated acks are immediate MAC acks: the channel was
3296                // clear when the acknowledged frame ended, and the ACK
3297                // protection interval reserves this window for them
3298                // (channel-access.md § Immediate ACK Transmission).
3299                nocca: true,
3300            })
3301            .ok()?;
3302        Some(Effect::StartTransmit)
3303    }
3304
3305    /// Report completion of the transmit started by
3306    /// [`Effect::StartTransmit`].
3307    pub fn on_tx_result(
3308        &mut self,
3309        outcome: TxOutcome,
3310        now_ms: u64,
3311        emit: &mut impl FnMut(&[u8]),
3312    ) -> Option<Effect> {
3313        let Some(pending) = self.session.pending.pop_front() else {
3314            return None;
3315        };
3316        match outcome {
3317            TxOutcome::Sent => {
3318                self.config.duty.record(now_ms, pending.airtime_ms);
3319                if pending.autonomous {
3320                    // device-initiated: PROP_LAST_STATUS is left alone so a
3321                    // pending reset code still reaches the next host. Only
3322                    // now — with the ack actually on the air — does the
3323                    // acknowledged frame earn RX_FLAG_ACKED. A handle whose
3324                    // entry has since been drained, evicted, or discarded
3325                    // marks nothing.
3326                    if let Some(seq) = pending.ack_for {
3327                        self.host.queue.mark_acked(seq);
3328                    }
3329                } else {
3330                    self.complete(pending.tid, Status::OK, emit);
3331                }
3332            }
3333            // The frame never left the radio: no duty accounting, no
3334            // RX_FLAG_ACKED. Hosts learn CCA refusals distinctly so they
3335            // can apply their own backoff (spec § STATUS_CCA_FAILURE).
3336            TxOutcome::ChannelBusy if !pending.autonomous => {
3337                self.complete(pending.tid, Status::CCA_FAILURE, emit);
3338            }
3339            TxOutcome::Failed if !pending.autonomous => {
3340                self.complete(pending.tid, Status::FAILURE, emit);
3341            }
3342            TxOutcome::ChannelBusy | TxOutcome::Failed => {}
3343        }
3344        (!self.session.pending.is_empty()).then_some(Effect::StartTransmit)
3345    }
3346
3347    // ─── Command implementations ─────────────────────────────────────
3348
3349    /// Begin a multi-property command, serving as many entries as can be
3350    /// answered without leaving the session.
3351    fn begin_multi(
3352        &mut self,
3353        tid: u8,
3354        writes: bool,
3355        payload: &[u8],
3356        now_ms: u64,
3357        reply_budget: usize,
3358        emit: &mut impl FnMut(&[u8]),
3359    ) -> Option<Effect> {
3360        debug_assert!(self.multi.is_none());
3361        let Some(state) = MultiState::new(tid, writes, payload, reply_budget) else {
3362            // More request than one frame can carry; the host asks for
3363            // less.
3364            self.complete(tid, Status::NOMEM, emit);
3365            return None;
3366        };
3367        self.multi = Some(state);
3368        self.advance_multi(now_ms, emit)
3369    }
3370
3371    /// Serve entries until one needs a platform round trip, or until the
3372    /// sequence ends and the `CMD_PROP_ARE` is emitted.
3373    ///
3374    /// Each entry runs through `prop_get` or `prop_set` exactly as a lone
3375    /// command would; `send_prop_is` and `complete` divert what those
3376    /// would have sent into the reply being accumulated.
3377    fn advance_multi(&mut self, now_ms: u64, emit: &mut impl FnMut(&[u8])) -> Option<Effect> {
3378        loop {
3379            let mut value = [0u8; MULTI_MAX];
3380            let (tid, writes) = {
3381                let state = self.multi.as_ref()?;
3382                (state.tid, state.writes)
3383            };
3384            let next = {
3385                let state = self.multi.as_mut()?;
3386                // A write sequence stops at the first failure; both stop
3387                // once the reply is full.
3388                if state.truncated || (state.writes && state.failed) {
3389                    None
3390                } else {
3391                    let rest = &state.request[state.offset..];
3392                    if rest.is_empty() {
3393                        None
3394                    } else if state.writes {
3395                        let mut entries = MultiEntries::new(rest);
3396                        match entries.next() {
3397                            Some(Ok(entry)) => {
3398                                let consumed = rest.len() - entries.remainder().len();
3399                                state.offset += consumed;
3400                                // The entry is not executed unless its
3401                                // reply entry fits what is left of the
3402                                // response, so a sequence that runs out
3403                                // of room stops rather than applying a
3404                                // write it cannot report.
3405                                match frame::entry_len(entry.key, entry.value.len()) {
3406                                    Some(needed) if needed <= state.room() => {
3407                                        value[..entry.value.len()].copy_from_slice(entry.value);
3408                                        Some((entry.key, entry.value.len()))
3409                                    }
3410                                    _ => {
3411                                        state.truncated = true;
3412                                        None
3413                                    }
3414                                }
3415                            }
3416                            Some(Err(_)) => {
3417                                state.offset = state.request.len();
3418                                state.failed = true;
3419                                state.truncated |= !state.push_status(Status::PARSE_ERROR);
3420                                None
3421                            }
3422                            None => None,
3423                        }
3424                    } else {
3425                        match pui::decode(rest) {
3426                            Ok((key, consumed)) => {
3427                                state.offset += consumed;
3428                                Some((key, 0))
3429                            }
3430                            Err(_) => {
3431                                state.offset = state.request.len();
3432                                state.truncated |= !state.push_status(Status::PARSE_ERROR);
3433                                None
3434                            }
3435                        }
3436                    }
3437                }
3438            };
3439            let Some((key, value_len)) = next else {
3440                return self.finish_multi(emit);
3441            };
3442            let effect = if writes {
3443                self.prop_set(tid, key, &value[..value_len], now_ms, emit)
3444            } else {
3445                self.prop_get(tid, key, now_ms, emit)
3446            };
3447            // A deferred value: the driver serves the effect, the
3448            // matching `respond_*` fills the slot, and `resume_multi`
3449            // brings us back here for the next entry.
3450            if effect.is_some() {
3451                return effect;
3452            }
3453        }
3454    }
3455
3456    /// Emit the accumulated `CMD_PROP_ARE`.
3457    ///
3458    /// A reply that ran out of room carries the entries that fit and
3459    /// nothing else: the requester compares what it asked for against
3460    /// what came back and reissues the remainder.
3461    fn finish_multi(&mut self, emit: &mut impl FnMut(&[u8])) -> Option<Effect> {
3462        let state = self.multi.take()?;
3463        emit(&state.reply);
3464        None
3465    }
3466
3467    /// Continue a multi-property command that was waiting on a deferred
3468    /// value, returning the next effect to serve.
3469    ///
3470    /// Returns `None` when no multi-property command is in flight, which
3471    /// is the ordinary case after a single-property deferral.
3472    pub fn resume_multi(&mut self, now_ms: u64, emit: &mut impl FnMut(&[u8])) -> Option<Effect> {
3473        self.multi.as_ref()?;
3474        self.advance_multi(now_ms, emit)
3475    }
3476
3477    fn prop_get(
3478        &mut self,
3479        tid: u8,
3480        key: u32,
3481        now_ms: u64,
3482        emit: &mut impl FnMut(&[u8]),
3483    ) -> Option<Effect> {
3484        if self.is_admin() && !admin_reachable(key) {
3485            self.complete(tid, Status::PROP_NOT_FOUND, emit);
3486            return None;
3487        }
3488        // PROP_PHY_RSSI is an instantaneous radio reading the session cannot
3489        // produce on its own. While the PHY is enabled (in RX), defer to the
3490        // caller to sample it; while disabled there is no ambient RSSI to read.
3491        //
3492        // The write-only properties must not disclose their values —
3493        // for the device private key, not even whether one is
3494        // configured (spec §PROP_DEV_PRIVATE_KEY).
3495        if key == prop::BLE_PAIRING_PIN || key == prop::DEV_PRIVATE_KEY {
3496            self.complete(tid, Status::UNIMPLEMENTED, emit);
3497            return None;
3498        }
3499        if key == prop::PHY_RSSI {
3500            if self.device.settings.enabled {
3501                return Some(Effect::SampleRssi { tid });
3502            }
3503            self.complete(tid, Status::INVALID_STATE, emit);
3504            return None;
3505        }
3506        // PROP_IDENT is a signature over the current identity, and the
3507        // session holds no signing key. Defer to the platform, which
3508        // builds the canonical payload from the same fields the Identity
3509        // Request responder advertises and signs it with the device
3510        // identity. Deliberately not cached: caching would impose
3511        // coherence work across the device key, role, mobility,
3512        // forwarding state, name, and every identity field added later,
3513        // to save a signature nobody reads in a loop.
3514        if key == prop::IDENT {
3515            return Some(Effect::SignIdentity { tid });
3516        }
3517        // PROP_BATTERY is a measurement, not stored state: when any field
3518        // is reported, defer to the platform's battery source so the
3519        // response reflects a sample taken now. With no reported fields
3520        // the empty (unsupported-reporting) value needs no sampling.
3521        if key == prop::BATTERY
3522            && let Some(fields) = self.config.battery
3523        {
3524            if fields.any() {
3525                return Some(Effect::SampleBattery { tid });
3526            }
3527            self.send_prop_is(tid, prop::BATTERY, &[], emit);
3528            return None;
3529        }
3530        // The wall clock belongs to the platform: only it knows whether
3531        // the clock has been set and how far it has run since. Deferring
3532        // is also what keeps "we do not know what time it is" honest —
3533        // the session has nothing to answer with, rather than a stale
3534        // reading it would have to decide the age of.
3535        if key == prop::TIME && self.config.time.is_some() {
3536            return Some(Effect::ReadTime { tid });
3537        }
3538        // Positioning telemetry is a measurement, not stored state.
3539        if gnss::is_positioning_property(key) && self.config.gnss.is_some() {
3540            return Some(Effect::SampleGnss { tid, key });
3541        }
3542        // Ambient light, likewise: the sensor is read on demand and the
3543        // session caches nothing.
3544        if key == prop::ILLUMINANCE && self.config.illuminance {
3545            return Some(Effect::SampleIlluminance { tid });
3546        }
3547        let mut value = [0u8; PROP_BUF];
3548        match self.encode_prop(key, now_ms, &mut value) {
3549            PropValue::Encoded(len) => self.send_prop_is(tid, key, &value[..len], emit),
3550            PropValue::Unimplemented => self.complete(tid, Status::UNIMPLEMENTED, emit),
3551            PropValue::Unknown => self.complete(tid, Status::PROP_NOT_FOUND, emit),
3552        }
3553        None
3554    }
3555
3556    /// Complete a deferred `PROP_PHY_RSSI` read requested via
3557    /// [`Effect::SampleRssi`]. `rssi` is the sampled value in dBm, or `Err` if
3558    /// the radio read failed. Quote the same `tid` the effect carried.
3559    pub fn respond_rssi(&mut self, tid: u8, rssi: Result<i16, ()>, emit: &mut impl FnMut(&[u8])) {
3560        match rssi {
3561            Ok(dbm) => {
3562                let clamped = dbm.clamp(i16::from(i8::MIN), i16::from(i8::MAX)) as i8;
3563                self.send_prop_is(tid, prop::PHY_RSSI, &[clamped as u8], emit);
3564            }
3565            Err(()) => self.complete(tid, Status::FAILURE, emit),
3566        }
3567    }
3568
3569    /// Complete a deferred `PROP_IDENT` read requested via
3570    /// [`Effect::SignIdentity`]. `blob` is the complete signed
3571    /// node-identity payload — the canonical unsigned encoding followed
3572    /// by its 64-octet detached signature — or `Err` if it could not be
3573    /// produced. Quote the same `tid` the effect carried.
3574    pub fn respond_identity_blob(
3575        &mut self,
3576        tid: u8,
3577        blob: Result<&[u8], ()>,
3578        emit: &mut impl FnMut(&[u8]),
3579    ) {
3580        match blob {
3581            Ok(bytes) => self.send_prop_is(tid, prop::IDENT, bytes, emit),
3582            Err(()) => self.complete(tid, Status::FAILURE, emit),
3583        }
3584    }
3585
3586    /// `PROP_IDENT_ROLE`: the advertised `ROLE` byte, or `None` to let
3587    /// the device derive it from its live forwarding state.
3588    pub fn ident_role(&self) -> Option<u8> {
3589        self.device.ident_role
3590    }
3591
3592    /// `PROP_IDENT_MOBILE`: whether the device identity advertises the
3593    /// `MOB` capability bit.
3594    pub fn ident_mobile(&self) -> bool {
3595        self.device.ident_mobile
3596    }
3597
3598    /// `PROP_IDENT_LOCATION`: the position the advertised node identity
3599    /// carries, in the variable-precision encoding, or empty for none.
3600    pub fn ident_location(&self) -> &[u8] {
3601        &self.device.ident_location
3602    }
3603
3604    /// `PROP_IDENT_ALTITUDE`: meters above the WGS-84 ellipsoid, or
3605    /// `None`.
3606    pub fn ident_altitude_m(&self) -> Option<i32> {
3607        self.device.ident_altitude_m
3608    }
3609
3610    /// Offer a fix to the advertised position.
3611    ///
3612    /// A no-op unless the device is set to update its identity from
3613    /// fixes, so the platform can hand over every fix without first
3614    /// asking whether this one counts. The location is clamped to
3615    /// `PROP_GNSS_IDENT_PRECISION` before it is stored.
3616    ///
3617    /// Returns whether anything changed. A stationary node's fixes all
3618    /// clamp into the same cell, and re-signing an identity that says
3619    /// what the last one said would spend airtime to no purpose.
3620    pub fn absorb_ident_fix(&mut self, location: &[u8], altitude_m: Option<i32>) -> bool {
3621        if !self.gnss_ident_update() {
3622            return false;
3623        }
3624        let clamped = &location[..location.len().min(self.gnss_ident_precision() as usize)];
3625        if self.device.ident_location == clamped && self.device.ident_altitude_m == altitude_m {
3626            return false;
3627        }
3628        self.device.ident_location = HeaplessVec::from_slice(clamped).unwrap_or_default();
3629        self.device.ident_altitude_m = altitude_m;
3630        self.bump_dev_domain();
3631        true
3632    }
3633
3634    /// `PROP_DEV_DISCOVERABLE`: whether the device identity answers
3635    /// Identity Requests.
3636    pub fn dev_discoverable(&self) -> bool {
3637        self.device.dev_discoverable
3638    }
3639
3640    /// `PROP_ADVERT_INTERVAL`: seconds between unsolicited advertisements,
3641    /// 0 for none.
3642    pub fn advert_interval_s(&self) -> u32 {
3643        self.device.advert_interval_s
3644    }
3645
3646    /// `PROP_BEACON_INTERVAL`: seconds between unsolicited beacons, 0 for
3647    /// none.
3648    pub fn beacon_interval_s(&self) -> u32 {
3649        self.device.beacon_interval_s
3650    }
3651
3652    /// `PROP_STARTUP_BEACON`: whether one beacon goes out at bring-up.
3653    pub fn startup_beacon(&self) -> bool {
3654        self.device.startup_beacon
3655    }
3656
3657    /// `PROP_TZ_OFFSET`: minutes east of UTC.
3658    pub fn tz_offset_min(&self) -> i16 {
3659        self.device.tz_offset_min
3660    }
3661
3662    /// `PROP_GNSS_ENABLED`: whether the receiver should be powered.
3663    ///
3664    /// Always false on a board without `CAP_GNSS`, so a platform can act
3665    /// on it without first asking whether it has a receiver.
3666    pub fn gnss_enabled(&self) -> bool {
3667        self.config.gnss.is_some() && self.device.gnss_enabled
3668    }
3669
3670    /// `PROP_GNSS_IDENT_UPDATE`: whether fixes refresh the advertised
3671    /// node identity's location.
3672    pub fn gnss_ident_update(&self) -> bool {
3673        self.config.gnss.is_some() && self.device.gnss_ident_update
3674    }
3675
3676    /// `PROP_BLE_ENABLED`: whether the device is reachable over
3677    /// Bluetooth.
3678    ///
3679    /// Always false on a board without `CAP_BLE`, so a platform can act
3680    /// on it without first asking whether it has a transport.
3681    pub fn ble_enabled(&self) -> bool {
3682        self.config.ble && self.device.ble_enabled
3683    }
3684
3685    /// `PROP_BLE_BOND_COUNT`: how many bonds the transport last reported.
3686    pub fn ble_bond_count(&self) -> u8 {
3687        if self.config.ble_pairing {
3688            self.ble_bond_count
3689        } else {
3690            0
3691        }
3692    }
3693
3694    /// `PROP_BLE_LINK`: how far the Bluetooth transport has got with
3695    /// whoever is on the other end of it.
3696    ///
3697    /// Always `None` on a board without `CAP_BLE`, so a platform can act
3698    /// on it without first asking whether it has a transport.
3699    pub fn ble_link(&self) -> BleLinkState {
3700        if self.config.ble {
3701            self.ble_link
3702        } else {
3703            BleLinkState::None
3704        }
3705    }
3706
3707    /// `PROP_BLE_PAIRING`: whether a pairing window is open.
3708    pub fn ble_pairing(&self) -> bool {
3709        self.config.ble_pairing && self.ble_pairing_open
3710    }
3711
3712    /// `PROP_GNSS_IDENT_PRECISION`: the precision the advertised location
3713    /// is clamped to.
3714    pub fn gnss_ident_precision(&self) -> u8 {
3715        self.device.gnss_ident_precision
3716    }
3717
3718    /// `PROP_GNSS_TIME_TRUST`: whether receiver-derived time may set the
3719    /// wall clock.
3720    pub fn gnss_time_trust(&self) -> bool {
3721        self.device.gnss_time_trust
3722    }
3723
3724    /// `PROP_ALERT`: what the device is currently doing to draw
3725    /// attention to itself.
3726    pub fn alert(&self) -> AlertState {
3727        self.alert
3728    }
3729
3730    /// When the running alert gives itself up, as a monotonic
3731    /// millisecond deadline on the caller's clock, or `None` when no
3732    /// alert is running.
3733    ///
3734    /// The driver arms a timer on this and calls [`Session::poll_alert`]
3735    /// when it fires. Enforcing the bound centrally is what keeps the
3736    /// spec's "a device **MUST** bound how long it will remain in
3737    /// `ALERT_LOCATE`" from being a promise each board has to remember
3738    /// to keep.
3739    pub fn alert_deadline_ms(&self) -> Option<u64> {
3740        self.alert_deadline_ms
3741    }
3742
3743    /// Expire a running alert whose deadline has passed, returning the
3744    /// effect that stops the board's indication.
3745    ///
3746    /// Safe to call at any time: it does nothing until the deadline is
3747    /// actually reached, so a driver that polls it on every loop
3748    /// iteration behaves identically to one that arms a precise timer.
3749    pub fn poll_alert(&mut self, now_ms: u64, emit: &mut impl FnMut(&[u8])) -> Option<Effect> {
3750        match self.alert_deadline_ms {
3751            Some(deadline) if now_ms >= deadline => self.clear_alert(emit),
3752            _ => None,
3753        }
3754    }
3755
3756    /// Cancel a running alert from the device itself — the button press
3757    /// of whoever found the radio.
3758    ///
3759    /// Returns the effect that stops the indication, or `None` when no
3760    /// alert was running (so a board can use the return to decide
3761    /// whether the press was consumed).
3762    pub fn cancel_alert(&mut self, emit: &mut impl FnMut(&[u8])) -> Option<Effect> {
3763        self.clear_alert(emit)
3764    }
3765
3766    /// Flip `PROP_GNSS_ENABLED` from the device itself — a button on a
3767    /// board that offers the receiver as a user-facing switch.
3768    ///
3769    /// Returns the new state, or `None` on a device without `CAP_GNSS`
3770    /// (so a board can report a press unconditionally).
3771    pub fn toggle_gnss(&mut self, emit: &mut impl FnMut(&[u8])) -> Option<bool> {
3772        self.toggle_device_flag(
3773            self.config.gnss.is_some(),
3774            prop::GNSS_ENABLED,
3775            |device| &mut device.gnss_enabled,
3776            emit,
3777        )
3778    }
3779
3780    /// Flip `PROP_GNSS_IDENT_UPDATE` from the device itself.
3781    ///
3782    /// Whether the device knows where it is and whether it says so are
3783    /// two decisions, and a board that offers the first as a switch owes
3784    /// the user the second: nothing else on the device distinguishes a
3785    /// position kept for the screen from one put on the air.
3786    pub fn toggle_gnss_ident_update(&mut self, emit: &mut impl FnMut(&[u8])) -> Option<bool> {
3787        self.toggle_device_flag(
3788            self.config.gnss.is_some(),
3789            prop::GNSS_IDENT_UPDATE,
3790            |device| &mut device.gnss_ident_update,
3791            emit,
3792        )
3793    }
3794
3795    /// Flip `PROP_MAC_REPEATER_ENABLED` from the device itself.
3796    ///
3797    /// Every device carries the repeater, so this never refuses — the
3798    /// return is the new state rather than an availability answer.
3799    pub fn toggle_repeater(&mut self, emit: &mut impl FnMut(&[u8])) -> Option<bool> {
3800        self.toggle_device_flag(
3801            true,
3802            prop::MAC_REPEATER_ENABLED,
3803            |device| &mut device.repeater_enabled,
3804            emit,
3805        )
3806    }
3807
3808    /// Flip `PROP_BLE_ENABLED` from the device itself.
3809    ///
3810    /// The one toggle whose own effect can carry away the host that
3811    /// would have watched it: clearing it drops the attached link, so
3812    /// the announcement below is the last thing that host hears.
3813    pub fn toggle_ble(&mut self, emit: &mut impl FnMut(&[u8])) -> Option<bool> {
3814        self.toggle_device_flag(
3815            self.config.ble,
3816            prop::BLE_ENABLED,
3817            |device| &mut device.ble_enabled,
3818            emit,
3819        )
3820    }
3821
3822    /// Force `PROP_BLE_ENABLED` on for a physical gesture at the device —
3823    /// the hold-through-power-on ceremony that must always end with a
3824    /// reachable radio, including one whose operator turned Bluetooth off
3825    /// and walked away. A toggle would re-strand the ones already on.
3826    ///
3827    /// Returns `Some(true)` only when the flag actually moved, so the
3828    /// caller persists a snapshot for the same reason a toggle does and
3829    /// stays quiet when there was nothing to change.
3830    pub fn force_ble_on(&mut self, emit: &mut impl FnMut(&[u8])) -> Option<bool> {
3831        if !self.config.ble || self.device.ble_enabled {
3832            return None;
3833        }
3834        self.device.ble_enabled = true;
3835        self.bump_dev_domain();
3836        if self.attached {
3837            self.announce_prop_is(prop::BLE_ENABLED, &[1], emit);
3838        }
3839        Some(true)
3840    }
3841
3842    /// Flip one device-domain boolean on behalf of a control the operator
3843    /// can reach, and announce where it landed.
3844    ///
3845    /// No effect is returned: the switch reaches the platform through the
3846    /// device-domain mirror, the same path a host write, a boot restore
3847    /// and a `CMD_RST` all take. The transition is published like any the
3848    /// host did not command — none of these properties otherwise moves
3849    /// behind the host's back, but a switch someone can flip is exactly a
3850    /// thing that does.
3851    fn toggle_device_flag(
3852        &mut self,
3853        available: bool,
3854        key: u32,
3855        pick: fn(&mut DeviceDomain) -> &mut bool,
3856        emit: &mut impl FnMut(&[u8]),
3857    ) -> Option<bool> {
3858        if !available {
3859            return None;
3860        }
3861        let flag = pick(&mut self.device);
3862        *flag = !*flag;
3863        let enabled = *flag;
3864        self.bump_dev_domain();
3865        if self.attached {
3866            self.announce_prop_is(key, &[enabled as u8], emit);
3867        }
3868        Some(enabled)
3869    }
3870
3871    /// Return to `ALERT_NONE` for a reason the host did not command,
3872    /// announcing it with an unsolicited `CMD_PROP_IS`.
3873    fn clear_alert(&mut self, emit: &mut impl FnMut(&[u8])) -> Option<Effect> {
3874        if !self.alert.is_active() {
3875            return None;
3876        }
3877        self.alert = AlertState::None;
3878        self.alert_deadline_ms = None;
3879        // Every transition the host did not ask for is published; a host
3880        // that is not attached simply reads the current value when it
3881        // comes back.
3882        if self.attached {
3883            let mut value = [0u8; pui::MAX_LEN];
3884            let len = pui::encode(AlertState::None.code(), &mut value).unwrap_or(0);
3885            self.announce_prop_is(prop::ALERT, &value[..len], emit);
3886        }
3887        Some(Effect::ApplyAlert(AlertState::None))
3888    }
3889
3890    /// Publish an unsolicited `PROP_BATTERY` snapshot (spec
3891    /// §PROP_BATTERY, *Asynchronous Updates: Yes*).
3892    ///
3893    /// The platform decides *when* a measurement is worth announcing —
3894    /// it owns the sampling cadence and the charge-state edges, and it
3895    /// is the only layer that sees every sample. This publishes what it
3896    /// hands over, so the session keeps its rule that it never caches a
3897    /// reading: nothing here can answer a later `CMD_PROP_GET`.
3898    ///
3899    /// Returns whether a frame was emitted. Nothing is published while
3900    /// no host is attached (there is nobody to notify), and a snapshot
3901    /// populating a field the configured [`BatteryFields`] never claimed
3902    /// is dropped rather than sent — an unsolicited notification has no
3903    /// transaction to fail. A snapshot that merely omits an advertised
3904    /// field is published as-is: absence is how the device says the value
3905    /// is not knowable right now.
3906    pub fn publish_battery(&mut self, sample: BatteryStatus, emit: &mut impl FnMut(&[u8])) -> bool {
3907        if !self.attached {
3908            return false;
3909        }
3910        let Some(fields) = self.config.battery else {
3911            return false;
3912        };
3913        if !fields.matches(&sample) {
3914            return false;
3915        }
3916        let mut value = [0u8; battery::MAX_ENCODED_LEN];
3917        let Ok(len) = sample.encode(&mut value) else {
3918            return false;
3919        };
3920        self.announce_prop_is(prop::BATTERY, &value[..len], emit)
3921    }
3922
3923    /// Complete a deferred `PROP_BATTERY` read requested via
3924    /// [`Effect::SampleBattery`]. `sample` is the platform's snapshot, or
3925    /// `Err` if the measurement failed. Quote the same `tid` the effect
3926    /// carried.
3927    ///
3928    /// A snapshot populating a field the configured [`BatteryFields`]
3929    /// never claimed is refused as `STATUS_FAILURE`; one that omits an
3930    /// advertised field is answered as-is, since a field the platform
3931    /// cannot currently substantiate is reported by its absence.
3932    pub fn respond_battery(
3933        &mut self,
3934        tid: u8,
3935        sample: Result<BatteryStatus, ()>,
3936        emit: &mut impl FnMut(&[u8]),
3937    ) {
3938        let fields = self.config.battery.unwrap_or_default();
3939        match sample {
3940            Ok(snapshot) if fields.matches(&snapshot) => {
3941                let mut value = [0u8; battery::MAX_ENCODED_LEN];
3942                match snapshot.encode(&mut value) {
3943                    Ok(len) => self.send_prop_is(tid, prop::BATTERY, &value[..len], emit),
3944                    Err(_) => self.complete(tid, Status::FAILURE, emit),
3945                }
3946            }
3947            Ok(_) | Err(()) => self.complete(tid, Status::FAILURE, emit),
3948        }
3949    }
3950
3951    /// Complete a deferred `PROP_ILLUMINANCE` read requested via
3952    /// [`Effect::SampleIlluminance`]. `millilux` is the measurement, or
3953    /// `None` when the sensor could not be read. Quote the same `tid` the
3954    /// effect carried.
3955    ///
3956    /// A failed read is the empty value rather than an error status: the
3957    /// property is a measurement, and "no reading right now" is the same
3958    /// answer `PROP_TIME` gives for a clock that has never been set.
3959    pub fn respond_illuminance(
3960        &mut self,
3961        tid: u8,
3962        millilux: Option<u32>,
3963        emit: &mut impl FnMut(&[u8]),
3964    ) {
3965        match millilux {
3966            Some(value) => {
3967                self.send_prop_is(tid, prop::ILLUMINANCE, &value.to_le_bytes(), emit);
3968            }
3969            None => self.send_prop_is(tid, prop::ILLUMINANCE, &[], emit),
3970        }
3971    }
3972
3973    /// Complete a deferred `PROP_TIME` read requested via
3974    /// [`Effect::ReadTime`]. `epoch` is the platform's wall clock in Unix
3975    /// seconds, or `None` when the device does not know what time it is.
3976    /// Quote the same `tid` the effect carried.
3977    ///
3978    /// Not knowing is a legitimate answer, not a failure: it is reported
3979    /// as the empty value, which is precisely what tells a host — and a
3980    /// device's own display — that there is no clock to show.
3981    pub fn respond_time(&mut self, tid: u8, epoch: Option<u32>, emit: &mut impl FnMut(&[u8])) {
3982        match epoch {
3983            Some(seconds) => self.send_prop_is(tid, prop::TIME, &seconds.to_le_bytes(), emit),
3984            None => self.send_prop_is(tid, prop::TIME, &[], emit),
3985        }
3986    }
3987
3988    /// Publish an unsolicited `PROP_TIME` (spec §PROP_TIME,
3989    /// *Asynchronous Updates: Yes*).
3990    ///
3991    /// The platform decides what is worth announcing — it owns the clock
3992    /// and is the only layer that sees every source that touches it. A
3993    /// clock going from unknown to known is the announcement that matters
3994    /// most; a fresh fix agreeing with the clock to the second is not.
3995    ///
3996    /// Returns whether a frame was emitted; nothing is published while no
3997    /// host is attached.
3998    pub fn publish_time(&mut self, epoch: Option<u32>, emit: &mut impl FnMut(&[u8])) -> bool {
3999        if !self.attached || self.config.time.is_none() {
4000            return false;
4001        }
4002        match epoch {
4003            Some(seconds) => self.announce_prop_is(prop::TIME, &seconds.to_le_bytes(), emit),
4004            None => self.announce_prop_is(prop::TIME, &[], emit),
4005        }
4006    }
4007
4008    /// Complete a deferred positioning read requested via
4009    /// [`Effect::SampleGnss`]. `sample` is the receiver's current view, or
4010    /// `Err` if it could not be obtained. Quote the same `tid` and `key`
4011    /// the effect carried.
4012    ///
4013    /// A receiver that is off or still searching is not a failure — it
4014    /// reports [`GnssSnapshot::SEARCHING`], which answers zero for the
4015    /// facts it is sure of and empty for the position it does not have.
4016    pub fn respond_gnss(
4017        &mut self,
4018        tid: u8,
4019        key: u32,
4020        sample: Result<GnssSnapshot, ()>,
4021        emit: &mut impl FnMut(&[u8]),
4022    ) {
4023        let mut value = [0u8; gnss::MAX_VALUE_LEN];
4024        match sample.and_then(|snapshot| snapshot.encode(key, &mut value).map_err(|_| ())) {
4025            Ok(len) => self.send_prop_is(tid, key, &value[..len], emit),
4026            Err(()) => self.complete(tid, Status::FAILURE, emit),
4027        }
4028    }
4029
4030    /// Publish one positioning property as an unsolicited `CMD_PROP_IS`
4031    /// (spec §PROP_GNSS_LOCATION / §PROP_GNSS_FIX, *Asynchronous Updates:
4032    /// Yes*).
4033    ///
4034    /// The platform decides the cadence, as it does for `PROP_BATTERY`:
4035    /// it sees every sentence the receiver produces and is the only layer
4036    /// that can tell a meaningful change from a jittering last digit.
4037    ///
4038    /// Returns whether a frame was emitted. `key` must be a positioning
4039    /// property; anything else, and any publication while no host is
4040    /// attached, is dropped.
4041    pub fn publish_gnss(
4042        &mut self,
4043        key: u32,
4044        snapshot: &GnssSnapshot,
4045        emit: &mut impl FnMut(&[u8]),
4046    ) -> bool {
4047        if !self.attached || self.config.gnss.is_none() {
4048            return false;
4049        }
4050        let mut value = [0u8; gnss::MAX_VALUE_LEN];
4051        let Ok(len) = snapshot.encode(key, &mut value) else {
4052            return false;
4053        };
4054        self.announce_prop_is(key, &value[..len], emit)
4055    }
4056
4057    /// Advance the drain started by [`Effect::DrainQueue`] one step,
4058    /// emitting either the next covered frame (oldest first, as
4059    /// `CMD_STR_RECV` with buffered metadata) or, once the covered set
4060    /// is exhausted, the completion status. Returns `true` while
4061    /// another call is needed; flush the transport between calls.
4062    pub fn drain_step(&mut self, now_ms: u64, emit: &mut impl FnMut(&[u8])) -> bool {
4063        let Some(drain) = &mut self.session.drain else {
4064            return false;
4065        };
4066        if drain.remaining == 0 {
4067            let tid = drain.tid;
4068            self.session.drain = None;
4069            self.complete(tid, Status::OK, emit);
4070            return false;
4071        }
4072        drain.remaining -= 1;
4073        let Some(entry) = self.host.queue.pop_front() else {
4074            // The covered set outliving the queue means state was reset
4075            // mid-drain; complete rather than stall.
4076            let tid = drain.tid;
4077            self.session.drain = None;
4078            self.complete(tid, Status::OK, emit);
4079            return false;
4080        };
4081        let mut rx_meta = [0u8; BufferedRxMeta::WIRE_LEN];
4082        let meta_len = BufferedRxMeta {
4083            rx: RxMeta {
4084                rssi_dbm: entry.rssi_dbm,
4085                lqi: entry.lqi,
4086                snr_cb: entry.snr_cb,
4087            },
4088            flags: RX_FLAG_BUFFERED
4089                | if entry.acked { RX_FLAG_ACKED } else { 0 }
4090                | if entry.self_tx { RX_FLAG_SELF_TX } else { 0 },
4091            age_s: u32::try_from(now_ms.saturating_sub(entry.rx_time_ms) / 1000)
4092                .unwrap_or(u32::MAX),
4093        }
4094        .encode(&mut rx_meta)
4095        .expect("buffer sized with WIRE_LEN");
4096        if let Ok(len) = frame::str_recv(
4097            &mut self.scratch,
4098            stream::PHY_RAW,
4099            entry.frame(),
4100            &rx_meta[..meta_len],
4101        ) {
4102            emit(&self.scratch[..len]);
4103        }
4104        true
4105    }
4106
4107    /// Encode the current device and host domains as a snapshot for
4108    /// [`Effect::SaveSnapshot`]. `out` must hold [`SNAPSHOT_MAX`]
4109    /// bytes.
4110    pub fn encode_snapshot(&self, out: &mut [u8]) -> Option<usize> {
4111        SavedState::capture(&self.device, self.config.duty.limit(), self.dev_key).encode(out)
4112    }
4113
4114    /// Restore a stored snapshot at boot, before any host command is
4115    /// processed. On success the saved configuration is applied — the
4116    /// returned effect re-enables the PHY if it was enabled when saved,
4117    /// and detached operation (filtering, queueing, delegation) begins
4118    /// immediately.
4119    ///
4120    /// The payload is decoded and validated into a candidate state
4121    /// first and committed in one step, so a malformed option arriving
4122    /// late in the decode cannot leave the device half-configured. On
4123    /// rejection nothing is modified and the caller should offer the
4124    /// next-older committed generation; see [`Session::note_snapshot_
4125    /// rejected`] for what the device reports when none decodes.
4126    pub fn restore_at_boot(&mut self, bytes: &[u8]) -> Result<Effect, SnapshotError> {
4127        let mut saved = SavedState::decode(&self.config, bytes)?;
4128        saved.derive_channel_ids(&self.engine);
4129        self.saved = Some(saved);
4130        self.apply_saved_device();
4131        Ok(self.apply_radio())
4132    }
4133
4134    /// Record that a stored generation was rejected at boot.
4135    ///
4136    /// Called once per rejected generation. If a later, older generation
4137    /// restores, `PROP_SAVED` reports [`SavedStatus::Fallback`] — the
4138    /// device is working but running on stale configuration, which is
4139    /// both more actionable and more urgent than "something was wrong".
4140    /// If none restores it reports [`SavedStatus::Unreadable`], which a
4141    /// host can tell apart from "nothing saved".
4142    pub fn note_snapshot_rejected(&mut self) {
4143        self.snapshot_rejected = true;
4144    }
4145
4146    /// What `PROP_SAVED` reports (spec §Saved State).
4147    pub fn saved_status(&self) -> SavedStatus {
4148        match (self.saved.is_some(), self.snapshot_rejected) {
4149            (true, false) => SavedStatus::Current,
4150            (true, true) => SavedStatus::Fallback,
4151            (false, true) => SavedStatus::Unreadable,
4152            (false, false) => SavedStatus::None,
4153        }
4154    }
4155
4156    /// Complete the durable write requested via
4157    /// [`Effect::SaveSnapshot`], quoting the same `tid`. On `Ok` the
4158    /// captured state becomes the post-reset baseline; on `Err` the
4159    /// previous snapshot (if any) must have been left intact by the
4160    /// caller and remains in effect.
4161    pub fn respond_save(&mut self, tid: u8, result: Result<(), ()>, emit: &mut impl FnMut(&[u8])) {
4162        match result {
4163            Ok(()) => {
4164                self.note_snapshot_saved();
4165                self.complete(tid, Status::OK, emit);
4166            }
4167            Err(()) => self.complete(tid, Status::FAILURE, emit),
4168        }
4169    }
4170
4171    /// Note that the live state was persisted without a host having
4172    /// asked — a device-initiated save, such as a switch the operator
4173    /// flipped at the board.
4174    ///
4175    /// Required after any such write. The session answers `CMD_RST` and
4176    /// `CMD_RESTORE` from its own copy of the snapshot rather than by
4177    /// re-reading flash, so a save it was not told about would leave the
4178    /// device restoring the values it had at boot and silently undoing
4179    /// what the operator did.
4180    pub fn note_snapshot_saved(&mut self) {
4181        self.saved = Some(SavedState::capture(
4182            &self.device,
4183            self.config.duty.limit(),
4184            self.dev_key,
4185        ));
4186    }
4187
4188    /// Complete the durable erase requested via [`Effect::ClearSaved`],
4189    /// quoting the same `tid`. Live state is unaffected either way: the
4190    /// live device identity in particular remains in effect until the
4191    /// `CMD_RST` that completes a factory reset.
4192    pub fn respond_clear(&mut self, tid: u8, result: Result<(), ()>, emit: &mut impl FnMut(&[u8])) {
4193        match result {
4194            Ok(()) => {
4195                self.saved = None;
4196                self.dev_key_persisted = None;
4197                self.complete(tid, Status::OK, emit);
4198            }
4199            Err(()) => self.complete(tid, Status::FAILURE, emit),
4200        }
4201    }
4202
4203    /// The staged `PROP_DEV_PRIVATE_KEY` provisioning awaiting
4204    /// [`Effect::ProvisionIdentity`] execution.
4205    pub fn identity_request(&self) -> Option<IdentitySource> {
4206        self.session
4207            .pending_identity
4208            .as_ref()
4209            .map(|pending| match pending.secret {
4210                Some(secret) => IdentitySource::Install(secret),
4211                None => IdentitySource::Generate,
4212            })
4213    }
4214
4215    /// Complete the device-identity provisioning requested via
4216    /// [`Effect::ProvisionIdentity`], quoting the same `tid`. `result`
4217    /// carries the new identity's *public* key once the keypair is
4218    /// durably stored — success is announced as `CMD_PROP_IS` for
4219    /// `PROP_DEV_KEY` and the private key is never emitted (spec
4220    /// §PROP_DEV_PRIVATE_KEY). On `Ok` the new identity is adopted even
4221    /// if the transaction was abandoned by a detach: the durable write
4222    /// already happened, and flash is the source of truth.
4223    pub fn respond_identity(
4224        &mut self,
4225        tid: u8,
4226        result: Result<[u8; items::PUBLIC_KEY_LEN], ()>,
4227        emit: &mut impl FnMut(&[u8]),
4228    ) {
4229        let matched = self
4230            .session
4231            .pending_identity
4232            .take_if(|pending| pending.tid == tid)
4233            .is_some();
4234        match result {
4235            Ok(public_key) => {
4236                self.dev_key = Some(public_key);
4237                self.dev_key_persisted = Some(public_key);
4238                // The device now claims a different identity than the
4239                // one a running node was brought up around. Publish it:
4240                // the node compares the live key against its own and
4241                // stops originating traffic it can no longer honestly
4242                // sign, until the boot that rebuilds it.
4243                self.bump_dev_domain();
4244                if matched {
4245                    self.send_prop_is(tid, prop::DEV_KEY, &public_key, emit);
4246                }
4247            }
4248            Err(()) if matched => self.complete(tid, Status::FAILURE, emit),
4249            Err(()) => {}
4250        }
4251    }
4252
4253    /// Install the independently persisted device identity's public
4254    /// key at boot, before any host command: the post-reset value of
4255    /// `PROP_DEV_KEY` is the persisted identity, snapshot or not.
4256    pub fn set_boot_identity(&mut self, public_key: [u8; items::PUBLIC_KEY_LEN]) {
4257        self.dev_key = Some(public_key);
4258        self.dev_key_persisted = Some(public_key);
4259    }
4260
4261    /// Complete a deferred write of the write-only BLE pairing PIN.
4262    pub fn respond_pin_set(
4263        &mut self,
4264        tid: u8,
4265        result: Result<(), ()>,
4266        emit: &mut impl FnMut(&[u8]),
4267    ) {
4268        self.complete(
4269            tid,
4270            if result.is_ok() {
4271                Status::OK
4272            } else {
4273                Status::INTERNAL_ERROR
4274            },
4275            emit,
4276        );
4277    }
4278
4279    /// Complete a deferred `CMD_BLE_CLEAR_BONDS`.
4280    pub fn respond_ble_clear_bonds(
4281        &mut self,
4282        tid: u8,
4283        result: Result<(), ()>,
4284        emit: &mut impl FnMut(&[u8]),
4285    ) {
4286        self.complete(
4287            tid,
4288            if result.is_ok() {
4289                Status::OK
4290            } else {
4291                Status::INTERNAL_ERROR
4292            },
4293            emit,
4294        );
4295    }
4296
4297    /// The number of Bluetooth bonds the platform is holding, as reported
4298    /// by `PROP_BLE_BOND_COUNT`. Bonding is the transport's business, so
4299    /// the session mirrors what it is told rather than counting anything.
4300    ///
4301    /// A change is announced like any the host did not command: enrolling
4302    /// or forgetting a bond happens at the device, so an attached host
4303    /// would otherwise have to poll to notice.
4304    pub fn set_ble_bond_count(&mut self, count: u8, emit: &mut impl FnMut(&[u8])) {
4305        if !self.config.ble_pairing || self.ble_bond_count == count {
4306            return;
4307        }
4308        self.ble_bond_count = count;
4309        if self.attached {
4310            self.announce_prop_is(prop::BLE_BOND_COUNT, &[count], emit);
4311        }
4312    }
4313
4314    /// How far the Bluetooth transport has got with whoever is on the
4315    /// other end of it, as reported by `PROP_BLE_LINK`.
4316    ///
4317    /// Announced like the bond count, and for the same reason: a host
4318    /// connecting or walking away is a transition nobody asked for. An
4319    /// announcement only ever reaches a host on some *other* binding —
4320    /// the Bluetooth host that would hear "attached" is the one that
4321    /// caused it, and by the time the state is `None` there is nobody
4322    /// there to tell.
4323    pub fn set_ble_link(&mut self, state: BleLinkState, emit: &mut impl FnMut(&[u8])) {
4324        if !self.config.ble || self.ble_link == state {
4325            return;
4326        }
4327        self.ble_link = state;
4328        if self.attached {
4329            self.announce_prop_is(prop::BLE_LINK, &[state.code()], emit);
4330        }
4331    }
4332
4333    /// Whether a pairing window is open, as reported by
4334    /// `PROP_BLE_PAIRING`.
4335    ///
4336    /// The transport calls this on every transition, commanded or not —
4337    /// and "or not" is the whole reason the window is a property: it
4338    /// closes by itself on a new bond or a timeout, and a host showing a
4339    /// toggle has to see it flip back.
4340    pub fn set_ble_pairing(&mut self, open: bool, emit: &mut impl FnMut(&[u8])) {
4341        if !self.config.ble_pairing || self.ble_pairing_open == open {
4342            return;
4343        }
4344        self.ble_pairing_open = open;
4345        if self.attached {
4346            self.announce_prop_is(prop::BLE_PAIRING, &[open as u8], emit);
4347        }
4348    }
4349
4350    /// Complete a deferred `PROP_BLE_PAIRING` write.
4351    ///
4352    /// `Ok(state)` quotes the state the transport is now in, which
4353    /// answers the write the way any set is answered — with the
4354    /// property's value. `Err(())` is a window the device cannot open
4355    /// right now: locked out after repeated pairing failures, or
4356    /// Bluetooth disabled.
4357    pub fn respond_ble_pairing(
4358        &mut self,
4359        tid: u8,
4360        result: Result<bool, ()>,
4361        emit: &mut impl FnMut(&[u8]),
4362    ) {
4363        match result {
4364            Ok(open) => {
4365                self.ble_pairing_open = open;
4366                self.send_prop_is(tid, prop::BLE_PAIRING, &[open as u8], emit);
4367            }
4368            Err(()) => self.complete(tid, Status::INVALID_STATE, emit),
4369        }
4370    }
4371
4372    fn prop_set(
4373        &mut self,
4374        tid: u8,
4375        key: u32,
4376        value: &[u8],
4377        now_ms: u64,
4378        emit: &mut impl FnMut(&[u8]),
4379    ) -> Option<Effect> {
4380        if self.is_admin() && !admin_reachable(key) {
4381            self.complete(tid, Status::PROP_NOT_FOUND, emit);
4382            return None;
4383        }
4384        if key == prop::BLE_PAIRING_PIN {
4385            let pin = if value.is_empty() {
4386                None
4387            } else {
4388                match parse_u32(value) {
4389                    Ok(pin) if pin <= 999_999 => Some(pin),
4390                    _ => {
4391                        self.complete(tid, Status::INVALID_ARGUMENT, emit);
4392                        return None;
4393                    }
4394                }
4395            };
4396            return Some(Effect::SetPairingPin { tid, pin });
4397        }
4398        // The pairing window. Defers like the PIN: whether a window can
4399        // open is the transport's call (lockout, Bluetooth off), and the
4400        // answer quotes the state actually reached rather than the one
4401        // asked for.
4402        if key == prop::BLE_PAIRING {
4403            if !self.config.ble_pairing {
4404                self.complete(tid, Status::PROP_NOT_FOUND, emit);
4405                return None;
4406            }
4407            let open = match parse_bool(value) {
4408                Ok(open) => open,
4409                Err(status) => {
4410                    self.complete(tid, status, emit);
4411                    return None;
4412                }
4413            };
4414            return Some(Effect::SetBlePairing { tid, open });
4415        }
4416        if key == prop::DEV_PRIVATE_KEY {
4417            // Both forms — installing a key and commanding on-device
4418            // generation — are key provisioning and require the
4419            // transport's security binding (spec §Provisioning
4420            // Security).
4421            if let Err(status) = self.require_secure_link() {
4422                self.complete(tid, status, emit);
4423                return None;
4424            }
4425            let secret = match value.len() {
4426                0 => None,
4427                PRIVATE_KEY_LEN => Some(value.try_into().expect("length checked")),
4428                _ => {
4429                    self.complete(tid, Status::INVALID_ARGUMENT, emit);
4430                    return None;
4431                }
4432            };
4433            if self.session.pending_identity.is_some() {
4434                self.complete(tid, Status::BUSY, emit);
4435                return None;
4436            }
4437            self.session.pending_identity = Some(PendingIdentity { tid, secret });
4438            return Some(Effect::ProvisionIdentity { tid });
4439        }
4440        if key == prop::HOST_KEY {
4441            let new_key = match value.len() {
4442                0 => None,
4443                items::PUBLIC_KEY_LEN => {
4444                    let mut key = [0; items::PUBLIC_KEY_LEN];
4445                    key.copy_from_slice(value);
4446                    Some(key)
4447                }
4448                _ => {
4449                    self.complete(tid, Status::INVALID_ARGUMENT, emit);
4450                    return None;
4451                }
4452            };
4453            // Setting the current value is idempotent and has no side
4454            // effects; a different value replaces the whole host domain
4455            // (spec §Host Replacement).
4456            //
4457            // The replacement is immediate and needs no durable
4458            // transaction: the host domain is not persisted, so there is
4459            // nothing on flash for a power cycle to resurrect. What was
4460            // a two-phase `WipeHostDomain` effect is now one assignment.
4461            if new_key != self.host.key {
4462                self.host.reset(new_key);
4463            }
4464            self.send_prop_is(tid, key, value, emit);
4465            return None;
4466        }
4467        // The locate alert drives physical hardware rather than session
4468        // state, so it completes with its own effect instead of going
4469        // through `apply_prop_set`.
4470        if key == prop::ALERT {
4471            let Some(config) = self.config.alert else {
4472                self.complete(tid, Status::PROP_NOT_FOUND, emit);
4473                return None;
4474            };
4475            let state = match pui::decode(value) {
4476                Ok((code, consumed)) if consumed == value.len() => AlertState::from_code(code),
4477                _ => None,
4478            };
4479            let Some(state) = state else {
4480                self.complete(tid, Status::INVALID_ARGUMENT, emit);
4481                return None;
4482            };
4483            self.alert = state;
4484            // Re-arming an alert that is already running restarts the
4485            // deadline rather than failing: that is how a host holds one
4486            // open for a search longer than the board's own bound.
4487            self.alert_deadline_ms = state
4488                .is_active()
4489                .then(|| now_ms.saturating_add(u64::from(config.timeout_ms)));
4490            let mut echo = [0u8; pui::MAX_LEN];
4491            let len = pui::encode(state.code(), &mut echo).unwrap_or(0);
4492            self.send_prop_is(tid, key, &echo[..len], emit);
4493            return Some(Effect::ApplyAlert(state));
4494        }
4495        // The wall clock lives in the platform, not in session state, so
4496        // a write completes with its own effect. The empty value is not a
4497        // malformed `UINT32_LE` — it is the host saying the device should
4498        // go back to not knowing what time it is.
4499        if key == prop::TIME && self.config.time.is_some() {
4500            let epoch = match value {
4501                [] => None,
4502                _ => match parse_u32(value) {
4503                    Ok(seconds) => Some(seconds),
4504                    Err(status) => {
4505                        self.complete(tid, status, emit);
4506                        return None;
4507                    }
4508                },
4509            };
4510            self.send_prop_is(tid, key, value, emit);
4511            return Some(Effect::ApplyTime { epoch });
4512        }
4513        // Which side of the radio multiplexer the host sits on is the
4514        // multiplexer's state, not the session's, so the write completes
4515        // with its own effect. Without a node there is no multiplexer;
4516        // the key then falls through to the unknown-property refusal.
4517        if key == prop::MAC_BACKHAUL && self.config.mac_node {
4518            let enabled = match parse_bool(value) {
4519                Ok(enabled) => enabled,
4520                Err(status) => {
4521                    self.complete(tid, status, emit);
4522                    return None;
4523                }
4524            };
4525            // Session-scoped: reverts to false on every attach.
4526            self.session.backhaul = enabled;
4527            self.send_prop_is(tid, key, &[enabled as u8], emit);
4528            return Some(Effect::ApplyBackhaul { enabled });
4529        }
4530        if key == prop::DEV_NAME {
4531            if !valid_device_name(value) {
4532                self.complete(tid, Status::INVALID_ARGUMENT, emit);
4533                return None;
4534            }
4535            self.device.name[..value.len()].copy_from_slice(value);
4536            self.device.name_len = value.len();
4537            self.send_prop_is(tid, key, value, emit);
4538            return Some(Effect::DeviceNameChanged);
4539        }
4540        let radio_affecting = match self.apply_prop_set(key, value) {
4541            Ok(radio_affecting) => radio_affecting,
4542            Err(status) => {
4543                self.complete(tid, status, emit);
4544                return None;
4545            }
4546        };
4547        // Echo the authoritative value back from session state.
4548        let mut encoded = [0u8; PROP_BUF];
4549        if let PropValue::Encoded(len) = self.encode_prop(key, now_ms, &mut encoded) {
4550            self.send_prop_is(tid, key, &encoded[..len], emit);
4551        }
4552        radio_affecting.then(|| self.apply_radio())
4553    }
4554
4555    /// Validate and apply a property write. Returns whether the radio
4556    /// configuration changed.
4557    fn apply_prop_set(&mut self, key: u32, value: &[u8]) -> Result<bool, Status> {
4558        // Clearing a counter. Zero is the only value a write may carry:
4559        // a counter that can be set to an arbitrary number is a counter
4560        // that can lie, and nothing downstream would be able to tell.
4561        // Not a device-domain change, so no `bump_dev_domain` and nothing
4562        // for the saved snapshot to carry.
4563        if let Some((counter, ledger)) = self.stats_counter(key) {
4564            if parse_u32(value)? != 0 {
4565                return Err(Status::INVALID_ARGUMENT);
4566            }
4567            ledger.reset(counter);
4568            return Ok(false);
4569        }
4570        match key {
4571            prop::PHY_ENABLED => {
4572                self.device.settings.enabled = parse_bool(value)?;
4573                Ok(true)
4574            }
4575            prop::PHY_FREQ => {
4576                self.device.settings.freq_khz = validate_freq_khz(&self.config, value)?;
4577                Ok(true)
4578            }
4579            prop::PHY_TX_POWER => {
4580                self.device.settings.tx_power_dbm = clamp_tx_power(&self.config, value)?;
4581                Ok(true)
4582            }
4583            prop::PHY_LORA_BW => {
4584                self.device.settings.bw_hz = validate_bw_hz(value)?;
4585                Ok(true)
4586            }
4587            prop::PHY_LORA_SF => {
4588                self.device.settings.sf = validate_sf(value)?;
4589                Ok(true)
4590            }
4591            prop::PHY_LORA_CR => {
4592                self.device.settings.cr_denom = validate_cr(value)?;
4593                Ok(true)
4594            }
4595            prop::PHY_LORA_SW => {
4596                // v0: the sync word is fixed at build time; accept only
4597                // a write of the same value.
4598                if parse_u16(value)? != self.config.sync_word {
4599                    return Err(Status::INVALID_ARGUMENT);
4600                }
4601                Ok(false)
4602            }
4603            prop::PHY_DUTY_LIMIT => {
4604                self.config.duty.set_limit(parse_u16(value)?);
4605                Ok(false)
4606            }
4607            prop::MAC_PROMISCUOUS => {
4608                // Session-scoped: reverts to false on every attach.
4609                self.session.promiscuous = parse_bool(value)?;
4610                Ok(false)
4611            }
4612            prop::HOST_AUTO_ACK => {
4613                self.host.auto_ack = parse_bool(value)?;
4614                Ok(false)
4615            }
4616            // Whole-table replacement: the complete value is validated
4617            // into a candidate table before anything changes, so no
4618            // observer sees a mixture of old and new contents.
4619            prop::HOST_RX_FILTERS => {
4620                self.host.filters = FilterTable::parse_table(value)?;
4621                Ok(false)
4622            }
4623            // Key-bearing writes require the transport's security
4624            // binding (spec §Provisioning Security).
4625            prop::HOST_CHANNEL_KEYS => {
4626                self.require_secure_link()?;
4627                let mut table = ChannelKeyTable::default();
4628                for key in items::fixed_items::<{ items::CHANNEL_KEY_LEN }>(value)
4629                    .map_err(|_| Status::INVALID_ARGUMENT)?
4630                {
4631                    // Duplicate keys in a set value collapse.
4632                    match table.insert(self.channel_entry(key)) {
4633                        Ok(()) | Err(Status::ALREADY) => {}
4634                        Err(status) => return Err(status),
4635                    }
4636                }
4637                self.host.channel_keys = table;
4638                Ok(false)
4639            }
4640            // Reconcile, not rebuild: the complete value is validated
4641            // into a candidate list before anything changes, and then
4642            // the live table's entry *set* is replaced while peers
4643            // present in both keep the replay window keyed to their
4644            // identity. See `PeerKeyTable::reconcile`.
4645            prop::HOST_PEER_KEYS => {
4646                self.require_secure_link()?;
4647                let mut desired: HeaplessVec<items::PeerKeyEntry, MAX_PEER_KEYS> =
4648                    HeaplessVec::new();
4649                for item in items::fixed_items::<{ items::PeerKeyEntry::WIRE_LEN }>(value)
4650                    .map_err(|_| Status::INVALID_ARGUMENT)?
4651                {
4652                    let entry =
4653                        items::PeerKeyEntry::decode(item).map_err(|_| Status::INVALID_ARGUMENT)?;
4654                    // A repeated public key replaces the earlier entry.
4655                    match desired
4656                        .iter_mut()
4657                        .find(|existing| existing.public_key == entry.public_key)
4658                    {
4659                        Some(existing) => *existing = entry,
4660                        None => desired.push(entry).map_err(|_| Status::NOMEM)?,
4661                    }
4662                }
4663                self.host.peer_keys.reconcile(&desired);
4664                Ok(false)
4665            }
4666            prop::DEV_CHANNEL_KEYS => {
4667                self.require_secure_link()?;
4668                let mut table = ChannelKeyTable::default();
4669                for key in items::fixed_items::<{ items::CHANNEL_KEY_LEN }>(value)
4670                    .map_err(|_| Status::INVALID_ARGUMENT)?
4671                {
4672                    match table.insert(self.channel_entry(key)) {
4673                        Ok(()) | Err(Status::ALREADY) => {}
4674                        Err(status) => return Err(status),
4675                    }
4676                }
4677                self.device.channel_keys = table;
4678                self.bump_dev_domain();
4679                Ok(false)
4680            }
4681            // Peer public keys carry no secret material, so no
4682            // secure-link gate — like PROP_HOST_KEY itself.
4683            prop::DEV_PEERS => {
4684                self.device.peers = DevPeerTable::parse_table(value)?;
4685                self.bump_dev_domain();
4686                Ok(false)
4687            }
4688            // Likewise public keys, and likewise ungated: authorizing a
4689            // node to manage this device says nothing secret.
4690            prop::DEV_ADMINS => {
4691                self.device.admins = DevAdminTable::parse_table(value)?;
4692                self.bump_dev_domain();
4693                Ok(false)
4694            }
4695            // Device-domain forwarding switch. Accepted regardless of
4696            // whether a device identity exists yet: the flag is persisted
4697            // and takes effect the moment the device node is brought up
4698            // (store-and-defer). The firmware reconciles it against the
4699            // live MAC via the dev-domain version.
4700            prop::MAC_REPEATER_ENABLED if self.config.mac_node => {
4701                self.device.repeater_enabled = parse_bool(value)?;
4702                self.bump_dev_domain();
4703                Ok(false)
4704            }
4705            // The advertised role. An empty value hands the choice back
4706            // to the device, which derives it from what it is actually
4707            // doing; any other value is advertised verbatim, including
4708            // combinations the device cannot infer — a mobile repeater,
4709            // a fixed tracker.
4710            prop::IDENT_ROLE => {
4711                self.device.ident_role = match value.len() {
4712                    0 => None,
4713                    1 => Some(value[0]),
4714                    _ => return Err(Status::INVALID_ARGUMENT),
4715                };
4716                self.bump_dev_domain();
4717                Ok(false)
4718            }
4719            prop::IDENT_MOBILE => {
4720                self.device.ident_mobile = parse_bool(value)?;
4721                self.bump_dev_domain();
4722                Ok(false)
4723            }
4724            // The advertised position. Refused while the device is
4725            // maintaining it from its own fixes: a written value would
4726            // survive only until the next one, and a setting that
4727            // silently reverts is worse than one that refuses.
4728            prop::IDENT_LOCATION => {
4729                if self.gnss_ident_update() {
4730                    return Err(Status::INVALID_STATE);
4731                }
4732                self.device.ident_location = validate_ident_location(value)?;
4733                self.bump_dev_domain();
4734                Ok(false)
4735            }
4736            prop::IDENT_ALTITUDE => {
4737                if self.gnss_ident_update() {
4738                    return Err(Status::INVALID_STATE);
4739                }
4740                self.device.ident_altitude_m = validate_ident_altitude(value)?;
4741                self.bump_dev_domain();
4742                Ok(false)
4743            }
4744            prop::DEV_DISCOVERABLE => {
4745                self.device.dev_discoverable = parse_bool(value)?;
4746                self.bump_dev_domain();
4747                Ok(false)
4748            }
4749            // Advertisement policy. Each interval stands alone: a mesh
4750            // usually wants cheap beacons often and expensive identity
4751            // advertisements rarely, and a single knob could not say that.
4752            prop::ADVERT_INTERVAL => {
4753                self.device.advert_interval_s = validate_announce_interval(value)?;
4754                self.bump_dev_domain();
4755                Ok(false)
4756            }
4757            prop::BEACON_INTERVAL => {
4758                self.device.beacon_interval_s = validate_announce_interval(value)?;
4759                self.bump_dev_domain();
4760                Ok(false)
4761            }
4762            prop::STARTUP_BEACON => {
4763                self.device.startup_beacon = parse_bool(value)?;
4764                self.bump_dev_domain();
4765                Ok(false)
4766            }
4767            // The forwarding policy. All four are accepted while
4768            // forwarding is disabled and simply take effect when it is
4769            // enabled, so an administrator can stage a whole repeater
4770            // configuration and turn it on last. Empty means "no gate"
4771            // in every case, which is also the post-reset value.
4772            prop::MAC_REPEATER_REGIONS if self.config.mac_node => {
4773                self.device.repeater_regions = RepeaterRegions::parse_table(value)?;
4774                self.bump_dev_domain();
4775                Ok(false)
4776            }
4777            // Not cross-checked against the region list: the two are
4778            // written separately and in either order, so enforcing
4779            // membership here would reject a legitimate write purely for
4780            // arriving first.
4781            prop::MAC_REPEATER_DEFAULT_REGION if self.config.mac_node => {
4782                self.device.repeater_default_region = parse_region_code(value)?;
4783                self.bump_dev_domain();
4784                Ok(false)
4785            }
4786            prop::MAC_REPEATER_MIN_RSSI if self.config.mac_node => {
4787                self.device.repeater_min_rssi = match value.is_empty() {
4788                    true => None,
4789                    false => Some(parse_i16(value)?),
4790                };
4791                self.bump_dev_domain();
4792                Ok(false)
4793            }
4794            prop::MAC_REPEATER_MIN_SNR if self.config.mac_node => {
4795                self.device.repeater_min_snr = match value.is_empty() {
4796                    true => None,
4797                    false => Some(parse_i8(value)?),
4798                };
4799                self.bump_dev_domain();
4800                Ok(false)
4801            }
4802            // The receiver switch and the positioning policy. All reach
4803            // the platform through the device-domain mirror rather than
4804            // through an effect of their own, which is what makes a host
4805            // write, a boot restore, and a `CMD_RST` land identically.
4806            prop::TZ_OFFSET if self.config.time.is_some() => {
4807                self.device.tz_offset_min = validate_tz_offset(value)?;
4808                self.bump_dev_domain();
4809                Ok(false)
4810            }
4811            prop::GNSS_ENABLED if self.config.gnss.is_some() => {
4812                self.device.gnss_enabled = parse_bool(value)?;
4813                self.bump_dev_domain();
4814                Ok(false)
4815            }
4816            prop::GNSS_IDENT_UPDATE if self.config.gnss.is_some() => {
4817                self.device.gnss_ident_update = parse_bool(value)?;
4818                self.bump_dev_domain();
4819                Ok(false)
4820            }
4821            // Accepted while auto-update is off, like the repeater policy:
4822            // an administrator stages the whole configuration and turns it
4823            // on last.
4824            prop::GNSS_IDENT_PRECISION if self.config.gnss.is_some() => {
4825                self.device.gnss_ident_precision = validate_ident_precision(value)?;
4826                self.bump_dev_domain();
4827                Ok(false)
4828            }
4829            // Reaches the transport through the device-domain mirror
4830            // like the rest, so the host write, the boot restore, and the
4831            // button on the front of the device all land the same way.
4832            prop::BLE_ENABLED if self.config.ble => {
4833                self.device.ble_enabled = parse_bool(value)?;
4834                self.bump_dev_domain();
4835                Ok(false)
4836            }
4837            prop::GNSS_TIME_TRUST if self.config.gnss.is_some() => {
4838                self.device.gnss_time_trust = parse_bool(value)?;
4839                self.bump_dev_domain();
4840                Ok(false)
4841            }
4842            // This device's queue size is fixed; adjustment is optional in
4843            // the spec and unimplemented here.
4844            prop::HOST_RX_QUEUE_CAPACITY => Err(Status::UNIMPLEMENTED),
4845            // Known read-only properties. PROP_DEV_KEY changes only
4846            // through PROP_DEV_PRIVATE_KEY provisioning.
4847            prop::LAST_STATUS
4848            | prop::PROTOCOL_VERSION
4849            | prop::DEV_VERSION
4850            | prop::DEV_MODEL
4851            | prop::INTERFACE_TYPE
4852            | prop::CAPS
4853            | prop::UPTIME
4854            | prop::PHY_RSSI
4855            | prop::PHY_MTU
4856            | prop::PHY_DUTY_NOW
4857            | prop::DEV_KEY
4858            | prop::HOST_RX_QUEUE_COUNT
4859            | prop::HOST_RX_QUEUE_DROPPED
4860            | prop::SAVED => Err(Status::INVALID_ARGUMENT),
4861            prop::BATTERY if self.config.battery.is_some() => Err(Status::INVALID_ARGUMENT),
4862            // Positioning telemetry reports what the receiver found and
4863            // is not writable. `PROP_GNSS_LOCATION` and
4864            // `PROP_GNSS_ALTITUDE` are the ones that could plausibly
4865            // become writable — a fixed node placed by hand — but that
4866            // needs a rule for which source wins over the other, so they
4867            // stay read-only until there is one.
4868            key if gnss::is_positioning_property(key) && self.config.gnss.is_some() => {
4869                Err(Status::INVALID_ARGUMENT)
4870            }
4871            prop::ILLUMINANCE if self.config.illuminance => Err(Status::INVALID_ARGUMENT),
4872            // Bonds are enrolled by pairing and removed by
4873            // `CMD_BLE_CLEAR_BONDS`; the count reports that, it does not
4874            // steer it.
4875            prop::BLE_BOND_COUNT if self.config.ble_pairing => Err(Status::INVALID_ARGUMENT),
4876            // Who is connected is the transport's to report, not the
4877            // host's to arrange. A host that wants nobody connected has
4878            // `PROP_BLE_ENABLED`.
4879            prop::BLE_LINK if self.config.ble => Err(Status::INVALID_ARGUMENT),
4880            _ => Err(Status::PROP_NOT_FOUND),
4881        }
4882    }
4883
4884    /// `CMD_PROP_INSERT`: add one item (in item form, no length prefix)
4885    /// to a multi-value property.
4886    fn prop_insert(&mut self, tid: u8, key: u32, item: &[u8], emit: &mut impl FnMut(&[u8])) {
4887        if self.is_admin() && !admin_reachable(key) {
4888            return self.complete(tid, Status::PROP_NOT_FOUND, emit);
4889        }
4890        match key {
4891            prop::HOST_RX_FILTERS => {
4892                let filter = match decode_filter(item) {
4893                    Ok(filter) => filter,
4894                    Err(status) => return self.complete(tid, status, emit),
4895                };
4896                match self.host.filters.insert(filter) {
4897                    Ok(()) => self.send_prop_inserted(tid, key, item, emit),
4898                    Err(status) => self.complete(tid, status, emit),
4899                }
4900            }
4901            // The item is the region string; the device derives the code.
4902            prop::MAC_REPEATER_REGIONS if self.config.mac_node => {
4903                let entry = match region_entry(item) {
4904                    Ok(entry) => entry,
4905                    Err(status) => return self.complete(tid, status, emit),
4906                };
4907                match self.device.repeater_regions.push(entry) {
4908                    Ok(()) => {
4909                        self.bump_dev_domain();
4910                        self.send_prop_inserted(tid, key, item, emit)
4911                    }
4912                    Err(status) => self.complete(tid, status, emit),
4913                }
4914            }
4915            // Key-bearing inserts require the transport's security
4916            // binding. The emitted digest never contains key material.
4917            prop::HOST_CHANNEL_KEYS => {
4918                let result = self.require_secure_link().and_then(|()| {
4919                    let key: &[u8; items::CHANNEL_KEY_LEN] =
4920                        item.try_into().map_err(|_| Status::INVALID_ARGUMENT)?;
4921                    let entry = self.channel_entry(key);
4922                    self.host.channel_keys.insert(entry).map(|()| entry.id)
4923                });
4924                match result {
4925                    Ok(id) => self.send_prop_inserted(tid, key, &id, emit),
4926                    Err(status) => self.complete(tid, status, emit),
4927                }
4928            }
4929            prop::HOST_PEER_KEYS => {
4930                let result = self.require_secure_link().and_then(|()| {
4931                    let entry =
4932                        items::PeerKeyEntry::decode(item).map_err(|_| Status::INVALID_ARGUMENT)?;
4933                    // A matching public key replaces the stored key
4934                    // material (never STATUS_ALREADY).
4935                    self.host.peer_keys.insert(entry).map(|()| entry.public_key)
4936                });
4937                match result {
4938                    Ok(public_key) => self.send_prop_inserted(tid, key, &public_key, emit),
4939                    Err(status) => self.complete(tid, status, emit),
4940                }
4941            }
4942            prop::DEV_CHANNEL_KEYS => {
4943                let result = self.require_secure_link().and_then(|()| {
4944                    let key: &[u8; items::CHANNEL_KEY_LEN] =
4945                        item.try_into().map_err(|_| Status::INVALID_ARGUMENT)?;
4946                    let entry = self.channel_entry(key);
4947                    self.device.channel_keys.insert(entry).map(|()| entry.id)
4948                });
4949                match result {
4950                    Ok(id) => {
4951                        self.bump_dev_domain();
4952                        self.send_prop_inserted(tid, key, &id, emit);
4953                    }
4954                    Err(status) => self.complete(tid, status, emit),
4955                }
4956            }
4957            prop::DEV_PEERS => {
4958                let result = item
4959                    .try_into()
4960                    .map_err(|_| Status::INVALID_ARGUMENT)
4961                    .and_then(|public_key: &[u8; items::PUBLIC_KEY_LEN]| {
4962                        self.device.peers.insert(*public_key)
4963                    });
4964                match result {
4965                    Ok(()) => {
4966                        self.bump_dev_domain();
4967                        self.send_prop_inserted(tid, key, item, emit);
4968                    }
4969                    Err(status) => self.complete(tid, status, emit),
4970                }
4971            }
4972            prop::DEV_ADMINS => {
4973                let result = item
4974                    .try_into()
4975                    .map_err(|_| Status::INVALID_ARGUMENT)
4976                    .and_then(|public_key: &[u8; items::PUBLIC_KEY_LEN]| {
4977                        self.device.admins.insert(*public_key)
4978                    });
4979                match result {
4980                    Ok(()) => {
4981                        self.bump_dev_domain();
4982                        self.send_prop_inserted(tid, key, item, emit);
4983                    }
4984                    Err(status) => self.complete(tid, status, emit),
4985                }
4986            }
4987            // A known property that is not a mutable multi-value
4988            // property cannot be inserted into.
4989            _ if self.known_prop(key) => self.complete(tid, Status::INVALID_ARGUMENT, emit),
4990            _ => self.complete(tid, Status::PROP_NOT_FOUND, emit),
4991        }
4992    }
4993
4994    /// `CMD_PROP_REMOVE`: remove the item matching the selector from a
4995    /// multi-value property.
4996    fn prop_remove(&mut self, tid: u8, key: u32, selector: &[u8], emit: &mut impl FnMut(&[u8])) {
4997        if self.is_admin() && !admin_reachable(key) {
4998            return self.complete(tid, Status::PROP_NOT_FOUND, emit);
4999        }
5000        match key {
5001            prop::HOST_RX_FILTERS => {
5002                // The remove selector is the full item.
5003                let filter = match decode_filter(selector) {
5004                    Ok(filter) => filter,
5005                    Err(status) => return self.complete(tid, status, emit),
5006                };
5007                match self.host.filters.remove(filter) {
5008                    Ok(()) => self.send_prop_removed(tid, key, selector, emit),
5009                    Err(status) => self.complete(tid, status, emit),
5010                }
5011            }
5012            // Selected by the string as written, not by the derived code:
5013            // two names can share a code only by collision, and the list
5014            // an administrator reads back is the list they remove from.
5015            prop::MAC_REPEATER_REGIONS if self.config.mac_node => {
5016                if let Err(status) = region_entry(selector) {
5017                    return self.complete(tid, status, emit);
5018                }
5019                match self.device.repeater_regions.remove(selector) {
5020                    Ok(()) => {
5021                        self.bump_dev_domain();
5022                        self.send_prop_removed(tid, key, selector, emit)
5023                    }
5024                    Err(status) => self.complete(tid, status, emit),
5025                }
5026            }
5027            // The channel-key remove selector is the key itself; the
5028            // digest reported back is the derived channel identifier.
5029            prop::HOST_CHANNEL_KEYS => {
5030                let result = selector
5031                    .try_into()
5032                    .map_err(|_| Status::INVALID_ARGUMENT)
5033                    .and_then(|key: &[u8; items::CHANNEL_KEY_LEN]| {
5034                        self.host.channel_keys.remove(key)
5035                    });
5036                match result {
5037                    Ok(id) => self.send_prop_removed(tid, key, &id, emit),
5038                    Err(status) => self.complete(tid, status, emit),
5039                }
5040            }
5041            // The peer remove selector is the peer public key (already
5042            // the digest form).
5043            prop::HOST_PEER_KEYS => {
5044                let result = selector
5045                    .try_into()
5046                    .map_err(|_| Status::INVALID_ARGUMENT)
5047                    .and_then(|public_key: &[u8; items::PUBLIC_KEY_LEN]| {
5048                        self.host.peer_keys.remove(public_key)
5049                    });
5050                match result {
5051                    Ok(()) => self.send_prop_removed(tid, key, selector, emit),
5052                    Err(status) => self.complete(tid, status, emit),
5053                }
5054            }
5055            prop::DEV_CHANNEL_KEYS => {
5056                let result = selector
5057                    .try_into()
5058                    .map_err(|_| Status::INVALID_ARGUMENT)
5059                    .and_then(|key: &[u8; items::CHANNEL_KEY_LEN]| {
5060                        self.device.channel_keys.remove(key)
5061                    });
5062                match result {
5063                    Ok(id) => {
5064                        self.bump_dev_domain();
5065                        self.send_prop_removed(tid, key, &id, emit);
5066                    }
5067                    Err(status) => self.complete(tid, status, emit),
5068                }
5069            }
5070            prop::DEV_PEERS => {
5071                let result = selector
5072                    .try_into()
5073                    .map_err(|_| Status::INVALID_ARGUMENT)
5074                    .and_then(|public_key: &[u8; items::PUBLIC_KEY_LEN]| {
5075                        self.device.peers.remove(public_key)
5076                    });
5077                match result {
5078                    Ok(()) => {
5079                        self.bump_dev_domain();
5080                        self.send_prop_removed(tid, key, selector, emit);
5081                    }
5082                    Err(status) => self.complete(tid, status, emit),
5083                }
5084            }
5085            prop::DEV_ADMINS => {
5086                let result = selector
5087                    .try_into()
5088                    .map_err(|_| Status::INVALID_ARGUMENT)
5089                    .and_then(|public_key: &[u8; items::PUBLIC_KEY_LEN]| {
5090                        self.device.admins.remove(public_key)
5091                    });
5092                match result {
5093                    Ok(()) => {
5094                        self.bump_dev_domain();
5095                        self.send_prop_removed(tid, key, selector, emit);
5096                    }
5097                    Err(status) => self.complete(tid, status, emit),
5098                }
5099            }
5100            _ if self.known_prop(key) => self.complete(tid, Status::INVALID_ARGUMENT, emit),
5101            _ => self.complete(tid, Status::PROP_NOT_FOUND, emit),
5102        }
5103    }
5104
5105    /// Derive a channel key's identifier (its digest form and implicit
5106    /// receive filter).
5107    fn channel_entry(&self, key: &[u8; items::CHANNEL_KEY_LEN]) -> ChannelKeyEntry {
5108        ChannelKeyEntry {
5109            key: *key,
5110            id: self.engine.derive_channel_id(&ChannelKey(*key)).0,
5111        }
5112    }
5113
5114    /// Refuse key-bearing writes over a transport that does not meet
5115    /// its security binding (spec §Provisioning Security).
5116    ///
5117    /// The mesh administrative binding meets it inherently: an executed
5118    /// request has already arrived authenticated and encrypted from a
5119    /// listed administrator, which is a stronger statement than either
5120    /// physical possession or a bonded link.
5121    fn require_secure_link(&self) -> Result<(), Status> {
5122        if self.link_secure || self.is_admin() {
5123            Ok(())
5124        } else {
5125            Err(Status::INVALID_STATE)
5126        }
5127    }
5128
5129    fn str_send(
5130        &mut self,
5131        tid: u8,
5132        payload: &StreamPayload<'_>,
5133        now_ms: u64,
5134        emit: &mut impl FnMut(&[u8]),
5135    ) -> Option<Effect> {
5136        if payload.stream != stream::PHY_RAW {
5137            self.complete(tid, Status::PROP_NOT_FOUND, emit);
5138            return None;
5139        }
5140        if !self.device.settings.enabled {
5141            self.complete(tid, Status::INVALID_STATE, emit);
5142            return None;
5143        }
5144        if payload.data.len() > usize::from(self.config.mtu) {
5145            self.complete(tid, Status::INVALID_ARGUMENT, emit);
5146            return None;
5147        }
5148        let Ok(tx_meta) = TxMeta::decode(payload.metadata) else {
5149            self.complete(tid, Status::PARSE_ERROR, emit);
5150            return None;
5151        };
5152        if self.session.pending.is_full() {
5153            self.complete(tid, Status::BUSY, emit);
5154            return None;
5155        }
5156
5157        // In backhaul mode the frame is handed to the device's own node
5158        // rather than transmitted, so it costs no airtime here. What the
5159        // node then chooses to repeat is charged to the duty ledger when
5160        // the node transmits it, which is where the airtime is actually
5161        // spent — charging both would bill one frame twice.
5162        let airtime_ms = if self.session.backhaul {
5163            0
5164        } else {
5165            lora_airtime_ms(
5166                self.device.settings.sf,
5167                self.device.settings.bw_hz,
5168                self.device.settings.cr_denom,
5169                payload.data.len(),
5170            )
5171        };
5172        let projected_airtime_ms = self
5173            .session
5174            .pending
5175            .iter()
5176            .fold(airtime_ms, |total, pending| {
5177                total.saturating_add(pending.airtime_ms)
5178            });
5179        if tx_meta.flags & meta::TX_FLAG_NODUTY == 0
5180            && self.config.duty.would_exceed(now_ms, projected_airtime_ms)
5181        {
5182            self.complete(tid, Status::DUTY_LIMIT, emit);
5183            return None;
5184        }
5185        let was_empty = self.session.pending.is_empty();
5186        let mut data = HeaplessVec::new();
5187        // The MTU check above proves this fixed-capacity copy can succeed.
5188        data.extend_from_slice(payload.data)
5189            .expect("payload bounded by MAX_MTU");
5190        let queued = self.session.pending.push_back(PendingTx {
5191            data,
5192            tid,
5193            airtime_ms,
5194            // The per-frame override is clamped to the radio's range for
5195            // the same reason `PROP_PHY_TX_POWER` is: an unreachable
5196            // power transmits at the nearest reachable one.
5197            power: match tx_meta.power {
5198                meta::TX_POWER_DEFAULT => TxPower::Default,
5199                meta::TX_POWER_MAX => TxPower::Max,
5200                dbm => TxPower::Dbm(
5201                    dbm.clamp(self.config.min_tx_power_dbm, self.config.max_tx_power_dbm),
5202                ),
5203            },
5204            autonomous: false,
5205            ack_for: None,
5206            nocca: tx_meta.flags & meta::TX_FLAG_NOCCA != 0,
5207        });
5208        debug_assert!(queued.is_ok(), "queue fullness checked above");
5209        // Remember this frame's MIC prefix so its echoes — the returning
5210        // (destination-hintless) MAC ack, a repeater's onward copy — can be
5211        // recognized as ours.
5212        self.host.note_tx_mic(payload.data);
5213        was_empty.then_some(Effect::StartTransmit)
5214    }
5215
5216    // ─── Property encoding ───────────────────────────────────────────
5217
5218    /// Whether `key` names a property this session knows, including
5219    /// write-only (`PROP_BLE_PAIRING_PIN`) and deferred-read
5220    /// (`PROP_PHY_RSSI`) properties that `encode_prop` cannot produce.
5221    /// The counter a key names, together with the ledger holding it —
5222    /// or `None` when this device does not have that property.
5223    ///
5224    /// One gate for the get, the set, and `known_prop`, so the three can
5225    /// never disagree about whether a counter exists.
5226    fn stats_counter(&self, key: u32) -> Option<(Counter, &'static StatsLedger)> {
5227        let counter = Counter::from_property(key)?;
5228        let ledger = self.config.stats?;
5229        // The four the MAC feeds exist only where there is a MAC.
5230        // Answering 0 instead would read as a repeater that has never
5231        // repeated anything, which is a different device.
5232        (!counter.needs_node() || self.config.mac_node).then_some((counter, ledger))
5233    }
5234
5235    fn known_prop(&self, key: u32) -> bool {
5236        if key == prop::DEV_MODEL {
5237            return self.config.dev_model.is_some();
5238        }
5239        if key == prop::BATTERY {
5240            return self.config.battery.is_some();
5241        }
5242        if key == prop::ALERT {
5243            return self.config.alert.is_some();
5244        }
5245        if key == prop::ILLUMINANCE {
5246            return self.config.illuminance;
5247        }
5248        if key == prop::BLE_ENABLED {
5249            return self.config.ble;
5250        }
5251        if key == prop::BLE_BOND_COUNT {
5252            return self.config.ble_pairing;
5253        }
5254        if key == prop::BLE_LINK {
5255            return self.config.ble;
5256        }
5257        if key == prop::BLE_PAIRING {
5258            return self.config.ble_pairing;
5259        }
5260        if matches!(key, prop::TIME | prop::TZ_OFFSET) {
5261            return self.config.time.is_some();
5262        }
5263        if Counter::from_property(key).is_some() {
5264            return self.stats_counter(key).is_some();
5265        }
5266        if matches!(
5267            key,
5268            prop::MAC_REPEATER_ENABLED
5269                | prop::MAC_REPEATER_REGIONS
5270                | prop::MAC_REPEATER_DEFAULT_REGION
5271                | prop::MAC_REPEATER_MIN_RSSI
5272                | prop::MAC_REPEATER_MIN_SNR
5273                | prop::MAC_BACKHAUL
5274        ) {
5275            return self.config.mac_node;
5276        }
5277        if gnss::is_positioning_property(key)
5278            || matches!(
5279                key,
5280                prop::GNSS_ENABLED
5281                    | prop::GNSS_IDENT_UPDATE
5282                    | prop::GNSS_IDENT_PRECISION
5283                    | prop::GNSS_TIME_TRUST
5284            )
5285        {
5286            return self.config.gnss.is_some();
5287        }
5288        matches!(
5289            key,
5290            prop::LAST_STATUS
5291                | prop::PROTOCOL_VERSION
5292                | prop::DEV_VERSION
5293                | prop::INTERFACE_TYPE
5294                | prop::CAPS
5295                | prop::UPTIME
5296                | prop::PHY_ENABLED
5297                | prop::PHY_FREQ
5298                | prop::PHY_TX_POWER
5299                | prop::PHY_RSSI
5300                | prop::PHY_LORA_BW
5301                | prop::PHY_LORA_SF
5302                | prop::PHY_LORA_CR
5303                | prop::PHY_MTU
5304                | prop::PHY_LORA_SW
5305                | prop::DEV_NAME
5306                | prop::DEV_KEY
5307                | prop::DEV_PRIVATE_KEY
5308                | prop::DEV_CHANNEL_KEYS
5309                | prop::DEV_PEERS
5310                | prop::DEV_ADMINS
5311                | prop::IDENT
5312                | prop::IDENT_ROLE
5313                | prop::IDENT_MOBILE
5314                | prop::IDENT_LOCATION
5315                | prop::IDENT_ALTITUDE
5316                | prop::DEV_DISCOVERABLE
5317                | prop::ADVERT_INTERVAL
5318                | prop::BEACON_INTERVAL
5319                | prop::STARTUP_BEACON
5320                | prop::PHY_DUTY_NOW
5321                | prop::PHY_DUTY_LIMIT
5322                | prop::BLE_PAIRING_PIN
5323                | prop::MAC_PROMISCUOUS
5324                | prop::SAVED
5325                | prop::HOST_KEY
5326                | prop::HOST_RX_FILTERS
5327                | prop::HOST_CHANNEL_KEYS
5328                | prop::HOST_PEER_KEYS
5329                | prop::HOST_AUTO_ACK
5330                | prop::HOST_RX_QUEUE_COUNT
5331                | prop::HOST_RX_QUEUE_CAPACITY
5332                | prop::HOST_RX_QUEUE_DROPPED
5333        )
5334    }
5335
5336    fn encode_prop(&mut self, key: u32, now_ms: u64, out: &mut [u8; PROP_BUF]) -> PropValue {
5337        // Read straight out of the shared ledger, like the duty usage
5338        // below and for the same reason: the session is one radio client
5339        // of several, and what a host is asking about is the antenna.
5340        // Lifted out of the match because the counter and the ledger are
5341        // two bindings a match guard cannot make.
5342        if let Some((counter, ledger)) = self.stats_counter(key) {
5343            return PropValue::Encoded(put(out, &ledger.get(counter).to_le_bytes()));
5344        }
5345        let len = match key {
5346            prop::LAST_STATUS => pui::encode(self.last_status.0, out).unwrap_or(0),
5347            prop::PROTOCOL_VERSION => {
5348                out[0] = ids::PROTOCOL_MAJOR_VERSION;
5349                out[1] = ids::PROTOCOL_MINOR_VERSION;
5350                2
5351            }
5352            prop::DEV_VERSION => put_str(out, self.config.dev_version),
5353            // Absent rather than empty on a device with no fixed model;
5354            // `known_prop` refuses the get before reaching this.
5355            prop::DEV_MODEL => put_str(out, self.config.dev_model.unwrap_or_default()),
5356            prop::INTERFACE_TYPE => pui::encode(ids::INTERFACE_TYPE, out).unwrap_or(0),
5357            // `now_ms` is milliseconds since boot by the `Clock` contract,
5358            // so uptime is a read of the clock rather than a counter this
5359            // session has to keep. Saturating instead of wrapping: a
5360            // device that somehow outlives the u32 pins at the ceiling
5361            // rather than appearing to have just rebooted.
5362            prop::UPTIME => put(
5363                out,
5364                &u32::try_from(now_ms / 1000)
5365                    .unwrap_or(u32::MAX)
5366                    .to_le_bytes(),
5367            ),
5368            prop::CAPS => {
5369                let mut len = 0;
5370                let mac_node = self.config.mac_node;
5371                for (capability, advertised) in [
5372                    (cap::WRITABLE_RAW_STREAM, true),
5373                    (cap::PHY_DUTY_LIMIT, true),
5374                    (cap::DEV_NAME, true),
5375                    (cap::PHY_LORA, true),
5376                    (cap::HOST_FILTER, true),
5377                    (cap::HOST_RX_QUEUE, true),
5378                    (cap::HOST_KEYS, true),
5379                    (cap::HOST_AUTO_ACK, true),
5380                    (cap::SAVE, true),
5381                    (cap::DEV_IDENTITY, true),
5382                    (cap::REPEATER, mac_node),
5383                    (cap::IDENT, true),
5384                    (cap::ADMIN, true),
5385                    (cap::ADVERT, true),
5386                    (cap::MAC_BACKHAUL, mac_node),
5387                    (cap::CMD_MULTI, true),
5388                ] {
5389                    if !advertised {
5390                        continue;
5391                    }
5392                    len += pui::encode(capability, &mut out[len..]).unwrap_or(0);
5393                }
5394                if self.config.battery.is_some() {
5395                    len += pui::encode(cap::BATTERY, &mut out[len..]).unwrap_or(0);
5396                }
5397                if self.config.alert.is_some() {
5398                    len += pui::encode(cap::ALERT, &mut out[len..]).unwrap_or(0);
5399                }
5400                if self.config.time.is_some() {
5401                    len += pui::encode(cap::TIME, &mut out[len..]).unwrap_or(0);
5402                }
5403                if self.config.gnss.is_some() {
5404                    len += pui::encode(cap::GNSS, &mut out[len..]).unwrap_or(0);
5405                }
5406                if self.config.illuminance {
5407                    len += pui::encode(cap::ILLUMINANCE, &mut out[len..]).unwrap_or(0);
5408                }
5409                if self.config.ble {
5410                    len += pui::encode(cap::BLE, &mut out[len..]).unwrap_or(0);
5411                }
5412                if self.config.reboot {
5413                    len += pui::encode(cap::REBOOT, &mut out[len..]).unwrap_or(0);
5414                }
5415                if self.config.stats.is_some() {
5416                    len += pui::encode(cap::STATS, &mut out[len..]).unwrap_or(0);
5417                }
5418                len
5419            }
5420            prop::PHY_ENABLED => {
5421                out[0] = self.device.settings.enabled as u8;
5422                1
5423            }
5424            prop::PHY_FREQ => put(out, &self.device.settings.freq_khz.to_le_bytes()),
5425            prop::PHY_TX_POWER => {
5426                out[0] = self.device.settings.tx_power_dbm as u8;
5427                1
5428            }
5429            prop::PHY_RSSI => return PropValue::Unimplemented,
5430            // Deferred-read like PHY_RSSI: prop_get intercepts and
5431            // samples; this arm is only a fallback.
5432            prop::BATTERY if self.config.battery.is_some() => return PropValue::Unimplemented,
5433            prop::ILLUMINANCE if self.config.illuminance => return PropValue::Unimplemented,
5434            prop::PHY_LORA_BW => put(out, &self.device.settings.bw_hz.to_le_bytes()),
5435            prop::PHY_LORA_SF => {
5436                out[0] = self.device.settings.sf;
5437                1
5438            }
5439            prop::PHY_LORA_CR => {
5440                out[0] = self.device.settings.cr_denom;
5441                1
5442            }
5443            prop::PHY_MTU => put(out, &self.config.mtu.to_le_bytes()),
5444            prop::PHY_LORA_SW => put(out, &self.config.sync_word.to_le_bytes()),
5445            prop::DEV_NAME => put(out, &self.device.name[..self.device.name_len]),
5446            prop::DEV_KEY => match &self.dev_key {
5447                Some(key) => put(out, key),
5448                None => 0,
5449            },
5450            prop::DEV_CHANNEL_KEYS => {
5451                let mut len = 0;
5452                for entry in self.device.channel_keys.iter() {
5453                    len += put(&mut out[len..], &entry.id);
5454                }
5455                len
5456            }
5457            prop::DEV_PEERS => {
5458                let mut len = 0;
5459                for public_key in self.device.peers.iter() {
5460                    len += put(&mut out[len..], public_key);
5461                }
5462                len
5463            }
5464            prop::DEV_ADMINS => {
5465                let mut len = 0;
5466                for public_key in self.device.admins.iter() {
5467                    len += put(&mut out[len..], public_key);
5468                }
5469                len
5470            }
5471            prop::MAC_REPEATER_ENABLED if self.config.mac_node => {
5472                out[0] = self.device.repeater_enabled as u8;
5473                1
5474            }
5475            // Deferred-read like PHY_RSSI: prop_get intercepts and asks
5476            // the platform to sign; this arm is only a fallback.
5477            prop::IDENT => return PropValue::Unimplemented,
5478            prop::IDENT_ROLE => match self.device.ident_role {
5479                Some(role) => {
5480                    out[0] = role;
5481                    1
5482                }
5483                None => 0,
5484            },
5485            prop::IDENT_MOBILE => {
5486                out[0] = self.device.ident_mobile as u8;
5487                1
5488            }
5489            prop::IDENT_LOCATION => put(out, &self.device.ident_location),
5490            // Re-encoded on every read, so what a host gets back is the
5491            // minimal form whatever width it wrote.
5492            prop::IDENT_ALTITUDE => match self.device.ident_altitude_m {
5493                Some(meters) => sint::encode(meters, out).unwrap_or(0),
5494                None => 0,
5495            },
5496            prop::DEV_DISCOVERABLE => {
5497                out[0] = self.device.dev_discoverable as u8;
5498                1
5499            }
5500            prop::ADVERT_INTERVAL => put(out, &self.device.advert_interval_s.to_le_bytes()),
5501            prop::BEACON_INTERVAL => put(out, &self.device.beacon_interval_s.to_le_bytes()),
5502            prop::STARTUP_BEACON => {
5503                out[0] = self.device.startup_beacon as u8;
5504                1
5505            }
5506            prop::ALERT if self.config.alert.is_some() => {
5507                pui::encode(self.alert.code(), out).unwrap_or(0)
5508            }
5509            // Deferred-read like PHY_RSSI: prop_get intercepts and asks
5510            // the platform; these arms are only fallbacks.
5511            prop::TIME if self.config.time.is_some() => return PropValue::Unimplemented,
5512            key if gnss::is_positioning_property(key) && self.config.gnss.is_some() => {
5513                return PropValue::Unimplemented;
5514            }
5515            prop::TZ_OFFSET if self.config.time.is_some() => {
5516                put(out, &self.device.tz_offset_min.to_le_bytes())
5517            }
5518            prop::GNSS_ENABLED if self.config.gnss.is_some() => {
5519                out[0] = self.device.gnss_enabled as u8;
5520                1
5521            }
5522            prop::GNSS_IDENT_UPDATE if self.config.gnss.is_some() => {
5523                out[0] = self.device.gnss_ident_update as u8;
5524                1
5525            }
5526            prop::GNSS_IDENT_PRECISION if self.config.gnss.is_some() => {
5527                out[0] = self.device.gnss_ident_precision;
5528                1
5529            }
5530            prop::GNSS_TIME_TRUST if self.config.gnss.is_some() => {
5531                out[0] = self.device.gnss_time_trust as u8;
5532                1
5533            }
5534            prop::BLE_ENABLED if self.config.ble => {
5535                out[0] = self.device.ble_enabled as u8;
5536                1
5537            }
5538            prop::BLE_BOND_COUNT if self.config.ble_pairing => {
5539                out[0] = self.ble_bond_count;
5540                1
5541            }
5542            prop::BLE_LINK if self.config.ble => {
5543                out[0] = self.ble_link.code();
5544                1
5545            }
5546            prop::BLE_PAIRING if self.config.ble_pairing => {
5547                out[0] = self.ble_pairing_open as u8;
5548                1
5549            }
5550            prop::MAC_REPEATER_REGIONS if self.config.mac_node => {
5551                // Digest form equals item form; items carry PUI length
5552                // prefixes in whole-table values.
5553                let mut len = 0;
5554                for entry in self.device.repeater_regions.iter() {
5555                    len += items::encode_prefixed_item(entry.text(), &mut out[len..])
5556                        .expect("out sized for a full region table");
5557                }
5558                len
5559            }
5560            prop::MAC_REPEATER_DEFAULT_REGION if self.config.mac_node => {
5561                match &self.device.repeater_default_region {
5562                    Some(code) => put(out, code),
5563                    None => 0,
5564                }
5565            }
5566            prop::MAC_REPEATER_MIN_RSSI if self.config.mac_node => {
5567                match self.device.repeater_min_rssi {
5568                    Some(rssi) => put(out, &rssi.to_le_bytes()),
5569                    None => 0,
5570                }
5571            }
5572            prop::MAC_REPEATER_MIN_SNR if self.config.mac_node => {
5573                match self.device.repeater_min_snr {
5574                    Some(snr) => {
5575                        out[0] = snr as u8;
5576                        1
5577                    }
5578                    None => 0,
5579                }
5580            }
5581            prop::PHY_DUTY_NOW => put(out, &self.config.duty.usage(now_ms).to_le_bytes()),
5582            prop::PHY_DUTY_LIMIT => put(out, &self.config.duty.limit().to_le_bytes()),
5583
5584            prop::MAC_PROMISCUOUS => {
5585                out[0] = self.session.promiscuous as u8;
5586                1
5587            }
5588            prop::MAC_BACKHAUL if self.config.mac_node => {
5589                out[0] = self.session.backhaul as u8;
5590                1
5591            }
5592            prop::SAVED => {
5593                out[0] = self.saved_status().as_octet();
5594                1
5595            }
5596            prop::HOST_KEY => match &self.host.key {
5597                Some(key) => put(out, key),
5598                None => 0,
5599            },
5600            // Key tables report digest forms only: derived channel
5601            // identifiers and peer public keys. Key material is never
5602            // read back (spec §Provisioning Security).
5603            prop::HOST_CHANNEL_KEYS => {
5604                let mut len = 0;
5605                for entry in self.host.channel_keys.iter() {
5606                    len += put(&mut out[len..], &entry.id);
5607                }
5608                len
5609            }
5610            prop::HOST_PEER_KEYS => {
5611                let mut len = 0;
5612                for slot in self.host.peer_keys.iter() {
5613                    len += put(&mut out[len..], &slot.entry.public_key);
5614                }
5615                len
5616            }
5617            prop::HOST_AUTO_ACK => {
5618                out[0] = self.host.auto_ack as u8;
5619                1
5620            }
5621            prop::HOST_RX_QUEUE_COUNT => put(out, &(self.host.queue.len as u16).to_le_bytes()),
5622            prop::HOST_RX_QUEUE_CAPACITY => put(out, &(RX_QUEUE_CAPACITY as u16).to_le_bytes()),
5623            prop::HOST_RX_QUEUE_DROPPED => put(out, &self.host.queue.dropped.to_le_bytes()),
5624            prop::HOST_RX_FILTERS => {
5625                // Digest form equals item form; items carry PUI length
5626                // prefixes in whole-table values.
5627                let mut len = 0;
5628                for filter in self.host.filters.iter() {
5629                    let mut item = [0u8; Filter::MAX_WIRE_LEN];
5630                    let item_len = filter.encode(&mut item).expect("MAX_WIRE_LEN sized");
5631                    len += items::encode_prefixed_item(&item[..item_len], &mut out[len..])
5632                        .expect("out sized for a full filter table");
5633                }
5634                len
5635            }
5636            _ => return PropValue::Unknown,
5637        };
5638        PropValue::Encoded(len)
5639    }
5640
5641    // ─── Emission helpers ────────────────────────────────────────────
5642
5643    /// Emit `CMD_PROP_IS` for `key` with `value` as a correlated
5644    /// response. Fire-and-forget commands (TID 0) receive nothing —
5645    /// the state change still happened.
5646    fn send_prop_is(&mut self, tid: u8, key: u32, value: &[u8], emit: &mut impl FnMut(&[u8])) {
5647        // While a multi-property command is being served, the value a
5648        // single-property path would have sent becomes that entry's
5649        // slot instead. This is what lets `CMD_PROP_MULTI_GET` and
5650        // `CMD_PROP_MULTI_SET` reuse `prop_get` and `prop_set` whole,
5651        // deferred platform round trips included, rather than
5652        // reimplementing the property surface.
5653        if let Some(state) = self.multi.as_mut() {
5654            state.truncated |= !state.push_entry(key, value);
5655            return;
5656        }
5657        if self.suppress_response(tid) {
5658            return;
5659        }
5660        let mut buf = [0u8; PROP_BUF + 16];
5661        if let Ok(len) = frame::prop_is(&mut buf, tid, key, value) {
5662            emit(&buf[..len]);
5663        }
5664    }
5665
5666    /// Whether a correlated response to a command bearing `tid` is owed.
5667    ///
5668    /// On a local binding TID 0 is fire-and-forget and the spec grants it
5669    /// no response. The administrative binding requires TID 0 on every
5670    /// frame and correlates by token instead, so there is no
5671    /// fire-and-forget form there and every request is answered.
5672    fn suppress_response(&self, tid: u8) -> bool {
5673        tid == TID_UNSOLICITED && !self.is_admin()
5674    }
5675
5676    /// Emit an *unsolicited* `CMD_PROP_IS` (TID 0) for `key`: the device
5677    /// publishing a new authoritative value for a reason the host did not
5678    /// initiate. The counterpart to [`Self::send_prop_is`], which
5679    /// deliberately suppresses TID 0 because a correlated response to a
5680    /// fire-and-forget command is not owed.
5681    ///
5682    /// Returns whether the frame was emitted.
5683    fn announce_prop_is(&mut self, key: u32, value: &[u8], emit: &mut impl FnMut(&[u8])) -> bool {
5684        let mut buf = [0u8; PROP_BUF + 16];
5685        match frame::prop_is(&mut buf, TID_UNSOLICITED, key, value) {
5686            Ok(len) => {
5687                emit(&buf[..len]);
5688                true
5689            }
5690            Err(_) => false,
5691        }
5692    }
5693
5694    /// Emit `CMD_PROP_INSERTED` for `key` with the item's digest form,
5695    /// as a correlated response (suppressed for TID 0; an unsolicited
5696    /// TID-0 `CMD_PROP_INSERTED` is reserved for changes the device makes
5697    /// for its own reasons, which none of these are).
5698    fn send_prop_inserted(
5699        &mut self,
5700        tid: u8,
5701        key: u32,
5702        digest: &[u8],
5703        emit: &mut impl FnMut(&[u8]),
5704    ) {
5705        if self.suppress_response(tid) {
5706            return;
5707        }
5708        let mut buf = [0u8; PROP_BUF + 16];
5709        if let Ok(len) = frame::prop_inserted(&mut buf, tid, key, digest) {
5710            emit(&buf[..len]);
5711        }
5712    }
5713
5714    /// Emit `CMD_PROP_REMOVED` for `key` with the item's digest form,
5715    /// as a correlated response (suppressed for TID 0).
5716    fn send_prop_removed(
5717        &mut self,
5718        tid: u8,
5719        key: u32,
5720        digest: &[u8],
5721        emit: &mut impl FnMut(&[u8]),
5722    ) {
5723        if self.suppress_response(tid) {
5724            return;
5725        }
5726        let mut buf = [0u8; PROP_BUF + 16];
5727        if let Ok(len) = frame::prop_removed(&mut buf, tid, key, digest) {
5728            emit(&buf[..len]);
5729        }
5730    }
5731
5732    /// Emit `PROP_LAST_STATUS` unconditionally (success paths and
5733    /// unsolicited notices).
5734    fn send_status(&mut self, tid: u8, status: Status, emit: &mut impl FnMut(&[u8])) {
5735        self.last_status = status;
5736        let mut buf = [0u8; 16];
5737        if let Ok(len) = frame::last_status(&mut buf, tid, status) {
5738            emit(&buf[..len]);
5739        }
5740    }
5741
5742    /// Record a command's completion status, success or failure.
5743    /// Correlated commands get a `PROP_LAST_STATUS` response;
5744    /// fire-and-forget (TID 0) commands only update `PROP_LAST_STATUS`
5745    /// — the spec grants them no correlated response. Deliberate
5746    /// unsolicited notifications (reset notices, `STATUS_RESET_RESTORED`)
5747    /// bypass this via [`Self::send_status`] with `TID_UNSOLICITED`.
5748    fn complete(&mut self, tid: u8, status: Status, emit: &mut impl FnMut(&[u8])) {
5749        // As in `send_prop_is`: inside a multi-property command the
5750        // status occupies the failing entry's slot. A read continues
5751        // past it; a write sequence stops there.
5752        if let Some(state) = self.multi.as_mut() {
5753            self.last_status = status;
5754            state.failed |= status != Status::OK;
5755            state.truncated |= !state.push_status(status);
5756            return;
5757        }
5758        if self.suppress_response(tid) {
5759            self.last_status = status;
5760        } else {
5761            self.send_status(tid, status, emit);
5762        }
5763    }
5764
5765    /// Publish a status the requester did not ask for: a reset notice, or
5766    /// `STATUS_RESET_RESTORED`.
5767    ///
5768    /// The counterpart to [`Self::complete`], and the reason the
5769    /// administrative binding needs the two told apart. That binding
5770    /// carries nothing the device was not asked for — a reset command is
5771    /// answered by no response payload at all, its delivery confirmed by
5772    /// the MAC acknowledgment and its completion by a later exchange
5773    /// reading `PROP_LAST_STATUS`. The status is still recorded, which is
5774    /// what that later exchange reads.
5775    fn announce_status(&mut self, status: Status, emit: &mut impl FnMut(&[u8])) {
5776        if self.is_admin() {
5777            self.last_status = status;
5778            return;
5779        }
5780        self.send_status(TID_UNSOLICITED, status, emit);
5781    }
5782}
5783
5784fn put(out: &mut [u8], bytes: &[u8]) -> usize {
5785    out[..bytes.len()].copy_from_slice(bytes);
5786    bytes.len()
5787}
5788
5789/// Write a STRING property value: the bytes plus the NUL terminator the
5790/// spec requires. A value too long for the buffer is truncated rather
5791/// than refused — these are constant identification strings, and a
5792/// shortened one is more useful to a host than an error.
5793fn put_str(out: &mut [u8], value: &str) -> usize {
5794    let bytes = value.as_bytes();
5795    let len = bytes.len().min(out.len() - 1);
5796    out[..len].copy_from_slice(&bytes[..len]);
5797    out[len] = 0;
5798    len + 1
5799}
5800
5801fn parse_bool(value: &[u8]) -> Result<bool, Status> {
5802    match value {
5803        [0] => Ok(false),
5804        [1] => Ok(true),
5805        _ => Err(Status::INVALID_ARGUMENT),
5806    }
5807}
5808
5809fn parse_u8(value: &[u8]) -> Result<u8, Status> {
5810    match value {
5811        [byte] => Ok(*byte),
5812        _ => Err(Status::INVALID_ARGUMENT),
5813    }
5814}
5815
5816fn parse_i8(value: &[u8]) -> Result<i8, Status> {
5817    parse_u8(value).map(|byte| byte as i8)
5818}
5819
5820fn parse_i16(value: &[u8]) -> Result<i16, Status> {
5821    parse_u16(value).map(|half| half as i16)
5822}
5823
5824fn parse_u16(value: &[u8]) -> Result<u16, Status> {
5825    match value {
5826        [lo, hi] => Ok(u16::from_le_bytes([*lo, *hi])),
5827        _ => Err(Status::INVALID_ARGUMENT),
5828    }
5829}
5830
5831fn parse_u32(value: &[u8]) -> Result<u32, Status> {
5832    match value {
5833        [a, b, c, d] => Ok(u32::from_le_bytes([*a, *b, *c, *d])),
5834        _ => Err(Status::INVALID_ARGUMENT),
5835    }
5836}
5837
5838fn valid_device_name(value: &[u8]) -> bool {
5839    (1..=MAX_DEVICE_NAME_LEN).contains(&value.len())
5840        && !value.contains(&0)
5841        && core::str::from_utf8(value).is_ok()
5842}
5843
5844// ─── Shared property-value validators ───────────────────────────────────
5845//
5846// Used by both the live `CMD_PROP_SET` path and the snapshot decoder, so
5847// a value a host could never write is also a value a snapshot cannot
5848// smuggle in. They validate the value only: transport authorization is
5849// the caller's business, and the restore path deliberately has none.
5850//
5851// Values naming a discrete choice (a frequency, a modem setting) are
5852// rejected outright when unsupported, because the nearest supported
5853// value is a different choice and silently substituting it produces a
5854// radio that cannot talk to the network it was pointed at. Values
5855// expressing "as much as the hardware has" are clamped instead — see
5856// `clamp_tx_power`.
5857
5858fn validate_freq_khz(config: &SessionConfig, value: &[u8]) -> Result<u32, Status> {
5859    let freq_khz = parse_u32(value)?;
5860    if !(config.freq_khz_min..=config.freq_khz_max).contains(&freq_khz) {
5861        return Err(Status::INVALID_ARGUMENT);
5862    }
5863    Ok(freq_khz)
5864}
5865
5866/// Transmit power is a hardware capability, not a protocol choice: a
5867/// request the radio cannot reach is honored as closely as it can be,
5868/// and the `CMD_PROP_IS` echo of the stored value reports what the host
5869/// actually got. Nothing else advertises the achievable range, so that
5870/// echo is how a host discovers it. Only the width is an error.
5871fn clamp_tx_power(config: &SessionConfig, value: &[u8]) -> Result<i8, Status> {
5872    Ok(parse_i8(value)?.clamp(config.min_tx_power_dbm, config.max_tx_power_dbm))
5873}
5874
5875fn validate_bw_hz(value: &[u8]) -> Result<u32, Status> {
5876    let bw_hz = parse_u32(value)?;
5877    if !umsh_ulcp::profiles::SUPPORTED_BANDWIDTHS_HZ.contains(&bw_hz) {
5878        return Err(Status::INVALID_ARGUMENT);
5879    }
5880    Ok(bw_hz)
5881}
5882
5883fn validate_sf(value: &[u8]) -> Result<u8, Status> {
5884    let sf = parse_u8(value)?;
5885    if !(5..=12).contains(&sf) {
5886        return Err(Status::INVALID_ARGUMENT);
5887    }
5888    Ok(sf)
5889}
5890
5891fn validate_cr(value: &[u8]) -> Result<u8, Status> {
5892    let cr = parse_u8(value)?;
5893    if !(5..=8).contains(&cr) {
5894        return Err(Status::INVALID_ARGUMENT);
5895    }
5896    Ok(cr)
5897}
5898
5899/// `PROP_TZ_OFFSET`, in minutes east of UTC.
5900///
5901/// Bounded by the real range of civil offsets — UTC−12:00 through
5902/// UTC+14:00 — rather than the width of the field. Everything outside it
5903/// is a byte-order or unit mistake, and a device that accepted one would
5904/// display a confidently wrong local time.
5905fn validate_tz_offset(value: &[u8]) -> Result<i16, Status> {
5906    let minutes = parse_i16(value)?;
5907    if !(-12 * 60..=14 * 60).contains(&minutes) {
5908        return Err(Status::INVALID_ARGUMENT);
5909    }
5910    Ok(minutes)
5911}
5912
5913/// `PROP_GNSS_IDENT_PRECISION`, in location bytes.
5914///
5915/// Zero is rejected rather than read as "advertise nothing": switching the
5916/// advertisement off is what `PROP_GNSS_IDENT_UPDATE` is for, and a
5917/// precision that silently means the opposite of a precision would be a
5918/// trap.
5919fn validate_ident_precision(value: &[u8]) -> Result<u8, Status> {
5920    let precision = parse_u8(value)?;
5921    if !(1..=MAX_IDENT_PRECISION).contains(&precision) {
5922        return Err(Status::INVALID_ARGUMENT);
5923    }
5924    Ok(precision)
5925}
5926
5927/// `PROP_IDENT_LOCATION`, in the variable-precision encoding.
5928///
5929/// The length is the precision, so anything up to the encoding's limit is
5930/// a legitimate statement of position — a host advertising a
5931/// neighbourhood writes fewer bytes than one advertising an address.
5932fn validate_ident_location(
5933    value: &[u8],
5934) -> Result<HeaplessVec<u8, { gnss::MAX_LOCATION_LEN }>, Status> {
5935    HeaplessVec::from_slice(value).map_err(|_| Status::INVALID_ARGUMENT)
5936}
5937
5938/// `PROP_IDENT_ALTITUDE`, in meters above the WGS-84 ellipsoid.
5939///
5940/// Accepts any width the encoding allows and returns the decoded value:
5941/// what is stored is the number, so what is reported back is minimal
5942/// however wide it arrived.
5943fn validate_ident_altitude(value: &[u8]) -> Result<Option<i32>, Status> {
5944    match value {
5945        [] => Ok(None),
5946        bytes => sint::decode(bytes)
5947            .map(Some)
5948            .map_err(|_| Status::INVALID_ARGUMENT),
5949    }
5950}
5951
5952/// `PROP_ADVERT_INTERVAL` / `PROP_BEACON_INTERVAL`, in seconds.
5953///
5954/// Zero is the off switch, so the bounds apply only above it. Neither is
5955/// an airtime control — the duty ledger is — but the two ends fail
5956/// differently: too short spends everyone's airtime on this device's
5957/// announcements, while too long is a schedule that has stopped being
5958/// one. Refusing both at the write is cheaper than discovering either on
5959/// the air.
5960fn validate_announce_interval(value: &[u8]) -> Result<u32, Status> {
5961    let seconds = parse_u32(value)?;
5962    if seconds != 0
5963        && !(MIN_AUTO_ANNOUNCE_INTERVAL_S..=MAX_AUTO_ANNOUNCE_INTERVAL_S).contains(&seconds)
5964    {
5965        return Err(Status::INVALID_ARGUMENT);
5966    }
5967    Ok(seconds)
5968}
5969
5970#[cfg(test)]
5971mod tests {
5972    use super::*;
5973    use umsh_crypto::software::{SoftwareAes, SoftwareSha256};
5974
5975    type TestSession = Session<SoftwareAes, SoftwareSha256>;
5976
5977    fn test_engine() -> CryptoEngine<SoftwareAes, SoftwareSha256> {
5978        CryptoEngine::new(SoftwareAes, SoftwareSha256)
5979    }
5980
5981    /// A session with a host attached over a secure transport (the
5982    /// normal state for command dispatch and live-delivery tests).
5983    /// Queueing tests detach it; gate tests re-attach insecurely.
5984    fn test_session() -> TestSession {
5985        let mut session = test_session_with_boot_status(Status::RESET_POWER_ON);
5986        session.attach(true);
5987        session
5988    }
5989
5990    fn test_session_with_boot_status(boot_status: Status) -> TestSession {
5991        let mut session = Session::new(test_config(), boot_status, test_engine());
5992        session.attach(true);
5993        session
5994    }
5995
5996    fn test_config() -> SessionConfig {
5997        SessionConfig {
5998            dev_version: "test-dev/0.1",
5999            dev_model: Some("Test Board"),
6000            default_device_name: "Test UMSH Device",
6001            mtu: 255,
6002            sync_word: 0x1424,
6003            min_tx_power_dbm: -9,
6004            max_tx_power_dbm: 22,
6005            freq_khz_min: 150_000,
6006            freq_khz_max: 960_000,
6007            defaults: RadioSettings {
6008                enabled: false,
6009                freq_khz: 910_525,
6010                bw_hz: 62_500,
6011                sf: 7,
6012                cr_denom: 5,
6013                tx_power_dbm: 14,
6014            },
6015            default_duty_limit: 0xFFFF,
6016            // Each test session gets its own leaked ledger so parallel
6017            // tests never share duty state.
6018            duty: Box::leak(Box::new(DutyLedger::new())),
6019            // Likewise for the traffic counters.
6020            stats: Some(Box::leak(Box::new(StatsLedger::new()))),
6021            // Mixed support matrix: voltage and charge state without a
6022            // level, the same shape as the T-1000E profile.
6023            battery: Some(BatteryFields {
6024                voltage: true,
6025                level: false,
6026                charge_state: true,
6027            }),
6028            alert: Some(AlertConfig::DEFAULT),
6029            time: Some(TimeConfig),
6030            gnss: Some(GnssConfig::DEFAULT),
6031            illuminance: true,
6032            ble: true,
6033            ble_pairing: true,
6034            reboot: true,
6035            mac_node: true,
6036        }
6037    }
6038
6039    /// A board with neither a clock nor a receiver, for the tests that
6040    /// check the capability gates actually hide the properties.
6041    fn timeless_config() -> SessionConfig {
6042        SessionConfig {
6043            time: None,
6044            gnss: None,
6045            ..test_config()
6046        }
6047    }
6048
6049    /// Drive `handle_frame` and collect emitted frames.
6050    fn dispatch<const TX: usize>(
6051        session: &mut Session<SoftwareAes, SoftwareSha256, TX>,
6052        request: &[u8],
6053        now_ms: u64,
6054    ) -> (Vec<Vec<u8>>, Option<Effect>) {
6055        let mut emitted = Vec::new();
6056        let effect = session.handle_frame(request, now_ms, &mut |bytes: &[u8]| {
6057            emitted.push(bytes.to_vec())
6058        });
6059        (emitted, effect)
6060    }
6061
6062    /// Parse an emitted frame as `CMD_PROP_IS` and return (tid, key, value).
6063    fn parse_prop_is(bytes: &[u8]) -> (u8, u32, Vec<u8>) {
6064        let parsed = Frame::parse(bytes).unwrap();
6065        assert_eq!(parsed.command(), Some(Cmd::PropIs));
6066        let payload = PropPayload::parse(parsed.payload).unwrap();
6067        (parsed.header.tid(), payload.key, payload.value.to_vec())
6068    }
6069
6070    fn expect_status(bytes: &[u8], tid: u8, status: Status) {
6071        let (response_tid, key, value) = parse_prop_is(bytes);
6072        assert_eq!(response_tid, tid);
6073        assert_eq!(key, prop::LAST_STATUS);
6074        assert_eq!(pui::decode(&value).unwrap().0, status.0);
6075    }
6076
6077    fn get(session: &mut TestSession, key: u32) -> Vec<u8> {
6078        let mut buf = [0u8; 16];
6079        let len = frame::prop_get(&mut buf, 1, key).unwrap();
6080        let (emitted, effect) = dispatch(session, &buf[..len], 0);
6081        assert!(effect.is_none());
6082        let (_, response_key, value) = parse_prop_is(&emitted[0]);
6083        assert_eq!(response_key, key);
6084        value
6085    }
6086
6087    /// `PROP_CAPS` decoded into the codes it lists.
6088    fn capabilities(session: &mut TestSession) -> Vec<u32> {
6089        let raw = get(session, prop::CAPS);
6090        let mut caps = Vec::new();
6091        let mut offset = 0;
6092        while offset < raw.len() {
6093            let (value, used) = pui::decode(&raw[offset..]).unwrap();
6094            caps.push(value);
6095            offset += used;
6096        }
6097        caps
6098    }
6099
6100    fn set(session: &mut TestSession, key: u32, value: &[u8]) -> (Vec<Vec<u8>>, Option<Effect>) {
6101        let mut buf = [0u8; 1024];
6102        let len = frame::prop_set(&mut buf, 2, key, value).unwrap();
6103        dispatch(session, &buf[..len], 0)
6104    }
6105
6106    /// Build a `PROP_MAC_REPEATER_REGIONS` whole-table value: one
6107    /// length-prefixed item per region string.
6108    fn region_table(regions: &[&str]) -> Vec<u8> {
6109        region_table_raw(
6110            &regions
6111                .iter()
6112                .map(|text| text.as_bytes())
6113                .collect::<Vec<_>>(),
6114        )
6115    }
6116
6117    /// The same, for items that are deliberately not valid regions.
6118    fn region_table_raw(items: &[&[u8]]) -> Vec<u8> {
6119        let mut table = Vec::new();
6120        for item in items {
6121            let mut encoded = [0u8; 64];
6122            let len = items::encode_prefixed_item(item, &mut encoded).unwrap();
6123            table.extend_from_slice(&encoded[..len]);
6124        }
6125        table
6126    }
6127
6128    fn region_codes(session: &TestSession) -> Vec<[u8; REGION_CODE_LEN]> {
6129        session.repeater_region_codes().collect()
6130    }
6131
6132    fn region_names(session: &TestSession) -> Vec<String> {
6133        session.repeater_region_names().map(str::to_owned).collect()
6134    }
6135
6136    fn send_packet<const TX: usize>(
6137        session: &mut Session<SoftwareAes, SoftwareSha256, TX>,
6138        tid: u8,
6139        data: &[u8],
6140        meta: &[u8],
6141        now_ms: u64,
6142    ) -> (Vec<Vec<u8>>, Option<Effect>) {
6143        let mut buf = [0u8; 320];
6144        let len = frame::str_send(&mut buf, tid, stream::PHY_RAW, data, meta).unwrap();
6145        dispatch(session, &buf[..len], now_ms)
6146    }
6147
6148    fn enable(session: &mut TestSession) {
6149        let (_, effect) = set(session, prop::PHY_ENABLED, &[1]);
6150        assert!(matches!(effect, Some(Effect::ApplyRadio(settings)) if settings.enabled));
6151    }
6152
6153    /// Drive a multi-property command to completion, serving every
6154    /// deferred effect the way the driver loop does, and return the
6155    /// entries of the `CMD_PROP_ARE`.
6156    fn multi(
6157        session: &mut TestSession,
6158        request: &[u8],
6159        now_ms: u64,
6160    ) -> (u8, Vec<(u32, Vec<u8>)>, Vec<Effect>) {
6161        multi_budgeted(session, request, now_ms, MULTI_MAX)
6162    }
6163
6164    fn multi_budgeted(
6165        session: &mut TestSession,
6166        request: &[u8],
6167        now_ms: u64,
6168        reply_budget: usize,
6169    ) -> (u8, Vec<(u32, Vec<u8>)>, Vec<Effect>) {
6170        let mut emitted = Vec::new();
6171        let mut served = Vec::new();
6172        let mut pending =
6173            session.handle_frame_budgeted(request, now_ms, reply_budget, &mut |bytes: &[u8]| {
6174                emitted.push(bytes.to_vec())
6175            });
6176        while let Some(effect) = pending.take() {
6177            // Stand in for the platform round trips the driver performs.
6178            match effect {
6179                Effect::SampleBattery { tid } => session.respond_battery(
6180                    tid,
6181                    Ok(BatteryStatus::default()),
6182                    &mut |bytes: &[u8]| emitted.push(bytes.to_vec()),
6183                ),
6184                Effect::ReadTime { tid } => {
6185                    session.respond_time(tid, Some(1_700_000_000), &mut |bytes: &[u8]| {
6186                        emitted.push(bytes.to_vec())
6187                    })
6188                }
6189                Effect::SampleIlluminance { tid } => {
6190                    session.respond_illuminance(tid, Some(1234), &mut |bytes: &[u8]| {
6191                        emitted.push(bytes.to_vec())
6192                    })
6193                }
6194                other => served.push(other),
6195            }
6196            pending =
6197                session.resume_multi(now_ms, &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
6198        }
6199        assert_eq!(emitted.len(), 1, "a multi command answers with one frame");
6200        let parsed = Frame::parse(&emitted[0]).unwrap();
6201        assert_eq!(parsed.command(), Some(Cmd::PropAre));
6202        let entries = MultiEntries::new(parsed.payload)
6203            .map(|entry| entry.unwrap())
6204            .map(|entry| (entry.key, entry.value.to_vec()))
6205            .collect();
6206        (parsed.header.tid(), entries, served)
6207    }
6208
6209    fn entry_status(value: &[u8]) -> Status {
6210        Status(pui::decode(value).unwrap().0)
6211    }
6212
6213    #[test]
6214    fn multi_get_answers_every_slot_in_order() {
6215        let mut session = test_session();
6216        let mut buf = [0u8; 64];
6217        // A known property, an unknown one, and a write-only one: the
6218        // read continues past both failures.
6219        let len = frame::prop_multi_get(
6220            &mut buf,
6221            3,
6222            &[
6223                prop::PHY_TX_POWER,
6224                60_000,
6225                prop::DEV_PRIVATE_KEY,
6226                prop::PHY_LORA_SF,
6227            ],
6228        )
6229        .unwrap();
6230        let (tid, entries, _) = multi(&mut session, &buf[..len], 0);
6231
6232        assert_eq!(tid, 3);
6233        assert_eq!(entries.len(), 4);
6234        assert_eq!(entries[0], (prop::PHY_TX_POWER, vec![14]));
6235        assert_eq!(entries[1].0, prop::LAST_STATUS);
6236        assert_eq!(entry_status(&entries[1].1), Status::PROP_NOT_FOUND);
6237        assert_eq!(entries[2].0, prop::LAST_STATUS);
6238        assert_eq!(entry_status(&entries[2].1), Status::UNIMPLEMENTED);
6239        assert_eq!(entries[3], (prop::PHY_LORA_SF, vec![7]));
6240    }
6241
6242    /// A property whose value the session cannot produce on its own is
6243    /// sampled from the platform and lands in its own slot, rather than
6244    /// suspending the whole read.
6245    #[test]
6246    fn multi_get_completes_deferred_values_in_place() {
6247        let mut session = test_session();
6248        let mut buf = [0u8; 64];
6249        let len = frame::prop_multi_get(
6250            &mut buf,
6251            1,
6252            &[prop::DEV_NAME, prop::TIME, prop::ILLUMINANCE, prop::PHY_MTU],
6253        )
6254        .unwrap();
6255        let (_, entries, _) = multi(&mut session, &buf[..len], 0);
6256
6257        assert_eq!(entries.len(), 4);
6258        assert_eq!(entries[0].0, prop::DEV_NAME);
6259        assert_eq!(entries[1].0, prop::TIME);
6260        assert_eq!(entries[1].1, 1_700_000_000u32.to_le_bytes());
6261        assert_eq!(entries[2].0, prop::ILLUMINANCE);
6262        assert_eq!(entries[3].0, prop::PHY_MTU);
6263    }
6264
6265    #[test]
6266    fn multi_set_applies_in_order_and_echoes_values() {
6267        let mut session = test_session();
6268        let mut buf = [0u8; 128];
6269        let entries: [(u32, &[u8]); 2] = [(prop::PHY_TX_POWER, &[20]), (prop::PHY_LORA_SF, &[9])];
6270        let len = frame::prop_multi_set(&mut buf, 4, &entries).unwrap();
6271        let (tid, reply, _) = multi(&mut session, &buf[..len], 0);
6272
6273        assert_eq!(tid, 4);
6274        assert_eq!(reply.len(), 2);
6275        assert_eq!(reply[0], (prop::PHY_TX_POWER, vec![20]));
6276        assert_eq!(reply[1], (prop::PHY_LORA_SF, vec![9]));
6277        assert_eq!(get(&mut session, prop::PHY_TX_POWER), vec![20]);
6278        assert_eq!(get(&mut session, prop::PHY_LORA_SF), vec![9]);
6279    }
6280
6281    #[test]
6282    fn multi_set_stops_at_the_first_failure() {
6283        let mut session = test_session();
6284        let mut buf = [0u8; 128];
6285        // Spreading factor 99 is out of range; the write after it must
6286        // not be applied.
6287        let entries: [(u32, &[u8]); 3] = [
6288            (prop::PHY_TX_POWER, &[20]),
6289            (prop::PHY_LORA_SF, &[99]),
6290            (prop::PHY_LORA_CR, &[8]),
6291        ];
6292        let len = frame::prop_multi_set(&mut buf, 5, &entries).unwrap();
6293        let (_, reply, _) = multi(&mut session, &buf[..len], 0);
6294
6295        assert_eq!(reply.len(), 2);
6296        assert_eq!(reply[0], (prop::PHY_TX_POWER, vec![20]));
6297        assert_eq!(reply[1].0, prop::LAST_STATUS);
6298        assert_eq!(entry_status(&reply[1].1), Status::INVALID_ARGUMENT);
6299        assert_eq!(get(&mut session, prop::PHY_LORA_CR), vec![5]);
6300    }
6301
6302    /// The reply bounds the sequence: an entry whose echo would not fit
6303    /// is not executed, and the requester reissues the remainder.
6304    #[test]
6305    fn multi_set_stops_before_an_entry_it_cannot_report() {
6306        let mut session = test_session();
6307        let mut buf = [0u8; 256];
6308        let name = [0x41u8; 40];
6309        let entries: [(u32, &[u8]); 3] = [
6310            (prop::DEV_NAME, &name),
6311            (prop::PHY_TX_POWER, &[17]),
6312            (prop::PHY_LORA_CR, &[8]),
6313        ];
6314        let len = frame::prop_multi_set(&mut buf, 6, &entries).unwrap();
6315        // Room for the name entry and nothing after it.
6316        let budget = 2 + frame::entry_len(prop::DEV_NAME, name.len()).unwrap() + 1;
6317        let (_, reply, _) = multi_budgeted(&mut session, &buf[..len], 0, budget);
6318
6319        assert_eq!(reply.len(), 1);
6320        assert_eq!(reply[0].0, prop::DEV_NAME);
6321        // Neither trailing write executed, so both properties keep the
6322        // values they had.
6323        assert_eq!(get(&mut session, prop::PHY_TX_POWER), vec![14]);
6324        assert_eq!(get(&mut session, prop::PHY_LORA_CR), vec![5]);
6325    }
6326
6327    /// A read that overruns its budget comes back short rather than
6328    /// wrong: the requester sees fewer entries than it asked for and
6329    /// reissues the remainder.
6330    #[test]
6331    fn multi_get_returns_what_fits() {
6332        let mut session = test_session();
6333        let mut buf = [0u8; 64];
6334        let keys = [prop::DEV_NAME, prop::PHY_TX_POWER, prop::PHY_LORA_SF];
6335        let len = frame::prop_multi_get(&mut buf, 7, &keys).unwrap();
6336        let full = multi(&mut session, &buf[..len], 0).1;
6337        assert_eq!(full.len(), 3);
6338
6339        let budget = 2 + frame::entry_len(prop::DEV_NAME, full[0].1.len()).unwrap() + 1;
6340        let (tid, short, _) = multi_budgeted(&mut session, &buf[..len], 0, budget);
6341        assert_eq!(tid, 7);
6342        assert_eq!(short.len(), 1);
6343        assert_eq!(short[0], full[0]);
6344    }
6345
6346    #[test]
6347    fn multi_commands_reject_malformed_and_oversized_requests() {
6348        let mut session = test_session();
6349
6350        // A truncated key PUI in a read.
6351        let request = [0x81, Cmd::PropMultiGet as u8, 0x80];
6352        let (_, entries, _) = multi(&mut session, &request, 0);
6353        assert_eq!(entries.len(), 1);
6354        assert_eq!(entry_status(&entries[0].1), Status::PARSE_ERROR);
6355
6356        // A request longer than one frame can carry answers with a
6357        // plain status rather than an empty entry list.
6358        let mut buf = [0u8; 1024];
6359        let keys = [prop::PHY_TX_POWER; 400];
6360        let len = frame::prop_multi_get(&mut buf, 2, &keys).unwrap();
6361        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6362        assert!(effect.is_none());
6363        expect_status(&emitted[0], 2, Status::NOMEM);
6364    }
6365
6366    #[test]
6367    fn prop_are_from_the_host_is_an_invalid_command() {
6368        let mut session = test_session();
6369        let request = [0x83, Cmd::PropAre as u8];
6370        let (emitted, effect) = dispatch(&mut session, &request, 0);
6371        assert!(effect.is_none());
6372        expect_status(&emitted[0], 3, Status::INVALID_COMMAND);
6373    }
6374
6375    #[test]
6376    fn nop_replies_ok() {
6377        let mut session = test_session();
6378        let mut buf = [0u8; 4];
6379        let len = frame::nop(&mut buf, 3).unwrap();
6380        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6381        assert!(effect.is_none());
6382        expect_status(&emitted[0], 3, Status::OK);
6383    }
6384
6385    #[test]
6386    fn reset_returns_to_defaults() {
6387        let mut session = test_session();
6388        enable(&mut session);
6389        set(&mut session, prop::PHY_LORA_SF, &[12]);
6390
6391        let mut buf = [0u8; 4];
6392        let len = frame::reset(&mut buf, 0).unwrap();
6393        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6394        expect_status(&emitted[0], TID_UNSOLICITED, Status::RESET_SOFTWARE);
6395        let Some(Effect::ApplyRadio(settings)) = effect else {
6396            panic!("expected ApplyRadio, got {effect:?}");
6397        };
6398        assert!(!settings.enabled);
6399        assert_eq!(settings.sf, 7);
6400        assert_eq!(get(&mut session, prop::PHY_ENABLED), [0]);
6401    }
6402
6403    #[test]
6404    fn identity_properties() {
6405        let mut session = test_session();
6406        assert_eq!(get(&mut session, prop::PROTOCOL_VERSION), [6, 0]);
6407        assert_eq!(get(&mut session, prop::DEV_VERSION), b"test-dev/0.1\0");
6408        assert_eq!(get(&mut session, prop::DEV_MODEL), b"Test Board\0");
6409        assert_eq!(get(&mut session, prop::DEV_NAME), b"Test UMSH Device");
6410        assert_eq!(get(&mut session, prop::PHY_MTU), 255u16.to_le_bytes());
6411        assert_eq!(
6412            pui::decode(&get(&mut session, prop::INTERFACE_TYPE))
6413                .unwrap()
6414                .0,
6415            ids::INTERFACE_TYPE
6416        );
6417        // Post-reset LAST_STATUS is the reset reason.
6418        assert_eq!(
6419            pui::decode(&get(&mut session, prop::LAST_STATUS))
6420                .unwrap()
6421                .0,
6422            Status::RESET_POWER_ON.0
6423        );
6424    }
6425
6426    #[test]
6427    fn caps_list_decodes() {
6428        let mut session = test_session();
6429        assert_eq!(
6430            capabilities(&mut session),
6431            [
6432                cap::WRITABLE_RAW_STREAM,
6433                cap::PHY_DUTY_LIMIT,
6434                cap::DEV_NAME,
6435                cap::PHY_LORA,
6436                cap::HOST_FILTER,
6437                cap::HOST_RX_QUEUE,
6438                cap::HOST_KEYS,
6439                cap::HOST_AUTO_ACK,
6440                cap::SAVE,
6441                cap::DEV_IDENTITY,
6442                cap::REPEATER,
6443                cap::IDENT,
6444                cap::ADMIN,
6445                cap::ADVERT,
6446                cap::MAC_BACKHAUL,
6447                cap::CMD_MULTI,
6448                cap::BATTERY,
6449                cap::ALERT,
6450                cap::TIME,
6451                cap::GNSS,
6452                cap::ILLUMINANCE,
6453                cap::BLE,
6454                cap::REBOOT,
6455                cap::STATS,
6456            ]
6457        );
6458    }
6459
6460    /// The clock and the receiver are separate claims, and a board that
6461    /// makes neither must not have the properties at all.
6462    #[test]
6463    fn caps_omit_time_and_gnss_when_unconfigured() {
6464        let mut session: TestSession =
6465            Session::new(timeless_config(), Status::RESET_POWER_ON, test_engine());
6466        session.attach(true);
6467        let raw = get(&mut session, prop::CAPS);
6468        let mut caps = Vec::new();
6469        let mut offset = 0;
6470        while offset < raw.len() {
6471            let (value, used) = pui::decode(&raw[offset..]).unwrap();
6472            caps.push(value);
6473            offset += used;
6474        }
6475        assert!(!caps.contains(&cap::TIME));
6476        assert!(!caps.contains(&cap::GNSS));
6477
6478        for key in [
6479            prop::TIME,
6480            prop::TZ_OFFSET,
6481            prop::GNSS_ENABLED,
6482            prop::GNSS_LOCATION,
6483            prop::GNSS_ALTITUDE,
6484            prop::GNSS_FIX,
6485            prop::GNSS_PRECISION,
6486            prop::GNSS_SATELLITES,
6487            prop::GNSS_IDENT_UPDATE,
6488            prop::GNSS_IDENT_PRECISION,
6489            prop::GNSS_TIME_TRUST,
6490        ] {
6491            let mut buf = [0u8; 16];
6492            let len = frame::prop_get(&mut buf, 6, key).unwrap();
6493            let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6494            assert_eq!(effect, None, "prop {key} produced an effect");
6495            let (_, status_key, value) = parse_prop_is(&emitted[0]);
6496            assert_eq!(status_key, prop::LAST_STATUS);
6497            assert_eq!(
6498                pui::decode(&value).unwrap().0,
6499                Status::PROP_NOT_FOUND.0,
6500                "prop {key} is visible without its capability"
6501            );
6502        }
6503    }
6504
6505    /// The traffic counters are a window on a ledger the session does not
6506    /// own: it reports what is in it, clears it on request, and keeps no
6507    /// copy of its own.
6508    #[test]
6509    fn traffic_counters_report_the_ledger_and_clear_to_zero() {
6510        let stats: &'static StatsLedger = Box::leak(Box::new(StatsLedger::new()));
6511        let config = SessionConfig {
6512            stats: Some(stats),
6513            ..test_config()
6514        };
6515        let mut session: TestSession = Session::new(config, Status::RESET_POWER_ON, test_engine());
6516        session.attach(true);
6517
6518        assert!(capabilities(&mut session).contains(&cap::STATS));
6519
6520        stats.add(Counter::TxPackets, 7);
6521        stats.add(Counter::RxBadCrc, 3);
6522        assert_eq!(
6523            get(&mut session, prop::STAT_TX_PACKETS),
6524            7u32.to_le_bytes().to_vec()
6525        );
6526        assert_eq!(
6527            get(&mut session, prop::STAT_RX_BAD_CRC),
6528            3u32.to_le_bytes().to_vec()
6529        );
6530        assert_eq!(
6531            get(&mut session, prop::STAT_RX_PACKETS),
6532            0u32.to_le_bytes().to_vec()
6533        );
6534
6535        // Zero is the only value a write may carry, and the echo is the
6536        // authoritative value rather than what the host sent.
6537        let (emitted, effect) = set(&mut session, prop::STAT_TX_PACKETS, &0u32.to_le_bytes());
6538        assert!(effect.is_none());
6539        let (_, key, value) = parse_prop_is(&emitted[0]);
6540        assert_eq!(key, prop::STAT_TX_PACKETS);
6541        assert_eq!(value, 0u32.to_le_bytes().to_vec());
6542        assert_eq!(stats.get(Counter::TxPackets), 0);
6543        // Clearing one counter clears only that one.
6544        assert_eq!(stats.get(Counter::RxBadCrc), 3);
6545
6546        // Counting resumes from the clear.
6547        stats.add(Counter::TxPackets, 2);
6548        assert_eq!(
6549            get(&mut session, prop::STAT_TX_PACKETS),
6550            2u32.to_le_bytes().to_vec()
6551        );
6552
6553        let (emitted, _) = set(&mut session, prop::STAT_TX_PACKETS, &1u32.to_le_bytes());
6554        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
6555        assert_eq!(
6556            stats.get(Counter::TxPackets),
6557            2,
6558            "a refused write cleared it"
6559        );
6560
6561        // A protocol reset is not an operator asking to start over.
6562        // `PROP_LAST_STATUS` and `PROP_UPTIME` are what say a device
6563        // restarted; the counters are hardware history.
6564        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_| {});
6565        session.attach(true);
6566        assert_eq!(
6567            get(&mut session, prop::STAT_TX_PACKETS),
6568            2u32.to_le_bytes().to_vec()
6569        );
6570        assert_eq!(
6571            get(&mut session, prop::STAT_RX_BAD_CRC),
6572            3u32.to_le_bytes().to_vec()
6573        );
6574    }
6575
6576    /// A device that keeps no counters does not have the properties, and
6577    /// a device with no node of its own has only the ones its antenna can
6578    /// answer for. Reporting zero forwards would describe a repeater that
6579    /// has never repeated anything, which is a different device.
6580    #[test]
6581    fn traffic_counters_follow_the_capability_and_the_node() {
6582        let mut countless: TestSession = Session::new(
6583            SessionConfig {
6584                stats: None,
6585                ..test_config()
6586            },
6587            Status::RESET_POWER_ON,
6588            test_engine(),
6589        );
6590        countless.attach(true);
6591        assert!(!capabilities(&mut countless).contains(&cap::STATS));
6592        for counter in Counter::ALL {
6593            let mut buf = [0u8; 16];
6594            let len = frame::prop_get(&mut buf, 1, counter.property()).unwrap();
6595            let (emitted, _) = dispatch(&mut countless, &buf[..len], 0);
6596            expect_status(&emitted[0], 1, Status::PROP_NOT_FOUND);
6597        }
6598
6599        let mut headless: TestSession = Session::new(
6600            SessionConfig {
6601                mac_node: false,
6602                ..test_config()
6603            },
6604            Status::RESET_POWER_ON,
6605            test_engine(),
6606        );
6607        headless.attach(true);
6608        assert!(capabilities(&mut headless).contains(&cap::STATS));
6609        for counter in Counter::ALL {
6610            let mut buf = [0u8; 16];
6611            let len = frame::prop_get(&mut buf, 1, counter.property()).unwrap();
6612            let (emitted, _) = dispatch(&mut headless, &buf[..len], 0);
6613            if counter.needs_node() {
6614                expect_status(&emitted[0], 1, Status::PROP_NOT_FOUND);
6615            } else {
6616                let (_, key, value) = parse_prop_is(&emitted[0]);
6617                assert_eq!(key, counter.property());
6618                assert_eq!(value, 0u32.to_le_bytes().to_vec());
6619            }
6620        }
6621    }
6622
6623    /// A session with no node behind it — a simulated device, a bridge's
6624    /// soft device — must not claim one: `CAP_MAC_BACKHAUL` is exactly
6625    /// what a bridge checks before trusting a device to front a segment,
6626    /// and the repeater surface configures a forwarder that does not
6627    /// exist.
6628    #[test]
6629    fn caps_omit_the_node_when_none_runs_behind_the_session() {
6630        let config = SessionConfig {
6631            mac_node: false,
6632            ..test_config()
6633        };
6634        let mut session: TestSession = Session::new(config, Status::RESET_POWER_ON, test_engine());
6635        session.attach(true);
6636        let caps = capabilities(&mut session);
6637        assert!(!caps.contains(&cap::REPEATER));
6638        assert!(!caps.contains(&cap::MAC_BACKHAUL));
6639
6640        for key in [
6641            prop::MAC_REPEATER_ENABLED,
6642            prop::MAC_REPEATER_REGIONS,
6643            prop::MAC_REPEATER_DEFAULT_REGION,
6644            prop::MAC_REPEATER_MIN_RSSI,
6645            prop::MAC_REPEATER_MIN_SNR,
6646            prop::MAC_BACKHAUL,
6647        ] {
6648            let mut buf = [0u8; 16];
6649            let len = frame::prop_get(&mut buf, 6, key).unwrap();
6650            let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6651            assert_eq!(effect, None, "prop {key} produced an effect");
6652            let (_, status_key, value) = parse_prop_is(&emitted[0]);
6653            assert_eq!(status_key, prop::LAST_STATUS);
6654            assert_eq!(
6655                pui::decode(&value).unwrap().0,
6656                Status::PROP_NOT_FOUND.0,
6657                "prop {key} is visible without a node"
6658            );
6659
6660            let len = frame::prop_set(&mut buf, 7, key, &[1]).unwrap();
6661            let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6662            assert_eq!(effect, None, "setting prop {key} produced an effect");
6663            let (_, status_key, value) = parse_prop_is(&emitted[0]);
6664            assert_eq!(status_key, prop::LAST_STATUS);
6665            assert_eq!(
6666                pui::decode(&value).unwrap().0,
6667                Status::PROP_NOT_FOUND.0,
6668                "prop {key} is settable without a node"
6669            );
6670        }
6671    }
6672
6673    /// `CMD_REBOOT` is the platform's to perform, and a platform that
6674    /// cannot says so rather than staying silent — silence is how a
6675    /// device that *is* rebooting answers, and a host has no other way
6676    /// to tell the two apart.
6677    #[test]
6678    fn reboot_defers_to_the_platform_or_says_it_cannot() {
6679        let mut buf = [0u8; 8];
6680        let len = frame::reboot(&mut buf, 3).unwrap();
6681
6682        let mut session = test_session();
6683        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6684        assert_eq!(effect, Some(Effect::Reboot));
6685        assert!(emitted.is_empty(), "a reboot answers nothing");
6686        assert!(capabilities(&mut session).contains(&cap::REBOOT));
6687
6688        let config = SessionConfig {
6689            reboot: false,
6690            ..test_config()
6691        };
6692        let mut fixed: TestSession = Session::new(config, Status::RESET_POWER_ON, test_engine());
6693        fixed.attach(true);
6694        let (emitted, effect) = dispatch(&mut fixed, &buf[..len], 0);
6695        assert_eq!(effect, None);
6696        let (_, status_key, value) = parse_prop_is(&emitted[0]);
6697        assert_eq!(status_key, prop::LAST_STATUS);
6698        assert_eq!(pui::decode(&value).unwrap().0, Status::UNIMPLEMENTED.0);
6699        assert!(!capabilities(&mut fixed).contains(&cap::REBOOT));
6700    }
6701
6702    /// Both bond commands wait for the platform before they answer:
6703    /// acknowledging a clear that then failed would leave a host believing
6704    /// it had been forgotten when it had not.
6705    #[test]
6706    fn bond_commands_defer_to_the_platform_or_say_they_cannot() {
6707        let mut buf = [0u8; 8];
6708        let clear = frame::ble_clear_bonds(&mut buf, 3).unwrap();
6709        let clear = buf[..clear].to_vec();
6710
6711        let mut session = test_session();
6712        let (emitted, effect) = dispatch(&mut session, &clear, 0);
6713        assert_eq!(effect, Some(Effect::BleClearBonds { tid: 3 }));
6714        assert!(emitted.is_empty(), "the answer waits for the platform");
6715
6716        let mut emitted = Vec::new();
6717        session.respond_ble_clear_bonds(3, Ok(()), &mut |frame| emitted.push(frame.to_vec()));
6718        let (_, key, value) = parse_prop_is(&emitted[0]);
6719        assert_eq!(key, prop::LAST_STATUS);
6720        assert_eq!(pui::decode(&value).unwrap().0, Status::OK.0);
6721
6722        let config = SessionConfig {
6723            ble_pairing: false,
6724            ..test_config()
6725        };
6726        let mut fixed: TestSession = Session::new(config, Status::RESET_POWER_ON, test_engine());
6727        fixed.attach(true);
6728        let (emitted, effect) = dispatch(&mut fixed, &clear, 0);
6729        assert_eq!(effect, None);
6730        let (_, key, value) = parse_prop_is(&emitted[0]);
6731        assert_eq!(key, prop::LAST_STATUS);
6732        assert_eq!(pui::decode(&value).unwrap().0, Status::UNIMPLEMENTED.0);
6733        // The refusal is the whole of what the host is told. Bluetooth
6734        // has one capability, and a device that keeps its bonds to itself
6735        // still advertises it — so the count, not the caps list, is what
6736        // a host asks.
6737        assert!(capabilities(&mut fixed).contains(&cap::BLE));
6738        let mut buf = [0u8; 16];
6739        let len = frame::prop_get(&mut buf, 6, prop::BLE_BOND_COUNT).unwrap();
6740        let (emitted, _) = dispatch(&mut fixed, &buf[..len], 0);
6741        expect_status(&emitted[0], 6, Status::PROP_NOT_FOUND);
6742    }
6743
6744    /// The pairing window is a property, not a command: it can be
6745    /// opened, closed, observed, and — the case no command could
6746    /// express — the transport reports it closing on its own.
6747    #[test]
6748    fn the_pairing_window_is_a_toggle_the_transport_answers_for() {
6749        let mut session = test_session();
6750        assert_eq!(get(&mut session, prop::BLE_PAIRING), [0]);
6751
6752        // Opening defers to the transport, which answers with the state
6753        // actually reached.
6754        let mut buf = [0u8; 16];
6755        let len = frame::prop_set(&mut buf, 4, prop::BLE_PAIRING, &[1]).unwrap();
6756        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6757        assert_eq!(effect, Some(Effect::SetBlePairing { tid: 4, open: true }));
6758        assert!(emitted.is_empty(), "the answer waits for the platform");
6759        let mut emitted = Vec::new();
6760        session.respond_ble_pairing(4, Ok(true), &mut |frame| emitted.push(frame.to_vec()));
6761        let (_, key, value) = parse_prop_is(&emitted[0]);
6762        assert_eq!(key, prop::BLE_PAIRING);
6763        assert_eq!(value, [1]);
6764        assert_eq!(get(&mut session, prop::BLE_PAIRING), [1]);
6765
6766        // A refused open — locked out, or Bluetooth disabled — is a
6767        // state the caller can retry out of, not an internal error.
6768        let len = frame::prop_set(&mut buf, 5, prop::BLE_PAIRING, &[1]).unwrap();
6769        let (_, effect) = dispatch(&mut session, &buf[..len], 0);
6770        assert_eq!(effect, Some(Effect::SetBlePairing { tid: 5, open: true }));
6771        let mut emitted = Vec::new();
6772        session.respond_ble_pairing(5, Err(()), &mut |frame| emitted.push(frame.to_vec()));
6773        expect_status(&emitted[0], 5, Status::INVALID_STATE);
6774
6775        // The transport closing the window on its own — a bond enrolled,
6776        // a timeout — is published like any transition the host did not
6777        // command.
6778        let mut announced = Vec::new();
6779        session.set_ble_pairing(false, &mut |frame| announced.push(frame.to_vec()));
6780        let (_, key, value) = parse_prop_is(&announced[0]);
6781        assert_eq!(key, prop::BLE_PAIRING);
6782        assert_eq!(value, [0]);
6783        assert_eq!(get(&mut session, prop::BLE_PAIRING), [0]);
6784
6785        // A device that does not manage its own bonds has no window to
6786        // ask about, and says so the same way it does for the count.
6787        let config = SessionConfig {
6788            ble_pairing: false,
6789            ..test_config()
6790        };
6791        let mut fixed: TestSession = Session::new(config, Status::RESET_POWER_ON, test_engine());
6792        fixed.attach(true);
6793        let len = frame::prop_set(&mut buf, 6, prop::BLE_PAIRING, &[1]).unwrap();
6794        let (emitted, effect) = dispatch(&mut fixed, &buf[..len], 0);
6795        assert_eq!(effect, None);
6796        expect_status(&emitted[0], 6, Status::PROP_NOT_FOUND);
6797    }
6798
6799    /// The bond count mirrors the transport. It is not configuration, so
6800    /// it is not writable and a protocol reset does not clear it — bonds
6801    /// outlive `CMD_RST`, and a count that said zero would be lying about
6802    /// hosts that are still enrolled.
6803    #[test]
6804    fn bond_count_reports_the_transport_and_survives_a_reset() {
6805        let mut session = test_session();
6806        assert_eq!(get(&mut session, prop::BLE_BOND_COUNT), [0]);
6807        let mut announced = Vec::new();
6808        session.set_ble_bond_count(2, &mut |frame| announced.push(frame.to_vec()));
6809        assert_eq!(get(&mut session, prop::BLE_BOND_COUNT), [2]);
6810        let (_, key, value) = parse_prop_is(&announced[0]);
6811        assert_eq!(key, prop::BLE_BOND_COUNT);
6812        assert_eq!(value, [2]);
6813
6814        let mut buf = [0u8; 16];
6815        let len = frame::prop_set(&mut buf, 5, prop::BLE_BOND_COUNT, &[1]).unwrap();
6816        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
6817        let (_, key, value) = parse_prop_is(&emitted[0]);
6818        assert_eq!(key, prop::LAST_STATUS);
6819        assert_eq!(pui::decode(&value).unwrap().0, Status::INVALID_ARGUMENT.0);
6820
6821        session.reset(Status::RESET_OTHER, &mut |_| {});
6822        assert_eq!(get(&mut session, prop::BLE_BOND_COUNT), [2]);
6823    }
6824
6825    /// The link is live transport state on the same footing as the bond
6826    /// count: reported, announced, not writable, and untouched by a
6827    /// protocol reset — the host on the other end of it does not
6828    /// disconnect because someone sent `CMD_RST`.
6829    #[test]
6830    fn link_state_reports_the_transport_and_survives_a_reset() {
6831        let mut session = test_session();
6832        assert_eq!(
6833            get(&mut session, prop::BLE_LINK),
6834            [BleLinkState::None.code()]
6835        );
6836
6837        // Connected and attached are different claims, and both are
6838        // published: a host arriving on Bluetooth is a transition the
6839        // administrator watching over the mesh never asked about.
6840        let mut announced = Vec::new();
6841        let mut emit = |frame: &[u8]| announced.push(frame.to_vec());
6842        session.set_ble_link(BleLinkState::Connected, &mut emit);
6843        session.set_ble_link(BleLinkState::Connected, &mut emit);
6844        session.set_ble_link(BleLinkState::Attached, &mut emit);
6845        assert_eq!(announced.len(), 2, "an unchanged state announces nothing");
6846        let (_, key, value) = parse_prop_is(&announced[1]);
6847        assert_eq!(key, prop::BLE_LINK);
6848        assert_eq!(value, [BleLinkState::Attached.code()]);
6849        assert!(session.ble_link().is_attached());
6850
6851        let mut buf = [0u8; 16];
6852        let len = frame::prop_set(&mut buf, 5, prop::BLE_LINK, &[0]).unwrap();
6853        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
6854        expect_status(&emitted[0], 5, Status::INVALID_ARGUMENT);
6855
6856        session.reset(Status::RESET_OTHER, &mut |_| {});
6857        assert_eq!(
6858            get(&mut session, prop::BLE_LINK),
6859            [BleLinkState::Attached.code()]
6860        );
6861    }
6862
6863    /// A board with no Bluetooth has neither property, and the session
6864    /// answers for the transport it does not have rather than guessing.
6865    #[test]
6866    fn a_board_without_bluetooth_has_no_link_to_report() {
6867        let config = SessionConfig {
6868            ble: false,
6869            ble_pairing: false,
6870            ..test_config()
6871        };
6872        let mut session: TestSession = Session::new(config, Status::RESET_POWER_ON, test_engine());
6873        session.attach(true);
6874        assert!(!capabilities(&mut session).contains(&cap::BLE));
6875
6876        let mut buf = [0u8; 16];
6877        let len = frame::prop_get(&mut buf, 2, prop::BLE_LINK).unwrap();
6878        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
6879        expect_status(&emitted[0], 2, Status::PROP_NOT_FOUND);
6880
6881        // Told about a link anyway, it stays quiet: a transport the
6882        // config says is absent has nothing to announce.
6883        let mut announced = Vec::new();
6884        session.set_ble_link(BleLinkState::Attached, &mut |frame| {
6885            announced.push(frame.to_vec())
6886        });
6887        assert!(announced.is_empty());
6888        assert_eq!(session.ble_link(), BleLinkState::None);
6889    }
6890
6891    /// The clock is the platform's, not the session's: a get defers, a
6892    /// set hands the platform the new value, and "we do not know" is the
6893    /// empty value in both directions.
6894    #[test]
6895    fn time_reads_and_writes_defer_to_the_platform() {
6896        let mut session = test_session();
6897
6898        let mut buf = [0u8; 16];
6899        let len = frame::prop_get(&mut buf, 7, prop::TIME).unwrap();
6900        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6901        assert!(emitted.is_empty(), "no response until the clock is read");
6902        assert_eq!(effect, Some(Effect::ReadTime { tid: 7 }));
6903
6904        let mut out = Vec::new();
6905        session.respond_time(7, Some(1_780_000_000), &mut |bytes: &[u8]| {
6906            out.push(bytes.to_vec())
6907        });
6908        let (tid, key, value) = parse_prop_is(&out[0]);
6909        assert_eq!((tid, key), (7, prop::TIME));
6910        assert_eq!(value, 1_780_000_000u32.to_le_bytes());
6911
6912        // A device that does not know what time it is says so with the
6913        // empty value rather than failing the read.
6914        let mut out = Vec::new();
6915        session.respond_time(4, None, &mut |bytes: &[u8]| out.push(bytes.to_vec()));
6916        let (_, _, value) = parse_prop_is(&out[0]);
6917        assert_eq!(value, Vec::<u8>::new());
6918
6919        let (_, effect) = set(&mut session, prop::TIME, &1_780_000_042u32.to_le_bytes());
6920        assert_eq!(
6921            effect,
6922            Some(Effect::ApplyTime {
6923                epoch: Some(1_780_000_042)
6924            })
6925        );
6926        // The empty write is not a malformed integer: it is the host
6927        // telling the device to forget what time it is.
6928        let (_, effect) = set(&mut session, prop::TIME, &[]);
6929        assert_eq!(effect, Some(Effect::ApplyTime { epoch: None }));
6930        // A width that is neither is still an error.
6931        let (emitted, effect) = set(&mut session, prop::TIME, &[0, 0]);
6932        assert_eq!(effect, None);
6933        let (_, _, value) = parse_prop_is(&emitted[0]);
6934        assert_eq!(pui::decode(&value).unwrap().0, Status::INVALID_ARGUMENT.0);
6935    }
6936
6937    /// A publication reaches an attached host and nobody else.
6938    #[test]
6939    fn time_publishes_only_while_attached() {
6940        let mut session = test_session();
6941        let mut out = Vec::new();
6942        assert!(
6943            session.publish_time(Some(1_780_000_000), &mut |bytes: &[u8]| {
6944                out.push(bytes.to_vec())
6945            })
6946        );
6947        let (tid, key, value) = parse_prop_is(&out[0]);
6948        assert_eq!((tid, key), (TID_UNSOLICITED, prop::TIME));
6949        assert_eq!(value, 1_780_000_000u32.to_le_bytes());
6950
6951        session.detach();
6952        let mut out = Vec::new();
6953        assert!(
6954            !session.publish_time(Some(1_780_000_000), &mut |bytes: &[u8]| {
6955                out.push(bytes.to_vec())
6956            })
6957        );
6958        assert!(out.is_empty());
6959    }
6960
6961    /// The time zone is configuration, so unlike the clock it always has
6962    /// a value, it is saved, and the session answers it directly.
6963    #[test]
6964    fn timezone_is_always_known_and_bounded() {
6965        let mut session = test_session();
6966        assert_eq!(get(&mut session, prop::TZ_OFFSET), [0, 0]);
6967        assert_eq!(session.tz_offset_min(), 0);
6968
6969        // UTC−08:00.
6970        set(&mut session, prop::TZ_OFFSET, &(-480i16).to_le_bytes());
6971        assert_eq!(get(&mut session, prop::TZ_OFFSET), (-480i16).to_le_bytes());
6972        assert_eq!(session.tz_offset_min(), -480);
6973
6974        // The extremes of the civil range are accepted; past them is a
6975        // unit or byte-order mistake, not a place.
6976        for minutes in [-12 * 60i16, 14 * 60] {
6977            let (_, effect) = set(&mut session, prop::TZ_OFFSET, &minutes.to_le_bytes());
6978            assert_eq!(effect, None);
6979            assert_eq!(session.tz_offset_min(), minutes);
6980        }
6981        for minutes in [-12 * 60i16 - 1, 14 * 60 + 1] {
6982            let (emitted, _) = set(&mut session, prop::TZ_OFFSET, &minutes.to_le_bytes());
6983            let (_, _, value) = parse_prop_is(&emitted[0]);
6984            assert_eq!(pui::decode(&value).unwrap().0, Status::INVALID_ARGUMENT.0);
6985        }
6986    }
6987
6988    /// Positioning telemetry is a measurement: every get samples, and the
6989    /// answer is whatever the platform reports right now.
6990    #[test]
6991    fn positioning_gets_sample_and_are_never_writable() {
6992        let mut session = test_session();
6993        let mut buf = [0u8; 16];
6994        let len = frame::prop_get(&mut buf, 5, prop::GNSS_LOCATION).unwrap();
6995        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
6996        assert!(emitted.is_empty());
6997        assert_eq!(
6998            effect,
6999            Some(Effect::SampleGnss {
7000                tid: 5,
7001                key: prop::GNSS_LOCATION
7002            })
7003        );
7004
7005        let mut snapshot = GnssSnapshot::SEARCHING;
7006        snapshot.fix = umsh_ulcp::gnss::FixKind::ThreeD;
7007        snapshot.altitude_m = Some(112);
7008        snapshot.accuracy_dm = Some(45);
7009        snapshot.sats_used = 8;
7010        snapshot.sats_in_view = Some(11);
7011        snapshot.set_location(&[0x8a, 0x1f, 0x4c, 0x00, 0xd3]);
7012        let mut out = Vec::new();
7013        session.respond_gnss(
7014            5,
7015            prop::GNSS_LOCATION,
7016            Ok(snapshot),
7017            &mut |bytes: &[u8]| out.push(bytes.to_vec()),
7018        );
7019        let (tid, key, value) = parse_prop_is(&out[0]);
7020        assert_eq!((tid, key), (5, prop::GNSS_LOCATION));
7021        assert_eq!(value, [0x8a, 0x1f, 0x4c, 0x00, 0xd3]);
7022
7023        // None of the five accepts a write.
7024        for key in [
7025            prop::GNSS_LOCATION,
7026            prop::GNSS_ALTITUDE,
7027            prop::GNSS_FIX,
7028            prop::GNSS_PRECISION,
7029            prop::GNSS_SATELLITES,
7030        ] {
7031            let (emitted, _) = set(&mut session, key, &[0]);
7032            let (_, _, value) = parse_prop_is(&emitted[0]);
7033            assert_eq!(
7034                pui::decode(&value).unwrap().0,
7035                Status::INVALID_ARGUMENT.0,
7036                "prop {key} accepted a write"
7037            );
7038        }
7039    }
7040
7041    /// A receiver that is off still answers the two questions it is sure
7042    /// of, and stays silent about the position it does not have.
7043    #[test]
7044    fn a_searching_receiver_reports_zero_rather_than_nothing() {
7045        let mut session = test_session();
7046        for (key, expected) in [
7047            (prop::GNSS_FIX, vec![0u8]),
7048            (prop::GNSS_SATELLITES, vec![0u8]),
7049            (prop::GNSS_LOCATION, vec![]),
7050            (prop::GNSS_ALTITUDE, vec![]),
7051            (prop::GNSS_PRECISION, vec![]),
7052        ] {
7053            let mut out = Vec::new();
7054            session.respond_gnss(
7055                3,
7056                key,
7057                Ok(GnssSnapshot::SEARCHING),
7058                &mut |bytes: &[u8]| out.push(bytes.to_vec()),
7059            );
7060            let (_, answered, value) = parse_prop_is(&out[0]);
7061            assert_eq!(answered, key);
7062            assert_eq!(value, expected, "prop {key}");
7063        }
7064    }
7065
7066    /// The receiver switch and the positioning policy are device-domain
7067    /// settings: readable, bounded, saved, and restored.
7068    #[test]
7069    fn gnss_settings_round_trip_and_are_bounded() {
7070        let mut session = test_session();
7071        assert_eq!(get(&mut session, prop::GNSS_ENABLED), [0]);
7072        assert_eq!(get(&mut session, prop::GNSS_IDENT_UPDATE), [0]);
7073        assert_eq!(
7074            get(&mut session, prop::GNSS_IDENT_PRECISION),
7075            [DEFAULT_IDENT_PRECISION]
7076        );
7077        assert_eq!(get(&mut session, prop::GNSS_TIME_TRUST), [1]);
7078        assert!(!session.gnss_enabled());
7079        assert!(session.gnss_time_trust());
7080
7081        set(&mut session, prop::GNSS_ENABLED, &[1]);
7082        set(&mut session, prop::GNSS_IDENT_UPDATE, &[1]);
7083        set(&mut session, prop::GNSS_IDENT_PRECISION, &[3]);
7084        set(&mut session, prop::GNSS_TIME_TRUST, &[0]);
7085        assert!(session.gnss_enabled());
7086        assert!(session.gnss_ident_update());
7087        assert_eq!(session.gnss_ident_precision(), 3);
7088        assert!(!session.gnss_time_trust());
7089
7090        // Precision names a location width; zero and past the maximum are
7091        // both outside it. Turning the advertisement off is a different
7092        // property's job.
7093        for precision in [0u8, MAX_IDENT_PRECISION + 1] {
7094            let (emitted, _) = set(&mut session, prop::GNSS_IDENT_PRECISION, &[precision]);
7095            let (_, _, value) = parse_prop_is(&emitted[0]);
7096            assert_eq!(pui::decode(&value).unwrap().0, Status::INVALID_ARGUMENT.0);
7097        }
7098        assert_eq!(session.gnss_ident_precision(), 3);
7099    }
7100
7101    /// A board whose job is to know where it is boots its receiver on,
7102    /// and every path that decides "what does unconfigured mean" agrees.
7103    ///
7104    /// The subtle one is the saved baseline. A snapshot omits nothing
7105    /// scalar, but the baseline is what an *older* snapshot's absent
7106    /// options decode against — so if `SavedState::defaults` kept saying
7107    /// `false` here, restoring such a snapshot would switch the receiver
7108    /// off on the one board that wants it on.
7109    #[test]
7110    fn a_board_can_boot_its_receiver_on() {
7111        let config = SessionConfig {
7112            gnss: Some(GnssConfig::ALWAYS_ON),
7113            ..test_config()
7114        };
7115        let always_on = || {
7116            let mut session = Session::new(config, Status::RESET_POWER_ON, test_engine());
7117            session.attach(true);
7118            session
7119        };
7120
7121        let mut session = always_on();
7122        assert_eq!(get(&mut session, prop::GNSS_ENABLED), [1]);
7123        assert!(session.gnss_enabled());
7124
7125        // Switching it off and saving means off — a board default is a
7126        // starting point, not a policy the operator has to fight.
7127        set(&mut session, prop::GNSS_ENABLED, &[0]);
7128        let mut buf = [0u8; 512];
7129        let len = session.encode_snapshot(&mut buf).expect("snapshot");
7130        let saved = SavedState::decode(&config, &buf[..len]).expect("decode");
7131        assert!(!saved.gnss_enabled);
7132
7133        // And `CMD_RST` returns to the board default, not the protocol's.
7134        let mut fresh = always_on();
7135        set(&mut fresh, prop::GNSS_ENABLED, &[0]);
7136        assert!(!fresh.gnss_enabled());
7137        fresh.reset(Status::RESET_SOFTWARE, &mut |_: &[u8]| {});
7138        assert!(fresh.gnss_enabled(), "reset dropped the board default");
7139    }
7140
7141    /// A switch the operator can reach moves the property, the mirror,
7142    /// and an attached host's view of it.
7143    #[test]
7144    fn a_local_toggle_flips_the_switch_and_announces_it() {
7145        let mut session = test_session();
7146        assert!(!session.gnss_enabled());
7147        let before = session.dev_domain_version();
7148
7149        let mut emitted = Vec::new();
7150        let enabled = session.toggle_gnss(&mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
7151        assert_eq!(enabled, Some(true));
7152        assert!(session.gnss_enabled());
7153        assert_eq!(get(&mut session, prop::GNSS_ENABLED), [1]);
7154        assert_ne!(
7155            session.dev_domain_version(),
7156            before,
7157            "the receiver never heard about it"
7158        );
7159        let (tid, key, value) = parse_prop_is(&emitted[0]);
7160        assert_eq!(
7161            (tid, key, value),
7162            (TID_UNSOLICITED, prop::GNSS_ENABLED, vec![1])
7163        );
7164
7165        // It is a toggle, not a set.
7166        emitted.clear();
7167        assert_eq!(
7168            session.toggle_gnss(&mut |bytes: &[u8]| emitted.push(bytes.to_vec())),
7169            Some(false)
7170        );
7171        assert!(!session.gnss_enabled());
7172    }
7173
7174    /// The other three switches a display board offers behave the same
7175    /// way: flip the value, move the mirror, publish the transition.
7176    #[test]
7177    fn every_local_toggle_flips_its_own_property() {
7178        // (start, toggle, property) — each starts at its post-reset value.
7179        let cases: [(
7180            bool,
7181            fn(&mut TestSession, &mut Vec<Vec<u8>>) -> Option<bool>,
7182            u32,
7183        ); 3] = [
7184            (
7185                false,
7186                |session, emitted| {
7187                    session.toggle_gnss_ident_update(&mut |b: &[u8]| emitted.push(b.to_vec()))
7188                },
7189                prop::GNSS_IDENT_UPDATE,
7190            ),
7191            (
7192                false,
7193                |session, emitted| {
7194                    session.toggle_repeater(&mut |b: &[u8]| emitted.push(b.to_vec()))
7195                },
7196                prop::MAC_REPEATER_ENABLED,
7197            ),
7198            (
7199                true,
7200                |session, emitted| session.toggle_ble(&mut |b: &[u8]| emitted.push(b.to_vec())),
7201                prop::BLE_ENABLED,
7202            ),
7203        ];
7204
7205        for (start, toggle, key) in cases {
7206            let mut session = test_session();
7207            assert_eq!(get(&mut session, key), [start as u8], "prop {key} start");
7208            let before = session.dev_domain_version();
7209
7210            let mut emitted = Vec::new();
7211            assert_eq!(toggle(&mut session, &mut emitted), Some(!start));
7212            assert_eq!(get(&mut session, key), [!start as u8]);
7213            assert_ne!(
7214                session.dev_domain_version(),
7215                before,
7216                "prop {key} never reached the mirror"
7217            );
7218            assert_eq!(
7219                parse_prop_is(&emitted[0]),
7220                (TID_UNSOLICITED, key, vec![!start as u8]),
7221                "prop {key} was not announced"
7222            );
7223
7224            // And back, because it is a toggle rather than a set.
7225            emitted.clear();
7226            assert_eq!(toggle(&mut session, &mut emitted), Some(start));
7227        }
7228    }
7229
7230    /// Bluetooth is a capability like any other: a board without the
7231    /// transport has neither the property nor the switch.
7232    #[test]
7233    fn a_board_without_bluetooth_has_neither_the_property_nor_the_switch() {
7234        let config = SessionConfig {
7235            ble: false,
7236            ..test_config()
7237        };
7238        let mut session: TestSession = Session::new(config, Status::RESET_POWER_ON, test_engine());
7239        session.attach(true);
7240
7241        assert!(!session.ble_enabled());
7242        let mut emitted = Vec::new();
7243        assert_eq!(
7244            session.toggle_ble(&mut |bytes: &[u8]| emitted.push(bytes.to_vec())),
7245            None
7246        );
7247        assert!(emitted.is_empty());
7248
7249        let raw = get(&mut session, prop::CAPS);
7250        let mut offset = 0;
7251        while offset < raw.len() {
7252            let (value, used) = pui::decode(&raw[offset..]).unwrap();
7253            assert_ne!(value, cap::BLE);
7254            offset += used;
7255        }
7256
7257        let mut buf = [0u8; 16];
7258        let len = frame::prop_get(&mut buf, 6, prop::BLE_ENABLED).unwrap();
7259        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
7260        let (_, status_key, value) = parse_prop_is(&emitted[0]);
7261        assert_eq!(status_key, prop::LAST_STATUS);
7262        assert_eq!(pui::decode(&value).unwrap().0, Status::PROP_NOT_FOUND.0);
7263    }
7264
7265    /// A press on a board with no receiver is nothing at all, so a board
7266    /// can report the press without knowing what it has.
7267    #[test]
7268    fn a_local_toggle_without_the_capability_is_inert() {
7269        let mut session: TestSession =
7270            Session::new(timeless_config(), Status::RESET_POWER_ON, test_engine());
7271        let mut emitted = Vec::new();
7272        assert_eq!(
7273            session.toggle_gnss(&mut |bytes: &[u8]| emitted.push(bytes.to_vec())),
7274            None
7275        );
7276        assert!(emitted.is_empty());
7277    }
7278
7279    /// A board without a receiver reports the switch as off rather than
7280    /// leaving the platform to ask whether it has one.
7281    #[test]
7282    fn gnss_accessors_are_false_without_the_capability() {
7283        let session: TestSession =
7284            Session::new(timeless_config(), Status::RESET_POWER_ON, test_engine());
7285        assert!(!session.gnss_enabled());
7286        assert!(!session.gnss_ident_update());
7287    }
7288
7289    #[test]
7290    fn advertisement_policy_round_trips_and_survives_a_reboot() {
7291        let mut session = test_session();
7292        assert_eq!(
7293            get(&mut session, prop::ADVERT_INTERVAL),
7294            DEFAULT_ADVERT_INTERVAL_S.to_le_bytes()
7295        );
7296        assert_eq!(
7297            get(&mut session, prop::BEACON_INTERVAL),
7298            DEFAULT_BEACON_INTERVAL_S.to_le_bytes()
7299        );
7300        assert_eq!(get(&mut session, prop::STARTUP_BEACON), [1]);
7301
7302        let (emitted, effect) = set(&mut session, prop::ADVERT_INTERVAL, &7200u32.to_le_bytes());
7303        assert!(effect.is_none());
7304        let (_, key, value) = parse_prop_is(&emitted[0]);
7305        assert_eq!(key, prop::ADVERT_INTERVAL);
7306        assert_eq!(value, 7200u32.to_le_bytes());
7307        // Zero is the off switch, and each interval moves alone.
7308        set(&mut session, prop::BEACON_INTERVAL, &0u32.to_le_bytes());
7309        set(&mut session, prop::STARTUP_BEACON, &[0]);
7310        assert_eq!(session.advert_interval_s(), 7200);
7311        assert_eq!(session.beacon_interval_s(), 0);
7312        assert!(!session.startup_beacon());
7313
7314        save(&mut session);
7315        let mut bytes = [0u8; SNAPSHOT_MAX];
7316        let len = session.encode_snapshot(&mut bytes).unwrap();
7317        let mut booted: TestSession =
7318            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
7319        booted.restore_at_boot(&bytes[..len]).unwrap();
7320        assert_eq!(booted.advert_interval_s(), 7200);
7321        assert_eq!(booted.beacon_interval_s(), 0);
7322        assert!(!booted.startup_beacon());
7323    }
7324
7325    /// The bounds exist to catch a mistyped interval, so they must not
7326    /// also catch the one value that legitimately means "never", and both
7327    /// ends themselves have to be reachable.
7328    #[test]
7329    fn announce_interval_holds_to_its_bounds_but_accepts_zero() {
7330        let mut session = test_session();
7331        for &key in &[prop::ADVERT_INTERVAL, prop::BEACON_INTERVAL] {
7332            for rejected in [
7333                MIN_AUTO_ANNOUNCE_INTERVAL_S - 1,
7334                MAX_AUTO_ANNOUNCE_INTERVAL_S + 1,
7335                u32::MAX,
7336            ] {
7337                let (emitted, _) = set(&mut session, key, &rejected.to_le_bytes());
7338                expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
7339            }
7340
7341            for accepted in [
7342                MIN_AUTO_ANNOUNCE_INTERVAL_S,
7343                MAX_AUTO_ANNOUNCE_INTERVAL_S,
7344                // Zero is the off switch, not a too-short interval.
7345                0,
7346            ] {
7347                let (emitted, _) = set(&mut session, key, &accepted.to_le_bytes());
7348                let (_, response_key, value) = parse_prop_is(&emitted[0]);
7349                assert_eq!(response_key, key);
7350                assert_eq!(value, accepted.to_le_bytes());
7351            }
7352        }
7353    }
7354
7355    /// A snapshot written before these properties existed carries none of
7356    /// them, and absence has to decode as the documented default rather
7357    /// than as zero — otherwise an upgrade would silently switch every
7358    /// automatic announcement off.
7359    #[test]
7360    fn a_snapshot_without_advertisement_options_restores_the_defaults() {
7361        let mut session = test_session();
7362        set(&mut session, prop::ADVERT_INTERVAL, &0u32.to_le_bytes());
7363        set(&mut session, prop::STARTUP_BEACON, &[0]);
7364        save(&mut session);
7365        let mut bytes = [0u8; SNAPSHOT_MAX];
7366        let len = session.encode_snapshot(&mut bytes).unwrap();
7367
7368        // Strip the three advertisement options, leaving what an older
7369        // writer would have produced.
7370        let stripped = strip_snapshot_options(
7371            &bytes[..len],
7372            &[
7373                prop::ADVERT_INTERVAL,
7374                prop::BEACON_INTERVAL,
7375                prop::STARTUP_BEACON,
7376            ],
7377        );
7378
7379        let mut booted: TestSession =
7380            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
7381        booted.restore_at_boot(&stripped).unwrap();
7382        assert_eq!(booted.advert_interval_s(), DEFAULT_ADVERT_INTERVAL_S);
7383        assert_eq!(booted.beacon_interval_s(), DEFAULT_BEACON_INTERVAL_S);
7384        assert!(booted.startup_beacon());
7385    }
7386
7387    /// Role, mobility and forwarding are three independent dimensions.
7388    /// The property surface has to keep them that way: forwarding is a
7389    /// fact the device reports, the role is what it presents itself as,
7390    /// and mobility is neither.
7391    #[test]
7392    fn identity_role_and_mobility_are_independent_of_forwarding() {
7393        let mut session = test_session();
7394        // Defaults: derive the role, not mobile.
7395        assert_eq!(get(&mut session, prop::IDENT_ROLE), Vec::<u8>::new());
7396        assert_eq!(get(&mut session, prop::IDENT_MOBILE), [0]);
7397
7398        // Enabling forwarding does not touch either property; the role
7399        // stays "derive it", and derivation happens where the identity
7400        // is actually built.
7401        set(&mut session, prop::MAC_REPEATER_ENABLED, &[1]);
7402        assert_eq!(get(&mut session, prop::IDENT_ROLE), Vec::<u8>::new());
7403
7404        // A mobile repeater: an explicit role plus the mobility bit,
7405        // with forwarding still on.
7406        let (emitted, effect) = set(&mut session, prop::IDENT_ROLE, &[1]);
7407        assert!(effect.is_none());
7408        let (_, key, value) = parse_prop_is(&emitted[0]);
7409        assert_eq!(key, prop::IDENT_ROLE);
7410        assert_eq!(value, [1]);
7411        set(&mut session, prop::IDENT_MOBILE, &[1]);
7412        assert_eq!(get(&mut session, prop::IDENT_ROLE), [1]);
7413        assert_eq!(get(&mut session, prop::IDENT_MOBILE), [1]);
7414        assert_eq!(get(&mut session, prop::MAC_REPEATER_ENABLED), [1]);
7415
7416        // Clearing the role returns it to derivation.
7417        set(&mut session, prop::IDENT_ROLE, &[]);
7418        assert_eq!(get(&mut session, prop::IDENT_ROLE), Vec::<u8>::new());
7419
7420        // Both are saved device-domain state.
7421        set(&mut session, prop::IDENT_ROLE, &[3]);
7422        save(&mut session);
7423        let mut bytes = [0u8; SNAPSHOT_MAX];
7424        let len = session.encode_snapshot(&mut bytes).unwrap();
7425        let mut booted: TestSession =
7426            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
7427        booted.restore_at_boot(&bytes[..len]).unwrap();
7428        assert_eq!(booted.ident_role(), Some(3));
7429        assert!(booted.ident_mobile());
7430        booted.attach(true);
7431        assert_eq!(get(&mut booted, prop::IDENT_ROLE), [3]);
7432        assert_eq!(get(&mut booted, prop::IDENT_MOBILE), [1]);
7433
7434        // Out-of-range values are refused rather than truncated.
7435        let (emitted, _) = set(&mut booted, prop::IDENT_ROLE, &[1, 2]);
7436        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
7437        let (emitted, _) = set(&mut booted, prop::IDENT_MOBILE, &[2]);
7438        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
7439    }
7440
7441    /// The advertised position is writable, survives a save, and reports
7442    /// the altitude back in the minimal encoding whatever width arrived.
7443    #[test]
7444    fn advertised_position_is_written_and_saved() {
7445        let mut session = test_session();
7446        assert_eq!(get(&mut session, prop::IDENT_LOCATION), Vec::<u8>::new());
7447        assert_eq!(get(&mut session, prop::IDENT_ALTITUDE), Vec::<u8>::new());
7448
7449        set(&mut session, prop::IDENT_LOCATION, &[1, 2, 3, 4, 5]);
7450        set(&mut session, prop::IDENT_ALTITUDE, &[100]);
7451        assert_eq!(get(&mut session, prop::IDENT_LOCATION), [1, 2, 3, 4, 5]);
7452        assert_eq!(get(&mut session, prop::IDENT_ALTITUDE), [100]);
7453
7454        // A padded write is understood, and read back minimal.
7455        set(&mut session, prop::IDENT_ALTITUDE, &[100, 0, 0, 0]);
7456        assert_eq!(get(&mut session, prop::IDENT_ALTITUDE), [100]);
7457        // Below the ellipsoid is ordinary.
7458        set(&mut session, prop::IDENT_ALTITUDE, &[0x9C, 0xFF]);
7459        assert_eq!(session.ident_altitude_m(), Some(-100));
7460        assert_eq!(get(&mut session, prop::IDENT_ALTITUDE), [0x9C]);
7461        // Two octets once the value stops fitting in one.
7462        set(&mut session, prop::IDENT_ALTITUDE, &[0xC8, 0x00]);
7463        assert_eq!(get(&mut session, prop::IDENT_ALTITUDE), [0xC8, 0x00]);
7464
7465        // Over-wide and over-long values are refused.
7466        let (emitted, _) = set(&mut session, prop::IDENT_ALTITUDE, &[0; 5]);
7467        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
7468        let (emitted, _) = set(&mut session, prop::IDENT_LOCATION, &[0; 8]);
7469        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
7470
7471        save(&mut session);
7472        let mut bytes = [0u8; SNAPSHOT_MAX];
7473        let len = session.encode_snapshot(&mut bytes).unwrap();
7474        let mut booted: TestSession =
7475            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
7476        booted.restore_at_boot(&bytes[..len]).unwrap();
7477        assert_eq!(booted.ident_location(), [1, 2, 3, 4, 5]);
7478        assert_eq!(booted.ident_altitude_m(), Some(200));
7479
7480        // Clearing the location is how a stale claim is withdrawn.
7481        booted.attach(true);
7482        set(&mut booted, prop::IDENT_LOCATION, &[]);
7483        assert_eq!(get(&mut booted, prop::IDENT_LOCATION), Vec::<u8>::new());
7484    }
7485
7486    /// Where a node is and whether it can find that out itself are
7487    /// separate questions: a board with no receiver still has a position
7488    /// worth advertising, and nothing gates it away.
7489    #[test]
7490    fn a_board_without_a_receiver_still_advertises_a_position() {
7491        let mut session: TestSession =
7492            Session::new(timeless_config(), Status::RESET_POWER_ON, test_engine());
7493        session.attach(true);
7494        set(&mut session, prop::IDENT_LOCATION, &[9, 8, 7]);
7495        assert_eq!(get(&mut session, prop::IDENT_LOCATION), [9, 8, 7]);
7496        // The receiver's own properties are still hidden.
7497        let mut buf = [0u8; 16];
7498        let len = frame::prop_get(&mut buf, 1, prop::GNSS_LOCATION).unwrap();
7499        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
7500        expect_status(&emitted[0], 1, Status::PROP_NOT_FOUND);
7501    }
7502
7503    /// While the device maintains the position from its own fixes, a
7504    /// written one would survive only until the next fix — so it is
7505    /// refused rather than silently reverted. Switching auto-update off
7506    /// freezes what the fixes found, which is how a fixed node is placed.
7507    #[test]
7508    fn auto_update_owns_the_position_until_it_is_switched_off() {
7509        let mut session = test_session();
7510        set(&mut session, prop::GNSS_IDENT_UPDATE, &[1]);
7511
7512        let (emitted, _) = set(&mut session, prop::IDENT_LOCATION, &[1, 2, 3]);
7513        expect_status(&emitted[0], 2, Status::INVALID_STATE);
7514        let (emitted, _) = set(&mut session, prop::IDENT_ALTITUDE, &[10]);
7515        expect_status(&emitted[0], 2, Status::INVALID_STATE);
7516
7517        // Fixes land, clamped to the advertised precision.
7518        set(&mut session, prop::GNSS_IDENT_PRECISION, &[3]);
7519        assert!(session.absorb_ident_fix(&[1, 2, 3, 4, 5, 6, 7], Some(120)));
7520        assert_eq!(session.ident_location(), [1, 2, 3]);
7521        assert_eq!(session.ident_altitude_m(), Some(120));
7522
7523        // A fix landing in the same cell changes nothing, so nothing
7524        // re-signs.
7525        assert!(!session.absorb_ident_fix(&[1, 2, 3, 9, 9, 9, 9], Some(120)));
7526        assert!(session.absorb_ident_fix(&[1, 2, 4, 0, 0, 0, 0], Some(120)));
7527
7528        // Switching auto-update off freezes the last position and hands
7529        // it back as an ordinary written value.
7530        set(&mut session, prop::GNSS_IDENT_UPDATE, &[0]);
7531        assert_eq!(session.ident_location(), [1, 2, 4]);
7532        assert_eq!(session.ident_altitude_m(), Some(120));
7533        assert!(!session.absorb_ident_fix(&[7, 7, 7], Some(0)));
7534        set(&mut session, prop::IDENT_LOCATION, &[5, 5]);
7535        assert_eq!(get(&mut session, prop::IDENT_LOCATION), [5, 5]);
7536    }
7537
7538    /// Discoverability defaults on — a deployed device being askable is
7539    /// most of the point — and the opt-out is saved device-domain state.
7540    #[test]
7541    fn dev_discoverable_defaults_on_and_the_opt_out_survives_reboot() {
7542        let mut session = test_session();
7543        assert_eq!(get(&mut session, prop::DEV_DISCOVERABLE), [1]);
7544        assert!(session.dev_discoverable());
7545
7546        let (emitted, effect) = set(&mut session, prop::DEV_DISCOVERABLE, &[0]);
7547        assert!(effect.is_none());
7548        let (_, key, value) = parse_prop_is(&emitted[0]);
7549        assert_eq!(key, prop::DEV_DISCOVERABLE);
7550        assert_eq!(value, [0]);
7551        assert!(!session.dev_discoverable());
7552
7553        save(&mut session);
7554        let mut bytes = [0u8; SNAPSHOT_MAX];
7555        let len = session.encode_snapshot(&mut bytes).unwrap();
7556        let mut booted: TestSession =
7557            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
7558        booted.restore_at_boot(&bytes[..len]).unwrap();
7559        assert!(!booted.dev_discoverable());
7560        booted.attach(true);
7561        assert_eq!(get(&mut booted, prop::DEV_DISCOVERABLE), [0]);
7562
7563        // Bool discipline: anything but 0 or 1 is refused.
7564        let (emitted, _) = set(&mut booted, prop::DEV_DISCOVERABLE, &[2]);
7565        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
7566    }
7567
7568    /// `PROP_IDENT` is a signature the session cannot produce, so the
7569    /// read defers to the platform and the platform's answer — success
7570    /// or failure — is what the host sees.
7571    #[test]
7572    fn prop_ident_defers_to_the_platform_for_signing() {
7573        let mut session = test_session();
7574        let mut buf = [0u8; 16];
7575        let len = frame::prop_get(&mut buf, 7, prop::IDENT).unwrap();
7576        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
7577        assert!(emitted.is_empty(), "nothing before the signature exists");
7578        assert_eq!(effect, Some(Effect::SignIdentity { tid: 7 }));
7579
7580        let blob = [0xAB; 96];
7581        let mut out = Vec::new();
7582        session.respond_identity_blob(7, Ok(&blob), &mut |bytes: &[u8]| out.push(bytes.to_vec()));
7583        let (tid, key, value) = parse_prop_is(&out[0]);
7584        assert_eq!((tid, key), (7, prop::IDENT));
7585        assert_eq!(value, blob);
7586
7587        // A board with no device node reports failure rather than an
7588        // empty or fabricated identity.
7589        let (_, effect) = dispatch(&mut session, &buf[..len], 0);
7590        assert_eq!(effect, Some(Effect::SignIdentity { tid: 7 }));
7591        let mut out = Vec::new();
7592        session.respond_identity_blob(7, Err(()), &mut |bytes: &[u8]| out.push(bytes.to_vec()));
7593        expect_status(&out[0], 7, Status::FAILURE);
7594    }
7595
7596    #[test]
7597    fn repeater_enable_round_trips_and_persists() {
7598        let mut session = test_session();
7599        // Defaults off, accepted before any identity is provisioned
7600        // (store-and-defer), and echoes the authoritative value back.
7601        assert_eq!(get(&mut session, prop::MAC_REPEATER_ENABLED), [0]);
7602        let (emitted, effect) = set(&mut session, prop::MAC_REPEATER_ENABLED, &[1]);
7603        let (_, key, value) = parse_prop_is(&emitted[0]);
7604        assert_eq!(key, prop::MAC_REPEATER_ENABLED);
7605        assert_eq!(value, [1]);
7606        // Not radio-affecting; no ApplyRadio effect.
7607        assert_eq!(effect, None);
7608        assert_eq!(get(&mut session, prop::MAC_REPEATER_ENABLED), [1]);
7609        assert!(session.repeater_enabled());
7610        // Only a boolean is accepted.
7611        let (emitted, _) = set(&mut session, prop::MAC_REPEATER_ENABLED, &[2]);
7612        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
7613
7614        // Survives save + boot-from-snapshot.
7615        save(&mut session);
7616        let mut bytes = [0u8; SNAPSHOT_MAX];
7617        let len = session.encode_snapshot(&mut bytes).unwrap();
7618        let mut booted: TestSession =
7619            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
7620        booted.restore_at_boot(&bytes[..len]).unwrap();
7621        assert!(booted.repeater_enabled());
7622        booted.attach(true);
7623        assert_eq!(get(&mut booted, prop::MAC_REPEATER_ENABLED), [1]);
7624
7625        // A fresh unprovisioned session defaults off.
7626        let fresh: TestSession = Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
7627        assert!(!fresh.repeater_enabled());
7628    }
7629
7630    /// The four forwarding gates are configurable while forwarding is
7631    /// off — an administrator stages the policy and enables it last —
7632    /// and each survives save and boot-from-snapshot.
7633    #[test]
7634    fn repeater_policy_round_trips_and_persists() {
7635        let mut session = test_session();
7636
7637        // Post-reset every gate is unset, which reads as empty.
7638        assert_eq!(
7639            get(&mut session, prop::MAC_REPEATER_REGIONS),
7640            Vec::<u8>::new()
7641        );
7642        assert_eq!(
7643            get(&mut session, prop::MAC_REPEATER_DEFAULT_REGION),
7644            Vec::<u8>::new()
7645        );
7646        assert_eq!(
7647            get(&mut session, prop::MAC_REPEATER_MIN_RSSI),
7648            Vec::<u8>::new()
7649        );
7650        assert_eq!(
7651            get(&mut session, prop::MAC_REPEATER_MIN_SNR),
7652            Vec::<u8>::new()
7653        );
7654
7655        // Written with forwarding still disabled.
7656        assert_eq!(get(&mut session, prop::MAC_REPEATER_ENABLED), [0]);
7657        let table = region_table(&["SJC", "Rogue Valley"]);
7658        let (emitted, effect) = set(&mut session, prop::MAC_REPEATER_REGIONS, &table);
7659        assert_eq!(effect, None, "policy is not radio-affecting");
7660        let (_, key, value) = parse_prop_is(&emitted[0]);
7661        assert_eq!(key, prop::MAC_REPEATER_REGIONS);
7662        assert_eq!(value, table, "the strings read back as they were written");
7663        set(
7664            &mut session,
7665            prop::MAC_REPEATER_DEFAULT_REGION,
7666            &[0x78, 0x53],
7667        );
7668        // −115 dBm, little-endian.
7669        set(&mut session, prop::MAC_REPEATER_MIN_RSSI, &[0x8D, 0xFF]);
7670        // −7 dB.
7671        set(&mut session, prop::MAC_REPEATER_MIN_SNR, &[0xF9]);
7672
7673        assert_eq!(region_names(&session), ["SJC", "Rogue Valley"]);
7674        assert_eq!(
7675            region_codes(&session),
7676            [[0x78, 0x53], [0xC0, 0xF9]],
7677            "an airport code and a hashed name, derived once at the write"
7678        );
7679        assert_eq!(session.repeater_default_region(), Some([0x78, 0x53]));
7680        assert_eq!(session.repeater_min_rssi(), Some(-115));
7681        assert_eq!(session.repeater_min_snr(), Some(-7));
7682
7683        // Survives save + boot-from-snapshot.
7684        save(&mut session);
7685        let mut bytes = [0u8; SNAPSHOT_MAX];
7686        let len = session.encode_snapshot(&mut bytes).unwrap();
7687        let mut booted: TestSession =
7688            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
7689        booted.restore_at_boot(&bytes[..len]).unwrap();
7690        assert_eq!(region_names(&booted), ["SJC", "Rogue Valley"]);
7691        assert_eq!(
7692            region_codes(&booted),
7693            [[0x78, 0x53], [0xC0, 0xF9]],
7694            "the snapshot carries the strings; the codes re-derive on restore"
7695        );
7696        assert_eq!(booted.repeater_default_region(), Some([0x78, 0x53]));
7697        assert_eq!(booted.repeater_min_rssi(), Some(-115));
7698        assert_eq!(booted.repeater_min_snr(), Some(-7));
7699        booted.attach(true);
7700        assert_eq!(
7701            get(&mut booted, prop::MAC_REPEATER_MIN_RSSI),
7702            [0x8D, 0xFF],
7703            "the reported value is the little-endian INT16 that was written"
7704        );
7705        assert_eq!(get(&mut booted, prop::MAC_REPEATER_MIN_SNR), [0xF9]);
7706    }
7707
7708    /// Every gate clears back to unset by writing it empty, and a
7709    /// snapshot taken with them unset carries no option at all — which
7710    /// is what makes absence and the default decode identically.
7711    #[test]
7712    fn repeater_policy_clears_back_to_unset() {
7713        let mut session = test_session();
7714        set(
7715            &mut session,
7716            prop::MAC_REPEATER_REGIONS,
7717            &region_table(&["SJC"]),
7718        );
7719        set(
7720            &mut session,
7721            prop::MAC_REPEATER_DEFAULT_REGION,
7722            &[0x78, 0x53],
7723        );
7724        set(&mut session, prop::MAC_REPEATER_MIN_RSSI, &[0x8D, 0xFF]);
7725        set(&mut session, prop::MAC_REPEATER_MIN_SNR, &[0xF9]);
7726
7727        set(&mut session, prop::MAC_REPEATER_REGIONS, &[]);
7728        set(&mut session, prop::MAC_REPEATER_DEFAULT_REGION, &[]);
7729        set(&mut session, prop::MAC_REPEATER_MIN_RSSI, &[]);
7730        set(&mut session, prop::MAC_REPEATER_MIN_SNR, &[]);
7731
7732        assert_eq!(region_names(&session), Vec::<String>::new());
7733        assert_eq!(session.repeater_default_region(), None);
7734        assert_eq!(session.repeater_min_rssi(), None);
7735        assert_eq!(session.repeater_min_snr(), None);
7736
7737        save(&mut session);
7738        let mut bytes = [0u8; SNAPSHOT_MAX];
7739        let len = session.encode_snapshot(&mut bytes).unwrap();
7740        let mut booted: TestSession =
7741            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
7742        booted.restore_at_boot(&bytes[..len]).unwrap();
7743        assert_eq!(region_names(&booted), Vec::<String>::new());
7744        assert_eq!(booted.repeater_default_region(), None);
7745        assert_eq!(booted.repeater_min_rssi(), None);
7746        assert_eq!(booted.repeater_min_snr(), None);
7747    }
7748
7749    /// A malformed gate is refused outright rather than truncated or
7750    /// rounded into range, so a host never believes it configured a
7751    /// policy the device did not accept.
7752    #[test]
7753    fn repeater_policy_rejects_malformed_values() {
7754        let mut session = test_session();
7755
7756        // A region string is 1 to 24 octets of UTF-8. Everything outside
7757        // that is malformed, not merely unrecognized — the derivation
7758        // itself is total over every string within the bounds.
7759        for bad in [
7760            b"".as_slice(),
7761            // Twenty-five octets: one past the cap.
7762            b"AAAAAAAAAAAAAAAAAAAAAAAAA",
7763            b"\xFF\xFE not text",
7764        ] {
7765            let (emitted, _) = set(
7766                &mut session,
7767                prop::MAC_REPEATER_REGIONS,
7768                &region_table_raw(&[bad]),
7769            );
7770            expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
7771        }
7772        // A truncated length prefix is malformed the same way.
7773        let (emitted, _) = set(&mut session, prop::MAC_REPEATER_REGIONS, &[9, b'S', b'J']);
7774        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
7775        // More regions than the device can hold is a capacity answer.
7776        let names: Vec<String> = (0..=MAX_REPEATER_REGIONS)
7777            .map(|index| format!("Region {index}"))
7778            .collect();
7779        let over_capacity = region_table(&names.iter().map(String::as_str).collect::<Vec<_>>());
7780        let (emitted, _) = set(&mut session, prop::MAC_REPEATER_REGIONS, &over_capacity);
7781        expect_status(&emitted[0], 2, Status::NOMEM);
7782
7783        // The default region is exactly one code or nothing.
7784        let (emitted, _) = set(
7785            &mut session,
7786            prop::MAC_REPEATER_DEFAULT_REGION,
7787            &[0x78, 0x53, 0x31],
7788        );
7789        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
7790
7791        // The thresholds are fixed-width.
7792        let (emitted, _) = set(&mut session, prop::MAC_REPEATER_MIN_RSSI, &[0x8D]);
7793        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
7794        let (emitted, _) = set(&mut session, prop::MAC_REPEATER_MIN_SNR, &[0xF9, 0xFF]);
7795        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
7796
7797        // Nothing was partially applied.
7798        assert_eq!(region_names(&session), Vec::<String>::new());
7799        assert_eq!(session.repeater_default_region(), None);
7800        assert_eq!(session.repeater_min_rssi(), None);
7801        assert_eq!(session.repeater_min_snr(), None);
7802    }
7803
7804    /// The default region is not required to appear in the region list.
7805    /// The two are written separately and in either order, so a
7806    /// membership check here would reject a legitimate write purely for
7807    /// arriving first.
7808    #[test]
7809    fn repeater_default_region_is_not_cross_checked_against_the_region_list() {
7810        let mut session = test_session();
7811        // Default first, list second.
7812        set(
7813            &mut session,
7814            prop::MAC_REPEATER_DEFAULT_REGION,
7815            &[0x78, 0x53],
7816        );
7817        assert_eq!(session.repeater_default_region(), Some([0x78, 0x53]));
7818        set(
7819            &mut session,
7820            prop::MAC_REPEATER_REGIONS,
7821            &region_table(&["SF Bay Area"]),
7822        );
7823        assert_eq!(region_codes(&session), [[0xD8, 0xB7]]);
7824        assert_eq!(
7825            session.repeater_default_region(),
7826            Some([0x78, 0x53]),
7827            "a later region-list write must not silently drop the default"
7828        );
7829
7830        // A default with no list at all is equally allowed: filtering and
7831        // tagging are independent decisions.
7832        let mut session = test_session();
7833        set(
7834            &mut session,
7835            prop::MAC_REPEATER_DEFAULT_REGION,
7836            &[0xAB, 0xCD],
7837        );
7838        assert_eq!(region_names(&session), Vec::<String>::new());
7839        assert_eq!(session.repeater_default_region(), Some([0xAB, 0xCD]));
7840    }
7841
7842    /// The region table is also editable one entry at a time, so a host
7843    /// adding a region does not have to know — or resend — the rest of
7844    /// the table it is not changing.
7845    #[test]
7846    fn repeater_regions_insert_and_remove_one_entry_at_a_time() {
7847        let mut session = test_session();
7848
7849        let (emitted, effect) = insert_item(&mut session, prop::MAC_REPEATER_REGIONS, b"SJC");
7850        assert!(effect.is_none());
7851        let (key, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
7852        assert_eq!(key, prop::MAC_REPEATER_REGIONS);
7853        assert_eq!(digest, b"SJC", "the inserted item is echoed as written");
7854        insert_item(&mut session, prop::MAC_REPEATER_REGIONS, b"Rogue Valley");
7855        assert_eq!(region_names(&session), ["SJC", "Rogue Valley"]);
7856        assert_eq!(region_codes(&session), [[0x78, 0x53], [0xC0, 0xF9]]);
7857        assert_eq!(
7858            get(&mut session, prop::MAC_REPEATER_REGIONS),
7859            region_table(&["SJC", "Rogue Valley"])
7860        );
7861
7862        // A literal code is a region string too, and derives to itself.
7863        insert_item(&mut session, prop::MAC_REPEATER_REGIONS, b"0x1234");
7864        assert_eq!(region_codes(&session)[2], [0x12, 0x34]);
7865
7866        // Re-inserting is idempotent, not an error the host has to
7867        // distinguish from a real failure.
7868        let (emitted, _) = insert_item(&mut session, prop::MAC_REPEATER_REGIONS, b"SJC");
7869        expect_status(&emitted[0], 5, Status::ALREADY);
7870        assert_eq!(region_names(&session), ["SJC", "Rogue Valley", "0x1234"]);
7871
7872        // Removal selects by the string that was written, not the code.
7873        let (emitted, _) = remove_item(&mut session, prop::MAC_REPEATER_REGIONS, b"SJC");
7874        let (key, digest) = parse_table_notice(&emitted[0], Cmd::PropRemoved, 6);
7875        assert_eq!(key, prop::MAC_REPEATER_REGIONS);
7876        assert_eq!(digest, b"SJC");
7877        assert_eq!(region_names(&session), ["0x1234", "Rogue Valley"]);
7878
7879        let (emitted, _) = remove_item(&mut session, prop::MAC_REPEATER_REGIONS, b"SJC");
7880        expect_status(&emitted[0], 6, Status::ITEM_NOT_FOUND);
7881        // A malformed selector is refused before the lookup, so it reads
7882        // as bad input rather than a missing entry.
7883        let (emitted, _) = remove_item(&mut session, prop::MAC_REPEATER_REGIONS, b"");
7884        expect_status(&emitted[0], 6, Status::INVALID_ARGUMENT);
7885
7886        // Filling the table leaves the insert answering out of capacity.
7887        for index in 0..MAX_REPEATER_REGIONS - 2 {
7888            let name = format!("Region {index}");
7889            insert_item(&mut session, prop::MAC_REPEATER_REGIONS, name.as_bytes());
7890        }
7891        let (emitted, _) = insert_item(&mut session, prop::MAC_REPEATER_REGIONS, b"One too many");
7892        expect_status(&emitted[0], 5, Status::NOMEM);
7893    }
7894
7895    /// A region is one region however it was capitalized. Holding two
7896    /// spellings of it would spend two of eight slots on entries that
7897    /// filter identically and read alike, so a new spelling replaces the
7898    /// held one instead.
7899    #[test]
7900    fn repeater_regions_hold_one_entry_per_region_whatever_its_case() {
7901        let mut session = test_session();
7902
7903        insert_item(&mut session, prop::MAC_REPEATER_REGIONS, b"MFD");
7904        let (emitted, _) = insert_item(&mut session, prop::MAC_REPEATER_REGIONS, b"mfd");
7905        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
7906        assert_eq!(digest, b"mfd", "the respelling is what the host is told");
7907        assert_eq!(region_names(&session), ["mfd"]);
7908        assert_eq!(
7909            region_codes(&session),
7910            [[0x52, 0x34]],
7911            "the code never moved: it derives from the folded string"
7912        );
7913
7914        // The same string twice is still the idempotent case.
7915        let (emitted, _) = insert_item(&mut session, prop::MAC_REPEATER_REGIONS, b"mfd");
7916        expect_status(&emitted[0], 5, Status::ALREADY);
7917
7918        // Names fold too, and removal selects a region rather than a
7919        // spelling of one.
7920        insert_item(&mut session, prop::MAC_REPEATER_REGIONS, b"Rogue Valley");
7921        insert_item(&mut session, prop::MAC_REPEATER_REGIONS, b"ROGUE VALLEY");
7922        assert_eq!(region_names(&session), ["mfd", "ROGUE VALLEY"]);
7923        remove_item(&mut session, prop::MAC_REPEATER_REGIONS, b"rogue valley");
7924        assert_eq!(region_names(&session), ["mfd"]);
7925
7926        // A whole-table write collapses the same way, keeping the last
7927        // spelling it carried.
7928        set(
7929            &mut session,
7930            prop::MAC_REPEATER_REGIONS,
7931            &region_table(&["SJC", "sjc", "Sjc"]),
7932        );
7933        assert_eq!(region_names(&session), ["Sjc"]);
7934        assert_eq!(region_codes(&session), [[0x78, 0x53]]);
7935    }
7936
7937    #[test]
7938    fn device_name_round_trips_survives_attach_and_resets_to_default() {
7939        let mut session = test_session();
7940        let configured = "Field Radio 📻";
7941        let (emitted, effect) = set(&mut session, prop::DEV_NAME, configured.as_bytes());
7942        let (_, key, value) = parse_prop_is(&emitted[0]);
7943        assert_eq!(key, prop::DEV_NAME);
7944        assert_eq!(value, configured.as_bytes());
7945        assert_eq!(effect, Some(Effect::DeviceNameChanged));
7946        assert_eq!(session.device_name(), configured);
7947
7948        session.attach(true);
7949        assert_eq!(get(&mut session, prop::DEV_NAME), configured.as_bytes());
7950
7951        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_| {});
7952        assert_eq!(get(&mut session, prop::DEV_NAME), b"Test UMSH Device");
7953    }
7954
7955    #[test]
7956    fn attach_preserves_device_domain_and_emits_nothing() {
7957        let mut session = test_session_with_boot_status(Status::RESET_WATCHDOG);
7958
7959        // Configure and enable the PHY, adjust the duty limit, and
7960        // record duty usage.
7961        set(&mut session, prop::PHY_FREQ, &906_875u32.to_le_bytes());
7962        set(&mut session, prop::PHY_LORA_SF, &[9]);
7963        set(&mut session, prop::PHY_DUTY_LIMIT, &100u16.to_le_bytes());
7964        enable(&mut session);
7965        let settings_before = session.settings();
7966        assert!(settings_before.enabled);
7967        let (_, effect) = send_packet(&mut session, 1, &[0xAB; 8], &[], 0);
7968        assert_eq!(effect, Some(Effect::StartTransmit));
7969        let mut emitted = Vec::new();
7970        session.on_tx_result(TxOutcome::Sent, 0, &mut |bytes: &[u8]| {
7971            emitted.push(bytes.to_vec())
7972        });
7973        let duty_before = get(&mut session, prop::PHY_DUTY_NOW);
7974        assert_ne!(duty_before, 0u16.to_le_bytes());
7975
7976        // Attach must not reconfigure or disable the PHY, must not
7977        // touch the duty limit or accounting, and must emit nothing.
7978        session.attach(true);
7979        assert_eq!(session.settings(), settings_before);
7980        assert_eq!(get(&mut session, prop::PHY_ENABLED), [1]);
7981        assert_eq!(get(&mut session, prop::PHY_FREQ), 906_875u32.to_le_bytes());
7982        assert_eq!(
7983            get(&mut session, prop::PHY_DUTY_LIMIT),
7984            100u16.to_le_bytes()
7985        );
7986        assert_eq!(get(&mut session, prop::PHY_DUTY_NOW), duty_before);
7987    }
7988
7989    #[test]
7990    fn attach_retains_boot_status_for_first_query() {
7991        let mut session = test_session_with_boot_status(Status::RESET_WATCHDOG);
7992        session.attach(true);
7993        let raw = get(&mut session, prop::LAST_STATUS);
7994        assert_eq!(pui::decode(&raw).unwrap().0, Status::RESET_WATCHDOG.0);
7995    }
7996
7997    #[test]
7998    fn attach_resets_promiscuous_mode() {
7999        let mut session = test_session();
8000        set(&mut session, prop::MAC_PROMISCUOUS, &[1]);
8001        assert_eq!(get(&mut session, prop::MAC_PROMISCUOUS), [1]);
8002        session.attach(true);
8003        assert_eq!(get(&mut session, prop::MAC_PROMISCUOUS), [0]);
8004
8005        // Detach discards session state the same way.
8006        set(&mut session, prop::MAC_PROMISCUOUS, &[1]);
8007        session.detach();
8008        session.attach(true);
8009        assert_eq!(get(&mut session, prop::MAC_PROMISCUOUS), [0]);
8010
8011        // BOOL validation.
8012        let (emitted, _) = set(&mut session, prop::MAC_PROMISCUOUS, &[2]);
8013        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8014    }
8015
8016    #[test]
8017    fn attach_resets_backhaul_mode() {
8018        let mut session = test_session();
8019        let (_, effect) = set(&mut session, prop::MAC_BACKHAUL, &[1]);
8020        assert_eq!(effect, Some(Effect::ApplyBackhaul { enabled: true }));
8021        assert_eq!(get(&mut session, prop::MAC_BACKHAUL), [1]);
8022
8023        session.attach(true);
8024        assert_eq!(get(&mut session, prop::MAC_BACKHAUL), [0]);
8025
8026        let (emitted, _) = set(&mut session, prop::MAC_BACKHAUL, &[2]);
8027        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8028    }
8029
8030    /// In backhaul mode the host's frame is handed to the device's own
8031    /// node instead of transmitted. The node pays for whatever it then
8032    /// decides to put on the air; charging the host too would bill one
8033    /// frame's airtime twice.
8034    #[test]
8035    fn a_backhauled_send_spends_no_airtime() {
8036        let mut session = test_session();
8037        enable(&mut session);
8038        set(&mut session, prop::MAC_BACKHAUL, &[1]);
8039
8040        let (_, effect) = send_packet(&mut session, 1, &[0xAB; 32], &[], 0);
8041        assert_eq!(effect, Some(Effect::StartTransmit));
8042        session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {});
8043        assert_eq!(get(&mut session, prop::PHY_DUTY_NOW), 0u16.to_le_bytes());
8044
8045        // The same frame on the air is charged as usual.
8046        set(&mut session, prop::MAC_BACKHAUL, &[0]);
8047        let (_, effect) = send_packet(&mut session, 2, &[0xAB; 32], &[], 0);
8048        assert_eq!(effect, Some(Effect::StartTransmit));
8049        session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {});
8050        assert_ne!(get(&mut session, prop::PHY_DUTY_NOW), 0u16.to_le_bytes());
8051    }
8052
8053    /// A frame the device transmitted itself arrives with nothing
8054    /// measured, and says so rather than reporting the placeholder
8055    /// values as a reading.
8056    #[test]
8057    fn a_self_transmitted_frame_is_delivered_as_unmeasured() {
8058        let mut session = test_session();
8059        enable(&mut session);
8060        set(&mut session, prop::MAC_PROMISCUOUS, &[1]);
8061
8062        let mut emitted = Vec::new();
8063        session.on_radio_rx(
8064            &[1, 2, 3],
8065            &RadioRxInfo::self_transmitted(),
8066            0,
8067            &mut |bytes: &[u8]| emitted.push(bytes.to_vec()),
8068        );
8069        let parsed = Frame::parse(&emitted[0]).unwrap();
8070        let payload = StreamPayload::parse(parsed.payload).unwrap();
8071        assert_eq!(payload.data, &[1, 2, 3]);
8072        let meta = BufferedRxMeta::decode(payload.metadata).unwrap();
8073        assert_eq!(meta.flags, RX_FLAG_SELF_TX);
8074        assert_eq!(meta.age_s, 0);
8075        assert_eq!(meta.rx.rssi_dbm, None);
8076        assert_eq!(meta.rx.snr_cb, None);
8077    }
8078
8079    /// The flags byte is worth five bytes a frame only when it carries
8080    /// something; an ordinary reception stays on the short layout.
8081    #[test]
8082    fn an_ordinary_reception_carries_no_flags_byte() {
8083        let mut session = test_session();
8084        enable(&mut session);
8085
8086        let mut emitted = Vec::new();
8087        session.on_radio_rx(
8088            &[1, 2, 3],
8089            &RadioRxInfo::measured(-91, -53, None),
8090            0,
8091            &mut |bytes: &[u8]| emitted.push(bytes.to_vec()),
8092        );
8093        let parsed = Frame::parse(&emitted[0]).unwrap();
8094        let payload = StreamPayload::parse(parsed.payload).unwrap();
8095        assert_eq!(payload.metadata.len(), RxMeta::WIRE_LEN);
8096    }
8097
8098    #[test]
8099    fn attach_clears_pending_transmit_correlation() {
8100        let mut session = test_session();
8101        enable(&mut session);
8102        let (_, effect) = send_packet(&mut session, 3, &[0x01; 4], &[], 0);
8103        assert_eq!(effect, Some(Effect::StartTransmit));
8104        assert!(session.has_pending_tx());
8105
8106        // The requesting session is gone; its TID correlation must not
8107        // leak into the successor.
8108        session.attach(true);
8109        assert!(!session.has_pending_tx());
8110        let mut emitted = Vec::new();
8111        session.on_tx_result(TxOutcome::Sent, 0, &mut |bytes: &[u8]| {
8112            emitted.push(bytes.to_vec())
8113        });
8114        assert!(emitted.is_empty());
8115
8116        // The new session is free to transmit (no stale BUSY).
8117        let (_, effect) = send_packet(&mut session, 4, &[0x02; 4], &[], 0);
8118        assert_eq!(effect, Some(Effect::StartTransmit));
8119    }
8120
8121    #[test]
8122    fn reset_restores_post_reset_values_and_announces() {
8123        let mut session = test_session();
8124        set(&mut session, prop::PHY_FREQ, &906_875u32.to_le_bytes());
8125        set(&mut session, prop::MAC_PROMISCUOUS, &[1]);
8126        enable(&mut session);
8127
8128        let mut emitted = Vec::new();
8129        let effect = session.reset(Status::RESET_SOFTWARE, &mut |bytes: &[u8]| {
8130            emitted.push(bytes.to_vec())
8131        });
8132        expect_status(&emitted[0], TID_UNSOLICITED, Status::RESET_SOFTWARE);
8133        assert!(matches!(effect, Effect::ApplyRadio(settings) if !settings.enabled));
8134        assert_eq!(get(&mut session, prop::PHY_FREQ), 910_525u32.to_le_bytes());
8135        assert_eq!(get(&mut session, prop::MAC_PROMISCUOUS), [0]);
8136    }
8137
8138    #[test]
8139    fn device_name_rejects_empty_invalid_nul_and_oversize_values() {
8140        let mut session = test_session();
8141        let oversize = [b'x'; MAX_DEVICE_NAME_LEN + 1];
8142        for bad in [&[][..], &[0xff][..], b"bad\0name", &oversize[..]] {
8143            let (emitted, effect) = set(&mut session, prop::DEV_NAME, bad);
8144            assert!(effect.is_none());
8145            expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8146        }
8147        assert_eq!(session.device_name(), "Test UMSH Device");
8148    }
8149
8150    #[test]
8151    fn rf_property_round_trip() {
8152        let mut session = test_session();
8153        let (emitted, effect) = set(&mut session, prop::PHY_FREQ, &906_875u32.to_le_bytes());
8154        let (_, key, value) = parse_prop_is(&emitted[0]);
8155        assert_eq!(key, prop::PHY_FREQ);
8156        assert_eq!(value, 906_875u32.to_le_bytes());
8157        assert!(matches!(effect, Some(Effect::ApplyRadio(s)) if s.freq_khz == 906_875));
8158        assert_eq!(get(&mut session, prop::PHY_FREQ), 906_875u32.to_le_bytes());
8159    }
8160
8161    /// The test radio spans -9..=22 dBm. A request outside it succeeds
8162    /// at the nearest reachable power, and the echoed `CMD_PROP_IS` —
8163    /// the only place the range is visible — reports what was installed.
8164    #[test]
8165    fn tx_power_clamps_to_radio_range() {
8166        let mut session = test_session();
8167        for (requested, expected) in [(-20i8, -9i8), (40, 22), (-9, -9), (22, 22), (14, 14)] {
8168            let (emitted, effect) = set(&mut session, prop::PHY_TX_POWER, &[requested as u8]);
8169            let (_, key, value) = parse_prop_is(&emitted[0]);
8170            assert_eq!(key, prop::PHY_TX_POWER);
8171            assert_eq!(value, [expected as u8], "set {requested} dBm");
8172            assert!(
8173                matches!(effect, Some(Effect::ApplyRadio(s)) if s.tx_power_dbm == expected),
8174                "set {requested} dBm"
8175            );
8176            assert_eq!(get(&mut session, prop::PHY_TX_POWER), [expected as u8]);
8177        }
8178    }
8179
8180    /// The per-frame `TX_POWER` override obeys the same range as the
8181    /// property, so a host cannot route around it by staging a transmit.
8182    #[test]
8183    fn tx_power_override_clamps_to_radio_range() {
8184        for (requested, expected) in [(-20i8, -9i8), (40, 22)] {
8185            let mut session = test_session();
8186            enable(&mut session);
8187            let meta = [requested as u8, 0x00];
8188            let (_, effect) = send_packet(&mut session, 4, &[0u8; 8], &meta, 0);
8189            assert_eq!(effect, Some(Effect::StartTransmit));
8190            assert_eq!(session.tx_power(), TxPower::Dbm(expected), "tx {requested}");
8191        }
8192    }
8193
8194    #[test]
8195    fn invalid_values_rejected() {
8196        let mut session = test_session();
8197        for (key, bad) in [
8198            (prop::PHY_LORA_SF, &[4][..]),
8199            (prop::PHY_LORA_SF, &[13][..]),
8200            (prop::PHY_LORA_CR, &[9][..]),
8201            (prop::PHY_LORA_BW, &123_456u32.to_le_bytes()[..]),
8202            (prop::PHY_FREQ, &10_000u32.to_le_bytes()[..]),
8203            // Out-of-range TX power clamps rather than failing; only a
8204            // wrong-width value is an error.
8205            (prop::PHY_TX_POWER, &[14, 0][..]),
8206            (prop::PHY_ENABLED, &[2][..]),
8207            (prop::PHY_LORA_SW, &0xBEEFu16.to_le_bytes()[..]),
8208            // Wrong width.
8209            (prop::PHY_FREQ, &[1, 2][..]),
8210        ] {
8211            let (emitted, effect) = set(&mut session, key, bad);
8212            assert!(effect.is_none(), "key {key} accepted {bad:?}");
8213            expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8214        }
8215    }
8216
8217    #[test]
8218    fn uptime_reads_the_callers_clock() {
8219        let mut session = test_session();
8220
8221        // Seconds since boot, truncated, little-endian. The caller's
8222        // clock is the only source: the session keeps no counter.
8223        let read_at = |session: &mut TestSession, now_ms: u64| -> u32 {
8224            let mut buf = [0u8; 16];
8225            let len = frame::prop_get(&mut buf, 1, prop::UPTIME).unwrap();
8226            let (emitted, effect) = dispatch(session, &buf[..len], now_ms);
8227            assert!(effect.is_none());
8228            let (_, key, value) = parse_prop_is(&emitted[0]);
8229            assert_eq!(key, prop::UPTIME);
8230            u32::from_le_bytes(value.try_into().expect("UINT32"))
8231        };
8232
8233        assert_eq!(read_at(&mut session, 0), 0);
8234        assert_eq!(read_at(&mut session, 999), 0);
8235        assert_eq!(read_at(&mut session, 3_600_500), 3600);
8236
8237        // A protocol reset is not a reboot, so it must not disturb the
8238        // reading — that is what makes uptime worth reading next to
8239        // PROP_LAST_STATUS.
8240        let mut buf = [0u8; 4];
8241        let len = frame::reset(&mut buf, 0).unwrap();
8242        dispatch(&mut session, &buf[..len], 3_600_500);
8243        assert_eq!(read_at(&mut session, 3_601_500), 3601);
8244
8245        // Read-only, and ungated: refused as a write, not as a stranger.
8246        let (emitted, _) = set(&mut session, prop::UPTIME, &7u32.to_le_bytes());
8247        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8248    }
8249
8250    #[test]
8251    fn read_only_and_unknown_props() {
8252        let mut session = test_session();
8253        let (emitted, _) = set(&mut session, prop::PHY_MTU, &[0, 1]);
8254        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8255
8256        let (emitted, _) = set(&mut session, 9_999, &[0]);
8257        expect_status(&emitted[0], 2, Status::PROP_NOT_FOUND);
8258
8259        let mut buf = [0u8; 16];
8260        let len = frame::prop_get(&mut buf, 1, 9_999).unwrap();
8261        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
8262        expect_status(&emitted[0], 1, Status::PROP_NOT_FOUND);
8263
8264        // PHY_RSSI while the PHY is disabled: no ambient RSSI to read.
8265        let len = frame::prop_get(&mut buf, 1, prop::PHY_RSSI).unwrap();
8266        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
8267        assert!(effect.is_none());
8268        expect_status(&emitted[0], 1, Status::INVALID_STATE);
8269    }
8270
8271    #[test]
8272    fn phy_rssi_defers_to_radio_when_enabled() {
8273        let mut session = test_session();
8274        enable(&mut session);
8275
8276        // A GET while enabled defers instead of answering inline.
8277        let mut buf = [0u8; 16];
8278        let len = frame::prop_get(&mut buf, 3, prop::PHY_RSSI).unwrap();
8279        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
8280        assert!(emitted.is_empty(), "no response until the radio is sampled");
8281        assert_eq!(effect, Some(Effect::SampleRssi { tid: 3 }));
8282
8283        // The caller feeds the sample back; the session emits PROP_IS.
8284        let mut out = Vec::new();
8285        session.respond_rssi(3, Ok(-91), &mut |bytes: &[u8]| out.push(bytes.to_vec()));
8286        let (tid, key, value) = parse_prop_is(&out[0]);
8287        assert_eq!(tid, 3);
8288        assert_eq!(key, prop::PHY_RSSI);
8289        assert_eq!(value, [(-91i8) as u8]);
8290
8291        // A failed radio read surfaces as STATUS_FAILURE.
8292        let mut out = Vec::new();
8293        session.respond_rssi(4, Err(()), &mut |bytes: &[u8]| out.push(bytes.to_vec()));
8294        expect_status(&out[0], 4, Status::FAILURE);
8295    }
8296
8297    #[test]
8298    fn battery_get_samples_on_request() {
8299        let mut session = test_session();
8300
8301        // A GET defers to the platform battery source; nothing is cached.
8302        let mut buf = [0u8; 16];
8303        let len = frame::prop_get(&mut buf, 7, prop::BATTERY).unwrap();
8304        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
8305        assert!(
8306            emitted.is_empty(),
8307            "no response until the battery is sampled"
8308        );
8309        assert_eq!(effect, Some(Effect::SampleBattery { tid: 7 }));
8310
8311        // The caller feeds the snapshot back; the session emits PROP_IS
8312        // with the exact wire form: flags 0b101, voltage LE, state PUI.
8313        let mut out = Vec::new();
8314        session.respond_battery(
8315            7,
8316            Ok(BatteryStatus {
8317                voltage_mv: Some(3987),
8318                level_percent: None,
8319                charge_state: Some(battery::BatteryChargeState::Charging),
8320            }),
8321            &mut |bytes: &[u8]| out.push(bytes.to_vec()),
8322        );
8323        let (tid, key, value) = parse_prop_is(&out[0]);
8324        assert_eq!(tid, 7);
8325        assert_eq!(key, prop::BATTERY);
8326        assert_eq!(value, [0b101, 0x93, 0x0F, 1]);
8327
8328        // A failed measurement surfaces as STATUS_FAILURE, never as an
8329        // empty (unsupported-reporting) value.
8330        let mut out = Vec::new();
8331        session.respond_battery(6, Err(()), &mut |bytes: &[u8]| out.push(bytes.to_vec()));
8332        expect_status(&out[0], 6, Status::FAILURE);
8333    }
8334
8335    #[test]
8336    fn battery_snapshot_must_match_configured_fields() {
8337        let mut session = test_session();
8338        // The test profile reports voltage + charge state; a source that
8339        // suddenly includes a level would change the advertised flags, so
8340        // the session refuses it.
8341        let mut out = Vec::new();
8342        session.respond_battery(
8343            5,
8344            Ok(BatteryStatus {
8345                voltage_mv: Some(4200),
8346                level_percent: Some(80),
8347                charge_state: Some(battery::BatteryChargeState::Charged),
8348            }),
8349            &mut |bytes: &[u8]| out.push(bytes.to_vec()),
8350        );
8351        expect_status(&out[0], 5, Status::FAILURE);
8352
8353        // Omitting an advertised field is the opposite case and is
8354        // allowed: it is how a platform says the value is not knowable
8355        // right now, which beats quoting one it knows to be wrong.
8356        let mut out = Vec::new();
8357        session.respond_battery(
8358            6,
8359            Ok(BatteryStatus {
8360                voltage_mv: Some(4200),
8361                level_percent: None,
8362                charge_state: None,
8363            }),
8364            &mut |bytes: &[u8]| out.push(bytes.to_vec()),
8365        );
8366        let (tid, key, value) = parse_prop_is(&out[0]);
8367        assert_eq!((tid, key), (6, prop::BATTERY));
8368        let decoded = BatteryStatus::decode(&value).unwrap();
8369        assert_eq!(decoded.voltage_mv, Some(4200));
8370        assert_eq!(decoded.charge_state, None);
8371    }
8372
8373    #[test]
8374    fn battery_without_capability_is_unknown() {
8375        let mut config = test_config();
8376        config.battery = None;
8377        let mut session = Session::new(config, Status::RESET_POWER_ON, test_engine());
8378        session.attach(true);
8379
8380        let raw = get(&mut session, prop::CAPS);
8381        let mut offset = 0;
8382        while offset < raw.len() {
8383            let (value, used) = pui::decode(&raw[offset..]).unwrap();
8384            assert_ne!(value, cap::BATTERY);
8385            offset += used;
8386        }
8387
8388        let mut buf = [0u8; 16];
8389        let len = frame::prop_get(&mut buf, 1, prop::BATTERY).unwrap();
8390        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
8391        assert!(effect.is_none());
8392        expect_status(&emitted[0], 1, Status::PROP_NOT_FOUND);
8393
8394        let (emitted, _) = set(&mut session, prop::BATTERY, &[0b001, 0, 0]);
8395        expect_status(&emitted[0], 2, Status::PROP_NOT_FOUND);
8396    }
8397
8398    #[test]
8399    fn battery_with_no_fields_answers_empty_without_sampling() {
8400        let mut config = test_config();
8401        config.battery = Some(BatteryFields::NONE);
8402        let mut session = Session::new(config, Status::RESET_POWER_ON, test_engine());
8403        session.attach(true);
8404
8405        // The empty (unsupported-reporting) value needs no measurement.
8406        assert_eq!(get(&mut session, prop::BATTERY), Vec::<u8>::new());
8407    }
8408
8409    #[test]
8410    fn battery_rejects_mutation() {
8411        let mut session = test_session();
8412        let (emitted, effect) = set(&mut session, prop::BATTERY, &[0b001, 0, 0]);
8413        assert!(effect.is_none());
8414        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8415
8416        let mut buf = [0u8; 16];
8417        let len = frame::prop_insert(&mut buf, 3, prop::BATTERY, &[0]).unwrap();
8418        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
8419        expect_status(&emitted[0], 3, Status::INVALID_ARGUMENT);
8420
8421        let len = frame::prop_remove(&mut buf, 4, prop::BATTERY, &[0]).unwrap();
8422        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
8423        expect_status(&emitted[0], 4, Status::INVALID_ARGUMENT);
8424    }
8425
8426    #[test]
8427    fn illuminance_get_samples_on_request() {
8428        let mut session = test_session();
8429        let mut buf = [0u8; 16];
8430        let len = frame::prop_get(&mut buf, 5, prop::ILLUMINANCE).unwrap();
8431        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
8432        assert!(emitted.is_empty(), "no response until the sensor is read");
8433        assert_eq!(effect, Some(Effect::SampleIlluminance { tid: 5 }));
8434
8435        let mut out = Vec::new();
8436        session.respond_illuminance(5, Some(12_345), &mut |bytes: &[u8]| {
8437            out.push(bytes.to_vec())
8438        });
8439        let (tid, key, value) = parse_prop_is(&out[0]);
8440        assert_eq!((tid, key), (5, prop::ILLUMINANCE));
8441        assert_eq!(value, 12_345u32.to_le_bytes());
8442    }
8443
8444    /// A sensor that could not be read reports no reading, not a failure —
8445    /// the same shape `PROP_TIME` uses for a clock that was never set.
8446    #[test]
8447    fn illuminance_reports_a_failed_read_as_empty() {
8448        let mut session = test_session();
8449        let mut out = Vec::new();
8450        session.respond_illuminance(4, None, &mut |bytes: &[u8]| out.push(bytes.to_vec()));
8451        let (tid, key, value) = parse_prop_is(&out[0]);
8452        assert_eq!((tid, key, value), (4, prop::ILLUMINANCE, Vec::new()));
8453    }
8454
8455    #[test]
8456    fn illuminance_without_the_sensor_is_unknown() {
8457        let mut config = test_config();
8458        config.illuminance = false;
8459        let mut session = Session::new(config, Status::RESET_POWER_ON, test_engine());
8460        session.attach(true);
8461
8462        let raw = get(&mut session, prop::CAPS);
8463        let mut offset = 0;
8464        while offset < raw.len() {
8465            let (value, used) = pui::decode(&raw[offset..]).unwrap();
8466            assert_ne!(value, cap::ILLUMINANCE);
8467            offset += used;
8468        }
8469
8470        let mut buf = [0u8; 16];
8471        let len = frame::prop_get(&mut buf, 1, prop::ILLUMINANCE).unwrap();
8472        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
8473        assert!(effect.is_none());
8474        expect_status(&emitted[0], 1, Status::PROP_NOT_FOUND);
8475    }
8476
8477    #[test]
8478    fn illuminance_rejects_mutation() {
8479        let mut session = test_session();
8480        let (emitted, effect) = set(&mut session, prop::ILLUMINANCE, &0u32.to_le_bytes());
8481        assert!(effect.is_none());
8482        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8483    }
8484
8485    /// `CMD_PROP_SET` of `PROP_ALERT` at a chosen clock reading.
8486    fn set_alert_at(
8487        session: &mut TestSession,
8488        state: AlertState,
8489        now_ms: u64,
8490    ) -> (Vec<Vec<u8>>, Option<Effect>) {
8491        let mut value = [0u8; pui::MAX_LEN];
8492        let value_len = pui::encode(state.code(), &mut value).unwrap();
8493        let mut buf = [0u8; 16];
8494        let len = frame::prop_set(&mut buf, 2, prop::ALERT, &value[..value_len]).unwrap();
8495        dispatch(session, &buf[..len], now_ms)
8496    }
8497
8498    #[test]
8499    fn alert_starts_and_reports_the_new_state() {
8500        let mut session = test_session();
8501        assert_eq!(get(&mut session, prop::ALERT), vec![0]);
8502
8503        let (emitted, effect) = set_alert_at(&mut session, AlertState::Locate, 1_000);
8504        assert_eq!(effect, Some(Effect::ApplyAlert(AlertState::Locate)));
8505        let (tid, key, value) = parse_prop_is(&emitted[0]);
8506        assert_eq!((tid, key, value), (2, prop::ALERT, vec![1]));
8507        assert_eq!(session.alert(), AlertState::Locate);
8508        assert_eq!(get(&mut session, prop::ALERT), vec![1]);
8509    }
8510
8511    #[test]
8512    fn alert_rejects_unknown_states() {
8513        let mut session = test_session();
8514        let (emitted, effect) = set(&mut session, prop::ALERT, &[2]);
8515        assert!(effect.is_none());
8516        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8517        // An empty value names no state at all.
8518        let (emitted, effect) = set(&mut session, prop::ALERT, &[]);
8519        assert!(effect.is_none());
8520        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8521        assert_eq!(session.alert(), AlertState::None);
8522    }
8523
8524    #[test]
8525    fn alert_expires_at_its_deadline() {
8526        let mut session = test_session();
8527        let started = 10_000;
8528        set_alert_at(&mut session, AlertState::Locate, started);
8529        let deadline = started + u64::from(AlertConfig::DEFAULT.timeout_ms);
8530        assert_eq!(session.alert_deadline_ms(), Some(deadline));
8531
8532        // One millisecond early is still an alert.
8533        let mut emitted = Vec::new();
8534        let effect = session.poll_alert(deadline - 1, &mut |bytes: &[u8]| {
8535            emitted.push(bytes.to_vec())
8536        });
8537        assert!(effect.is_none());
8538        assert!(emitted.is_empty());
8539        assert_eq!(session.alert(), AlertState::Locate);
8540
8541        let effect = session.poll_alert(deadline, &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
8542        assert_eq!(effect, Some(Effect::ApplyAlert(AlertState::None)));
8543        assert_eq!(session.alert(), AlertState::None);
8544        assert_eq!(session.alert_deadline_ms(), None);
8545        // The transition the host did not command is announced.
8546        let (tid, key, value) = parse_prop_is(&emitted[0]);
8547        assert_eq!((tid, key, value), (TID_UNSOLICITED, prop::ALERT, vec![0]));
8548    }
8549
8550    #[test]
8551    fn re_arming_an_alert_restarts_the_deadline() {
8552        let mut session = test_session();
8553        set_alert_at(&mut session, AlertState::Locate, 1_000);
8554        let first = session.alert_deadline_ms().unwrap();
8555
8556        // The host holds the alert open for a longer search.
8557        let (_, effect) = set_alert_at(&mut session, AlertState::Locate, 60_000);
8558        assert_eq!(effect, Some(Effect::ApplyAlert(AlertState::Locate)));
8559        assert_eq!(
8560            session.alert_deadline_ms(),
8561            Some(60_000 + u64::from(AlertConfig::DEFAULT.timeout_ms))
8562        );
8563        assert!(session.alert_deadline_ms().unwrap() > first);
8564        // The originally scheduled expiry no longer ends it.
8565        let mut emitted = Vec::new();
8566        assert!(
8567            session
8568                .poll_alert(first, &mut |bytes: &[u8]| emitted.push(bytes.to_vec()))
8569                .is_none()
8570        );
8571        assert_eq!(session.alert(), AlertState::Locate);
8572    }
8573
8574    #[test]
8575    fn local_cancel_clears_and_announces_once() {
8576        let mut session = test_session();
8577        set_alert_at(&mut session, AlertState::Locate, 0);
8578
8579        let mut emitted = Vec::new();
8580        let effect = session.cancel_alert(&mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
8581        assert_eq!(effect, Some(Effect::ApplyAlert(AlertState::None)));
8582        let (tid, key, value) = parse_prop_is(&emitted[0]);
8583        assert_eq!((tid, key, value), (TID_UNSOLICITED, prop::ALERT, vec![0]));
8584
8585        // A second press has nothing to cancel and says nothing.
8586        emitted.clear();
8587        let effect = session.cancel_alert(&mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
8588        assert!(effect.is_none());
8589        assert!(emitted.is_empty());
8590    }
8591
8592    #[test]
8593    fn alert_survives_detach_and_reset() {
8594        let mut session = test_session();
8595        set_alert_at(&mut session, AlertState::Locate, 0);
8596
8597        // Detach must not silence it: the link drops as soon as the
8598        // searcher walks out of range, which is when it matters most.
8599        session.detach();
8600        assert_eq!(session.alert(), AlertState::Locate);
8601        assert!(session.alert_deadline_ms().is_some());
8602
8603        // Nor may CMD_RST, which resets session state and not the
8604        // device's physical behavior.
8605        session.attach(true);
8606        let mut buf = [0u8; 16];
8607        let len = frame::reset(&mut buf, 7).unwrap();
8608        dispatch(&mut session, &buf[..len], 0);
8609        assert_eq!(session.alert(), AlertState::Locate);
8610        assert_eq!(get(&mut session, prop::ALERT), vec![1]);
8611    }
8612
8613    #[test]
8614    fn cancelling_while_detached_emits_nothing() {
8615        let mut session = test_session();
8616        set_alert_at(&mut session, AlertState::Locate, 0);
8617        session.detach();
8618
8619        let mut emitted = Vec::new();
8620        let effect = session.cancel_alert(&mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
8621        // The indication still stops; there is simply nobody to tell.
8622        assert_eq!(effect, Some(Effect::ApplyAlert(AlertState::None)));
8623        assert!(emitted.is_empty());
8624        assert_eq!(session.alert(), AlertState::None);
8625    }
8626
8627    #[test]
8628    fn alert_is_absent_without_the_capability() {
8629        let mut config = test_config();
8630        config.alert = None;
8631        let mut session: TestSession = Session::new(config, Status::RESET_POWER_ON, test_engine());
8632        session.attach(true);
8633
8634        let mut buf = [0u8; 16];
8635        let len = frame::prop_get(&mut buf, 1, prop::ALERT).unwrap();
8636        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
8637        expect_status(&emitted[0], 1, Status::PROP_NOT_FOUND);
8638
8639        let (emitted, effect) = set(&mut session, prop::ALERT, &[1]);
8640        assert!(effect.is_none());
8641        expect_status(&emitted[0], 2, Status::PROP_NOT_FOUND);
8642
8643        let raw = get(&mut session, prop::CAPS);
8644        let mut offset = 0;
8645        while offset < raw.len() {
8646            let (value, used) = pui::decode(&raw[offset..]).unwrap();
8647            assert_ne!(value, cap::ALERT);
8648            offset += used;
8649        }
8650    }
8651
8652    #[test]
8653    fn alert_is_not_saved() {
8654        let mut session = test_session();
8655        set_alert_at(&mut session, AlertState::Locate, 0);
8656        let mut buf = [0u8; SNAPSHOT_MAX];
8657        let len = session.encode_snapshot(&mut buf).unwrap();
8658
8659        // Restoring a snapshot taken mid-alert onto a quiet device must
8660        // not start one: the alert is live state, not configuration.
8661        let mut fresh: TestSession =
8662            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
8663        fresh.attach(true);
8664        fresh.restore_at_boot(&buf[..len]).unwrap();
8665        assert_eq!(fresh.alert(), AlertState::None);
8666        assert_eq!(fresh.alert_deadline_ms(), None);
8667    }
8668
8669    #[test]
8670    fn battery_reads_still_sample_after_reset() {
8671        let mut session = test_session();
8672        let mut buf = [0u8; 16];
8673        let len = frame::reset(&mut buf, 0).unwrap();
8674        dispatch(&mut session, &buf[..len], 0);
8675
8676        // No battery state exists to reset or restore: a GET after reset
8677        // defers to a fresh sample exactly as before.
8678        let len = frame::prop_get(&mut buf, 5, prop::BATTERY).unwrap();
8679        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
8680        assert!(emitted.is_empty());
8681        assert_eq!(effect, Some(Effect::SampleBattery { tid: 5 }));
8682    }
8683
8684    /// Drive `publish_battery` and collect emitted frames.
8685    fn publish(session: &mut TestSession, sample: BatteryStatus) -> (bool, Vec<Vec<u8>>) {
8686        let mut out = Vec::new();
8687        let published =
8688            session.publish_battery(sample, &mut |bytes: &[u8]| out.push(bytes.to_vec()));
8689        (published, out)
8690    }
8691
8692    fn matching_sample() -> BatteryStatus {
8693        BatteryStatus {
8694            voltage_mv: Some(3987),
8695            level_percent: None,
8696            charge_state: Some(battery::BatteryChargeState::Charging),
8697        }
8698    }
8699
8700    #[test]
8701    fn battery_publishes_unsolicited_snapshot() {
8702        let mut session = test_session();
8703        let (published, out) = publish(&mut session, matching_sample());
8704        assert!(published);
8705        let (tid, key, value) = parse_prop_is(&out[0]);
8706        // TID 0 marks it unsolicited: no transaction to correlate with.
8707        assert_eq!(tid, TID_UNSOLICITED);
8708        assert_eq!(key, prop::BATTERY);
8709        // The same wire form a GET response carries.
8710        assert_eq!(value, [0b101, 0x93, 0x0F, 1]);
8711    }
8712
8713    #[test]
8714    fn battery_publish_needs_an_attached_host() {
8715        let mut session = test_session();
8716        session.detach();
8717        let (published, out) = publish(&mut session, matching_sample());
8718        assert!(!published, "nobody to notify while detached");
8719        assert!(out.is_empty());
8720
8721        // Re-attaching restores publication without any rearming.
8722        session.attach(true);
8723        assert!(publish(&mut session, matching_sample()).0);
8724    }
8725
8726    #[test]
8727    fn battery_publish_enforces_configured_fields() {
8728        let mut session = test_session();
8729        // A level the profile never advertised cannot be encoded within
8730        // the flags this platform claims. An unsolicited notification has
8731        // no transaction to fail, so it is dropped outright.
8732        let (published, out) = publish(
8733            &mut session,
8734            BatteryStatus {
8735                voltage_mv: Some(4200),
8736                level_percent: Some(80),
8737                charge_state: Some(battery::BatteryChargeState::Charged),
8738            },
8739        );
8740        assert!(!published);
8741        assert!(out.is_empty());
8742
8743        // Omitting an advertised field goes out unchanged: a charging
8744        // pack whose level is not derivable still has a voltage worth
8745        // publishing, and silence would strand the host on the last
8746        // reading it saw.
8747        let (published, out) = publish(
8748            &mut session,
8749            BatteryStatus {
8750                voltage_mv: Some(4200),
8751                level_percent: None,
8752                charge_state: None,
8753            },
8754        );
8755        assert!(published);
8756        let (_, key, value) = parse_prop_is(&out[0]);
8757        assert_eq!(key, prop::BATTERY);
8758        assert_eq!(
8759            BatteryStatus::decode(&value).unwrap().voltage_mv,
8760            Some(4200)
8761        );
8762    }
8763
8764    #[test]
8765    fn battery_publish_is_silent_without_the_capability() {
8766        let mut config = test_config();
8767        config.battery = None;
8768        let mut session: TestSession = Session::new(config, Status::RESET_POWER_ON, test_engine());
8769        session.attach(true);
8770
8771        let (published, out) = publish(&mut session, matching_sample());
8772        assert!(!published);
8773        assert!(out.is_empty());
8774    }
8775
8776    #[test]
8777    fn battery_publish_does_not_disturb_last_status_or_reads() {
8778        let mut session = test_session();
8779        // A publication is not an operation: PROP_LAST_STATUS must still
8780        // hold the boot reason a freshly attached host needs to see.
8781        publish(&mut session, matching_sample());
8782        let status = get(&mut session, prop::LAST_STATUS);
8783        assert_eq!(pui::decode(&status).unwrap().0, Status::RESET_POWER_ON.0);
8784
8785        // And it caches nothing: the next GET still defers to a sample.
8786        let mut buf = [0u8; 16];
8787        let len = frame::prop_get(&mut buf, 3, prop::BATTERY).unwrap();
8788        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
8789        assert!(emitted.is_empty());
8790        assert_eq!(effect, Some(Effect::SampleBattery { tid: 3 }));
8791    }
8792
8793    #[test]
8794    fn pairing_pin_set_clear_validate_and_defer() {
8795        let mut session = test_session();
8796
8797        let (emitted, effect) = set(
8798            &mut session,
8799            prop::BLE_PAIRING_PIN,
8800            &123_456u32.to_le_bytes(),
8801        );
8802        assert!(
8803            emitted.is_empty(),
8804            "PIN must not be acknowledged before apply"
8805        );
8806        assert_eq!(
8807            effect,
8808            Some(Effect::SetPairingPin {
8809                tid: 2,
8810                pin: Some(123_456)
8811            })
8812        );
8813
8814        let (emitted, effect) = set(&mut session, prop::BLE_PAIRING_PIN, &[]);
8815        assert!(emitted.is_empty());
8816        assert_eq!(effect, Some(Effect::SetPairingPin { tid: 2, pin: None }));
8817
8818        for bad in [&1_000_000u32.to_le_bytes()[..], &[1, 2, 3][..]] {
8819            let (emitted, effect) = set(&mut session, prop::BLE_PAIRING_PIN, bad);
8820            assert!(effect.is_none());
8821            expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
8822        }
8823    }
8824
8825    #[test]
8826    fn pairing_pin_completion_and_get_refusal() {
8827        let mut session = test_session();
8828        let mut emitted = Vec::new();
8829        session.respond_pin_set(7, Ok(()), &mut |frame| emitted.push(frame.to_vec()));
8830        expect_status(&emitted[0], 7, Status::OK);
8831        emitted.clear();
8832        session.respond_pin_set(6, Err(()), &mut |frame| emitted.push(frame.to_vec()));
8833        expect_status(&emitted[0], 6, Status::INTERNAL_ERROR);
8834
8835        let mut request = [0; 16];
8836        let len = frame::prop_get(&mut request, 5, prop::BLE_PAIRING_PIN).unwrap();
8837        let (emitted, effect) = dispatch(&mut session, &request[..len], 0);
8838        assert!(effect.is_none());
8839        expect_status(&emitted[0], 5, Status::UNIMPLEMENTED);
8840    }
8841
8842    #[test]
8843    fn reset_has_no_pairing_pin_effect() {
8844        let mut session = test_session();
8845        let mut request = [0; 4];
8846        let len = frame::reset(&mut request, 1).unwrap();
8847        let (_, effect) = dispatch(&mut session, &request[..len], 0);
8848        assert!(matches!(effect, Some(Effect::ApplyRadio(_))));
8849    }
8850
8851    #[test]
8852    fn transmit_lifecycle() {
8853        let mut session = test_session();
8854        enable(&mut session);
8855
8856        let packet = [0xAAu8; 32];
8857        let (emitted, effect) = send_packet(&mut session, 4, &packet, &[], 0);
8858        assert!(emitted.is_empty(), "no response until TX completes");
8859        assert_eq!(effect, Some(Effect::StartTransmit));
8860        assert_eq!(session.tx_data(), &packet);
8861        assert_eq!(session.tx_power(), TxPower::Default);
8862
8863        // A second confirmed send while busy fails with BUSY.
8864        let (emitted, effect) = send_packet(&mut session, 5, &packet, &[], 0);
8865        assert!(effect.is_none());
8866        expect_status(&emitted[0], 5, Status::BUSY);
8867
8868        // Completion emits OK with the original TID and records duty.
8869        let mut emitted = Vec::new();
8870        session.on_tx_result(TxOutcome::Sent, 0, &mut |bytes: &[u8]| {
8871            emitted.push(bytes.to_vec())
8872        });
8873        expect_status(&emitted[0], 4, Status::OK);
8874        assert!(!session.has_pending_tx());
8875        let duty = get(&mut session, prop::PHY_DUTY_NOW);
8876        assert!(u16::from_le_bytes([duty[0], duty[1]]) > 0);
8877    }
8878
8879    #[test]
8880    fn target_selected_transmit_queue_pipelines_frames() {
8881        type PipelinedSession = Session<SoftwareAes, SoftwareSha256, 3>;
8882        let mut session: PipelinedSession =
8883            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
8884        session.attach(true);
8885        let mut request = [0u8; 16];
8886        let len = frame::prop_set(&mut request, 1, prop::PHY_ENABLED, &[1]).unwrap();
8887        let (_, effect) = dispatch(&mut session, &request[..len], 0);
8888        assert!(matches!(effect, Some(Effect::ApplyRadio(settings)) if settings.enabled));
8889
8890        for tid in 2..=4 {
8891            let (emitted, effect) = send_packet(&mut session, tid, &[tid; 8], &[], 0);
8892            assert!(emitted.is_empty());
8893            assert_eq!(effect, (tid == 2).then_some(Effect::StartTransmit));
8894        }
8895        let (emitted, effect) = send_packet(&mut session, 5, &[5; 8], &[], 0);
8896        assert!(effect.is_none());
8897        expect_status(&emitted[0], 5, Status::BUSY);
8898
8899        for tid in 2..=4 {
8900            assert_eq!(session.tx_data(), &[tid; 8]);
8901            let mut emitted = Vec::new();
8902            let effect = session.on_tx_result(TxOutcome::Sent, 0, &mut |bytes| {
8903                emitted.push(bytes.to_vec())
8904            });
8905            expect_status(&emitted[0], tid, Status::OK);
8906            assert_eq!(effect, (tid != 4).then_some(Effect::StartTransmit));
8907        }
8908        assert!(!session.has_pending_tx());
8909    }
8910
8911    #[test]
8912    fn transmit_requires_enabled_phy() {
8913        let mut session = test_session();
8914        let (emitted, effect) = send_packet(&mut session, 4, &[0u8; 8], &[], 0);
8915        assert!(effect.is_none());
8916        expect_status(&emitted[0], 4, Status::INVALID_STATE);
8917    }
8918
8919    #[test]
8920    fn transmit_power_override() {
8921        let mut session = test_session();
8922        enable(&mut session);
8923        let meta = [22u8, 0x00];
8924        let (_, effect) = send_packet(&mut session, 4, &[0u8; 8], &meta, 0);
8925        assert_eq!(effect, Some(Effect::StartTransmit));
8926        assert_eq!(session.tx_power(), TxPower::Dbm(22));
8927    }
8928
8929    #[test]
8930    fn duty_limit_blocks_and_noduty_bypasses() {
8931        let mut session = test_session();
8932        enable(&mut session);
8933        // Slowest settings: one full frame is minutes of airtime.
8934        set(&mut session, prop::PHY_LORA_SF, &[12]);
8935        set(&mut session, prop::PHY_LORA_BW, &7_810u32.to_le_bytes());
8936        // 0.1% limit.
8937        set(&mut session, prop::PHY_DUTY_LIMIT, &65u16.to_le_bytes());
8938
8939        let packet = [0u8; 255];
8940        let (emitted, effect) = send_packet(&mut session, 3, &packet, &[], 0);
8941        assert!(effect.is_none());
8942        expect_status(&emitted[0], 3, Status::DUTY_LIMIT);
8943
8944        // NODUTY flag bypasses the limit.
8945        let meta = [meta::TX_POWER_DEFAULT as u8, meta::TX_FLAG_NODUTY];
8946        let (_, effect) = send_packet(&mut session, 3, &packet, &meta, 0);
8947        assert_eq!(effect, Some(Effect::StartTransmit));
8948    }
8949
8950    #[test]
8951    fn nocca_flag_controls_channel_sensing() {
8952        let mut session = test_session();
8953        enable(&mut session);
8954
8955        // Default (no flags): the transmit must be channel-sensed.
8956        let (_, effect) = send_packet(&mut session, 3, &[0u8; 8], &[], 0);
8957        assert_eq!(effect, Some(Effect::StartTransmit));
8958        assert!(!session.tx_nocca());
8959        session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {});
8960
8961        // TX_FLAG_NOCCA requests transmit without channel sensing.
8962        let meta = [meta::TX_POWER_DEFAULT as u8, meta::TX_FLAG_NOCCA];
8963        let (_, effect) = send_packet(&mut session, 4, &[0u8; 8], &meta, 0);
8964        assert_eq!(effect, Some(Effect::StartTransmit));
8965        assert!(session.tx_nocca());
8966    }
8967
8968    #[test]
8969    fn channel_busy_completes_host_send_with_cca_failure() {
8970        let mut session = test_session();
8971        enable(&mut session);
8972
8973        let (_, effect) = send_packet(&mut session, 7, &[0u8; 8], &[], 0);
8974        assert_eq!(effect, Some(Effect::StartTransmit));
8975
8976        // A busy channel refuses the transmit; the host learns it distinctly
8977        // from an ordinary failure so it can back off and retry.
8978        let mut emitted = Vec::new();
8979        session.on_tx_result(TxOutcome::ChannelBusy, 0, &mut |bytes: &[u8]| {
8980            emitted.push(bytes.to_vec())
8981        });
8982        expect_status(&emitted[0], 7, Status::CCA_FAILURE);
8983
8984        // The refused frame never left the radio: no duty was charged.
8985        assert_eq!(get(&mut session, prop::PHY_DUTY_NOW), 0u16.to_le_bytes());
8986    }
8987
8988    #[test]
8989    fn delegated_ack_transmits_without_channel_sensing() {
8990        // A delegated MAC ack owns its channel-access window the moment the
8991        // acknowledged frame ends (the ACK protection interval), so it must
8992        // transmit without a channel-activity check.
8993        let mut session = auto_ack_session();
8994        let keys = test_pairwise();
8995        let effect = rx_effect(&mut session, &sealed_unar(5, &keys, false), 0);
8996        assert_eq!(effect, Some(Effect::StartTransmit));
8997        assert!(
8998            session.tx_nocca(),
8999            "delegated ack must skip the channel-activity check"
9000        );
9001    }
9002
9003    /// The ledger is shared with every other radio client on the
9004    /// device (the device node). Airtime recorded by another client
9005    /// counts against the session's limit — host transmits refuse with
9006    /// STATUS_DUTY_LIMIT — and PROP_PHY_DUTY_NOW reports the combined
9007    /// figure, all without the session transmitting anything itself.
9008    #[test]
9009    fn foreign_client_airtime_counts_against_the_session() {
9010        let config = test_config();
9011        let ledger = config.duty;
9012        let mut session = Session::new(config, Status::RESET_POWER_ON, test_engine());
9013        session.attach(true);
9014        enable(&mut session);
9015        set(&mut session, prop::PHY_DUTY_LIMIT, &655u16.to_le_bytes());
9016
9017        assert_eq!(get(&mut session, prop::PHY_DUTY_NOW), 0u16.to_le_bytes());
9018        // The device node completes 36 s of transmission (≈1%).
9019        for _ in 0..36 {
9020            ledger.record(0, 1_000);
9021        }
9022        let duty_now = get(&mut session, prop::PHY_DUTY_NOW);
9023        assert!(u16::from_le_bytes([duty_now[0], duty_now[1]]) >= 655);
9024
9025        let (emitted, effect) = send_packet(&mut session, 3, &[0u8; 32], &[], 0);
9026        assert!(effect.is_none());
9027        expect_status(&emitted[0], 3, Status::DUTY_LIMIT);
9028
9029        // And the session's settings feed the ledger's modulation view,
9030        // so the node prices its frames at what is actually on the air.
9031        set(&mut session, prop::PHY_LORA_SF, &[12]);
9032        set(&mut session, prop::PHY_LORA_BW, &7_810u32.to_le_bytes());
9033        assert_eq!(
9034            ledger.airtime_ms(32),
9035            umsh_ulcp::airtime::lora_airtime_ms(12, 7_810, 5, 32)
9036        );
9037    }
9038
9039    #[test]
9040    fn fire_and_forget_failures_are_silent() {
9041        let mut session = test_session();
9042        // PHY disabled: a TID-0 send fails without emitting anything.
9043        let (emitted, effect) = send_packet(&mut session, 0, &[0u8; 4], &[], 0);
9044        assert!(effect.is_none());
9045        assert!(emitted.is_empty());
9046        // ... but LAST_STATUS records it.
9047        assert_eq!(
9048            pui::decode(&get(&mut session, prop::LAST_STATUS))
9049                .unwrap()
9050                .0,
9051            Status::INVALID_STATE.0
9052        );
9053    }
9054
9055    #[test]
9056    fn radio_rx_emits_str_recv() {
9057        let mut session = test_session();
9058        enable(&mut session);
9059        let mut emitted = Vec::new();
9060        session.on_radio_rx(
9061            &[1, 2, 3],
9062            &RadioRxInfo::measured(-91, -53, None),
9063            0,
9064            &mut |bytes: &[u8]| emitted.push(bytes.to_vec()),
9065        );
9066        let parsed = Frame::parse(&emitted[0]).unwrap();
9067        assert_eq!(parsed.command(), Some(Cmd::StrRecv));
9068        assert_eq!(parsed.header.tid(), TID_UNSOLICITED);
9069        let payload = StreamPayload::parse(parsed.payload).unwrap();
9070        assert_eq!(payload.data, &[1, 2, 3]);
9071        let rx_meta = RxMeta::decode(payload.metadata).unwrap();
9072        assert_eq!(rx_meta.rssi_dbm, Some(-91));
9073        assert_eq!(rx_meta.snr_cb, Some(-53));
9074    }
9075
9076    #[test]
9077    fn radio_rx_suppressed_while_disabled() {
9078        let mut session = test_session();
9079        let mut emitted = Vec::new();
9080        session.on_radio_rx(
9081            &[1, 2, 3],
9082            &RadioRxInfo::measured(-91, -53, None),
9083            0,
9084            &mut |bytes: &[u8]| emitted.push(bytes.to_vec()),
9085        );
9086        assert!(emitted.is_empty());
9087        // Nothing is queued either: the PHY is disabled.
9088        session.detach();
9089        session.on_radio_rx(
9090            &[1, 2, 3],
9091            &RadioRxInfo::measured(-91, -53, None),
9092            0,
9093            &mut |_: &[u8]| {},
9094        );
9095        session.attach(true);
9096        assert_eq!(
9097            get(&mut session, prop::HOST_RX_QUEUE_COUNT),
9098            0u16.to_le_bytes()
9099        );
9100    }
9101
9102    #[test]
9103    fn unknown_command_rejected() {
9104        let mut session = test_session();
9105        let (emitted, _) = dispatch(&mut session, &[0x81, 42], 0);
9106        expect_status(&emitted[0], 1, Status::INVALID_COMMAND);
9107    }
9108
9109    #[test]
9110    fn insert_remove_reject_per_property_knowledge() {
9111        let mut session = test_session();
9112        let mut buf = [0u8; 80];
9113
9114        // A known single-value property is not insertable/removable.
9115        for known in [
9116            prop::PHY_FREQ,
9117            prop::BLE_PAIRING_PIN,
9118            prop::CAPS,
9119            prop::MAC_REPEATER_ENABLED,
9120            prop::IDENT_ROLE,
9121            prop::IDENT_MOBILE,
9122            prop::IDENT_LOCATION,
9123            prop::IDENT_ALTITUDE,
9124            prop::DEV_DISCOVERABLE,
9125            // The rest of the repeater policy is whole-value; only the
9126            // region table is edited entry by entry.
9127            prop::MAC_REPEATER_DEFAULT_REGION,
9128            prop::MAC_REPEATER_MIN_RSSI,
9129            prop::MAC_REPEATER_MIN_SNR,
9130            // Advertisement policy is likewise whole-value.
9131            prop::ADVERT_INTERVAL,
9132            prop::BEACON_INTERVAL,
9133            prop::STARTUP_BEACON,
9134        ] {
9135            let len = frame::prop_insert(&mut buf, 1, known, &[0; 4]).unwrap();
9136            let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
9137            assert!(effect.is_none());
9138            expect_status(&emitted[0], 1, Status::INVALID_ARGUMENT);
9139        }
9140        // An unknown property is not found; 85 is still-spare space in
9141        // the advertisement sub-range.
9142        for unknown in [85, 1_234] {
9143            let len = frame::prop_remove(&mut buf, 2, unknown, &[0; 4]).unwrap();
9144            let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
9145            assert!(effect.is_none());
9146            expect_status(&emitted[0], 2, Status::PROP_NOT_FOUND);
9147        }
9148        // A payload without a decodable property key is malformed.
9149        let (emitted, _) = dispatch(&mut session, &[0x81, Cmd::PropInsert as u8], 0);
9150        expect_status(&emitted[0], 1, Status::PARSE_ERROR);
9151    }
9152
9153    #[test]
9154    fn clear_defers_and_leaves_live_state_alone() {
9155        let mut session = test_session();
9156        let mut buf = [0u8; 8];
9157        // CMD_CLEAR is base-protocol: it defers to the durable erase
9158        // even with nothing saved (the erase is idempotent) and must
9159        // not disturb live state (the device name survives).
9160        set(&mut session, prop::DEV_NAME, b"kept name");
9161        let len = frame::clear(&mut buf, 4).unwrap();
9162        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
9163        assert!(emitted.is_empty(), "no response before the erase commits");
9164        assert_eq!(effect, Some(Effect::ClearSaved { tid: 4 }));
9165        let mut emitted = Vec::new();
9166        session.respond_clear(4, Ok(()), &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
9167        expect_status(&emitted[0], 4, Status::OK);
9168        assert_eq!(session.device_name(), "kept name");
9169
9170        // A failed erase reports FAILURE.
9171        let (_, effect) = dispatch(&mut session, &buf[..len], 0);
9172        assert_eq!(effect, Some(Effect::ClearSaved { tid: 4 }));
9173        let mut emitted = Vec::new();
9174        session.respond_clear(4, Err(()), &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
9175        expect_status(&emitted[0], 4, Status::FAILURE);
9176    }
9177
9178    #[test]
9179    fn device_only_notifications_rejected_from_host() {
9180        let mut session = test_session();
9181        for cmd in [
9182            Cmd::PropInserted,
9183            Cmd::PropRemoved,
9184            Cmd::PropIs,
9185            Cmd::StrRecv,
9186        ] {
9187            let (emitted, effect) = dispatch(&mut session, &[0x81, cmd as u8], 0);
9188            assert!(effect.is_none());
9189            expect_status(&emitted[0], 1, Status::INVALID_COMMAND);
9190        }
9191    }
9192
9193    #[test]
9194    fn malformed_frames_ignored() {
9195        let mut session = test_session();
9196        for bad in [&[][..], &[0x81][..], &[0x00, 0x00][..], &[0xB8, 0x00][..]] {
9197            let (emitted, effect) = dispatch(&mut session, bad, 0);
9198            assert!(emitted.is_empty());
9199            assert!(effect.is_none());
9200        }
9201    }
9202
9203    // ─── CAP_HOST_FILTER gate ────────────────────────────────────────
9204
9205    use umsh_core::{ChannelId, NodeHint, PacketBuilder};
9206
9207    fn unicast_to(dst: [u8; 3]) -> Vec<u8> {
9208        let mut buf = [0u8; 64];
9209        PacketBuilder::new(&mut buf)
9210            .unicast(NodeHint(dst))
9211            .source_hint(NodeHint([9, 9, 9]))
9212            .frame_counter(1)
9213            .payload(&[1, 2, 3])
9214            .build()
9215            .unwrap()
9216            .as_bytes()
9217            .to_vec()
9218    }
9219
9220    fn multicast_on(channel: [u8; 2]) -> Vec<u8> {
9221        let mut buf = [0u8; 64];
9222        PacketBuilder::new(&mut buf)
9223            .multicast(ChannelId(channel))
9224            .source_hint(NodeHint([9, 9, 9]))
9225            .frame_counter(1)
9226            .payload(&[1, 2, 3])
9227            .build()
9228            .unwrap()
9229            .as_bytes()
9230            .to_vec()
9231    }
9232
9233    fn blind_unicast_on(channel: [u8; 2]) -> Vec<u8> {
9234        let mut buf = [0u8; 96];
9235        PacketBuilder::new(&mut buf)
9236            .blind_unicast(ChannelId(channel), NodeHint([7, 7, 7]))
9237            .source_hint(NodeHint([9, 9, 9]))
9238            .frame_counter(1)
9239            .payload(&[1, 2, 3])
9240            .build()
9241            .unwrap()
9242            .as_bytes()
9243            .to_vec()
9244    }
9245
9246    fn broadcast_frame() -> Vec<u8> {
9247        let mut buf = [0u8; 64];
9248        PacketBuilder::new(&mut buf)
9249            .broadcast()
9250            .source_hint(NodeHint([9, 9, 9]))
9251            .payload(&[1, 2, 3])
9252            .build()
9253            .unwrap()
9254            .to_vec()
9255    }
9256
9257    fn mac_ack_with_mic(ack_mic: [u8; 4]) -> Vec<u8> {
9258        let mut trailer = [0u8; 8];
9259        trailer[..4].copy_from_slice(&ack_mic);
9260        trailer[4..].copy_from_slice(&[0x5A; 4]); // arbitrary keyed-tag half
9261        let mut buf = [0u8; 32];
9262        PacketBuilder::new(&mut buf)
9263            .mac_ack(trailer)
9264            .build()
9265            .unwrap()
9266            .to_vec()
9267    }
9268
9269    /// Feed a radio frame in and report whether it was delivered.
9270    fn delivered(session: &mut TestSession, frame: &[u8]) -> bool {
9271        delivered_at(session, frame, 0)
9272    }
9273
9274    fn delivered_at(session: &mut TestSession, frame: &[u8], now_ms: u64) -> bool {
9275        let mut emitted = Vec::new();
9276        session.on_radio_rx(
9277            frame,
9278            &RadioRxInfo::measured(-80, 40, None),
9279            now_ms,
9280            &mut |bytes: &[u8]| emitted.push(bytes.to_vec()),
9281        );
9282        !emitted.is_empty()
9283    }
9284
9285    fn insert_item(
9286        session: &mut TestSession,
9287        key: u32,
9288        item: &[u8],
9289    ) -> (Vec<Vec<u8>>, Option<Effect>) {
9290        let mut buf = [0u8; 128];
9291        let len = frame::prop_insert(&mut buf, 5, key, item).unwrap();
9292        dispatch(session, &buf[..len], 0)
9293    }
9294
9295    fn remove_item(
9296        session: &mut TestSession,
9297        key: u32,
9298        item: &[u8],
9299    ) -> (Vec<Vec<u8>>, Option<Effect>) {
9300        let mut buf = [0u8; 128];
9301        let len = frame::prop_remove(&mut buf, 6, key, item).unwrap();
9302        dispatch(session, &buf[..len], 0)
9303    }
9304
9305    /// Install a host key, completing the deferred durable wipe.
9306    fn install_host_key(session: &mut TestSession, key: &[u8; 32]) {
9307        let (emitted, effect) = set(session, prop::HOST_KEY, key);
9308        assert!(effect.is_none(), "host replacement needs no durable step");
9309        let (_, response_key, value) = parse_prop_is(&emitted[0]);
9310        assert_eq!(response_key, prop::HOST_KEY);
9311        assert_eq!(value, key);
9312    }
9313
9314    /// Parse an emitted frame as INSERTED/REMOVED and return (key, digest).
9315    fn parse_table_notice(bytes: &[u8], expected: Cmd, tid: u8) -> (u32, Vec<u8>) {
9316        let parsed = Frame::parse(bytes).unwrap();
9317        assert_eq!(parsed.command(), Some(expected));
9318        assert_eq!(parsed.header.tid(), tid);
9319        let payload = PropPayload::parse(parsed.payload).unwrap();
9320        (payload.key, payload.value.to_vec())
9321    }
9322
9323    #[test]
9324    fn factory_state_accepts_everything() {
9325        let mut session = test_session();
9326        enable(&mut session);
9327        // No host key, no filters: minimal-protocol behavior, including
9328        // frames that do not parse as UMSH at all.
9329        assert!(delivered(&mut session, &unicast_to([1, 2, 3])));
9330        assert!(delivered(&mut session, &broadcast_frame()));
9331        assert!(delivered(&mut session, &[0x00, 0x01, 0x02]));
9332    }
9333
9334    #[test]
9335    fn host_key_round_trip_and_implicit_dest_filter() {
9336        let mut session = test_session();
9337        enable(&mut session);
9338        assert_eq!(get(&mut session, prop::HOST_KEY), Vec::<u8>::new());
9339
9340        let key = [0xC4; 32];
9341        install_host_key(&mut session, &key);
9342        assert_eq!(get(&mut session, prop::HOST_KEY), key);
9343
9344        // The implicit destination-hint filter: unicast traffic to the
9345        // host's 3-byte prefix is accepted, everything else — including
9346        // unparseable frames — is not. A MAC ack carries no destination
9347        // hint; with nothing transmitted, its ack_mic matches no expected
9348        // send, so it is dropped (see mac_ack_accepted_only_when_expected).
9349        assert!(delivered(&mut session, &unicast_to([0xC4, 0xC4, 0xC4])));
9350        assert!(!delivered(
9351            &mut session,
9352            &mac_ack_with_mic([0xC4, 0xC4, 0xC4, 0xC4])
9353        ));
9354        assert!(!delivered(&mut session, &unicast_to([1, 2, 3])));
9355        // Broadcasts stay implicitly accepted for live delivery.
9356        assert!(delivered(&mut session, &broadcast_frame()));
9357        assert!(!delivered(&mut session, &[0x00, 0x01, 0x02]));
9358    }
9359
9360    #[test]
9361    fn mac_ack_accepted_only_when_expected() {
9362        let mut session = test_session();
9363        enable(&mut session);
9364        install_host_key(&mut session, &[0xC4; 32]); // configure filtering
9365
9366        // Before sending anything, no ack is expected.
9367        assert!(!delivered(
9368            &mut session,
9369            &mac_ack_with_mic([0x11, 0x22, 0x33, 0x44])
9370        ));
9371
9372        // Transmit an ack-requested frame; its MIC prefix is now expected.
9373        let frame = sealed_unar(7, &test_pairwise(), false);
9374        let header = PacketHeader::parse(&frame).unwrap();
9375        let mic = &frame[header.mic_range.clone()];
9376        let ack_mic = [mic[0], mic[1], mic[2], mic[3]];
9377        let (_emitted, effect) = send_packet(&mut session, 4, &frame, &[], 0);
9378        assert_eq!(effect, Some(Effect::StartTransmit));
9379        session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {});
9380
9381        // An ack echoing that ack_mic is now accepted...
9382        assert!(delivered(&mut session, &mac_ack_with_mic(ack_mic)));
9383        // ...and is NOT evicted on match: a duplicate arriving over another
9384        // route still passes (lazy eviction).
9385        assert!(delivered(&mut session, &mac_ack_with_mic(ack_mic)));
9386        // An ack for a different (unsent) frame is still rejected.
9387        assert!(!delivered(
9388            &mut session,
9389            &mac_ack_with_mic([0x99, 0x88, 0x77, 0x66])
9390        ));
9391    }
9392
9393    /// A repeater's onward copy of a frame the host transmitted passes the
9394    /// filter even though its destination hint names the remote peer: the
9395    /// MIC prefix marks it as an echo of our own send, which is exactly what
9396    /// the host's forwarding-confirmation machinery waits to overhear.
9397    /// Without this rule a bridged host retries every hop send it makes,
9398    /// because the confirmation can never reach it.
9399    #[test]
9400    fn repeat_of_a_transmitted_frame_passes_the_filter() {
9401        let mut session = test_session();
9402        enable(&mut session);
9403        install_host_key(&mut session, &HOST_PUB);
9404
9405        // A non-ack unicast from the host out to a remote peer, flooding.
9406        let mut buf = [0u8; 96];
9407        let mut packet = PacketBuilder::new(&mut buf)
9408            .unicast(NodeHint([PEER_PUB[0], PEER_PUB[1], PEER_PUB[2]]))
9409            .source_hint(NodeHint([HOST_PUB[0], HOST_PUB[1], HOST_PUB[2]]))
9410            .frame_counter(9)
9411            .flood_hops(5)
9412            .mic_size(MicSize::Mic8)
9413            .payload(&[4, 5, 6])
9414            .build()
9415            .unwrap();
9416        test_engine()
9417            .seal_packet(&mut packet, &test_pairwise())
9418            .unwrap();
9419        let frame = packet.as_bytes().to_vec();
9420
9421        // The repeat: mutable routing state rewritten, MIC untouched — what
9422        // a repeater is permitted to do.
9423        let header = PacketHeader::parse(&frame).unwrap();
9424        let mut repeat = frame.clone();
9425        repeat[1] = header.flood_hops.unwrap().decremented().0;
9426        assert_ne!(repeat, frame);
9427
9428        // Before the host transmits, the same bytes are just somebody
9429        // else's unicast.
9430        assert!(!delivered(&mut session, &repeat));
9431
9432        let (_emitted, effect) = send_packet(&mut session, 4, &frame, &[], 0);
9433        assert_eq!(effect, Some(Effect::StartTransmit));
9434        session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {});
9435
9436        // Now it is an echo of our own send: accepted, and — like a
9437        // returning ack — not evicted on match, so a second repeater's copy
9438        // passes too.
9439        assert!(delivered(&mut session, &repeat));
9440        assert!(delivered(&mut session, &repeat));
9441    }
9442
9443    #[test]
9444    fn explicit_pkt_type_filter_accepts_unexpected_mac_ack() {
9445        // The implicit ack_mic match is one arm of the union of filters; an
9446        // explicit FILTER_PKT_TYPE for MacAck must still accept an ack whose
9447        // mic we never recorded.
9448        let mut session = test_session();
9449        enable(&mut session);
9450        install_host_key(&mut session, &[0xC4; 32]); // configure filtering
9451
9452        // No matching send, so the implicit ack_mic filter rejects it.
9453        assert!(!delivered(
9454            &mut session,
9455            &mac_ack_with_mic([0x11, 0x22, 0x33, 0x44])
9456        ));
9457
9458        // Explicitly request MacAck frames by type.
9459        insert_item(
9460            &mut session,
9461            prop::HOST_RX_FILTERS,
9462            &[items::FILTER_PKT_TYPE, PacketType::MacAck as u8],
9463        );
9464
9465        // Now the same unexpected ack is accepted via the explicit filter.
9466        assert!(delivered(
9467            &mut session,
9468            &mac_ack_with_mic([0x11, 0x22, 0x33, 0x44])
9469        ));
9470    }
9471
9472    #[test]
9473    fn host_key_rejects_bad_lengths() {
9474        let mut session = test_session();
9475        for bad in [&[0u8; 31][..], &[0u8; 33][..], &[1u8][..]] {
9476            let (emitted, effect) = set(&mut session, prop::HOST_KEY, bad);
9477            assert!(effect.is_none());
9478            expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
9479        }
9480    }
9481
9482    #[test]
9483    fn host_key_set_is_idempotent_for_current_value() {
9484        let mut session = test_session();
9485        // Empty -> empty: no replacement, immediate echo.
9486        let (emitted, effect) = set(&mut session, prop::HOST_KEY, &[]);
9487        assert!(effect.is_none());
9488        let (_, key, value) = parse_prop_is(&emitted[0]);
9489        assert_eq!(key, prop::HOST_KEY);
9490        assert!(value.is_empty());
9491
9492        let host_key = [0xC4; 32];
9493        install_host_key(&mut session, &host_key);
9494        insert_item(
9495            &mut session,
9496            prop::HOST_RX_FILTERS,
9497            &[items::FILTER_PKT_TYPE, 0],
9498        );
9499
9500        // Same key again: no wipe, and the filter table survives.
9501        let (emitted, effect) = set(&mut session, prop::HOST_KEY, &host_key);
9502        assert!(effect.is_none());
9503        let (_, key, value) = parse_prop_is(&emitted[0]);
9504        assert_eq!(key, prop::HOST_KEY);
9505        assert_eq!(value, host_key);
9506        assert!(!get(&mut session, prop::HOST_RX_FILTERS).is_empty());
9507    }
9508
9509    /// Host replacement is one immediate assignment now that the host
9510    /// domain is never persisted: there is no durable transaction to
9511    /// stage, fail, or leave in flight.
9512    #[test]
9513    fn host_replacement_clears_the_host_domain_immediately() {
9514        let mut session = test_session();
9515        install_host_key(&mut session, &[0xAA; 32]);
9516        insert_item(
9517            &mut session,
9518            prop::HOST_RX_FILTERS,
9519            &[items::FILTER_PKT_TYPE, 0],
9520        );
9521
9522        install_host_key(&mut session, &[0xBB; 32]);
9523        assert!(get(&mut session, prop::HOST_RX_FILTERS).is_empty());
9524
9525        // Clearing the key (set to empty) is also a replacement.
9526        insert_item(
9527            &mut session,
9528            prop::HOST_RX_FILTERS,
9529            &[items::FILTER_PKT_TYPE, 0],
9530        );
9531        let (emitted, effect) = set(&mut session, prop::HOST_KEY, &[]);
9532        assert!(effect.is_none());
9533        let (_, key, value) = parse_prop_is(&emitted[0]);
9534        assert_eq!(key, prop::HOST_KEY);
9535        assert!(value.is_empty());
9536        assert_eq!(get(&mut session, prop::HOST_KEY), Vec::<u8>::new());
9537        assert!(get(&mut session, prop::HOST_RX_FILTERS).is_empty());
9538    }
9539
9540    #[test]
9541    fn cmd_rst_clears_host_domain() {
9542        let mut session = test_session();
9543        install_host_key(&mut session, &[0xAA; 32]);
9544        insert_item(
9545            &mut session,
9546            prop::HOST_RX_FILTERS,
9547            &[items::FILTER_PKT_TYPE, 0],
9548        );
9549        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_| {});
9550        assert_eq!(get(&mut session, prop::HOST_KEY), Vec::<u8>::new());
9551        assert!(get(&mut session, prop::HOST_RX_FILTERS).is_empty());
9552    }
9553
9554    #[test]
9555    fn filter_insert_remove_lifecycle() {
9556        let mut session = test_session();
9557        let item = [items::FILTER_DEST_HINT, 0x11, 0x22, 0x33];
9558
9559        let (emitted, effect) = insert_item(&mut session, prop::HOST_RX_FILTERS, &item);
9560        assert!(effect.is_none());
9561        let (key, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
9562        assert_eq!(key, prop::HOST_RX_FILTERS);
9563        assert_eq!(digest, item);
9564
9565        // Duplicate insert fails with ALREADY.
9566        let (emitted, _) = insert_item(&mut session, prop::HOST_RX_FILTERS, &item);
9567        expect_status(&emitted[0], 5, Status::ALREADY);
9568
9569        // GET returns the whole table with item length prefixes.
9570        let table = get(&mut session, prop::HOST_RX_FILTERS);
9571        assert_eq!(table, [&[4u8][..], &item[..]].concat());
9572
9573        let (emitted, _) = remove_item(&mut session, prop::HOST_RX_FILTERS, &item);
9574        let (key, digest) = parse_table_notice(&emitted[0], Cmd::PropRemoved, 6);
9575        assert_eq!(key, prop::HOST_RX_FILTERS);
9576        assert_eq!(digest, item);
9577        assert!(get(&mut session, prop::HOST_RX_FILTERS).is_empty());
9578
9579        // Removing a missing item fails with ITEM_NOT_FOUND.
9580        let (emitted, _) = remove_item(&mut session, prop::HOST_RX_FILTERS, &item);
9581        expect_status(&emitted[0], 6, Status::ITEM_NOT_FOUND);
9582    }
9583
9584    #[test]
9585    fn filter_insert_rejects_invalid_entries() {
9586        let mut session = test_session();
9587        for bad in [
9588            &[][..],                                  // empty item
9589            &[3, 0][..],                              // unknown FILTER_TYPE
9590            &[items::FILTER_DEST_HINT, 1, 2][..],     // wrong value length
9591            &[items::FILTER_CHANNEL_ID, 1, 2, 3][..], // wrong value length
9592            &[items::FILTER_PKT_TYPE, 8][..],         // packet type out of range
9593        ] {
9594            let (emitted, effect) = insert_item(&mut session, prop::HOST_RX_FILTERS, bad);
9595            assert!(effect.is_none());
9596            expect_status(&emitted[0], 5, Status::INVALID_ARGUMENT);
9597        }
9598        assert!(get(&mut session, prop::HOST_RX_FILTERS).is_empty());
9599    }
9600
9601    #[test]
9602    fn filter_table_capacity_is_bounded() {
9603        let mut session = test_session();
9604        for index in 0..MAX_RX_FILTERS as u8 {
9605            let (emitted, _) = insert_item(
9606                &mut session,
9607                prop::HOST_RX_FILTERS,
9608                &[items::FILTER_DEST_HINT, index, 0, 0],
9609            );
9610            parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
9611        }
9612        let (emitted, _) = insert_item(
9613            &mut session,
9614            prop::HOST_RX_FILTERS,
9615            &[items::FILTER_DEST_HINT, 0xFF, 0, 0],
9616        );
9617        expect_status(&emitted[0], 5, Status::NOMEM);
9618    }
9619
9620    #[test]
9621    fn whole_table_set_is_atomic() {
9622        let mut session = test_session();
9623        let good_a = [items::FILTER_DEST_HINT, 1, 2, 3];
9624        let good_b = [items::FILTER_PKT_TYPE, 0];
9625
9626        let mut table = Vec::new();
9627        for item in [&good_a[..], &good_b[..]] {
9628            table.push(item.len() as u8);
9629            table.extend_from_slice(item);
9630        }
9631        let (emitted, effect) = set(&mut session, prop::HOST_RX_FILTERS, &table);
9632        assert!(effect.is_none());
9633        let (_, key, value) = parse_prop_is(&emitted[0]);
9634        assert_eq!(key, prop::HOST_RX_FILTERS);
9635        assert_eq!(value, table);
9636
9637        // A set containing any invalid item fails without applying
9638        // anything: the previous table is fully retained.
9639        let mut bad_table = table.clone();
9640        bad_table.extend_from_slice(&[2, 3, 0]); // unknown FILTER_TYPE 3
9641        let (emitted, _) = set(&mut session, prop::HOST_RX_FILTERS, &bad_table);
9642        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
9643        assert_eq!(get(&mut session, prop::HOST_RX_FILTERS), table);
9644
9645        // A value that cannot be split into items is malformed.
9646        let (emitted, _) = set(&mut session, prop::HOST_RX_FILTERS, &[9, 1]);
9647        expect_status(&emitted[0], 2, Status::PARSE_ERROR);
9648        assert_eq!(get(&mut session, prop::HOST_RX_FILTERS), table);
9649
9650        // Duplicates in the value collapse (a set, not a list).
9651        let mut doubled = table.clone();
9652        doubled.extend_from_slice(&table);
9653        let (emitted, _) = set(&mut session, prop::HOST_RX_FILTERS, &doubled);
9654        let (_, _, value) = parse_prop_is(&emitted[0]);
9655        assert_eq!(value, table);
9656
9657        // Setting an empty value clears the table.
9658        let (emitted, _) = set(&mut session, prop::HOST_RX_FILTERS, &[]);
9659        let (_, _, value) = parse_prop_is(&emitted[0]);
9660        assert!(value.is_empty());
9661        assert!(get(&mut session, prop::HOST_RX_FILTERS).is_empty());
9662    }
9663
9664    #[test]
9665    fn explicit_filters_match_each_type() {
9666        let mut session = test_session();
9667        enable(&mut session);
9668
9669        // Destination-hint filter.
9670        insert_item(
9671            &mut session,
9672            prop::HOST_RX_FILTERS,
9673            &[items::FILTER_DEST_HINT, 0x11, 0x22, 0x33],
9674        );
9675        assert!(delivered(&mut session, &unicast_to([0x11, 0x22, 0x33])));
9676        // A MAC ack for a frame we never sent is dropped.
9677        assert!(!delivered(
9678            &mut session,
9679            &mac_ack_with_mic([0x11, 0x22, 0x33, 0x44])
9680        ));
9681        assert!(!delivered(&mut session, &unicast_to([4, 5, 6])));
9682        // Broadcasts are implicitly accepted for live delivery even
9683        // though no explicit filter selects them.
9684        assert!(delivered(&mut session, &broadcast_frame()));
9685
9686        // Channel filter: matches multicast and blind unicast on the
9687        // channel (a blind unicast's destination hint is concealed).
9688        insert_item(
9689            &mut session,
9690            prop::HOST_RX_FILTERS,
9691            &[items::FILTER_CHANNEL_ID, 0xAB, 0xCD],
9692        );
9693        assert!(delivered(&mut session, &multicast_on([0xAB, 0xCD])));
9694        assert!(delivered(&mut session, &blind_unicast_on([0xAB, 0xCD])));
9695        assert!(!delivered(&mut session, &multicast_on([0x00, 0x01])));
9696
9697        // Packet-type filter: a MAC ack that matches no recorded
9698        // ack_mic was rejected above, but an explicit entry admits it.
9699        insert_item(
9700            &mut session,
9701            prop::HOST_RX_FILTERS,
9702            &[items::FILTER_PKT_TYPE, PacketType::MacAck as u8],
9703        );
9704        assert!(delivered(
9705            &mut session,
9706            &mac_ack_with_mic([0x11, 0x22, 0x33, 0x44])
9707        ));
9708        // Still rejects frames matching no filter.
9709        assert!(!delivered(&mut session, &unicast_to([4, 5, 6])));
9710        assert!(!delivered(&mut session, &[0x00, 0x01, 0x02]));
9711    }
9712
9713    #[test]
9714    fn promiscuous_bypasses_filtering_for_live_delivery() {
9715        let mut session = test_session();
9716        enable(&mut session);
9717        insert_item(
9718            &mut session,
9719            prop::HOST_RX_FILTERS,
9720            &[items::FILTER_DEST_HINT, 0x11, 0x22, 0x33],
9721        );
9722        assert!(!delivered(&mut session, &unicast_to([4, 5, 6])));
9723
9724        set(&mut session, prop::MAC_PROMISCUOUS, &[1]);
9725        assert!(delivered(&mut session, &unicast_to([4, 5, 6])));
9726        assert!(delivered(&mut session, &[0x00, 0x01, 0x02]));
9727
9728        // Attach reverts promiscuous mode; filtering applies again.
9729        session.attach(true);
9730        assert!(!delivered(&mut session, &unicast_to([4, 5, 6])));
9731    }
9732
9733    #[test]
9734    fn filters_survive_attach() {
9735        let mut session = test_session();
9736        enable(&mut session);
9737        install_host_key(&mut session, &[0xC4; 32]);
9738        insert_item(
9739            &mut session,
9740            prop::HOST_RX_FILTERS,
9741            &[items::FILTER_PKT_TYPE, 0],
9742        );
9743        session.attach(true);
9744        assert_eq!(get(&mut session, prop::HOST_KEY), [0xC4; 32]);
9745        assert!(delivered(&mut session, &broadcast_frame()));
9746        assert!(delivered(&mut session, &unicast_to([0xC4, 0xC4, 0xC4])));
9747        assert!(!delivered(&mut session, &unicast_to([1, 2, 3])));
9748    }
9749
9750    #[test]
9751    fn host_key_insert_remove_is_invalid_argument() {
9752        let mut session = test_session();
9753        let (emitted, _) = insert_item(&mut session, prop::HOST_KEY, &[0; 32]);
9754        expect_status(&emitted[0], 5, Status::INVALID_ARGUMENT);
9755        let (emitted, _) = remove_item(&mut session, prop::HOST_KEY, &[0; 32]);
9756        expect_status(&emitted[0], 6, Status::INVALID_ARGUMENT);
9757    }
9758
9759    // ─── CAP_HOST_RX_QUEUE gate ──────────────────────────────────────
9760
9761    /// Feed a frame while detached at `now_ms` (asserting it is not
9762    /// delivered live).
9763    fn receive_detached(session: &mut TestSession, frame: &[u8], now_ms: u64) {
9764        assert!(!delivered_at(session, frame, now_ms));
9765    }
9766
9767    fn queue_count(session: &mut TestSession) -> u16 {
9768        let raw = get(session, prop::HOST_RX_QUEUE_COUNT);
9769        u16::from_le_bytes([raw[0], raw[1]])
9770    }
9771
9772    /// Issue CMD_QUEUE_DRAIN and run it to completion, returning the
9773    /// drained (frame, metadata) pairs. Asserts correct completion.
9774    fn drain(session: &mut TestSession, now_ms: u64) -> Vec<(Vec<u8>, BufferedRxMeta)> {
9775        let mut buf = [0u8; 4];
9776        let len = frame::queue_drain(&mut buf, 7).unwrap();
9777        let (emitted, effect) = dispatch(session, &buf[..len], now_ms);
9778        if effect.is_none() {
9779            // Empty queue: immediate success, nothing drained.
9780            expect_status(&emitted[0], 7, Status::OK);
9781            return Vec::new();
9782        }
9783        assert_eq!(effect, Some(Effect::DrainQueue));
9784        assert!(emitted.is_empty());
9785        let mut steps = Vec::new();
9786        loop {
9787            let mut emitted = Vec::new();
9788            let more = session.drain_step(now_ms, &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
9789            assert_eq!(emitted.len(), 1, "each step emits exactly one frame");
9790            if !more {
9791                expect_status(&emitted[0], 7, Status::OK);
9792                return steps;
9793            }
9794            let parsed = Frame::parse(&emitted[0]).unwrap();
9795            assert_eq!(parsed.command(), Some(Cmd::StrRecv));
9796            let payload = StreamPayload::parse(parsed.payload).unwrap();
9797            steps.push((
9798                payload.data.to_vec(),
9799                BufferedRxMeta::decode(payload.metadata).unwrap(),
9800            ));
9801        }
9802    }
9803
9804    #[test]
9805    fn detached_receive_then_attach_count_drain() {
9806        let mut session = test_session();
9807        enable(&mut session);
9808        session.detach();
9809        receive_detached(&mut session, &unicast_to([1, 2, 3]), 1_000);
9810        receive_detached(&mut session, &broadcast_frame(), 3_000);
9811
9812        // Attach does not flush the queue; live delivery resumes while
9813        // the backlog waits for an explicit drain.
9814        session.attach(true);
9815        assert_eq!(queue_count(&mut session), 2);
9816        assert!(delivered(&mut session, &unicast_to([7, 7, 7])));
9817        assert_eq!(queue_count(&mut session), 2);
9818
9819        let drained = drain(&mut session, 8_000);
9820        assert_eq!(drained.len(), 2);
9821        // Oldest first, with buffered metadata: flags, one-second age
9822        // granularity, and the recorded RSSI/SNR.
9823        assert_eq!(drained[0].0, unicast_to([1, 2, 3]));
9824        assert_eq!(drained[1].0, broadcast_frame());
9825        for (_, meta) in &drained {
9826            assert_eq!(meta.flags, RX_FLAG_BUFFERED);
9827            assert_eq!(meta.rx.rssi_dbm, Some(-80));
9828            assert_eq!(meta.rx.snr_cb, Some(40));
9829        }
9830        assert_eq!((drained[0].1.age_s, drained[1].1.age_s), (7, 5));
9831
9832        assert_eq!(queue_count(&mut session), 0);
9833        // Draining an empty queue succeeds immediately.
9834        assert!(drain(&mut session, 9_000).is_empty());
9835    }
9836
9837    #[test]
9838    fn queue_overflow_evicts_oldest_and_counts_dropped() {
9839        let mut session = test_session();
9840        enable(&mut session);
9841        session.detach();
9842        // Overfill by three: the queue keeps the most recent traffic.
9843        for index in 0..(RX_QUEUE_CAPACITY + 3) as u8 {
9844            receive_detached(&mut session, &unicast_to([index, 0, 0]), 0);
9845        }
9846        session.attach(true);
9847        assert_eq!(queue_count(&mut session), RX_QUEUE_CAPACITY as u16);
9848        assert_eq!(
9849            get(&mut session, prop::HOST_RX_QUEUE_DROPPED),
9850            3u32.to_le_bytes()
9851        );
9852        let drained = drain(&mut session, 0);
9853        assert_eq!(drained[0].0, unicast_to([3, 0, 0]));
9854        assert_eq!(
9855            drained.last().unwrap().0,
9856            unicast_to([(RX_QUEUE_CAPACITY + 2) as u8, 0, 0])
9857        );
9858    }
9859
9860    #[test]
9861    fn queue_respects_receive_filtering() {
9862        let mut session = test_session();
9863        enable(&mut session);
9864        insert_item(
9865            &mut session,
9866            prop::HOST_RX_FILTERS,
9867            &[items::FILTER_DEST_HINT, 0x11, 0x22, 0x33],
9868        );
9869        session.detach();
9870        receive_detached(&mut session, &unicast_to([0x11, 0x22, 0x33]), 0);
9871        receive_detached(&mut session, &unicast_to([4, 5, 6]), 0); // rejected
9872        receive_detached(&mut session, &[0xFF, 0xFE], 0); // unparseable
9873        session.attach(true);
9874        assert_eq!(queue_count(&mut session), 1);
9875    }
9876
9877    #[test]
9878    fn broadcasts_are_implicit_live_but_follow_filters_when_queued() {
9879        let mut session = test_session();
9880        enable(&mut session);
9881        // A configured host key means filtering is active, yet a live
9882        // broadcast is still delivered: every node is a broadcast's
9883        // addressee, the host included.
9884        install_host_key(&mut session, &[0xC4; 32]);
9885        assert!(delivered(&mut session, &broadcast_frame()));
9886        // While detached the implicit rule does not apply — ambient
9887        // broadcast traffic must not displace queued unicast frames.
9888        session.detach();
9889        receive_detached(&mut session, &broadcast_frame(), 0);
9890        session.attach(true);
9891        assert_eq!(queue_count(&mut session), 0);
9892    }
9893
9894    #[test]
9895    fn unauthenticated_duplicates_occupy_separate_entries() {
9896        // No keys are provisioned before CAP_HOST_KEYS, so no
9897        // protocol-defined duplicate detection applies.
9898        let mut session = test_session();
9899        enable(&mut session);
9900        session.detach();
9901        let frame = unicast_to([1, 2, 3]);
9902        receive_detached(&mut session, &frame, 0);
9903        receive_detached(&mut session, &frame, 0);
9904        session.attach(true);
9905        assert_eq!(queue_count(&mut session), 2);
9906    }
9907
9908    #[test]
9909    fn live_arrivals_interleave_with_a_drain() {
9910        let mut session = test_session();
9911        enable(&mut session);
9912        session.detach();
9913        receive_detached(&mut session, &unicast_to([1, 0, 0]), 0);
9914        receive_detached(&mut session, &unicast_to([2, 0, 0]), 0);
9915        session.attach(true);
9916
9917        let mut buf = [0u8; 4];
9918        let len = frame::queue_drain(&mut buf, 7).unwrap();
9919        let (_, effect) = dispatch(&mut session, &buf[..len], 10_000);
9920        assert_eq!(effect, Some(Effect::DrainQueue));
9921
9922        // First covered frame.
9923        let mut emitted = Vec::new();
9924        assert!(session.drain_step(10_000, &mut |bytes: &[u8]| emitted.push(bytes.to_vec())));
9925
9926        // A live arrival mid-drain is delivered immediately and is not
9927        // part of the covered set.
9928        assert!(delivered_at(&mut session, &unicast_to([3, 0, 0]), 10_000));
9929
9930        // The drain still covers exactly the original two frames.
9931        let mut frames = 0;
9932        loop {
9933            let mut emitted = Vec::new();
9934            let more = session.drain_step(10_000, &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
9935            if !more {
9936                expect_status(&emitted[0], 7, Status::OK);
9937                break;
9938            }
9939            frames += 1;
9940        }
9941        assert_eq!(frames, 1);
9942        assert_eq!(queue_count(&mut session), 0);
9943    }
9944
9945    #[test]
9946    fn second_drain_while_in_progress_is_busy() {
9947        let mut session = test_session();
9948        enable(&mut session);
9949        session.detach();
9950        receive_detached(&mut session, &unicast_to([1, 0, 0]), 0);
9951        session.attach(true);
9952
9953        let mut buf = [0u8; 4];
9954        let len = frame::queue_drain(&mut buf, 7).unwrap();
9955        let (_, effect) = dispatch(&mut session, &buf[..len], 0);
9956        assert_eq!(effect, Some(Effect::DrainQueue));
9957
9958        let len = frame::queue_drain(&mut buf, 6).unwrap();
9959        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
9960        assert!(effect.is_none());
9961        expect_status(&emitted[0], 6, Status::BUSY);
9962    }
9963
9964    #[test]
9965    fn reset_and_host_replacement_discard_the_queue() {
9966        let mut session = test_session();
9967        enable(&mut session);
9968        session.detach();
9969        for _ in 0..(RX_QUEUE_CAPACITY + 1) {
9970            receive_detached(&mut session, &unicast_to([1, 2, 3]), 0);
9971        }
9972        session.attach(true);
9973        assert_ne!(queue_count(&mut session), 0);
9974
9975        // Host replacement discards the queue and its counters as part
9976        // of the host domain.
9977        install_host_key(&mut session, &[0xAA; 32]);
9978        assert_eq!(queue_count(&mut session), 0);
9979        assert_eq!(
9980            get(&mut session, prop::HOST_RX_QUEUE_DROPPED),
9981            0u32.to_le_bytes()
9982        );
9983
9984        // CMD_RST does too. (Refill first; the host key now filters, so
9985        // address the host.)
9986        enable(&mut session);
9987        session.detach();
9988        receive_detached(&mut session, &unicast_to([0xAA, 0xAA, 0xAA]), 0);
9989        session.attach(true);
9990        assert_eq!(queue_count(&mut session), 1);
9991        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_| {});
9992        assert_eq!(queue_count(&mut session), 0);
9993    }
9994
9995    // ─── CAP_HOST_KEYS gate ──────────────────────────────────────────
9996
9997    /// Insert a channel key, returning its derived identifier digest.
9998    fn install_channel_key(session: &mut TestSession, key: &[u8; 32]) -> [u8; 2] {
9999        let (emitted, effect) = insert_item(session, prop::HOST_CHANNEL_KEYS, key);
10000        assert!(effect.is_none());
10001        let (prop_key, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
10002        assert_eq!(prop_key, prop::HOST_CHANNEL_KEYS);
10003        digest.try_into().expect("channel digest is 2 bytes")
10004    }
10005
10006    fn peer_entry(seed: u8) -> [u8; 96] {
10007        let mut item = [0u8; 96];
10008        item[..32].fill(seed);
10009        item[32..64].fill(0xE0 | (seed & 0x0F));
10010        item[64..].fill(0x50 | (seed & 0x0F));
10011        item
10012    }
10013
10014    #[test]
10015    fn channel_key_lifecycle_and_digest_is_derived_id() {
10016        let mut session = test_session();
10017        let key = [0x42; 32];
10018        let expected_id = test_engine().derive_channel_id(&ChannelKey(key)).0;
10019
10020        let digest = install_channel_key(&mut session, &key);
10021        assert_eq!(digest, expected_id);
10022        assert_eq!(get(&mut session, prop::HOST_CHANNEL_KEYS), expected_id);
10023
10024        // Duplicate channel key fails with ALREADY.
10025        let (emitted, _) = insert_item(&mut session, prop::HOST_CHANNEL_KEYS, &key);
10026        expect_status(&emitted[0], 5, Status::ALREADY);
10027
10028        // Remove selector is the key; the digest reported is the id.
10029        let (emitted, _) = remove_item(&mut session, prop::HOST_CHANNEL_KEYS, &key);
10030        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropRemoved, 6);
10031        assert_eq!(digest, expected_id);
10032        assert!(get(&mut session, prop::HOST_CHANNEL_KEYS).is_empty());
10033
10034        let (emitted, _) = remove_item(&mut session, prop::HOST_CHANNEL_KEYS, &key);
10035        expect_status(&emitted[0], 6, Status::ITEM_NOT_FOUND);
10036
10037        // Wrong-size items are invalid.
10038        for bad in [&[0u8; 31][..], &[0u8; 33][..], &[][..]] {
10039            let (emitted, _) = insert_item(&mut session, prop::HOST_CHANNEL_KEYS, bad);
10040            expect_status(&emitted[0], 5, Status::INVALID_ARGUMENT);
10041        }
10042    }
10043
10044    #[test]
10045    fn channel_key_capacity_is_bounded() {
10046        let mut session = test_session();
10047        for seed in 0..MAX_CHANNEL_KEYS as u8 {
10048            install_channel_key(&mut session, &[seed; 32]);
10049        }
10050        let (emitted, _) = insert_item(&mut session, prop::HOST_CHANNEL_KEYS, &[0xFF; 32]);
10051        expect_status(&emitted[0], 5, Status::NOMEM);
10052    }
10053
10054    #[test]
10055    fn peer_key_lifecycle_replacement_and_secret_free_digests() {
10056        let mut session = test_session();
10057        let entry = peer_entry(0xA1);
10058
10059        let (emitted, _) = insert_item(&mut session, prop::HOST_PEER_KEYS, &entry);
10060        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
10061        assert_eq!(digest, entry[..32]);
10062        // No emitted frame may carry the pairwise key material.
10063        for frame in &emitted {
10064            assert!(!frame.windows(32).any(|window| window == &entry[32..64]));
10065            assert!(!frame.windows(32).any(|window| window == &entry[64..]));
10066        }
10067
10068        // GET reports public keys only.
10069        assert_eq!(get(&mut session, prop::HOST_PEER_KEYS), entry[..32]);
10070
10071        // Inserting the same public key with new key material replaces
10072        // the entry (never ALREADY) and does not grow the table.
10073        let mut replacement = entry;
10074        replacement[32..].fill(0x77);
10075        let (emitted, _) = insert_item(&mut session, prop::HOST_PEER_KEYS, &replacement);
10076        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
10077        assert_eq!(digest, entry[..32]);
10078        assert_eq!(get(&mut session, prop::HOST_PEER_KEYS), entry[..32]);
10079
10080        // Remove selector is the public key.
10081        let (emitted, _) = remove_item(&mut session, prop::HOST_PEER_KEYS, &entry[..32]);
10082        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropRemoved, 6);
10083        assert_eq!(digest, entry[..32]);
10084        assert!(get(&mut session, prop::HOST_PEER_KEYS).is_empty());
10085
10086        let (emitted, _) = remove_item(&mut session, prop::HOST_PEER_KEYS, &entry[..32]);
10087        expect_status(&emitted[0], 6, Status::ITEM_NOT_FOUND);
10088
10089        // Malformed entries are invalid.
10090        let (emitted, _) = insert_item(&mut session, prop::HOST_PEER_KEYS, &entry[..95]);
10091        expect_status(&emitted[0], 5, Status::INVALID_ARGUMENT);
10092    }
10093
10094    #[test]
10095    fn key_table_whole_set_is_atomic_and_collapses_duplicates() {
10096        let mut session = test_session();
10097
10098        // Channels: duplicates collapse; a short trailing item fails
10099        // the whole set, leaving the table unchanged.
10100        let key_a = [0xA0; 32];
10101        let key_b = [0xB0; 32];
10102        let mut table = Vec::new();
10103        table.extend_from_slice(&key_a);
10104        table.extend_from_slice(&key_b);
10105        table.extend_from_slice(&key_a);
10106        let (emitted, _) = set(&mut session, prop::HOST_CHANNEL_KEYS, &table);
10107        let (_, key, value) = parse_prop_is(&emitted[0]);
10108        assert_eq!(key, prop::HOST_CHANNEL_KEYS);
10109        assert_eq!(value.len(), 4, "two unique channels, 2-byte ids");
10110
10111        let (emitted, _) = set(&mut session, prop::HOST_CHANNEL_KEYS, &table[..40]);
10112        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
10113        assert_eq!(get(&mut session, prop::HOST_CHANNEL_KEYS).len(), 4);
10114
10115        // Peers: a repeated public key replaces the earlier entry.
10116        let mut peers = Vec::new();
10117        peers.extend_from_slice(&peer_entry(0x01));
10118        let mut updated = peer_entry(0x01);
10119        updated[32..].fill(0x99);
10120        peers.extend_from_slice(&updated);
10121        let (emitted, _) = set(&mut session, prop::HOST_PEER_KEYS, &peers);
10122        let (_, _, value) = parse_prop_is(&emitted[0]);
10123        assert_eq!(value, peer_entry(0x01)[..32], "one entry, digest form");
10124
10125        // Empty set clears; oversized set fails atomically.
10126        let (emitted, _) = set(&mut session, prop::HOST_PEER_KEYS, &[]);
10127        let (_, _, value) = parse_prop_is(&emitted[0]);
10128        assert!(value.is_empty());
10129        let mut oversized = Vec::new();
10130        for seed in 0..(MAX_PEER_KEYS + 1) as u8 {
10131            oversized.extend_from_slice(&peer_entry(seed));
10132        }
10133        let (emitted, _) = set(&mut session, prop::HOST_PEER_KEYS, &oversized);
10134        expect_status(&emitted[0], 2, Status::NOMEM);
10135        assert!(get(&mut session, prop::HOST_PEER_KEYS).is_empty());
10136    }
10137
10138    #[test]
10139    fn insecure_transport_refuses_key_writes() {
10140        let mut session = test_session();
10141        session.attach(false); // e.g. a bare UART with no possession story
10142
10143        for (key, item) in [
10144            (prop::HOST_CHANNEL_KEYS, &[0x42u8; 32][..]),
10145            (prop::HOST_PEER_KEYS, &peer_entry(0x01)[..]),
10146        ] {
10147            let (emitted, effect) = set(&mut session, key, item);
10148            assert!(effect.is_none());
10149            expect_status(&emitted[0], 2, Status::INVALID_STATE);
10150            let (emitted, _) = insert_item(&mut session, key, item);
10151            expect_status(&emitted[0], 5, Status::INVALID_STATE);
10152            assert!(get(&mut session, key).is_empty(), "table must stay empty");
10153        }
10154
10155        // Non-key properties are unaffected by the gate.
10156        let (emitted, _) = set(&mut session, prop::PHY_DUTY_LIMIT, &100u16.to_le_bytes());
10157        let (_, key, _) = parse_prop_is(&emitted[0]);
10158        assert_eq!(key, prop::PHY_DUTY_LIMIT);
10159
10160        // Re-attaching over a secure transport unlocks provisioning.
10161        session.attach(true);
10162        install_channel_key(&mut session, &[0x42; 32]);
10163    }
10164
10165    #[test]
10166    fn provisioned_channel_id_is_an_implicit_filter() {
10167        let mut session = test_session();
10168        enable(&mut session);
10169        // Only a channel key is provisioned: filtering becomes
10170        // configured (compatibility rule) and the derived id matches
10171        // multicast and blind unicast on that channel.
10172        let id = install_channel_key(&mut session, &[0x42; 32]);
10173        assert!(delivered(&mut session, &multicast_on(id)));
10174        assert!(delivered(&mut session, &blind_unicast_on(id)));
10175        let other = [id[0] ^ 0xFF, id[1]];
10176        assert!(!delivered(&mut session, &multicast_on(other)));
10177        // Broadcasts stay implicitly accepted for live delivery.
10178        assert!(delivered(&mut session, &broadcast_frame()));
10179        assert!(!delivered(&mut session, &[0x00, 0x01, 0x02]));
10180
10181        // Detached queueing honors the same implicit filter.
10182        session.detach();
10183        receive_detached(&mut session, &multicast_on(id), 0);
10184        receive_detached(&mut session, &multicast_on(other), 0);
10185        session.attach(true);
10186        assert_eq!(queue_count(&mut session), 1);
10187    }
10188
10189    #[test]
10190    fn host_replacement_clears_key_tables() {
10191        let mut session = test_session();
10192        install_channel_key(&mut session, &[0x42; 32]);
10193        insert_item(&mut session, prop::HOST_PEER_KEYS, &peer_entry(0x01));
10194
10195        install_host_key(&mut session, &[0xAA; 32]);
10196        assert!(get(&mut session, prop::HOST_CHANNEL_KEYS).is_empty());
10197        assert!(get(&mut session, prop::HOST_PEER_KEYS).is_empty());
10198
10199        // CMD_RST clears them too.
10200        install_channel_key(&mut session, &[0x42; 32]);
10201        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_| {});
10202        assert!(get(&mut session, prop::HOST_CHANNEL_KEYS).is_empty());
10203    }
10204
10205    // ─── CAP_HOST_AUTO_ACK gate ──────────────────────────────────────
10206
10207    use umsh_core::{MicSize, PublicKey};
10208
10209    const HOST_PUB: [u8; 32] = [0xC4; 32];
10210    const PEER_PUB: [u8; 32] = [0x0A; 32];
10211
10212    fn test_pairwise() -> PairwiseKeys {
10213        PairwiseKeys {
10214            k_enc: [0x5E; 32],
10215            k_mic: [0x5F; 32],
10216        }
10217    }
10218
10219    fn peer_item(public_key: &[u8; 32], keys: &PairwiseKeys) -> [u8; 96] {
10220        let mut item = [0u8; 96];
10221        item[..32].copy_from_slice(public_key);
10222        item[32..64].copy_from_slice(&keys.k_enc);
10223        item[64..].copy_from_slice(&keys.k_mic);
10224        item
10225    }
10226
10227    /// Detached session provisioned for delegated acknowledgement:
10228    /// host key, one peer, auto-ACK on, PHY enabled.
10229    fn auto_ack_session() -> TestSession {
10230        let mut session = test_session();
10231        enable(&mut session);
10232        install_host_key(&mut session, &HOST_PUB);
10233        insert_item(
10234            &mut session,
10235            prop::HOST_PEER_KEYS,
10236            &peer_item(&PEER_PUB, &test_pairwise()),
10237        );
10238        set(&mut session, prop::HOST_AUTO_ACK, &[1]);
10239        session.detach();
10240        session
10241    }
10242
10243    /// A sealed UNAR from the test peer to the host (unencrypted body,
10244    /// 8-byte MIC), authenticated with `keys`.
10245    fn sealed_unar(counter: u32, keys: &PairwiseKeys, full_source: bool) -> Vec<u8> {
10246        let mut buf = [0u8; 96];
10247        let builder = PacketBuilder::new(&mut buf).unicast(NodeHint([0xC4, 0xC4, 0xC4]));
10248        let builder = if full_source {
10249            builder.source_full(&PublicKey(PEER_PUB))
10250        } else {
10251            builder.source_hint(NodeHint([0x0A, 0x0A, 0x0A]))
10252        };
10253        let mut packet = builder
10254            .frame_counter(counter)
10255            .ack_requested()
10256            .mic_size(MicSize::Mic8)
10257            .payload(&[3, 1, 2])
10258            .build()
10259            .unwrap();
10260        test_engine().seal_packet(&mut packet, keys).unwrap();
10261        packet.as_bytes().to_vec()
10262    }
10263
10264    /// A sealed BUAR from the test peer to the host through `channel_key`.
10265    fn sealed_buar(counter: u32, channel_key: &[u8; 32]) -> Vec<u8> {
10266        let engine = test_engine();
10267        let channel_keys = engine.derive_channel_keys(&ChannelKey(*channel_key));
10268        let mut buf = [0u8; 96];
10269        let mut packet = PacketBuilder::new(&mut buf)
10270            .blind_unicast(channel_keys.channel_id, NodeHint([0xC4, 0xC4, 0xC4]))
10271            .source_hint(NodeHint([0x0A, 0x0A, 0x0A]))
10272            .frame_counter(counter)
10273            .ack_requested()
10274            .encrypted()
10275            .mic_size(MicSize::Mic8)
10276            .payload(&[3, 9, 9])
10277            .build()
10278            .unwrap();
10279        let blind = engine.derive_blind_keys(&test_pairwise(), &channel_keys);
10280        engine
10281            .seal_blind_packet(&mut packet, &blind, &channel_keys)
10282            .unwrap();
10283        packet.as_bytes().to_vec()
10284    }
10285
10286    /// Feed a detached frame; detached processing must emit nothing.
10287    fn rx_effect(session: &mut TestSession, frame: &[u8], now_ms: u64) -> Option<Effect> {
10288        session.on_radio_rx(
10289            frame,
10290            &RadioRxInfo::measured(-80, 40, None),
10291            now_ms,
10292            &mut |_: &[u8]| panic!("detached receive must not emit"),
10293        )
10294    }
10295
10296    /// The expected 8-byte ack trailer (`ack_mic || ack_tag`) for an
10297    /// unencrypted sealed frame.
10298    fn expected_ack_trailer(frame: &[u8], keys: &PairwiseKeys) -> [u8; 8] {
10299        let engine = test_engine();
10300        let header = PacketHeader::parse(frame).unwrap();
10301        let full_mac = engine.s2v_tag(
10302            &keys.k_mic,
10303            |cmac| umsh_core::feed_aad(&header, frame, |chunk| cmac.update(chunk)),
10304            &frame[header.body_range.clone()],
10305        );
10306        engine.compute_ack_trailer(&full_mac, &keys.k_enc)
10307    }
10308
10309    /// Assert the staged transmit is a MAC ack (which carries no
10310    /// destination hint), and complete it.
10311    fn expect_ack_transmit(
10312        session: &mut TestSession,
10313        effect: Option<Effect>,
10314        trailer: Option<[u8; 8]>,
10315    ) {
10316        assert_eq!(effect, Some(Effect::StartTransmit));
10317        let header = PacketHeader::parse(session.tx_data()).unwrap();
10318        assert_eq!(header.fcf.packet_type(), PacketType::MacAck);
10319        if let Some(trailer) = trailer {
10320            assert_eq!(session.tx_data()[header.mic_range.clone()], trailer);
10321        }
10322        session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {
10323            panic!("autonomous ack must be silent")
10324        });
10325    }
10326
10327    #[test]
10328    fn unar_success_acks_queues_and_reports_acked() {
10329        let mut session = auto_ack_session();
10330        let frame = sealed_unar(100, &test_pairwise(), false);
10331        let effect = rx_effect(&mut session, &frame, 1_000);
10332        expect_ack_transmit(
10333            &mut session,
10334            effect,
10335            Some(expected_ack_trailer(&frame, &test_pairwise())),
10336        );
10337
10338        // The autonomous ack leaves PROP_LAST_STATUS alone: the boot
10339        // reason must still reach the next host.
10340        session.attach(true);
10341        assert_eq!(
10342            pui::decode(&get(&mut session, prop::LAST_STATUS))
10343                .unwrap()
10344                .0,
10345            Status::RESET_POWER_ON.0
10346        );
10347        assert_eq!(queue_count(&mut session), 1);
10348        let drained = drain(&mut session, 1_000);
10349        assert_eq!(
10350            drained[0].0, frame,
10351            "the queue holds the original wire bytes"
10352        );
10353        assert_eq!(drained[0].1.flags, RX_FLAG_BUFFERED | RX_FLAG_ACKED);
10354    }
10355
10356    #[test]
10357    fn buar_success_and_missing_channel_key() {
10358        let channel_key = [0x42; 32];
10359        let mut session = auto_ack_session();
10360        // Without the channel key the frame does not even pass
10361        // filtering (its destination hint is concealed).
10362        let frame = sealed_buar(7, &channel_key);
10363        assert!(rx_effect(&mut session, &frame, 0).is_none());
10364        session.attach(true);
10365        assert_eq!(queue_count(&mut session), 0);
10366
10367        // With the channel key provisioned it is accepted,
10368        // authenticated with the combined blind keys, and acked.
10369        install_channel_key(&mut session, &channel_key);
10370        session.detach();
10371        let effect = rx_effect(&mut session, &frame, 0);
10372        expect_ack_transmit(&mut session, effect, None);
10373        session.attach(true);
10374        assert_eq!(queue_count(&mut session), 1);
10375        let drained = drain(&mut session, 0);
10376        assert_eq!(drained[0].1.flags, RX_FLAG_BUFFERED | RX_FLAG_ACKED);
10377    }
10378
10379    #[test]
10380    fn unprovisioned_source_and_bad_mic_queue_unacked() {
10381        let mut session = auto_ack_session();
10382
10383        // Sealed with keys the device does not hold: authentication fails,
10384        // but filtering accepted it (host destination hint), so it is
10385        // queued for the host — unacknowledged.
10386        let wrong_keys = PairwiseKeys {
10387            k_enc: [1; 32],
10388            k_mic: [2; 32],
10389        };
10390        assert!(rx_effect(&mut session, &sealed_unar(5, &wrong_keys, false), 0).is_none());
10391
10392        // A corrupted MIC likewise fails closed without an ack and
10393        // without disturbing the peer's replay baseline.
10394        let mut corrupted = sealed_unar(6, &test_pairwise(), false);
10395        let last = corrupted.len() - 1;
10396        corrupted[last] ^= 0xFF;
10397        assert!(rx_effect(&mut session, &corrupted, 0).is_none());
10398
10399        // First-contact baseline is unset: an early counter still
10400        // authenticates and establishes the baseline at face value.
10401        let effect = rx_effect(&mut session, &sealed_unar(1, &test_pairwise(), false), 0);
10402        expect_ack_transmit(&mut session, effect, None);
10403
10404        session.attach(true);
10405        assert_eq!(queue_count(&mut session), 3);
10406        let drained = drain(&mut session, 0);
10407        assert_eq!(drained[0].1.flags, RX_FLAG_BUFFERED);
10408        assert_eq!(drained[1].1.flags, RX_FLAG_BUFFERED);
10409        assert_eq!(drained[2].1.flags, RX_FLAG_BUFFERED | RX_FLAG_ACKED);
10410    }
10411
10412    #[test]
10413    fn ambiguous_source_hint_is_never_acked_but_full_key_resolves() {
10414        let mut session = auto_ack_session();
10415        // A second provisioned peer shares the 3-byte prefix (key
10416        // writes need the secure attached link).
10417        let mut twin = PEER_PUB;
10418        twin[31] ^= 0xFF;
10419        session.attach(true);
10420        insert_item(
10421            &mut session,
10422            prop::HOST_PEER_KEYS,
10423            &peer_item(&twin, &test_pairwise()),
10424        );
10425        session.detach();
10426
10427        // Hint form: ambiguous, does not resolve, no ack.
10428        assert!(rx_effect(&mut session, &sealed_unar(4, &test_pairwise(), false), 0).is_none());
10429
10430        // Full-key form (S flag): resolves and acks.
10431        let effect = rx_effect(&mut session, &sealed_unar(4, &test_pairwise(), true), 0);
10432        expect_ack_transmit(&mut session, effect, None);
10433    }
10434
10435    #[test]
10436    fn duplicates_coalesce_and_reack_only_within_window() {
10437        let mut session = auto_ack_session();
10438        let keys = test_pairwise();
10439
10440        let first = sealed_unar(5, &keys, false);
10441        let effect = rx_effect(&mut session, &first, 0);
10442        expect_ack_transmit(
10443            &mut session,
10444            effect,
10445            Some(expected_ack_trailer(&first, &keys)),
10446        );
10447
10448        // Exact retransmission, past the re-ack holdoff: coalesced (no
10449        // new entry) and re-acked.
10450        let effect = rx_effect(&mut session, &first, 10_000);
10451        expect_ack_transmit(
10452            &mut session,
10453            effect,
10454            Some(expected_ack_trailer(&first, &keys)),
10455        );
10456
10457        // Advance the baseline well past the re-ack window.
10458        for counter in 6..=14 {
10459            let effect = rx_effect(&mut session, &sealed_unar(counter, &keys, false), 20_000);
10460            expect_ack_transmit(&mut session, effect, None);
10461        }
10462        // counter 5 is now 9 behind: MUST NOT be acknowledged.
10463        assert!(rx_effect(&mut session, &first, 30_000).is_none());
10464
10465        // The re-ack did not advance the baseline: the next counter is
10466        // still accepted normally.
10467        let effect = rx_effect(&mut session, &sealed_unar(15, &keys, false), 40_000);
10468        expect_ack_transmit(&mut session, effect, None);
10469
10470        session.attach(true);
10471        // 5, 6..=14, the out-of-window copy of 5, and 15: the exact
10472        // duplicate of 5 consumed no slot.
10473        assert_eq!(queue_count(&mut session), 12);
10474    }
10475
10476    #[test]
10477    fn attached_host_suppresses_delegation() {
10478        let mut session = auto_ack_session();
10479        session.attach(true);
10480        let frame = sealed_unar(5, &test_pairwise(), false);
10481        let mut emitted = Vec::new();
10482        let effect = session.on_radio_rx(
10483            &frame,
10484            &RadioRxInfo::measured(-80, 40, None),
10485            0,
10486            &mut |bytes: &[u8]| emitted.push(bytes.to_vec()),
10487        );
10488        // Delivered live, never acknowledged on the host's behalf.
10489        assert!(effect.is_none());
10490        assert_eq!(emitted.len(), 1);
10491    }
10492
10493    #[test]
10494    fn auto_ack_disabled_and_duty_limit_leave_frames_unacked() {
10495        let mut session = auto_ack_session();
10496        session.attach(true);
10497        set(&mut session, prop::HOST_AUTO_ACK, &[0]);
10498        session.detach();
10499        assert!(rx_effect(&mut session, &sealed_unar(5, &test_pairwise(), false), 0).is_none());
10500
10501        // Re-enable delegation but exhaust the duty budget: the ack is
10502        // prohibited and the frame stays queued unacked.
10503        session.attach(true);
10504        set(&mut session, prop::HOST_AUTO_ACK, &[1]);
10505        set(&mut session, prop::PHY_DUTY_LIMIT, &0u16.to_le_bytes());
10506        session.detach();
10507        assert!(rx_effect(&mut session, &sealed_unar(6, &test_pairwise(), false), 0).is_none());
10508
10509        session.attach(true);
10510        assert_eq!(queue_count(&mut session), 2);
10511        for (_, meta) in drain(&mut session, 0) {
10512            assert_eq!(meta.flags, RX_FLAG_BUFFERED);
10513        }
10514    }
10515
10516    #[test]
10517    fn auto_ack_property_round_trips_and_resets() {
10518        let mut session = test_session();
10519        assert_eq!(get(&mut session, prop::HOST_AUTO_ACK), [0]);
10520        set(&mut session, prop::HOST_AUTO_ACK, &[1]);
10521        assert_eq!(get(&mut session, prop::HOST_AUTO_ACK), [1]);
10522
10523        // Survives attach; cleared by host replacement.
10524        session.attach(true);
10525        assert_eq!(get(&mut session, prop::HOST_AUTO_ACK), [1]);
10526        install_host_key(&mut session, &[0xBB; 32]);
10527        assert_eq!(get(&mut session, prop::HOST_AUTO_ACK), [0]);
10528
10529        let (emitted, _) = set(&mut session, prop::HOST_AUTO_ACK, &[2]);
10530        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
10531    }
10532
10533    #[test]
10534    fn peer_key_replacement_preserves_replay_baseline() {
10535        let mut session = auto_ack_session();
10536        let old_keys = test_pairwise();
10537        let effect = rx_effect(&mut session, &sealed_unar(5, &old_keys, false), 0);
10538        expect_ack_transmit(&mut session, effect, None);
10539
10540        // Replace the peer's key material (secure link required).
10541        session.attach(true);
10542        let new_keys = PairwiseKeys {
10543            k_enc: [0x77; 32],
10544            k_mic: [0x78; 32],
10545        };
10546        insert_item(
10547            &mut session,
10548            prop::HOST_PEER_KEYS,
10549            &peer_item(&PEER_PUB, &new_keys),
10550        );
10551        session.detach();
10552
10553        // The baseline survived the replacement: a fresh frame reusing
10554        // counter 5 under the new keys is a suspected replay and is
10555        // not acknowledged, while counter 6 proceeds normally.
10556        assert!(rx_effect(&mut session, &sealed_unar(5, &new_keys, false), 10).is_none());
10557        let effect = rx_effect(&mut session, &sealed_unar(6, &new_keys, false), 20);
10558        expect_ack_transmit(&mut session, effect, None);
10559    }
10560
10561    // ─── CAP_SAVE gate ───────────────────────────────────────────────
10562
10563    /// Re-encode a snapshot with the named options left out, standing in
10564    /// for one written by a firmware that did not have them yet.
10565    fn strip_snapshot_options(bytes: &[u8], drop: &[u32]) -> Vec<u8> {
10566        let (format, options) = bytes.split_first().unwrap();
10567        let mut out = vec![0u8; bytes.len()];
10568        out[0] = *format;
10569        let mut encoder = OptionEncoder::new(&mut out[1..]);
10570        for item in OptionDecoder::new(options) {
10571            let (number, value) = item.unwrap();
10572            if drop.contains(&u32::from(number)) {
10573                continue;
10574            }
10575            encoder.put(number, value).unwrap();
10576        }
10577        let len = 1 + encoder.finish();
10578        out.truncate(len);
10579        out
10580    }
10581
10582    /// Issue CMD_SAVE and complete the durable write successfully.
10583    fn save(session: &mut TestSession) {
10584        let mut buf = [0u8; 4];
10585        let len = frame::save(&mut buf, 3).unwrap();
10586        let (emitted, effect) = dispatch(session, &buf[..len], 0);
10587        assert!(emitted.is_empty(), "no response before the write commits");
10588        assert_eq!(effect, Some(Effect::SaveSnapshot { tid: 3 }));
10589        let mut emitted = Vec::new();
10590        session.respond_save(3, Ok(()), &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
10591        expect_status(&emitted[0], 3, Status::OK);
10592    }
10593
10594    /// Issue CMD_RESTORE, expecting the reset completion form.
10595    fn restore(session: &mut TestSession) -> Option<Effect> {
10596        let mut buf = [0u8; 4];
10597        let len = frame::restore(&mut buf, 5).unwrap();
10598        let (emitted, effect) = dispatch(session, &buf[..len], 0);
10599        expect_status(&emitted[0], TID_UNSOLICITED, Status::RESET_RESTORED);
10600        effect
10601    }
10602
10603    /// A provisioned session worth saving: PHY enabled on a custom
10604    /// frequency, custom name, host key, one filter, channel key, peer.
10605    fn provisioned_session() -> TestSession {
10606        let mut session = test_session();
10607        set(&mut session, prop::PHY_FREQ, &906_875u32.to_le_bytes());
10608        enable(&mut session);
10609        set(&mut session, prop::DEV_NAME, b"saved name");
10610        install_host_key(&mut session, &HOST_PUB);
10611        insert_item(
10612            &mut session,
10613            prop::HOST_RX_FILTERS,
10614            &[items::FILTER_PKT_TYPE, 0],
10615        );
10616        install_channel_key(&mut session, &[0x42; 32]);
10617        insert_item(
10618            &mut session,
10619            prop::HOST_PEER_KEYS,
10620            &peer_item(&PEER_PUB, &test_pairwise()),
10621        );
10622        set(&mut session, prop::HOST_AUTO_ACK, &[1]);
10623        session
10624    }
10625
10626    #[test]
10627    fn save_sets_prop_saved_and_failure_rolls_back() {
10628        let mut session = test_session();
10629        assert_eq!(get(&mut session, prop::SAVED), [0]);
10630
10631        // A failed durable write leaves nothing saved.
10632        let mut buf = [0u8; 4];
10633        let len = frame::save(&mut buf, 3).unwrap();
10634        let (_, effect) = dispatch(&mut session, &buf[..len], 0);
10635        assert_eq!(effect, Some(Effect::SaveSnapshot { tid: 3 }));
10636        let mut emitted = Vec::new();
10637        session.respond_save(3, Err(()), &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
10638        expect_status(&emitted[0], 3, Status::FAILURE);
10639        assert_eq!(get(&mut session, prop::SAVED), [0]);
10640
10641        save(&mut session);
10642        assert_eq!(get(&mut session, prop::SAVED), [1]);
10643
10644        // PROP_SAVED is read-only.
10645        let (emitted, _) = set(&mut session, prop::SAVED, &[0]);
10646        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
10647    }
10648
10649    #[test]
10650    fn restore_without_snapshot_is_invalid_state() {
10651        let mut session = test_session();
10652        let mut buf = [0u8; 4];
10653        let len = frame::restore(&mut buf, 5).unwrap();
10654        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
10655        assert!(effect.is_none());
10656        expect_status(&emitted[0], 5, Status::INVALID_STATE);
10657    }
10658
10659    #[test]
10660    fn snapshot_round_trips_through_the_wire_encoding() {
10661        let session = provisioned_session();
10662        let mut bytes = [0u8; SNAPSHOT_MAX];
10663        let len = session.encode_snapshot(&mut bytes).unwrap();
10664
10665        // A fresh session boots from those bytes into the saved
10666        // configuration, with the PHY re-enabled, before any host
10667        // command.
10668        let mut booted: TestSession =
10669            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
10670        let effect = booted.restore_at_boot(&bytes[..len]).unwrap();
10671        assert!(matches!(effect, Effect::ApplyRadio(s) if s.enabled && s.freq_khz == 906_875));
10672        assert_eq!(booted.device_name(), "saved name");
10673        // The device domain came back; the host domain did not, and a
10674        // multicast on the host's saved channel is no longer accepted
10675        // because nothing was provisioned to accept it.
10676        let id = test_engine().derive_channel_id(&ChannelKey([0x42; 32])).0;
10677        booted.on_radio_rx(
10678            &multicast_on(id),
10679            &RadioRxInfo::measured(-80, 40, None),
10680            0,
10681            &mut |_: &[u8]| panic!("detached boot must not emit"),
10682        );
10683        booted.attach(true);
10684        assert_eq!(get(&mut booted, prop::SAVED), [ids::saved::CURRENT]);
10685        assert_eq!(get(&mut booted, prop::HOST_KEY), Vec::<u8>::new());
10686        assert_eq!(get(&mut booted, prop::HOST_AUTO_ACK), [0]);
10687        // With no host key and no filters the domain is unprovisioned,
10688        // which filters nothing — the frame is queued, but for nobody in
10689        // particular, and it was never acknowledged on anyone's behalf.
10690        assert_eq!(queue_count(&mut booted), 1);
10691
10692        // A truncated payload and a foreign format byte are both
10693        // rejected, and rejection is reported rather than looking like
10694        // "nothing saved".
10695        let mut fresh: TestSession =
10696            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
10697        assert_eq!(
10698            fresh.restore_at_boot(&bytes[..len - 1]),
10699            Err(SnapshotError::Malformed)
10700        );
10701        let mut wrong_format = bytes;
10702        wrong_format[0] = SNAPSHOT_FORMAT + 1;
10703        assert_eq!(
10704            fresh.restore_at_boot(&wrong_format[..len]),
10705            Err(SnapshotError::UnknownFormat)
10706        );
10707        // The retired positional format is rejected too, even though its
10708        // leading 0x03 reads as a well-formed option header.
10709        let mut legacy = bytes;
10710        legacy[0] = 3;
10711        assert_eq!(
10712            fresh.restore_at_boot(&legacy[..len]),
10713            Err(SnapshotError::UnknownFormat)
10714        );
10715        fresh.attach(true);
10716        assert_eq!(get(&mut fresh, prop::SAVED), [ids::saved::NONE]);
10717        fresh.note_snapshot_rejected();
10718        assert_eq!(get(&mut fresh, prop::SAVED), [ids::saved::UNREADABLE]);
10719    }
10720
10721    /// The receiver comes back up as it was left, and so does the whole
10722    /// positioning policy. `PROP_TIME` deliberately does not: an epoch
10723    /// written to flash accumulates unbounded error while the device is
10724    /// off, so the clock is restored from a real time source or not at
10725    /// all.
10726    #[test]
10727    fn positioning_settings_survive_a_save_and_the_clock_does_not() {
10728        let mut session = test_session();
10729        set(&mut session, prop::GNSS_ENABLED, &[1]);
10730        set(&mut session, prop::TZ_OFFSET, &(-300i16).to_le_bytes());
10731        set(&mut session, prop::GNSS_IDENT_UPDATE, &[1]);
10732        set(&mut session, prop::GNSS_IDENT_PRECISION, &[6]);
10733        set(&mut session, prop::GNSS_TIME_TRUST, &[0]);
10734        set(&mut session, prop::TIME, &1_780_000_000u32.to_le_bytes());
10735
10736        let mut bytes = [0u8; SNAPSHOT_MAX];
10737        let len = session.encode_snapshot(&mut bytes).unwrap();
10738
10739        let mut booted: TestSession =
10740            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
10741        booted.restore_at_boot(&bytes[..len]).unwrap();
10742        assert!(booted.gnss_enabled());
10743        assert_eq!(booted.tz_offset_min(), -300);
10744        assert!(booted.gnss_ident_update());
10745        assert_eq!(booted.gnss_ident_precision(), 6);
10746        assert!(!booted.gnss_time_trust());
10747        booted.attach(true);
10748        assert_eq!(get(&mut booted, prop::GNSS_ENABLED), [1]);
10749        assert_eq!(get(&mut booted, prop::GNSS_TIME_TRUST), [0]);
10750        // The clock is the platform's; a restore has nothing to say about
10751        // it, so a get still defers.
10752        let mut buf = [0u8; 16];
10753        let get_len = frame::prop_get(&mut buf, 3, prop::TIME).unwrap();
10754        let (_, effect) = dispatch(&mut booted, &buf[..get_len], 0);
10755        assert_eq!(effect, Some(Effect::ReadTime { tid: 3 }));
10756
10757        // A reset reverts to the saved snapshot rather than to the
10758        // factory values, so the receiver stays on across it.
10759        let mut out = Vec::new();
10760        booted.reset(Status::RESET_SOFTWARE, &mut |bytes: &[u8]| {
10761            out.push(bytes.to_vec())
10762        });
10763        assert!(booted.gnss_enabled());
10764        assert_eq!(booted.tz_offset_min(), -300);
10765    }
10766
10767    /// Restoring a saved repeater domain onto different hardware must
10768    /// not put the PHY on the air under the replacement's throwaway
10769    /// identity. Everything else restores; only the enable is withheld,
10770    /// and installing the expected identity first makes it stick.
10771    #[test]
10772    fn restore_under_a_different_identity_leaves_the_phy_disabled() {
10773        let mut session = provisioned_session();
10774        session.set_boot_identity([0xAA; 32]);
10775        let mut bytes = [0u8; SNAPSHOT_MAX];
10776        let len = session.encode_snapshot(&mut bytes).unwrap();
10777
10778        // Replacement hardware: a different auto-generated identity.
10779        let mut replacement: TestSession =
10780            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
10781        replacement.set_boot_identity([0xBB; 32]);
10782        let effect = replacement.restore_at_boot(&bytes[..len]).unwrap();
10783        assert!(matches!(effect, Effect::ApplyRadio(s) if !s.enabled));
10784        // The rest of the domain did restore.
10785        assert_eq!(replacement.device_name(), "saved name");
10786        assert_eq!(replacement.settings().freq_khz, 906_875);
10787
10788        // With the expected identity installed, the same snapshot
10789        // restores the enable too.
10790        let mut same: TestSession =
10791            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
10792        same.set_boot_identity([0xAA; 32]);
10793        let effect = same.restore_at_boot(&bytes[..len]).unwrap();
10794        assert!(matches!(effect, Effect::ApplyRadio(s) if s.enabled));
10795    }
10796
10797    /// Apply order is schema metadata, not the identifier ordering the
10798    /// option encoder imposes. Both constraints are real and they
10799    /// disagree: `PROP_PHY_ENABLED` sorts first by number and must apply
10800    /// last.
10801    #[test]
10802    fn saved_schema_orders_by_identifier_and_applies_by_phase() {
10803        assert!(
10804            SAVED_SCHEMA
10805                .windows(2)
10806                .all(|pair| pair[0].number < pair[1].number),
10807            "the option encoder rejects a number below the last one written"
10808        );
10809        let enable = SAVED_SCHEMA
10810            .iter()
10811            .find(|entry| u32::from(entry.number) == prop::PHY_ENABLED)
10812            .expect("PHY_ENABLED is saved");
10813        assert_eq!(enable.phase, ApplyPhase::Enable);
10814        assert_eq!(*ApplyPhase::ORDER.last().unwrap(), ApplyPhase::Enable);
10815        assert!(
10816            SAVED_SCHEMA
10817                .iter()
10818                .filter(|entry| entry.phase == ApplyPhase::Enable)
10819                .count()
10820                == 1,
10821            "only the PHY enable belongs in the last phase"
10822        );
10823        // And the numeric order really would get it wrong.
10824        assert!(u32::from(enable.number) < prop::PHY_FREQ);
10825    }
10826
10827    /// Absent options take documented defaults, which is the whole of
10828    /// forward compatibility (decision 13): a snapshot written by a
10829    /// build that did not know a property must not corrupt it.
10830    #[test]
10831    fn absent_options_decode_to_post_reset_defaults() {
10832        let mut bare = [0u8; SNAPSHOT_MAX];
10833        bare[0] = SNAPSHOT_FORMAT;
10834        let mut booted: TestSession =
10835            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
10836        let effect = booted.restore_at_boot(&bare[..1]).unwrap();
10837        let defaults = test_config().defaults;
10838        assert!(matches!(
10839            effect,
10840            Effect::ApplyRadio(s) if !s.enabled
10841                && s.freq_khz == defaults.freq_khz
10842                && s.sf == defaults.sf
10843        ));
10844        assert_eq!(booted.device_name(), test_config().default_device_name);
10845        booted.attach(true);
10846        assert_eq!(get(&mut booted, prop::SAVED), [ids::saved::CURRENT]);
10847        assert_eq!(get(&mut booted, prop::MAC_REPEATER_ENABLED), [0]);
10848        assert_eq!(get(&mut booted, prop::HOST_KEY), [] as [u8; 0]);
10849    }
10850
10851    /// Unknown option numbers are skipped rather than rejected: a newer
10852    /// writer's property, or one this build has retired, must not take
10853    /// the device to a bare boot.
10854    #[test]
10855    fn unknown_options_are_skipped() {
10856        let session = provisioned_session();
10857        let mut bytes = [0u8; SNAPSHOT_MAX];
10858        let len = session.encode_snapshot(&mut bytes).unwrap();
10859        // Append an option numbered above every allocated property.
10860        let mut extended = bytes;
10861        let mut encoder =
10862            OptionEncoder::with_last_number(&mut extended[len..], prop::PHY_DUTY_LIMIT as u16);
10863        encoder.put(60000, &[1, 2, 3]).unwrap();
10864        let extra = encoder.finish();
10865
10866        let mut booted: TestSession =
10867            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
10868        booted.restore_at_boot(&extended[..len + extra]).unwrap();
10869        assert_eq!(booted.device_name(), "saved name");
10870    }
10871
10872    /// A single-valued property appearing twice is corruption, not a
10873    /// last-writer-wins update.
10874    #[test]
10875    fn a_repeated_single_valued_property_is_rejected() {
10876        let mut bytes = [0u8; SNAPSHOT_MAX];
10877        bytes[0] = SNAPSHOT_FORMAT;
10878        let mut encoder = OptionEncoder::new(&mut bytes[1..]);
10879        encoder.put(prop::PHY_LORA_SF as u16, &[9]).unwrap();
10880        encoder.put(prop::PHY_LORA_SF as u16, &[10]).unwrap();
10881        let len = 1 + encoder.finish();
10882
10883        let mut booted: TestSession =
10884            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
10885        assert_eq!(
10886            booted.restore_at_boot(&bytes[..len]),
10887            Err(SnapshotError::Malformed)
10888        );
10889    }
10890
10891    /// Snapshot values pass the same validators a host write does, so a
10892    /// corrupted or foreign snapshot cannot install a setting the
10893    /// property surface would refuse.
10894    #[test]
10895    fn out_of_range_snapshot_values_are_rejected() {
10896        for (number, value) in [
10897            (prop::PHY_LORA_SF, &[13u8][..]),
10898            (prop::PHY_LORA_CR, &[4][..]),
10899            (prop::PHY_LORA_BW, &7_000u32.to_le_bytes()[..]),
10900            (prop::PHY_ENABLED, &[2][..]),
10901            (prop::DEV_NAME, &[][..]),
10902        ] {
10903            let mut bytes = [0u8; SNAPSHOT_MAX];
10904            bytes[0] = SNAPSHOT_FORMAT;
10905            let mut encoder = OptionEncoder::new(&mut bytes[1..]);
10906            encoder.put(number as u16, value).unwrap();
10907            let len = 1 + encoder.finish();
10908
10909            let mut booted: TestSession =
10910                Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
10911            assert_eq!(
10912                booted.restore_at_boot(&bytes[..len]),
10913                Err(SnapshotError::InvalidValue),
10914                "property {number} accepted an out-of-range value"
10915            );
10916        }
10917    }
10918
10919    /// The buffer every caller sizes from `SNAPSHOT_MAX` has to hold a
10920    /// snapshot with every table full — the option framing costs about
10921    /// two octets an entry over the retired positional format.
10922    #[test]
10923    fn snapshot_at_capacity_fits_the_buffer() {
10924        let mut session = provisioned_session();
10925        for seed in 0..MAX_CHANNEL_KEYS as u8 {
10926            let _ = session.device.channel_keys.insert(ChannelKeyEntry {
10927                key: [seed; items::CHANNEL_KEY_LEN],
10928                id: [seed, seed],
10929            });
10930        }
10931        for seed in 0..MAX_DEV_PEERS as u8 {
10932            let _ = session.device.peers.insert([seed; items::PUBLIC_KEY_LEN]);
10933        }
10934        for seed in 0..MAX_DEV_ADMINS as u8 {
10935            let _ = session.device.admins.insert([seed; items::PUBLIC_KEY_LEN]);
10936        }
10937        for seed in 0..MAX_REPEATER_REGIONS as u8 {
10938            // Distinct, and each the longest a region string may be.
10939            let mut text = [b'r'; REGION_STRING_MAX_LEN];
10940            text[0] = b'a' + seed;
10941            let _ = session
10942                .device
10943                .repeater_regions
10944                .push(region_entry(&text).unwrap());
10945        }
10946        session.device.name = [b'n'; MAX_DEVICE_NAME_LEN];
10947        session.device.name_len = MAX_DEVICE_NAME_LEN;
10948        session.set_boot_identity([0xEE; 32]);
10949
10950        let mut bytes = [0u8; SNAPSHOT_MAX];
10951        let len = session
10952            .encode_snapshot(&mut bytes)
10953            .expect("a full snapshot must fit SNAPSHOT_MAX");
10954        assert!(len <= SNAPSHOT_MAX, "worst case is {len} octets");
10955
10956        let mut booted: TestSession =
10957            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
10958        booted.set_boot_identity([0xEE; 32]);
10959        booted.restore_at_boot(&bytes[..len]).unwrap();
10960        assert_eq!(booted.device.channel_keys.len, MAX_CHANNEL_KEYS);
10961        assert_eq!(booted.device.peers.len, MAX_DEV_PEERS);
10962        assert_eq!(booted.device.admins.len, MAX_DEV_ADMINS);
10963    }
10964
10965    #[test]
10966    fn reset_post_reset_values_come_from_the_snapshot() {
10967        let mut session = provisioned_session();
10968        save(&mut session);
10969
10970        // Diverge from the saved configuration, then CMD_RST.
10971        set(&mut session, prop::PHY_FREQ, &915_000u32.to_le_bytes());
10972        set(&mut session, prop::DEV_NAME, b"diverged");
10973        let mut emitted = Vec::new();
10974        let effect = session.reset(Status::RESET_SOFTWARE, &mut |bytes: &[u8]| {
10975            emitted.push(bytes.to_vec())
10976        });
10977        // Post-reset values are the saved ones — including the PHY
10978        // enable state, which comes back up.
10979        assert!(matches!(effect, Effect::ApplyRadio(s) if s.enabled && s.freq_khz == 906_875));
10980        assert_eq!(session.device_name(), "saved name");
10981        // The host domain is not saved, so a reset returns it to
10982        // defaults whatever the snapshot held when it was written.
10983        assert_eq!(get(&mut session, prop::HOST_KEY), Vec::<u8>::new());
10984
10985        // Factory defaults need CMD_CLEAR + CMD_RST.
10986        let mut buf = [0u8; 4];
10987        let len = frame::clear(&mut buf, 4).unwrap();
10988        let (_, effect) = dispatch(&mut session, &buf[..len], 0);
10989        assert_eq!(effect, Some(Effect::ClearSaved { tid: 4 }));
10990        session.respond_clear(4, Ok(()), &mut |_: &[u8]| {});
10991        let effect = session.reset(Status::RESET_SOFTWARE, &mut |_: &[u8]| {});
10992        assert!(matches!(effect, Effect::ApplyRadio(s) if !s.enabled && s.freq_khz == 910_525));
10993        assert_eq!(session.device_name(), "Test UMSH Device");
10994        assert_eq!(get(&mut session, prop::HOST_KEY), Vec::<u8>::new());
10995    }
10996
10997    #[test]
10998    fn restore_reverts_config_but_preserves_queue_and_baselines() {
10999        let mut session = provisioned_session();
11000        save(&mut session);
11001
11002        // Accumulate dynamic state: a queued frame and an advanced
11003        // replay baseline (counter 5 acknowledged).
11004        session.detach();
11005        let first = sealed_unar(5, &test_pairwise(), false);
11006        let effect = rx_effect(&mut session, &first, 0);
11007        expect_ack_transmit(&mut session, effect, None);
11008        session.attach(true);
11009        assert_eq!(queue_count(&mut session), 1);
11010
11011        // Diverge the configuration, in both domains.
11012        set(&mut session, prop::PHY_DUTY_LIMIT, &77u16.to_le_bytes());
11013        insert_item(
11014            &mut session,
11015            prop::HOST_RX_FILTERS,
11016            &[items::FILTER_DEST_HINT, 9, 9, 9],
11017        );
11018
11019        // Restore (reset form): device-domain configuration reverts, the
11020        // radio is re-applied, and the queue survives.
11021        let effect = restore(&mut session);
11022        assert!(matches!(effect, Some(Effect::ApplyRadio(s)) if s.enabled));
11023        assert_eq!(
11024            get(&mut session, prop::PHY_DUTY_LIMIT),
11025            0xFFFFu16.to_le_bytes()
11026        );
11027        // Host-domain state is outside the snapshot, so the added filter
11028        // is still there: a restore has nothing to revert it to.
11029        let filters = get(&mut session, prop::HOST_RX_FILTERS);
11030        assert_eq!(
11031            filters,
11032            [2, items::FILTER_PKT_TYPE, 0, 4, 0, 9, 9, 9],
11033            "host domain untouched by a restore"
11034        );
11035        assert_eq!(queue_count(&mut session), 1);
11036
11037        // The replay baseline survived too: replaying the pre-restore
11038        // frame (past the re-ack holdoff) is still an identified
11039        // duplicate (coalesced, re-acked), not a first-contact
11040        // acceptance.
11041        session.detach();
11042        let effect = rx_effect(&mut session, &first, 10_000);
11043        expect_ack_transmit(&mut session, effect, None);
11044        session.attach(true);
11045        assert_eq!(queue_count(&mut session), 1);
11046    }
11047
11048    /// The host domain is outside the snapshot, so a restore leaves it
11049    /// alone entirely: no host-key special case, and queued traffic and
11050    /// replay baselines survive unconditionally.
11051    #[test]
11052    fn restore_leaves_the_host_domain_untouched() {
11053        let mut session = provisioned_session();
11054        save(&mut session);
11055
11056        // A different host takes over after the save and queues
11057        // detached traffic.
11058        install_host_key(&mut session, &[0xBB; 32]);
11059        assert_eq!(get(&mut session, prop::SAVED), [ids::saved::CURRENT]);
11060        session.detach();
11061        assert!(!delivered_at(
11062            &mut session,
11063            &unicast_to([0xBB, 0xBB, 0xBB]),
11064            0
11065        ));
11066        session.attach(true);
11067        assert_eq!(queue_count(&mut session), 1);
11068
11069        let effect = restore(&mut session);
11070        assert!(matches!(effect, Some(Effect::ApplyRadio(_))));
11071        // Device domain reverts from the snapshot...
11072        assert_eq!(session.device_name(), "saved name");
11073        // ...and the current host keeps its key and its queue.
11074        assert_eq!(get(&mut session, prop::HOST_KEY), [0xBB; 32]);
11075        assert_eq!(queue_count(&mut session), 1);
11076    }
11077
11078    /// The host domain is not persisted, so a reboot forgets it while
11079    /// the device domain comes back intact. This is what makes host
11080    /// provisioning a per-attach concern rather than a durable one.
11081    #[test]
11082    fn a_saved_snapshot_carries_no_host_domain_across_a_reboot() {
11083        let mut session = provisioned_session();
11084        save(&mut session);
11085        let mut bytes = [0u8; SNAPSHOT_MAX];
11086        let len = session.encode_snapshot(&mut bytes).unwrap();
11087
11088        let mut booted: TestSession =
11089            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
11090        booted.restore_at_boot(&bytes[..len]).unwrap();
11091        booted.attach(true);
11092        assert_eq!(booted.device_name(), "saved name");
11093        assert_eq!(get(&mut booted, prop::HOST_KEY), Vec::<u8>::new());
11094        assert!(get(&mut booted, prop::HOST_CHANNEL_KEYS).is_empty());
11095        assert!(get(&mut booted, prop::HOST_PEER_KEYS).is_empty());
11096        assert!(get(&mut booted, prop::HOST_RX_FILTERS).is_empty());
11097        assert_eq!(get(&mut booted, prop::HOST_AUTO_ACK), [0]);
11098    }
11099
11100    /// Re-provisioning a peer that is already present must not restart
11101    /// its replay baseline: decision 6 has the host re-assert its whole
11102    /// table on every attach, many times a day, where the documented
11103    /// resynchronization path assumes reboot frequency.
11104    #[test]
11105    fn a_whole_table_peer_set_reconciles_rather_than_rebuilding() {
11106        let mut session = test_session();
11107        enable(&mut session);
11108        install_host_key(&mut session, &HOST_PUB);
11109        install_channel_key(&mut session, &[0x42; 32]);
11110        set(
11111            &mut session,
11112            prop::HOST_PEER_KEYS,
11113            &peer_item(&PEER_PUB, &test_pairwise()),
11114        );
11115        set(&mut session, prop::HOST_AUTO_ACK, &[1]);
11116
11117        // Establish a replay baseline for PEER_PUB.
11118        session.detach();
11119        let frame = sealed_unar(9, &test_pairwise(), false);
11120        let effect = rx_effect(&mut session, &frame, 0);
11121        expect_ack_transmit(&mut session, effect, None);
11122        session.attach(true);
11123        assert_eq!(queue_count(&mut session), 1);
11124        let _ = drain(&mut session, 0);
11125
11126        // Re-assert the same table plus a second peer, exactly as an
11127        // attaching host does. The existing peer keeps its window, so
11128        // the replayed frame is still an identified duplicate.
11129        let mut table = peer_item(&PEER_PUB, &test_pairwise()).to_vec();
11130        table.extend_from_slice(&peer_item(&[0x77; 32], &test_pairwise()));
11131        set(&mut session, prop::HOST_PEER_KEYS, &table);
11132        assert_eq!(get(&mut session, prop::HOST_PEER_KEYS).len(), 64);
11133
11134        session.detach();
11135        let effect = rx_effect(&mut session, &frame, 10_000);
11136        expect_ack_transmit(&mut session, effect, None);
11137        session.attach(true);
11138        assert_eq!(
11139            queue_count(&mut session),
11140            0,
11141            "a preserved baseline coalesces the replay instead of queueing it again"
11142        );
11143
11144        // Omitting a peer still removes it: reconcile replaces the entry
11145        // set even though it preserves per-entry state.
11146        set(
11147            &mut session,
11148            prop::HOST_PEER_KEYS,
11149            &peer_item(&[0x77; 32], &test_pairwise()),
11150        );
11151        assert_eq!(get(&mut session, prop::HOST_PEER_KEYS), [0x77; 32]);
11152    }
11153
11154    // ─── Review-fix regressions: ack confirmation, flood return, ─────
11155    // ─── multicast coalescing, TID-zero semantics ────────────────────
11156
11157    /// Drain and return each entry's RX_FLAGS.
11158    fn drained_flags(session: &mut TestSession) -> Vec<u8> {
11159        drain(session, 0)
11160            .into_iter()
11161            .map(|(_, meta)| meta.flags)
11162            .collect()
11163    }
11164
11165    #[test]
11166    fn ack_flag_requires_confirmed_transmission() {
11167        let mut session = auto_ack_session();
11168        let keys = test_pairwise();
11169        let frame = sealed_unar(5, &keys, false);
11170
11171        // The ack is staged but the radio transmission fails: the entry
11172        // must not claim an ack that never went out.
11173        let effect = rx_effect(&mut session, &frame, 0);
11174        assert_eq!(effect, Some(Effect::StartTransmit));
11175        session.on_tx_result(TxOutcome::Failed, 0, &mut |_: &[u8]| {
11176            panic!("autonomous ack is silent")
11177        });
11178
11179        // The sender retransmits past the re-ack holdoff; the duplicate
11180        // re-ack completes, which marks the original (still queued,
11181        // still unacked) entry.
11182        let effect = rx_effect(&mut session, &frame, 10_000);
11183        assert_eq!(
11184            effect,
11185            Some(Effect::StartTransmit),
11186            "re-ack after failed TX"
11187        );
11188        session.on_tx_result(TxOutcome::Sent, 10_000, &mut |_: &[u8]| {
11189            panic!("autonomous ack is silent")
11190        });
11191
11192        session.attach(true);
11193        assert_eq!(queue_count(&mut session), 1, "duplicate coalesced");
11194        assert_eq!(
11195            drained_flags(&mut session),
11196            [RX_FLAG_BUFFERED | RX_FLAG_ACKED]
11197        );
11198    }
11199
11200    #[test]
11201    fn failed_ack_leaves_flag_clear_and_eviction_is_handle_safe() {
11202        let mut session = auto_ack_session();
11203        let keys = test_pairwise();
11204
11205        // Frame 1's ack fails; its entry stays unacked.
11206        let effect = rx_effect(&mut session, &sealed_unar(1, &keys, false), 0);
11207        assert_eq!(effect, Some(Effect::StartTransmit));
11208        session.on_tx_result(TxOutcome::Failed, 0, &mut |_: &[u8]| {});
11209
11210        // Evict frame 1 with newer traffic while an ack for frame 2 is
11211        // in flight, then confirm it: the stale handle for the evicted
11212        // entry must mark nothing, and the confirmed handle must mark
11213        // exactly frame 2's entry even though the queue rotated
11214        // underneath it.
11215        let effect = rx_effect(&mut session, &sealed_unar(2, &keys, false), 0);
11216        assert_eq!(effect, Some(Effect::StartTransmit));
11217        for counter in 3..(2 + RX_QUEUE_CAPACITY as u32) {
11218            // Radio busy: these queue unacked, no effect.
11219            assert!(rx_effect(&mut session, &sealed_unar(counter, &keys, false), 0).is_none());
11220        }
11221        session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {});
11222
11223        session.attach(true);
11224        assert_eq!(queue_count(&mut session), RX_QUEUE_CAPACITY as u16);
11225        let flags = drained_flags(&mut session);
11226        // Frame 1 was evicted; the oldest remaining entry is frame 2 —
11227        // the only acknowledged one.
11228        assert_eq!(flags[0], RX_FLAG_BUFFERED | RX_FLAG_ACKED);
11229        assert!(
11230            flags[1..].iter().all(|flags| *flags == RX_FLAG_BUFFERED),
11231            "no other entry may borrow the confirmation"
11232        );
11233    }
11234
11235    /// Build a sealed UNAR carrying flood-hop state with the given
11236    /// accumulated count. FHOPS is dynamic (excluded from the AAD), so
11237    /// rewriting it after sealing preserves the MIC — exactly as a
11238    /// relaying node would.
11239    fn sealed_flooded_unar(counter: u32, keys: &PairwiseKeys, accumulated: u8) -> Vec<u8> {
11240        let mut buf = [0u8; 96];
11241        let mut packet = PacketBuilder::new(&mut buf)
11242            .unicast(NodeHint([0xC4, 0xC4, 0xC4]))
11243            .source_hint(NodeHint([0x0A, 0x0A, 0x0A]))
11244            .frame_counter(counter)
11245            .ack_requested()
11246            .mic_size(MicSize::Mic8)
11247            .flood_hops(15)
11248            .payload(&[3, 1, 2])
11249            .build()
11250            .unwrap();
11251        test_engine().seal_packet(&mut packet, keys).unwrap();
11252        let mut frame = packet.as_bytes().to_vec();
11253        frame[1] = umsh_core::FloodHops::new(15 - accumulated, accumulated)
11254            .unwrap()
11255            .0;
11256        frame
11257    }
11258
11259    #[test]
11260    fn flooded_traffic_gets_flood_return_acks() {
11261        let mut session = auto_ack_session();
11262        let keys = test_pairwise();
11263
11264        // Direct traffic: direct ack (no FHOPS on the wire).
11265        let effect = rx_effect(&mut session, &sealed_unar(1, &keys, false), 0);
11266        assert_eq!(effect, Some(Effect::StartTransmit));
11267        let header = PacketHeader::parse(session.tx_data()).unwrap();
11268        assert_eq!(header.flood_hops, None);
11269        session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {});
11270
11271        // Flooded traffic: the ack's remaining hops seed from the
11272        // received frame's accumulated count.
11273        for (accumulated, expected_remaining) in [(3u8, 3u8), (0, 1), (15, 15)] {
11274            let frame = sealed_flooded_unar(u32::from(accumulated) + 10, &keys, accumulated);
11275            let effect = rx_effect(&mut session, &frame, 0);
11276            assert_eq!(
11277                effect,
11278                Some(Effect::StartTransmit),
11279                "accumulated={accumulated}"
11280            );
11281            let header = PacketHeader::parse(session.tx_data()).unwrap();
11282            assert_eq!(header.fcf.packet_type(), PacketType::MacAck);
11283            let hops = header.flood_hops.expect("flood-return ack");
11284            assert_eq!(
11285                hops.remaining(),
11286                expected_remaining,
11287                "accumulated={accumulated}"
11288            );
11289            session.on_tx_result(TxOutcome::Sent, 0, &mut |_: &[u8]| {});
11290        }
11291
11292        // A duplicate re-ack routes from the retransmission itself: the
11293        // same logical packet (counter 25, the current baseline)
11294        // arriving past the holdoff with a different accumulated count
11295        // gets a return flood sized for the path it actually took.
11296        let retransmission = sealed_flooded_unar(25, &keys, 7);
11297        let effect = rx_effect(&mut session, &retransmission, 10_000);
11298        assert_eq!(effect, Some(Effect::StartTransmit));
11299        let header = PacketHeader::parse(session.tx_data()).unwrap();
11300        assert_eq!(
11301            header.flood_hops.expect("flood-return re-ack").remaining(),
11302            7
11303        );
11304    }
11305
11306    /// A sealed multicast frame on `channel_key` (channel keys act as
11307    /// the pairwise keys for multicast sealing).
11308    fn sealed_multicast(counter: u32, channel_key: &[u8; 32], fill: u8) -> Vec<u8> {
11309        let engine = test_engine();
11310        let derived = engine.derive_channel_keys(&ChannelKey(*channel_key));
11311        let mut buf = [0u8; 96];
11312        let mut packet = PacketBuilder::new(&mut buf)
11313            .multicast(derived.channel_id)
11314            .source_hint(NodeHint([0x0A, 0x0A, 0x0A]))
11315            .frame_counter(counter)
11316            .mic_size(MicSize::Mic8)
11317            .payload(&[3, fill])
11318            .build()
11319            .unwrap();
11320        let keys = PairwiseKeys {
11321            k_enc: derived.k_enc,
11322            k_mic: derived.k_mic,
11323        };
11324        engine.seal_packet(&mut packet, &keys).unwrap();
11325        packet.as_bytes().to_vec()
11326    }
11327
11328    #[test]
11329    fn authenticated_multicast_duplicates_coalesce_queue_locally() {
11330        let channel_key = [0x42u8; 32];
11331        let mut session = auto_ack_session();
11332        session.attach(true);
11333        install_channel_key(&mut session, &channel_key);
11334        session.detach();
11335
11336        // Exact retransmissions of an authenticated multicast frame
11337        // coalesce; no ack is ever generated for multicast.
11338        let frame = sealed_multicast(9, &channel_key, 0x11);
11339        assert!(rx_effect(&mut session, &frame, 0).is_none());
11340        assert!(rx_effect(&mut session, &frame, 5).is_none());
11341        // Different counter or different content queue separately.
11342        assert!(rx_effect(&mut session, &sealed_multicast(10, &channel_key, 0x11), 0).is_none());
11343        assert!(rx_effect(&mut session, &sealed_multicast(11, &channel_key, 0x22), 0).is_none());
11344
11345        session.attach(true);
11346        assert_eq!(queue_count(&mut session), 3);
11347        for flags in drained_flags(&mut session) {
11348            assert_eq!(flags, RX_FLAG_BUFFERED, "multicast is never acked");
11349        }
11350    }
11351
11352    #[test]
11353    fn unauthenticated_multicast_duplicates_occupy_separate_entries() {
11354        // Accepted via an explicit packet-type filter with no channel
11355        // key: the device cannot authenticate, so no protocol-defined
11356        // duplicate detection applies.
11357        let mut session = test_session();
11358        enable(&mut session);
11359        insert_item(
11360            &mut session,
11361            prop::HOST_RX_FILTERS,
11362            &[items::FILTER_PKT_TYPE, PacketType::Multicast as u8],
11363        );
11364        session.detach();
11365        let frame = sealed_multicast(9, &[0x42; 32], 0x11);
11366        receive_detached(&mut session, &frame, 0);
11367        receive_detached(&mut session, &frame, 0);
11368        session.attach(true);
11369        assert_eq!(queue_count(&mut session), 2);
11370    }
11371
11372    // ─── CAP_DEV_IDENTITY gate ───────────────────────────────────────
11373
11374    /// Derive the public key a firmware would persist for this secret.
11375    fn public_of(secret: &[u8; 32]) -> [u8; 32] {
11376        use umsh_crypto::NodeIdentity;
11377        umsh_crypto::software::SoftwareIdentity::from_secret_bytes(secret)
11378            .public_key()
11379            .0
11380    }
11381
11382    /// Set `PROP_DEV_PRIVATE_KEY` and execute the provisioning effect
11383    /// the way firmware would: derive the keypair, "persist" it, and
11384    /// respond with the public key. Returns that public key.
11385    fn provision_identity(session: &mut TestSession, tid: u8, secret: &[u8; 32]) -> [u8; 32] {
11386        let mut buf = [0u8; 64];
11387        let len = frame::prop_set(&mut buf, tid, prop::DEV_PRIVATE_KEY, secret).unwrap();
11388        let (emitted, effect) = dispatch(session, &buf[..len], 0);
11389        assert!(
11390            emitted.is_empty(),
11391            "no response before the identity is stored"
11392        );
11393        assert_eq!(effect, Some(Effect::ProvisionIdentity { tid }));
11394        let Some(IdentitySource::Install(staged)) = session.identity_request() else {
11395            panic!("staged request must carry the installed secret");
11396        };
11397        assert_eq!(staged, *secret);
11398        let public_key = public_of(&staged);
11399        let mut emitted = Vec::new();
11400        session.respond_identity(tid, Ok(public_key), &mut |bytes: &[u8]| {
11401            emitted.push(bytes.to_vec())
11402        });
11403        let (response_tid, key, value) = parse_prop_is(&emitted[0]);
11404        assert_eq!(response_tid, tid);
11405        assert_eq!(key, prop::DEV_KEY, "success is announced as the public key");
11406        assert_eq!(value, public_key);
11407        public_key
11408    }
11409
11410    #[test]
11411    fn device_identity_provisioning_lifecycle() {
11412        let mut session = test_session();
11413        // Unconfigured: PROP_DEV_KEY is empty, and the write-only
11414        // private key discloses nothing — not even whether one exists.
11415        assert!(get(&mut session, prop::DEV_KEY).is_empty());
11416        let mut buf = [0u8; 16];
11417        let len = frame::prop_get(&mut buf, 4, prop::DEV_PRIVATE_KEY).unwrap();
11418        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
11419        expect_status(&emitted[0], 4, Status::UNIMPLEMENTED);
11420
11421        // PROP_DEV_KEY is read-only.
11422        let (emitted, _) = set(&mut session, prop::DEV_KEY, &[0x55; 32]);
11423        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
11424
11425        // Wrong-size private keys are invalid.
11426        let (emitted, effect) = set(&mut session, prop::DEV_PRIVATE_KEY, &[0x11; 31]);
11427        assert!(effect.is_none());
11428        expect_status(&emitted[0], 2, Status::INVALID_ARGUMENT);
11429
11430        let public_key = provision_identity(&mut session, 7, &[0x11; 32]);
11431        assert_eq!(get(&mut session, prop::DEV_KEY), public_key);
11432
11433        // The identity survives CMD_RST: its post-reset value is the
11434        // persisted one.
11435        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_: &[u8]| {});
11436        assert_eq!(get(&mut session, prop::DEV_KEY), public_key);
11437
11438        // Replacing the identity is permitted; peer list and channel
11439        // keys survive the replacement (they are not derived from it).
11440        insert_item(&mut session, prop::DEV_PEERS, &[0xD0; 32]);
11441        let replaced = provision_identity(&mut session, 3, &[0x22; 32]);
11442        assert_ne!(replaced, public_key);
11443        assert_eq!(get(&mut session, prop::DEV_KEY), replaced);
11444        assert_eq!(get(&mut session, prop::DEV_PEERS), [0xD0; 32]);
11445    }
11446
11447    #[test]
11448    fn identity_generation_stages_and_concurrent_writes_are_busy() {
11449        let mut session = test_session();
11450        // An empty value commands on-device generation.
11451        let (emitted, effect) = set(&mut session, prop::DEV_PRIVATE_KEY, &[]);
11452        assert!(emitted.is_empty());
11453        assert_eq!(effect, Some(Effect::ProvisionIdentity { tid: 2 }));
11454        assert!(matches!(
11455            session.identity_request(),
11456            Some(IdentitySource::Generate)
11457        ));
11458
11459        // A second write while the durable store is in flight is BUSY.
11460        let (emitted, effect) = set(&mut session, prop::DEV_PRIVATE_KEY, &[0x33; 32]);
11461        assert!(effect.is_none());
11462        expect_status(&emitted[0], 2, Status::BUSY);
11463
11464        // The firmware generates the secret itself and reports the
11465        // resulting public key.
11466        let generated = public_of(&[0x5A; 32]);
11467        let mut emitted = Vec::new();
11468        session.respond_identity(2, Ok(generated), &mut |bytes: &[u8]| {
11469            emitted.push(bytes.to_vec())
11470        });
11471        let (_, key, value) = parse_prop_is(&emitted[0]);
11472        assert_eq!(key, prop::DEV_KEY);
11473        assert_eq!(value, generated);
11474        assert!(session.identity_request().is_none());
11475    }
11476
11477    #[test]
11478    fn identity_provisioning_failure_leaves_the_identity_unchanged() {
11479        let mut session = test_session();
11480        let original = provision_identity(&mut session, 7, &[0x11; 32]);
11481
11482        let (_, effect) = set(&mut session, prop::DEV_PRIVATE_KEY, &[0x22; 32]);
11483        assert_eq!(effect, Some(Effect::ProvisionIdentity { tid: 2 }));
11484        let mut emitted = Vec::new();
11485        session.respond_identity(2, Err(()), &mut |bytes: &[u8]| emitted.push(bytes.to_vec()));
11486        expect_status(&emitted[0], 2, Status::FAILURE);
11487        assert_eq!(get(&mut session, prop::DEV_KEY), original);
11488        assert!(session.identity_request().is_none());
11489    }
11490
11491    #[test]
11492    fn identity_and_dev_channel_writes_require_a_secure_link() {
11493        let mut session = test_session();
11494        session.attach(false);
11495
11496        // Installing and generating both count as key provisioning.
11497        for value in [&[0x11u8; 32][..], &[][..]] {
11498            let (emitted, effect) = set(&mut session, prop::DEV_PRIVATE_KEY, value);
11499            assert!(effect.is_none());
11500            expect_status(&emitted[0], 2, Status::INVALID_STATE);
11501        }
11502        let (emitted, _) = insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &[0x42; 32]);
11503        expect_status(&emitted[0], 5, Status::INVALID_STATE);
11504        let (emitted, _) = set(&mut session, prop::DEV_CHANNEL_KEYS, &[0x42; 32]);
11505        expect_status(&emitted[0], 2, Status::INVALID_STATE);
11506
11507        // Peer public keys carry no secret material: no gate, like
11508        // PROP_HOST_KEY itself.
11509        let (emitted, _) = insert_item(&mut session, prop::DEV_PEERS, &[0xD0; 32]);
11510        let (key, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
11511        assert_eq!(key, prop::DEV_PEERS);
11512        assert_eq!(digest, [0xD0; 32]);
11513    }
11514
11515    #[test]
11516    fn dev_channel_keys_and_peers_lifecycle() {
11517        let mut session = test_session();
11518        let dev_channel = [0x66u8; 32];
11519        let expected_id = test_engine().derive_channel_id(&ChannelKey(dev_channel)).0;
11520
11521        // Channel keys report the derived identifier as their digest;
11522        // the key itself is never read back.
11523        let (emitted, _) = insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
11524        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
11525        assert_eq!(digest, expected_id);
11526        assert_eq!(get(&mut session, prop::DEV_CHANNEL_KEYS), expected_id);
11527        let (emitted, _) = insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
11528        expect_status(&emitted[0], 5, Status::ALREADY);
11529
11530        // Peers: digest form is the item itself; duplicates collapse
11531        // on whole-table set and fail an insert.
11532        let (emitted, _) = insert_item(&mut session, prop::DEV_PEERS, &[0xD0; 32]);
11533        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
11534        assert_eq!(digest, [0xD0; 32]);
11535        let (emitted, _) = insert_item(&mut session, prop::DEV_PEERS, &[0xD0; 32]);
11536        expect_status(&emitted[0], 5, Status::ALREADY);
11537        let mut two = Vec::new();
11538        two.extend_from_slice(&[0xD1; 32]);
11539        two.extend_from_slice(&[0xD1; 32]);
11540        let (emitted, _) = set(&mut session, prop::DEV_PEERS, &two);
11541        let (_, key, value) = parse_prop_is(&emitted[0]);
11542        assert_eq!(key, prop::DEV_PEERS);
11543        assert_eq!(value, [0xD1; 32], "duplicate items collapse");
11544
11545        // Remove by full item; a missing item is ITEM_NOT_FOUND.
11546        let (emitted, _) = remove_item(&mut session, prop::DEV_PEERS, &[0xD1; 32]);
11547        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropRemoved, 6);
11548        assert_eq!(digest, [0xD1; 32]);
11549        let (emitted, _) = remove_item(&mut session, prop::DEV_PEERS, &[0xD1; 32]);
11550        expect_status(&emitted[0], 6, Status::ITEM_NOT_FOUND);
11551        let (emitted, _) = remove_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
11552        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropRemoved, 6);
11553        assert_eq!(digest, expected_id);
11554
11555        // Capacity bounds.
11556        for seed in 0..MAX_DEV_PEERS as u8 {
11557            insert_item(&mut session, prop::DEV_PEERS, &[seed; 32]);
11558        }
11559        let (emitted, _) = insert_item(&mut session, prop::DEV_PEERS, &[0xFF; 32]);
11560        expect_status(&emitted[0], 5, Status::NOMEM);
11561    }
11562
11563    /// The administrator list is a second public-key table with the same
11564    /// shape as the peer list, and independent of it: authorizing a node
11565    /// to manage this device is not the same as recognizing it as a
11566    /// correspondent.
11567    #[test]
11568    fn dev_admins_lifecycle() {
11569        let mut session = test_session();
11570
11571        let (emitted, _) = insert_item(&mut session, prop::DEV_ADMINS, &[0xA0; 32]);
11572        let (key, digest) = parse_table_notice(&emitted[0], Cmd::PropInserted, 5);
11573        assert_eq!(key, prop::DEV_ADMINS);
11574        assert_eq!(digest, [0xA0; 32], "the digest form is the key itself");
11575        assert_eq!(session.dev_admins().collect::<Vec<_>>(), [[0xA0; 32]]);
11576        assert_eq!(session.dev_peers().count(), 0, "peers are a separate set");
11577
11578        let (emitted, _) = insert_item(&mut session, prop::DEV_ADMINS, &[0xA0; 32]);
11579        expect_status(&emitted[0], 5, Status::ALREADY);
11580
11581        // Whole-table set collapses duplicates, like every key table.
11582        let mut two = Vec::new();
11583        two.extend_from_slice(&[0xA1; 32]);
11584        two.extend_from_slice(&[0xA1; 32]);
11585        let (emitted, _) = set(&mut session, prop::DEV_ADMINS, &two);
11586        let (_, key, value) = parse_prop_is(&emitted[0]);
11587        assert_eq!(key, prop::DEV_ADMINS);
11588        assert_eq!(value, [0xA1; 32]);
11589
11590        let (emitted, _) = remove_item(&mut session, prop::DEV_ADMINS, &[0xA1; 32]);
11591        let (_, digest) = parse_table_notice(&emitted[0], Cmd::PropRemoved, 6);
11592        assert_eq!(digest, [0xA1; 32]);
11593        let (emitted, _) = remove_item(&mut session, prop::DEV_ADMINS, &[0xA1; 32]);
11594        expect_status(&emitted[0], 6, Status::ITEM_NOT_FOUND);
11595
11596        for seed in 0..MAX_DEV_ADMINS as u8 {
11597            insert_item(&mut session, prop::DEV_ADMINS, &[seed; 32]);
11598        }
11599        let (emitted, _) = insert_item(&mut session, prop::DEV_ADMINS, &[0xFF; 32]);
11600        expect_status(&emitted[0], 5, Status::NOMEM);
11601    }
11602
11603    /// Who may manage the device is device-domain state, so every change
11604    /// to it has to reach whatever answers management requests.
11605    #[test]
11606    fn dev_admin_changes_move_the_dev_domain_version() {
11607        let mut session = test_session();
11608        let start = session.dev_domain_version();
11609
11610        insert_item(&mut session, prop::DEV_ADMINS, &[0xA0; 32]);
11611        assert_eq!(session.dev_domain_version(), start + 1);
11612        set(&mut session, prop::DEV_ADMINS, &[0xA1; 32]);
11613        assert_eq!(session.dev_domain_version(), start + 2);
11614        remove_item(&mut session, prop::DEV_ADMINS, &[0xA1; 32]);
11615        assert_eq!(session.dev_domain_version(), start + 3);
11616
11617        // A failed removal changes nothing to publish.
11618        remove_item(&mut session, prop::DEV_ADMINS, &[0xEE; 32]);
11619        assert_eq!(session.dev_domain_version(), start + 3);
11620    }
11621
11622    /// An administrator list outlives a power cycle: a device managed
11623    /// only over the mesh would otherwise become unmanageable at its
11624    /// first reboot.
11625    #[test]
11626    fn dev_admins_survive_a_save_and_boot() {
11627        let mut session = test_session();
11628        insert_item(&mut session, prop::DEV_ADMINS, &[0xA0; 32]);
11629        insert_item(&mut session, prop::DEV_ADMINS, &[0xA1; 32]);
11630        save(&mut session);
11631
11632        let mut bytes = [0u8; SNAPSHOT_MAX];
11633        let len = session.encode_snapshot(&mut bytes).unwrap();
11634        let mut booted: TestSession =
11635            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
11636        booted.restore_at_boot(&bytes[..len]).unwrap();
11637        assert_eq!(
11638            booted.dev_admins().collect::<Vec<_>>(),
11639            [[0xA0; 32], [0xA1; 32]]
11640        );
11641    }
11642
11643    #[test]
11644    fn dev_domain_version_tracks_node_table_changes() {
11645        let mut session = test_session();
11646        assert_eq!(session.dev_domain_version(), 0);
11647        assert_eq!(session.dev_channel_keys().count(), 0);
11648        assert_eq!(session.dev_peers().count(), 0);
11649        assert!(session.dev_key().is_none());
11650
11651        // Every successful device-table mutation moves the version and
11652        // is visible through the node-sync accessors.
11653        let dev_channel = [0x66u8; 32];
11654        insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
11655        assert_eq!(session.dev_domain_version(), 1);
11656        assert_eq!(
11657            session.dev_channel_keys().collect::<Vec<_>>(),
11658            [dev_channel]
11659        );
11660        insert_item(&mut session, prop::DEV_PEERS, &[0xD0; 32]);
11661        assert_eq!(session.dev_domain_version(), 2);
11662        assert_eq!(session.dev_peers().collect::<Vec<_>>(), [[0xD0; 32]]);
11663
11664        // Failed mutations do not: the node has nothing to re-sync.
11665        insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
11666        remove_item(&mut session, prop::DEV_PEERS, &[0xEE; 32]);
11667        assert_eq!(session.dev_domain_version(), 2);
11668
11669        // Neither do host-domain mutations — device and host tables are
11670        // independent surfaces.
11671        insert_item(&mut session, prop::HOST_CHANNEL_KEYS, &[0x42; 32]);
11672        assert_eq!(session.dev_domain_version(), 2);
11673
11674        // Whole-table set and remove bump.
11675        set(&mut session, prop::DEV_PEERS, &[0xD1; 32]);
11676        assert_eq!(session.dev_domain_version(), 3);
11677        remove_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
11678        assert_eq!(session.dev_domain_version(), 4);
11679
11680        // CMD_RST rebuilds the tables (from the snapshot when one is
11681        // saved, post-reset defaults otherwise) — always a re-sync.
11682        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_: &[u8]| {});
11683        assert_eq!(session.dev_domain_version(), 5);
11684        assert_eq!(session.dev_channel_keys().count(), 0);
11685        assert_eq!(session.dev_peers().count(), 0);
11686
11687        // A boot restore replays the saved tables into a fresh session:
11688        // the version moves off its initial value so the firmware
11689        // publishes the restored tables to the node.
11690        insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
11691        save(&mut session);
11692        let mut bytes = [0u8; SNAPSHOT_MAX];
11693        let len = session.encode_snapshot(&mut bytes).unwrap();
11694        let mut booted: TestSession =
11695            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
11696        assert_eq!(booted.dev_domain_version(), 0);
11697        booted.restore_at_boot(&bytes[..len]).unwrap();
11698        assert_ne!(booted.dev_domain_version(), 0);
11699        assert_eq!(booted.dev_channel_keys().collect::<Vec<_>>(), [dev_channel]);
11700    }
11701
11702    #[test]
11703    fn dev_channel_keys_do_not_create_host_receive_filters() {
11704        let mut session = test_session();
11705        enable(&mut session);
11706        // Host filtering is configured (host key present), and the
11707        // device identity participates in its own channel.
11708        install_host_key(&mut session, &HOST_PUB);
11709        let dev_channel = [0x66u8; 32];
11710        insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
11711        let dev_id = test_engine().derive_channel_id(&ChannelKey(dev_channel)).0;
11712
11713        // Traffic on the device channel reaches the host only through
11714        // the host's own filtering — it is not queued.
11715        session.detach();
11716        receive_detached(&mut session, &multicast_on(dev_id), 0);
11717        session.attach(true);
11718        assert_eq!(queue_count(&mut session), 0);
11719
11720        // The same frame with a matching *host* channel key queues.
11721        install_channel_key(&mut session, &dev_channel);
11722        session.detach();
11723        receive_detached(&mut session, &multicast_on(dev_id), 0);
11724        session.attach(true);
11725        assert_eq!(queue_count(&mut session), 1);
11726    }
11727
11728    #[test]
11729    fn snapshot_carries_device_tables_but_never_the_identity() {
11730        let mut session = test_session();
11731        let dev_channel = [0x66u8; 32];
11732        let dev_id = test_engine().derive_channel_id(&ChannelKey(dev_channel)).0;
11733        insert_item(&mut session, prop::DEV_CHANNEL_KEYS, &dev_channel);
11734        insert_item(&mut session, prop::DEV_PEERS, &[0xD0; 32]);
11735        let public_key = provision_identity(&mut session, 7, &[0x11; 32]);
11736        save(&mut session);
11737
11738        // Divergence reverts on CMD_RST (post-reset values come from
11739        // the snapshot).
11740        remove_item(&mut session, prop::DEV_PEERS, &[0xD0; 32]);
11741        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_: &[u8]| {});
11742        assert_eq!(get(&mut session, prop::DEV_PEERS), [0xD0; 32]);
11743
11744        // A boot from the snapshot restores the tables — but not the
11745        // identity, which is persisted (and installed) independently.
11746        let mut bytes = [0u8; SNAPSHOT_MAX];
11747        let len = session.encode_snapshot(&mut bytes).unwrap();
11748        let mut booted: TestSession =
11749            Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
11750        booted.restore_at_boot(&bytes[..len]).unwrap();
11751        booted.attach(true);
11752        assert_eq!(get(&mut booted, prop::DEV_CHANNEL_KEYS), dev_id);
11753        assert_eq!(get(&mut booted, prop::DEV_PEERS), [0xD0; 32]);
11754        assert!(get(&mut booted, prop::DEV_KEY).is_empty());
11755        booted.set_boot_identity(public_key);
11756        assert_eq!(get(&mut booted, prop::DEV_KEY), public_key);
11757
11758        // Host replacement never touches the device domain.
11759        install_host_key(&mut booted, &[0xBB; 32]);
11760        assert_eq!(get(&mut booted, prop::DEV_CHANNEL_KEYS), dev_id);
11761        assert_eq!(get(&mut booted, prop::DEV_PEERS), [0xD0; 32]);
11762        assert_eq!(get(&mut booted, prop::DEV_KEY), public_key);
11763    }
11764
11765    #[test]
11766    fn restore_never_reverts_the_identity_and_clear_plus_reset_erases_it() {
11767        let mut session = test_session();
11768        let first = provision_identity(&mut session, 7, &[0x11; 32]);
11769        save(&mut session);
11770
11771        // CMD_RESTORE reverts configuration but the identity — outside
11772        // the snapshot — keeps its newest value.
11773        let second = provision_identity(&mut session, 3, &[0x22; 32]);
11774        assert_ne!(second, first);
11775        let _ = restore(&mut session);
11776        assert_eq!(get(&mut session, prop::DEV_KEY), second);
11777
11778        // CMD_CLEAR erases the durable identity but not the live one;
11779        // the CMD_RST completing the factory reset loses it.
11780        let mut buf = [0u8; 4];
11781        let len = frame::clear(&mut buf, 4).unwrap();
11782        let (_, effect) = dispatch(&mut session, &buf[..len], 0);
11783        assert_eq!(effect, Some(Effect::ClearSaved { tid: 4 }));
11784        session.respond_clear(4, Ok(()), &mut |_: &[u8]| {});
11785        assert_eq!(get(&mut session, prop::DEV_KEY), second);
11786        let _ = session.reset(Status::RESET_SOFTWARE, &mut |_: &[u8]| {});
11787        assert!(get(&mut session, prop::DEV_KEY).is_empty());
11788    }
11789
11790    #[test]
11791    fn tid_zero_identity_provisioning_is_silent_but_applies() {
11792        let mut session = test_session();
11793        let mut buf = [0u8; 64];
11794        let len = frame::prop_set(&mut buf, 0, prop::DEV_PRIVATE_KEY, &[0x11; 32]).unwrap();
11795        let effect = dispatch_tid0_silent(&mut session, &buf[..len].to_vec());
11796        assert_eq!(effect, Some(Effect::ProvisionIdentity { tid: 0 }));
11797        let public_key = public_of(&[0x11; 32]);
11798        session.respond_identity(0, Ok(public_key), &mut |_: &[u8]| {
11799            panic!("tid-0 provisioning must be silent")
11800        });
11801        assert_eq!(get(&mut session, prop::DEV_KEY), public_key);
11802    }
11803
11804    /// Dispatch a frame built with TID zero and assert total silence.
11805    fn dispatch_tid0_silent(session: &mut TestSession, bytes: &[u8]) -> Option<Effect> {
11806        let (emitted, effect) = dispatch(session, bytes, 0);
11807        assert!(
11808            emitted.is_empty(),
11809            "fire-and-forget commands receive no correlated response"
11810        );
11811        effect
11812    }
11813
11814    fn last_status_of(session: &mut TestSession) -> u32 {
11815        pui::decode(&get(session, prop::LAST_STATUS)).unwrap().0
11816    }
11817
11818    #[test]
11819    fn tid_zero_commands_are_fire_and_forget() {
11820        let mut session = test_session();
11821        let mut buf = [0u8; 640];
11822
11823        // NOP, empty drain, save, and clear: silent success, recorded
11824        // in PROP_LAST_STATUS only.
11825        let len = frame::nop(&mut buf, 0).unwrap();
11826        assert!(dispatch_tid0_silent(&mut session, &buf[..len].to_vec()).is_none());
11827        assert_eq!(last_status_of(&mut session), Status::OK.0);
11828
11829        let len = frame::queue_drain(&mut buf, 0).unwrap();
11830        assert!(dispatch_tid0_silent(&mut session, &buf[..len].to_vec()).is_none());
11831
11832        let len = frame::save(&mut buf, 0).unwrap();
11833        let effect = dispatch_tid0_silent(&mut session, &buf[..len].to_vec());
11834        assert_eq!(effect, Some(Effect::SaveSnapshot { tid: 0 }));
11835        session.respond_save(0, Ok(()), &mut |_: &[u8]| {
11836            panic!("tid-0 save must be silent")
11837        });
11838        assert_eq!(get(&mut session, prop::SAVED), [1]);
11839
11840        // A TID-zero failure is recorded silently.
11841        let len = frame::clear(&mut buf, 0).unwrap();
11842        let effect = dispatch_tid0_silent(&mut session, &buf[..len].to_vec());
11843        assert_eq!(effect, Some(Effect::ClearSaved { tid: 0 }));
11844        session.respond_clear(0, Err(()), &mut |_: &[u8]| {
11845            panic!("tid-0 clear must be silent")
11846        });
11847        assert_eq!(last_status_of(&mut session), Status::FAILURE.0);
11848        assert_eq!(
11849            get(&mut session, prop::SAVED),
11850            [1],
11851            "failed clear rolls back nothing"
11852        );
11853
11854        // TID-zero SET and INSERT mutate state without a correlated
11855        // response.
11856        let len = frame::prop_set(&mut buf, 0, prop::PHY_DUTY_LIMIT, &99u16.to_le_bytes()).unwrap();
11857        assert!(dispatch_tid0_silent(&mut session, &buf[..len].to_vec()).is_none());
11858        assert_eq!(get(&mut session, prop::PHY_DUTY_LIMIT), 99u16.to_le_bytes());
11859
11860        let item = [items::FILTER_PKT_TYPE, 0];
11861        let len = frame::prop_insert(&mut buf, 0, prop::HOST_RX_FILTERS, &item).unwrap();
11862        assert!(dispatch_tid0_silent(&mut session, &buf[..len].to_vec()).is_none());
11863        assert_eq!(
11864            get(&mut session, prop::HOST_RX_FILTERS),
11865            [2, items::FILTER_PKT_TYPE, 0]
11866        );
11867
11868        let len = frame::prop_remove(&mut buf, 0, prop::HOST_RX_FILTERS, &item).unwrap();
11869        assert!(dispatch_tid0_silent(&mut session, &buf[..len].to_vec()).is_none());
11870        assert!(get(&mut session, prop::HOST_RX_FILTERS).is_empty());
11871
11872        // TID-zero GET expects no response either.
11873        let len = frame::prop_get(&mut buf, 0, prop::PHY_MTU).unwrap();
11874        assert!(dispatch_tid0_silent(&mut session, &buf[..len].to_vec()).is_none());
11875    }
11876
11877    #[test]
11878    fn tid_zero_drain_delivers_frames_but_no_completion() {
11879        let mut session = test_session();
11880        enable(&mut session);
11881        session.detach();
11882        receive_detached(&mut session, &unicast_to([1, 2, 3]), 0);
11883        session.attach(true);
11884
11885        let mut buf = [0u8; 4];
11886        let len = frame::queue_drain(&mut buf, 0).unwrap();
11887        let (emitted, effect) = dispatch(&mut session, &buf[..len], 0);
11888        assert!(emitted.is_empty());
11889        assert_eq!(effect, Some(Effect::DrainQueue));
11890
11891        // First step: the buffered frame. Final step: silence.
11892        let mut emitted = Vec::new();
11893        assert!(session.drain_step(0, &mut |bytes: &[u8]| emitted.push(bytes.to_vec())));
11894        assert_eq!(emitted.len(), 1);
11895        assert_eq!(
11896            Frame::parse(&emitted[0]).unwrap().command(),
11897            Some(Cmd::StrRecv)
11898        );
11899        let mut emitted = Vec::new();
11900        assert!(!session.drain_step(0, &mut |bytes: &[u8]| emitted.push(bytes.to_vec())));
11901        assert!(
11902            emitted.is_empty(),
11903            "TID-zero drain has no completion response"
11904        );
11905        assert_eq!(last_status_of(&mut session), Status::OK.0);
11906    }
11907
11908    // ─── The mesh administrative binding ─────────────────────────────
11909
11910    /// Drive one administrative exchange the way the responder does:
11911    /// hand the frame over, serve nothing (these cases defer nothing),
11912    /// and end the exchange.
11913    fn admin(session: &mut TestSession, request: &[u8]) -> Vec<Vec<u8>> {
11914        let mut emitted = Vec::new();
11915        let effect = session.handle_admin_frame(request, 0, 180, &mut |bytes: &[u8]| {
11916            emitted.push(bytes.to_vec())
11917        });
11918        assert!(effect.is_none(), "this exchange defers nothing");
11919        session.end_admin_exchange();
11920        emitted
11921    }
11922
11923    /// The whole point of the binding's TID handling: correlation is by
11924    /// token, so a request whose TID is zero — which the spec requires —
11925    /// is still answered, where a local host's TID-zero request is
11926    /// fire-and-forget.
11927    #[test]
11928    fn an_admin_request_is_answered_despite_its_zero_tid() {
11929        let mut session = test_session();
11930        let mut buf = [0u8; 16];
11931        let len = frame::prop_get(&mut buf, TID_UNSOLICITED, prop::PROTOCOL_VERSION).unwrap();
11932        let emitted = admin(&mut session, &buf[..len]);
11933        assert_eq!(emitted.len(), 1);
11934        let (tid, key, _) = parse_prop_is(&emitted[0]);
11935        assert_eq!(tid, TID_UNSOLICITED);
11936        assert_eq!(key, prop::PROTOCOL_VERSION);
11937
11938        // The same request over the local binding is answered by nothing.
11939        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
11940        assert!(emitted.is_empty());
11941    }
11942
11943    #[test]
11944    fn an_unparseable_admin_frame_is_answered_rather_than_ignored() {
11945        let mut session = test_session();
11946        let emitted = admin(&mut session, &[]);
11947        assert_eq!(emitted.len(), 1);
11948        expect_status(&emitted[0], TID_UNSOLICITED, Status::PARSE_ERROR);
11949    }
11950
11951    #[test]
11952    fn the_host_transports_are_not_an_admins_to_command() {
11953        let mut session = test_session();
11954        enable(&mut session);
11955        let mut buf = [0u8; 64];
11956        for len in [
11957            frame::str_send(&mut buf, TID_UNSOLICITED, stream::PHY_RAW, b"hi", &[]).unwrap(),
11958            frame::queue_drain(&mut buf, TID_UNSOLICITED).unwrap(),
11959        ]
11960        .map(|len| len)
11961        {
11962            let emitted = admin(&mut session, &buf[..len]);
11963            assert_eq!(emitted.len(), 1);
11964            expect_status(&emitted[0], TID_UNSOLICITED, Status::INVALID_COMMAND);
11965        }
11966    }
11967
11968    #[test]
11969    fn the_host_domain_does_not_exist_for_an_admin() {
11970        let mut session = test_session();
11971        let mut buf = [0u8; 64];
11972        for key in [
11973            prop::HOST_KEY,
11974            prop::HOST_CHANNEL_KEYS,
11975            prop::HOST_PEER_KEYS,
11976            prop::HOST_RX_FILTERS,
11977            prop::HOST_AUTO_ACK,
11978            prop::HOST_RX_QUEUE_COUNT,
11979            prop::HOST_RX_QUEUE_CAPACITY,
11980            prop::HOST_RX_QUEUE_DROPPED,
11981            prop::MAC_PROMISCUOUS,
11982            prop::MAC_BACKHAUL,
11983            prop::DEV_PRIVATE_KEY,
11984        ] {
11985            let len = frame::prop_get(&mut buf, TID_UNSOLICITED, key).unwrap();
11986            let emitted = admin(&mut session, &buf[..len]);
11987            expect_status(&emitted[0], TID_UNSOLICITED, Status::PROP_NOT_FOUND);
11988
11989            let len = frame::prop_set(&mut buf, TID_UNSOLICITED, key, &[0]).unwrap();
11990            let emitted = admin(&mut session, &buf[..len]);
11991            expect_status(&emitted[0], TID_UNSOLICITED, Status::PROP_NOT_FOUND);
11992
11993            let len = frame::prop_insert(&mut buf, TID_UNSOLICITED, key, &[0; 32]).unwrap();
11994            let emitted = admin(&mut session, &buf[..len]);
11995            expect_status(&emitted[0], TID_UNSOLICITED, Status::PROP_NOT_FOUND);
11996
11997            let len = frame::prop_remove(&mut buf, TID_UNSOLICITED, key, &[0; 32]).unwrap();
11998            let emitted = admin(&mut session, &buf[..len]);
11999            expect_status(&emitted[0], TID_UNSOLICITED, Status::PROP_NOT_FOUND);
12000        }
12001    }
12002
12003    /// Key-bearing device-domain writes are the reason the binding
12004    /// declares itself secure: they must succeed remotely on a session
12005    /// whose local transport is insecure, because there is no local
12006    /// transport involved at all.
12007    #[test]
12008    fn device_domain_key_material_may_be_provisioned_remotely() {
12009        let mut session = Session::new(test_config(), Status::RESET_POWER_ON, test_engine());
12010        session.attach(false);
12011        let mut buf = [0u8; 64];
12012
12013        // Insecure and local: refused for the transport it arrived on.
12014        let len = frame::prop_insert(
12015            &mut buf,
12016            5,
12017            prop::DEV_CHANNEL_KEYS,
12018            &[7u8; items::CHANNEL_KEY_LEN],
12019        )
12020        .unwrap();
12021        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
12022        expect_status(&emitted[0], 5, Status::INVALID_STATE);
12023
12024        let len = frame::prop_insert(
12025            &mut buf,
12026            TID_UNSOLICITED,
12027            prop::DEV_CHANNEL_KEYS,
12028            &[7u8; items::CHANNEL_KEY_LEN],
12029        )
12030        .unwrap();
12031        let emitted = admin(&mut session, &buf[..len]);
12032        assert_eq!(
12033            Frame::parse(&emitted[0]).unwrap().command(),
12034            Some(Cmd::PropInserted)
12035        );
12036    }
12037
12038    /// A reset-class command executes and answers with nothing: the
12039    /// administrator's exchange completes on the MAC acknowledgment.
12040    /// `CMD_RESTORE` with nothing saved is not a reset, so it still
12041    /// reports why it did nothing.
12042    #[test]
12043    fn a_reset_produces_no_response_but_a_refused_restore_does() {
12044        let mut session = test_session();
12045        let mut buf = [0u8; 16];
12046
12047        let len = frame::reset(&mut buf, TID_UNSOLICITED).unwrap();
12048        let mut emitted = Vec::new();
12049        let effect = session.handle_admin_frame(&buf[..len], 0, 180, &mut |bytes: &[u8]| {
12050            emitted.push(bytes.to_vec())
12051        });
12052        session.end_admin_exchange();
12053        assert!(matches!(effect, Some(Effect::ApplyRadio(_))));
12054        assert!(emitted.is_empty(), "a reset is answered by no response");
12055
12056        let len = frame::restore(&mut buf, TID_UNSOLICITED).unwrap();
12057        let emitted = admin(&mut session, &buf[..len]);
12058        expect_status(&emitted[0], TID_UNSOLICITED, Status::INVALID_STATE);
12059    }
12060
12061    /// The binding is a property of the exchange, not of the session.
12062    /// Once it ends, TID zero is fire-and-forget again.
12063    #[test]
12064    fn ending_an_exchange_restores_the_local_binding() {
12065        let mut session = test_session();
12066        let mut buf = [0u8; 16];
12067        let len = frame::prop_get(&mut buf, TID_UNSOLICITED, prop::HOST_AUTO_ACK).unwrap();
12068        assert_eq!(admin(&mut session, &buf[..len]).len(), 1);
12069
12070        let (emitted, _) = dispatch(&mut session, &buf[..len], 0);
12071        assert!(emitted.is_empty(), "the local binding is back");
12072        // And the host domain is the host's again.
12073        let (_, _, value) = parse_prop_is(&{
12074            let len = frame::prop_get(&mut buf, 3, prop::HOST_AUTO_ACK).unwrap();
12075            dispatch(&mut session, &buf[..len], 0).0[0].clone()
12076        });
12077        assert_eq!(value.len(), 1);
12078    }
12079
12080    /// A multi read reaches the device domain and reports the
12081    /// out-of-reach slots in place, without ending the sequence.
12082    #[test]
12083    fn a_multi_read_reports_out_of_reach_slots_in_place() {
12084        let mut session = test_session();
12085        let mut buf = [0u8; 64];
12086        let len = frame::prop_multi_get(
12087            &mut buf,
12088            TID_UNSOLICITED,
12089            &[prop::PROTOCOL_VERSION, prop::HOST_AUTO_ACK, prop::DEV_MODEL],
12090        )
12091        .unwrap();
12092        let mut emitted = Vec::new();
12093        let effect = session.handle_admin_frame(&buf[..len], 0, 180, &mut |bytes: &[u8]| {
12094            emitted.push(bytes.to_vec())
12095        });
12096        session.end_admin_exchange();
12097        assert!(effect.is_none());
12098        let parsed = Frame::parse(&emitted[0]).unwrap();
12099        assert_eq!(parsed.command(), Some(Cmd::PropAre));
12100        let entries: Vec<_> = MultiEntries::new(parsed.payload)
12101            .map(|entry| entry.unwrap())
12102            .map(|entry| (entry.key, entry.value.to_vec()))
12103            .collect();
12104        assert_eq!(entries.len(), 3);
12105        assert_eq!(entries[0].0, prop::PROTOCOL_VERSION);
12106        assert_eq!(entries[1].0, prop::LAST_STATUS);
12107        assert_eq!(entry_status(&entries[1].1), Status::PROP_NOT_FOUND);
12108        assert_eq!(entries[2].0, prop::DEV_MODEL);
12109    }
12110
12111    #[test]
12112    fn queue_properties_are_read_only_and_capacity_fixed() {
12113        let mut session = test_session();
12114        assert_eq!(
12115            get(&mut session, prop::HOST_RX_QUEUE_CAPACITY),
12116            (RX_QUEUE_CAPACITY as u16).to_le_bytes()
12117        );
12118        for (key, status) in [
12119            (prop::HOST_RX_QUEUE_COUNT, Status::INVALID_ARGUMENT),
12120            (prop::HOST_RX_QUEUE_DROPPED, Status::INVALID_ARGUMENT),
12121            (prop::HOST_RX_QUEUE_CAPACITY, Status::UNIMPLEMENTED),
12122        ] {
12123            let (emitted, effect) = set(&mut session, key, &0u16.to_le_bytes());
12124            assert!(effect.is_none());
12125            expect_status(&emitted[0], 2, status);
12126        }
12127    }
12128}