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