Skip to main content

device_envoy/wifi_auto/
fields.rs

1//! A device abstraction for extra setup fields used by [`WifiAuto`](crate::wifi_auto::WifiAuto).
2//! See the [`WifiAuto` struct example](crate::wifi_auto::WifiAuto) for the full setup.
3//!
4//! This module provides ready-to-use field types that can be passed to
5//! [`WifiAuto::new()`](super::WifiAuto::new) for collecting additional
6//! configuration beyond WiFi credentials. The fields module example below focuses
7//! on adding custom fields.
8//!
9//! # Example
10//!
11//! ```rust,no_run
12//! # #![no_std]
13//! # #![no_main]
14//! # use defmt_rtt as _;
15//! # use panic_probe as _;
16//! use device_envoy::button::PressedTo;
17//! use device_envoy::flash_array::FlashArray;
18//! use device_envoy::Error;
19//! use device_envoy::wifi_auto::{WifiAuto, WifiAutoEvent};
20//! use device_envoy::wifi_auto::fields::{
21//!     TextField,
22//!     TextFieldStatic,
23//!     TimezoneField,
24//!     TimezoneFieldStatic,
25//! };
26//!
27//! async fn example(
28//!     spawner: embassy_executor::Spawner,
29//!     p: embassy_rp::Peripherals,
30//! ) -> Result<(), device_envoy::Error> {
31//!     let [wifi_flash, website_flash, timezone_flash] = FlashArray::<3>::new(p.FLASH)?;
32//!
33//!     static WEBSITE_STATIC: TextFieldStatic<32> = TextField::new_static();
34//!     let website_field = TextField::new(
35//!         &WEBSITE_STATIC,
36//!         website_flash,
37//!         "website",
38//!         "Website",
39//!         "google.com",
40//!     );
41//!
42//!     static TIMEZONE_STATIC: TimezoneFieldStatic = TimezoneField::new_static();
43//!     let timezone_field = TimezoneField::new(&TIMEZONE_STATIC, timezone_flash);
44//!
45//!     let wifi_auto = WifiAuto::new(
46//!         p.PIN_23,
47//!         p.PIN_24,
48//!         p.PIN_25,
49//!         p.PIN_29,
50//!         p.PIO0,
51//!         p.DMA_CH0,
52//!         wifi_flash,
53//!         p.PIN_13,
54//!         PressedTo::Ground,
55//!         "Pico",
56//!         [website_field, timezone_field],
57//!         spawner,
58//!     )?;
59//!
60//!     let (stack, _button) = wifi_auto
61//!         .connect(|event| async move {
62//!             match event {
63//!                 WifiAutoEvent::CaptivePortalReady => {
64//!                     defmt::info!("Captive portal ready");
65//!                 }
66//!                 WifiAutoEvent::Connecting {
67//!                     try_index,
68//!                     try_count,
69//!                 } => {
70//!                     defmt::info!(
71//!                         "Connecting to WiFi (attempt {} of {})...",
72//!                         try_index + 1,
73//!                         try_count
74//!                     );
75//!                 }
76//!                 WifiAutoEvent::ConnectionFailed => {
77//!                     defmt::info!("WiFi connection failed");
78//!                 }
79//!             }
80//!             Ok(())
81//!         })
82//!         .await?;
83//!
84//!     let website = website_field.text()?.unwrap_or_default();
85//!     let offset_minutes = timezone_field
86//!         .offset_minutes()?
87//!         .ok_or(Error::MissingCustomWifiAutoField)?;
88//!     defmt::info!("Timezone offset minutes: {}", offset_minutes);
89//!
90//!     loop {
91//!         let query_name = website.as_str();
92//!         if let Ok(addresses) = stack
93//!             .dns_query(query_name, embassy_net::dns::DnsQueryType::A)
94//!             .await
95//!         {
96//!             defmt::info!("{}: {:?}", query_name, addresses);
97//!         } else {
98//!             defmt::info!("{}: lookup failed", query_name);
99//!         }
100//!         embassy_time::Timer::after(embassy_time::Duration::from_secs(15)).await;
101//!     }
102//! }
103//! ```
104
105#![allow(
106    unsafe_code,
107    reason = "unsafe impl Sync is sound: single-threaded Embassy executor, no concurrent access"
108)]
109
110use core::{cell::RefCell, fmt::Write as FmtWrite};
111use defmt::info;
112use heapless::String;
113use static_cell::StaticCell;
114
115use super::portal::{FormData, HtmlBuffer, WifiAutoField};
116use crate::flash_array::FlashBlock;
117use crate::{Error, Result};
118
119/// A timezone selection field for WiFi provisioning.
120///
121/// Allows users to select their timezone from a dropdown during the captive portal
122/// setup. The selected offset (in minutes from UTC) is persisted to flash and can
123/// be retrieved later.
124///
125/// See the [wifi_auto::fields module example](crate::wifi_auto::fields) for usage.
126pub struct TimezoneField {
127    flash: RefCell<FlashBlock>,
128}
129
130// SAFETY: TimezoneField is used in a single-threaded Embassy executor on RP2040/RP2350.
131// There are no interrupts that access this data, and all async operations are cooperative
132// (non-preemptive). The Sync bound is required only because WifiAutoField trait objects
133// are stored in static storage, not because of actual concurrent access.
134unsafe impl Sync for TimezoneField {}
135
136/// Static for [`TimezoneField`]. See the [wifi_auto::fields module example](crate::wifi_auto::fields)
137/// for usage.
138pub struct TimezoneFieldStatic {
139    cell: StaticCell<TimezoneField>,
140}
141
142impl TimezoneFieldStatic {
143    const fn new() -> Self {
144        Self {
145            cell: StaticCell::new(),
146        }
147    }
148}
149
150impl TimezoneField {
151    /// Create static resources for [`TimezoneField`].
152    ///
153    /// See the [wifi_auto::fields module example](crate::wifi_auto::fields) for usage.
154    pub const fn new_static() -> TimezoneFieldStatic {
155        TimezoneFieldStatic::new()
156    }
157
158    /// Initialize a new timezone field.
159    ///
160    /// See the [wifi_auto::fields module example](crate::wifi_auto::fields) for usage.
161    pub fn new(
162        timezone_field_static: &'static TimezoneFieldStatic,
163        flash: FlashBlock,
164    ) -> &'static Self {
165        timezone_field_static.cell.init(Self::from_flash(flash))
166    }
167
168    fn from_flash(flash: FlashBlock) -> Self {
169        Self {
170            flash: RefCell::new(flash),
171        }
172    }
173
174    /// Load the stored timezone offset in minutes from UTC.
175    ///
176    /// Returns `None` if no timezone has been configured yet.
177    ///
178    /// See the [wifi_auto::fields module example](crate::wifi_auto::fields) for usage.
179    pub fn offset_minutes(&self) -> Result<Option<i32>> {
180        self.flash.borrow_mut().load::<i32>()
181    }
182
183    /// Save a new timezone offset in minutes from UTC to flash.
184    ///
185    /// This method allows programmatic updates to the timezone, such as when
186    /// the user adjusts the timezone via button presses or other UI interactions.
187    ///
188    /// Only writes to flash if the value has changed, avoiding unnecessary flash wear.
189    ///
190    /// Alternatively, you can access the underlying flash block directly for
191    /// more control over flash operations.
192    pub fn set_offset_minutes(&self, offset: i32) -> Result<()> {
193        let current = self.offset_minutes()?;
194        if current != Some(offset) {
195            self.flash.borrow_mut().save(&offset)?;
196        }
197        Ok(())
198    }
199
200    /// Clear the stored timezone offset, returning the field to an unconfigured state.
201    pub fn clear(&self) -> Result<()> {
202        self.flash.borrow_mut().clear()
203    }
204}
205
206impl WifiAutoField for TimezoneField {
207    fn render(&self, page: &mut HtmlBuffer) -> Result<()> {
208        info!("WifiAuto field: rendering timezone select");
209        let current = self.offset_minutes()?.unwrap_or(0);
210        FmtWrite::write_str(page, "<label for=\"timezone\">Time zone:</label>")
211            .map_err(|_| Error::FormatError)?;
212        FmtWrite::write_str(page, "<select id=\"timezone\" name=\"timezone\" required>")
213            .map_err(|_| Error::FormatError)?;
214        for option in TIMEZONE_OPTIONS {
215            let selected = if option.minutes == current {
216                " selected"
217            } else {
218                ""
219            };
220            FmtWrite::write_fmt(
221                page,
222                format_args!(
223                    "<option value=\"{}\"{}>{}</option>",
224                    option.minutes, selected, option.label
225                ),
226            )
227            .map_err(|_| Error::FormatError)?;
228        }
229        page.push_str("</select>").map_err(|_| Error::FormatError)?;
230        Ok(())
231    }
232
233    fn parse(&self, form: &FormData<'_>) -> Result<()> {
234        let value = form.get("timezone").ok_or(Error::FormatError)?;
235        let offset = value.parse::<i32>().map_err(|_| Error::FormatError)?;
236        self.set_offset_minutes(offset)
237    }
238
239    fn is_satisfied(&self) -> Result<bool> {
240        Ok(self.offset_minutes()?.is_some())
241    }
242}
243
244struct TimezoneOption {
245    minutes: i32,
246    label: &'static str,
247}
248
249const TIMEZONE_OPTIONS: &[TimezoneOption] = &[
250    TimezoneOption {
251        minutes: -720,
252        label: "Baker Island (UTC-12:00)",
253    },
254    TimezoneOption {
255        minutes: -660,
256        label: "American Samoa (UTC-11:00)",
257    },
258    TimezoneOption {
259        minutes: -600,
260        label: "Honolulu (UTC-10:00)",
261    },
262    TimezoneOption {
263        minutes: -540,
264        label: "Anchorage, Alaska ST (UTC-09:00)",
265    },
266    TimezoneOption {
267        minutes: -480,
268        label: "Anchorage, Alaska DT (UTC-08:00)",
269    },
270    TimezoneOption {
271        minutes: -480,
272        label: "Los Angeles, San Francisco, Seattle ST (UTC-08:00)",
273    },
274    TimezoneOption {
275        minutes: -420,
276        label: "Los Angeles, San Francisco, Seattle DT (UTC-07:00)",
277    },
278    TimezoneOption {
279        minutes: -420,
280        label: "Denver, Phoenix ST (UTC-07:00)",
281    },
282    TimezoneOption {
283        minutes: -360,
284        label: "Denver DT (UTC-06:00)",
285    },
286    TimezoneOption {
287        minutes: -360,
288        label: "Chicago, Dallas, Mexico City ST (UTC-06:00)",
289    },
290    TimezoneOption {
291        minutes: -300,
292        label: "Chicago, Dallas DT (UTC-05:00)",
293    },
294    TimezoneOption {
295        minutes: -300,
296        label: "New York, Toronto, Bogota ST (UTC-05:00)",
297    },
298    TimezoneOption {
299        minutes: -240,
300        label: "New York, Toronto DT (UTC-04:00)",
301    },
302    TimezoneOption {
303        minutes: -240,
304        label: "Santiago, Halifax ST (UTC-04:00)",
305    },
306    TimezoneOption {
307        minutes: -210,
308        label: "St. John's, Newfoundland ST (UTC-03:30)",
309    },
310    TimezoneOption {
311        minutes: -180,
312        label: "Buenos Aires, Sao Paulo (UTC-03:00)",
313    },
314    TimezoneOption {
315        minutes: -120,
316        label: "South Georgia (UTC-02:00)",
317    },
318    TimezoneOption {
319        minutes: -60,
320        label: "Azores ST (UTC-01:00)",
321    },
322    TimezoneOption {
323        minutes: 0,
324        label: "London, Lisbon ST (UTC+00:00)",
325    },
326    TimezoneOption {
327        minutes: 60,
328        label: "London, Paris, Berlin DT (UTC+01:00)",
329    },
330    TimezoneOption {
331        minutes: 60,
332        label: "Paris, Berlin, Rome ST (UTC+01:00)",
333    },
334    TimezoneOption {
335        minutes: 120,
336        label: "Paris, Berlin, Rome DT (UTC+02:00)",
337    },
338    TimezoneOption {
339        minutes: 120,
340        label: "Athens, Cairo, Johannesburg ST (UTC+02:00)",
341    },
342    TimezoneOption {
343        minutes: 180,
344        label: "Athens DT (UTC+03:00)",
345    },
346    TimezoneOption {
347        minutes: 180,
348        label: "Moscow, Istanbul, Nairobi (UTC+03:00)",
349    },
350    TimezoneOption {
351        minutes: 240,
352        label: "Dubai, Baku (UTC+04:00)",
353    },
354    TimezoneOption {
355        minutes: 270,
356        label: "Tehran ST (UTC+04:30)",
357    },
358    TimezoneOption {
359        minutes: 300,
360        label: "Karachi, Tashkent (UTC+05:00)",
361    },
362    TimezoneOption {
363        minutes: 330,
364        label: "Mumbai, Delhi (UTC+05:30)",
365    },
366    TimezoneOption {
367        minutes: 345,
368        label: "Kathmandu (UTC+05:45)",
369    },
370    TimezoneOption {
371        minutes: 360,
372        label: "Dhaka, Almaty (UTC+06:00)",
373    },
374    TimezoneOption {
375        minutes: 390,
376        label: "Yangon (UTC+06:30)",
377    },
378    TimezoneOption {
379        minutes: 420,
380        label: "Bangkok, Jakarta (UTC+07:00)",
381    },
382    TimezoneOption {
383        minutes: 480,
384        label: "Singapore, Hong Kong, Beijing (UTC+08:00)",
385    },
386    TimezoneOption {
387        minutes: 525,
388        label: "Eucla, Australia (UTC+08:45)",
389    },
390    TimezoneOption {
391        minutes: 540,
392        label: "Tokyo, Seoul (UTC+09:00)",
393    },
394    TimezoneOption {
395        minutes: 570,
396        label: "Adelaide ST (UTC+09:30)",
397    },
398    TimezoneOption {
399        minutes: 600,
400        label: "Sydney, Melbourne ST (UTC+10:00)",
401    },
402    TimezoneOption {
403        minutes: 630,
404        label: "Adelaide DT (UTC+10:30)",
405    },
406    TimezoneOption {
407        minutes: 660,
408        label: "Sydney, Melbourne DT (UTC+11:00)",
409    },
410    TimezoneOption {
411        minutes: 720,
412        label: "Auckland, Fiji ST (UTC+12:00)",
413    },
414    TimezoneOption {
415        minutes: 780,
416        label: "Auckland DT (UTC+13:00)",
417    },
418    TimezoneOption {
419        minutes: 840,
420        label: "Kiribati (UTC+14:00)",
421    },
422];
423
424/// A generic text input field for collecting user input during WiFi provisioning.
425///
426/// Presents a customizable text input box in the captive portal that validates and stores
427/// user-provided text to flash. Can be used for device names, locations, or any other
428/// text-based configuration.
429///
430/// Multiple `TextField` instances can be created with different labels and field names
431/// to collect various pieces of information during the provisioning process.
432///
433/// See the [wifi_auto::fields module example](crate::wifi_auto::fields) for usage.
434pub struct TextField<const N: usize> {
435    flash: RefCell<FlashBlock>,
436    field_name: &'static str,
437    label: &'static str,
438    default_value: &'static str,
439}
440
441// SAFETY: TextField is used in a single-threaded Embassy executor on RP2040/RP2350.
442// There are no interrupts that access this data, and all async operations are cooperative
443// (non-preemptive). The Sync bound is required only because WifiAutoField trait objects
444// are stored in static storage, not because of actual concurrent access.
445unsafe impl<const N: usize> Sync for TextField<N> {}
446
447/// Static for [`TextField`]. See the [wifi_auto::fields module example](crate::wifi_auto::fields)
448/// for usage.
449pub struct TextFieldStatic<const N: usize> {
450    cell: StaticCell<TextField<N>>,
451}
452
453impl<const N: usize> TextFieldStatic<N> {
454    const fn new() -> Self {
455        Self {
456            cell: StaticCell::new(),
457        }
458    }
459}
460
461impl<const N: usize> TextField<N> {
462    /// Create static resources for [`TextField`].
463    ///
464    /// See the [wifi_auto::fields module example](crate::wifi_auto::fields) for usage.
465    pub const fn new_static() -> TextFieldStatic<N> {
466        TextFieldStatic::new()
467    }
468
469    /// Initialize a new text input field.
470    ///
471    /// # Parameters
472    /// - `text_field_static`: Static resources for initialization
473    /// - `flash`: Flash block for persistent storage
474    /// - `field_name`: HTML form field name (e.g., "device_name", "location")
475    /// - `label`: HTML label text (e.g., "Device Name:", "Location:")
476    /// - `default_value`: Initial value if nothing saved
477    ///
478    /// The maximum length is determined by the generic parameter `N`.
479    ///
480    /// See the [wifi_auto::fields module example](crate::wifi_auto::fields) for usage.
481    pub fn new(
482        text_field_static: &'static TextFieldStatic<N>,
483        flash: FlashBlock,
484        field_name: &'static str,
485        label: &'static str,
486        default_value: &'static str,
487    ) -> &'static Self {
488        text_field_static
489            .cell
490            .init(Self::from_flash(flash, field_name, label, default_value))
491    }
492
493    fn from_flash(
494        flash: FlashBlock,
495        field_name: &'static str,
496        label: &'static str,
497        default_value: &'static str,
498    ) -> Self {
499        Self {
500            flash: RefCell::new(flash),
501            field_name,
502            label,
503            default_value,
504        }
505    }
506
507    /// Load the stored text from flash.
508    ///
509    /// Returns `None` if no text has been configured yet.
510    ///
511    /// See the [wifi_auto::fields module example](crate::wifi_auto::fields) for usage.
512    pub fn text(&self) -> Result<Option<String<N>>> {
513        self.flash.borrow_mut().load::<String<N>>()
514    }
515
516    /// Save new text to flash.
517    ///
518    /// This method allows programmatic updates to the field value, such as when
519    /// the user modifies configuration via button presses or other UI interactions.
520    ///
521    /// The text must not exceed the maximum length `N` specified in the type parameter.
522    ///
523    /// Alternatively, you can access the underlying flash block directly for
524    /// more control over flash operations.
525    pub fn set_text(&self, text: &String<N>) -> Result<()> {
526        self.flash.borrow_mut().save(text)
527    }
528}
529
530impl<const N: usize> WifiAutoField for TextField<N> {
531    fn render(&self, page: &mut HtmlBuffer) -> Result<()> {
532        info!("WifiAuto field: rendering text input");
533        let current = self
534            .text()?
535            .filter(|value| !value.is_empty())
536            .unwrap_or_else(|| {
537                let mut text = String::<N>::new();
538                text.push_str(self.default_value)
539                    .expect("default value exceeds capacity");
540                text
541            });
542        let escaped = simple_escape(current.as_str());
543        FmtWrite::write_fmt(
544            page,
545            format_args!(
546                "<label for=\"{}\">{}:</label>\
547                 <input type=\"text\" id=\"{}\" name=\"{}\" value=\"{}\" \
548                 maxlength=\"{}\" required>",
549                self.field_name, self.label, self.field_name, self.field_name, escaped, N
550            ),
551        )
552        .map_err(|_| Error::FormatError)?;
553        Ok(())
554    }
555
556    fn parse(&self, form: &FormData<'_>) -> Result<()> {
557        let Some(value) = form.get(self.field_name) else {
558            info!("WifiAuto field: text input missing from submission");
559            return Ok(());
560        };
561        let trimmed = value.trim();
562        if trimmed.is_empty() || trimmed.len() > N {
563            return Err(Error::FormatError);
564        }
565        let mut text = String::<N>::new();
566        text.push_str(trimmed).map_err(|_| Error::FormatError)?;
567        self.set_text(&text)
568    }
569
570    fn is_satisfied(&self) -> Result<bool> {
571        Ok(self.text()?.map_or(false, |text| !text.is_empty()))
572    }
573}
574
575fn simple_escape(input: &str) -> String<128> {
576    let mut escaped = String::<128>::new();
577    for ch in input.chars() {
578        match ch {
579            '&' => {
580                escaped
581                    .push_str("&amp;")
582                    .expect("escaped text exceeds capacity");
583            }
584            '<' => {
585                escaped
586                    .push_str("&lt;")
587                    .expect("escaped text exceeds capacity");
588            }
589            '>' => {
590                escaped
591                    .push_str("&gt;")
592                    .expect("escaped text exceeds capacity");
593            }
594            '"' => {
595                escaped
596                    .push_str("&quot;")
597                    .expect("escaped text exceeds capacity");
598            }
599            '\'' => {
600                escaped
601                    .push_str("&#39;")
602                    .expect("escaped text exceeds capacity");
603            }
604            _ => {
605                escaped.push(ch).expect("escaped text exceeds capacity");
606            }
607        }
608    }
609    escaped
610}