Skip to main content

device_envoy_core/
wifi_auto.rs

1//! A device abstraction for common Wi-Fi auto-provisioning types and portal helpers.
2//!
3//! See [`WifiAuto`] for the primary trait and [`WifiAutoEvent`] for connection flow events.
4
5use crate::button::Button;
6use core::future::Future;
7
8mod fields;
9mod portal;
10
11pub use portal::FormData;
12#[doc(hidden)] // Platform plumbing helper type used by RP/ESP captive portal code.
13pub use portal::HtmlBuffer;
14pub use portal::WifiAutoField;
15#[doc(hidden)] // Platform plumbing helper used by RP/ESP captive portal code.
16pub use portal::generate_config_page;
17#[doc(hidden)] // Platform plumbing helper used by RP/ESP captive portal code.
18pub use portal::parse_post;
19
20/// Canonical network stack type returned by [`WifiAuto::connect`].
21pub type WifiStack = &'static embassy_net::Stack<'static>;
22
23// This helper macro must be `pub` because downstream crates expand it in impl blocks.
24#[doc(hidden)]
25#[macro_export]
26macro_rules! __impl_wifi_auto_connect {
27    (
28        $(#[$meta:meta])*
29        fn $name:ident (&self as $self_ident:ident, $on_event:ident) -> $return_ty:ty $body:block
30    ) => {
31        $(#[$meta])*
32        pub async fn $name<OnEvent, OnEventFuture>(
33            &self,
34            mut $on_event: OnEvent,
35        ) -> $return_ty
36        where
37            OnEvent: FnMut($crate::wifi_auto::WifiAutoEvent) -> OnEventFuture,
38            OnEventFuture: core::future::Future<Output = crate::Result<()>>,
39        {
40            let $self_ident = self;
41            $body
42        }
43    };
44    (
45        $(#[$meta:meta])*
46        fn $name:ident (self as $self_ident:ident, $on_event:ident) -> $return_ty:ty $body:block
47    ) => {
48        $(#[$meta])*
49        pub async fn $name<OnEvent, OnEventFuture>(
50            self,
51            mut $on_event: OnEvent,
52        ) -> $return_ty
53        where
54            OnEvent: FnMut($crate::wifi_auto::WifiAutoEvent) -> OnEventFuture,
55            OnEventFuture: core::future::Future<Output = crate::Result<()>>,
56        {
57            let $self_ident = self;
58            $body
59        }
60    };
61}
62
63/// Events emitted while connecting.
64///
65/// See [`WifiAuto::connect`] for usage examples.
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub enum WifiAutoEvent {
68    /// Captive portal is ready and waiting for user configuration.
69    CaptivePortalReady,
70    /// Attempting to connect to WiFi network.
71    Connecting {
72        /// Current attempt number (0-based).
73        try_index: u8,
74        /// Total number of attempts that will be made.
75        try_count: u8,
76    },
77    /// Connection failed after all attempts, device will reset.
78    ConnectionFailed,
79}
80
81/// Shared Wi-Fi auto-provisioning error variants used across platform ports.
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub enum WifiAutoError {
84    /// Captive-portal data or rendering format was invalid.
85    FormatError,
86    /// Stored Wi-Fi auto state is invalid for expected runtime flow.
87    StorageCorrupted,
88    /// A required custom field is missing from the Wi-Fi auto setup.
89    MissingCustomWifiAutoField,
90}
91
92/// Preferred Wi-Fi startup mode.
93#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
94#[doc(hidden)] // Startup-policy plumbing used by platform wifi_auto state machines.
95pub enum WifiStartMode {
96    /// Start directly in Wi-Fi client mode using saved credentials.
97    Client,
98    /// Start in captive-portal mode for reconfiguration.
99    CaptivePortal,
100}
101
102impl Default for WifiStartMode {
103    fn default() -> Self {
104        Self::Client
105    }
106}
107
108/// Return whether startup should enter captive-portal mode.
109#[must_use]
110#[doc(hidden)] // Backend-plumbing helper used by platform crates.
111pub const fn should_enter_captive_portal(
112    wifi_start_mode: WifiStartMode,
113    force_captive_portal: bool,
114    has_persisted_credentials: bool,
115    custom_fields_satisfied: bool,
116) -> bool {
117    force_captive_portal
118        || !custom_fields_satisfied
119        || !has_persisted_credentials
120        || matches!(wifi_start_mode, WifiStartMode::CaptivePortal)
121}
122
123/// Wi-Fi credentials collected from the captive portal.
124#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
125#[doc(hidden)] // Shared plumbing type used across platform wifi_auto implementations.
126pub struct WifiCredentials {
127    /// Network name (SSID).
128    pub ssid: heapless::String<32>,
129    /// Network password.
130    pub password: heapless::String<64>,
131}
132
133impl WifiCredentials {
134    /// Create credentials from string slices.
135    #[must_use]
136    pub fn new(ssid: &str, password: &str) -> Self {
137        assert!(!ssid.is_empty(), "ssid must not be empty");
138        let mut ssid_string = heapless::String::<32>::new();
139        ssid_string
140            .push_str(ssid)
141            .expect("ssid exceeds 32 characters");
142        let mut password_string = heapless::String::<64>::new();
143        password_string
144            .push_str(password)
145            .expect("password exceeds 64 characters");
146        Self {
147            ssid: ssid_string,
148            password: password_string,
149        }
150    }
151}
152
153/// Persisted Wi-Fi auto state shared across platform ports.
154#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
155#[doc(hidden)] // Shared persistence type used by platform wifi_auto storage backends.
156pub struct WifiAutoPersistedState {
157    /// Persisted credentials, if available.
158    pub wifi_credentials: Option<WifiCredentials>,
159    /// Preferred startup mode for next boot.
160    pub wifi_start_mode: WifiStartMode,
161}
162
163impl Default for WifiAutoPersistedState {
164    fn default() -> Self {
165        Self {
166            wifi_credentials: None,
167            wifi_start_mode: WifiStartMode::Client,
168        }
169    }
170}
171
172/// Platform-agnostic Wi-Fi auto-connect contract.
173///
174/// Platform crates implement this trait for their concrete runtime handles.
175/// Constructors remain inherent on platform types.
176///
177/// `WifiAuto::connect` handles Wi-Fi setup end-to-end. It usually tries saved
178/// credentials first, and if credentials are missing or invalid it can run a
179/// captive portal so the user can submit new credentials.
180///
181/// While `connect` runs, `on_event` receives progress updates:
182///
183/// - [`WifiAutoEvent::CaptivePortalReady`]: captive portal is ready for user input.
184/// - [`WifiAutoEvent::Connecting`]: a Wi-Fi connection attempt is in progress.
185/// - [`WifiAutoEvent::ConnectionFailed`]: all connection attempts failed.
186///
187/// # Example
188///
189/// ```rust,no_run
190/// use core::future::Future;
191/// use core::convert::Infallible;
192/// use device_envoy_core::{
193///     button::Button,
194///     wifi_auto::{WifiAuto, WifiAutoEvent, WifiStack},
195/// };
196///
197/// async fn connect_with_status(
198///     wifi_auto: impl WifiAuto<Error = Infallible>,
199/// ) -> Result<WifiStack, Infallible> {
200///     wifi_auto
201///         .connect(&mut ButtonMock, |wifi_auto_event| async move {
202///             match wifi_auto_event {
203///                 WifiAutoEvent::CaptivePortalReady => {
204///                     // Captive portal is ready for Wi-Fi credential entry.
205///                 }
206///                 WifiAutoEvent::Connecting { .. } => {
207///                     // A Wi-Fi connection attempt is in progress.
208///                 }
209///                 WifiAutoEvent::ConnectionFailed => {
210///                     // All connection attempts failed.
211///                 }
212///             }
213///             Ok(())
214///         })
215///         .await
216/// }
217///
218/// # struct ButtonMock;
219/// # impl device_envoy_core::button::__ButtonMonitor for ButtonMock {
220/// #     fn is_pressed_raw(&self) -> bool { false }
221/// #     async fn wait_until_pressed_state(&mut self, _pressed: bool) {}
222/// # }
223/// # impl Button for ButtonMock {}
224/// # struct DemoWifiAuto;
225/// # impl WifiAuto for DemoWifiAuto {
226/// #     type Error = Infallible;
227/// #     async fn connect<
228/// #         OnEvent: FnMut(WifiAutoEvent) -> OnEventFuture,
229/// #         OnEventFuture: Future<Output = Result<(), Self::Error>>,
230/// #     >(
231/// #         self,
232/// #         button: &mut impl Button,
233/// #         mut on_event: OnEvent,
234/// #     ) -> Result<WifiStack, Self::Error>
235/// #     {
236/// #         on_event(WifiAutoEvent::Connecting {
237/// #             try_index: 0,
238/// #             try_count: 1,
239/// #         })
240/// #         .await?;
241/// #         let _todo_result: Result<WifiStack, Self::Error> = todo!();
242/// #         _todo_result
243/// #     }
244/// # }
245/// # fn main() {
246/// #     let wifi_auto = DemoWifiAuto;
247/// #     let _future = connect_with_status(wifi_auto);
248/// # }
249/// ```
250#[allow(async_fn_in_trait)]
251pub trait WifiAuto {
252    /// Platform-specific error type.
253    type Error;
254
255    /// Connect to Wi-Fi, emitting progress events to `on_event`.
256    ///
257    /// See the [WifiAuto trait documentation](Self) for usage examples.
258    async fn connect<OnEvent, OnEventFuture>(
259        self,
260        button: &mut impl Button,
261        on_event: OnEvent,
262    ) -> Result<WifiStack, Self::Error>
263    where
264        OnEvent: FnMut(WifiAutoEvent) -> OnEventFuture,
265        OnEventFuture: Future<Output = Result<(), Self::Error>>;
266}
267
268/// Backend contract for platform-specific Wi-Fi auto-connect operations.
269#[doc(hidden)] // Backend-plumbing trait used by platform crates.
270pub trait WifiAutoBackend {
271    /// Platform-specific error type.
272    type Error;
273
274    /// Whether boot should force captive portal regardless of persisted state.
275    fn force_captive_portal(&self) -> bool;
276
277    /// Number of connection attempts before emitting `ConnectionFailed`.
278    fn try_count(&self) -> u8;
279
280    /// Load persisted startup mode.
281    fn load_start_mode(&self) -> Result<WifiStartMode, Self::Error>;
282
283    /// Check whether all custom fields are currently satisfied.
284    fn custom_fields_satisfied(&self) -> Result<bool, Self::Error>;
285
286    /// Load persisted credentials, if present.
287    fn load_persisted_credentials(&self) -> Result<Option<WifiCredentials>, Self::Error>;
288
289    /// Persist submitted credentials.
290    fn persist_credentials(&self, wifi_credentials: &WifiCredentials) -> Result<(), Self::Error>;
291
292    /// Persist startup mode.
293    fn set_start_mode(&self, wifi_start_mode: WifiStartMode) -> Result<(), Self::Error>;
294
295    /// Run captive portal and return submitted credentials.
296    fn run_captive_portal(
297        &mut self,
298    ) -> impl Future<Output = Result<WifiCredentials, Self::Error>> + '_;
299
300    /// Configure platform networking once credentials are resolved.
301    fn on_resolved_credentials(
302        &mut self,
303        wifi_credentials: &WifiCredentials,
304    ) -> impl Future<Output = Result<(), Self::Error>> + '_;
305
306    /// Run one platform-specific connect attempt.
307    fn on_connect_attempt(
308        &mut self,
309        try_index: u8,
310    ) -> impl Future<Output = Result<bool, Self::Error>> + '_;
311}
312
313/// Run the shared Wi-Fi auto-connect flow through a platform backend.
314#[doc(hidden)] // Backend-plumbing helper used by platform crates.
315pub async fn connect_with_backend<Backend, OnEvent, OnEventFuture>(
316    backend: &mut Backend,
317    on_event: &mut OnEvent,
318) -> Result<bool, Backend::Error>
319where
320    Backend: WifiAutoBackend,
321    OnEvent: FnMut(WifiAutoEvent) -> OnEventFuture,
322    OnEventFuture: Future<Output = Result<(), Backend::Error>>,
323{
324    let wifi_start_mode = backend.load_start_mode()?;
325    let custom_fields_satisfied = backend.custom_fields_satisfied()?;
326    let mut wifi_credentials = backend.load_persisted_credentials()?;
327    let has_persisted_credentials = wifi_credentials.is_some();
328
329    if should_enter_captive_portal(
330        wifi_start_mode,
331        backend.force_captive_portal(),
332        has_persisted_credentials,
333        custom_fields_satisfied,
334    ) {
335        on_event(WifiAutoEvent::CaptivePortalReady).await?;
336        let portal_wifi_credentials = backend.run_captive_portal().await?;
337        backend.persist_credentials(&portal_wifi_credentials)?;
338        backend.set_start_mode(WifiStartMode::Client)?;
339        wifi_credentials = Some(portal_wifi_credentials);
340    }
341
342    let wifi_credentials =
343        wifi_credentials.expect("wifi credentials should exist after captive portal fallback");
344    backend.on_resolved_credentials(&wifi_credentials).await?;
345
346    for try_index in 0..backend.try_count() {
347        on_event(WifiAutoEvent::Connecting {
348            try_index,
349            try_count: backend.try_count(),
350        })
351        .await?;
352        if backend.on_connect_attempt(try_index).await? {
353            return Ok(true);
354        }
355    }
356
357    on_event(WifiAutoEvent::ConnectionFailed).await?;
358    Ok(false)
359}