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("&")
582 .expect("escaped text exceeds capacity");
583 }
584 '<' => {
585 escaped
586 .push_str("<")
587 .expect("escaped text exceeds capacity");
588 }
589 '>' => {
590 escaped
591 .push_str(">")
592 .expect("escaped text exceeds capacity");
593 }
594 '"' => {
595 escaped
596 .push_str(""")
597 .expect("escaped text exceeds capacity");
598 }
599 '\'' => {
600 escaped
601 .push_str("'")
602 .expect("escaped text exceeds capacity");
603 }
604 _ => {
605 escaped.push(ch).expect("escaped text exceeds capacity");
606 }
607 }
608 }
609 escaped
610}