Skip to main content

device_envoy_core/wasm/
simulator.rs

1//! Shared browser-facing construction and input control for a simulated CYD.
2//!
3//! See the shared [`crate::cyd`] API and the compiled [`crate::wasm`] example
4//! for the device-level drawing path. This module adds the browser shell's
5//! canvas construction and interactive touch/BOOT controls:
6//!
7//! ```rust,no_run
8//! use device_envoy_core::{
9//!     UnwrapInfallible,
10//!     button::Button,
11//!     cyd::{CydDisplay, CydTouch, display::Orientation, touch::TouchEvent},
12//! };
13//! use device_envoy_core::wasm::simulator::{
14//!     CydSimulatorControlWasm, CydSimulatorWasm,
15//! };
16//! use embedded_graphics::pixelcolor::{Rgb888, RgbColor};
17//! use web_sys::HtmlCanvasElement;
18//! use wasm_bindgen::JsValue;
19//!
20//! fn start_simulation(canvas: HtmlCanvasElement) -> Result<(), JsValue> {
21//!     let standard = CydSimulatorWasm::new(canvas.clone(), Orientation::Landscape)?;
22//!     drop(standard);
23//!     let simulator = CydSimulatorWasm::new_with_style(
24//!         canvas,
25//!         Orientation::Landscape,
26//!         Rgb888::BLACK,
27//!         Rgb888::WHITE,
28//!         &embedded_graphics::mono_font::ascii::FONT_6X10,
29//!     )?;
30//!     let (cyd, button, control): (_, _, CydSimulatorControlWasm) = simulator.into_parts();
31//!     assert_eq!(cyd.display().screen_size(), Orientation::Landscape.size());
32//!     assert_eq!(control.orientation(), Orientation::Landscape);
33//!     assert!(!control.orientation_is_inverted());
34//!     let (_, mut touch) = cyd.owned_parts();
35//!     control.touch_down(10.0, 20.0);
36//!     assert!(matches!(
37//!         touch.try_read().unwrap_infallible(),
38//!         Some(TouchEvent::Down { .. }),
39//!     ));
40//!     control.touch_move(12.0, 22.0);
41//!     assert!(matches!(
42//!         touch.try_read().unwrap_infallible(),
43//!         Some(TouchEvent::Move { .. }),
44//!     ));
45//!     control.touch_up();
46//!     assert!(matches!(touch.try_read().unwrap_infallible(), Some(TouchEvent::Up)));
47//!     control.boot_down();
48//!     assert!(button.is_pressed());
49//!     control.boot_up();
50//!     assert!(!button.is_pressed());
51//!     control.reset_transient_state();
52//!     Ok(())
53//! }
54//! ```
55
56use embedded_graphics::{
57    mono_font::{MonoFont, ascii::FONT_6X10},
58    pixelcolor::Rgb888,
59};
60use std::{cell::RefCell, thread_local, vec::Vec};
61use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen};
62use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
63
64use super::{ButtonWasm, ButtonWasmSource, CydTouchWasmSource, CydWasm, next_animation_frame};
65use crate::button::Button;
66use crate::cyd::display::Orientation;
67use crate::wifi_auto::WifiAutoEvent;
68
69const WIFI_CAPTIVE_PORTAL_WAIT_FRAMES: usize = 15;
70const WIFI_CONNECT_WAIT_FRAMES: usize = 90;
71
72const BACKGROUND_COLOR: Rgb888 = Rgb888::new(10, 10, 12); // near-black
73const FOREGROUND_COLOR: Rgb888 = Rgb888::new(230, 230, 230); // near-white
74
75/// The reusable resources and input protocol for one browser CYD instance.
76/// See the compiled [`crate::wasm::simulator`] example.
77pub struct CydSimulatorWasm {
78    cyd: CydWasm,
79    button_source: ButtonWasmSource,
80    control: CydSimulatorControlWasm,
81}
82
83/// Browser input and lifecycle control shared by an application launcher.
84/// See the compiled [`crate::wasm::simulator`] example.
85#[wasm_bindgen]
86#[derive(Clone)]
87pub struct CydSimulatorControlWasm {
88    touch_source: Option<CydTouchWasmSource>,
89    button_source: ButtonWasmSource,
90    orientation: Orientation,
91}
92
93/// Result of the shared browser Wi-Fi connection simulation.
94/// The compiled [`WifiSimulatorWasm`] example constructs both variants.
95#[derive(Clone, Copy, Debug, Eq, PartialEq)]
96pub enum WifiConnectOutcome {
97    /// The simulated client connection completed.
98    Connected,
99    /// BOOT interrupted the connection and requests a reset.
100    ResetRequested,
101}
102
103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
104enum WifiSimulatorPhase {
105    Disconnected,
106    CaptivePortal,
107    Connecting,
108    Connected,
109}
110
111thread_local! {
112    static WIFI_SIMULATOR_PHASES: RefCell<Vec<(&'static str, WifiSimulatorPhase)>> =
113        const { RefCell::new(Vec::new()) };
114}
115
116/// A deterministic browser substitute for the platform Wi-Fi auto-provisioner.
117/// See the [`crate::wasm`] module for the browser implementation overview.
118///
119/// ```rust,no_run
120/// use core::convert::Infallible;
121/// use device_envoy_core::wasm::{
122///     ButtonWasmSource, WifiConnectOutcome, WifiSimulatorWasm,
123/// };
124///
125/// async fn connect() -> Result<(), Infallible> {
126///     let outcomes = [
127///         WifiConnectOutcome::Connected,
128///         WifiConnectOutcome::ResetRequested,
129///     ];
130///     assert_eq!(outcomes.len(), 2);
131///     let button_source = ButtonWasmSource::new();
132///     let mut button = button_source.button();
133///     let wifi_simulator = WifiSimulatorWasm::new("counter-demo");
134///     wifi_simulator.reset();
135///     let outcome = wifi_simulator
136///         .connect(&mut button, async |_event| Ok::<(), Infallible>(()))
137///         .await?;
138///     assert_eq!(outcome, WifiConnectOutcome::Connected);
139///     Ok(())
140/// }
141/// ```
142pub struct WifiSimulatorWasm {
143    storage_namespace: &'static str,
144}
145
146impl WifiSimulatorWasm {
147    /// Construct a simulated Wi-Fi resource scoped to an application namespace.
148    /// See the compiled [`WifiSimulatorWasm`] example.
149    #[must_use]
150    pub const fn new(storage_namespace: &'static str) -> Self {
151        Self { storage_namespace }
152    }
153
154    /// Reset this application's simulated Wi-Fi resource to its disconnected state.
155    /// See the compiled [`WifiSimulatorWasm`] example.
156    pub fn reset(&self) {
157        set_phase(self.storage_namespace, WifiSimulatorPhase::Disconnected);
158    }
159
160    fn phase(&self) -> WifiSimulatorPhase {
161        WIFI_SIMULATOR_PHASES.with(|phases| {
162            phases
163                .borrow()
164                .iter()
165                .find(|(namespace, _)| *namespace == self.storage_namespace)
166                .map_or(WifiSimulatorPhase::Disconnected, |(_, phase)| *phase)
167        })
168    }
169
170    /// Run the deterministic browser connection sequence.
171    /// See the compiled [`WifiSimulatorWasm`] example.
172    pub async fn connect<OnEvent, Error>(
173        &self,
174        button: &mut ButtonWasm,
175        mut on_event: OnEvent,
176    ) -> Result<WifiConnectOutcome, Error>
177    where
178        OnEvent: AsyncFnMut(WifiAutoEvent) -> Result<(), Error>,
179    {
180        if self.phase() == WifiSimulatorPhase::Connected {
181            return Ok(WifiConnectOutcome::Connected);
182        }
183
184        set_phase(self.storage_namespace, WifiSimulatorPhase::CaptivePortal);
185        on_event(WifiAutoEvent::CaptivePortalReady).await?;
186        if wait_for_wifi_frames(button, WIFI_CAPTIVE_PORTAL_WAIT_FRAMES).await {
187            return Ok(WifiConnectOutcome::ResetRequested);
188        }
189
190        set_phase(self.storage_namespace, WifiSimulatorPhase::Connecting);
191        on_event(WifiAutoEvent::Connecting {
192            try_index: 0,
193            try_count: 1,
194        })
195        .await?;
196        if wait_for_wifi_frames(button, WIFI_CONNECT_WAIT_FRAMES).await {
197            return Ok(WifiConnectOutcome::ResetRequested);
198        }
199
200        set_phase(self.storage_namespace, WifiSimulatorPhase::Connected);
201        Ok(WifiConnectOutcome::Connected)
202    }
203}
204
205fn set_phase(storage_namespace: &'static str, phase: WifiSimulatorPhase) {
206    WIFI_SIMULATOR_PHASES.with(|phases| {
207        let mut phases = phases.borrow_mut();
208        if let Some((_, current_phase)) = phases
209            .iter_mut()
210            .find(|(namespace, _)| *namespace == storage_namespace)
211        {
212            *current_phase = phase;
213        } else {
214            phases.push((storage_namespace, phase));
215        }
216    });
217}
218
219impl CydSimulatorWasm {
220    /// Construct a simulated CYD using the standard CYD browser palette.
221    /// See the compiled [`crate::wasm::simulator`] example.
222    pub fn new(canvas: HtmlCanvasElement, orientation: Orientation) -> Result<Self, JsValue> {
223        Self::new_with_style(
224            canvas,
225            orientation,
226            BACKGROUND_COLOR,
227            FOREGROUND_COLOR,
228            &FONT_6X10,
229        )
230    }
231
232    /// Construct a simulated CYD with an application-specific display style.
233    /// See the compiled [`crate::wasm::simulator`] example.
234    pub fn new_with_style(
235        canvas: HtmlCanvasElement,
236        orientation: Orientation,
237        background_color: Rgb888,
238        foreground_color: Rgb888,
239        font: &'static MonoFont<'static>,
240    ) -> Result<Self, JsValue> {
241        let context = canvas
242            .get_context("2d")?
243            .ok_or_else(|| JsValue::from_str("2D canvas context unavailable"))?
244            .dyn_into::<CanvasRenderingContext2d>()?;
245        canvas.set_width(orientation.width());
246        canvas.set_height(orientation.height());
247
248        let touch_source = CydTouchWasmSource::new();
249        let button_source = ButtonWasmSource::new();
250        let cyd = CydWasm::new(
251            context,
252            orientation,
253            background_color,
254            foreground_color,
255            font,
256            touch_source.clone(),
257        );
258        let control = CydSimulatorControlWasm {
259            touch_source: Some(touch_source),
260            button_source: button_source.clone(),
261            orientation,
262        };
263        Ok(Self {
264            cyd,
265            button_source,
266            control,
267        })
268    }
269
270    /// Split the simulator into application device resources and browser control.
271    /// See the compiled [`crate::wasm::simulator`] example.
272    pub fn into_parts(self) -> (CydWasm, ButtonWasm, CydSimulatorControlWasm) {
273        let Self {
274            cyd,
275            button_source,
276            control,
277        } = self;
278        (cyd, button_source.button(), control)
279    }
280}
281
282impl CydSimulatorControlWasm {
283    /// Return the display orientation used by this simulator instance.
284    /// See the compiled [`crate::wasm::simulator`] example.
285    #[must_use]
286    pub const fn orientation(&self) -> Orientation {
287        self.orientation
288    }
289}
290
291async fn wait_for_wifi_frames(button: &ButtonWasm, frame_count: usize) -> bool {
292    for _ in 0..frame_count {
293        if button.is_pressed() {
294            return true;
295        }
296        next_animation_frame().await;
297    }
298    false
299}
300
301#[wasm_bindgen]
302impl CydSimulatorControlWasm {
303    /// Return whether the simulated display is presented upside down.
304    /// See the compiled [`crate::wasm::simulator`] example.
305    #[wasm_bindgen(js_name = orientation_is_inverted)]
306    pub fn orientation_is_inverted(&self) -> bool {
307        matches!(
308            self.orientation,
309            Orientation::LandscapeInverted | Orientation::PortraitInverted
310        )
311    }
312
313    /// Forward a browser pointer-down position in logical canvas coordinates.
314    /// See the compiled [`crate::wasm::simulator`] example.
315    #[wasm_bindgen(js_name = touch_down)]
316    pub fn touch_down(&self, x: f32, y: f32) {
317        let point = map_to_landscape(self.orientation, x, y);
318        if let Some(touch_source) = &self.touch_source {
319            touch_source.touch_down(point.0, point.1);
320        }
321    }
322
323    /// Forward a browser pointer-move position in logical canvas coordinates.
324    /// See the compiled [`crate::wasm::simulator`] example.
325    #[wasm_bindgen(js_name = touch_move)]
326    pub fn touch_move(&self, x: f32, y: f32) {
327        let point = map_to_landscape(self.orientation, x, y);
328        if let Some(touch_source) = &self.touch_source {
329            touch_source.touch_move(point.0, point.1);
330        }
331    }
332
333    /// Forward a browser pointer-up or pointer-cancel event.
334    /// See the compiled [`crate::wasm::simulator`] example.
335    #[wasm_bindgen(js_name = touch_up)]
336    pub fn touch_up(&self) {
337        if let Some(touch_source) = &self.touch_source {
338            touch_source.touch_up();
339        }
340    }
341
342    /// Forward a physical BOOT-button press.
343    /// See the compiled [`crate::wasm::simulator`] example.
344    #[wasm_bindgen(js_name = boot_down)]
345    pub fn boot_down(&self) {
346        self.button_source.press();
347    }
348
349    /// Forward a physical BOOT-button release.
350    /// See the compiled [`crate::wasm::simulator`] example.
351    #[wasm_bindgen(js_name = boot_up)]
352    pub fn boot_up(&self) {
353        self.button_source.release();
354    }
355
356    /// Clear transient browser input after a simulated reset.
357    /// See the compiled [`crate::wasm::simulator`] example.
358    pub fn reset_transient_state(&self) {
359        if let Some(touch_source) = &self.touch_source {
360            touch_source.touch_up();
361        }
362        self.button_source.release();
363    }
364}
365
366fn map_to_landscape(orientation: Orientation, x: f32, y: f32) -> (f32, f32) {
367    match orientation {
368        Orientation::Landscape => (x, y),
369        Orientation::Portrait => (319.0 - y, x),
370        Orientation::LandscapeInverted => (319.0 - x, 239.0 - y),
371        Orientation::PortraitInverted => (y, 239.0 - x),
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn orientation_mapping_round_trips() {
381        for orientation in [
382            Orientation::Landscape,
383            Orientation::Portrait,
384            Orientation::LandscapeInverted,
385            Orientation::PortraitInverted,
386        ] {
387            let landscape_point = map_to_landscape(orientation, 37.0, 83.0);
388            let logical_point = match orientation {
389                Orientation::Landscape => landscape_point,
390                Orientation::Portrait => (landscape_point.1, 319.0 - landscape_point.0),
391                Orientation::LandscapeInverted => {
392                    (319.0 - landscape_point.0, 239.0 - landscape_point.1)
393                }
394                Orientation::PortraitInverted => (239.0 - landscape_point.1, landscape_point.0),
395            };
396            assert_eq!(logical_point, (37.0, 83.0));
397        }
398    }
399
400    #[test]
401    fn wifi_state_is_scoped_by_storage_namespace() {
402        set_phase("app-a", WifiSimulatorPhase::Connected);
403        assert_eq!(
404            WifiSimulatorWasm::new("app-a").phase(),
405            WifiSimulatorPhase::Connected
406        );
407        assert_eq!(
408            WifiSimulatorWasm::new("app-b").phase(),
409            WifiSimulatorPhase::Disconnected
410        );
411
412        WifiSimulatorWasm::new("app-a").reset();
413        assert_eq!(
414            WifiSimulatorWasm::new("app-a").phase(),
415            WifiSimulatorPhase::Disconnected
416        );
417        assert_eq!(
418            WifiSimulatorWasm::new("app-b").phase(),
419            WifiSimulatorPhase::Disconnected
420        );
421    }
422}