Skip to main content

clock_led8x12/
clock_led8x12.rs

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