1use 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#[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 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 battery: Some(BatteryFields {
58 voltage: true,
59 level: true,
60 charge_state: true,
61 }),
62 alert: Some(AlertConfig::DEFAULT),
65 time: Some(TimeConfig),
71 gnss: Some(GnssConfig::DEFAULT),
72 illuminance: true,
73 ble: true,
77 ble_pairing: true,
78 reboot: true,
81 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
95#[serde(rename_all = "snake_case")]
96pub enum Transport {
97 SerialHdlc,
98 BleSar,
99}
100
101#[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#[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#[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 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
858pub 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 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 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 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 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 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 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 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(®ion).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 #[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 #[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 assert_eq!(
2214 decode_property(prop::BATTERY, &[]).unwrap().display,
2215 "reporting unsupported"
2216 );
2217 assert_eq!(
2219 decode_property(prop::BATTERY, &[0b101, 0x74, 0x0E, 0])
2220 .unwrap()
2221 .display,
2222 "3700 mV, level unsupported, discharging"
2223 );
2224 assert_eq!(
2226 decode_property(prop::BATTERY, &[0b111, 0x0F, 0x10, 87, 1])
2227 .unwrap()
2228 .display,
2229 "4111 mV, 87%, charging"
2230 );
2231 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 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}