1#![allow(missing_docs)]
2#![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
44const 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 let [wifi_credentials_flash_block, timezone_flash_block] = FlashArray::<2>::new(p.FLASH)?;
90
91 static TIMEZONE_FIELD_STATIC: TimezoneFieldStatic = TimezoneField::new_static();
93 let timezone_field = TimezoneField::new(&TIMEZONE_FIELD_STATIC, timezone_flash_block);
94
95 let wifi_auto = WifiAuto::new(
97 p.PIN_23, p.PIN_24, p.PIN_25, p.PIN_29, p.PIO0, p.DMA_CH0, wifi_credentials_flash_block,
104 p.PIN_13, PressedTo::Ground,
106 "www.picoclock.net", [timezone_field], spawner,
109 )?;
110
111 let led8x12 = Led8x12::new(p.PIN_4, p.PIO1, p.DMA_CH1, spawner)?;
113
114 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 let button_watch13 = ButtonWatch13::from_button(button, spawner)?;
147
148 let offset_minutes = timezone_field
150 .offset_minutes()?
151 .ok_or(Error::MissingCustomWifiAutoField)?;
152
153 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 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#[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 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 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 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 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 let (hours, minutes, _) = h12_m_s(&clock_sync.now_local());
293 show_hours_minutes_indicator(led8x12, hours, minutes).await?;
294
295 let mut offset_minutes = clock_sync.offset_minutes();
297 info!("Current offset: {} minutes", offset_minutes);
298
299 clock_sync.set_tick_interval(None).await; 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 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 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 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
334async 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 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}