umsh_ulcp_web_engine/
lib.rs

1//! Sans-IO host engine for browser and native ULCP tools.
2//!
3//! Browser APIs, clocks, and presentation deliberately live outside this
4//! crate. Callers feed transport bytes, drain transport writes, and consume a
5//! stable stream of serializable events.
6
7use std::collections::VecDeque;
8
9use serde::Serialize;
10use umsh_core::{PacketHeader, PacketType, ParsedOptions, PayloadType, PublicKey, SourceAddrRef};
11use umsh_ulcp::{
12    BufferedRxMeta, Frame, FrameDescription, PropPayload, StreamPayload, capability_name,
13    frame::{self, Cmd, TID_MAX},
14    gatt, hdlc,
15    ids::{PROTOCOL_MAJOR_VERSION, prop, stream},
16    items::{self, Filter},
17    meta::{RX_FLAG_ACKED, RX_FLAG_BUFFERED},
18    property_name, pui,
19};
20
21#[cfg(feature = "sim-device")]
22mod sim;
23#[cfg(feature = "sim-device")]
24pub use sim::SimulatedDevice;
25
26const FRAME_CAPACITY: usize = gatt::MAX_FRAME;
27const RESPONSE_TIMEOUT_MS: u64 = 2_000;
28
29/// The framing carried by the browser-owned physical link.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum Transport {
33    SerialHdlc,
34    BleSar,
35}
36
37/// Structured engine output. The JSON representation is the public browser
38/// contract and can be reused by other web frontends.
39#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
40#[serde(tag = "type", rename_all = "snake_case")]
41pub enum Event {
42    Trace {
43        timestamp_ms: u64,
44        direction: Direction,
45        summary: String,
46        raw_hex: Option<String>,
47        redacted: bool,
48    },
49    Property {
50        key: u32,
51        name: Option<&'static str>,
52        value_hex: String,
53        decoded: Option<DecodedValue>,
54        unsolicited: bool,
55    },
56    PropertyError {
57        key: u32,
58        name: Option<&'static str>,
59        status: String,
60    },
61    CommandResult {
62        command: &'static str,
63        status: String,
64        success: bool,
65    },
66    StreamRx {
67        timestamp_ms: u64,
68        stream: u32,
69        data_hex: String,
70        metadata: Option<RxMetadata>,
71        metadata_error: Option<String>,
72        packet: Option<PacketSummary>,
73        packet_error: Option<String>,
74    },
75    Attached {
76        protocol_major: u8,
77        protocol_minor: u8,
78        dev_version: String,
79        boot_status: String,
80        capabilities: Vec<Capability>,
81        phy_mtu: u16,
82    },
83    ProtocolError {
84        message: String,
85    },
86    Detached {
87        reason: String,
88    },
89}
90
91#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
92#[serde(rename_all = "snake_case")]
93pub enum Direction {
94    HostToDevice,
95    DeviceToHost,
96}
97
98#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
99pub struct Capability {
100    pub code: u32,
101    pub name: Option<&'static str>,
102}
103
104/// Human-readable interpretation that accompanies, but never replaces, the
105/// property's authoritative raw octets.
106#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
107pub struct DecodedValue {
108    pub kind: &'static str,
109    pub display: String,
110    pub edit: Option<String>,
111}
112
113/// Presentation-neutral description of a known property. Web frontends can
114/// render this as a table, form, or conversational settings surface without
115/// carrying a second copy of the protocol schema.
116#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
117pub struct PropertySpec {
118    pub key: u32,
119    pub name: &'static str,
120    pub group: &'static str,
121    pub description: &'static str,
122    pub readable: bool,
123    pub writable: bool,
124    pub editor: &'static str,
125    pub unit: Option<&'static str>,
126    pub capability: Option<u32>,
127    pub choices: &'static [PropertyChoice],
128}
129
130#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
131pub struct PropertyChoice {
132    pub value: &'static str,
133    pub label: &'static str,
134}
135
136const SF_CHOICES: &[PropertyChoice] = &[
137    choice("5", "SF5"),
138    choice("6", "SF6"),
139    choice("7", "SF7"),
140    choice("8", "SF8"),
141    choice("9", "SF9"),
142    choice("10", "SF10"),
143    choice("11", "SF11"),
144    choice("12", "SF12"),
145];
146const BW_CHOICES: &[PropertyChoice] = &[
147    choice("7810", "7.81 kHz"),
148    choice("10420", "10.42 kHz"),
149    choice("15630", "15.63 kHz"),
150    choice("20830", "20.83 kHz"),
151    choice("31250", "31.25 kHz"),
152    choice("41670", "41.67 kHz"),
153    choice("62500", "62.5 kHz"),
154    choice("125000", "125 kHz"),
155    choice("250000", "250 kHz"),
156    choice("500000", "500 kHz"),
157];
158const CR_CHOICES: &[PropertyChoice] = &[
159    choice("5", "4/5"),
160    choice("6", "4/6"),
161    choice("7", "4/7"),
162    choice("8", "4/8"),
163];
164
165const ALERT_CHOICES: &[PropertyChoice] = &[choice("0", "ALERT_NONE"), choice("1", "ALERT_LOCATE")];
166
167const fn choice(value: &'static str, label: &'static str) -> PropertyChoice {
168    PropertyChoice { value, label }
169}
170
171const PROPERTY_SPECS: &[PropertySpec] = &[
172    spec(
173        prop::LAST_STATUS,
174        "Protocol",
175        "Last operation or reset status",
176        true,
177        false,
178        "none",
179        None,
180        None,
181    ),
182    spec(
183        prop::PROTOCOL_VERSION,
184        "Protocol",
185        "ULCP version",
186        true,
187        false,
188        "none",
189        None,
190        None,
191    ),
192    spec(
193        prop::DEV_VERSION,
194        "Protocol",
195        "Device firmware version",
196        true,
197        false,
198        "none",
199        None,
200        None,
201    ),
202    spec(
203        prop::INTERFACE_TYPE,
204        "Protocol",
205        "Network interface type",
206        true,
207        false,
208        "none",
209        None,
210        None,
211    ),
212    spec(
213        prop::CAPS,
214        "Protocol",
215        "Supported protocol capabilities",
216        true,
217        false,
218        "none",
219        None,
220        None,
221    ),
222    spec(
223        prop::PHY_ENABLED,
224        "Radio",
225        "Radio enabled",
226        true,
227        true,
228        "boolean",
229        None,
230        None,
231    ),
232    spec(
233        prop::PHY_FREQ,
234        "Radio",
235        "Center frequency",
236        true,
237        true,
238        "integer",
239        Some("kHz"),
240        None,
241    ),
242    spec(
243        prop::PHY_TX_POWER,
244        "Radio",
245        "Transmit power",
246        true,
247        true,
248        "integer",
249        Some("dBm"),
250        None,
251    ),
252    spec(
253        prop::PHY_RSSI,
254        "Radio",
255        "Current received signal strength",
256        true,
257        false,
258        "none",
259        Some("dBm"),
260        None,
261    ),
262    spec(
263        prop::PHY_LORA_BW,
264        "Radio",
265        "LoRa bandwidth",
266        true,
267        true,
268        "integer",
269        Some("Hz"),
270        Some(umsh_ulcp::ids::cap::PHY_LORA),
271    ),
272    spec(
273        prop::PHY_LORA_SF,
274        "Radio",
275        "LoRa spreading factor",
276        true,
277        true,
278        "integer",
279        None,
280        Some(umsh_ulcp::ids::cap::PHY_LORA),
281    ),
282    spec(
283        prop::PHY_LORA_CR,
284        "Radio",
285        "LoRa coding-rate denominator",
286        true,
287        true,
288        "integer",
289        None,
290        Some(umsh_ulcp::ids::cap::PHY_LORA),
291    ),
292    spec(
293        prop::PHY_MTU,
294        "Radio",
295        "Maximum raw radio frame size",
296        true,
297        false,
298        "none",
299        Some("octets"),
300        None,
301    ),
302    spec(
303        prop::PHY_LORA_SW,
304        "Radio",
305        "LoRa sync word",
306        true,
307        true,
308        "hex_integer",
309        None,
310        Some(umsh_ulcp::ids::cap::PHY_LORA),
311    ),
312    spec(
313        prop::PHY_DUTY_NOW,
314        "Radio",
315        "Current transmit duty usage",
316        true,
317        false,
318        "none",
319        None,
320        Some(umsh_ulcp::ids::cap::PHY_DUTY_LIMIT),
321    ),
322    spec(
323        prop::PHY_DUTY_LIMIT,
324        "Radio",
325        "Transmit duty limit (0–65535)",
326        true,
327        true,
328        "integer",
329        None,
330        Some(umsh_ulcp::ids::cap::PHY_DUTY_LIMIT),
331    ),
332    spec(
333        prop::MAC_PROMISCUOUS,
334        "Host session",
335        "Deliver every received frame",
336        true,
337        true,
338        "boolean",
339        None,
340        Some(umsh_ulcp::ids::cap::HOST_FILTER),
341    ),
342    spec(
343        prop::SAVED,
344        "Device",
345        "Saved autonomous snapshot exists",
346        true,
347        false,
348        "none",
349        None,
350        Some(umsh_ulcp::ids::cap::SAVE),
351    ),
352    spec(
353        prop::DEV_KEY,
354        "Device",
355        "Device identity public key",
356        true,
357        false,
358        "none",
359        None,
360        Some(umsh_ulcp::ids::cap::DEV_IDENTITY),
361    ),
362    spec(
363        prop::DEV_PRIVATE_KEY,
364        "Device",
365        "Install or generate device identity",
366        false,
367        false,
368        "none",
369        None,
370        Some(umsh_ulcp::ids::cap::DEV_IDENTITY),
371    ),
372    spec(
373        prop::DEV_CHANNEL_KEYS,
374        "Device",
375        "Device channel identifiers",
376        true,
377        false,
378        "none",
379        None,
380        Some(umsh_ulcp::ids::cap::DEV_IDENTITY),
381    ),
382    spec(
383        prop::DEV_PEERS,
384        "Device",
385        "Recognized device peers",
386        true,
387        false,
388        "none",
389        None,
390        Some(umsh_ulcp::ids::cap::DEV_IDENTITY),
391    ),
392    spec(
393        prop::DEV_NAME,
394        "Device",
395        "Human-readable device name",
396        true,
397        true,
398        "text",
399        None,
400        Some(umsh_ulcp::ids::cap::DEV_NAME),
401    ),
402    spec(
403        prop::BATTERY,
404        "Device",
405        "Battery status snapshot (sampled on request)",
406        true,
407        false,
408        "none",
409        None,
410        Some(umsh_ulcp::ids::cap::BATTERY),
411    ),
412    spec(
413        prop::ILLUMINANCE,
414        "Device",
415        "Ambient illuminance (sampled on request)",
416        true,
417        false,
418        "none",
419        Some("mlux"),
420        Some(umsh_ulcp::ids::cap::ILLUMINANCE),
421    ),
422    spec(
423        prop::ALERT,
424        "Device",
425        "Locate alert: make the device conspicuous so it can be found",
426        true,
427        true,
428        "choice",
429        None,
430        Some(umsh_ulcp::ids::cap::ALERT),
431    ),
432    spec(
433        prop::ADVERT_INTERVAL,
434        "Device",
435        "Seconds between unsolicited advertisements, 0 for none",
436        true,
437        true,
438        "integer",
439        Some("s"),
440        Some(umsh_ulcp::ids::cap::ADVERT),
441    ),
442    spec(
443        prop::BEACON_INTERVAL,
444        "Device",
445        "Seconds between unsolicited beacons, 0 for none",
446        true,
447        true,
448        "integer",
449        Some("s"),
450        Some(umsh_ulcp::ids::cap::ADVERT),
451    ),
452    spec(
453        prop::STARTUP_BEACON,
454        "Device",
455        "Whether a beacon goes out once the device comes up",
456        true,
457        true,
458        "boolean",
459        None,
460        Some(umsh_ulcp::ids::cap::ADVERT),
461    ),
462    spec(
463        prop::GNSS_ENABLED,
464        "Positioning",
465        "Whether the GNSS receiver is powered",
466        true,
467        true,
468        "boolean",
469        None,
470        Some(umsh_ulcp::ids::cap::GNSS),
471    ),
472    spec(
473        prop::GNSS_LOCATION,
474        "Positioning",
475        "Position of the last fix, or empty for no fix",
476        true,
477        false,
478        "none",
479        None,
480        Some(umsh_ulcp::ids::cap::GNSS),
481    ),
482    spec(
483        prop::GNSS_ALTITUDE,
484        "Positioning",
485        "Altitude of the last fix",
486        true,
487        false,
488        "none",
489        Some("m"),
490        Some(umsh_ulcp::ids::cap::GNSS),
491    ),
492    spec(
493        prop::GNSS_FIX,
494        "Positioning",
495        "Fix quality (0 while off or searching, never empty)",
496        true,
497        false,
498        "none",
499        None,
500        Some(umsh_ulcp::ids::cap::GNSS),
501    ),
502    spec(
503        prop::GNSS_PRECISION,
504        "Positioning",
505        "Estimated horizontal accuracy of the last fix",
506        true,
507        false,
508        "none",
509        Some("dm"),
510        Some(umsh_ulcp::ids::cap::GNSS),
511    ),
512    spec(
513        prop::GNSS_SATELLITES,
514        "Positioning",
515        "Satellites used, and optionally in view",
516        true,
517        false,
518        "none",
519        None,
520        Some(umsh_ulcp::ids::cap::GNSS),
521    ),
522    spec(
523        prop::TIME,
524        "Positioning",
525        "Wall clock in Unix seconds; empty means the device does not know",
526        true,
527        true,
528        "integer",
529        Some("s"),
530        Some(umsh_ulcp::ids::cap::TIME),
531    ),
532    spec(
533        prop::TZ_OFFSET,
534        "Positioning",
535        "Local time-zone offset east of UTC",
536        true,
537        true,
538        "integer",
539        Some("min"),
540        Some(umsh_ulcp::ids::cap::TIME),
541    ),
542    spec(
543        prop::GNSS_IDENT_UPDATE,
544        "Positioning",
545        "Refresh the advertised node identity's location from fixes",
546        true,
547        true,
548        "boolean",
549        None,
550        Some(umsh_ulcp::ids::cap::GNSS),
551    ),
552    spec(
553        prop::GNSS_IDENT_PRECISION,
554        "Positioning",
555        "Precision the advertised location is clamped to",
556        true,
557        true,
558        "integer",
559        Some("bytes"),
560        Some(umsh_ulcp::ids::cap::GNSS),
561    ),
562    spec(
563        prop::GNSS_TIME_TRUST,
564        "Positioning",
565        "Whether receiver-derived time may set the wall clock",
566        true,
567        true,
568        "boolean",
569        None,
570        Some(umsh_ulcp::ids::cap::GNSS),
571    ),
572    spec(
573        prop::HOST_KEY,
574        "Host",
575        "Attached host identity",
576        true,
577        false,
578        "none",
579        None,
580        Some(umsh_ulcp::ids::cap::HOST_FILTER),
581    ),
582    spec(
583        prop::HOST_CHANNEL_KEYS,
584        "Host",
585        "Host channel identifiers",
586        true,
587        false,
588        "none",
589        None,
590        Some(umsh_ulcp::ids::cap::HOST_KEYS),
591    ),
592    spec(
593        prop::HOST_PEER_KEYS,
594        "Host",
595        "Provisioned host peers",
596        true,
597        false,
598        "none",
599        None,
600        Some(umsh_ulcp::ids::cap::HOST_KEYS),
601    ),
602    spec(
603        prop::HOST_RX_FILTERS,
604        "Host",
605        "Explicit receive filters",
606        true,
607        false,
608        "none",
609        None,
610        Some(umsh_ulcp::ids::cap::HOST_FILTER),
611    ),
612    spec(
613        prop::HOST_AUTO_ACK,
614        "Host",
615        "Delegate acknowledgements to the device",
616        true,
617        true,
618        "boolean",
619        None,
620        Some(umsh_ulcp::ids::cap::HOST_AUTO_ACK),
621    ),
622    spec(
623        prop::HOST_RX_QUEUE_COUNT,
624        "Host",
625        "Queued inbound frames",
626        true,
627        false,
628        "none",
629        Some("frames"),
630        Some(umsh_ulcp::ids::cap::HOST_RX_QUEUE),
631    ),
632    spec(
633        prop::HOST_RX_QUEUE_CAPACITY,
634        "Host",
635        "Inbound queue capacity",
636        true,
637        true,
638        "integer",
639        Some("frames"),
640        Some(umsh_ulcp::ids::cap::HOST_RX_QUEUE),
641    ),
642    spec(
643        prop::HOST_RX_QUEUE_DROPPED,
644        "Host",
645        "Frames dropped from the inbound queue",
646        true,
647        false,
648        "none",
649        Some("frames"),
650        Some(umsh_ulcp::ids::cap::HOST_RX_QUEUE),
651    ),
652    spec(
653        prop::BLE_PAIRING_PIN,
654        "Bluetooth",
655        "Pairing PIN (write-only)",
656        false,
657        false,
658        "none",
659        None,
660        None,
661    ),
662];
663
664const fn spec(
665    key: u32,
666    group: &'static str,
667    description: &'static str,
668    readable: bool,
669    writable: bool,
670    editor: &'static str,
671    unit: Option<&'static str>,
672    capability: Option<u32>,
673) -> PropertySpec {
674    PropertySpec {
675        key,
676        name: match property_name(key) {
677            Some(name) => name,
678            None => "UNKNOWN",
679        },
680        group,
681        description,
682        readable,
683        writable,
684        editor,
685        unit,
686        capability,
687        choices: match key {
688            prop::PHY_LORA_BW => BW_CHOICES,
689            prop::PHY_LORA_SF => SF_CHOICES,
690            prop::PHY_LORA_CR => CR_CHOICES,
691            prop::ALERT => ALERT_CHOICES,
692            _ => &[],
693        },
694    }
695}
696
697pub fn property_specs() -> &'static [PropertySpec] {
698    PROPERTY_SPECS
699}
700
701#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
702pub struct RxMetadata {
703    pub rssi_dbm: Option<i16>,
704    pub lqi: Option<u8>,
705    pub snr_cb: Option<i16>,
706    pub buffered: bool,
707    pub acknowledged: bool,
708    pub age_s: u32,
709}
710
711#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
712pub struct PacketSummary {
713    pub packet_type: String,
714    pub source: Option<String>,
715    pub destination: Option<String>,
716    pub channel_hex: Option<String>,
717    pub frame_counter: Option<u32>,
718    pub encrypted: bool,
719    pub ack_requested: bool,
720    pub flood_remaining: Option<u8>,
721    pub flood_accumulated: Option<u8>,
722    pub header_len: usize,
723    pub body_len: usize,
724    pub mic_len: usize,
725    pub body_hex: String,
726    pub payload_type: Option<String>,
727    pub options: Option<PacketOptionsSummary>,
728    pub options_error: Option<String>,
729}
730
731#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
732pub struct PacketOptionsSummary {
733    pub region_code: Option<String>,
734    pub source_route_len: Option<usize>,
735    pub trace_route_len: Option<usize>,
736    pub min_rssi_dbm: Option<i16>,
737    pub min_snr_db: Option<i8>,
738    pub route_retry: bool,
739    pub unknown_critical: bool,
740}
741
742#[derive(Clone, Copy, Debug, PartialEq, Eq)]
743struct Pending {
744    key: u32,
745    deadline_ms: u64,
746}
747
748#[derive(Clone, Copy, Debug, PartialEq, Eq)]
749enum CommonCommand {
750    Nop,
751    QueueDrain,
752    Save,
753    Clear,
754    Restore,
755}
756
757impl CommonCommand {
758    const fn name(self) -> &'static str {
759        match self {
760            Self::Nop => "nop",
761            Self::QueueDrain => "queue_drain",
762            Self::Save => "save",
763            Self::Clear => "clear",
764            Self::Restore => "restore",
765        }
766    }
767}
768
769#[derive(Default)]
770struct AttachState {
771    requested: bool,
772    boot_status: Option<String>,
773    protocol: Option<(u8, u8)>,
774    dev_version: Option<String>,
775    capabilities: Option<Vec<u32>>,
776    phy_mtu: Option<u16>,
777    announced: bool,
778}
779
780/// Transport-neutral debugger state machine.
781pub struct DebuggerEngine {
782    transport: Transport,
783    hdlc: hdlc::Decoder<FRAME_CAPACITY>,
784    sar: gatt::Reassembler<FRAME_CAPACITY>,
785    outbound: VecDeque<Vec<u8>>,
786    queued_gets: VecDeque<u32>,
787    events: VecDeque<Event>,
788    pending: [Option<Pending>; TID_MAX as usize + 1],
789    pending_commands: [Option<CommonCommand>; TID_MAX as usize + 1],
790    reset_requested: bool,
791    next_tid: u8,
792    now_ms: u64,
793    ble_segment_payload: usize,
794    attach: AttachState,
795}
796
797impl Default for DebuggerEngine {
798    fn default() -> Self {
799        Self::new(Transport::SerialHdlc)
800    }
801}
802
803impl DebuggerEngine {
804    pub fn new(transport: Transport) -> Self {
805        Self {
806            transport,
807            hdlc: hdlc::Decoder::new(),
808            sar: gatt::Reassembler::new(),
809            outbound: VecDeque::new(),
810            queued_gets: VecDeque::new(),
811            events: VecDeque::new(),
812            pending: [None; TID_MAX as usize + 1],
813            pending_commands: [None; TID_MAX as usize + 1],
814            reset_requested: false,
815            next_tid: 1,
816            now_ms: 0,
817            ble_segment_payload: 19,
818            attach: AttachState::default(),
819        }
820    }
821
822    pub fn set_transport(&mut self, transport: Transport) {
823        self.transport = transport;
824        self.hdlc.reset();
825        self.sar.reset();
826        self.outbound.clear();
827        self.queued_gets.clear();
828        self.pending.fill(None);
829        self.pending_commands.fill(None);
830        self.reset_requested = false;
831        self.attach = AttachState::default();
832    }
833
834    /// Set the number of frame octets carried after each one-byte SAR header.
835    pub fn set_ble_segment_payload(&mut self, size: usize) -> Result<(), &'static str> {
836        if !(1..=511).contains(&size) {
837            return Err("BLE segment payload must be between 1 and 511 octets");
838        }
839        self.ble_segment_payload = size;
840        Ok(())
841    }
842
843    /// Queue the non-destructive full-protocol attach reads.
844    pub fn attach(&mut self) -> Result<(), &'static str> {
845        self.attach = AttachState {
846            requested: true,
847            ..AttachState::default()
848        };
849        for key in [
850            prop::LAST_STATUS,
851            prop::PROTOCOL_VERSION,
852            prop::DEV_VERSION,
853            prop::CAPS,
854            prop::PHY_MTU,
855        ] {
856            self.prop_get(key)?;
857        }
858        Ok(())
859    }
860
861    pub fn prop_get(&mut self, key: u32) -> Result<(), &'static str> {
862        let tid = self.reserve_tid(key)?;
863        let mut buf = [0u8; FRAME_CAPACITY];
864        let len = frame::prop_get(&mut buf, tid, key).map_err(|_| "property id is too large")?;
865        self.queue_frame(&buf[..len]);
866        Ok(())
867    }
868
869    pub fn refresh_known_properties(&mut self) {
870        if let Some(capabilities) = self.attach.capabilities.clone() {
871            self.queue_supported_property_refresh(&capabilities);
872        }
873    }
874
875    pub fn prop_set(&mut self, key: u32, value: &[u8]) -> Result<(), &'static str> {
876        let tid = self.reserve_tid(key)?;
877        let mut buf = [0u8; FRAME_CAPACITY];
878        let len = frame::prop_set(&mut buf, tid, key, value)
879            .map_err(|_| "property value does not fit in a ULCP frame")?;
880        self.queue_frame(&buf[..len]);
881        Ok(())
882    }
883
884    /// Encode a user-facing value according to the known property schema.
885    pub fn prop_set_text(&mut self, key: u32, value: &str) -> Result<(), String> {
886        let encoded = encode_property_text(key, value)?;
887        self.prop_set(key, &encoded).map_err(str::to_owned)
888    }
889
890    pub fn prop_insert(&mut self, key: u32, value: &[u8]) -> Result<(), &'static str> {
891        let tid = self.reserve_tid(key)?;
892        let mut buf = [0u8; FRAME_CAPACITY];
893        let len = frame::prop_insert(&mut buf, tid, key, value)
894            .map_err(|_| "property item does not fit in a ULCP frame")?;
895        self.queue_frame(&buf[..len]);
896        Ok(())
897    }
898
899    pub fn prop_remove(&mut self, key: u32, value: &[u8]) -> Result<(), &'static str> {
900        let tid = self.reserve_tid(key)?;
901        let mut buf = [0u8; FRAME_CAPACITY];
902        let len = frame::prop_remove(&mut buf, tid, key, value)
903            .map_err(|_| "property selector does not fit in a ULCP frame")?;
904        self.queue_frame(&buf[..len]);
905        Ok(())
906    }
907
908    pub fn command(&mut self, command: &str) -> Result<(), &'static str> {
909        if command == "reset" {
910            let mut buf = [0u8; 4];
911            let len = frame::reset(&mut buf, 0).map_err(|_| "could not encode reset command")?;
912            self.reset_requested = true;
913            self.queue_frame(&buf[..len]);
914            return Ok(());
915        }
916        let command = match command {
917            "nop" => CommonCommand::Nop,
918            "queue_drain" => CommonCommand::QueueDrain,
919            "save" => CommonCommand::Save,
920            "clear" => CommonCommand::Clear,
921            "restore" => CommonCommand::Restore,
922            _ => return Err("unknown common command"),
923        };
924        let tid = self.reserve_tid(prop::LAST_STATUS)?;
925        let mut buf = [0u8; 4];
926        let encoded = match command {
927            CommonCommand::Nop => frame::nop(&mut buf, tid),
928            CommonCommand::QueueDrain => frame::queue_drain(&mut buf, tid),
929            CommonCommand::Save => frame::save(&mut buf, tid),
930            CommonCommand::Clear => frame::clear(&mut buf, tid),
931            CommonCommand::Restore => frame::restore(&mut buf, tid),
932        };
933        let len = match encoded {
934            Ok(len) => len,
935            Err(_) => {
936                self.pending[tid as usize] = None;
937                return Err("could not encode common command");
938            }
939        };
940        self.pending_commands[tid as usize] = Some(command);
941        self.queue_frame(&buf[..len]);
942        Ok(())
943    }
944
945    /// Feed a byte-stream chunk (serial) or one ATT value (BLE).
946    pub fn ingest(&mut self, bytes: &[u8]) {
947        match self.transport {
948            Transport::SerialHdlc => {
949                for &byte in bytes {
950                    let outcome = self
951                        .hdlc
952                        .push(byte)
953                        .map(|result| result.map(<[u8]>::to_vec));
954                    if let Some(outcome) = outcome {
955                        match outcome {
956                            Ok(frame) => self.ingest_frame(&frame),
957                            Err(error) => {
958                                self.protocol_error(format!("HDLC decode error: {error:?}"))
959                            }
960                        }
961                    }
962                }
963            }
964            Transport::BleSar => {
965                let outcome = self
966                    .sar
967                    .push(bytes)
968                    .map(|result| result.map(<[u8]>::to_vec));
969                if let Some(outcome) = outcome {
970                    match outcome {
971                        Ok(frame) => self.ingest_frame(&frame),
972                        Err(error) => {
973                            self.protocol_error(format!("BLE SAR decode error: {error:?}"))
974                        }
975                    }
976                }
977            }
978        }
979    }
980
981    pub fn tick(&mut self, now_ms: u64) {
982        self.now_ms = now_ms;
983        for tid in 1..=TID_MAX {
984            if self.pending[tid as usize].is_some_and(|pending| pending.deadline_ms <= now_ms) {
985                let pending = self.pending[tid as usize].take().unwrap();
986                if let Some(command) = self.pending_commands[tid as usize].take() {
987                    self.events.push_back(Event::CommandResult {
988                        command: command.name(),
989                        status: "TIMEOUT".into(),
990                        success: false,
991                    });
992                }
993                self.protocol_error(format!(
994                    "timed out waiting for {} (TID {tid})",
995                    property_name(pending.key).unwrap_or("property response")
996                ));
997            }
998        }
999        self.pump_gets();
1000    }
1001
1002    pub fn take_outbound(&mut self) -> Option<Vec<u8>> {
1003        self.outbound.pop_front()
1004    }
1005
1006    pub fn take_event(&mut self) -> Option<Event> {
1007        self.events.pop_front()
1008    }
1009
1010    pub fn disconnected(&mut self, reason: impl Into<String>) {
1011        self.pending.fill(None);
1012        self.pending_commands.fill(None);
1013        self.reset_requested = false;
1014        self.queued_gets.clear();
1015        self.events.push_back(Event::Detached {
1016            reason: reason.into(),
1017        });
1018    }
1019
1020    fn queue_supported_property_refresh(&mut self, capabilities: &[u32]) {
1021        for spec in PROPERTY_SPECS {
1022            if !spec.readable
1023                || matches!(
1024                    spec.key,
1025                    prop::LAST_STATUS
1026                        | prop::PROTOCOL_VERSION
1027                        | prop::DEV_VERSION
1028                        | prop::CAPS
1029                        | prop::PHY_MTU
1030                )
1031                || spec
1032                    .capability
1033                    .is_some_and(|cap| !capabilities.contains(&cap))
1034            {
1035                continue;
1036            }
1037            if !self.queued_gets.contains(&spec.key)
1038                && !self
1039                    .pending
1040                    .iter()
1041                    .flatten()
1042                    .any(|pending| pending.key == spec.key)
1043            {
1044                self.queued_gets.push_back(spec.key);
1045            }
1046        }
1047        self.pump_gets();
1048    }
1049
1050    fn pump_gets(&mut self) {
1051        while let Some(key) = self.queued_gets.pop_front() {
1052            match self.prop_get(key) {
1053                Ok(()) => {}
1054                Err("all ULCP transaction identifiers are busy") => {
1055                    self.queued_gets.push_front(key);
1056                    break;
1057                }
1058                Err(error) => {
1059                    self.protocol_error(format!("could not refresh property {key}: {error}"))
1060                }
1061            }
1062        }
1063    }
1064
1065    fn reserve_tid(&mut self, key: u32) -> Result<u8, &'static str> {
1066        for _ in 0..TID_MAX {
1067            let tid = self.next_tid;
1068            self.next_tid = if tid == TID_MAX { 1 } else { tid + 1 };
1069            if self.pending[tid as usize].is_none() {
1070                self.pending[tid as usize] = Some(Pending {
1071                    key,
1072                    deadline_ms: self.now_ms.saturating_add(RESPONSE_TIMEOUT_MS),
1073                });
1074                return Ok(tid);
1075            }
1076        }
1077        Err("all ULCP transaction identifiers are busy")
1078    }
1079
1080    fn queue_frame(&mut self, bytes: &[u8]) {
1081        self.trace(Direction::HostToDevice, bytes);
1082        match self.transport {
1083            Transport::SerialHdlc => {
1084                let mut encoded = vec![0; hdlc::max_encoded_len(bytes.len())];
1085                let len =
1086                    hdlc::encode_frame(bytes, &mut encoded).expect("sized for HDLC worst case");
1087                encoded.truncate(len);
1088                self.outbound.push_back(encoded);
1089            }
1090            Transport::BleSar => {
1091                for segment in gatt::segments(bytes, self.ble_segment_payload) {
1092                    let mut encoded = vec![0; segment.payload().len() + 1];
1093                    let len = segment
1094                        .write_to(&mut encoded)
1095                        .expect("exact SAR segment size");
1096                    encoded.truncate(len);
1097                    self.outbound.push_back(encoded);
1098                }
1099            }
1100        }
1101    }
1102
1103    fn ingest_frame(&mut self, bytes: &[u8]) {
1104        self.trace(Direction::DeviceToHost, bytes);
1105        let Ok(frame) = Frame::parse(bytes) else {
1106            self.protocol_error("malformed ULCP frame".into());
1107            return;
1108        };
1109        let Some(command) = frame.command() else {
1110            self.protocol_error(format!("unknown ULCP command {}", frame.cmd));
1111            return;
1112        };
1113        if command == Cmd::StrRecv {
1114            self.ingest_stream(frame.payload);
1115            return;
1116        }
1117        if !matches!(command, Cmd::PropIs | Cmd::PropInserted | Cmd::PropRemoved) {
1118            return;
1119        }
1120        let Ok(payload) = PropPayload::parse(frame.payload) else {
1121            self.protocol_error("malformed property payload".into());
1122            return;
1123        };
1124        let tid = frame.header.tid();
1125        let unsolicited = tid == 0;
1126        let pending = if unsolicited {
1127            None
1128        } else {
1129            match self.pending[tid as usize].take() {
1130                Some(pending) if pending.key == payload.key || payload.key == prop::LAST_STATUS => {
1131                    Some(pending)
1132                }
1133                Some(pending) => {
1134                    self.pending[tid as usize] = Some(pending);
1135                    self.protocol_error(format!(
1136                        "TID {tid} returned unexpected property {}",
1137                        payload.key
1138                    ));
1139                    None
1140                }
1141                None => {
1142                    self.protocol_error(format!("response for unused TID {tid}"));
1143                    None
1144                }
1145            }
1146        };
1147        let mut refresh_after_command = false;
1148        if payload.key == prop::LAST_STATUS && !unsolicited {
1149            if let Some(command) = self.pending_commands[tid as usize].take() {
1150                let (status, success) = match pui::decode(payload.value) {
1151                    Ok((code, _)) => (format!("{:?}", umsh_ulcp::Status(code)), code == 0),
1152                    Err(_) => ("MALFORMED STATUS".into(), false),
1153                };
1154                self.events.push_back(Event::CommandResult {
1155                    command: command.name(),
1156                    status,
1157                    success,
1158                });
1159                refresh_after_command = success;
1160            }
1161        }
1162        if payload.key == prop::LAST_STATUS
1163            && pending.is_some_and(|pending| pending.key != prop::LAST_STATUS)
1164        {
1165            let requested = pending.expect("checked above").key;
1166            let status = pui::decode(payload.value)
1167                .map(|(code, _)| format!("{:?}", umsh_ulcp::Status(code)))
1168                .unwrap_or_else(|_| "MALFORMED STATUS".into());
1169            self.events.push_back(Event::PropertyError {
1170                key: requested,
1171                name: property_name(requested),
1172                status,
1173            });
1174            self.pump_gets();
1175            return;
1176        }
1177        self.events.push_back(Event::Property {
1178            key: payload.key,
1179            name: property_name(payload.key),
1180            value_hex: hex(payload.value),
1181            decoded: decode_property(payload.key, payload.value),
1182            unsolicited,
1183        });
1184        if payload.key == prop::LAST_STATUS && unsolicited && self.attach.announced {
1185            if let Ok((code, _)) = pui::decode(payload.value) {
1186                let status = umsh_ulcp::Status(code);
1187                if status.is_reset() {
1188                    let restore_tid = (1..=TID_MAX).find(|tid| {
1189                        self.pending_commands[*tid as usize] == Some(CommonCommand::Restore)
1190                    });
1191                    if self.reset_requested {
1192                        self.events.push_back(Event::CommandResult {
1193                            command: "reset",
1194                            status: format!("{status:?}"),
1195                            success: true,
1196                        });
1197                    } else if status == umsh_ulcp::Status::RESET_RESTORED {
1198                        if let Some(tid) = restore_tid {
1199                            self.events.push_back(Event::CommandResult {
1200                                command: "restore",
1201                                status: format!("{status:?}"),
1202                                success: true,
1203                            });
1204                            self.pending[tid as usize] = None;
1205                        }
1206                    }
1207                    self.reset_requested = false;
1208                    self.pending.fill(None);
1209                    self.pending_commands.fill(None);
1210                    self.queued_gets.clear();
1211                    refresh_after_command = true;
1212                }
1213            }
1214        }
1215        if self.attach.requested {
1216            self.capture_attach_property(payload.key, payload.value);
1217            self.maybe_announce_attached();
1218        }
1219        if refresh_after_command {
1220            self.refresh_known_properties();
1221        }
1222        self.pump_gets();
1223    }
1224
1225    fn ingest_stream(&mut self, payload: &[u8]) {
1226        let Ok(payload) = StreamPayload::parse(payload) else {
1227            self.protocol_error("malformed stream payload".into());
1228            return;
1229        };
1230        let (metadata, metadata_error) = match BufferedRxMeta::decode(payload.metadata) {
1231            Ok(meta) => (
1232                Some(RxMetadata {
1233                    rssi_dbm: meta.rx.rssi_dbm,
1234                    lqi: meta.rx.lqi.map(|value| value.get()),
1235                    snr_cb: meta.rx.snr_cb,
1236                    buffered: meta.flags & RX_FLAG_BUFFERED != 0,
1237                    acknowledged: meta.flags & RX_FLAG_ACKED != 0,
1238                    age_s: meta.age_s,
1239                }),
1240                None,
1241            ),
1242            Err(error) => (None, Some(format!("{error:?}"))),
1243        };
1244        let (packet, packet_error) = if payload.stream == stream::PHY_RAW {
1245            match PacketHeader::parse(payload.data) {
1246                Ok(header) => (Some(summarize_packet(payload.data, &header)), None),
1247                Err(error) => (None, Some(format!("{error:?}"))),
1248            }
1249        } else {
1250            (None, None)
1251        };
1252        self.events.push_back(Event::StreamRx {
1253            timestamp_ms: self.now_ms,
1254            stream: payload.stream,
1255            data_hex: hex(payload.data),
1256            metadata,
1257            metadata_error,
1258            packet,
1259            packet_error,
1260        });
1261    }
1262
1263    fn capture_attach_property(&mut self, key: u32, value: &[u8]) {
1264        match key {
1265            prop::LAST_STATUS => {
1266                self.attach.boot_status = Some(match pui::decode(value) {
1267                    Ok((status, consumed)) if consumed == value.len() => {
1268                        format!("{:?}", umsh_ulcp::Status(status))
1269                    }
1270                    _ => "MALFORMED".into(),
1271                });
1272            }
1273            prop::PROTOCOL_VERSION if value.len() == 2 => {
1274                self.attach.protocol = Some((value[0], value[1]));
1275                if value[0] != PROTOCOL_MAJOR_VERSION {
1276                    self.protocol_error(format!(
1277                        "unsupported protocol major version {}; expected {PROTOCOL_MAJOR_VERSION}",
1278                        value[0]
1279                    ));
1280                }
1281            }
1282            prop::DEV_VERSION => {
1283                let value = value.strip_suffix(&[0]).unwrap_or(value);
1284                self.attach.dev_version = Some(String::from_utf8_lossy(value).into_owned());
1285            }
1286            prop::CAPS => {
1287                let mut rest = value;
1288                let mut caps = Vec::new();
1289                while !rest.is_empty() {
1290                    match pui::decode(rest) {
1291                        Ok((cap, used)) => {
1292                            caps.push(cap);
1293                            rest = &rest[used..];
1294                        }
1295                        Err(_) => {
1296                            self.protocol_error("malformed PROP_CAPS".into());
1297                            return;
1298                        }
1299                    }
1300                }
1301                self.attach.capabilities = Some(caps);
1302            }
1303            prop::PHY_MTU if value.len() == 2 => {
1304                self.attach.phy_mtu = Some(u16::from_le_bytes([value[0], value[1]]));
1305            }
1306            _ => {}
1307        }
1308    }
1309
1310    fn maybe_announce_attached(&mut self) {
1311        if self.attach.announced {
1312            return;
1313        }
1314        let (
1315            Some(boot_status),
1316            Some((protocol_major, protocol_minor)),
1317            Some(dev_version),
1318            Some(capabilities),
1319            Some(phy_mtu),
1320        ) = (
1321            self.attach.boot_status.clone(),
1322            self.attach.protocol,
1323            self.attach.dev_version.clone(),
1324            self.attach.capabilities.clone(),
1325            self.attach.phy_mtu,
1326        )
1327        else {
1328            return;
1329        };
1330        self.attach.announced = true;
1331        self.events.push_back(Event::Attached {
1332            protocol_major,
1333            protocol_minor,
1334            dev_version,
1335            boot_status,
1336            capabilities: capabilities
1337                .iter()
1338                .copied()
1339                .map(|code| Capability {
1340                    code,
1341                    name: capability_name(code),
1342                })
1343                .collect(),
1344            phy_mtu,
1345        });
1346        self.queue_supported_property_refresh(&capabilities);
1347    }
1348
1349    fn trace(&mut self, direction: Direction, bytes: &[u8]) {
1350        let redacted = direction == Direction::HostToDevice && secret_bearing_write(bytes);
1351        self.events.push_back(Event::Trace {
1352            timestamp_ms: self.now_ms,
1353            direction,
1354            summary: FrameDescription(bytes).to_string(),
1355            raw_hex: (!redacted).then(|| hex(bytes)),
1356            redacted,
1357        });
1358    }
1359
1360    fn protocol_error(&mut self, message: String) {
1361        self.events.push_back(Event::ProtocolError { message });
1362    }
1363}
1364
1365fn decode_property(key: u32, value: &[u8]) -> Option<DecodedValue> {
1366    let decoded = match key {
1367        prop::LAST_STATUS => {
1368            let (code, used) = pui::decode(value).ok()?;
1369            let status = format!("{:?}", umsh_ulcp::Status(code));
1370            let detail = value.get(used..)?;
1371            let detail = detail.strip_suffix(&[0]).unwrap_or(detail);
1372            let detail = String::from_utf8_lossy(detail);
1373            let display = if detail.is_empty() {
1374                status
1375            } else {
1376                format!("{status}: {detail}")
1377            };
1378            ("status", display)
1379        }
1380        prop::PROTOCOL_VERSION if value.len() == 2 => {
1381            ("version", format!("{}.{}", value[0], value[1]))
1382        }
1383        prop::DEV_VERSION => {
1384            let value = value.strip_suffix(&[0]).unwrap_or(value);
1385            ("string", String::from_utf8(value.to_vec()).ok()?)
1386        }
1387        prop::DEV_NAME => ("string", String::from_utf8(value.to_vec()).ok()?),
1388        prop::INTERFACE_TYPE => {
1389            let (interface, used) = pui::decode(value).ok()?;
1390            if used != value.len() {
1391                return None;
1392            }
1393            let display = if interface == umsh_ulcp::ids::INTERFACE_TYPE {
1394                format!("UMSH ({interface})")
1395            } else {
1396                interface.to_string()
1397            };
1398            ("enum", display)
1399        }
1400        prop::ALERT => {
1401            let (code, used) = pui::decode(value).ok()?;
1402            if used != value.len() {
1403                return None;
1404            }
1405            let display = match umsh_ulcp::alert::AlertState::from_code(code)? {
1406                umsh_ulcp::alert::AlertState::None => "ALERT_NONE",
1407                umsh_ulcp::alert::AlertState::Locate => "ALERT_LOCATE",
1408            };
1409            ("enum", display.to_string())
1410        }
1411        prop::CAPS => {
1412            let mut rest = value;
1413            let mut items = Vec::new();
1414            while !rest.is_empty() {
1415                let (code, used) = pui::decode(rest).ok()?;
1416                let item = capability_name(code)
1417                    .map(|name| format!("{name} ({code})"))
1418                    .unwrap_or_else(|| code.to_string());
1419                items.push(item);
1420                rest = &rest[used..];
1421            }
1422            (
1423                "capability_list",
1424                if items.is_empty() {
1425                    "none".into()
1426                } else {
1427                    items.join(", ")
1428                },
1429            )
1430        }
1431        prop::PHY_ENABLED
1432        | prop::MAC_PROMISCUOUS
1433        | prop::SAVED
1434        | prop::HOST_AUTO_ACK
1435        | prop::GNSS_ENABLED
1436        | prop::GNSS_IDENT_UPDATE
1437        | prop::GNSS_TIME_TRUST
1438            if value.len() == 1 && value[0] <= 1 =>
1439        {
1440            ("boolean", (value[0] != 0).to_string())
1441        }
1442        // The state worth being able to see at a glance: a device that
1443        // does not know what time it is, and must therefore show no clock.
1444        prop::TIME if value.is_empty() => ("time", "not set".into()),
1445        prop::TIME if value.len() == 4 => {
1446            let epoch = u32::from_le_bytes(value.try_into().ok()?);
1447            let at = umsh_gnss::DateTime::from_unix(epoch);
1448            (
1449                "time",
1450                format!(
1451                    "{:04}-{:02}-{:02} {:02}:{:02}:{:02}Z ({epoch})",
1452                    at.year, at.month, at.day, at.hour, at.minute, at.second
1453                ),
1454            )
1455        }
1456        prop::TZ_OFFSET if value.len() == 2 => {
1457            let minutes = i16::from_le_bytes(value.try_into().ok()?);
1458            let magnitude = minutes.unsigned_abs();
1459            (
1460                "tz_offset",
1461                format!(
1462                    "UTC{}{:02}:{:02}",
1463                    if minutes < 0 { '-' } else { '+' },
1464                    magnitude / 60,
1465                    magnitude % 60
1466                ),
1467            )
1468        }
1469        prop::GNSS_FIX if value.len() == 1 => (
1470            "enum",
1471            match umsh_ulcp::gnss::FixKind::from_code(value[0])? {
1472                umsh_ulcp::gnss::FixKind::None => "no fix",
1473                umsh_ulcp::gnss::FixKind::TwoD => "2D",
1474                umsh_ulcp::gnss::FixKind::ThreeD => "3D",
1475            }
1476            .to_string(),
1477        ),
1478        prop::GNSS_LOCATION if value.is_empty() => ("location", "no fix".into()),
1479        prop::GNSS_LOCATION if value.len() <= 7 => (
1480            "location",
1481            format!("{} ({} bytes precision)", hex(value), value.len()),
1482        ),
1483        prop::GNSS_ALTITUDE if value.is_empty() => ("int32", "unknown".into()),
1484        prop::GNSS_ALTITUDE if value.len() == 4 => (
1485            "int32",
1486            format!("{} m", i32::from_le_bytes(value.try_into().ok()?)),
1487        ),
1488        prop::GNSS_PRECISION if value.is_empty() => ("uint16", "unknown".into()),
1489        prop::GNSS_PRECISION if value.len() == 2 => {
1490            let dm = u16::from_le_bytes(value.try_into().ok()?);
1491            ("uint16", format!("~{}.{} m (estimated)", dm / 10, dm % 10))
1492        }
1493        prop::GNSS_SATELLITES => match value {
1494            [used] => ("satellites", format!("{used} used")),
1495            [used, in_view] => ("satellites", format!("{used} used of {in_view} in view")),
1496            _ => return None,
1497        },
1498        prop::GNSS_IDENT_PRECISION if value.len() == 1 => ("uint8", format!("{} bytes", value[0])),
1499        prop::ILLUMINANCE if value.is_empty() => ("uint32", "no reading".into()),
1500        prop::ILLUMINANCE if value.len() == 4 => {
1501            let millilux = u32::from_le_bytes(value.try_into().ok()?);
1502            (
1503                "uint32",
1504                format!("{}.{:03} lux", millilux / 1000, millilux % 1000),
1505            )
1506        }
1507        prop::ADVERT_INTERVAL | prop::BEACON_INTERVAL if value.len() == 4 => {
1508            let seconds = u32::from_le_bytes(value.try_into().ok()?);
1509            match seconds {
1510                0 => ("uint32", "off".to_string()),
1511                seconds => ("uint32", format!("every {seconds} s")),
1512            }
1513        }
1514        prop::PHY_TX_POWER | prop::PHY_RSSI if value.len() == 1 => {
1515            ("dbm", format!("{} dBm", value[0] as i8))
1516        }
1517        prop::PHY_LORA_SF | prop::PHY_LORA_CR if value.len() == 1 => {
1518            ("uint8", value[0].to_string())
1519        }
1520        prop::PHY_MTU | prop::HOST_RX_QUEUE_COUNT | prop::HOST_RX_QUEUE_CAPACITY
1521            if value.len() == 2 =>
1522        {
1523            let number = u16::from_le_bytes(value.try_into().ok()?);
1524            let suffix = if key == prop::PHY_MTU {
1525                " octets"
1526            } else {
1527                " frames"
1528            };
1529            ("uint16", format!("{number}{suffix}"))
1530        }
1531        prop::PHY_LORA_SW if value.len() == 2 => (
1532            "uint16",
1533            format!("0x{:04x}", u16::from_le_bytes(value.try_into().ok()?)),
1534        ),
1535        prop::PHY_DUTY_NOW | prop::PHY_DUTY_LIMIT if value.len() == 2 => {
1536            let number = u16::from_le_bytes(value.try_into().ok()?);
1537            (
1538                "duty_cycle",
1539                format!("{:.3}% ({number})", f64::from(number) * 100.0 / 65535.0),
1540            )
1541        }
1542        prop::PHY_FREQ | prop::PHY_LORA_BW | prop::HOST_RX_QUEUE_DROPPED if value.len() == 4 => {
1543            let number = u32::from_le_bytes(value.try_into().ok()?);
1544            let suffix = match key {
1545                prop::PHY_FREQ => " kHz",
1546                prop::PHY_LORA_BW => " Hz",
1547                _ => " frames",
1548            };
1549            ("uint32", format!("{number}{suffix}"))
1550        }
1551        prop::BATTERY => {
1552            let status = umsh_ulcp::battery::BatteryStatus::decode(value).ok()?;
1553            let display = if status.is_empty() {
1554                "reporting unsupported".to_string()
1555            } else {
1556                let voltage = status
1557                    .voltage_mv
1558                    .map_or("voltage unsupported".to_string(), |mv| format!("{mv} mV"));
1559                let level = status
1560                    .level_percent
1561                    .map_or("level unsupported".to_string(), |percent| {
1562                        format!("{percent}%")
1563                    });
1564                let state = match status.charge_state {
1565                    Some(umsh_ulcp::battery::BatteryChargeState::Discharging) => "discharging",
1566                    Some(umsh_ulcp::battery::BatteryChargeState::Charging) => "charging",
1567                    Some(umsh_ulcp::battery::BatteryChargeState::Charged) => "charged",
1568                    None => "charge state unsupported",
1569                };
1570                format!("{voltage}, {level}, {state}")
1571            };
1572            ("battery", display)
1573        }
1574        prop::DEV_KEY | prop::HOST_KEY if value.is_empty() => {
1575            ("public_key", "not configured".into())
1576        }
1577        prop::DEV_KEY | prop::HOST_KEY if value.len() == 32 => {
1578            let key = PublicKey(value.try_into().ok()?);
1579            ("public_key", key.to_string())
1580        }
1581        prop::DEV_CHANNEL_KEYS | prop::HOST_CHANNEL_KEYS => {
1582            let items = items::fixed_items::<2>(value).ok()?;
1583            let values = items.map(|item| hex(item)).collect::<Vec<_>>();
1584            ("channel_list", display_list(values))
1585        }
1586        prop::DEV_PEERS | prop::HOST_PEER_KEYS => {
1587            let items = items::fixed_items::<32>(value).ok()?;
1588            let values = items
1589                .map(|item| PublicKey(*item).to_string())
1590                .collect::<Vec<_>>();
1591            ("public_key_list", display_list(values))
1592        }
1593        prop::HOST_RX_FILTERS => {
1594            let prefixed = items::prefixed_items(value)
1595                .map(|item| item.and_then(Filter::decode))
1596                .collect::<Result<Vec<_>, _>>();
1597            let filters = match prefixed {
1598                Ok(filters) => filters,
1599                Err(_) => vec![Filter::decode(value).ok()?],
1600            };
1601            let values = filters.into_iter().map(format_filter).collect();
1602            ("filter_list", display_list(values))
1603        }
1604        _ => return None,
1605    };
1606    Some(DecodedValue {
1607        kind: decoded.0,
1608        display: decoded.1,
1609        edit: editable_property_text(key, value),
1610    })
1611}
1612
1613fn editable_property_text(key: u32, value: &[u8]) -> Option<String> {
1614    match key {
1615        prop::PHY_ENABLED
1616        | prop::MAC_PROMISCUOUS
1617        | prop::HOST_AUTO_ACK
1618        | prop::GNSS_ENABLED
1619        | prop::GNSS_IDENT_UPDATE
1620        | prop::GNSS_TIME_TRUST
1621            if value.len() == 1 && value[0] <= 1 =>
1622        {
1623            Some((value[0] != 0).to_string())
1624        }
1625        // An unset clock has no text to edit; typing one is how it is set,
1626        // and an empty write is how it goes back to unset.
1627        prop::TIME if value.len() == 4 => {
1628            Some(u32::from_le_bytes(value.try_into().ok()?).to_string())
1629        }
1630        prop::TZ_OFFSET if value.len() == 2 => {
1631            Some(i16::from_le_bytes(value.try_into().ok()?).to_string())
1632        }
1633        prop::GNSS_IDENT_PRECISION if value.len() == 1 => Some(value[0].to_string()),
1634        prop::PHY_TX_POWER if value.len() == 1 => Some((value[0] as i8).to_string()),
1635        prop::PHY_LORA_SF | prop::PHY_LORA_CR if value.len() == 1 => Some(value[0].to_string()),
1636        prop::PHY_MTU | prop::HOST_RX_QUEUE_CAPACITY | prop::PHY_DUTY_LIMIT if value.len() == 2 => {
1637            Some(u16::from_le_bytes(value.try_into().ok()?).to_string())
1638        }
1639        prop::PHY_LORA_SW if value.len() == 2 => Some(format!(
1640            "0x{:04x}",
1641            u16::from_le_bytes(value.try_into().ok()?)
1642        )),
1643        prop::PHY_FREQ | prop::PHY_LORA_BW | prop::ADVERT_INTERVAL | prop::BEACON_INTERVAL
1644            if value.len() == 4 =>
1645        {
1646            Some(u32::from_le_bytes(value.try_into().ok()?).to_string())
1647        }
1648        prop::DEV_NAME => String::from_utf8(value.to_vec()).ok(),
1649        _ => None,
1650    }
1651}
1652
1653fn encode_property_text(key: u32, value: &str) -> Result<Vec<u8>, String> {
1654    let text = value;
1655    let value = value.trim();
1656    match key {
1657        prop::PHY_ENABLED
1658        | prop::MAC_PROMISCUOUS
1659        | prop::HOST_AUTO_ACK
1660        | prop::GNSS_ENABLED
1661        | prop::GNSS_IDENT_UPDATE
1662        | prop::GNSS_TIME_TRUST => {
1663            let parsed = match value.to_ascii_lowercase().as_str() {
1664                "true" | "1" | "on" | "yes" => 1,
1665                "false" | "0" | "off" | "no" => 0,
1666                _ => return Err("enter true or false".into()),
1667            };
1668            Ok(vec![parsed])
1669        }
1670        // An empty write returns the device to not knowing what time it
1671        // is, so it is a legitimate value rather than a parse failure.
1672        prop::TIME if value.is_empty() => Ok(Vec::new()),
1673        prop::TIME => Ok(
1674            parse_integer::<u32>(value, "Unix seconds, or empty to clear the clock")?
1675                .to_le_bytes()
1676                .to_vec(),
1677        ),
1678        prop::TZ_OFFSET => Ok(parse_integer::<i16>(
1679            value,
1680            "minutes east of UTC, from -720 to 840",
1681        )?
1682        .to_le_bytes()
1683        .to_vec()),
1684        prop::GNSS_IDENT_PRECISION => Ok(vec![parse_integer::<u8>(
1685            value,
1686            "a location precision from 1 to 7 bytes",
1687        )?]),
1688        prop::ADVERT_INTERVAL | prop::BEACON_INTERVAL => Ok(parse_integer::<u32>(
1689            value,
1690            "seconds, from 1200 (20 m) to 86400 (24 h), or 0 to send none",
1691        )?
1692        .to_le_bytes()
1693        .to_vec()),
1694        prop::PHY_TX_POWER => Ok(vec![
1695            parse_integer::<i8>(value, "an 8-bit signed integer")? as u8
1696        ]),
1697        prop::PHY_LORA_SF | prop::PHY_LORA_CR => Ok(vec![parse_integer::<u8>(
1698            value,
1699            "an 8-bit unsigned integer",
1700        )?]),
1701        prop::PHY_LORA_SW => Ok(parse_u16(value)?.to_le_bytes().to_vec()),
1702        prop::PHY_DUTY_LIMIT | prop::HOST_RX_QUEUE_CAPACITY => {
1703            Ok(parse_integer::<u16>(value, "a number from 0 to 65535")?
1704                .to_le_bytes()
1705                .to_vec())
1706        }
1707        prop::PHY_FREQ | prop::PHY_LORA_BW => {
1708            Ok(parse_integer::<u32>(value, "a 32-bit unsigned integer")?
1709                .to_le_bytes()
1710                .to_vec())
1711        }
1712        prop::DEV_NAME => {
1713            if text.is_empty() || text.len() > 64 || text.contains('\0') {
1714                return Err("device name must contain 1–64 UTF-8 octets".into());
1715            }
1716            Ok(text.as_bytes().to_vec())
1717        }
1718        _ => Err("this property does not have a typed editor".into()),
1719    }
1720}
1721
1722fn parse_integer<T>(value: &str, expected: &str) -> Result<T, String>
1723where
1724    T: std::str::FromStr,
1725{
1726    value
1727        .replace('_', "")
1728        .parse()
1729        .map_err(|_| format!("enter {expected}"))
1730}
1731
1732fn parse_u16(value: &str) -> Result<u16, String> {
1733    let compact = value.replace('_', "");
1734    if let Some(hex) = compact
1735        .strip_prefix("0x")
1736        .or_else(|| compact.strip_prefix("0X"))
1737    {
1738        u16::from_str_radix(hex, 16).map_err(|_| "enter a 16-bit integer such as 0x1424".into())
1739    } else {
1740        parse_integer(&compact, "a 16-bit integer such as 0x1424")
1741    }
1742}
1743
1744fn display_list(values: Vec<String>) -> String {
1745    if values.is_empty() {
1746        "none".into()
1747    } else {
1748        values.join(", ")
1749    }
1750}
1751
1752fn format_filter(filter: Filter) -> String {
1753    match filter {
1754        Filter::DestHint(bytes) => format!("destination {}", umsh_core::NodeHint(bytes)),
1755        Filter::ChannelId(bytes) => format!("channel {}", hex(&bytes)),
1756        Filter::PktType(packet_type) => format!("packet type {packet_type}"),
1757    }
1758}
1759
1760fn summarize_packet(bytes: &[u8], header: &PacketHeader) -> PacketSummary {
1761    let source = match header.source {
1762        SourceAddrRef::Hint(hint) => Some(hint.to_string()),
1763        SourceAddrRef::FullKeyAt { offset } => bytes
1764            .get(offset..offset + 32)
1765            .and_then(|bytes| <&[u8; 32]>::try_from(bytes).ok())
1766            .map(|bytes| PublicKey(*bytes).to_string()),
1767        SourceAddrRef::Encrypted { .. } => Some("encrypted".into()),
1768        SourceAddrRef::None => None,
1769    };
1770    let encrypted = header
1771        .sec_info
1772        .is_some_and(|security| security.scf.encrypted());
1773    let payload_type = (!encrypted && header.packet_type() != PacketType::MacAck)
1774        .then(|| {
1775            bytes
1776                .get(header.body_range.start)
1777                .and_then(|byte| PayloadType::from_byte(*byte))
1778                .map(|payload_type| format!("{payload_type:?}"))
1779        })
1780        .flatten();
1781    let (options, options_error) = match ParsedOptions::extract(bytes, header.options_range.clone())
1782    {
1783        Ok(options) => (
1784            Some(PacketOptionsSummary {
1785                region_code: options
1786                    .region_code
1787                    .map(|region| String::from_utf8_lossy(&region).into_owned()),
1788                source_route_len: options.source_route.map(|route| route.len()),
1789                trace_route_len: options.trace_route.map(|route| route.len()),
1790                min_rssi_dbm: options.min_rssi,
1791                min_snr_db: options.min_snr,
1792                route_retry: options.route_retry,
1793                unknown_critical: options.has_unknown_critical,
1794            }),
1795            None,
1796        ),
1797        Err(error) => (None, Some(format!("{error:?}"))),
1798    };
1799    PacketSummary {
1800        packet_type: format!("{:?}", header.packet_type()),
1801        source,
1802        destination: header.dst.map(|hint| hint.to_string()),
1803        channel_hex: header.channel.map(|channel| hex(&channel.0)),
1804        frame_counter: header.sec_info.map(|info| info.frame_counter),
1805        encrypted,
1806        ack_requested: header.ack_requested(),
1807        flood_remaining: header.flood_hops.map(|hops| hops.remaining()),
1808        flood_accumulated: header.flood_hops.map(|hops| hops.accumulated()),
1809        header_len: header.body_range.start,
1810        body_len: header.body_range.len(),
1811        mic_len: header.mic_range.len(),
1812        body_hex: hex(&bytes[header.body_range.clone()]),
1813        payload_type,
1814        options,
1815        options_error,
1816    }
1817}
1818
1819fn secret_bearing_write(bytes: &[u8]) -> bool {
1820    let Ok(frame) = Frame::parse(bytes) else {
1821        return false;
1822    };
1823    if !matches!(frame.command(), Some(Cmd::PropSet | Cmd::PropInsert)) {
1824        return false;
1825    }
1826    let Ok(payload) = PropPayload::parse(frame.payload) else {
1827        return false;
1828    };
1829    matches!(
1830        payload.key,
1831        prop::DEV_PRIVATE_KEY
1832            | prop::DEV_CHANNEL_KEYS
1833            | prop::HOST_CHANNEL_KEYS
1834            | prop::HOST_PEER_KEYS
1835            | prop::BLE_PAIRING_PIN
1836    )
1837}
1838
1839fn hex(bytes: &[u8]) -> String {
1840    use std::fmt::Write as _;
1841    let mut out = String::with_capacity(bytes.len() * 2);
1842    for byte in bytes {
1843        write!(out, "{byte:02x}").expect("writing to String cannot fail");
1844    }
1845    out
1846}
1847
1848#[cfg(target_arch = "wasm32")]
1849mod wasm {
1850    use super::*;
1851    use wasm_bindgen::prelude::*;
1852
1853    /// Minimal wasm-bindgen adapter. JSON events keep generated glue small and
1854    /// make the interface consumable by any browser UI architecture.
1855    #[wasm_bindgen(js_name = DebuggerEngine)]
1856    pub struct WebDebuggerEngine(DebuggerEngine);
1857
1858    #[cfg(feature = "sim-device")]
1859    #[wasm_bindgen(js_name = SimulatedDevice)]
1860    pub struct WebSimulatedDevice(SimulatedDevice);
1861
1862    #[wasm_bindgen(js_name = propertySpecs)]
1863    pub fn web_property_specs() -> String {
1864        serde_json::to_string(property_specs()).expect("property schema serialization")
1865    }
1866
1867    #[wasm_bindgen(js_class = DebuggerEngine)]
1868    impl WebDebuggerEngine {
1869        #[wasm_bindgen(constructor)]
1870        pub fn new(transport: &str) -> Result<WebDebuggerEngine, JsError> {
1871            Ok(Self(DebuggerEngine::new(parse_transport(transport)?)))
1872        }
1873
1874        pub fn set_transport(&mut self, transport: &str) -> Result<(), JsError> {
1875            self.0.set_transport(parse_transport(transport)?);
1876            Ok(())
1877        }
1878
1879        pub fn set_ble_segment_payload(&mut self, size: usize) -> Result<(), JsError> {
1880            self.0.set_ble_segment_payload(size).map_err(JsError::new)
1881        }
1882
1883        pub fn attach(&mut self) -> Result<(), JsError> {
1884            self.0.attach().map_err(JsError::new)
1885        }
1886
1887        pub fn prop_get(&mut self, key: u32) -> Result<(), JsError> {
1888            self.0.prop_get(key).map_err(JsError::new)
1889        }
1890
1891        pub fn refresh_known_properties(&mut self) {
1892            self.0.refresh_known_properties();
1893        }
1894
1895        pub fn prop_set(&mut self, key: u32, value: &[u8]) -> Result<(), JsError> {
1896            self.0.prop_set(key, value).map_err(JsError::new)
1897        }
1898
1899        pub fn prop_set_text(&mut self, key: u32, value: &str) -> Result<(), JsError> {
1900            self.0
1901                .prop_set_text(key, value)
1902                .map_err(|error| JsError::new(&error))
1903        }
1904
1905        pub fn prop_insert(&mut self, key: u32, value: &[u8]) -> Result<(), JsError> {
1906            self.0.prop_insert(key, value).map_err(JsError::new)
1907        }
1908
1909        pub fn prop_remove(&mut self, key: u32, value: &[u8]) -> Result<(), JsError> {
1910            self.0.prop_remove(key, value).map_err(JsError::new)
1911        }
1912
1913        pub fn command(&mut self, command: &str) -> Result<(), JsError> {
1914            self.0.command(command).map_err(JsError::new)
1915        }
1916
1917        pub fn ingest(&mut self, bytes: &[u8]) {
1918            self.0.ingest(bytes);
1919        }
1920
1921        pub fn tick(&mut self, now_ms: f64) {
1922            self.0.tick(browser_millis(now_ms));
1923        }
1924
1925        pub fn take_outbound(&mut self) -> Option<Vec<u8>> {
1926            self.0.take_outbound()
1927        }
1928
1929        pub fn take_event(&mut self) -> Option<String> {
1930            self.0
1931                .take_event()
1932                .map(|event| serde_json::to_string(&event).expect("event serialization"))
1933        }
1934
1935        pub fn disconnected(&mut self, reason: String) {
1936            self.0.disconnected(reason);
1937        }
1938    }
1939
1940    #[cfg(feature = "sim-device")]
1941    #[wasm_bindgen(js_class = SimulatedDevice)]
1942    impl WebSimulatedDevice {
1943        #[wasm_bindgen(constructor)]
1944        pub fn new() -> WebSimulatedDevice {
1945            Self(SimulatedDevice::new())
1946        }
1947
1948        pub fn attach(&mut self) {
1949            self.0.attach();
1950        }
1951
1952        pub fn detach(&mut self) {
1953            self.0.detach();
1954        }
1955
1956        pub fn ingest(&mut self, bytes: &[u8], now_ms: f64) -> Result<(), JsError> {
1957            self.0
1958                .ingest(bytes, browser_millis(now_ms))
1959                .map_err(|error| JsError::new(&error))
1960        }
1961
1962        pub fn take_outbound(&mut self) -> Option<Vec<u8>> {
1963            self.0.take_outbound()
1964        }
1965
1966        pub fn inject_radio_rx(&mut self, bytes: &[u8], now_ms: f64) {
1967            self.0.inject_radio_rx(bytes, browser_millis(now_ms));
1968        }
1969
1970        pub fn inject_demo_rx(&mut self, now_ms: f64) {
1971            self.0.inject_demo_rx(browser_millis(now_ms));
1972        }
1973    }
1974
1975    fn browser_millis(value: f64) -> u64 {
1976        if value.is_finite() && value > 0.0 {
1977            value.min(u64::MAX as f64) as u64
1978        } else {
1979            0
1980        }
1981    }
1982
1983    fn parse_transport(value: &str) -> Result<Transport, JsError> {
1984        match value {
1985            "serial_hdlc" => Ok(Transport::SerialHdlc),
1986            "ble_sar" => Ok(Transport::BleSar),
1987            _ => Err(JsError::new("transport must be serial_hdlc or ble_sar")),
1988        }
1989    }
1990}
1991
1992#[cfg(test)]
1993mod tests {
1994    use super::*;
1995
1996    fn drain_serial_frame(engine: &mut DebuggerEngine) -> Vec<u8> {
1997        let wire = engine.take_outbound().unwrap();
1998        let mut decoder = hdlc::Decoder::<FRAME_CAPACITY>::new();
1999        wire.into_iter()
2000            .find_map(|byte| decoder.push(byte).map(|result| result.unwrap().to_vec()))
2001            .unwrap()
2002    }
2003
2004    fn send_property(engine: &mut DebuggerEngine, request: &[u8], value: &[u8]) {
2005        let parsed = Frame::parse(request).unwrap();
2006        let payload = PropPayload::parse(parsed.payload).unwrap();
2007        let mut response = [0; FRAME_CAPACITY];
2008        let len = frame::prop_is(&mut response, parsed.header.tid(), payload.key, value).unwrap();
2009        let mut wire = vec![0; hdlc::max_encoded_len(len)];
2010        let wire_len = hdlc::encode_frame(&response[..len], &mut wire).unwrap();
2011        engine.ingest(&wire[..wire_len]);
2012    }
2013
2014    fn ingest_serial_frame(engine: &mut DebuggerEngine, ulcp_frame: &[u8]) {
2015        let mut wire = vec![0; hdlc::max_encoded_len(ulcp_frame.len())];
2016        let wire_len = hdlc::encode_frame(ulcp_frame, &mut wire).unwrap();
2017        engine.ingest(&wire[..wire_len]);
2018    }
2019
2020    #[test]
2021    fn attach_correlates_properties_and_announces_dashboard() {
2022        let mut engine = DebuggerEngine::default();
2023        engine.attach().unwrap();
2024        let values: [&[u8]; 5] = [
2025            &[1],
2026            &[PROTOCOL_MAJOR_VERSION, 0],
2027            b"test-dev\0",
2028            &[8, 0x83, 0x04],
2029            &255u16.to_le_bytes(),
2030        ];
2031        for value in values {
2032            let request = drain_serial_frame(&mut engine);
2033            send_property(&mut engine, &request, value);
2034        }
2035        let events: Vec<_> = std::iter::from_fn(|| engine.take_event()).collect();
2036        assert!(events.iter().any(|event| matches!(event, Event::Attached {
2037            protocol_major: PROTOCOL_MAJOR_VERSION,
2038            dev_version,
2039            phy_mtu: 255,
2040            capabilities,
2041            ..
2042        } if dev_version == "test-dev"
2043            && capabilities.iter().map(|cap| cap.code).collect::<Vec<_>>() == [8, 515])));
2044    }
2045
2046    #[test]
2047    fn ble_segments_round_trip_and_secret_writes_are_redacted() {
2048        let mut engine = DebuggerEngine::new(Transport::BleSar);
2049        engine.set_ble_segment_payload(3).unwrap();
2050        engine.prop_set(prop::BLE_PAIRING_PIN, b"123456").unwrap();
2051        assert!(matches!(
2052            engine.take_event(),
2053            Some(Event::Trace {
2054                redacted: true,
2055                raw_hex: None,
2056                ..
2057            })
2058        ));
2059
2060        let segments: Vec<_> = std::iter::from_fn(|| engine.take_outbound()).collect();
2061        assert!(segments.len() > 1);
2062        let mut reassembler = gatt::Reassembler::<FRAME_CAPACITY>::new();
2063        let frame = segments
2064            .into_iter()
2065            .find_map(|segment| {
2066                reassembler
2067                    .push(&segment)
2068                    .map(|result| result.unwrap().to_vec())
2069            })
2070            .unwrap();
2071        assert_eq!(
2072            PropPayload::parse(Frame::parse(&frame).unwrap().payload)
2073                .unwrap()
2074                .value,
2075            b"123456"
2076        );
2077    }
2078
2079    #[test]
2080    fn timeout_releases_transaction_identifier() {
2081        let mut engine = DebuggerEngine::default();
2082        engine.prop_get(prop::PHY_MTU).unwrap();
2083        engine.tick(RESPONSE_TIMEOUT_MS);
2084        assert!(matches!(engine.take_event(), Some(Event::Trace { .. })));
2085        assert!(
2086            matches!(engine.take_event(), Some(Event::ProtocolError { message }) if message.contains("timed out"))
2087        );
2088    }
2089
2090    #[test]
2091    fn property_values_keep_raw_bytes_and_add_typed_display() {
2092        assert_eq!(
2093            decode_property(prop::PHY_FREQ, &915_000u32.to_le_bytes()),
2094            Some(DecodedValue {
2095                kind: "uint32",
2096                display: "915000 kHz".into(),
2097                edit: Some("915000".into()),
2098            })
2099        );
2100        assert_eq!(
2101            decode_property(prop::PHY_ENABLED, &[1]).unwrap().display,
2102            "true"
2103        );
2104        assert!(decode_property(prop::PHY_ENABLED, &[2]).is_none());
2105        assert!(decode_property(2_000, &[1, 2, 3]).is_none());
2106        assert_eq!(
2107            decode_property(prop::HOST_CHANNEL_KEYS, &[0x12, 0x34, 0x56, 0x78])
2108                .unwrap()
2109                .display,
2110            "1234, 5678"
2111        );
2112        assert_eq!(
2113            decode_property(prop::HOST_RX_FILTERS, &[2, 2, 0])
2114                .unwrap()
2115                .display,
2116            "packet type 0"
2117        );
2118    }
2119
2120    #[test]
2121    fn battery_snapshots_decode_to_distinct_presentations() {
2122        // The empty value: battery powered, reporting unsupported.
2123        assert_eq!(
2124            decode_property(prop::BATTERY, &[]).unwrap().display,
2125            "reporting unsupported"
2126        );
2127        // The T-1000E shape: voltage + charge state, no level.
2128        assert_eq!(
2129            decode_property(prop::BATTERY, &[0b101, 0x74, 0x0E, 0])
2130                .unwrap()
2131                .display,
2132            "3700 mV, level unsupported, discharging"
2133        );
2134        // The full simulator shape.
2135        assert_eq!(
2136            decode_property(prop::BATTERY, &[0b111, 0x0F, 0x10, 87, 1])
2137                .unwrap()
2138                .display,
2139            "4111 mV, 87%, charging"
2140        );
2141        // Malformed: reserved bit, bad length, unknown charge code.
2142        assert!(decode_property(prop::BATTERY, &[0b1000, 1]).is_none());
2143        assert!(decode_property(prop::BATTERY, &[0b001, 0x74]).is_none());
2144        assert!(decode_property(prop::BATTERY, &[0b100, 3]).is_none());
2145        // Battery is read-only: no typed editor.
2146        assert!(
2147            decode_property(prop::BATTERY, &[0b100, 2])
2148                .unwrap()
2149                .edit
2150                .is_none()
2151        );
2152    }
2153
2154    #[test]
2155    fn known_property_schema_and_typed_writes_share_protocol_types() {
2156        let mut keys = property_specs()
2157            .iter()
2158            .map(|spec| spec.key)
2159            .collect::<Vec<_>>();
2160        keys.sort_unstable();
2161        keys.dedup();
2162        assert_eq!(keys.len(), property_specs().len());
2163        assert!(
2164            property_specs()
2165                .iter()
2166                .all(|spec| property_name(spec.key) == Some(spec.name))
2167        );
2168
2169        let mut engine = DebuggerEngine::default();
2170        engine.prop_set_text(prop::PHY_FREQ, "915_000").unwrap();
2171        let request = drain_serial_frame(&mut engine);
2172        let payload = PropPayload::parse(Frame::parse(&request).unwrap().payload).unwrap();
2173        assert_eq!(payload.key, prop::PHY_FREQ);
2174        assert_eq!(payload.value, &915_000u32.to_le_bytes());
2175        assert_eq!(encode_property_text(prop::PHY_ENABLED, "on").unwrap(), [1]);
2176        assert!(encode_property_text(prop::PHY_ENABLED, "maybe").is_err());
2177    }
2178
2179    #[test]
2180    fn status_response_is_attributed_to_the_requested_property() {
2181        let mut engine = DebuggerEngine::default();
2182        engine.prop_get(prop::PHY_RSSI).unwrap();
2183        let request = drain_serial_frame(&mut engine);
2184        let request = Frame::parse(&request).unwrap();
2185        let mut response = [0; 16];
2186        let len = frame::last_status(
2187            &mut response,
2188            request.header.tid(),
2189            umsh_ulcp::Status::INVALID_STATE,
2190        )
2191        .unwrap();
2192        ingest_serial_frame(&mut engine, &response[..len]);
2193        assert!(matches!(engine.take_event(), Some(Event::Trace { .. })));
2194        assert!(matches!(engine.take_event(), Some(Event::Trace { .. })));
2195        assert!(matches!(engine.take_event(), Some(Event::PropertyError {
2196            key: prop::PHY_RSSI,
2197            status,
2198            ..
2199        }) if status == "Status::INVALID_STATE"));
2200    }
2201
2202    #[test]
2203    fn late_property_response_still_updates_observed_state() {
2204        let mut engine = DebuggerEngine::default();
2205        engine.prop_get(prop::PHY_FREQ).unwrap();
2206        let request = drain_serial_frame(&mut engine);
2207        while engine.take_event().is_some() {}
2208        engine.tick(RESPONSE_TIMEOUT_MS);
2209        while engine.take_event().is_some() {}
2210
2211        send_property(&mut engine, &request, &915_000u32.to_le_bytes());
2212        let events = std::iter::from_fn(|| engine.take_event()).collect::<Vec<_>>();
2213        assert!(events.iter().any(
2214            |event| matches!(event, Event::ProtocolError { message } if message.contains("unused TID"))
2215        ));
2216        assert!(events.iter().any(|event| matches!(
2217            event,
2218            Event::Property {
2219                key: prop::PHY_FREQ,
2220                decoded: Some(DecodedValue { edit: Some(value), .. }),
2221                ..
2222            } if value == "915000"
2223        )));
2224    }
2225
2226    #[test]
2227    fn mismatched_property_response_is_visible_without_consuming_request() {
2228        let mut engine = DebuggerEngine::default();
2229        engine.prop_get(prop::PHY_MTU).unwrap();
2230        let request = drain_serial_frame(&mut engine);
2231        let request = Frame::parse(&request).unwrap();
2232        while engine.take_event().is_some() {}
2233
2234        let mut response = [0; 16];
2235        let len =
2236            frame::prop_is(&mut response, request.header.tid(), prop::PHY_ENABLED, &[1]).unwrap();
2237        ingest_serial_frame(&mut engine, &response[..len]);
2238        let events = std::iter::from_fn(|| engine.take_event()).collect::<Vec<_>>();
2239        assert!(events.iter().any(
2240            |event| matches!(event, Event::ProtocolError { message } if message.contains("unexpected property"))
2241        ));
2242        assert!(events.iter().any(|event| matches!(
2243            event,
2244            Event::Property {
2245                key: prop::PHY_ENABLED,
2246                ..
2247            }
2248        )));
2249        assert!(engine.pending[request.header.tid() as usize].is_some());
2250    }
2251
2252    #[test]
2253    fn common_command_reports_correlated_status() {
2254        let mut engine = DebuggerEngine::default();
2255        engine.command("save").unwrap();
2256        let request = drain_serial_frame(&mut engine);
2257        let request = Frame::parse(&request).unwrap();
2258        assert_eq!(request.command(), Some(Cmd::Save));
2259
2260        let mut response = [0; 16];
2261        let len =
2262            frame::last_status(&mut response, request.header.tid(), umsh_ulcp::Status::OK).unwrap();
2263        ingest_serial_frame(&mut engine, &response[..len]);
2264        let events = std::iter::from_fn(|| engine.take_event()).collect::<Vec<_>>();
2265        assert!(events.iter().any(|event| matches!(
2266            event,
2267            Event::CommandResult {
2268                command: "save",
2269                success: true,
2270                ..
2271            }
2272        )));
2273        assert!(events.iter().any(|event| matches!(
2274            event,
2275            Event::Property {
2276                key: prop::LAST_STATUS,
2277                ..
2278            }
2279        )));
2280    }
2281
2282    #[test]
2283    fn lora_property_choices_match_device_constraints() {
2284        let bandwidth = property_specs()
2285            .iter()
2286            .find(|spec| spec.key == prop::PHY_LORA_BW)
2287            .unwrap();
2288        assert_eq!(bandwidth.choices.first().unwrap().value, "7810");
2289        assert_eq!(bandwidth.choices.last().unwrap().value, "500000");
2290        let spreading = property_specs()
2291            .iter()
2292            .find(|spec| spec.key == prop::PHY_LORA_SF)
2293            .unwrap();
2294        assert_eq!(spreading.choices.len(), 8);
2295        assert_eq!(spreading.choices.first().unwrap().value, "5");
2296        assert_eq!(spreading.choices.last().unwrap().value, "12");
2297    }
2298
2299    #[test]
2300    fn stream_receive_decodes_metadata_and_packet_header() {
2301        let mut ulcp_frame = [0; FRAME_CAPACITY];
2302        let packet = [0xC0, 0xA1, 0xB2, 0x03];
2303        let metadata = [
2304            91,
2305            200,
2306            0xCB,
2307            0xFF,
2308            RX_FLAG_BUFFERED | RX_FLAG_ACKED,
2309            7,
2310            0,
2311            0,
2312            0,
2313        ];
2314        let len = frame::str_recv(&mut ulcp_frame, stream::PHY_RAW, &packet, &metadata).unwrap();
2315        let mut engine = DebuggerEngine::default();
2316        ingest_serial_frame(&mut engine, &ulcp_frame[..len]);
2317
2318        assert!(matches!(engine.take_event(), Some(Event::Trace { .. })));
2319        assert!(matches!(engine.take_event(), Some(Event::StreamRx {
2320            stream: stream::PHY_RAW,
2321            metadata: Some(RxMetadata {
2322                rssi_dbm: Some(-91),
2323                lqi: Some(200),
2324                snr_cb: Some(-53),
2325                buffered: true,
2326                acknowledged: true,
2327                age_s: 7,
2328            }),
2329            packet: Some(PacketSummary {
2330                packet_type,
2331                encrypted: false,
2332                header_len: 4,
2333                body_len: 0,
2334                mic_len: 0,
2335                payload_type: None,
2336                ..
2337            }),
2338            packet_error: None,
2339            ..
2340        }) if packet_type == "Broadcast"));
2341    }
2342
2343    #[cfg(feature = "sim-device")]
2344    #[test]
2345    fn host_engine_attaches_through_real_simulated_session() {
2346        let mut engine = DebuggerEngine::default();
2347        let mut device = SimulatedDevice::new();
2348        device.attach();
2349        engine.tick(1_000);
2350        engine.attach().unwrap();
2351
2352        while let Some(wire) = engine.take_outbound() {
2353            device.ingest(&wire, 1_000).unwrap();
2354            while let Some(response) = device.take_outbound() {
2355                engine.ingest(&response);
2356            }
2357        }
2358
2359        let events: Vec<_> = std::iter::from_fn(|| engine.take_event()).collect();
2360        assert!(events.iter().any(|event| matches!(
2361            event,
2362            Event::Attached {
2363                protocol_major: PROTOCOL_MAJOR_VERSION,
2364                dev_version,
2365                phy_mtu: 255,
2366                ..
2367            } if dev_version == "umsh-web-sim/0.1"
2368        )));
2369        assert!(events.iter().any(|event| matches!(
2370            event,
2371            Event::Property {
2372                key: prop::PHY_FREQ,
2373                decoded: Some(_),
2374                ..
2375            }
2376        )));
2377        assert!(events.iter().any(|event| matches!(
2378            event,
2379            Event::Property {
2380                key: prop::BATTERY,
2381                decoded: Some(DecodedValue { kind: "battery", display, .. }),
2382                ..
2383            } if display == "4111 mV, 87%, charging"
2384        )));
2385        assert!(
2386            events
2387                .iter()
2388                .filter(|event| matches!(event, Event::Trace { .. }))
2389                .count()
2390                > 10
2391        );
2392    }
2393}