diff --git a/Cargo.lock b/Cargo.lock index e9a03469..f333c670 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -476,6 +476,7 @@ dependencies = [ "sntpc", "sntpc-net-std", "sqlx", + "sunrise", "tempfile", "thiserror 2.0.18", "tikv-jemallocator", @@ -4228,6 +4229,15 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "sunrise" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08871f1db1390f915b7bb2ab2f1ac7370e8c78667857a15ae5cfea25da3262f2" +dependencies = [ + "chrono", +] + [[package]] name = "symbolic-common" version = "12.18.3" diff --git a/contrib/Settings-sample.toml b/contrib/Settings-sample.toml index 10cfbac2..ff3f319d 100644 --- a/contrib/Settings-sample.toml +++ b/contrib/Settings-sample.toml @@ -13,6 +13,16 @@ auto-share = false # This will also set the correct timezone, it uses # time.cloudflare.com and ipapi.co auto-time = false +# Automatically adjust frontlight warmth based on sun position. +# Coordinates are auto-detected during time sync (via ipapi.co) +# unless manually specified below. +auto-frontlight = false +# Night brightness level when the sun is down (0.0 to 100.0). +auto-frontlight-night-brightness = 10 +# Manual GPS coordinates override (latitude, longitude). +# auto-frontlight-manual-coordinates = [51.5074, -0.1278] +# Last auto-detected coordinates from time sync (written automatically). +# auto-frontlight-last-coordinates = [48.8566, 2.3522] # Defines how the back and forward buttons are mapped to the # *page forward* and *page backward* actions. # Possible values: "natural", "inverted". diff --git a/crates/cadmus/src/app.rs b/crates/cadmus/src/app.rs index 632abc34..2782c94b 100644 --- a/crates/cadmus/src/app.rs +++ b/crates/cadmus/src/app.rs @@ -198,11 +198,7 @@ fn resume( debug!(task = ?id, "resume called"); if id == TaskId::Suspend { tasks.retain(|task| task.id != TaskId::Suspend); - if context.settings.frontlight { - let levels = context.settings.frontlight_levels; - context.frontlight.set_warmth(levels.warmth); - context.frontlight.set_intensity(levels.intensity); - } + context.set_frontlight(context.settings.frontlight); if context.settings.wifi { if let Ok(wifi) = CURRENT_DEVICE.wifi_manager() { thread::spawn(move || { @@ -367,9 +363,7 @@ fn prepare_share_for_usb( context.database.close(); if context.settings.frontlight { - context.settings.frontlight_levels = context.frontlight.levels(); - context.frontlight.set_intensity(0.0); - context.frontlight.set_warmth(0.0); + context.set_frontlight(false); } #[cfg(not(feature = "test"))] if context.settings.wifi { @@ -724,14 +718,7 @@ pub fn run() -> Result<(), Error> { } } - if context.settings.frontlight { - let levels = context.settings.frontlight_levels; - context.frontlight.set_warmth(levels.warmth); - context.frontlight.set_intensity(levels.intensity); - } else { - context.frontlight.set_intensity(0.0); - context.frontlight.set_warmth(0.0); - } + context.set_frontlight(context.settings.frontlight); let mut tasks: Vec = Vec::new(); let mut background_tasks = TaskManager::new(); @@ -1106,8 +1093,9 @@ pub fn run() -> Result<(), Error> { if context.settings.frontlight { context.settings.frontlight_levels = context.frontlight.levels(); - context.frontlight.set_intensity(0.0); - context.frontlight.set_warmth(0.0); + if let Err(error) = context.frontlight.turn_off() { + tracing::error!(error = %error, "failed to turn off frontlight for suspend"); + } } if context.settings.wifi { if let Ok(wifi) = CURRENT_DEVICE.wifi_manager() { @@ -1324,6 +1312,57 @@ pub fn run() -> Result<(), Error> { &mut context, ); } + Event::SetFrontlightLevels(levels) => { + #[cfg(feature = "kobo")] + if let Err(e) = background_tasks.stop(&cadmus_core::task::TaskId::AutoFrontlight) + && !matches!( + e, + cadmus_core::task::TaskError::NotRunning( + cadmus_core::task::TaskId::AutoFrontlight + ) + ) + { + tracing::warn!(error = %e, "failed to stop auto_frontlight task after manual adjustment"); + } + + if let Err(error) = context.frontlight.set_intensity(levels.intensity) { + tracing::error!(error = %error, "failed to set frontlight intensity"); + } + if let Err(error) = context.frontlight.set_warmth(levels.warmth) { + tracing::error!(error = %error, "failed to set frontlight warmth"); + } + context.settings.frontlight_levels = levels; + } + // TODO(OGKevin): once https://github.com/OGKevin/cadmus/issues/582 is done + // this shall be refactored accordingly. + Event::UpdateAutoFrontlight => { + if context.settings.auto_frontlight && context.settings.frontlight { + if let Some(coords) = + cadmus_core::settings::resolve_coordinates(&context.settings) + { + let night_brightness = context + .settings + .auto_frontlight_night_brightness + .unwrap_or_default(); + let current_intensity = context.frontlight.levels().intensity; + let levels = cadmus_core::frontlight::auto::compute_auto_frontlight_levels( + Local::now(), + coords, + night_brightness, + current_intensity, + ); + if let Err(error) = context.frontlight.set_intensity(levels.intensity) { + tracing::error!(error = %error, "failed to set auto frontlight intensity"); + } + if let Err(error) = context.frontlight.set_warmth(levels.warmth) { + tracing::error!(error = %error, "failed to set auto frontlight warmth"); + } + context.settings.frontlight_levels = levels; + } else { + tracing::debug!("no coordinates available for auto-frontlight"); + } + } + } Event::Open(info) => { let rotation = context.display.rotation; let dithered = context.fb.dithered(); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 65df7501..f8024b18 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -56,6 +56,7 @@ chrono = { version = "0.4.42", features = [ "clock", ], default-features = false } chrono-tz = "0.8" +sunrise = "1.2" reqwest = { version = "0.13.1", default-features = false, features = [ "blocking", "form", diff --git a/crates/core/i18n/en-GB/cadmus_core.ftl b/crates/core/i18n/en-GB/cadmus_core.ftl index 3f09fbf0..9ed616f8 100644 --- a/crates/core/i18n/en-GB/cadmus_core.ftl +++ b/crates/core/i18n/en-GB/cadmus_core.ftl @@ -45,6 +45,11 @@ settings-finished-action-close = Close settings-finished-action-goto-next = Go to Next # Settings - General +settings-general-auto-frontlight = Automatic Frontlight +settings-general-auto-frontlight-brightness = Auto Night Brightness +settings-general-auto-frontlight-brightness-input = Night Brightness (% 0-100) +settings-general-auto-frontlight-manual-coordinates = Manual Coordinates +settings-general-auto-frontlight-manual-coordinates-input = lat,lon | coordinates settings-general-auto-power-off = Auto Power Off (days) settings-general-auto-power-off-input = Auto Power Off (days, 0 = never) settings-general-auto-suspend = Auto Suspend (minutes) diff --git a/crates/core/src/context.rs b/crates/core/src/context.rs index 11aa61b8..de7aa738 100644 --- a/crates/core/src/context.rs +++ b/crates/core/src/context.rs @@ -42,6 +42,7 @@ pub struct Context { pub dictionaries: BTreeMap, pub keyboard_layouts: BTreeMap, pub input_history: FxHashMap>, + // TODO(OGKevin): this shall be on the device struct, instead of on context pub frontlight: Box, pub battery: Box, pub lightsensor: Box, @@ -209,17 +210,46 @@ impl Context { } } + /// Enables or disables the device frontlight and keeps the persisted + /// frontlight settings in sync. + /// + /// When automatic frontlight is enabled and coordinates are available, + /// turning the light on recomputes the effective levels for the current + /// time before applying them. pub fn set_frontlight(&mut self, enable: bool) { self.settings.frontlight = enable; if enable { - let levels = self.settings.frontlight_levels; - self.frontlight.set_warmth(levels.warmth); - self.frontlight.set_intensity(levels.intensity); + let levels = if self.settings.auto_frontlight { + if let Some(coords) = crate::settings::resolve_coordinates(&self.settings) { + let night_brightness = self + .settings + .auto_frontlight_night_brightness + .unwrap_or_default(); + crate::frontlight::auto::compute_auto_frontlight_levels( + Local::now(), + coords, + night_brightness, + self.settings.frontlight_levels.intensity, + ) + } else { + self.settings.frontlight_levels + } + } else { + self.settings.frontlight_levels + }; + if let Err(error) = self.frontlight.set_warmth(levels.warmth) { + tracing::error!(error = %error, "failed to set frontlight warmth"); + } + if let Err(error) = self.frontlight.set_intensity(levels.intensity) { + tracing::error!(error = %error, "failed to set frontlight intensity"); + } + self.settings.frontlight_levels = levels; } else { self.settings.frontlight_levels = self.frontlight.levels(); - self.frontlight.set_intensity(0.0); - self.frontlight.set_warmth(0.0); + if let Err(error) = self.frontlight.turn_off() { + tracing::error!(error = %error, "failed to turn off frontlight"); + } } } } diff --git a/crates/core/src/frontlight/auto.rs b/crates/core/src/frontlight/auto.rs new file mode 100644 index 00000000..69d7425a --- /dev/null +++ b/crates/core/src/frontlight/auto.rs @@ -0,0 +1,316 @@ +//! Automatic frontlight calculations based on sunrise and sunset. +//! +//! The logic in this module keeps brightness and warmth aligned with the +//! current solar day for a given location. + +use crate::geolocation::Coordinates; + +use super::{LightLevel, LightLevels}; +use chrono::{DateTime, Local, NaiveDateTime, Timelike}; + +const MINUTES_PER_DAY: f64 = 24.0 * 60.0; + +const WARMTH_TRANSITION_HOURS: f64 = 1.5; + +/// Computes the frontlight levels that should be active at the given time. +/// +/// Brightness switches between `current_intensity` during daylight hours and +/// `night_brightness` while the sun is down. Warmth ramps over a fixed +/// transition window before sunrise and before sunset, reaching fully cool at +/// sunrise and fully warm at sunset. +pub fn compute_auto_frontlight_levels( + now: DateTime, + coordinates: Coordinates, + night_brightness: LightLevel, + current_intensity: LightLevel, +) -> LightLevels { + let today = now.date_naive(); + let coords: sunrise::Coordinates = coordinates.into(); + let solar_day = sunrise::SolarDay::new(coords, today); + let sunrise_utc = solar_day.event_time(sunrise::SolarEvent::Sunrise); + let sunset_utc = solar_day.event_time(sunrise::SolarEvent::Sunset); + + let minutes_since_midnight = + |dt: NaiveDateTime| -> f64 { (dt.hour() as f64 * 60.0) + dt.minute() as f64 }; + + let now_min = (now.hour() as f64 * 60.0) + now.minute() as f64; + let offset = *now.offset(); + let sr_local = sunrise_utc.with_timezone(&offset).naive_local(); + let ss_local = sunset_utc.with_timezone(&offset).naive_local(); + let sr_min = minutes_since_midnight(sr_local); + let ss_min = minutes_since_midnight(ss_local); + let transition_min = WARMTH_TRANSITION_HOURS * 60.0; + + let sun_is_down = now_min < sr_min || now_min > ss_min; + + let intensity = if sun_is_down { + night_brightness + } else { + current_intensity + }; + + let evening_ramp_start = ss_min - transition_min; + let evening_ramp_end = ss_min; + let morning_ramp_start = sr_min - transition_min; + let morning_ramp_end = sr_min; + + let normalized_minute = |minute: f64| minute.rem_euclid(MINUTES_PER_DAY); + let minute_in_wrapped_range = |minute: f64, start: f64, end: f64| { + let minute = normalized_minute(minute); + let start = normalized_minute(start); + let end = normalized_minute(end); + + if start <= end { + minute >= start && minute < end + } else { + minute >= start || minute < end + } + }; + let wrapped_range_progress = |minute: f64, start: f64, end: f64| { + let span = (end - start).rem_euclid(MINUTES_PER_DAY); + let elapsed = (minute - start).rem_euclid(MINUTES_PER_DAY); + elapsed / span + }; + + let warmth_fraction: f32 = + if minute_in_wrapped_range(now_min, evening_ramp_end, morning_ramp_start) { + 1.0 + } else if minute_in_wrapped_range(now_min, morning_ramp_end, evening_ramp_start) { + 0.0 + } else if minute_in_wrapped_range(now_min, evening_ramp_start, evening_ramp_end) { + wrapped_range_progress(now_min, evening_ramp_start, evening_ramp_end) as f32 + } else { + 1.0 - wrapped_range_progress(now_min, morning_ramp_start, morning_ramp_end) as f32 + }; + + LightLevels { + intensity, + warmth: LightLevel::from_fraction(warmth_fraction), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn london() -> Coordinates { + Coordinates::new(51.5074, -0.1278).unwrap() + } + + fn tromso() -> Coordinates { + Coordinates::new(69.6492, 18.9553).unwrap() + } + + fn make_dt(date: &str, time: &str) -> DateTime { + let naive = + chrono::NaiveDateTime::parse_from_str(&format!("{date} {time}"), "%Y-%m-%d %H:%M:%S") + .unwrap(); + Local.from_local_datetime(&naive).single().unwrap() + } + + fn compute_expected_sun_times(date: chrono::NaiveDate, coords: Coordinates) -> (f64, f64) { + let day = sunrise::SolarDay::new(coords.into(), date); + let sr = day + .event_time(sunrise::SolarEvent::Sunrise) + .with_timezone(&Local) + .naive_local(); + let ss = day + .event_time(sunrise::SolarEvent::Sunset) + .with_timezone(&Local) + .naive_local(); + ( + (sr.hour() as f64 * 60.0) + sr.minute() as f64, + (ss.hour() as f64 * 60.0) + ss.minute() as f64, + ) + } + + #[test] + fn night_brightness_is_applied_when_sun_is_down() { + let midnight = make_dt("2025-06-21", "00:00:00"); + let levels = compute_auto_frontlight_levels(midnight, london(), 10.0.into(), 50.0.into()); + assert_eq!( + levels.intensity, 10.0, + "night brightness should be night_brightness" + ); + } + + #[test] + fn day_brightness_preserves_current_intensity() { + let noon = make_dt("2025-06-21", "12:00:00"); + let levels = compute_auto_frontlight_levels(noon, london(), 10.0.into(), 50.0.into()); + assert_eq!( + levels.intensity, 50.0, + "day brightness should be current_intensity" + ); + } + + #[test] + fn warmth_is_zero_at_sunrise() { + let sr_utc = sunrise::SolarDay::new( + london().into(), + chrono::NaiveDate::from_ymd_opt(2025, 6, 21).unwrap(), + ) + .event_time(sunrise::SolarEvent::Sunrise); + let sr_local = sr_utc.with_timezone(&Local); + let levels = compute_auto_frontlight_levels(sr_local, london(), 10.0.into(), 50.0.into()); + assert!( + levels.warmth < 1.0, + "at sunrise warmth should be ~0, got {}", + levels.warmth + ); + } + + #[test] + fn warmth_is_one_hundred_at_sunset() { + let ss_utc = sunrise::SolarDay::new( + london().into(), + chrono::NaiveDate::from_ymd_opt(2025, 6, 21).unwrap(), + ) + .event_time(sunrise::SolarEvent::Sunset); + let ss_local = ss_utc.with_timezone(&Local); + let levels = compute_auto_frontlight_levels(ss_local, london(), 10.0.into(), 50.0.into()); + assert!( + (levels.warmth - 100.0).abs() < 1.0, + "at sunset warmth should be ~100, got {}", + levels.warmth + ); + } + + #[test] + fn warmth_is_zero_during_middle_of_day() { + let noon = make_dt("2025-06-21", "12:00:00"); + let levels = compute_auto_frontlight_levels(noon, london(), 10.0.into(), 50.0.into()); + assert!( + levels.warmth < 1.0, + "midday warmth should be ~0, got {}", + levels.warmth + ); + } + + #[test] + fn warmth_is_one_hundred_during_middle_of_night() { + let midnight = make_dt("2025-06-21", "00:00:00"); + let levels = compute_auto_frontlight_levels(midnight, london(), 10.0.into(), 50.0.into()); + assert!( + (levels.warmth - 100.0).abs() < 1.0, + "midnight warmth should be ~100, got {}", + levels.warmth + ); + } + + #[test] + fn warmth_ramps_from_zero_to_one_hundred_in_evening_transition() { + let (_, ss) = compute_expected_sun_times( + chrono::NaiveDate::from_ymd_opt(2025, 6, 21).unwrap(), + london(), + ); + let transition = 90.0; + + let ramp_start = (ss - transition) as i64; + let h = ramp_start / 60; + let m = ramp_start % 60; + let t = make_dt("2025-06-21", &format!("{h:02}:{m:02}:00")); + let levels = compute_auto_frontlight_levels(t, london(), 10.0.into(), 50.0.into()); + assert!( + levels.warmth < 2.0, + "evening ramp start: warmth should be ~0, got {}", + levels.warmth + ); + + let midpoint = (ss - transition / 2.0) as i64; + let h = midpoint / 60; + let m = midpoint % 60; + let t = make_dt("2025-06-21", &format!("{h:02}:{m:02}:00")); + let levels = compute_auto_frontlight_levels(t, london(), 10.0.into(), 50.0.into()); + assert!( + (levels.warmth - 50.0).abs() < 6.0, + "evening ramp midpoint: warmth should be ~50, got {}", + levels.warmth + ); + } + + #[test] + fn warmth_ramps_from_one_hundred_to_zero_in_morning_transition() { + let (sr, _) = compute_expected_sun_times( + chrono::NaiveDate::from_ymd_opt(2025, 6, 21).unwrap(), + london(), + ); + let transition = 90.0; + + let ramp_start = (sr - transition) as i64; + let h = ramp_start / 60; + let m = ramp_start % 60; + let t = make_dt("2025-06-21", &format!("{h:02}:{m:02}:00")); + let levels = compute_auto_frontlight_levels(t, london(), 10.0.into(), 50.0.into()); + assert!( + (levels.warmth - 100.0).abs() < 2.0, + "morning ramp start: warmth should be ~100, got {}", + levels.warmth + ); + + let midpoint = (sr - transition / 2.0) as i64; + let h = midpoint / 60; + let m = midpoint % 60; + let t = make_dt("2025-06-21", &format!("{h:02}:{m:02}:00")); + let levels = compute_auto_frontlight_levels(t, london(), 10.0.into(), 50.0.into()); + assert!( + (levels.warmth - 50.0).abs() < 6.0, + "morning ramp midpoint: warmth should be ~50, got {}", + levels.warmth + ); + } + + #[test] + fn evening_brightness_is_night_level() { + let (_, ss) = compute_expected_sun_times( + chrono::NaiveDate::from_ymd_opt(2025, 6, 21).unwrap(), + london(), + ); + let post_sunset_min = ss + 30.0; + let h = post_sunset_min as i64 / 60; + let m = post_sunset_min as i64 % 60; + let t = make_dt("2025-06-21", &format!("{h:02}:{m:02}:00")); + let levels = compute_auto_frontlight_levels(t, london(), 10.0.into(), 50.0.into()); + assert_eq!( + levels.intensity, 10.0, + "post-sunset brightness should be night_brightness" + ); + } + + #[test] + fn morning_brightness_is_night_level_before_sunrise() { + let (sr, _) = compute_expected_sun_times( + chrono::NaiveDate::from_ymd_opt(2025, 6, 21).unwrap(), + london(), + ); + let pre_sunrise_min = sr - 120.0; + let h = pre_sunrise_min as i64 / 60; + let m = pre_sunrise_min as i64 % 60; + let t = make_dt("2025-06-21", &format!("{h:02}:{m:02}:00")); + let levels = compute_auto_frontlight_levels(t, london(), 10.0.into(), 50.0.into()); + assert_eq!( + levels.intensity, 10.0, + "pre-sunrise brightness should be night_brightness" + ); + } + + #[test] + fn warmth_stays_continuous_across_midnight_when_morning_ramp_wraps() { + let coordinates = tromso(); + let before_midnight = make_dt("2025-05-15", "23:59:00"); + let after_midnight = make_dt("2025-05-16", "00:00:00"); + + let before_levels = + compute_auto_frontlight_levels(before_midnight, coordinates, 10.0.into(), 50.0.into()); + let after_levels = + compute_auto_frontlight_levels(after_midnight, coordinates, 10.0.into(), 50.0.into()); + + assert!( + (f32::from(before_levels.warmth) - f32::from(after_levels.warmth)).abs() < 4.0, + "warmth should stay continuous across midnight, got {} then {}", + before_levels.warmth, + after_levels.warmth + ); + } +} diff --git a/crates/core/src/frontlight/mod.rs b/crates/core/src/frontlight/mod.rs index 20eb86fa..405f146a 100644 --- a/crates/core/src/frontlight/mod.rs +++ b/crates/core/src/frontlight/mod.rs @@ -1,3 +1,5 @@ +/// Automatic frontlight calculations based on sunrise and sunset. +pub mod auto; mod natural; mod premixed; mod standard; @@ -6,46 +8,170 @@ pub use self::natural::NaturalFrontlight; pub use self::premixed::PremixedFrontlight; pub use self::standard::StandardFrontlight; use crate::geom::lerp; +use libc::c_int; use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::fmt::{Display, Formatter}; +/// The level of light intensity from 0-100. +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, PartialOrd)] +pub struct LightLevel(f32); + +/// The default implementation reutrns 1%, so that the screen can be seen +/// if the device happens to be turned on in a dark place. +impl Default for LightLevel { + fn default() -> Self { + Self(1f32) + } +} + +impl From for c_int { + fn from(value: LightLevel) -> Self { + value.0.round() as c_int + } +} + +impl From for i16 { + fn from(value: LightLevel) -> Self { + value.0.round() as i16 + } +} + +impl From for String { + fn from(value: LightLevel) -> Self { + format!("{:.0}", value.0) + } +} + +impl Display for LightLevel { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{:.0}%", self.value()) + } +} + +impl std::cmp::PartialEq for LightLevel { + fn eq(&self, other: &f32) -> bool { + self.value().eq(other) + } +} + +impl std::cmp::PartialOrd for LightLevel { + fn partial_cmp(&self, other: &f32) -> Option { + self.value().partial_cmp(other) + } +} + +impl std::ops::Sub for LightLevel { + type Output = LightLevel; + + fn sub(self, rhs: f32) -> Self::Output { + Self(self.value() - rhs) + } +} +impl From for LightLevel { + fn from(value: f32) -> Self { + Self(value.clamp(0.0, 100.0)) + } +} + +impl From for f32 { + fn from(value: LightLevel) -> Self { + value.0 + } +} + +impl LightLevel { + const ZERO: f32 = 0.0; + + /// Returns the absolute value of the light level. + pub fn abs(self) -> Self { + self.value().abs().into() + } + + /// Returns a light level representing `0%` output. + pub fn off() -> Self { + Self(Self::ZERO) + } + + fn value(&self) -> f32 { + self.0 + } + + /// Given a value between 0.0 and 1, this normalizes LightLevel accordingly to 0-100. + pub fn from_fraction(fraction: f32) -> Self { + Self::from(fraction * 100f32) + } + + /// Returns the level as a value between 0.0-1 + pub fn as_fraction(&self) -> f32 { + self.value() / 100.0 + } + + /// Instead of 0-100, this returns a value between 0-10. + /// Usefull for e.g. `/sys/class/backlight/lm3630a_led/color` that accepts + /// a value between 0-10. + pub fn as_10_base(&self) -> i16 { + (self.value() / 10.0).round() as i16 + } + + /// Similar to [`Self::as_10_base`] but instead of 10 being max, 0 is max. + pub fn as_10_base_inverted(&self) -> i16 { + 10 - self.as_10_base() + } +} + +/// A complete frontlight state containing brightness and warmth. #[derive(Debug, Copy, Clone, Serialize, Deserialize)] pub struct LightLevels { - pub intensity: f32, - pub warmth: f32, + /// The overall frontlight brightness. + pub intensity: LightLevel, + /// The warmth of the emitted light. + pub warmth: LightLevel, } impl Default for LightLevels { fn default() -> Self { LightLevels { - intensity: 0.0, - warmth: 0.0, + intensity: LightLevel::default(), + warmth: LightLevel::default(), } } } impl LightLevels { + /// Linearly interpolates between two frontlight states. + /// + /// `t = 0.0` returns `self` and `t = 1.0` returns `other`. pub fn interpolate(self, other: Self, t: f32) -> Self { LightLevels { - intensity: lerp(self.intensity, other.intensity, t), - warmth: lerp(self.warmth, other.warmth, t), + intensity: LightLevel(lerp(self.intensity.value(), other.intensity.value(), t)), + warmth: LightLevel(lerp(self.warmth.value(), other.warmth.value(), t)), } } } pub trait Frontlight { // value is a percentage. - fn set_intensity(&mut self, value: f32); - fn set_warmth(&mut self, value: f32); + fn set_intensity(&mut self, value: LightLevel) -> anyhow::Result<()>; + fn set_warmth(&mut self, value: LightLevel) -> anyhow::Result<()>; fn levels(&self) -> LightLevels; + /// Turns the FrontLight off by setting everything to [`LightLevel::off()`] + fn turn_off(&mut self) -> anyhow::Result<()> { + self.set_intensity(LightLevel::off())?; + self.set_warmth(LightLevel::off())?; + Ok(()) + } } impl Frontlight for LightLevels { - fn set_intensity(&mut self, value: f32) { + fn set_intensity(&mut self, value: LightLevel) -> anyhow::Result<()> { self.intensity = value; + Ok(()) } - fn set_warmth(&mut self, value: f32) { + fn set_warmth(&mut self, value: LightLevel) -> anyhow::Result<()> { self.warmth = value; + Ok(()) } fn levels(&self) -> LightLevels { diff --git a/crates/core/src/frontlight/natural.rs b/crates/core/src/frontlight/natural.rs index dbd6139b..52016bbc 100644 --- a/crates/core/src/frontlight/natural.rs +++ b/crates/core/src/frontlight/natural.rs @@ -1,4 +1,4 @@ -use super::{Frontlight, LightLevels}; +use super::{Frontlight, LightLevel, LightLevels}; use crate::device::{CURRENT_DEVICE, Model}; use anyhow::Error; use fxhash::FxHashMap; @@ -61,15 +61,15 @@ lazy_static! { } pub struct NaturalFrontlight { - intensity: f32, - warmth: f32, + intensity: LightLevel, + warmth: LightLevel, values: FxHashMap, powers: FxHashMap, maxima: FxHashMap, } impl NaturalFrontlight { - pub fn new(intensity: f32, warmth: f32) -> Result { + pub fn new(intensity: LightLevel, warmth: LightLevel) -> Result { let mut maxima = FxHashMap::default(); let mut values = FxHashMap::default(); let mut powers = FxHashMap::default(); @@ -98,25 +98,26 @@ impl NaturalFrontlight { }) } - fn set(&mut self, c: LightColor, percent: f32) { + fn set(&mut self, c: LightColor, percent: f32) -> Result<(), Error> { let max_value = self.maxima[&c] as f32; let value = (percent.clamp(0.0, 100.0) / 100.0 * max_value) as i16; let mut file = &self.values[&c]; - write!(file, "{}", value).unwrap(); + write!(file, "{}", value)?; let mut file = &self.powers[&c]; let power = if value > 0 { FRONTLIGHT_POWER_ON } else { FRONTLIGHT_POWER_OFF }; - write!(file, "{}", power).unwrap(); + write!(file, "{}", power)?; + Ok(()) } - fn update(&mut self, intensity: f32, warmth: f32) { - let i = intensity / 100.0; - let w = warmth / 100.0; + fn update(&mut self, intensity: LightLevel, warmth: LightLevel) -> Result<(), Error> { + let i = intensity.as_fraction(); + let w = warmth.as_fraction(); let white = 80.0 * i * (1.0 - w).sqrt(); - self.set(LightColor::White, white); + self.set(LightColor::White, white)?; if self.values.len() == 3 { let green = 64.0 * (w * i).sqrt(); @@ -125,27 +126,28 @@ impl NaturalFrontlight { } else { green + 20.0 + 7.0 * (1.0 - green / 64.0) + w * 4.0 }; - self.set(LightColor::Red, red); - self.set(LightColor::Green, green); + self.set(LightColor::Red, red)?; + self.set(LightColor::Green, green)?; } else { let orange = 95.0 * (w * i).sqrt(); - self.set(LightColor::Orange, orange); + self.set(LightColor::Orange, orange)?; } self.intensity = intensity; self.warmth = warmth; + Ok(()) } } impl Frontlight for NaturalFrontlight { - fn set_intensity(&mut self, value: f32) { + fn set_intensity(&mut self, value: LightLevel) -> Result<(), Error> { let warmth = self.warmth; - self.update(value, warmth); + self.update(value, warmth) } - fn set_warmth(&mut self, value: f32) { + fn set_warmth(&mut self, value: LightLevel) -> Result<(), Error> { let intensity = self.intensity; - self.update(intensity, value); + self.update(intensity, value) } fn levels(&self) -> LightLevels { diff --git a/crates/core/src/frontlight/premixed.rs b/crates/core/src/frontlight/premixed.rs index 118753a8..3cd43021 100644 --- a/crates/core/src/frontlight/premixed.rs +++ b/crates/core/src/frontlight/premixed.rs @@ -1,4 +1,4 @@ -use super::{Frontlight, LightLevels}; +use super::{Frontlight, LightLevel, LightLevels}; use crate::device::CURRENT_DEVICE; use anyhow::Error; use std::fs::File; @@ -16,14 +16,14 @@ const FRONTLIGHT_ORANGE_B: &str = "/sys/class/backlight/lm3630a_led/color"; const FRONTLIGHT_ORANGE_C: &str = "/sys/class/leds/aw99703-bl_FL1/color"; pub struct PremixedFrontlight { - intensity: f32, - warmth: f32, + intensity: LightLevel, + warmth: LightLevel, white: File, orange: File, } impl PremixedFrontlight { - pub fn new(intensity: f32, warmth: f32) -> Result { + pub fn new(intensity: LightLevel, warmth: LightLevel) -> Result { let white = OpenOptions::new().write(true).open(FRONTLIGHT_WHITE)?; let orange_path = if Path::new(FRONTLIGHT_ORANGE_C).exists() { FRONTLIGHT_ORANGE_C @@ -43,19 +43,20 @@ impl PremixedFrontlight { } impl Frontlight for PremixedFrontlight { - fn set_intensity(&mut self, intensity: f32) { - let white = intensity.round() as i16; - write!(self.white, "{}", white).unwrap(); + fn set_intensity(&mut self, intensity: LightLevel) -> Result<(), Error> { + write!(self.white, "{}", i16::from(intensity))?; self.intensity = intensity; + Ok(()) } - fn set_warmth(&mut self, warmth: f32) { - let mut orange = (warmth / 10.0).round() as i16; + fn set_warmth(&mut self, warmth: LightLevel) -> Result<(), Error> { if CURRENT_DEVICE.mark() != 8 { - orange = 10 - orange; + write!(self.orange, "{}", warmth.as_10_base_inverted())?; + } else { + write!(self.orange, "{}", warmth.as_10_base())?; } - write!(self.orange, "{}", orange).unwrap(); self.warmth = warmth; + Ok(()) } fn levels(&self) -> LightLevels { diff --git a/crates/core/src/frontlight/standard.rs b/crates/core/src/frontlight/standard.rs index e3e52360..459e606e 100644 --- a/crates/core/src/frontlight/standard.rs +++ b/crates/core/src/frontlight/standard.rs @@ -1,4 +1,4 @@ -use super::{Frontlight, LightLevels}; +use super::{Frontlight, LightLevel, LightLevels}; use anyhow::Error; use nix::ioctl_write_int_bad; use std::fs::File; @@ -9,32 +9,39 @@ ioctl_write_int_bad!(write_frontlight_intensity, 241); const FRONTLIGHT_INTERFACE: &str = "/dev/ntx_io"; pub struct StandardFrontlight { - value: f32, + value: LightLevel, interface: File, } impl StandardFrontlight { - pub fn new(value: f32) -> Result { + pub fn new(value: LightLevel) -> Result { let interface = OpenOptions::new().write(true).open(FRONTLIGHT_INTERFACE)?; Ok(StandardFrontlight { value, interface }) } } impl Frontlight for StandardFrontlight { - fn set_intensity(&mut self, value: f32) { - let ret = - unsafe { write_frontlight_intensity(self.interface.as_raw_fd(), value as libc::c_int) }; - if ret.is_ok() { - self.value = value; - } + /// # SAFETY + /// `self.interface` is an open `/dev/ntx_io` handle owned by this + /// `StandardFrontlight`, so `as_raw_fd()` yields a valid descriptor for the + /// duration of the call. The ioctl request code and integer payload match the + /// kernel frontlight brightness interface expected by `write_frontlight_intensity`. + fn set_intensity(&mut self, value: LightLevel) -> Result<(), Error> { + unsafe { + write_frontlight_intensity(self.interface.as_raw_fd(), libc::c_int::from(value)) + }?; + self.value = value; + Ok(()) } - fn set_warmth(&mut self, _value: f32) {} + fn set_warmth(&mut self, _value: LightLevel) -> Result<(), Error> { + Ok(()) + } fn levels(&self) -> LightLevels { LightLevels { intensity: self.value, - warmth: 0.0, + warmth: LightLevel::off(), } } } diff --git a/crates/core/src/geolocation.rs b/crates/core/src/geolocation.rs new file mode 100644 index 00000000..63a582e7 --- /dev/null +++ b/crates/core/src/geolocation.rs @@ -0,0 +1,140 @@ +use anyhow::Error; +use chrono_tz::Tz; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::time::Duration; + +use crate::http::Client as HttpClient; + +/// Geographic coordinates expressed as latitude and longitude in degrees. +/// +/// Latitude must be in the inclusive range `-90.0..=90.0` and longitude +/// must be in the inclusive range `-180.0..=180.0`. +#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] +pub struct Coordinates(f64, f64); + +impl Coordinates { + /// Creates validated geographic coordinates. + /// + /// Returns an error if either component is `NaN` or falls outside the + /// allowed latitude/longitude bounds. + pub fn new(lat: f64, lon: f64) -> Result { + anyhow::ensure!( + !lat.is_nan() + && !lon.is_nan() + && (-90.0..=90.0).contains(&lat) + && (-180.0..=180.0).contains(&lon), + "The given coordinates are invalid" + ); + + Ok(Self(lat, lon)) + } + + /// Returns the latitude in degrees. + pub fn latitude(self) -> f64 { + self.0 + } + + /// Returns the longitude in degrees. + pub fn longitude(self) -> f64 { + self.1 + } +} + +impl From for sunrise::Coordinates { + fn from(value: Coordinates) -> Self { + Self::new(value.0, value.1).unwrap_or_else(|| { + panic!( + "Given coordinates are invalid, the Coordinates struct should not be holding invalid coordinates: {value}" + ) + }) + } +} + +impl fmt::Display for Coordinates { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "({},{})", self.latitude(), self.longitude()) + } +} + +/// A geographic location paired with its local time zone. +#[derive(Copy, Clone)] +pub struct GeoLocation { + /// The location's latitude and longitude. + pub coordinates: Coordinates, + /// The IANA time zone associated with the coordinates. + pub timezone: Tz, +} + +impl fmt::Display for GeoLocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {}", self.coordinates, self.timezone) + } +} + +impl From for sunrise::Coordinates { + fn from(value: GeoLocation) -> Self { + value.coordinates.into() + } +} + +#[derive(Debug, Clone, Deserialize)] +struct IpApiResponse { + latitude: f64, + longitude: f64, + timezone: String, +} + +/// Fetches an approximate geolocation for the current network connection. +/// +/// This uses `https://ipapi.co/json/` to resolve the device's public IP to a +/// latitude/longitude pair and an IANA time zone. The request times out after +/// 10 seconds. +pub fn fetch_geolocation(client: &HttpClient) -> Result { + let resp: IpApiResponse = client + .get("https://ipapi.co/json/") + .timeout(Duration::from_secs(10)) + .send()? + .json()?; + + let timezone = resp + .timezone + .parse::() + .map_err(|e| anyhow::anyhow!("invalid timezone from ipapi: {e}"))?; + + let coordinates = Coordinates::new(resp.latitude, resp.longitude)?; + + Ok(GeoLocation { + coordinates, + timezone, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_ipapi_response() { + let json = serde_json::json!({ + "latitude": 51.5074, + "longitude": -0.1278, + "timezone": "Europe/London" + }); + let resp: IpApiResponse = serde_json::from_value(json).unwrap(); + assert!((resp.latitude - 51.5074).abs() < 0.001); + assert!((resp.longitude - (-0.1278)).abs() < 0.001); + assert_eq!(resp.timezone, "Europe/London"); + } + + #[test] + fn coordinates_validate_bounds() { + assert!(Coordinates::new(51.5074, -0.1278).is_ok()); + assert!(Coordinates::new(-90.0, -180.0).is_ok()); + assert!(Coordinates::new(90.0, 180.0).is_ok()); + assert!(Coordinates::new(f64::NAN, 0.0).is_err()); + assert!(Coordinates::new(0.0, f64::NAN).is_err()); + assert!(Coordinates::new(90.1, 0.0).is_err()); + assert!(Coordinates::new(0.0, 180.1).is_err()); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3342bb90..32fb578d 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -16,6 +16,7 @@ pub mod document; pub mod font; pub mod framebuffer; pub mod frontlight; +pub mod geolocation; pub mod gesture; pub mod github; pub mod helpers; diff --git a/crates/core/src/settings/mod.rs b/crates/core/src/settings/mod.rs index 4237104f..68335781 100644 --- a/crates/core/src/settings/mod.rs +++ b/crates/core/src/settings/mod.rs @@ -4,7 +4,8 @@ pub mod versioned; use crate::color::{BLACK, Color}; use crate::device::CURRENT_DEVICE; use crate::fl; -use crate::frontlight::LightLevels; +use crate::frontlight::{LightLevel, LightLevels}; +use crate::geolocation::Coordinates; use crate::i18n::I18nDisplay; use crate::metadata::{SortMethod, TextAlign}; use crate::unit::mm_to_px; @@ -257,6 +258,28 @@ pub struct Settings { pub sleep_cover: bool, pub auto_share: bool, pub auto_time: bool, + /// Whether frontlight levels should be managed automatically. + /// + /// When enabled, Cadmus derives brightness and warmth from the current + /// time and a known location. + pub auto_frontlight: bool, + /// The brightness to use after sunset and before sunrise when automatic + /// frontlight control is enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_frontlight_night_brightness: Option, + /// A user-specified location for automatic frontlight calculations. + /// + /// When present, this takes precedence over coordinates discovered from + /// automatic time syncing. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_frontlight_manual_coordinates: Option, + /// The last automatically discovered location for automatic frontlight + /// calculations. + /// + /// This is typically refreshed during automatic time synchronization and + /// is used only when no manual coordinates are configured. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_frontlight_last_coordinates: Option, #[serde(skip_serializing_if = "Option::is_none")] pub rotation_lock: Option, pub button_scheme: ButtonScheme, @@ -997,6 +1020,10 @@ impl Default for Settings { sleep_cover: true, auto_share: false, auto_time: false, + auto_frontlight: false, + auto_frontlight_night_brightness: Some(LightLevel::default()), + auto_frontlight_manual_coordinates: None, + auto_frontlight_last_coordinates: None, rotation_lock: None, button_scheme: ButtonScheme::Natural, auto_suspend: 30.0, @@ -1025,6 +1052,16 @@ impl Default for Settings { } } +/// Returns the coordinates to use for automatic frontlight calculations. +/// +/// Manual coordinates take precedence over the last automatically discovered +/// location. +pub fn resolve_coordinates(settings: &Settings) -> Option { + settings + .auto_frontlight_manual_coordinates + .or(settings.auto_frontlight_last_coordinates) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/core/src/task/auto_frontlight.rs b/crates/core/src/task/auto_frontlight.rs new file mode 100644 index 00000000..1635f758 --- /dev/null +++ b/crates/core/src/task/auto_frontlight.rs @@ -0,0 +1,31 @@ +use std::sync::mpsc::Sender; +use std::time::Duration; + +use crate::task::{BackgroundTask, ShutdownSignal, TaskId}; +use crate::view::Event; + +const CHECK_INTERVAL: Duration = Duration::from_secs(5 * 60); + +/// Background task that periodically asks the UI loop to recompute and apply +/// automatic frontlight levels. +#[derive(Default)] +pub struct AutoFrontlightTask; + +impl BackgroundTask for AutoFrontlightTask { + fn id(&self) -> TaskId { + TaskId::AutoFrontlight + } + + fn run(&mut self, hub: &Sender, shutdown: &ShutdownSignal) { + while !shutdown.should_stop() { + if let Err(e) = hub.send(Event::UpdateAutoFrontlight) { + tracing::error!(error = %e, "failed to send auto-frontlight update event"); + break; + } + + if shutdown.wait(CHECK_INTERVAL) { + break; + } + } + } +} diff --git a/crates/core/src/task/mod.rs b/crates/core/src/task/mod.rs index 25481921..3a34af6f 100644 --- a/crates/core/src/task/mod.rs +++ b/crates/core/src/task/mod.rs @@ -36,6 +36,8 @@ //! } //! ``` +#[cfg(any(feature = "kobo", doc))] +pub mod auto_frontlight; #[cfg(any(all(feature = "test", feature = "kobo"), doc))] mod dbus_monitor; pub mod dictionary_index; @@ -98,6 +100,9 @@ pub enum TaskId { /// Time synchronization via NTP (kobo builds only). #[cfg(any(feature = "kobo", doc))] TimeSync, + /// Auto frontlight adjustment (kobo builds only). + #[cfg(any(feature = "kobo", doc))] + AutoFrontlight, /// Test-only task for unit tests. #[cfg(test)] TestTask, @@ -121,6 +126,8 @@ impl std::fmt::Display for TaskId { TaskId::WifiStatusMonitor => write!(f, "wifi_status_monitor"), #[cfg(feature = "kobo")] TaskId::TimeSync => write!(f, "time_sync"), + #[cfg(feature = "kobo")] + TaskId::AutoFrontlight => write!(f, "auto_frontlight"), #[cfg(test)] TaskId::TestTask => write!(f, "test_task"), #[cfg(test)] @@ -275,7 +282,8 @@ impl TaskManager { /// [`ShutdownSignal`] for graceful termination. /// /// Returns an error if a task with the same ID is already running. - #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, task, hub), fields(task_id = tracing::field::Empty), ret))] + #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, task, hub), fields(task_id = tracing::field::Empty + ), ret))] pub fn start( &mut self, task: Box, @@ -340,7 +348,8 @@ impl TaskManager { /// Stops all running tasks. /// /// Sends shutdown signals to all tasks and waits for them to finish. - #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(task_count = tracing::field::Empty)))] + #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(task_count = tracing::field::Empty + )))] pub fn stop_all(&mut self) { let tasks: Vec<_> = self.tasks.drain().collect(); @@ -437,6 +446,10 @@ impl TaskManager { } } } + #[cfg(feature = "kobo")] + Event::AutoFrontlightConfigChanged => { + self.sync_auto_frontlight(hub, &context.settings); + } Event::Select(EntryId::SyncTime) => { #[cfg(feature = "kobo")] { @@ -523,6 +536,28 @@ impl TaskManager { } } + #[cfg(feature = "kobo")] + #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))] + fn sync_auto_frontlight(&mut self, hub: &Sender, settings: &Settings) { + if self.is_running(&TaskId::AutoFrontlight) { + tracing::debug!("stopping running auto_frontlight task for restart"); + if let Err(e) = self.stop(&TaskId::AutoFrontlight) { + tracing::warn!(error = %e, "failed to stop auto_frontlight task for restart"); + } + } + + if !settings.auto_frontlight { + return; + } + + self.flush_buffered_events(hub); + + let task = Box::new(auto_frontlight::AutoFrontlightTask); + if let Err(e) = self.start(task, hub.clone()) { + tracing::warn!(error = %e, "failed to start auto_frontlight task"); + } + } + #[cfg(feature = "kobo")] #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))] fn schedule_time_sync(&mut self, manual: bool, hub: &Sender) { @@ -635,9 +670,17 @@ pub fn register_startup_tasks( ) { #[cfg(feature = "kobo")] { - let task = Box::new(wifi_status_monitor::WifiStatusMonitorTask); - if let Err(e) = manager.start(task, hub.clone()) { - tracing::warn!(error = %e, "failed to start wifi_status_monitor task"); + { + let task = Box::new(wifi_status_monitor::WifiStatusMonitorTask); + if let Err(e) = manager.start(task, hub.clone()) { + tracing::warn!(error = %e, "failed to start wifi_status_monitor task"); + } + } + if settings.auto_frontlight { + let task = Box::new(auto_frontlight::AutoFrontlightTask); + if let Err(e) = manager.start(task, hub.clone()) { + tracing::warn!(error = %e, "failed to start auto_frontlight task"); + } } } diff --git a/crates/core/src/task/time_sync.rs b/crates/core/src/task/time_sync.rs index 7f1e42cd..8c663e5f 100644 --- a/crates/core/src/task/time_sync.rs +++ b/crates/core/src/task/time_sync.rs @@ -1,5 +1,7 @@ use std::sync::mpsc::Sender; +use crate::geolocation::fetch_geolocation; +use crate::http::Client; use crate::task::{BackgroundTask, ShutdownSignal, TaskId}; use crate::time_manager::TimeManager; use crate::view::Event; @@ -26,8 +28,28 @@ impl BackgroundTask for TimeSyncTask { } fn run(&mut self, hub: &Sender, _shutdown: &ShutdownSignal) { - if let Err(e) = self.time_manager.sync(NTP_HOST, self.manual, hub) { + let geo = match Client::new() { + Ok(client) => match fetch_geolocation(&client) { + Ok(geo) => Some(geo), + Err(e) => { + tracing::error!(error = %e, "failed to fetch geolocation"); + None + } + }, + Err(e) => { + tracing::error!(error = %e, "failed to create http client"); + None + } + }; + + let coordinates = geo.as_ref().map(|geo| geo.coordinates); + + if let Err(e) = self.time_manager.sync(NTP_HOST, self.manual, geo, hub) { tracing::error!(error = %e, "time sync failed"); } + + if let Some(coordinates) = coordinates { + hub.send(Event::AutoFrontlightCoordinates(coordinates)).ok(); + } } } diff --git a/crates/core/src/time_manager.rs b/crates/core/src/time_manager.rs index 595434db..8330e448 100644 --- a/crates/core/src/time_manager.rs +++ b/crates/core/src/time_manager.rs @@ -7,6 +7,8 @@ use std::sync::mpsc::Sender; use std::time::Duration; use crate::device::CURRENT_DEVICE; +use crate::geolocation; +use crate::geolocation::GeoLocation; use crate::http::Client as HttpClient; use crate::rtc::Rtc; use crate::view::{Event, NotificationEvent}; @@ -22,8 +24,14 @@ impl TimeManager { TimeManager { rtc } } - pub fn sync(&self, ntp_host: &str, manual: bool, hub: &Sender) -> Result<(), Error> { - if let Err(e) = self.detect_and_set_timezone() { + pub fn sync( + &self, + ntp_host: &str, + manual: bool, + geolocation: Option, + hub: &Sender, + ) -> Result<(), Error> { + if let Err(e) = self.detect_and_set_timezone(geolocation) { if manual { hub.send(Event::Notification(NotificationEvent::Show(crate::fl!( "notification-timezone-detection-failed" @@ -71,22 +79,19 @@ impl TimeManager { } } - fn detect_and_set_timezone(&self) -> Result { - let client = HttpClient::new()?; - let resp: serde_json::Value = client - .get("https://ipapi.co/json/") - .timeout(Duration::from_secs(10)) - .send()? - .json()?; - - let tz = resp["timezone"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("timezone field missing from ipapi response"))? - .parse::() - .map_err(|e| anyhow::anyhow!("invalid timezone from ipapi: {e}"))?; - - CURRENT_DEVICE.set_system_timezone(tz)?; - Ok(tz) + fn detect_and_set_timezone(&self, geolocation: Option) -> Result<(), Error> { + let geo = match geolocation { + Some(geo) => geo, + None => { + let client = HttpClient::new()?; + + geolocation::fetch_geolocation(&client)? + } + }; + + CURRENT_DEVICE.set_system_timezone(geo.timezone)?; + + Ok(()) } fn query_ntp(&self, host: &str) -> Result, Error> { diff --git a/crates/core/src/view/frontlight.rs b/crates/core/src/view/frontlight.rs index a9a0d887..a166bcfa 100644 --- a/crates/core/src/view/frontlight.rs +++ b/crates/core/src/view/frontlight.rs @@ -26,6 +26,7 @@ pub struct FrontlightWindow { id: Id, rect: Rectangle, children: Vec>, + frontlight_levels: LightLevels, } impl FrontlightWindow { @@ -140,7 +141,7 @@ impl FrontlightWindow { min_y + small_height ], *slider_id, - value, + value.into(), 0.0, 100.0, ); @@ -158,7 +159,7 @@ impl FrontlightWindow { min_y + small_height ], SliderId::LightIntensity, - levels.intensity, + levels.intensity.into(), 0.0, 100.0, ); @@ -213,7 +214,12 @@ impl FrontlightWindow { children.push(Box::new(presets_list) as Box); } - FrontlightWindow { id, rect, children } + FrontlightWindow { + id, + rect, + children, + frontlight_levels: levels, + } } fn toggle_presets(&mut self, enable: bool, rq: &mut RenderQueue, context: &mut Context) { @@ -250,24 +256,18 @@ impl FrontlightWindow { } } - fn set_frontlight_levels( - &mut self, - frontlight_levels: LightLevels, - rq: &mut RenderQueue, - context: &mut Context, - ) { + fn set_frontlight_levels(&mut self, frontlight_levels: LightLevels, rq: &mut RenderQueue) { + self.frontlight_levels = frontlight_levels; let LightLevels { intensity, warmth } = frontlight_levels; - context.frontlight.set_intensity(intensity); - context.frontlight.set_warmth(warmth); if CURRENT_DEVICE.has_natural_light() { if let Some(slider_intensity) = self.child_mut(3).downcast_mut::() { - slider_intensity.update(intensity, rq); + slider_intensity.update(intensity.into(), rq); } if let Some(slider_warmth) = self.child_mut(5).downcast_mut::() { - slider_warmth.update(warmth, rq); + slider_warmth.update(warmth.into(), rq); } } else if let Some(slider_intensity) = self.child_mut(2).downcast_mut::() { - slider_intensity.update(intensity, rq); + slider_intensity.update(intensity.into(), rq); } } @@ -280,7 +280,8 @@ impl FrontlightWindow { } impl View for FrontlightWindow { - #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, hub, _bus, rq, context), fields(event = ?evt), ret(level=tracing::Level::TRACE)))] + #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, hub, _bus, rq, context), fields(event = ?evt + ), ret(level=tracing::Level::TRACE)))] fn handle_event( &mut self, evt: &Event, @@ -291,11 +292,17 @@ impl View for FrontlightWindow { ) -> bool { match *evt { Event::Slider(SliderId::LightIntensity, value, _) => { - context.frontlight.set_intensity(value); + let mut levels = self.frontlight_levels; + levels.intensity = value.into(); + self.frontlight_levels = levels; + hub.send(Event::SetFrontlightLevels(levels)).ok(); true } Event::Slider(SliderId::LightWarmth, value, _) => { - context.frontlight.set_warmth(value); + let mut levels = self.frontlight_levels; + levels.warmth = value.into(); + self.frontlight_levels = levels; + hub.send(Event::SetFrontlightLevels(levels)).ok(); true } Event::Gesture(GestureEvent::Tap(center)) if !self.rect.includes(center) => { @@ -364,7 +371,8 @@ impl View for FrontlightWindow { Event::LoadPreset(index) => { let frontlight_levels = context.settings.frontlight_presets[index].frontlight_levels; - self.set_frontlight_levels(frontlight_levels, rq, context); + self.set_frontlight_levels(frontlight_levels, rq); + hub.send(Event::SetFrontlightLevels(frontlight_levels)).ok(); true } Event::Guess => { @@ -376,7 +384,9 @@ impl View for FrontlightWindow { if let Some(ref frontlight_levels) = guess_frontlight(lightsensor_level, &context.settings.frontlight_presets) { - self.set_frontlight_levels(*frontlight_levels, rq, context); + self.set_frontlight_levels(*frontlight_levels, rq); + hub.send(Event::SetFrontlightLevels(*frontlight_levels)) + .ok(); } true } @@ -384,7 +394,8 @@ impl View for FrontlightWindow { } } - #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, fb, _fonts, _rect), fields(rect = ?_rect)))] + #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, fb, _fonts, _rect), fields(rect = ?_rect + )))] fn render(&self, fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut Fonts) { let dpi = CURRENT_DEVICE.dpi; diff --git a/crates/core/src/view/home/mod.rs b/crates/core/src/view/home/mod.rs index 549ba9d8..a6817400 100644 --- a/crates/core/src/view/home/mod.rs +++ b/crates/core/src/view/home/mod.rs @@ -2362,6 +2362,18 @@ impl View for Home { } true } + + Event::AutoFrontlightCoordinates(coordinates) => { + if context + .settings + .auto_frontlight_manual_coordinates + .is_none() + { + context.settings.auto_frontlight_last_coordinates = Some(coordinates); + hub.send(Event::AutoFrontlightConfigChanged).ok(); + } + true + } Event::Reseed => { self.reseed(rq, context); true diff --git a/crates/core/src/view/mod.rs b/crates/core/src/view/mod.rs index 9dc48cd4..d747816f 100644 --- a/crates/core/src/view/mod.rs +++ b/crates/core/src/view/mod.rs @@ -64,6 +64,7 @@ use crate::context::Context; use crate::document::{Location, TextLocation}; use crate::font::Fonts; use crate::framebuffer::{Framebuffer, UpdateMode}; +use crate::frontlight::LightLevels; use crate::geom::{Boundary, CycleDir, LinearDir, Rectangle}; use crate::gesture::GestureEvent; use crate::input::{DeviceEvent, FingerStatus}; @@ -487,6 +488,8 @@ pub enum Event { ClockTick, BatteryTick, ToggleFrontlight, + SetFrontlightLevels(LightLevels), + UpdateAutoFrontlight, Load(PathBuf), LoadPreset(usize), Scroll(i32), @@ -506,6 +509,8 @@ pub enum Event { Quit, WakeUp, Hold(EntryId), + AutoFrontlightCoordinates(crate::geolocation::Coordinates), + AutoFrontlightConfigChanged, /// The file chooser was closed. /// The `Option` contains the selected path, if any. FileChooserClosed(Option), @@ -638,6 +643,8 @@ pub enum ViewId { LibraryRenameInput, AutoSuspendInput, AutoPowerOffInput, + AutoFrontlightBrightnessInput, + AutoFrontlightManualCoordinatesInput, SettingsRetentionInput, IntermissionSuspendInput, IntermissionPowerOffInput, @@ -823,6 +830,8 @@ pub enum EntryId { ToggleAutoShare, EditAutoSuspend, EditAutoPowerOff, + EditAutoFrontlightBrightness, + EditAutoFrontlightManualCoordinates, EditSettingsRetention, SetLogLevel(tracing::Level), EditOtlpEndpoint, diff --git a/crates/core/src/view/reader/mod.rs b/crates/core/src/view/reader/mod.rs index 1df5e55f..74954819 100644 --- a/crates/core/src/view/reader/mod.rs +++ b/crates/core/src/view/reader/mod.rs @@ -4032,8 +4032,13 @@ impl View for Reader { &context.settings.frontlight_presets, ) { let LightLevels { intensity, warmth } = *frontlight_levels; - context.frontlight.set_intensity(intensity); - context.frontlight.set_warmth(warmth); + if let Err(error) = context.frontlight.set_intensity(intensity) + { + tracing::error!(error = %error, "failed to set frontlight intensity"); + } + if let Err(error) = context.frontlight.set_warmth(warmth) { + tracing::error!(error = %error, "failed to set frontlight warmth"); + } } } } else { diff --git a/crates/core/src/view/settings_editor/category.rs b/crates/core/src/view/settings_editor/category.rs index cb43eddc..7cba09a8 100644 --- a/crates/core/src/view/settings_editor/category.rs +++ b/crates/core/src/view/settings_editor/category.rs @@ -1,8 +1,9 @@ use super::kinds::SettingKind; use super::kinds::dictionary::DictionaryInfo; use super::kinds::general::{ - AutoPowerOff, AutoShare, AutoSuspend, AutoTime, ButtonScheme, KeyboardLayout, Locale, - SettingsRetention, SleepCover, + AutoFrontlight, AutoFrontlightBrightness, AutoFrontlightManualCoordinates, AutoPowerOff, + AutoShare, AutoSuspend, AutoTime, ButtonScheme, KeyboardLayout, Locale, SettingsRetention, + SleepCover, }; use super::kinds::import::{AllowedKindsSetting, ForceFullImport, ImportSyncMetadata}; use super::kinds::intermission::{IntermissionPowerOff, IntermissionShare, IntermissionSuspend}; @@ -65,6 +66,9 @@ impl Category { Box::new(Locale), Box::new(AutoShare), Box::new(AutoTime), + Box::new(AutoFrontlight), + Box::new(AutoFrontlightBrightness), + Box::new(AutoFrontlightManualCoordinates), Box::new(AutoSuspend), Box::new(AutoPowerOff), Box::new(ButtonScheme), diff --git a/crates/core/src/view/settings_editor/category_editor.rs b/crates/core/src/view/settings_editor/category_editor.rs index 1f1b4374..26a718dc 100644 --- a/crates/core/src/view/settings_editor/category_editor.rs +++ b/crates/core/src/view/settings_editor/category_editor.rs @@ -797,6 +797,8 @@ impl CategoryEditor { | ViewId::LibraryEditor | ViewId::DictionaryDownloadConfirm | ViewId::ForceImportConfirm + | ViewId::AutoFrontlightBrightnessInput + | ViewId::AutoFrontlightManualCoordinatesInput | ViewId::RefreshRateByKindEditor => { if let Some(index) = locate_by_id(self, *view_id) { let input_rect = *self.children[index].rect(); @@ -832,7 +834,8 @@ impl CategoryEditor { } impl View for CategoryEditor { - #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, hub, _bus, rq, context), fields(event = ?evt), ret(level=tracing::Level::TRACE)))] + #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, hub, _bus, rq, context), fields(event = ?evt + ), ret(level=tracing::Level::TRACE)))] fn handle_event( &mut self, evt: &Event, @@ -934,7 +937,8 @@ impl View for CategoryEditor { } } - #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, _fb, _fonts), fields(rect = ?_rect)))] + #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, _fb, _fonts), fields(rect = ?_rect + )))] fn render(&self, _fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut crate::font::Fonts) { } @@ -1009,6 +1013,13 @@ mod tests { CategoryEditor::new(rect, Category::Import, &mut rq, context) } + fn create_test_general_category_editor(context: &mut Context) -> CategoryEditor { + let rect = rect![0, 0, 600, 800]; + let mut rq = RenderQueue::new(); + + CategoryEditor::new(rect, Category::General, &mut rq, context) + } + #[test] fn test_add_library_event() { let mut context = create_test_context(); @@ -1552,6 +1563,57 @@ mod tests { assert!(!rq.is_empty()); } + #[test] + fn test_close_auto_frontlight_named_inputs_removes_overlay_and_clears_focus() { + let mut context = create_test_context(); + let mut editor = create_test_general_category_editor(&mut context); + + for view_id in [ + ViewId::AutoFrontlightBrightnessInput, + ViewId::AutoFrontlightManualCoordinatesInput, + ] { + let (hub, receiver) = channel(); + let mut bus = VecDeque::new(); + let mut rq = RenderQueue::new(); + + let handled = editor.handle_event( + &Event::OpenNamedInput { + view_id, + label: "Test".to_string(), + max_chars: 16, + initial_text: "1".to_string(), + }, + &hub, + &mut bus, + &mut rq, + &mut context, + ); + + assert!(handled, "OpenNamedInput event should be handled"); + assert!(locate_by_id(&editor, view_id).is_some()); + + let focus_event = receiver.recv().unwrap(); + assert!(matches!(focus_event, Event::Focus(Some(id)) if id == view_id)); + editor.focus = Some(view_id); + + let handled = editor.handle_event( + &Event::Close(view_id), + &hub, + &mut bus, + &mut rq, + &mut context, + ); + + assert!(handled, "Close event should be handled"); + assert!(locate_by_id(&editor, view_id).is_none()); + + let focus_event = receiver.recv().unwrap(); + assert!(matches!(focus_event, Event::Focus(None))); + editor.handle_event(&focus_event, &hub, &mut bus, &mut rq, &mut context); + assert_eq!(editor.focus, None); + } + } + #[test] fn test_import_library_force_true_closes_confirmation_dialog() { let mut context = create_test_context(); diff --git a/crates/core/src/view/settings_editor/kinds/general.rs b/crates/core/src/view/settings_editor/kinds/general.rs index cd983a91..5f86fba9 100644 --- a/crates/core/src/view/settings_editor/kinds/general.rs +++ b/crates/core/src/view/settings_editor/kinds/general.rs @@ -5,6 +5,8 @@ use super::{ }; use crate::device::CURRENT_DEVICE; use crate::fl; +use crate::frontlight::LightLevel; +use crate::geolocation::Coordinates; use crate::i18n::I18nDisplay; use crate::settings::Settings; use crate::view::{Bus, EntryId, EntryKind, Event, ToggleEvent, ViewId}; @@ -417,6 +419,240 @@ impl SettingKind for ButtonScheme { } } +/// Setting kind for toggling automatic frontlight adjustment. +/// +/// Changing this setting triggers a re-evaluation of the active frontlight +/// configuration so the device can immediately react to the new mode. +pub struct AutoFrontlight; + +impl SettingKind for AutoFrontlight { + fn identity(&self) -> SettingIdentity { + SettingIdentity::AutoFrontlight + } + + fn label(&self, _settings: &Settings) -> String { + fl!("settings-general-auto-frontlight") + } + + fn fetch(&self, settings: &Settings) -> SettingData { + SettingData { + value: settings.auto_frontlight.to_string(), + widget: WidgetKind::Toggle { + left_label: fl!("settings-general-toggle-on"), + right_label: fl!("settings-general-toggle-off"), + enabled: settings.auto_frontlight, + tap_event: Event::Toggle(ToggleEvent::Setting(ToggleSettings::AutoFrontlight)), + }, + } + } + + fn handle( + &self, + evt: &Event, + settings: &mut Settings, + bus: &mut Bus, + ) -> (Option, bool) { + if let Event::Toggle(ToggleEvent::Setting(ToggleSettings::AutoFrontlight)) = evt { + settings.auto_frontlight = !settings.auto_frontlight; + bus.push_back(Event::AutoFrontlightConfigChanged); + return (Some(settings.auto_frontlight.to_string()), true); + } + (None, false) + } +} + +/// Setting kind for configuring the brightness used while the sun is down. +/// +/// This value is applied by automatic frontlight mode after sunset and before +/// sunrise. +pub struct AutoFrontlightBrightness; + +impl SettingKind for AutoFrontlightBrightness { + fn identity(&self) -> SettingIdentity { + SettingIdentity::AutoFrontlightBrightness + } + + fn label(&self, _settings: &Settings) -> String { + fl!("settings-general-auto-frontlight-brightness") + } + + fn fetch(&self, settings: &Settings) -> SettingData { + SettingData { + value: self.display_value(settings), + widget: WidgetKind::ActionLabel(Event::Select(EntryId::EditAutoFrontlightBrightness)), + } + } + + fn handle( + &self, + evt: &Event, + settings: &mut Settings, + bus: &mut Bus, + ) -> (Option, bool) { + if let Event::Submit(ViewId::AutoFrontlightBrightnessInput, text) = evt { + let display = self.apply_text(text, settings); + bus.push_back(Event::AutoFrontlightConfigChanged); + return (Some(display), true); + } + + (None, false) + } + + fn as_input_kind(&self) -> Option<&dyn InputSettingKind> { + Some(self) + } +} + +impl AutoFrontlightBrightness { + fn display_value(&self, settings: &Settings) -> String { + settings + .auto_frontlight_night_brightness + .map(|brightness| brightness.to_string()) + .unwrap_or_else(|| LightLevel::default().to_string()) + } +} + +impl InputSettingKind for AutoFrontlightBrightness { + fn submit_view_id(&self) -> ViewId { + ViewId::AutoFrontlightBrightnessInput + } + + fn open_entry_id(&self) -> EntryId { + EntryId::EditAutoFrontlightBrightness + } + + fn input_label(&self) -> String { + fl!("settings-general-auto-frontlight-brightness-input") + } + + fn input_max_chars(&self) -> usize { + 3 + } + + fn current_text(&self, settings: &Settings) -> String { + settings + .auto_frontlight_night_brightness + .map(|b| b.into()) + .unwrap_or_else(|| LightLevel::default().into()) + } + + fn apply_text(&self, text: &str, settings: &mut Settings) -> String { + if let Ok(value) = text.trim().parse::() { + settings.auto_frontlight_night_brightness = Some(value.into()); + } + self.display_value(settings) + } +} + +/// Setting kind for overriding automatic frontlight coordinates manually. +/// +/// Users can enter a `latitude, longitude` pair to control which sunrise and +/// sunset times automatic frontlight should follow. +pub struct AutoFrontlightManualCoordinates; + +impl SettingKind for AutoFrontlightManualCoordinates { + fn identity(&self) -> SettingIdentity { + SettingIdentity::AutoFrontlightManualCoordinates + } + + fn label(&self, _settings: &Settings) -> String { + fl!("settings-general-auto-frontlight-manual-coordinates") + } + + fn fetch(&self, settings: &Settings) -> SettingData { + SettingData { + value: self.display_value(settings), + widget: WidgetKind::ActionLabel(Event::Select( + EntryId::EditAutoFrontlightManualCoordinates, + )), + } + } + + fn handle( + &self, + evt: &Event, + settings: &mut Settings, + bus: &mut Bus, + ) -> (Option, bool) { + if let Event::Submit(ViewId::AutoFrontlightManualCoordinatesInput, text) = evt { + let display = self.apply_text(text, settings); + bus.push_back(Event::AutoFrontlightConfigChanged); + return (Some(display), true); + } + + (None, false) + } + + fn as_input_kind(&self) -> Option<&dyn InputSettingKind> { + Some(self) + } +} + +impl AutoFrontlightManualCoordinates { + fn display_value(&self, settings: &Settings) -> String { + settings + .auto_frontlight_manual_coordinates + .map(|coordinates| { + format!( + "{:.4}, {:.4}", + coordinates.latitude(), + coordinates.longitude() + ) + }) + .unwrap_or_else(|| fl!("settings-general-not-set")) + } +} + +impl InputSettingKind for AutoFrontlightManualCoordinates { + fn submit_view_id(&self) -> ViewId { + ViewId::AutoFrontlightManualCoordinatesInput + } + + fn open_entry_id(&self) -> EntryId { + EntryId::EditAutoFrontlightManualCoordinates + } + + fn input_label(&self) -> String { + fl!("settings-general-auto-frontlight-manual-coordinates-input") + } + + fn input_max_chars(&self) -> usize { + 32 + } + + fn current_text(&self, settings: &Settings) -> String { + settings + .auto_frontlight_manual_coordinates + .map(|coordinates| coordinates.to_string()) + .unwrap_or_default() + } + + fn apply_text(&self, text: &str, settings: &mut Settings) -> String { + let trimmed = text.trim(); + if trimmed.is_empty() { + settings.auto_frontlight_manual_coordinates = None; + return self.display_value(settings); + } + + let mut parts = trimmed.split(',').map(str::trim); + let parsed_coordinates = + parts + .next() + .zip(parts.next()) + .and_then(|(latitude, longitude)| { + let latitude = latitude.parse::().ok()?; + let longitude = longitude.parse::().ok()?; + Coordinates::new(latitude, longitude).ok() + }); + + if parsed_coordinates.is_some() && parts.next().is_none() { + settings.auto_frontlight_manual_coordinates = parsed_coordinates; + } + + self.display_value(settings) + } +} + /// Settings retention count setting pub struct SettingsRetention; @@ -714,6 +950,145 @@ mod tests { } } + mod auto_frontlight { + use super::*; + + #[test] + fn brightness_apply_text_parses_and_updates() { + let setting = AutoFrontlightBrightness; + let mut settings = Settings::default(); + let mut bus: Bus = VecDeque::new(); + + let display = setting.apply_text("25", &mut settings); + let result = setting.handle( + &Event::Submit(ViewId::AutoFrontlightBrightnessInput, "25".to_string()), + &mut settings, + &mut bus, + ); + + assert_eq!(display, "25%"); + assert_eq!(settings.auto_frontlight_night_brightness, Some(25.0.into())); + assert_eq!(result, (Some("25%".to_string()), true)); + assert!(matches!( + bus.pop_front(), + Some(Event::AutoFrontlightConfigChanged) + )); + } + + #[test] + fn brightness_apply_text_ignores_invalid_input() { + let setting = AutoFrontlightBrightness; + let mut settings = Settings { + auto_frontlight_night_brightness: Some(10.0.into()), + ..Default::default() + }; + let mut bus: Bus = VecDeque::new(); + + let display = setting.apply_text("invalid", &mut settings); + let result = setting.handle( + &Event::Submit(ViewId::AutoFrontlightBrightnessInput, "invalid".to_string()), + &mut settings, + &mut bus, + ); + + assert_eq!(display, "10%"); + assert_eq!(settings.auto_frontlight_night_brightness, Some(10.0.into())); + assert_eq!(result, (Some("10%".to_string()), true)); + assert!(matches!( + bus.pop_front(), + Some(Event::AutoFrontlightConfigChanged) + )); + } + + #[test] + fn manual_coordinates_apply_text_parses_and_updates() { + let setting = AutoFrontlightManualCoordinates; + let mut settings = Settings::default(); + let mut bus: Bus = VecDeque::new(); + + let display = setting.apply_text("51.5074, -0.1278", &mut settings); + let result = setting.handle( + &Event::Submit( + ViewId::AutoFrontlightManualCoordinatesInput, + "51.5074, -0.1278".to_string(), + ), + &mut settings, + &mut bus, + ); + + assert_eq!(display, "51.5074, -0.1278"); + assert_eq!( + settings.auto_frontlight_manual_coordinates, + Some(Coordinates::new(51.5074, -0.1278).unwrap()) + ); + assert_eq!(result, (Some("51.5074, -0.1278".to_string()), true)); + assert!(matches!( + bus.pop_front(), + Some(Event::AutoFrontlightConfigChanged) + )); + } + + #[test] + fn manual_coordinates_apply_text_clears_on_empty_input() { + let setting = AutoFrontlightManualCoordinates; + let mut settings = Settings { + auto_frontlight_manual_coordinates: Some( + Coordinates::new(51.5074, -0.1278).unwrap(), + ), + ..Default::default() + }; + let mut bus: Bus = VecDeque::new(); + + let display = setting.apply_text("", &mut settings); + let result = setting.handle( + &Event::Submit(ViewId::AutoFrontlightManualCoordinatesInput, "".to_string()), + &mut settings, + &mut bus, + ); + + assert_eq!(display, "Not set"); + assert_eq!(settings.auto_frontlight_manual_coordinates, None); + assert_eq!(result, (Some("Not set".to_string()), true)); + assert!(matches!( + bus.pop_front(), + Some(Event::AutoFrontlightConfigChanged) + )); + } + + #[test] + fn manual_coordinates_apply_text_ignores_invalid_input() { + let setting = AutoFrontlightManualCoordinates; + let mut settings = Settings { + auto_frontlight_manual_coordinates: Some( + Coordinates::new(51.5074, -0.1278).unwrap(), + ), + ..Default::default() + }; + let mut bus: Bus = VecDeque::new(); + + let display = setting.apply_text("invalid", &mut settings); + let result = setting.handle( + &Event::Submit( + ViewId::AutoFrontlightManualCoordinatesInput, + "invalid".to_string(), + ), + &mut settings, + &mut bus, + ); + + assert_eq!(display, "51.5074, -0.1278"); + assert_eq!( + settings.auto_frontlight_manual_coordinates, + Some(Coordinates::new(51.5074, -0.1278).unwrap()) + ); + assert_eq!(result, (Some("51.5074, -0.1278".to_string()), true)); + assert!(matches!( + bus.pop_front(), + Some(Event::AutoFrontlightConfigChanged) + )); + } + } + mod button_scheme { use super::*; use crate::settings::ButtonScheme; diff --git a/crates/core/src/view/settings_editor/kinds/identity.rs b/crates/core/src/view/settings_editor/kinds/identity.rs index 15be80d2..352fce00 100644 --- a/crates/core/src/view/settings_editor/kinds/identity.rs +++ b/crates/core/src/view/settings_editor/kinds/identity.rs @@ -21,6 +21,9 @@ pub enum SettingIdentity { SleepCover, AutoShare, AutoTime, + AutoFrontlight, + AutoFrontlightBrightness, + AutoFrontlightManualCoordinates, ButtonScheme, LoggingEnabled, FinishedAction, diff --git a/crates/core/src/view/settings_editor/kinds/mod.rs b/crates/core/src/view/settings_editor/kinds/mod.rs index e7baf9a4..30737609 100644 --- a/crates/core/src/view/settings_editor/kinds/mod.rs +++ b/crates/core/src/view/settings_editor/kinds/mod.rs @@ -37,6 +37,8 @@ pub enum ToggleSettings { AutoShare, /// Auto time sync enable/disable setting AutoTime, + /// Auto frontlight adjustment enable/disable setting + AutoFrontlight, /// Button scheme selection (natural or inverted) ButtonScheme, /// Logging enabled setting diff --git a/crates/emulator/src/main.rs b/crates/emulator/src/main.rs index 31acbe8b..9328c8ec 100644 --- a/crates/emulator/src/main.rs +++ b/crates/emulator/src/main.rs @@ -381,11 +381,19 @@ fn run() -> Result<(), Error> { if context.settings.frontlight { let levels = context.settings.frontlight_levels; - context.frontlight.set_intensity(levels.intensity); - context.frontlight.set_warmth(levels.warmth); + context + .frontlight + .set_intensity(levels.intensity) + .expect("failed to set emulator frontlight intensity"); + context + .frontlight + .set_warmth(levels.warmth) + .expect("failed to set emulator frontlight warmth"); } else { - context.frontlight.set_warmth(0.0); - context.frontlight.set_intensity(0.0); + context + .frontlight + .turn_off() + .expect("failed to turn off emulator frontlight"); } info!( diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 0dc9fd8a..9f4320bf 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -17,6 +17,7 @@ - [Library](library/index.md) - [Importing](library/importing.md) - [Automatic Time Syncing](time-sync.md) +- [Automatic Frontlight](auto-frontlight.md) - [Troubleshooting](troubleshooting/index.md) --- diff --git a/docs/src/auto-frontlight.md b/docs/src/auto-frontlight.md new file mode 100644 index 00000000..16c4a707 --- /dev/null +++ b/docs/src/auto-frontlight.md @@ -0,0 +1,81 @@ +# Automatic Frontlight + +Cadmus can automatically adjust your frontlight's warmth and brightness +throughout the day based on the position of the sun at your location. + +## How it works + +When enabled, Cadmus runs a background task that recalculates the ideal +frontlight levels every **5 minutes** and applies them immediately. The first +adjustment happens as soon as the feature starts, and then repeats on every 5-minute mark. + +The adjustment has two dimensions: + +- **Warmth** – zero warmth during the day, gradually ramping up to full warmth + over a 1.5-hour window around sunset, staying at full warmth through the night, + then ramping back down to zero over the 1.5-hour window before sunrise. +- **Brightness** – unchanged during the day (keeps whatever level you last set). + After sunset brightness drops to `auto-frontlight-night-brightness` and stays + there until sunrise. + +## Enabling automatic adjustment + +Open **Main Menu → Settings → General** and turn on **Auto Frontlight**. + +You can also enable it directly in your settings file: + +```toml +auto-frontlight = true +``` + +See [`auto-frontlight`](settings/index.md#auto-frontlight) and related entries +in the Settings reference for all available options. + +## Manual adjustments pause automation + +If you manually change the frontlight level while automatic adjustment is +running, Cadmus stops the background task so your chosen level is preserved. +Automatic adjustment resumes the next time the app is started. + +## Location detection + +Sun position is calculated from your geographic coordinates. Cadmus obtains +these automatically during each [time sync](time-sync.md): the same `ipapi.co` +lookup that resolves your timezone also returns a latitude and longitude, which +are saved to `auto-frontlight-last-coordinates` in your settings file. + +If no coordinates are available yet (for example, before the first successful +time sync), automatic adjustment is skipped until a location is known. + +### Manual coordinates override + +If you prefer not to rely on IP-based location, or if the detected location is +inaccurate, you can set your own coordinates: + +```toml +auto-frontlight-manual-coordinates = [51.5074, -0.1278] +``` + +Manual coordinates take priority over auto-detected ones. + +You can also edit this in **Main Menu → Settings → General** under **Auto +Frontlight Manual Coordinates**. + +## Night brightness + +The brightness level used after sunset defaults to `1.0` (1%). You can raise +this if you find the screen too dim for nighttime reading: + +```toml +auto-frontlight-night-brightness = 10.0 +``` + +The value is a percentage from `0.0` to `100.0`. + +## Privacy + +> [!NOTE] +> When [automatic time syncing](time-sync.md) is enabled, a request is sent +> to `ipapi.co` to resolve your timezone. That same response includes an +> approximate latitude and longitude derived from your public IP address, which +> Cadmus stores as `auto-frontlight-last-coordinates`. diff --git a/docs/src/settings/index.md b/docs/src/settings/index.md index abb19415..7ec03894 100644 --- a/docs/src/settings/index.md +++ b/docs/src/settings/index.md @@ -59,6 +59,56 @@ Automatically synchronize the device time via NTP when WiFi connects. This will auto-time = false ``` +### `auto-frontlight` + +✏️ + +Automatically adjust the frontlight warmth and brightness based on the sun's position at the device's location. + +- During the day warmth is at its minimum. +- Around sunrise and sunset warmth ramps gradually between zero and full. +- After sunset brightness is reduced to `auto-frontlight-night-brightness` and warmth stays at its maximum until sunrise. + +Coordinates are auto-detected during each time sync (via ipapi.co) and stored in `auto-frontlight-last-coordinates`. Set `auto-frontlight-manual-coordinates` to override the detected location. + +```toml +auto-frontlight = false +``` + +### `auto-frontlight-night-brightness` + +✏️ + +Frontlight brightness level (0.0–100.0) applied when the sun is below the horizon. + +This setting is optional. When not set, a default of `1.0` is used. + +```toml +auto-frontlight-night-brightness = 10.0 +``` + +### `auto-frontlight-manual-coordinates` + +✏️ + +GPS coordinates `[latitude, longitude]` to use for sun-position calculations instead of the auto-detected location. Takes priority over `auto-frontlight-last-coordinates`. + +This setting is optional. + +```toml +auto-frontlight-manual-coordinates = [51.5074, -0.1278] +``` + +### `auto-frontlight-last-coordinates` + +GPS coordinates `[latitude, longitude]` last detected during a time sync. Written automatically — do not edit this by hand; set `auto-frontlight-manual-coordinates` to override the location instead. + +This setting is optional and managed automatically. + +```toml +# auto-frontlight-last-coordinates = [48.8566, 2.3522] +``` + ### `auto-suspend` ✏️