#![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
}