1#![allow(missing_docs)]
2#![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
39const 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 let [wifi_credentials_flash_block, timezone_flash_block] = FlashArray::<2>::new(p.FLASH)?;
83
84 static TIMEZONE_FIELD_STATIC: TimezoneFieldStatic = TimezoneField::new_static();
86 let timezone_field = TimezoneField::new(&TIMEZONE_FIELD_STATIC, timezone_flash_block);
87
88 let wifi_auto = WifiAuto::new(
90 p.PIN_23, p.PIN_24, p.PIN_25, p.PIN_29, p.PIO1, p.DMA_CH0, wifi_credentials_flash_block,
97 p.PIN_13, PressedTo::Ground,
99 "www.picoclock.net", [timezone_field], spawner,
102 )?;
103
104 let led12x4 = Led12x4::new(p.PIN_3, p.PIO0, p.DMA_CH1, spawner)?;
106
107 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 let button_watch13 = ButtonWatch13::from_button(button, spawner)?;
140
141 let offset_minutes = timezone_field
143 .offset_minutes()?
144 .ok_or(Error::MissingCustomWifiAutoField)?;
145
146 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 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#[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 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 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 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 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 let (hours, minutes, _) = h12_m_s(&clock_sync.now_local());
296 show_hours_minutes_indicator(led12x4, hours, minutes).await?;
297
298 let mut offset_minutes = clock_sync.offset_minutes();
300 info!("Current offset: {} minutes", offset_minutes);
301
302 clock_sync.set_tick_interval(None).await; 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 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 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 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
337async 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}