umsh_ulcp_simdev/
lib.rs

1//! Simulated ULCP device: the production device [`Session`] behind a
2//! deterministic in-memory link.
3//!
4//! The storage, radio, RSSI sampler, and entropy source are small
5//! stand-ins; every protocol decision is made by the real session. What
6//! stands where a radio would is a queue: transmissions accumulate until
7//! [`SimulatedDevice::take_transmitted`] drains them, so the caller
8//! decides what "the air" is — the web debugger discards it, a bridge
9//! copies it to real segments.
10//!
11//! The [`SessionConfig`] is the caller's: it names the device and
12//! declares its capability surface. A simulated device has no node
13//! behind it, so its configuration must leave
14//! [`SessionConfig::mac_node`] unset — `Effect::ApplyBackhaul` would
15//! connect the host to nothing.
16
17use std::collections::VecDeque;
18
19use umsh_core::{NodeHint, PacketBuilder};
20use umsh_crypto::{
21    CryptoEngine, NodeIdentity,
22    software::{SoftwareAes, SoftwareIdentity, SoftwareSha256},
23};
24use umsh_ulcp::battery::{BatteryChargeState, BatteryStatus};
25use umsh_ulcp::gnss::{FixKind, GnssSnapshot};
26use umsh_ulcp::{Status, hdlc};
27use umsh_ulcp_device::{Effect, IdentitySource, SNAPSHOT_MAX, Session, TxOutcome};
28
29pub use umsh_ulcp_device::{
30    AlertConfig, BatteryFields, DutyLedger, GnssConfig, RadioRxInfo, RadioSettings, SessionConfig,
31    TimeConfig,
32};
33
34const WIRE_CAPACITY: usize = umsh_ulcp::gatt::MAX_FRAME;
35
36/// Bonds a fresh simulated device pretends to be holding.
37const SIMULATED_BONDS: u8 = 2;
38
39type DeviceSession = Session<SoftwareAes, SoftwareSha256>;
40
41/// A deterministic, RAM-backed device that speaks HDLC-Lite exactly like USB.
42pub struct SimulatedDevice {
43    session: DeviceSession,
44    config: SessionConfig,
45    decoder: hdlc::Decoder<WIRE_CAPACITY>,
46    outbound: VecDeque<Vec<u8>>,
47    snapshot: Option<Vec<u8>>,
48    identity: Option<([u8; 32], [u8; 32])>,
49    identity_seed: u8,
50    pairing_pin: Option<u32>,
51    /// Bonds the simulated transport is holding. Nothing here pairs, so
52    /// the count only ever falls — it starts non-zero so a host has
53    /// something to clear, which is the state the command is for.
54    bond_count: u8,
55    air: Vec<Vec<u8>>,
56    now_ms: u64,
57    /// The caller's clock reading when this device last came up.
58    /// `Clock::now_ms` is specified as milliseconds since boot, and a
59    /// factory reset reboots the hardware, so the simulated clock has to
60    /// restart with it — otherwise a freshly reset device would report the
61    /// uptime of the process driving it.
62    boot_ms: u64,
63    /// The simulated wall clock, as a Unix second, or `None` for a device
64    /// that has not been told and has never had a fix. Starts unset so the
65    /// property's most interesting state is the one you see first.
66    epoch: Option<u32>,
67    /// Where the simulated receiver currently thinks it is. Advanced one
68    /// step per sample so a host watching `PROP_GNSS_LOCATION` sees a
69    /// track rather than a fixed point.
70    fix_step: u32,
71}
72
73impl SimulatedDevice {
74    pub fn new(config: SessionConfig) -> Self {
75        let mut session = DeviceSession::new(
76            config,
77            Status::RESET_POWER_ON,
78            CryptoEngine::new(SoftwareAes, SoftwareSha256),
79        );
80        // Nothing is attached yet, so this only seeds the value a later
81        // read answers with.
82        session.set_ble_bond_count(SIMULATED_BONDS, &mut |_| {});
83        Self {
84            session,
85            config,
86            decoder: hdlc::Decoder::new(),
87            outbound: VecDeque::new(),
88            snapshot: None,
89            identity: None,
90            identity_seed: 0,
91            pairing_pin: None,
92            bond_count: SIMULATED_BONDS,
93            air: Vec::new(),
94            now_ms: 0,
95            boot_ms: 0,
96            epoch: None,
97            fix_step: 0,
98        }
99    }
100
101    /// The caller's clock, rebased onto this device's boot.
102    fn device_ms(&self) -> u64 {
103        self.now_ms.saturating_sub(self.boot_ms)
104    }
105
106    /// Attach over the virtual equivalent of a physically secure serial link.
107    pub fn attach(&mut self) {
108        self.decoder.reset();
109        self.outbound.clear();
110        self.session.attach(true);
111    }
112
113    pub fn detach(&mut self) {
114        self.decoder.reset();
115        self.outbound.clear();
116        self.session.detach();
117    }
118
119    /// Feed an arbitrary serial byte chunk into the virtual USB link.
120    pub fn ingest(&mut self, bytes: &[u8], now_ms: u64) -> Result<(), String> {
121        self.now_ms = now_ms;
122        for &byte in bytes {
123            let outcome = self
124                .decoder
125                .push(byte)
126                .map(|result| result.map(<[u8]>::to_vec));
127            if let Some(outcome) = outcome {
128                let frame = outcome.map_err(|error| format!("HDLC decode error: {error:?}"))?;
129                self.handle_frame(&frame);
130            }
131        }
132        Ok(())
133    }
134
135    pub fn take_outbound(&mut self) -> Option<Vec<u8>> {
136        self.outbound.pop_front()
137    }
138
139    /// Everything the device has transmitted since the last drain, in
140    /// order. The queue grows until drained; a caller with no air to put
141    /// frames on drains and discards.
142    pub fn take_transmitted(&mut self) -> Vec<Vec<u8>> {
143        std::mem::take(&mut self.air)
144    }
145
146    /// Put a canned radio frame through the real device receive path.
147    pub fn inject_radio_rx(&mut self, bytes: &[u8], now_ms: u64) {
148        self.inject_radio_rx_with_info(bytes, &RadioRxInfo::measured(-82, 35, None), now_ms);
149    }
150
151    /// Put a radio frame through the real device receive path with the
152    /// caller's own reception facts — all-`None` for a frame that
153    /// crossed no air and was measured by nobody.
154    pub fn inject_radio_rx_with_info(&mut self, bytes: &[u8], info: &RadioRxInfo, now_ms: u64) {
155        self.now_ms = now_ms;
156        let device_ms = self.device_ms();
157        let mut emitted = Vec::new();
158        let effect = self
159            .session
160            .on_radio_rx(bytes, info, device_ms, &mut |frame| {
161                emitted.push(frame.to_vec())
162            });
163        self.execute(effect, &mut emitted);
164        self.queue_emitted(emitted);
165    }
166
167    /// Build and inject a small valid UMSH packet for interactive UI demos.
168    pub fn inject_demo_rx(&mut self, now_ms: u64) {
169        let mut bytes = [0; 64];
170        let packet = PacketBuilder::new(&mut bytes)
171            .broadcast()
172            .source_hint(NodeHint([0x11, 0x22, 0x33]))
173            .flood_hops(3)
174            .payload(b"hello from the simulated radio")
175            .build()
176            .expect("fixed demo packet fits")
177            .to_vec();
178        self.inject_radio_rx(&packet, now_ms);
179    }
180
181    fn handle_frame(&mut self, frame: &[u8]) {
182        let device_ms = self.device_ms();
183        let mut emitted = Vec::new();
184        let effect = self
185            .session
186            .handle_frame(frame, device_ms, &mut |bytes| emitted.push(bytes.to_vec()));
187        self.execute(effect, &mut emitted);
188        self.queue_emitted(emitted);
189    }
190
191    fn execute(&mut self, effect: Option<Effect>, emitted: &mut Vec<Vec<u8>>) {
192        let mut emit = |frame: &[u8]| emitted.push(frame.to_vec());
193        match effect {
194            // The simulated board has no buzzer or LED to drive, so the
195            // alert is purely the property value the session already holds.
196            None
197            | Some(Effect::ApplyRadio(_))
198            | Some(Effect::DeviceNameChanged)
199            // The simulated board has no node of its own, so there is
200            // nothing for a backhaul to connect the host to.
201            | Some(Effect::ApplyBackhaul { .. })
202            | Some(Effect::ApplyAlert(_)) => {}
203            Some(Effect::StartTransmit) => {
204                let device_ms = self.device_ms();
205                self.air.push(self.session.tx_data().to_vec());
206                self.session
207                    .on_tx_result(TxOutcome::Sent, device_ms, &mut emit);
208            }
209            Some(Effect::SampleRssi { tid }) => {
210                self.session.respond_rssi(tid, Ok(-77), &mut emit);
211            }
212            Some(Effect::SampleBattery { tid }) => {
213                // Stable, human-recognizable simulated measurement; the
214                // configured field set decides what of it is reported.
215                self.session.respond_battery(
216                    tid,
217                    Ok(BatteryStatus {
218                        voltage_mv: Some(4111),
219                        level_percent: Some(87),
220                        charge_state: Some(BatteryChargeState::Charging),
221                    }),
222                    &mut emit,
223                );
224            }
225            Some(Effect::SampleIlluminance { tid }) => {
226                // A stable simulated reading: ordinary office lighting.
227                self.session
228                    .respond_illuminance(tid, Some(320_000), &mut emit);
229            }
230            // The simulated device has no device node and no signing key,
231            // so PROP_IDENT reads report failure rather than a blob.
232            Some(Effect::SignIdentity { tid }) => {
233                self.session.respond_identity_blob(tid, Err(()), &mut emit);
234            }
235            Some(Effect::SetPairingPin { tid, pin }) => {
236                self.pairing_pin = pin;
237                self.session.respond_pin_set(tid, Ok(()), &mut emit);
238            }
239            Some(Effect::BleClearBonds { tid }) => {
240                // The full security reset a board performs: bonds, PIN,
241                // and lockout together, and the pairing window a
242                // freshly-cleared device opens.
243                self.bond_count = 0;
244                self.pairing_pin = None;
245                self.session.set_ble_bond_count(0, &mut emit);
246                self.session.set_ble_pairing(true, &mut emit);
247                self.session.respond_ble_clear_bonds(tid, Ok(()), &mut emit);
248            }
249            Some(Effect::SetBlePairing { tid, open }) => {
250                // Nothing here can pair, so the window opens and nothing
251                // walks through it. A full store is not a refusal —
252                // enrollment at capacity evicts — and the one state that
253                // would refuse an open, a pairing lockout, needs failed
254                // pairings this device cannot have.
255                self.session.respond_ble_pairing(tid, Ok(open), &mut emit);
256            }
257            Some(Effect::DrainQueue) => {
258                let device_ms = self.device_ms();
259                while self.session.drain_step(device_ms, &mut emit) {}
260            }
261            Some(Effect::SaveSnapshot { tid }) => {
262                let mut buf = [0u8; SNAPSHOT_MAX];
263                let result = match self.session.encode_snapshot(&mut buf) {
264                    Some(len) => {
265                        self.snapshot = Some(buf[..len].to_vec());
266                        Ok(())
267                    }
268                    None => Err(()),
269                };
270                self.session.respond_save(tid, result, &mut emit);
271            }
272            Some(Effect::ClearSaved { tid }) => {
273                self.snapshot = None;
274                self.identity = None;
275                self.session.respond_clear(tid, Ok(()), &mut emit);
276            }
277            Some(Effect::FactoryReset) => {
278                // Emulate the platform wipe-and-reboot: drop every persisted
279                // artifact (snapshot, identity, bonds/PIN) and bring the
280                // session back up factory-fresh as from a power cycle. No
281                // reply is emitted — on hardware the reboot drops the link.
282                self.snapshot = None;
283                self.identity = None;
284                self.identity_seed = 0;
285                self.pairing_pin = None;
286                self.bond_count = 0;
287                // The reboot restarts the monotonic clock along with
288                // everything else, so uptime counts from here.
289                self.boot_ms = self.now_ms;
290                self.session = DeviceSession::new(
291                    self.config,
292                    Status::RESET_POWER_ON,
293                    CryptoEngine::new(SoftwareAes, SoftwareSha256),
294                );
295            }
296            Some(Effect::Reboot) => {
297                // A power cycle and nothing else: the persisted
298                // artifacts survive, the session comes back announcing
299                // its power-on reset, and uptime counts from here. No
300                // reply, as on hardware.
301                self.boot_ms = self.now_ms;
302                self.session = DeviceSession::new(
303                    self.config,
304                    Status::RESET_POWER_ON,
305                    CryptoEngine::new(SoftwareAes, SoftwareSha256),
306                );
307                // A board configures itself from the saved snapshot on
308                // the way up, so replay it here. The identity and the
309                // pairing PIN are this simulator's own fields and are
310                // answered from them, so only the snapshot has to go
311                // back into the session.
312                if let Some(saved) = self.snapshot.clone() {
313                    let _ = self.session.restore_at_boot(&saved);
314                }
315                // Bonds outlive a reboot, and the fresh session starts
316                // from zero, so the transport reports itself again exactly
317                // as a board's does on the way up.
318                self.session
319                    .set_ble_bond_count(self.bond_count, &mut |_| {});
320            }
321            Some(Effect::ReadTime { tid }) => {
322                self.session.respond_time(tid, self.epoch, &mut emit);
323            }
324            Some(Effect::ApplyTime { epoch }) => {
325                self.epoch = epoch;
326            }
327            Some(Effect::SampleGnss { tid, key }) => {
328                let sample = self.gnss_sample();
329                self.session.respond_gnss(tid, key, Ok(sample), &mut emit);
330            }
331            Some(Effect::ProvisionIdentity { tid }) => {
332                let result = match self.session.identity_request() {
333                    Some(source) => {
334                        let secret = match source {
335                            IdentitySource::Install(secret) => secret,
336                            IdentitySource::Generate => {
337                                self.identity_seed = self.identity_seed.wrapping_add(1).max(1);
338                                [self.identity_seed; 32]
339                            }
340                        };
341                        let public = SoftwareIdentity::from_secret_bytes(&secret).public_key().0;
342                        self.identity = Some((secret, public));
343                        Ok(public)
344                    }
345                    None => Err(()),
346                };
347                self.session.respond_identity(tid, result, &mut emit);
348            }
349        }
350    }
351
352    /// The simulated receiver's view, walked one cell east per sample.
353    ///
354    /// A receiver that has been switched off reports
355    /// [`GnssSnapshot::SEARCHING`] — zero for the facts it is sure of,
356    /// empty for the position it does not have — which is the state most
357    /// worth being able to see in a debugger.
358    fn gnss_sample(&mut self) -> GnssSnapshot {
359        if !self.session.gnss_enabled() {
360            return GnssSnapshot::SEARCHING;
361        }
362        self.fix_step = self.fix_step.wrapping_add(1);
363        let mut snapshot = GnssSnapshot::SEARCHING;
364        snapshot.fix = FixKind::ThreeD;
365        snapshot.altitude_m = Some(64);
366        snapshot.accuracy_dm = Some(GnssSnapshot::accuracy_from_hdop_centi(120));
367        snapshot.sats_used = 9;
368        snapshot.sats_in_view = Some(14);
369        let step = (self.fix_step % 16) as u8;
370        snapshot.set_location(&[0x8a, 0x1f, 0x4c, 0x00, 0xd0 | step]);
371        snapshot
372    }
373
374    fn queue_emitted(&mut self, emitted: Vec<Vec<u8>>) {
375        for frame in emitted {
376            let mut encoded = vec![0; hdlc::max_encoded_len(frame.len())];
377            let len = hdlc::encode_frame(&frame, &mut encoded)
378                .expect("simulated device output buffer uses HDLC worst-case size");
379            encoded.truncate(len);
380            self.outbound.push_back(encoded);
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use umsh_ulcp::{Frame, PropPayload, frame, ids::prop};
389
390    fn test_config() -> SessionConfig {
391        SessionConfig {
392            dev_version: "umsh-simdev-test/0.1",
393            dev_model: None,
394            default_device_name: "Simulated test device",
395            mtu: 255,
396            sync_word: 0x1424,
397            min_tx_power_dbm: -9,
398            max_tx_power_dbm: 22,
399            freq_khz_min: 150_000,
400            freq_khz_max: 960_000,
401            defaults: RadioSettings {
402                enabled: false,
403                freq_khz: 910_525,
404                bw_hz: 62_500,
405                sf: 7,
406                cr_denom: 5,
407                tx_power_dbm: 14,
408            },
409            default_duty_limit: 0xFFFF,
410            duty: Box::leak(Box::new(DutyLedger::new())),
411            battery: Some(BatteryFields {
412                voltage: true,
413                level: true,
414                charge_state: true,
415            }),
416            alert: Some(AlertConfig::DEFAULT),
417            time: Some(TimeConfig),
418            gnss: Some(GnssConfig::DEFAULT),
419            illuminance: true,
420            ble: true,
421            ble_pairing: true,
422            // A simulated power cycle is a rebuilt session, which is
423            // exactly what a host watching this device would see.
424            reboot: true,
425            // A simulated device has no radio to count for.
426            stats: None,
427            mac_node: false,
428        }
429    }
430
431    fn exchange(sim: &mut SimulatedDevice, request: &[u8]) -> Vec<Vec<u8>> {
432        exchange_at(sim, request, 100)
433    }
434
435    fn exchange_at(sim: &mut SimulatedDevice, request: &[u8], now_ms: u64) -> Vec<Vec<u8>> {
436        let mut wire = vec![0; hdlc::max_encoded_len(request.len())];
437        let len = hdlc::encode_frame(request, &mut wire).unwrap();
438        sim.ingest(&wire[..len], now_ms).unwrap();
439
440        let mut frames = Vec::new();
441        while let Some(wire) = sim.take_outbound() {
442            let mut decoder = hdlc::Decoder::<WIRE_CAPACITY>::new();
443            frames.push(
444                wire.into_iter()
445                    .find_map(|byte| decoder.push(byte).map(|frame| frame.unwrap().to_vec()))
446                    .unwrap(),
447            );
448        }
449        frames
450    }
451
452    #[test]
453    fn real_session_answers_attach_property_over_hdlc() {
454        let mut sim = SimulatedDevice::new(test_config());
455        sim.attach();
456        let mut request = [0; 16];
457        let len = frame::prop_get(&mut request, 1, prop::DEV_VERSION).unwrap();
458        let responses = exchange(&mut sim, &request[..len]);
459        assert_eq!(responses.len(), 1);
460        let response = Frame::parse(&responses[0]).unwrap();
461        let payload = PropPayload::parse(response.payload).unwrap();
462        assert_eq!(payload.key, prop::DEV_VERSION);
463        assert_eq!(payload.value, b"umsh-simdev-test/0.1\0");
464    }
465
466    /// The simulated reboot has to restart the simulated clock, or a
467    /// factory-reset device would report the uptime of whatever process
468    /// happens to be driving it.
469    #[test]
470    fn a_factory_reset_restarts_the_uptime_clock() {
471        let mut sim = SimulatedDevice::new(test_config());
472        sim.attach();
473
474        let read_uptime = |sim: &mut SimulatedDevice, now_ms: u64| -> u32 {
475            let mut request = [0; 16];
476            let len = frame::prop_get(&mut request, 1, prop::UPTIME).unwrap();
477            let responses = exchange_at(sim, &request[..len], now_ms);
478            let response = Frame::parse(&responses[0]).unwrap();
479            let payload = PropPayload::parse(response.payload).unwrap();
480            assert_eq!(payload.key, prop::UPTIME);
481            u32::from_le_bytes(payload.value.try_into().expect("UINT32"))
482        };
483
484        assert_eq!(read_uptime(&mut sim, 5_000), 5);
485
486        let mut request = [0; 16];
487        let len = frame::factory_reset(&mut request, 2).unwrap();
488        exchange_at(&mut sim, &request[..len], 5_000);
489
490        // The caller's clock keeps running; the device's does not.
491        assert_eq!(read_uptime(&mut sim, 7_000), 2);
492    }
493
494    #[test]
495    fn real_session_executes_radio_transmit_effect() {
496        let mut sim = SimulatedDevice::new(test_config());
497        sim.attach();
498        let mut request = [0; 64];
499        let len = frame::prop_set(&mut request, 1, prop::PHY_ENABLED, &[1]).unwrap();
500        exchange(&mut sim, &request[..len]);
501        let len = frame::str_send(
502            &mut request,
503            2,
504            umsh_ulcp::ids::stream::PHY_RAW,
505            b"demo",
506            &[],
507        )
508        .unwrap();
509        exchange(&mut sim, &request[..len]);
510        assert_eq!(sim.take_transmitted(), &[b"demo".to_vec()]);
511        assert!(
512            sim.take_transmitted().is_empty(),
513            "the drain leaves nothing behind"
514        );
515    }
516
517    #[test]
518    fn demo_packet_uses_the_real_receive_path() {
519        let mut sim = SimulatedDevice::new(test_config());
520        sim.attach();
521        let mut request = [0; 16];
522        let len = frame::prop_set(&mut request, 1, prop::PHY_ENABLED, &[1]).unwrap();
523        exchange(&mut sim, &request[..len]);
524
525        sim.inject_demo_rx(200);
526        let wire = sim.take_outbound().expect("demo receive is delivered");
527        let mut decoder = hdlc::Decoder::<WIRE_CAPACITY>::new();
528        let response = wire
529            .into_iter()
530            .find_map(|byte| decoder.push(byte).map(|frame| frame.unwrap().to_vec()))
531            .unwrap();
532        assert_eq!(
533            Frame::parse(&response).unwrap().command(),
534            Some(umsh_ulcp::Cmd::StrRecv)
535        );
536    }
537
538    /// The whole point of the honest capability surface: nothing this
539    /// device advertises invites a bridge to backhaul through it.
540    #[test]
541    fn a_simulated_device_claims_no_node() {
542        let mut sim = SimulatedDevice::new(test_config());
543        sim.attach();
544        let mut request = [0; 16];
545        let len = frame::prop_get(&mut request, 1, umsh_ulcp::ids::prop::CAPS).unwrap();
546        let responses = exchange(&mut sim, &request[..len]);
547        let response = Frame::parse(&responses[0]).unwrap();
548        let payload = PropPayload::parse(response.payload).unwrap();
549        let mut caps = Vec::new();
550        let mut offset = 0;
551        while offset < payload.value.len() {
552            let (value, used) = umsh_ulcp::pui::decode(&payload.value[offset..]).unwrap();
553            caps.push(value);
554            offset += used;
555        }
556        assert!(!caps.contains(&umsh_ulcp::ids::cap::MAC_BACKHAUL));
557        assert!(!caps.contains(&umsh_ulcp::ids::cap::REPEATER));
558        assert!(caps.contains(&umsh_ulcp::ids::cap::WRITABLE_RAW_STREAM));
559    }
560}