Skip to main content

clock_led12x4/
clock_led12x4.rs

1#![allow(missing_docs)]
2//! Wi-Fi enabled 4-character LED panel clock (12x4 pixels) with captive-portal setup.
3//!
4//! This example mirrors the WiFi/clock state machine from `clock_servos.rs` but drives a
5//! 12x4 LED panel on GPIO3 instead of servos. The reset button is on GPIO13.
6
7#![no_std]
8#![no_main]
9#![cfg(feature = "wifi")]
10#![allow(clippy::future_not_send, reason = "single-threaded")]
11
12use core::convert::Infallible;
13
14use defmt::info;
15use defmt_rtt as _;
16use device_envoy::button::{PressDuration, PressedTo};
17use device_envoy::button_watch;
18use device_envoy::clock_sync::{
19    ClockSync, ClockSyncStatic, ONE_DAY, ONE_MINUTE, ONE_SECOND, h12_m_s,
20};
21use device_envoy::flash_array::FlashArray;
22use device_envoy::led_strip::Current;
23use device_envoy::led_strip::Gamma;
24use device_envoy::led_strip::colors;
25use device_envoy::led2d;
26use device_envoy::led2d::Frame2d;
27use device_envoy::led2d::Led2dFont;
28use device_envoy::led2d::layout::LedLayout;
29use device_envoy::wifi_auto::fields::{TimezoneField, TimezoneFieldStatic};
30use device_envoy::wifi_auto::{WifiAuto, WifiAutoEvent};
31use device_envoy::{Error, Result};
32use embassy_executor::Spawner;
33use embassy_futures::select::{Either, select};
34use embassy_time::Duration;
35use heapless::String;
36use panic_probe as _;
37use smart_leds::RGB8;
38
39// Single 12x4 panel wired serpentine column-major.
40const LED_LAYOUT_12X4: LedLayout<48, 12, 4> = LedLayout::serpentine_column_major();
41
42led2d! {
43    Led12x4 {
44        pio: PIO0,
45        pin: PIN_3,
46        dma: DMA_CH1,
47        led_layout: LED_LAYOUT_12X4,
48        max_current: Current::Milliamps(500),
49        gamma: Gamma::Linear,
50        max_frames: 32,
51        font: Led2dFont::Font3x4Trim,
52    }
53}
54
55const FAST_MODE_SPEED: f32 = 720.0;
56const CONNECTING_COLOR: RGB8 = colors::SADDLE_BROWN;
57const DIGIT_COLORS: [RGB8; 4] = [colors::NAVY, colors::GREEN, colors::TEAL, colors::MAROON];
58const EDIT_COLORS: [RGB8; 4] = [
59    colors::FIREBRICK,
60    colors::DARK_ORANGE,
61    colors::TEAL,
62    colors::MAROON,
63];
64
65button_watch! {
66    ButtonWatch13 {
67        pin: PIN_13,
68    }
69}
70
71#[embassy_executor::main]
72pub async fn main(spawner: Spawner) -> ! {
73    let err = inner_main(spawner).await.unwrap_err();
74    core::panic!("{err}");
75}
76
77async fn inner_main(spawner: Spawner) -> Result<Infallible> {
78    info!("Starting Wi-Fi 12x4 LED clock (WifiAuto)");
79    let p = embassy_rp::init(Default::default());
80
81    // Use two blocks of flash storage: Wi-Fi credentials + timezone
82    let [wifi_credentials_flash_block, timezone_flash_block] = FlashArray::<2>::new(p.FLASH)?;
83
84    // Define HTML to ask for timezone on the captive portal.
85    static TIMEZONE_FIELD_STATIC: TimezoneFieldStatic = TimezoneField::new_static();
86    let timezone_field = TimezoneField::new(&TIMEZONE_FIELD_STATIC, timezone_flash_block);
87
88    // Set up Wifi via a captive portal. The button pin is used to reset stored credentials.
89    let wifi_auto = WifiAuto::new(
90        p.PIN_23,  // CYW43 power
91        p.PIN_24,  // CYW43 clock
92        p.PIN_25,  // CYW43 chip select
93        p.PIN_29,  // CYW43 data pin
94        p.PIO1,    // CYW43 PIO interface (swapped to show PIO not hardcoded)
95        p.DMA_CH0, // CYW43 DMA channel
96        wifi_credentials_flash_block,
97        p.PIN_13, // Reset button pin
98        PressedTo::Ground,
99        "www.picoclock.net", // Captive-portal SSID
100        [timezone_field],    // Custom fields to ask for
101        spawner,
102    )?;
103
104    // Set up the 12x4 LED display on GPIO3.
105    let led12x4 = Led12x4::new(p.PIN_3, p.PIO0, p.DMA_CH1, spawner)?;
106
107    // Connect Wi-Fi, using the LED panel for status.
108    let led12x4_ref = &led12x4;
109    let (stack, button) = wifi_auto
110        .connect(|event| {
111            let led12x4_ref = led12x4_ref;
112            async move {
113                match event {
114                    WifiAutoEvent::CaptivePortalReady => {
115                        info!("WiFi: captive portal ready, displaying JOIN");
116                        show_portal_ready(led12x4_ref).await?;
117                    }
118                    WifiAutoEvent::Connecting {
119                        try_index,
120                        try_count,
121                    } => {
122                        info!("WiFi: connecting (attempt {}/{})", try_index + 1, try_count);
123                        show_connecting(led12x4_ref, try_index, try_count).await?;
124                    }
125                    WifiAutoEvent::ConnectionFailed => {
126                        info!("WiFi: connection failed, displaying FAIL, device will reset");
127                        show_connection_failed(led12x4_ref).await?;
128                    }
129                }
130                Ok(())
131            }
132        })
133        .await?;
134
135    info!("WiFi: connected successfully, displaying DONE");
136    show_connected(&led12x4).await?;
137
138    // Convert the Button from WifiAuto into a ButtonWatch for background monitoring
139    let button_watch13 = ButtonWatch13::from_button(button, spawner)?;
140
141    // Read the timezone offset, an extra field that WiFi portal saved to flash.
142    let offset_minutes = timezone_field
143        .offset_minutes()?
144        .ok_or(Error::MissingCustomWifiAutoField)?;
145
146    // Create a ClockSync device that knows its timezone offset.
147    static CLOCK_SYNC_STATIC: ClockSyncStatic = ClockSync::new_static();
148    let clock_sync = ClockSync::new(
149        &CLOCK_SYNC_STATIC,
150        stack,
151        offset_minutes,
152        Some(ONE_MINUTE),
153        spawner,
154    );
155
156    // Start in HH:MM mode
157    let mut state = State::HoursMinutes { speed: 1.0 };
158    loop {
159        state = match state {
160            State::HoursMinutes { speed } => {
161                state
162                    .execute_hours_minutes(speed, &clock_sync, button_watch13, &led12x4)
163                    .await?
164            }
165            State::MinutesSeconds => {
166                state
167                    .execute_minutes_seconds(&clock_sync, button_watch13, &led12x4)
168                    .await?
169            }
170            State::EditOffset => {
171                state
172                    .execute_edit_offset(&clock_sync, button_watch13, &timezone_field, &led12x4)
173                    .await?
174            }
175        };
176    }
177}
178
179// State machine for 24x4 LED clock display modes and transitions.
180
181/// Display states for the 24x4 LED clock.
182#[derive(Debug, defmt::Format, Clone, Copy, PartialEq)]
183pub enum State {
184    HoursMinutes { speed: f32 },
185    MinutesSeconds,
186    EditOffset,
187}
188
189impl State {
190    async fn execute_hours_minutes(
191        self,
192        speed: f32,
193        clock_sync: &ClockSync,
194        button_watch13: &ButtonWatch13,
195        led12x4: &Led12x4,
196    ) -> Result<Self> {
197        clock_sync.set_speed(speed).await;
198        let (hours, minutes, _) = h12_m_s(&clock_sync.now_local());
199        show_hours_minutes(led12x4, hours, minutes).await?;
200        clock_sync.set_tick_interval(Some(ONE_MINUTE)).await;
201        loop {
202            match select(
203                button_watch13.wait_for_press_duration(),
204                clock_sync.wait_for_tick(),
205            )
206            .await
207            {
208                // Button pushes
209                Either::First(press_duration) => {
210                    info!(
211                        "HoursMinutes: Button press detected: {:?}, speed_bits={}",
212                        press_duration,
213                        speed.to_bits()
214                    );
215                    match (press_duration, speed.to_bits()) {
216                        (PressDuration::Short, bits) if bits == 1.0f32.to_bits() => {
217                            info!("HoursMinutes -> MinutesSeconds");
218                            return Ok(Self::MinutesSeconds);
219                        }
220                        (PressDuration::Short, _) => {
221                            info!("HoursMinutes: Resetting speed to 1.0");
222                            return Ok(Self::HoursMinutes { speed: 1.0 });
223                        }
224                        (PressDuration::Long, _) => {
225                            info!("HoursMinutes -> EditOffset");
226                            return Ok(Self::EditOffset);
227                        }
228                    }
229                }
230                // Clock tick
231                Either::Second(tick) => {
232                    let (hours, minutes, _) = h12_m_s(&tick.local_time);
233                    show_hours_minutes(led12x4, hours, minutes).await?;
234                }
235            }
236        }
237    }
238
239    async fn execute_minutes_seconds(
240        self,
241        clock_sync: &ClockSync,
242        button_watch13: &ButtonWatch13,
243        led12x4: &Led12x4,
244    ) -> Result<Self> {
245        clock_sync.set_speed(1.0).await;
246        let (_, minutes, seconds) = h12_m_s(&clock_sync.now_local());
247        show_minutes_seconds(led12x4, minutes, seconds).await?;
248        clock_sync.set_tick_interval(Some(ONE_SECOND)).await;
249        loop {
250            match select(
251                button_watch13.wait_for_press_duration(),
252                clock_sync.wait_for_tick(),
253            )
254            .await
255            {
256                // Button pushes
257                Either::First(press_duration) => {
258                    info!(
259                        "MinutesSeconds: Button press detected: {:?}",
260                        press_duration
261                    );
262                    match press_duration {
263                        PressDuration::Short => {
264                            info!("MinutesSeconds -> HoursMinutes (fast)");
265                            return Ok(Self::HoursMinutes {
266                                speed: FAST_MODE_SPEED,
267                            });
268                        }
269                        PressDuration::Long => {
270                            info!("MinutesSeconds -> EditOffset");
271                            return Ok(Self::EditOffset);
272                        }
273                    }
274                }
275                // Clock tick
276                Either::Second(tick) => {
277                    let (_, minutes, seconds) = h12_m_s(&tick.local_time);
278                    show_minutes_seconds(led12x4, minutes, seconds).await?;
279                }
280            }
281        }
282    }
283
284    async fn execute_edit_offset(
285        self,
286        clock_sync: &ClockSync,
287        button_watch13: &ButtonWatch13,
288        timezone_field: &TimezoneField,
289        led12x4: &Led12x4,
290    ) -> Result<Self> {
291        info!("Entering edit offset mode");
292        clock_sync.set_speed(1.0).await;
293
294        // Blink current hours and minutes with edit color accent.
295        let (hours, minutes, _) = h12_m_s(&clock_sync.now_local());
296        show_hours_minutes_indicator(led12x4, hours, minutes).await?;
297
298        // Get the current offset minutes from clock (source of truth)
299        let mut offset_minutes = clock_sync.offset_minutes();
300        info!("Current offset: {} minutes", offset_minutes);
301
302        clock_sync.set_tick_interval(None).await; // Disable ticks in edit mode
303        loop {
304            info!("Waiting for button press in edit mode");
305            match button_watch13.wait_for_press_duration().await {
306                PressDuration::Short => {
307                    info!("Short press detected - incrementing offset");
308                    // Increment the offset by 1 hour
309                    offset_minutes += 60;
310                    const ONE_DAY_MINUTES: i32 = ONE_DAY.as_secs() as i32 / 60;
311                    if offset_minutes >= ONE_DAY_MINUTES {
312                        offset_minutes -= ONE_DAY_MINUTES;
313                    }
314                    clock_sync.set_offset_minutes(offset_minutes).await;
315                    info!("New offset: {} minutes", offset_minutes);
316
317                    // Update display (atomic already updated, can use now_local)
318                    let (hours, minutes, _) = h12_m_s(&clock_sync.now_local());
319                    info!(
320                        "Updated time after offset change: {:02}:{:02}",
321                        hours, minutes
322                    );
323                    show_hours_minutes_indicator(led12x4, hours, minutes).await?;
324                }
325                PressDuration::Long => {
326                    info!("Long press detected - saving and exiting edit mode");
327                    // Save to flash and exit edit mode
328                    timezone_field.set_offset_minutes(offset_minutes)?;
329                    info!("Offset saved to flash: {} minutes", offset_minutes);
330                    return Ok(Self::HoursMinutes { speed: 1.0 });
331                }
332            }
333        }
334    }
335}
336
337// Display helper functions for the 12x4 LED clock
338
339async fn show_portal_ready(led12x4: &Led12x4) -> Result<()> {
340    let on_frame = text_frame(led12x4, "JOIN", &DIGIT_COLORS)?;
341    led12x4.animate([
342        (on_frame, Duration::from_millis(700)),
343        (Frame2d::new(), Duration::from_millis(300)),
344    ])
345}
346
347async fn show_connecting(led12x4: &Led12x4, try_index: u8, _try_count: u8) -> Result<()> {
348    let clockwise = try_index % 2 == 0;
349    const FRAME_DURATION: Duration = Duration::from_millis(90);
350    let animation = perimeter_chase_animation(clockwise, CONNECTING_COLOR, FRAME_DURATION)?;
351    led12x4.animate(animation)
352}
353
354async fn show_connected(led12x4: &Led12x4) -> Result<()> {
355    led12x4.write_text("DONE", &DIGIT_COLORS).await
356}
357
358async fn show_connection_failed(led12x4: &Led12x4) -> Result<()> {
359    led12x4.write_text("FAIL", &DIGIT_COLORS).await
360}
361
362async fn show_hours_minutes(led12x4: &Led12x4, hours: u8, minutes: u8) -> Result<()> {
363    let (hours_tens, hours_ones) = hours_digits(hours);
364    let (minutes_tens, minutes_ones) = two_digit_chars(minutes);
365    let text = chars_to_text([hours_tens, hours_ones, minutes_tens, minutes_ones]);
366    led12x4.write_text(text.as_str(), &DIGIT_COLORS).await
367}
368
369async fn show_hours_minutes_indicator(led12x4: &Led12x4, hours: u8, minutes: u8) -> Result<()> {
370    let (hours_tens, hours_ones) = hours_digits(hours);
371    let (minutes_tens, minutes_ones) = two_digit_chars(minutes);
372    let text = chars_to_text([hours_tens, hours_ones, minutes_tens, minutes_ones]);
373    led12x4.write_text(text.as_str(), &EDIT_COLORS).await
374}
375
376async fn show_minutes_seconds(led12x4: &Led12x4, minutes: u8, seconds: u8) -> Result<()> {
377    let (minutes_tens, minutes_ones) = two_digit_chars(minutes);
378    let (seconds_tens, seconds_ones) = two_digit_chars(seconds);
379    let text = chars_to_text([minutes_tens, minutes_ones, seconds_tens, seconds_ones]);
380    led12x4.write_text(text.as_str(), &DIGIT_COLORS).await
381}
382
383const PERIMETER_LENGTH: usize = (Led12x4::WIDTH * 2) + ((Led12x4::HEIGHT - 2) * 2);
384
385fn chars_to_text(chars: [char; 4]) -> String<4> {
386    let mut text = String::new();
387    for ch in chars {
388        text.push(ch).expect("text buffer has capacity");
389    }
390    text
391}
392
393fn text_frame(led12x4: &Led12x4, text: &str, colors: &[RGB8]) -> Result<Frame2d<12, 4>> {
394    let mut frame = Frame2d::new();
395    led12x4.write_text_to_frame(text, colors, &mut frame)?;
396    Ok(frame)
397}
398
399fn perimeter_chase_animation(
400    clockwise: bool,
401    color: RGB8,
402    duration: Duration,
403) -> Result<heapless::Vec<(Frame2d<12, 4>, Duration), PERIMETER_LENGTH>> {
404    assert!(
405        duration.as_micros() > 0,
406        "perimeter animation duration must be positive"
407    );
408    let coordinates = perimeter_coordinates(clockwise);
409    let mut frames = heapless::Vec::new();
410    for frame_index in 0..PERIMETER_LENGTH {
411        let mut frame = Frame2d::new();
412        let (x_index, y_index) = coordinates[frame_index];
413        frame[(x_index, y_index)] = color;
414        frames
415            .push((frame, duration))
416            .map_err(|_| Error::FormatError)?;
417    }
418    Ok(frames)
419}
420
421fn perimeter_coordinates(clockwise: bool) -> [(usize, usize); PERIMETER_LENGTH] {
422    let mut coordinates = [(0_usize, 0_usize); PERIMETER_LENGTH];
423    let mut write_index = 0;
424    let mut push = |x_index: usize, y_index: usize| {
425        coordinates[write_index] = (x_index, y_index);
426        write_index += 1;
427    };
428
429    for x_index in 0..Led12x4::WIDTH {
430        push(x_index, 0);
431    }
432    for y_index in 1..Led12x4::HEIGHT {
433        push(Led12x4::WIDTH - 1, y_index);
434    }
435    for x_index in (0..(Led12x4::WIDTH - 1)).rev() {
436        push(x_index, Led12x4::HEIGHT - 1);
437    }
438    for y_index in (1..(Led12x4::HEIGHT - 1)).rev() {
439        push(0, y_index);
440    }
441
442    debug_assert_eq!(write_index, PERIMETER_LENGTH);
443
444    if clockwise {
445        coordinates
446    } else {
447        let mut reversed = [(0_usize, 0_usize); PERIMETER_LENGTH];
448        for (reverse_index, &(x_index, y_index)) in coordinates.iter().enumerate() {
449            reversed[PERIMETER_LENGTH - 1 - reverse_index] = (x_index, y_index);
450        }
451        reversed
452    }
453}
454
455#[inline]
456fn two_digit_chars(value: u8) -> (char, char) {
457    assert!(value < 100);
458    (tens_digit(value), ones_digit(value))
459}
460
461#[inline]
462fn hours_digits(hours: u8) -> (char, char) {
463    assert!(hours >= 1 && hours <= 12);
464    if hours >= 10 {
465        ('1', ones_digit(hours))
466    } else {
467        (' ', ones_digit(hours))
468    }
469}
470
471#[inline]
472#[expect(
473    clippy::arithmetic_side_effects,
474    clippy::integer_division_remainder_used,
475    reason = "Value < 100 ensures division is safe"
476)]
477fn tens_digit(value: u8) -> char {
478    ((value / 10) + b'0') as char
479}
480
481#[inline]
482#[expect(
483    clippy::arithmetic_side_effects,
484    clippy::integer_division_remainder_used,
485    reason = "Value < 100 ensures division is safe"
486)]
487fn ones_digit(value: u8) -> char {
488    ((value % 10) + b'0') as char
489}