device-envoy-esp 0.1.0

Build ESP32 applications with composable device abstractions
Documentation
// @generated by cargo xtask generate-board-examples
//!
//! Wiring:
//! - Force-portal button -> GPIO6 to GND (`PressedTo::Ground`)
//! - Bottom servo signal -> GPIO10
//! - Top servo signal -> GPIO18
//! - Servo power -> 5V (do not use 3.3V for typical hobby servos)
//! - Servo ground -> GND (shared with ESP32 GND)
//! - If using a separate 5V supply, connect supply GND to ESP32 GND (common ground required)
//! - Do not power a servo directly from a GPIO pin
//! - Common red/brown/yellow wiring (each servo):
//!   red -> 5V, brown -> GND, yellow -> signal (GPIO10 / GPIO18)
//! Wi-Fi enabled clock that visualizes time with two hobby servos.

#![no_std]
#![no_main]
#![allow(clippy::future_not_send, reason = "single-threaded")]

use core::convert::{Infallible, TryFrom};

use device_envoy_example_common::clock_ui::{ClockUiEvent, run_clock_ui};
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_backtrace as _;
use log::info;

use device_envoy_esp::{
    Error, Result,
    button::PressedTo,
    button_watch,
    clock_sync::{ClockSyncEsp, ClockSyncStaticEsp, ONE_MINUTE},
    flash_block::FlashBlockEsp,
    init_and_start,
    servo::Servo as _,
    servo::{AtEnd, Direction, ServoPlayer as _, ServoPlayerHandle, combine, linear, servo_player},
    wifi_auto::{
        WifiAuto as _, WifiAutoEsp, WifiAutoEvent,
        fields::{TimezoneField, TimezoneFieldStatic},
    },
};
esp_bootloader_esp_idf::esp_app_desc!();

const CAPTIVE_PORTAL_SSID: &str = "DeviceEnvoyClock";
const SERVO_MAX_STEPS: usize = 30;
type ClockServoPlayer = ServoPlayerHandle<SERVO_MAX_STEPS>;

button_watch! {
    ForcePortalButtonWatch {
        pin: GPIO6,
    }
}

servo_player! {
    BottomServoPlayer {
        pin: GPIO10,
        timer: Timer0,
        channel: Channel0,
        direction: Direction::Reverse,
        max_steps: SERVO_MAX_STEPS,
    }
}

servo_player! {
    TopServoPlayer {
        pin: GPIO18,
        timer: Timer1,
        channel: Channel1,
        direction: Direction::Reverse,
        max_steps: SERVO_MAX_STEPS,
    }
}

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    let err = inner_main(spawner).await.unwrap_err();
    panic!("{err:?}");
}

async fn inner_main(spawner: Spawner) -> Result<Infallible> {
    init_and_start!(p, ledc: ledc);
    esp_println::logger::init_logger(log::LevelFilter::Info);

    info!("Starting Wi-Fi servo clock (WifiAuto)");

    let [wifi_auto_flash_block, mut timezone_flash_block] = FlashBlockEsp::new_array::<2>(p.FLASH)?;

    static TIMEZONE_FIELD_STATIC: TimezoneFieldStatic = TimezoneField::new_static();
    let timezone_field = TimezoneField::new(&TIMEZONE_FIELD_STATIC, timezone_flash_block);
    let button_watch6 = ForcePortalButtonWatch::new(p.GPIO6, PressedTo::Ground, spawner).await?;

    let wifi_auto = WifiAutoEsp::new(
        p.WIFI,
        wifi_auto_flash_block,
        CAPTIVE_PORTAL_SSID,
        [timezone_field],
        spawner,
    )?;

    let bottom_servo_player = BottomServoPlayer::new(&ledc, p.GPIO10, spawner)?;
    let top_servo_player = TopServoPlayer::new(&ledc, p.GPIO18, spawner)?;

    let servo_clock_display = ServoClockDisplay::new(bottom_servo_player, top_servo_player);
    let servo_clock_display_ref = &servo_clock_display;

    let stack = wifi_auto
        .connect(&mut *button_watch6, |wifi_auto_event| {
            let servo_clock_display_ref = servo_clock_display_ref;
            async move {
                match wifi_auto_event {
                    WifiAutoEvent::CaptivePortalReady => {
                        info!("WifiAuto: setup mode ready");
                        servo_clock_display_ref.show_portal_ready().await;
                    }
                    WifiAutoEvent::Connecting { .. } => {
                        info!("WifiAuto: connecting");
                        servo_clock_display_ref.show_connecting().await;
                    }
                    WifiAutoEvent::ConnectionFailed => {
                        info!("WifiAuto: connection failed");
                        servo_clock_display_ref.show_connection_failed().await;
                    }
                }
                Ok(())
            }
        })
        .await?;

    info!("WiFi connected");
    servo_clock_display.show_portal_ready().await;

    let offset_minutes = timezone_field
        .offset_minutes()?
        .ok_or(Error::MissingCustomWifiAutoField)?;

    static CLOCK_SYNC_STATIC: ClockSyncStaticEsp = ClockSyncEsp::new_static();
    let clock_sync = ClockSyncEsp::new(
        &CLOCK_SYNC_STATIC,
        stack,
        offset_minutes,
        Some(ONE_MINUTE),
        spawner,
    )?;

    run_clock_ui(
        &clock_sync,
        &mut *button_watch6,
        &mut timezone_flash_block,
        |clock_ui_event| {
            let servo_clock_display_ref = &servo_clock_display;
            async move {
                match clock_ui_event {
                    ClockUiEvent::RenderHoursMinutes { hours, minutes } => {
                        info!("Clock mode: hours-minutes");
                        servo_clock_display_ref
                            .show_hours_minutes(hours, minutes)
                            .await;
                    }
                    ClockUiEvent::RenderMinutesSeconds { minutes, seconds } => {
                        info!("Clock mode: minutes-seconds");
                        servo_clock_display_ref
                            .show_minutes_seconds(minutes, seconds)
                            .await;
                    }
                    ClockUiEvent::RenderHoursMinutesEdit { hours, minutes } => {
                        info!("Clock mode: hours-minutes edit indicator");
                        servo_clock_display_ref
                            .show_hours_minutes_indicator(hours, minutes)
                            .await;
                    }
                }
                Ok(())
            }
        },
    )
    .await
}

struct ServoClockDisplay {
    bottom: ClockServoPlayer,
    top: ClockServoPlayer,
}

impl ServoClockDisplay {
    fn new(bottom: ClockServoPlayer, top: ClockServoPlayer) -> Self {
        Self { bottom, top }
    }

    async fn show_portal_ready(&self) {
        info!("ServoClockDisplay: portal-ready marker (bottom=90, top=90)");
        self.set_angles(90, 90).await;
    }

    async fn show_connecting(&self) {
        info!("ServoClockDisplay: connecting animation started (counter-rotating)");
        const FIVE_SECONDS: Duration = Duration::from_secs(5);
        const PHASE1: [(u16, Duration); 10] = linear(18, 0, FIVE_SECONDS);
        const PHASE2: [(u16, Duration); 2] = linear(0, 180, FIVE_SECONDS);
        self.top
            .animate(combine::<10, 2, 12>(PHASE1, PHASE2), AtEnd::Loop);
        self.bottom
            .animate(combine::<2, 10, 12>(PHASE2, PHASE1), AtEnd::Loop);
    }

    async fn show_connection_failed(&self) {
        info!("ServoClockDisplay: connection-failed marker (bottom=0, top=180)");
        self.set_angles(0, 180).await;
    }

    async fn show_hours_minutes(&self, hours: u8, minutes: u8) {
        info!(
            "ServoClockDisplay: show_hours_minutes hours={} minutes={}",
            hours, minutes
        );
        let left_degrees = hours_to_degrees(hours);
        let right_degrees = sixty_to_degrees(minutes);
        self.set_angles(left_degrees, right_degrees).await;
        Timer::after(Duration::from_millis(500)).await;
        self.bottom.relax();
        self.top.relax();
    }

    async fn show_minutes_seconds(&self, minutes: u8, seconds: u8) {
        info!(
            "ServoClockDisplay: show_minutes_seconds minutes={} seconds={}",
            minutes, seconds
        );
        let left_degrees = sixty_to_degrees(minutes);
        let right_degrees = sixty_to_degrees(seconds);
        self.set_angles(left_degrees, right_degrees).await;
    }

    async fn show_hours_minutes_indicator(&self, hours: u8, minutes: u8) {
        info!(
            "ServoClockDisplay: edit-indicator blink for hours={} minutes={}",
            hours, minutes
        );
        let left_degrees = hours_to_degrees(hours);
        let right_degrees = sixty_to_degrees(minutes);
        self.set_angles(left_degrees, right_degrees).await;
        Timer::after(Duration::from_millis(500)).await;
        self.bottom.relax();
        self.top.relax();
    }

    async fn set_angles(&self, left_degrees: i32, right_degrees: i32) {
        let bottom_degrees = right_degrees;
        let top_degrees = left_degrees;
        let left_angle =
            u16::try_from(bottom_degrees).expect("servo angles must be between 0 and 180 degrees");
        let right_angle =
            u16::try_from(top_degrees).expect("servo angles must be between 0 and 180 degrees");
        info!(
            "ServoClockDisplay: set_angles logical_left={} logical_right={} bottom={} top={}",
            left_degrees, right_degrees, bottom_degrees, top_degrees
        );
        self.bottom.set_degrees(left_angle);
        self.top.set_degrees(right_angle);
    }
}

#[expect(
    clippy::arithmetic_side_effects,
    clippy::integer_division_remainder_used,
    reason = "Checked ranges ensure safe integer scaling."
)]
fn hours_to_degrees(hours: u8) -> i32 {
    assert!((1..=12).contains(&hours));
    let normalized_hour = hours % 12;
    i32::from(normalized_hour) * 180 / 12
}

#[expect(
    clippy::arithmetic_side_effects,
    clippy::integer_division_remainder_used,
    reason = "Checked ranges ensure safe integer scaling."
)]
fn sixty_to_degrees(value: u8) -> i32 {
    assert!(value < 60);
    i32::from(value) * 180 / 60
}