umsh_ulcp_web_engine/
sim.rs

1//! Real ULCP device session behind a deterministic in-memory virtual link.
2
3use std::collections::VecDeque;
4
5use umsh_core::{NodeHint, PacketBuilder};
6use umsh_crypto::{
7    CryptoEngine, NodeIdentity,
8    software::{SoftwareAes, SoftwareIdentity, SoftwareSha256},
9};
10use umsh_ulcp::battery::{BatteryChargeState, BatteryStatus};
11use umsh_ulcp::gnss::{FixKind, GnssSnapshot};
12use umsh_ulcp::{Status, hdlc};
13use umsh_ulcp_device::{
14    AlertConfig, BatteryFields, DutyLedger, Effect, GnssConfig, IdentitySource, RadioSettings,
15    SNAPSHOT_MAX, Session, SessionConfig, TimeConfig, TxOutcome,
16};
17
18const WIRE_CAPACITY: usize = umsh_ulcp::gatt::MAX_FRAME;
19
20type DeviceSession = Session<SoftwareAes, SoftwareSha256>;
21
22/// A deterministic, RAM-backed device that speaks HDLC-Lite exactly like USB.
23///
24/// The storage, radio, RSSI sampler, and entropy source are small stand-ins;
25/// every protocol decision is made by the production device [`Session`].
26pub struct SimulatedDevice {
27    session: DeviceSession,
28    decoder: hdlc::Decoder<WIRE_CAPACITY>,
29    outbound: VecDeque<Vec<u8>>,
30    snapshot: Option<Vec<u8>>,
31    identity: Option<([u8; 32], [u8; 32])>,
32    identity_seed: u8,
33    pairing_pin: Option<u32>,
34    air: Vec<Vec<u8>>,
35    now_ms: u64,
36    /// The simulated wall clock, as a Unix second, or `None` for a device
37    /// that has not been told and has never had a fix. Starts unset so the
38    /// property's most interesting state is the one you see first.
39    epoch: Option<u32>,
40    /// Where the simulated receiver currently thinks it is. Advanced one
41    /// step per sample so a host watching `PROP_GNSS_LOCATION` sees a
42    /// track rather than a fixed point.
43    fix_step: u32,
44}
45
46impl Default for SimulatedDevice {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl SimulatedDevice {
53    pub fn new() -> Self {
54        Self {
55            session: DeviceSession::new(
56                session_config(),
57                Status::RESET_POWER_ON,
58                CryptoEngine::new(SoftwareAes, SoftwareSha256),
59            ),
60            decoder: hdlc::Decoder::new(),
61            outbound: VecDeque::new(),
62            snapshot: None,
63            identity: None,
64            identity_seed: 0,
65            pairing_pin: None,
66            air: Vec::new(),
67            now_ms: 0,
68            epoch: None,
69            fix_step: 0,
70        }
71    }
72
73    /// Attach over the virtual equivalent of a physically secure serial link.
74    pub fn attach(&mut self) {
75        self.decoder.reset();
76        self.outbound.clear();
77        self.session.attach(true);
78    }
79
80    pub fn detach(&mut self) {
81        self.decoder.reset();
82        self.outbound.clear();
83        self.session.detach();
84    }
85
86    /// Feed an arbitrary serial byte chunk into the virtual USB link.
87    pub fn ingest(&mut self, bytes: &[u8], now_ms: u64) -> Result<(), String> {
88        self.now_ms = now_ms;
89        for &byte in bytes {
90            let outcome = self
91                .decoder
92                .push(byte)
93                .map(|result| result.map(<[u8]>::to_vec));
94            if let Some(outcome) = outcome {
95                let frame = outcome.map_err(|error| format!("HDLC decode error: {error:?}"))?;
96                self.handle_frame(&frame);
97            }
98        }
99        Ok(())
100    }
101
102    pub fn take_outbound(&mut self) -> Option<Vec<u8>> {
103        self.outbound.pop_front()
104    }
105
106    /// Put a canned radio frame through the real device receive path.
107    pub fn inject_radio_rx(&mut self, bytes: &[u8], now_ms: u64) {
108        self.now_ms = now_ms;
109        let mut emitted = Vec::new();
110        let effect = self
111            .session
112            .on_radio_rx(bytes, -82, 35, None, now_ms, &mut |frame| {
113                emitted.push(frame.to_vec())
114            });
115        self.execute(effect, &mut emitted);
116        self.queue_emitted(emitted);
117    }
118
119    /// Build and inject a small valid UMSH packet for interactive UI demos.
120    pub fn inject_demo_rx(&mut self, now_ms: u64) {
121        let mut bytes = [0; 64];
122        let packet = PacketBuilder::new(&mut bytes)
123            .broadcast()
124            .source_hint(NodeHint([0x11, 0x22, 0x33]))
125            .flood_hops(3)
126            .payload(b"hello from the simulated radio")
127            .build()
128            .expect("fixed demo packet fits")
129            .to_vec();
130        self.inject_radio_rx(&packet, now_ms);
131    }
132
133    #[cfg(test)]
134    fn transmitted_frames(&self) -> &[Vec<u8>] {
135        &self.air
136    }
137
138    fn handle_frame(&mut self, frame: &[u8]) {
139        let mut emitted = Vec::new();
140        let effect = self.session.handle_frame(frame, self.now_ms, &mut |bytes| {
141            emitted.push(bytes.to_vec())
142        });
143        self.execute(effect, &mut emitted);
144        self.queue_emitted(emitted);
145    }
146
147    fn execute(&mut self, effect: Option<Effect>, emitted: &mut Vec<Vec<u8>>) {
148        let mut emit = |frame: &[u8]| emitted.push(frame.to_vec());
149        match effect {
150            // The simulated board has no buzzer or LED to drive, so the
151            // alert is purely the property value the session already holds.
152            None
153            | Some(Effect::ApplyRadio(_))
154            | Some(Effect::DeviceNameChanged)
155            | Some(Effect::ApplyAlert(_)) => {}
156            Some(Effect::StartTransmit) => {
157                self.air.push(self.session.tx_data().to_vec());
158                self.session
159                    .on_tx_result(TxOutcome::Sent, self.now_ms, &mut emit);
160            }
161            Some(Effect::SampleRssi { tid }) => {
162                self.session.respond_rssi(tid, Ok(-77), &mut emit);
163            }
164            Some(Effect::SampleBattery { tid }) => {
165                // Stable, human-recognizable simulated measurement
166                // matching the configured field set below.
167                self.session.respond_battery(
168                    tid,
169                    Ok(BatteryStatus {
170                        voltage_mv: Some(4111),
171                        level_percent: Some(87),
172                        charge_state: Some(BatteryChargeState::Charging),
173                    }),
174                    &mut emit,
175                );
176            }
177            Some(Effect::SampleIlluminance { tid }) => {
178                // A stable simulated reading: ordinary office lighting.
179                self.session
180                    .respond_illuminance(tid, Some(320_000), &mut emit);
181            }
182            // The web simulator has no device node and no signing key,
183            // so PROP_IDENT reads report failure rather than a blob.
184            Some(Effect::SignIdentity { tid }) => {
185                self.session.respond_identity_blob(tid, Err(()), &mut emit);
186            }
187            Some(Effect::SetPairingPin { tid, pin }) => {
188                self.pairing_pin = pin;
189                self.session.respond_pin_set(tid, Ok(()), &mut emit);
190            }
191            Some(Effect::DrainQueue) => while self.session.drain_step(self.now_ms, &mut emit) {},
192            Some(Effect::SaveSnapshot { tid }) => {
193                let mut buf = [0u8; SNAPSHOT_MAX];
194                let result = match self.session.encode_snapshot(&mut buf) {
195                    Some(len) => {
196                        self.snapshot = Some(buf[..len].to_vec());
197                        Ok(())
198                    }
199                    None => Err(()),
200                };
201                self.session.respond_save(tid, result, &mut emit);
202            }
203            Some(Effect::ClearSaved { tid }) => {
204                self.snapshot = None;
205                self.identity = None;
206                self.session.respond_clear(tid, Ok(()), &mut emit);
207            }
208            Some(Effect::FactoryReset) => {
209                // Emulate the platform wipe-and-reboot: drop every persisted
210                // artifact (snapshot, identity, bonds/PIN) and bring the
211                // session back up factory-fresh as from a power cycle. No
212                // reply is emitted — on hardware the reboot drops the link.
213                self.snapshot = None;
214                self.identity = None;
215                self.identity_seed = 0;
216                self.pairing_pin = None;
217                self.session = DeviceSession::new(
218                    session_config(),
219                    Status::RESET_POWER_ON,
220                    CryptoEngine::new(SoftwareAes, SoftwareSha256),
221                );
222            }
223            Some(Effect::ReadTime { tid }) => {
224                self.session.respond_time(tid, self.epoch, &mut emit);
225            }
226            Some(Effect::ApplyTime { epoch }) => {
227                self.epoch = epoch;
228            }
229            Some(Effect::SampleGnss { tid, key }) => {
230                let sample = self.gnss_sample();
231                self.session.respond_gnss(tid, key, Ok(sample), &mut emit);
232            }
233            Some(Effect::ProvisionIdentity { tid }) => {
234                let result = match self.session.identity_request() {
235                    Some(source) => {
236                        let secret = match source {
237                            IdentitySource::Install(secret) => secret,
238                            IdentitySource::Generate => {
239                                self.identity_seed = self.identity_seed.wrapping_add(1).max(1);
240                                [self.identity_seed; 32]
241                            }
242                        };
243                        let public = SoftwareIdentity::from_secret_bytes(&secret).public_key().0;
244                        self.identity = Some((secret, public));
245                        Ok(public)
246                    }
247                    None => Err(()),
248                };
249                self.session.respond_identity(tid, result, &mut emit);
250            }
251        }
252    }
253
254    /// The simulated receiver's view, walked one cell east per sample.
255    ///
256    /// A receiver that has been switched off reports
257    /// [`GnssSnapshot::SEARCHING`] — zero for the facts it is sure of,
258    /// empty for the position it does not have — which is the state most
259    /// worth being able to see in a debugger.
260    fn gnss_sample(&mut self) -> GnssSnapshot {
261        if !self.session.gnss_enabled() {
262            return GnssSnapshot::SEARCHING;
263        }
264        self.fix_step = self.fix_step.wrapping_add(1);
265        let mut snapshot = GnssSnapshot::SEARCHING;
266        snapshot.fix = FixKind::ThreeD;
267        snapshot.altitude_m = Some(64);
268        snapshot.accuracy_dm = Some(GnssSnapshot::accuracy_from_hdop_centi(120));
269        snapshot.sats_used = 9;
270        snapshot.sats_in_view = Some(14);
271        let step = (self.fix_step % 16) as u8;
272        snapshot.set_location(&[0x8a, 0x1f, 0x4c, 0x00, 0xd0 | step]);
273        snapshot
274    }
275
276    fn queue_emitted(&mut self, emitted: Vec<Vec<u8>>) {
277        for frame in emitted {
278            let mut encoded = vec![0; hdlc::max_encoded_len(frame.len())];
279            let len = hdlc::encode_frame(&frame, &mut encoded)
280                .expect("simulated device output buffer uses HDLC worst-case size");
281            encoded.truncate(len);
282            self.outbound.push_back(encoded);
283        }
284    }
285}
286
287fn session_config() -> SessionConfig {
288    SessionConfig {
289        dev_version: "umsh-web-sim/0.1",
290        default_device_name: "Browser simulated device",
291        mtu: 255,
292        sync_word: 0x1424,
293        min_tx_power_dbm: -9,
294        max_tx_power_dbm: 22,
295        freq_khz_min: 150_000,
296        freq_khz_max: 960_000,
297        defaults: RadioSettings {
298            enabled: false,
299            freq_khz: 910_525,
300            bw_hz: 62_500,
301            sf: 7,
302            cr_denom: 5,
303            tx_power_dbm: 14,
304        },
305        default_duty_limit: 0xFFFF,
306        duty: Box::leak(Box::new(DutyLedger::new())),
307        // The browser simulator reports all three battery measurements.
308        battery: Some(BatteryFields {
309            voltage: true,
310            level: true,
311            charge_state: true,
312        }),
313        // Advertised so the property surface is explorable, even though
314        // a browser tab has nothing to actually flash or beep.
315        alert: Some(AlertConfig::DEFAULT),
316        // Likewise for the clock and the receiver: the simulator keeps a
317        // settable epoch and walks a scripted track, which is enough to
318        // exercise every state of the property surface including the two
319        // that matter most — a clock that is not set, and a receiver that
320        // is switched off.
321        time: Some(TimeConfig),
322        gnss: Some(GnssConfig::DEFAULT),
323        illuminance: true,
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use umsh_ulcp::{Frame, PropPayload, frame, ids::prop};
331
332    fn exchange(sim: &mut SimulatedDevice, request: &[u8]) -> Vec<Vec<u8>> {
333        let mut wire = vec![0; hdlc::max_encoded_len(request.len())];
334        let len = hdlc::encode_frame(request, &mut wire).unwrap();
335        sim.ingest(&wire[..len], 100).unwrap();
336
337        let mut frames = Vec::new();
338        while let Some(wire) = sim.take_outbound() {
339            let mut decoder = hdlc::Decoder::<WIRE_CAPACITY>::new();
340            frames.push(
341                wire.into_iter()
342                    .find_map(|byte| decoder.push(byte).map(|frame| frame.unwrap().to_vec()))
343                    .unwrap(),
344            );
345        }
346        frames
347    }
348
349    #[test]
350    fn real_session_answers_attach_property_over_hdlc() {
351        let mut sim = SimulatedDevice::new();
352        sim.attach();
353        let mut request = [0; 16];
354        let len = frame::prop_get(&mut request, 1, prop::DEV_VERSION).unwrap();
355        let responses = exchange(&mut sim, &request[..len]);
356        assert_eq!(responses.len(), 1);
357        let response = Frame::parse(&responses[0]).unwrap();
358        let payload = PropPayload::parse(response.payload).unwrap();
359        assert_eq!(payload.key, prop::DEV_VERSION);
360        assert_eq!(payload.value, b"umsh-web-sim/0.1\0");
361    }
362
363    #[test]
364    fn real_session_executes_radio_transmit_effect() {
365        let mut sim = SimulatedDevice::new();
366        sim.attach();
367        let mut request = [0; 64];
368        let len = frame::prop_set(&mut request, 1, prop::PHY_ENABLED, &[1]).unwrap();
369        exchange(&mut sim, &request[..len]);
370        let len = frame::str_send(
371            &mut request,
372            2,
373            umsh_ulcp::ids::stream::PHY_RAW,
374            b"demo",
375            &[],
376        )
377        .unwrap();
378        exchange(&mut sim, &request[..len]);
379        assert_eq!(sim.transmitted_frames(), &[b"demo".to_vec()]);
380    }
381
382    #[test]
383    fn demo_packet_uses_the_real_receive_path() {
384        let mut sim = SimulatedDevice::new();
385        sim.attach();
386        let mut request = [0; 16];
387        let len = frame::prop_set(&mut request, 1, prop::PHY_ENABLED, &[1]).unwrap();
388        exchange(&mut sim, &request[..len]);
389
390        sim.inject_demo_rx(200);
391        let wire = sim.take_outbound().expect("demo receive is delivered");
392        let mut decoder = hdlc::Decoder::<WIRE_CAPACITY>::new();
393        let response = wire
394            .into_iter()
395            .find_map(|byte| decoder.push(byte).map(|frame| frame.unwrap().to_vec()))
396            .unwrap();
397        assert_eq!(
398            Frame::parse(&response).unwrap().command(),
399            Some(umsh_ulcp::Cmd::StrRecv)
400        );
401    }
402}