Skip to main content

clock_servos/
clock_servos.rs

1#![allow(missing_docs)]
2//! Wi-Fi enabled clock that visualizes time with two servos.
3//!
4//! This example combines the `WifiAuto` captive-portal workflow with a servo-based
5//! display. Because the servos are mounted reversed, the left servo shows minutes/seconds
6//! and the right servo shows hours/minutes with 180° reflections applied.
7
8#![no_std]
9#![no_main]
10#![cfg(feature = "wifi")]
11#![allow(clippy::future_not_send, reason = "single-threaded")]
12
13use core::convert::{Infallible, TryFrom};
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::servo_player::{AtEnd, combine, linear, servo_player};
23use device_envoy::wifi_auto::fields::{TimezoneField, TimezoneFieldStatic};
24use device_envoy::wifi_auto::{WifiAuto, WifiAutoEvent};
25use device_envoy::{Error, Result};
26use embassy_executor::Spawner;
27use embassy_futures::select::{Either, select};
28use embassy_time::{Duration, Timer};
29use panic_probe as _;
30
31const FAST_MODE_SPEED: f32 = 720.0;
32
33button_watch! {
34    ButtonWatch13 {
35        pin: PIN_13,
36    }
37}
38
39// Define two typed servo players at module scope
40servo_player! {
41    BottomServoPlayer {
42        pin: PIN_11,
43        max_steps: 30,
44    }
45}
46
47servo_player! {
48    TopServoPlayer {
49        pin: PIN_12,
50        max_steps: 30,
51    }
52}
53
54#[embassy_executor::main]
55pub async fn main(spawner: Spawner) -> ! {
56    let err = inner_main(spawner).await.unwrap_err();
57    core::panic!("{err}");
58}
59
60async fn inner_main(spawner: Spawner) -> Result<Infallible> {
61    info!("Starting Wi-Fi servo clock (WifiAuto)");
62    let p = embassy_rp::init(Default::default());
63
64    // Use two blocks of flash storage: Wi-Fi credentials + timezone
65    let [wifi_credentials_flash_block, timezone_flash_block] = FlashArray::<2>::new(p.FLASH)?;
66
67    // Define HTML to ask for timezone on the captive portal.
68    static TIMEZONE_FIELD_STATIC: TimezoneFieldStatic = TimezoneField::new_static();
69    let timezone_field = TimezoneField::new(&TIMEZONE_FIELD_STATIC, timezone_flash_block);
70
71    // Set up Wifi via a captive portal. The button pin is used to reset stored credentials.
72    let wifi_auto = WifiAuto::new(
73        p.PIN_23,  // CYW43 power
74        p.PIN_24,  // CYW43 clock
75        p.PIN_25,  // CYW43 chip select
76        p.PIN_29,  // CYW43 data pin
77        p.PIO0,    // CYW43 PIO interface
78        p.DMA_CH0, // CYW43 DMA channel
79        wifi_credentials_flash_block,
80        p.PIN_13, // Reset button pin
81        PressedTo::Ground,
82        "PicoServoClock", // Captive-portal SSID
83        [timezone_field],
84        spawner,
85    )?;
86
87    // Configure two servos for the display.
88    let bottom_servo = BottomServoPlayer::new(p.PIN_11, p.PWM_SLICE5, spawner)?;
89    let top_servo = TopServoPlayer::new(p.PIN_12, p.PWM_SLICE6, spawner)?;
90    let servo_display = ServoClockDisplay::new(bottom_servo, top_servo);
91
92    // Connect Wi-Fi, using the servos for status indications.
93    let servo_display_ref = &servo_display;
94    let (stack, button) = wifi_auto
95        .connect(|event| {
96            let servo_display_ref = servo_display_ref;
97            async move {
98                match event {
99                    WifiAutoEvent::CaptivePortalReady => {
100                        servo_display_ref.show_portal_ready().await;
101                    }
102                    WifiAutoEvent::Connecting { .. } => servo_display_ref.show_connecting().await,
103                    WifiAutoEvent::ConnectionFailed => {
104                        // No-op; portal remains visible on failure.
105                    }
106                }
107                Ok(())
108            }
109        })
110        .await?;
111
112    info!("WiFi connected");
113
114    // Convert the Button from WifiAuto into a ButtonWatch for background monitoring
115    let button_watch13 = ButtonWatch13::from_button(button, spawner)?;
116
117    // Read the timezone offset, an extra field that WiFi portal saved to flash.
118    let offset_minutes = timezone_field
119        .offset_minutes()?
120        .ok_or(Error::MissingCustomWifiAutoField)?;
121
122    // Create a ClockSync device that knows its timezone offset.
123    static CLOCK_SYNC_STATIC: ClockSyncStatic = ClockSync::new_static();
124    let clock_sync = ClockSync::new(
125        &CLOCK_SYNC_STATIC,
126        stack,
127        offset_minutes,
128        Some(ONE_MINUTE),
129        spawner,
130    );
131
132    // Start in HH:MM mode
133    let mut state = State::HoursMinutes { speed: 1.0 };
134    loop {
135        state = match state {
136            State::HoursMinutes { speed } => {
137                state
138                    .execute_hours_minutes(speed, &clock_sync, button_watch13, &servo_display)
139                    .await?
140            }
141            State::MinutesSeconds => {
142                state
143                    .execute_minutes_seconds(&clock_sync, button_watch13, &servo_display)
144                    .await?
145            }
146            State::EditOffset => {
147                state
148                    .execute_edit_offset(
149                        &clock_sync,
150                        button_watch13,
151                        &timezone_field,
152                        &servo_display,
153                    )
154                    .await?
155            }
156        };
157    }
158}
159
160// State machine for servo clock display modes and transitions.
161
162/// Display states for the servo clock.
163#[derive(Debug, defmt::Format, Clone, Copy, PartialEq)]
164pub enum State {
165    HoursMinutes { speed: f32 },
166    MinutesSeconds,
167    EditOffset,
168}
169
170impl State {
171    async fn execute_hours_minutes(
172        self,
173        speed: f32,
174        clock_sync: &ClockSync,
175        button_watch13: &ButtonWatch13,
176        servo_display: &ServoClockDisplay,
177    ) -> Result<Self> {
178        clock_sync.set_speed(speed).await;
179        let (hours, minutes, _) = h12_m_s(&clock_sync.now_local());
180        servo_display.show_hours_minutes(hours, minutes).await;
181        clock_sync.set_tick_interval(Some(ONE_MINUTE)).await;
182        loop {
183            match select(
184                button_watch13.wait_for_press_duration(),
185                clock_sync.wait_for_tick(),
186            )
187            .await
188            {
189                // Button pushes
190                Either::First(press_duration) => match (press_duration, speed.to_bits()) {
191                    (PressDuration::Short, bits) if bits == 1.0f32.to_bits() => {
192                        return Ok(Self::MinutesSeconds);
193                    }
194                    (PressDuration::Short, _) => {
195                        return Ok(Self::HoursMinutes { speed: 1.0 });
196                    }
197                    (PressDuration::Long, _) => {
198                        return Ok(Self::EditOffset);
199                    }
200                },
201                // Clock tick
202                Either::Second(tick) => {
203                    let (hours, minutes, _) = h12_m_s(&tick.local_time);
204                    servo_display.show_hours_minutes(hours, minutes).await;
205                }
206            }
207        }
208    }
209
210    async fn execute_minutes_seconds(
211        self,
212        clock_sync: &ClockSync,
213        button_watch13: &ButtonWatch13,
214        servo_display: &ServoClockDisplay,
215    ) -> Result<Self> {
216        clock_sync.set_speed(1.0).await;
217        let (_, minutes, seconds) = h12_m_s(&clock_sync.now_local());
218        servo_display.show_minutes_seconds(minutes, seconds).await;
219        clock_sync.set_tick_interval(Some(ONE_SECOND)).await;
220        loop {
221            match select(
222                button_watch13.wait_for_press_duration(),
223                clock_sync.wait_for_tick(),
224            )
225            .await
226            {
227                // Button pushes
228                Either::First(PressDuration::Short) => {
229                    return Ok(Self::HoursMinutes {
230                        speed: FAST_MODE_SPEED,
231                    });
232                }
233                Either::First(PressDuration::Long) => {
234                    return Ok(Self::EditOffset);
235                }
236                // Clock tick
237                Either::Second(tick) => {
238                    let (_, minutes, seconds) = h12_m_s(&tick.local_time);
239                    servo_display.show_minutes_seconds(minutes, seconds).await;
240                }
241            }
242        }
243    }
244
245    async fn execute_edit_offset(
246        self,
247        clock_sync: &ClockSync,
248        button_watch13: &ButtonWatch13,
249        timezone_field: &TimezoneField,
250        servo_display: &ServoClockDisplay,
251    ) -> Result<Self> {
252        info!("Entering edit offset mode");
253        clock_sync.set_speed(1.0).await;
254
255        // Show current hours and minutes
256        let (hours, minutes, _) = h12_m_s(&clock_sync.now_local());
257        servo_display
258            .show_hours_minutes_indicator(hours, minutes)
259            .await;
260        // Add a gentle wiggle on the bottom servo to signal edit mode.
261        const WIGGLE: [(u16, Duration); 2] = [
262            (80, Duration::from_millis(250)),
263            (100, Duration::from_millis(250)),
264        ];
265        servo_display.bottom.animate(WIGGLE, AtEnd::Loop);
266
267        // Get the current offset minutes from clock (source of truth)
268        let mut offset_minutes = clock_sync.offset_minutes();
269        info!("Current offset: {} minutes", offset_minutes);
270
271        clock_sync.set_tick_interval(None).await; // Disable ticks in edit mode
272        loop {
273            info!("Waiting for button press in edit mode");
274            match button_watch13.wait_for_press_duration().await {
275                PressDuration::Short => {
276                    info!("Short press detected - incrementing offset");
277                    // Increment the offset by 1 hour
278                    offset_minutes += 60;
279                    const ONE_DAY_MINUTES: i32 = ONE_DAY.as_secs() as i32 / 60;
280                    if offset_minutes >= ONE_DAY_MINUTES {
281                        offset_minutes -= ONE_DAY_MINUTES;
282                    }
283                    clock_sync.set_offset_minutes(offset_minutes).await;
284                    info!("New offset: {} minutes", offset_minutes);
285
286                    // Update display (atomic already updated, can use now_local)
287                    let (hours, minutes, _) = h12_m_s(&clock_sync.now_local());
288                    info!(
289                        "Updated time after offset change: {:02}:{:02}",
290                        hours, minutes
291                    );
292                    servo_display
293                        .show_hours_minutes_indicator(hours, minutes)
294                        .await;
295                    servo_display.bottom.animate(WIGGLE, AtEnd::Loop);
296                }
297                PressDuration::Long => {
298                    info!("Long press detected - saving and exiting edit mode");
299                    // Save to flash and exit edit mode
300                    timezone_field.set_offset_minutes(offset_minutes)?;
301                    info!("Offset saved to flash: {} minutes", offset_minutes);
302                    return Ok(Self::HoursMinutes { speed: 1.0 });
303                }
304            }
305        }
306    }
307}
308
309struct ServoClockDisplay {
310    bottom: &'static BottomServoPlayer,
311    top: &'static TopServoPlayer,
312}
313
314impl ServoClockDisplay {
315    fn new(bottom: &'static BottomServoPlayer, top: &'static TopServoPlayer) -> Self {
316        Self { bottom, top }
317    }
318
319    async fn show_portal_ready(&self) {
320        self.bottom.set_degrees(90);
321        self.top.set_degrees(90);
322    }
323
324    async fn show_connecting(&self) {
325        // Animate both servos in complementary two-phase sweeps.
326        const FIVE_SECONDS: Duration = Duration::from_secs(5);
327        const PHASE1: [(u16, Duration); 10] = linear(180 - 18, 0, FIVE_SECONDS);
328        const PHASE2: [(u16, Duration); 2] = linear(0, 180, FIVE_SECONDS);
329        self.top.animate(combine!(PHASE1, PHASE2), AtEnd::Loop);
330        self.bottom.animate(combine!(PHASE2, PHASE1), AtEnd::Loop);
331    }
332
333    async fn show_hours_minutes(&self, hours: u8, minutes: u8) {
334        let left_angle = hours_to_degrees(hours);
335        let right_angle = sixty_to_degrees(minutes);
336        self.set_angles(left_angle, right_angle).await;
337        Timer::after(Duration::from_millis(500)).await;
338        self.bottom.relax();
339        self.top.relax();
340    }
341
342    async fn show_hours_minutes_indicator(&self, hours: u8, minutes: u8) {
343        let left_angle = hours_to_degrees(hours);
344        let right_angle = sixty_to_degrees(minutes);
345        self.set_angles(left_angle, right_angle).await;
346        Timer::after(Duration::from_millis(500)).await;
347        self.bottom.relax();
348        self.top.relax();
349    }
350
351    async fn show_minutes_seconds(&self, minutes: u8, seconds: u8) {
352        let left_angle = sixty_to_degrees(minutes);
353        let right_angle = sixty_to_degrees(seconds);
354        self.set_angles(left_angle, right_angle).await;
355    }
356
357    async fn set_angles(&self, left_degrees: i32, right_degrees: i32) {
358        // Swap servos and reflect angles for physical orientation.
359        let physical_left = reflect_degrees(right_degrees);
360        let physical_right = reflect_degrees(left_degrees);
361        let left_angle =
362            u16::try_from(physical_left).expect("servo angles must be between 0 and 180 degrees");
363        let right_angle =
364            u16::try_from(physical_right).expect("servo angles must be between 0 and 180 degrees");
365        self.bottom.set_degrees(left_angle);
366        self.top.set_degrees(right_angle);
367    }
368}
369
370#[inline]
371fn hours_to_degrees(hours: u8) -> i32 {
372    assert!((1..=12).contains(&hours));
373    let normalized_hour = hours % 12;
374    i32::from(normalized_hour) * 180 / 12
375}
376
377#[inline]
378fn sixty_to_degrees(value: u8) -> i32 {
379    assert!(value < 60);
380    i32::from(value) * 180 / 60
381}
382
383#[inline]
384fn reflect_degrees(degrees: i32) -> i32 {
385    assert!((0..=180).contains(&degrees));
386    180 - degrees
387}