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