use std::cell::RefCell; use std::num::NonZeroU16; use std::ops::Deref; use std::sync::{Arc, RwLock}; use std::{borrow::Cow}; use std::str::FromStr; use anyhow::Context; use chrono::{DateTime, Datelike, Days, FixedOffset, NaiveTime, TimeDelta, Weekday}; use embedded_graphics::prelude::DrawTarget; use embedded_graphics::{ prelude::{Dimensions, Point}, text::{Text}, Drawable, }; use embedded_hal::delay::DelayNs; use epd_waveshare::{color::Color, epd7in5_v2::Epd7in5, prelude::WaveshareDisplay}; use esp_idf_hal::{ delay::{Ets}, gpio::{Gpio19, PinDriver}, spi::{config, SpiDriverConfig}, }; use esp_idf_svc::http::Method; use esp_idf_svc::http::client::{Configuration as HttpConfig, EspHttpConnection}; use esp_idf_svc::http::server::EspHttpServer; use esp_idf_svc::wifi::{BlockingWifi, EspWifi}; use esp_idf_svc::{ eventloop::EspSystemEventLoop, hal::{prelude::*, spi::SpiDeviceDriver}, nvs::EspDefaultNvsPartition, wifi::Configuration, }; use esp_idf_sys::{esp_deep_sleep, esp_deep_sleep_wake_stub_fn_t}; use u8g2_fonts::U8g2TextStyle; use untis::{Date, Lesson, LessonCode}; use embedded_layout::{layout::linear::{spacing::DistributeFill, FixedMargin, LinearLayout}, prelude::*}; type Timetable = [[Option;8]; 5]; #[unsafe(link_section = ".rtc.data")] static mut WAKEUP_COUNTER: u64 = 0; #[unsafe(link_section = ".rtc.data")] static mut FETCHING_FAIL_COUNT: Option = None; #[unsafe(link_section = ".rtc.data")] static mut ERROR_MSG: Option<[u8; 2048]> = None; static mut WEEK_OFFSET: i32 = 0; fn main() -> anyhow::Result<()> { // It is necessary to call this function once. Otherwise some patches to the runtime // implemented by esp-idf-sys might not link properly. See https://github.com/esp-rs/esp-idf-template/issues/71 esp_idf_svc::sys::link_patches(); // Bind the log crate to the ESP Logging facilities esp_idf_svc::log::EspLogger::initialize_default(); let peripherals = Peripherals::take().unwrap(); log::info!("Took peripherals"); let mut creds = include_str!("../wifi-creds.txt").split('\n'); let ssid: heapless::String<32> = heapless::String::<32>::from_str(creds.next().unwrap()).unwrap(); let password: heapless::String<64> = heapless::String::<64>::from_str(creds.next().unwrap()).unwrap(); std::panic::set_hook(Box::new(|panic|{ let msg: String = format!("{}", panic);/* if let Some(s) = panic.payload().downcast_ref::<&str>() { format!("{s:?}") } else if let Some(s) = panic.payload().downcast_ref::() { format!("{s:?}") } else { format!("Failed to downcast panic msg") }; */ let msg_bytes = msg.into_bytes(); // Copy the string buffer into the array let mut buffer = [0u8; 2048]; for i in 0..(buffer.len().min(msg_bytes.len())){ buffer[i] = msg_bytes[i]; } unsafe{ ERROR_MSG = Some(buffer); // Sleep for a second esp_deep_sleep(1_000_000) } })); let busy = PinDriver::input(peripherals.pins.gpio17).unwrap(); let rst = PinDriver::output(peripherals.pins.gpio16).unwrap(); let cs = peripherals.pins.gpio5; let clk = peripherals.pins.gpio18; let mosi = peripherals.pins.gpio23; let miso = peripherals.pins.gpio19; // aka. dc let dc = PinDriver::output(miso).unwrap(); log::info!("Initialized pins"); let config = config::Config::new(); let mut spi = SpiDeviceDriver::new_single( peripherals.spi2, // 2 oder 3? clk, mosi, Option::::None, Some(cs), &SpiDriverConfig::default(), &config, ) .unwrap(); log::info!("Initialized spi interface"); // Initialize the draw buffer // Allocating on the stack makes the chip crash. We gotta put it on the heap let mut display = Box::new(epd_waveshare::epd7in5_v2::Display7in5::default()); display.set_rotation(epd_waveshare::prelude::DisplayRotation::Rotate0); let mut delay = Ets; let mut epd7in5 = Epd7in5::new(&mut spi, busy, dc, rst, &mut delay, None).unwrap(); epd7in5.sleep(&mut spi, &mut delay)?; log::info!("Completed"); unsafe{ if let Some(error) = ERROR_MSG{ // Draw to the display let text_style = U8g2TextStyle::new(u8g2_fonts::fonts::u8g2_font_7x14_mr, Color::White); let error_label = Text::new("An error occured:", Point::zero(), &text_style); let error_message = String::from_utf8_lossy(&error); let error_message = match &error_message{ Cow::Borrowed(C) => *C, Cow::Owned(C) => &C } ; let error_message_label = Text::new(error_message, Point::zero(), &text_style); let mut vg = [error_label, error_message_label]; LinearLayout::vertical(Views::new(&mut vg)) .arrange() .draw(&mut *display).unwrap(); epd7in5.update_and_display_frame(&mut spi, &*display.buffer(), &mut delay)?; epd7in5.sleep(&mut spi, &mut delay)?; // Sleep for 12 hours esp_deep_sleep(12*60*60*60*1000_000) } } // Connecting to wifi let sysloop = EspSystemEventLoop::take()?; let nvs = EspDefaultNvsPartition::take()?; let mut wifi = BlockingWifi::wrap( EspWifi::new(peripherals.modem, sysloop.clone(), Some(nvs))?, sysloop.clone(), )?; wifi.set_configuration(&Configuration::Client( esp_idf_svc::wifi::ClientConfiguration { ssid, password, auth_method: esp_idf_svc::wifi::AuthMethod::WPA2WPA3Personal, ..Default::default() }, ))?; fn err() -> !{ println!("No wifi available. Sleeping for 5 minutes"); unsafe{ // retry in 5 minutes esp_deep_sleep(5*60*1000_000) } } wifi.start().unwrap_or_else(|_| err()); wifi.connect().unwrap_or_else(|_| err()); wifi.wait_netif_up().unwrap_or_else(|_| err()); loop {if wifi.is_connected().unwrap_or_else(|_| err()) {break;}}; unsafe { FETCHING_FAIL_COUNT = None; } let mut httpserver = EspHttpServer::new(&esp_idf_svc::http::server::Configuration{ http_port: 80, ..Default::default() })?; let do_update = Arc::new(RwLock::new(Option::::None)); let writer = do_update.clone(); httpserver.fn_handler("/", Method::Get, move |request| -> anyhow::Result<()> { *writer.write().unwrap() = Some(Command::Timetable); unsafe{ WEEK_OFFSET = request.header("week").and_then(|w| w.parse::().ok()).unwrap_or_default(); } // Retrieve html String let html = String::from("OK"); // Respond with OK status let mut response = request.into_ok_response()?; // Return Requested Object (Index Page) response.write(html.as_bytes())?; Ok(()) })?; let writer = do_update.clone(); httpserver.fn_handler("/msg", Method::Get, move |request| -> anyhow::Result<()> { *writer.write().unwrap() = Some(Command::Message(request.header("msg").unwrap().to_string())); // Retrieve html String let html = String::from("OK"); // Respond with OK status let mut response = request.into_ok_response()?; // Return Requested Object (Index Page) response.write(html.as_bytes())?; Ok(()) })?; loop { delay.delay_ns(100); match &*do_update.read().unwrap(){ Some(Command::Timetable) => { update_display_buffer(&mut display); println!("Rendered to buffer"); epd7in5.wake_up(&mut spi, &mut delay)?; epd7in5.update_and_display_frame(&mut spi, &*display.buffer(), &mut delay)?; println!("Updated frame"); epd7in5.sleep(&mut spi, &mut delay)?; println!("Sleep"); unsafe { WAKEUP_COUNTER += 1; } }, Some(Command::Message(msg)) => { display_custom_message(&mut display, &msg); epd7in5.wake_up(&mut spi, &mut delay)?; epd7in5.update_and_display_frame(&mut spi, &*display.buffer(), &mut delay)?; epd7in5.sleep(&mut spi, &mut delay)?; } None => continue } if let Ok(mut writer) = do_update.try_write(){ *writer = Option::::None; println!("Successfully reset rw lock") } } drop(httpserver); drop(epd7in5); drop(spi); //let next_wakeup_time = calculate_next_wakeuptime(now).context(format!("Failed to calculate next wakeup time for time {now}")).unwrap(); //let seconds_until_wakeup = next_wakeup_time.timestamp() - now.timestamp(); wifi.disconnect().unwrap(); wifi.stop().unwrap(); /* unsafe{ esp_deep_sleep(seconds_until_wakeup as u64 * 1000_000); } */ Ok(()) } #[derive(Clone)] enum Command{ Timetable, Message(String) } fn update_display_buffer(display: &mut epd_waveshare::epd7in5_v2::Display7in5){ let (tt, now) = match fetch_data(){ Ok(d) => d, Err(e) => { let fail_count: u16 = unsafe { let new_val = match FETCHING_FAIL_COUNT{ Some(C) => C.saturating_add(1), None => { NonZeroU16::new_unchecked(1) } }; FETCHING_FAIL_COUNT = Some(new_val); new_val }.into(); println!("Failed to fetch timetable for the {}th time. Error: {}", fail_count, e); if fail_count > 10{ panic!("10 failed attempts. Last error:\n{}\nBacktrace:\n{}", e, e.backtrace()); } // Quadratic falloff maxing out at 1 hours intervals let sleep_time = u32::min((fail_count as u32).pow(2u32) * 4, 3600); println!("Going to sleep for {} seconds", sleep_time); unsafe { esp_deep_sleep(sleep_time as u64 * 1000_000) } } }; // We wanna panic here if this fails because retrying this in a couple of seconds is stupid let next_wakeup_time = calculate_next_wakeuptime(now).context(format!("Failed to calculate next wakeup time for time {now}")).unwrap(); display.clear(Color::Black).unwrap(); // Render the layout draw_layout(display, &tt, now, next_wakeup_time, include_str!("../wifi-creds.txt").split('\n').next().unwrap()); } fn display_custom_message(display: &mut epd_waveshare::epd7in5_v2::Display7in5, msg: &str){ display.clear(Color::Black).unwrap(); let text_style = U8g2TextStyle::new(u8g2_fonts::fonts::u8g2_font_profont22_mr, Color::White); let display_area = display.bounding_box(); let text = Text::new(msg, Point::zero(), &text_style); LinearLayout::vertical(Chain::new(text)) .with_alignment(horizontal::Center) .arrange() .align_to(&display_area, horizontal::Center, vertical::Center) .draw(display) .unwrap(); } fn get_full_subject_name(shortname: &str) -> Option<&'static str>{ if shortname.len() < 2{ return None; } let shortname = shortname.to_lowercase(); Some(match shortname[..2].as_ref() { "in" => "Informatik", "en" => "Englisch", "de" => "Deutsch", "ds" => "Theater", "ph" => "Physik", "ma" => "Mathematik", "ch" => "Chemie", "ge" => "Geschichte", "sp" => "Sport", _ => { return None; } }) } fn calculate_next_wakeuptime(now: DateTime) -> Option>{ if now.weekday().num_days_from_monday() > 4 || (now.weekday() == Weekday::Fri && now.time() > NaiveTime::from_hms_opt(15, 15, 0)?){ // On weekends, wait until 5am on the next day to update let d = now.checked_add_signed(TimeDelta::days(1))?; let d = d.with_time(NaiveTime::from_hms_opt(5, 0, 0).unwrap()).single()?; Some(d) }else if now.time() > NaiveTime::from_hms_opt(15, 15, 0)? || now.time() < NaiveTime::from_hms_opt(5, 0, 0)?{ // If it's a weekday but it's after 15:15 or before 5am, update after an hour Some(now.checked_add_signed(TimeDelta::minutes(60))?) }else{ // In any other case, update 5 minutes before the end of a lesson but at least 10 minutes from now let (_, end_time) = get_school_period_and_end_time(now.time()); let before_lesson_end = now.with_time(end_time.overflowing_sub_signed(TimeDelta::minutes(5)).0).earliest()?; let in_10_minutes = now.checked_add_signed(TimeDelta::minutes(10))?; Some(before_lesson_end.max(in_10_minutes)) } } fn fetch_data() -> anyhow::Result<(Box, DateTime)>{ let httpconnection = EspHttpConnection::new(&HttpConfig { use_global_ca_store: true, crt_bundle_attach: Some(esp_idf_sys::esp_crt_bundle_attach), ..Default::default() })?; let res = fetch_timetable(httpconnection); res } fn is_lesson_cancelled(l: &Lesson) -> bool{ l.code == LessonCode::Cancelled || l.subst_text.as_ref().is_some_and(|s| s == "Aufgaben") } fn draw_layout(display: &mut epd_waveshare::epd7in5_v2::Display7in5, timetable: &Timetable, now: DateTime, next_wakeup_time: DateTime, wifi_details: &str){ let display_area = display.bounding_box(); // let text_style = MonoTextStyle::new(&embedded_graphics::mono_font::ascii::FONT_10X20, Color::White); let text_style = U8g2TextStyle::new(u8g2_fonts::fonts::u8g2_font_profont22_mr, Color::White); let mut inverted_text_style = U8g2TextStyle::new(u8g2_fonts::fonts::u8g2_font_profont22_mr, Color::Black); inverted_text_style.background_color = Some(Color::White); let text_style_small = U8g2TextStyle::new(u8g2_fonts::fonts::u8g2_font_7x14_mr, Color::White); let empty_str = String::from(" "); let day_of_week = now.weekday().num_days_from_monday(); // zero-indexed let current_period = get_school_period(now.time()); let mut texts = timetable.iter().enumerate().map(|(day_index, day)| day.iter().enumerate().map(|(period, l)| { let s = if let Some(l) = l && !is_lesson_cancelled(l){ (||{ Some(l.subjects.first()?.name.as_str()) })() }else{ None }.and_then(get_full_subject_name); let room_str = if let Some(l) = l && !is_lesson_cancelled(l){ (||{ Some(l.rooms.first()?.name.as_str()) })() }else{ None }; let text_style = if period as u8 == current_period && day_index as u32 == day_of_week{ &inverted_text_style }else{ &text_style }; LinearLayout::vertical( Chain::new( Text::new(s.unwrap_or_else(|| &empty_str), Point::zero(), text_style) ).append( Text::new(room_str.unwrap_or_else(|| &empty_str), Point::zero(), &text_style_small) ) ) .with_spacing(FixedMargin(2)).arrange() }).collect::>()).collect::>(); let mut columns = texts.iter_mut().map(|day| LinearLayout::vertical(Views::new(day)) .align_to(&display_area, horizontal::Center, vertical::Top) .with_spacing(FixedMargin(15)) .arrange() ).collect::>(); let columns = Views::new(&mut columns); // Unsafe is needed to access static mutable variable let time_str = format!("{} ({})", now.format("%Y-%m-%d %H:%M"), unsafe {WAKEUP_COUNTER}); let time_text = Text::new(&time_str, Point::zero(), &text_style_small); let next_wakeup_time_str = format!("Next wakup: {}", next_wakeup_time.naive_local()); let time_table_grid = LinearLayout::horizontal(columns) .with_alignment(vertical::Center) .with_spacing(FixedMargin(18)) .arrange(); let subtext = LinearLayout::vertical(Chain::new(time_text).append(Text::new(&next_wakeup_time_str, Point::zero(), &text_style_small))).with_alignment(horizontal::Right).with_spacing(FixedMargin(2)).arrange(); let bottom_bar = LinearLayout::horizontal(Chain::new( Text::new(wifi_details, Point::zero(), &text_style_small) ).append( subtext ) ).with_alignment(vertical::Bottom) .with_spacing(DistributeFill(display_area.size.width)) .arrange(); LinearLayout::vertical(Chain::new(time_table_grid).append(bottom_bar)) .with_alignment(horizontal::Center) .with_spacing(DistributeFill(display_area.size.height-10)) .arrange() .align_to(&display_area, horizontal::Center, vertical::Bottom) .draw(display) .unwrap(); } const PERIOD_LOOKUP: [NaiveTime; 8] = [ (NaiveTime::from_hms_opt(8, 45, 0).unwrap()), (NaiveTime::from_hms_opt(9, 35, 0).unwrap()), (NaiveTime::from_hms_opt(10, 40, 0).unwrap()), (NaiveTime::from_hms_opt(11, 30, 0).unwrap()), (NaiveTime::from_hms_opt(12, 40, 0).unwrap()), (NaiveTime::from_hms_opt(13, 30, 0).unwrap()), (NaiveTime::from_hms_opt(14, 25, 0).unwrap()), (NaiveTime::from_hms_opt(15, 15, 0).unwrap()), ]; fn get_school_period(time: NaiveTime) -> u8{ for (index, period_time) in PERIOD_LOOKUP.iter().enumerate(){ if time < *period_time{ return index as u8; } } 7u8 } fn get_school_period_and_end_time(time: NaiveTime) -> (u8, NaiveTime){ for (index, period_time) in PERIOD_LOOKUP.iter().enumerate(){ if time < *period_time{ return (index as u8, *period_time); } } (7u8, PERIOD_LOOKUP[7]) } fn transform_lessons(lessons: Vec) -> Box{ let mut table: Box<[[Option; 8]; 5]> = Box::new([0u8; 5].map(|_| [0u8; 8].map(|_| Option::::None))); for lesson in lessons { let day_idx = lesson.date.weekday().num_days_from_monday() as usize; let mut l = Cow::::Owned(lesson); for (period_idx, time) in PERIOD_LOOKUP.iter().enumerate(){ if l.start_time.0 < *time && &l.end_time.0 >= time{ table[day_idx][period_idx] = Some(l.into_owned()); l = Cow::Borrowed(&table[day_idx][period_idx].as_ref().unwrap()); } } } table } //TODO: Make this return result fn fetch_timetable(client: EspHttpConnection) -> anyhow::Result<(Box, DateTime)> { let mut creds = include_str!("../untis-creds.txt").split('\n'); let server = creds.next().unwrap(); let school_id = creds.next().unwrap(); let usr = creds.next().unwrap(); let password = creds.next().unwrap(); let mut s = untis::Client::login( server, school_id, usr, password, client ).or_else(|e| None.context(format!("{:?}", e)))?; let cet = chrono::FixedOffset::east_opt(1 * 3600).unwrap(); let now = s.date().context("Untis client provided no time but time is necessary for the requesting the timetable")?.with_timezone(&cet); println!("Current time: {}", now); let date = now.date_naive().checked_add_days(Days::new(2)).unwrap().checked_add_signed(TimeDelta::weeks(unsafe { WEEK_OFFSET } as i64 )).unwrap(); let lessons = s.own_timetable_for_week(&Date(date)).or_else(|e| None.context(format!("{:?}", e)))?; Ok((transform_lessons(lessons), now)) }