diff --git a/esp32/src/http.rs b/esp32/src/http.rs index e4f9ace..d564338 100644 --- a/esp32/src/http.rs +++ b/esp32/src/http.rs @@ -11,7 +11,14 @@ use esp_idf_svc::{ use log::info; use std::time::Duration; -pub fn fetch_image_data(url: &str) -> Result> { +const HEADER_X_ESP_DEEP_SLEEP_SECONDS: &str = "x-esp-deep-sleep-seconds"; + +pub struct Response { + pub image_data: Vec, + pub deep_sleep_seconds: Option, +} + +pub fn fetch_data(url: &str) -> Result { let connection = EspHttpConnection::new(&Configuration { timeout: Some(Duration::from_secs(5)), use_global_ca_store: true, @@ -30,10 +37,17 @@ pub fn fetch_image_data(url: &str) -> Result> { bail!("Expected response code 200, got {status}"); } + let deep_sleep_seconds = response + .header(HEADER_X_ESP_DEEP_SLEEP_SECONDS) + .and_then(|value| value.parse().ok()); + let mut buf = vec![0; display_buffer_size()]; let len = io::try_read_full(response, &mut buf).map_err(|err| err.0)?; info!("Received {len} bytes"); - Ok(buf[..len].to_vec()) + Ok(Response { + image_data: buf[..len].to_vec(), + deep_sleep_seconds, + }) } diff --git a/esp32/src/main.rs b/esp32/src/main.rs index d6f9c74..7ec34e6 100644 --- a/esp32/src/main.rs +++ b/esp32/src/main.rs @@ -40,18 +40,21 @@ fn main() -> Result<()> { let sysloop = EspSystemEventLoop::take()?; let nvs = EspDefaultNvsPartition::take()?; - if let Err(err) = run(peripherals, sysloop, nvs) { + let deep_sleep_seconds = run(peripherals, sysloop, nvs).unwrap_or_else(|err| { error!("{err}"); - } + None + }); + + let sleep_time = Duration::from_secs(deep_sleep_seconds.unwrap_or(CONFIG.deep_sleep_seconds)); - enter_deep_sleep(Duration::from_secs(CONFIG.deep_sleep_seconds)); + enter_deep_sleep(sleep_time); } fn run( peripherals: Peripherals, sysloop: EspSystemEventLoop, nvs: EspDefaultNvsPartition, -) -> Result<()> { +) -> Result> { let wifi = wifi::connect( CONFIG.wifi_ssid, CONFIG.wifi_psk, @@ -61,7 +64,7 @@ fn run( ) .context("Could not connect to WiFi network")?; - let image_data = http::fetch_image_data(CONFIG.data_url)?; + let resp = http::fetch_data(CONFIG.data_url)?; info!("Disconnecting WiFi"); drop(wifi); @@ -91,7 +94,7 @@ fn run( info!("E-Ink display init completed!"); info!("Drawing image"); - epd.update_and_display_frame(&mut spi, &image_data, &mut delay)?; + epd.update_and_display_frame(&mut spi, &resp.image_data, &mut delay)?; #[allow(clippy::absurd_extreme_comparisons)] if CONFIG.clear_after_seconds > 0 { @@ -104,7 +107,7 @@ fn run( } epd.sleep(&mut spi, &mut delay)?; - Ok(()) + Ok(resp.deep_sleep_seconds) } fn enter_deep_sleep(sleep_time: Duration) -> ! { diff --git a/server/Cargo.lock b/server/Cargo.lock index 3f252de..34c012c 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1255,6 +1255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a45489186a6123c128fdf6016183fcfab7113e1820eb813127e036e287233fb" dependencies = [ "jiff-tzdb-platform", + "serde", "windows-sys 0.59.0", ] diff --git a/server/Cargo.toml b/server/Cargo.toml index d24dc29..8e8e817 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -15,7 +15,7 @@ thiserror = "1.0.64" embedded-graphics = "0.8.1" monsoon = "0.1.1" tower = { version = "0.5.1", features = ["limit", "util"] } -jiff = "0.1.13" +jiff = { version = "0.1.13", features = ["serde"] } sun = "0.3.1" config = "0.14.0" serde = { version = "1.0.210", features = ["derive"] } diff --git a/server/config.example.toml b/server/config.example.toml index c87752f..f90e1f8 100644 --- a/server/config.example.toml +++ b/server/config.example.toml @@ -18,3 +18,41 @@ altitude = 0 # If you don't like inverted colors at night, you can disable night mode by # setting this to `true`. disable_night_mode = false + +# Configurable time-based presets. +# +# When an image request falls within one of the configured schedules, the +# server will perform certain actions like returning additional data to the ESP +# in HTTP headers to configure its behaviour. +# +# If multiple presets match at a given time, their settings are merged in the +# order they are defined. +# +# [presets.preset-name] +# # Enable or disable the preset. +# enabled = true +# +# # Start moment of the schedule. Can be a datetime, date or time. +# from = "20:00" +# +# # End moment of the schedule. Can be a datetime, date or time. +# to = "05:00" +# +# # Send the number of seconds the ESP should sleep after it requested a new +# # image. This field is optional. If unset, the ESP will sleep for its +# # configured default value. +# esp_deep_sleep_seconds = 600 +# +# # Wether to add a lot of randomness into the weather data by default. This +# # field is optional. +# wreck_havoc = true +# +# The following preset instructs the ESP to only ask for a new image every 60 +# minutes to save battery during night time when people would rarely look at it +# anyways: +# +# [presets.night-time] +# enabled = true +# from = "22:00" +# to = "06:00" +# esp_deep_sleep_seconds = 3600 diff --git a/server/src/app.rs b/server/src/app.rs index 3446dd6..e108c96 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -1,4 +1,4 @@ -use crate::{config::Config, error::Result, graphics::Renderer, weather::Weather}; +use crate::{config::Config, error::Result, graphics::Renderer, preset::Presets, weather::Weather}; use prometheus::{ IntCounterVec, Registry, core::{AtomicU64, GenericCounter}, @@ -9,6 +9,7 @@ use prometheus::{ #[derive(Clone)] pub struct AppState { pub metrics: Metrics, + pub presets: Presets, pub renderer: Renderer, pub weather: Weather, } @@ -18,9 +19,11 @@ impl AppState { pub fn new(config: &Config, metrics: Metrics) -> Result { let weather = Weather::new(config.latitude, config.longitude, config.altitude)?; let renderer = Renderer::new(config, metrics.clone()); + let presets = Presets::new(&config.presets)?; Ok(AppState { metrics, + presets, renderer, weather, }) diff --git a/server/src/config.rs b/server/src/config.rs index 6d52202..ff5344a 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -1,6 +1,7 @@ -use crate::error::Result; +use crate::{error::Result, preset::Moment}; use config::{Environment, File}; use serde::Deserialize; +use std::collections::BTreeMap; use tracing::debug; /// Application configuration sourced from env and/or config file. @@ -11,6 +12,18 @@ pub struct Config { pub altitude: Option, #[serde(default)] pub disable_night_mode: bool, + #[serde(default)] + pub presets: BTreeMap, +} + +/// Configuration of a time-based preset. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct PresetConfig { + pub enabled: bool, + pub from: Moment, + pub to: Moment, + pub wreck_havoc: Option, + pub esp_deep_sleep_seconds: Option, } impl Config { @@ -20,7 +33,7 @@ impl Config { // Configuration from `config.toml`. .add_source(File::with_name("config").required(false)) // Config from environment variables. - .add_source(Environment::default().separator("_")) + .add_source(Environment::default().separator("__")) .build()? .try_deserialize()?; diff --git a/server/src/main.rs b/server/src/main.rs index c68a06b..ad8d798 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -2,6 +2,7 @@ mod app; mod config; mod error; mod graphics; +mod preset; mod sun; mod weather; @@ -13,19 +14,19 @@ use crate::{ }; use actix_web::{ App, HttpResponse, HttpServer, get, - http::header::ContentType, middleware::Logger, web::{Data, Path, Query}, }; use actix_web_prom::PrometheusMetricsBuilder; +use jiff::Zoned; use rand::{SeedableRng, rngs::StdRng}; use serde::Deserialize; +use tracing::{debug, error}; #[derive(Deserialize, Clone, Debug)] struct ImageQuery { /// Adds a lot of randomness to the weather data to make the weather seem unpredictable. - #[serde(default)] - wreck_havoc: bool, + wreck_havoc: Option, /// A seed for the RNG to produce stable randomness. seed: Option, } @@ -33,7 +34,7 @@ struct ImageQuery { impl ImageQuery { fn seed_rng(&self) -> StdRng { let seed = self.seed.unwrap_or_else(rand::random); - tracing::debug!(?seed, "seeding RNG used for image rendering"); + debug!(?seed, "seeding RNG used for image rendering"); StdRng::seed_from_u64(seed) } } @@ -49,10 +50,14 @@ async fn image( format: Path, query: Query, ) -> actix_web::Result { + let settings = state.presets.get_settings_for(Zoned::now().datetime()); + + let wreck_havoc = query.wreck_havoc.or(settings.wreck_havoc).unwrap_or(false); + let mut data = state.weather.get().await?; let mut rng = query.seed_rng(); - if query.wreck_havoc { + if wreck_havoc { weather::wreck_havoc(&mut data, &mut rng); } @@ -61,9 +66,11 @@ async fn image( state.metrics.image_counter(mime_type.essence_str()).inc(); - Ok(HttpResponse::Ok() - .insert_header(ContentType(mime_type)) - .body(body)) + let mut resp = HttpResponse::Ok(); + + settings.configure_response(&mut resp); + + Ok(resp.content_type(mime_type).body(body)) } async fn run() -> Result<()> { @@ -98,7 +105,7 @@ async fn main() { tracing_subscriber::fmt::init(); if let Err(err) = run().await { - tracing::error!("{err}"); + error!("{err}"); std::process::exit(1); } } diff --git a/server/src/preset.rs b/server/src/preset.rs new file mode 100644 index 0000000..8c6baf1 --- /dev/null +++ b/server/src/preset.rs @@ -0,0 +1,317 @@ +use crate::{ + config::PresetConfig, + error::{Error, Result}, +}; +use actix_web::HttpResponseBuilder; +use jiff::civil::{Date, DateTime, Time}; +use serde::Deserialize; +use std::collections::BTreeMap; +use tracing::info; + +const HEADER_X_ESP_DEEP_SLEEP_SECONDS: &str = "x-esp-deep-sleep-seconds"; + +/// A moment in time. +#[derive(Deserialize, Debug, Copy, Clone, PartialEq, Eq)] +#[serde(untagged)] +pub enum Moment { + /// A time on a specific date. + DateTime(DateTime), + /// A specific date. + Date(Date), + /// A time on any date. + Time(Time), +} + +impl Default for Moment { + fn default() -> Self { + Moment::DateTime(DateTime::default()) + } +} + +/// A time interval with a start and end. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Interval { + DateTime { from: DateTime, to: DateTime }, + Date { from: Date, to: Date }, + Time { from: Time, to: Time }, +} + +impl Interval { + /// Creates an `Interval` from a `PresetConfig`. + fn new(config: &PresetConfig) -> Result { + match (config.from, config.to) { + (Moment::DateTime(from), Moment::DateTime(to)) => Ok(Interval::DateTime { from, to }), + (Moment::Date(from), Moment::Date(to)) => Ok(Interval::Date { from, to }), + (Moment::Time(from), Moment::Time(to)) => Ok(Interval::Time { from, to }), + (_, _) => Err(Error::new( + "`from` and `to` must be both either datetimes, dates or times", + )), + } + } + + /// Returns `true` if the instant falls within the interval, `false` otherwise. + fn contains(&self, instant: DateTime) -> bool { + match *self { + Interval::DateTime { from, to } => instant >= from && instant < to, + Interval::Date { from, to } => { + let date = instant.date(); + date >= from && date < to + } + Interval::Time { from, to } => { + let time = instant.time(); + if from < to { + // Start and end time are assumed to be on the same day. + time >= from && time < to + } else { + // Time window wraps over midnight. + time >= from || time < to + } + } + } + } +} + +/// A time-based preset. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Preset { + /// The name of the preset. + name: String, + /// The time interval in which the preset is active. + interval: Interval, + /// Preset settings. + settings: Settings, +} + +impl Preset { + /// Creates a `Preset` from a name and a `PresetConfig`. + fn new(name: &str, config: &PresetConfig) -> Result { + Ok(Preset { + name: name.to_string(), + interval: Interval::new(config)?, + settings: Settings::new(config), + }) + } +} + +/// Preset settings. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Settings { + /// Deep sleep time configuration sent back to the ESP the next time it requests a new image. + pub esp_deep_sleep_seconds: Option, + /// Whether to add a lot of randomness into the weather data. + pub wreck_havoc: Option, +} + +impl Settings { + /// Configures an HTTP response according to the settings. + pub fn configure_response(&self, resp: &mut HttpResponseBuilder) { + if let Some(seconds) = self.esp_deep_sleep_seconds { + info!(?seconds, "sending custom ESP deep sleep configuration"); + resp.insert_header((HEADER_X_ESP_DEEP_SLEEP_SECONDS, seconds)); + } + } + + /// Creates `Settings` from a `PresetConfig`. + fn new(config: &PresetConfig) -> Settings { + Settings { + esp_deep_sleep_seconds: config.esp_deep_sleep_seconds, + wreck_havoc: config.wreck_havoc, + } + } + + /// Merges `self` on top of `other` and returns the new `Settings`. + fn merge(&self, other: &Settings) -> Settings { + Settings { + esp_deep_sleep_seconds: other.esp_deep_sleep_seconds.or(self.esp_deep_sleep_seconds), + wreck_havoc: other.wreck_havoc.or(self.wreck_havoc), + } + } +} + +/// Container for time-based presets. +#[derive(Debug, Clone)] +pub struct Presets(Vec); + +impl Presets { + /// Creates a new `Presets` from a map of preset configs. + pub fn new(configs: &BTreeMap) -> Result { + let presets = configs + .iter() + .filter(|(_, config)| config.enabled) + .map(|(name, config)| Preset::new(name, config)) + .collect::>()?; + + Ok(Presets(presets)) + } + + /// Get the preset settings for a given datetime. + /// + /// If there are multiple presets for the time they are merged. + /// + /// Returns the `Default` settings if there are no presets for the given time. + pub fn get_settings_for(&self, instant: DateTime) -> Settings { + self.0 + .iter() + .filter(|preset| preset.interval.contains(instant)) + .fold(Settings::default(), |settings, preset| { + settings.merge(&preset.settings) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use jiff::civil::time; + + macro_rules! interval { + ($start_time:expr, $end_time:expr) => { + Interval::Time { + from: $start_time, + to: $end_time, + } + }; + } + + #[test] + fn interval() { + let interval = interval!(time(0, 30, 0, 0), time(23, 30, 0, 0)); + assert!(!interval.contains(time(23, 30, 0, 0).on(2025, 1, 1))); + assert!(!interval.contains(time(0, 0, 0, 0).on(2025, 1, 1))); + assert!(interval.contains(time(0, 30, 0, 0).on(2025, 1, 1))); + assert!(interval.contains(time(1, 0, 0, 0).on(2025, 1, 1))); + } + + #[test] + fn interval_wraps_over_midnight() { + let interval = interval!(time(23, 30, 0, 0), time(0, 30, 0, 0)); + assert!(interval.contains(time(23, 30, 0, 0).on(2025, 1, 1))); + assert!(interval.contains(time(0, 0, 0, 0).on(2025, 1, 1))); + assert!(!interval.contains(time(0, 30, 0, 0).on(2025, 1, 1))); + assert!(!interval.contains(time(1, 0, 0, 0).on(2025, 1, 1))); + } + + #[test] + fn interval_24hours() { + let interval = interval!(time(0, 0, 0, 0), time(0, 0, 0, 0)); + assert!(interval.contains(time(0, 0, 0, 0).on(2025, 1, 1))); + assert!(interval.contains(time(1, 0, 0, 0).on(2025, 1, 1))); + assert!(interval.contains(time(23, 0, 0, 0).on(2025, 1, 1))); + } + + #[test] + fn empty_presets() { + assert_eq!( + Presets(Vec::new()).get_settings_for(time(0, 0, 0, 0).on(2025, 1, 1)), + Settings::default() + ); + } + + #[test] + fn presets() { + let settings = Settings { + wreck_havoc: Some(true), + ..Default::default() + }; + + let presets = Presets(vec![ + Preset { + interval: interval!(time(23, 30, 0, 0), time(0, 30, 0, 0)), + settings: settings.clone(), + name: "preset1".into(), + }, + Preset { + interval: interval!(time(20, 0, 0, 0), time(23, 30, 0, 0)), + settings: Settings::default(), + name: "preset2".into(), + }, + ]); + + assert_eq!( + presets.get_settings_for(time(23, 30, 0, 0).on(2025, 1, 1)), + settings.clone() + ); + assert_eq!( + presets.get_settings_for(time(23, 29, 59, 0).on(2025, 1, 1)), + Settings::default() + ); + assert_eq!( + presets.get_settings_for(time(19, 59, 59, 999).on(2025, 1, 1)), + Settings::default() + ); + } + + #[test] + fn merge_overlapping_presets() { + let presets = Presets(vec![ + Preset { + name: "preset1".into(), + interval: interval!(time(23, 30, 0, 0), time(0, 30, 0, 0)), + settings: Settings { + wreck_havoc: Some(true), + ..Default::default() + }, + }, + Preset { + name: "preset2".into(), + interval: interval!(time(0, 0, 0, 0), time(2, 0, 0, 0)), + settings: Settings { + wreck_havoc: Some(false), + esp_deep_sleep_seconds: Some(10), + }, + }, + Preset { + name: "preset3".into(), + interval: interval!(time(0, 10, 0, 0), time(1, 0, 0, 0)), + settings: Settings { + esp_deep_sleep_seconds: Some(20), + ..Default::default() + }, + }, + ]); + + assert_eq!( + presets.get_settings_for(time(0, 15, 0, 0).on(2025, 1, 1)), + Settings { + wreck_havoc: Some(false), + esp_deep_sleep_seconds: Some(20), + } + ); + } + + #[test] + fn presets_new() { + let mut configs: BTreeMap = BTreeMap::new(); + configs.insert( + "my-preset1".into(), + PresetConfig { + from: Moment::Time(time(1, 0, 0, 0)), + to: Moment::Time(time(1, 0, 0, 0)), + ..Default::default() + }, + ); + configs.insert( + "preset2".into(), + PresetConfig { + enabled: true, + from: Moment::Time(time(1, 0, 0, 0)), + to: Moment::Time(time(1, 0, 0, 0)), + esp_deep_sleep_seconds: Some(10), + ..Default::default() + }, + ); + + let presets = Presets::new(&configs).unwrap(); + assert_eq!( + presets.0, + vec![Preset { + name: "preset2".into(), + interval: interval!(time(1, 0, 0, 0), time(1, 0, 0, 0)), + settings: Settings { + esp_deep_sleep_seconds: Some(10), + ..Default::default() + } + }] + ); + } +}