diff --git a/cspell.yaml b/cspell.yaml index 620f3d705a..94c88df66a 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -55,6 +55,7 @@ words: - gsettings - HHMM - horiz + - hypot - ibus - iface - ifaces diff --git a/src/blocks/weather.rs b/src/blocks/weather.rs index e0d5e6311b..16eb37a0ec 100644 --- a/src/blocks/weather.rs +++ b/src/blocks/weather.rs @@ -15,6 +15,7 @@ //! ----|--------|-------- //! `service` | The configuration of a weather service (see below). | **Required** //! `format` | A string to customise the output of this block. See below for available placeholders. Text may need to be escaped, refer to [Escaping Text](#escaping-text). | `" $icon $weather $temp "` +//! `format_alt` | If set, block will switch between `format` and `format_alt` on every click | `None` //! `interval` | Update interval, in seconds. | `600` //! `autolocate` | Gets your location using the ipapi.co IP location service (no API key required). If the API call fails then the block will fallback to `city_id` or `place`. | `false` //! `autolocate_interval` | Update interval for `autolocate` in seconds or "once" | `interval` @@ -32,6 +33,7 @@ //! `coordinates` | GPS latitude longitude coordinates as a tuple, example: `["39.2362","9.3317"]` | Yes* | None //! `units` | Either `"metric"` or `"imperial"`. | No | `"metric"` //! `lang` | Language code. See [here](https://openweathermap.org/current#multi). Currently only affects `weather_verbose` key. | No | `"en"` +//! `forecast_hours` | How many hours should be forecast (must be increments of 3 hours, max 120 hours) | No | 12 //! //! One of `city_id`, `place` or `coordinates` is required. If more than one are supplied, `city_id` takes precedence over `place` which takes place over `coordinates`. //! @@ -39,6 +41,8 @@ //! in which case they must be provided in the environment variables //! `OPENWEATHERMAP_API_KEY`, `OPENWEATHERMAP_CITY_ID`, `OPENWEATHERMAP_PLACE`. //! +//! Forecasts are only fetched if forecast_hours > 0 and the format has keys related to forecast. +//! //! # met.no Options //! //! Key | Values | Required | Default @@ -47,23 +51,34 @@ //! `coordinates` | GPS latitude longitude coordinates as a tuple, example: `["39.2362","9.3317"]` | Required if `autolocate = false` | None //! `lang` | Language code: `en`, `nn` or `nb` | No | `en` //! `altitude` | Meters above sea level of the ground | No | Approximated by server +//! `forecast_hours` | How many hours should be forecast | No | 12 //! //! Met.no does not support location name. //! //! # Available Format Keys //! -//! Key | Value | Type | Unit -//! ------------------|--------------------------------------------------------------------|--------|----- -//! `icon` | Icon representing the weather | Icon | - -//! `location` | Location name (exact format depends on the service) | Text | - -//! `temp` | Temperature | Number | degrees -//! `apparent` | Australian Apparent Temperature | Number | degrees -//! `humidity` | Humidity | Number | % -//! `weather` | Textual brief description of the weather, e.g. "Raining" | Text | - -//! `weather_verbose` | Textual verbose description of the weather, e.g. "overcast clouds" | Text | - -//! `wind` | Wind speed | Number | - -//! `wind_kmh` | Wind speed. The wind speed in km/h | Number | - -//! `direction` | Wind direction, e.g. "NE" | Text | - +//! Key | Value | Type | Unit +//! --------------------|-------------------------------------------------------------------------------|--------|----- +//! `icon` | Icon representing the weather | Icon | - +//! `location` | Location name (exact format depends on the service) | Text | - +//! `temp` | Temperature | Number | degrees +//! `apparent` | Australian Apparent Temperature | Number | degrees +//! `humidity` | Humidity | Number | % +//! `weather` | Textual brief description of the weather, e.g. "Raining" | Text | - +//! `weather_verbose` | Textual verbose description of the weather, e.g. "overcast clouds" | Text | - +//! `wind` | Wind speed | Number | - +//! `wind_kmh` | Wind speed. The wind speed in km/h | Number | - +//! `direction` | Wind direction, e.g. "NE" | Text | - +//! `temp_forecast` | Temperature forecast (omitted if no forecast available) | Number | degrees +//! `apparent_forecast` | Australian Apparent Temperature forecast (omitted if no forecast available) | Number | degrees +//! `humidity_forecast` | Humidity forecast (omitted if no forecast available) | Number | % +//! `wind_forecast` | Wind speed forecast (omitted if no forecast available) | Number | - +//! `wind_kmh_forecast` | Wind speed forecast. The wind speed in km/h (omitted if no forecast available) | Number | - +//! `direction_forecast` | Wind direction forecast, e.g. "NE" (omitted if no forecast available) | Text | - +//! +//! Action | Description | Default button +//! ----------------|-------------------------------------------|--------------- +//! `toggle_format` | Toggles between `format` and `format_alt` | Left //! //! # Example //! @@ -73,11 +88,13 @@ //! [[block]] //! block = "weather" //! format = " $icon $weather ($location) $temp, $wind m/s $direction " +//! format_alt = " ^icon_weather_default Forecast (9 hour avg) {$temp_forecast $wind_forecast m/s $direction_forecast|Unavailable} " //! [block.service] //! name = "openweathermap" //! api_key = "XXX" //! city_id = "5398563" //! units = "metric" +//! forecast_hours = 9 //! ``` //! //! # Used Icons @@ -98,6 +115,8 @@ use std::fmt; use std::sync::{Arc, Mutex}; use std::time::Instant; +use crate::formatting::Format; + use super::prelude::*; pub mod met_no; @@ -114,6 +133,7 @@ pub struct Config { pub interval: Seconds, #[serde(default)] pub format: FormatConfig, + pub format_alt: Option, pub service: WeatherService, #[serde(default)] pub autolocate: bool, @@ -126,8 +146,11 @@ fn default_interval() -> Seconds { #[async_trait] trait WeatherProvider { - async fn get_weather(&self, autolocated_location: Option) - -> Result; + async fn get_weather( + &self, + autolocated_location: Option, + need_forecast: bool, + ) -> Result; } #[derive(Deserialize, Debug)] @@ -167,6 +190,32 @@ impl WeatherIcon { } } +#[derive(Debug)] +struct Wind { + speed: f64, + degrees: Option, +} + +impl PartialEq for Wind { + fn eq(&self, other: &Self) -> bool { + (self.speed - other.speed).abs() < 0.001 + && match (self.degrees, other.degrees) { + (Some(degrees0), Some(degrees1)) => (degrees0 - degrees1).abs() < 0.001, + (None, None) => true, + _ => false, + } + } +} + +struct Forecast { + temp: f64, + apparent: f64, + humidity: f64, + wind: f64, + wind_kmh: f64, + wind_direction: Option, +} + struct WeatherResult { location: String, temp: f64, @@ -176,13 +225,14 @@ struct WeatherResult { weather_verbose: String, wind: f64, wind_kmh: f64, - wind_direction: String, + wind_direction: Option, + forecast: Option, icon: WeatherIcon, } impl WeatherResult { fn into_values(self) -> Values { - map! { + let mut values = map! { "icon" => Value::icon(self.icon.to_icon_str()), "location" => Value::text(self.location), "temp" => Value::degrees(self.temp), @@ -192,13 +242,31 @@ impl WeatherResult { "weather_verbose" => Value::text(self.weather_verbose), "wind" => Value::number(self.wind), "wind_kmh" => Value::number(self.wind_kmh), - "direction" => Value::text(self.wind_direction), + "direction" => Value::text(convert_wind_direction(self.wind_direction).into()), + }; + if let Some(forecast) = &self.forecast { + values.extend(map! { + "temp_forecast" => Value::degrees(forecast.temp), + "apparent_forecast" => Value::degrees(forecast.apparent), + "humidity_forecast" => Value::percents(forecast.humidity), + "wind_forecast" => Value::number(forecast.wind), + "wind_kmh_forecast" => Value::number(forecast.wind_kmh), + "direction_forecast" => Value::text(convert_wind_direction(forecast.wind_direction).into()), + }); } + values } } pub async fn run(config: &Config, api: &CommonApi) -> Result<()> { - let format = config.format.with_default(" $icon $weather $temp ")?; + let mut actions = api.get_actions()?; + api.set_default_actions(&[(MouseButton::Left, None, "toggle_format")])?; + + let mut format = config.format.with_default(" $icon $weather $temp ")?; + let mut format_alt = match &config.format_alt { + Some(f) => Some(f.with_default("")?), + None => None, + }; let provider: Box = match &config.service { WeatherService::MetNo(config) => Box::new(met_no::Service::new(config)?), @@ -206,6 +274,9 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> { }; let autolocate_interval = config.autolocate_interval.unwrap_or(config.interval); + let need_forecast = need_forecast(&format, &format_alt); + + let mut timer = config.interval.timer(); loop { let location = if config.autolocate { @@ -215,20 +286,43 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> { None }; - let fetch = || provider.get_weather(location); + let fetch = || provider.get_weather(location, need_forecast); let data = fetch.retry(&ExponentialBuilder::default()).await?; - - let mut widget = Widget::new().with_format(format.clone()); - widget.set_values(data.into_values()); - api.set_widget(widget)?; - - select! { - _ = sleep(config.interval.0) => (), - _ = api.wait_for_update_request() => () + let data_values = data.into_values(); + + loop { + let mut widget = Widget::new().with_format(format.clone()); + widget.set_values(data_values.clone()); + api.set_widget(widget)?; + + select! { + _ = timer.tick() => break, + _ = api.wait_for_update_request() => break, + Some(action) = actions.recv() => match action.as_ref() { + "toggle_format" => { + if let Some(ref mut format_alt) = format_alt { + std::mem::swap(format_alt, &mut format); + } + } + _ => (), + } + } } } } +fn need_forecast(format: &Format, format_alt: &Option) -> bool { + fn has_forecast_key(format: &Format) -> bool { + format.contains_key("temp_forecast") + || format.contains_key("apparent_forecast") + || format.contains_key("humidity_forecast") + || format.contains_key("wind_forecast") + || format.contains_key("wind_kmh_forecast") + || format.contains_key("direction_forecast") + } + has_forecast_key(format) || format_alt.as_ref().is_some_and(has_forecast_key) +} + #[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, SmartDefault)] #[serde(rename_all = "lowercase")] enum UnitSystem { @@ -331,9 +425,116 @@ fn convert_wind_direction(direction_opt: Option) -> &'static str { } } +// Compute the average wind speed and direction +fn average_wind(winds: &[Wind]) -> Wind { + let mut north = 0.0; + let mut east = 0.0; + let mut count = 0.0; + for wind in winds { + if let Some(degrees) = wind.degrees { + let (sin, cos) = degrees.to_radians().sin_cos(); + north += wind.speed * cos; + east += wind.speed * sin; + count += 1.0; + } + } + if count == 0.0 { + Wind { + speed: 0.0, + degrees: None, + } + } else { + Wind { + speed: east.hypot(north) / count, + degrees: Some(east.atan2(north).to_degrees().rem_euclid(360.0)), + } + } +} + /// Compute the Australian Apparent Temperature from metric units fn australian_apparent_temp(temp: f64, humidity: f64, wind_speed: f64) -> f64 { let exponent = 17.27 * temp / (237.7 + temp); let water_vapor_pressure = humidity * 0.06105 * exponent.exp(); temp + 0.33 * water_vapor_pressure - 0.7 * wind_speed - 4.0 } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_average_wind_speed() { + let mut degrees = 0.0; + while degrees < 360.0 { + let averaged = average_wind(&[ + Wind { + speed: 1.0, + degrees: Some(degrees), + }, + Wind { + speed: 2.0, + degrees: Some(degrees), + }, + ]); + assert_eq!( + averaged, + Wind { + speed: 1.5, + degrees: Some(degrees) + } + ); + + degrees += 15.0; + } + } + + #[test] + fn test_average_wind_degrees() { + let mut degrees = 0.0; + while degrees < 360.0 { + let low = degrees - 1.0; + let high = degrees + 1.0; + let averaged = average_wind(&[ + Wind { + speed: 1.0, + degrees: Some(low), + }, + Wind { + speed: 1.0, + degrees: Some(high), + }, + ]); + // For winds of equal strength the direction should will be the + // average of the low and high degrees + assert!((averaged.degrees.unwrap() - degrees).abs() < 0.1); + + degrees += 15.0; + } + } + + #[test] + fn test_average_wind_speed_and_degrees() { + let mut degrees = 0.0; + while degrees < 360.0 { + let low = degrees - 1.0; + let high = degrees + 1.0; + let averaged = average_wind(&[ + Wind { + speed: 1.0, + degrees: Some(low), + }, + Wind { + speed: 2.0, + degrees: Some(high), + }, + ]); + // Wind degree will be higher than the centerpoint of the low + // and high winds since the high wind is stronger and will be + // less than high + // (low+high)/2 < average.degrees < high + assert!((low + high) / 2.0 < averaged.degrees.unwrap()); + assert!(averaged.degrees.unwrap() < high); + degrees += 15.0; + } + } +} diff --git a/src/blocks/weather/met_no.rs b/src/blocks/weather/met_no.rs index 4543dc2e92..35bf197532 100644 --- a/src/blocks/weather/met_no.rs +++ b/src/blocks/weather/met_no.rs @@ -2,13 +2,15 @@ use super::*; type LegendsStore = HashMap; -#[derive(Deserialize, Debug)] -#[serde(tag = "name", rename_all = "lowercase")] +#[derive(Deserialize, Debug, SmartDefault)] +#[serde(tag = "name", rename_all = "lowercase", deny_unknown_fields, default)] pub struct Config { coordinates: Option<(String, String)>, altitude: Option, #[serde(default)] lang: ApiLanguage, + #[default(12)] + forecast_hours: usize, } pub(super) struct Service<'a> { @@ -109,11 +111,16 @@ fn translate(legend: &LegendsStore, summary: &str, lang: &ApiLanguage) -> String #[async_trait] impl WeatherProvider for Service<'_> { - async fn get_weather(&self, location: Option) -> Result { + async fn get_weather( + &self, + location: Option, + need_forecast: bool, + ) -> Result { let Config { coordinates, altitude, lang, + forecast_hours, } = &self.config; let (lat, lon) = location @@ -164,6 +171,53 @@ impl WeatherProvider for Service<'_> { let humidity = instant.relative_humidity.unwrap_or_default(); let wind_speed = instant.wind_speed.unwrap_or_default(); + let forecast = if !need_forecast || *forecast_hours == 0 { + None + } else { + let mut temp_forecast = 0.0; + let mut temp_forecast_count = 0.0; + let mut humidity_forecast = 0.0; + let mut humidity_forecast_count = 0.0; + let mut wind_forecasts = Vec::new(); + if data.properties.timeseries.len() < *forecast_hours { + Err(Error::new( + format!("Unable to fetch the specified number of forecast_hours specified {}, only {} hours available", *forecast_hours, data.properties.timeseries.len()), + ))?; + } + for forecast_time_step in data.properties.timeseries.iter().take(*forecast_hours) { + let forecast_instant = &forecast_time_step.data.instant.details; + if let Some(air_temperature) = forecast_instant.air_temperature { + temp_forecast += air_temperature; + temp_forecast_count += 1.0; + } + if let Some(relative_humidity) = forecast_instant.relative_humidity { + humidity_forecast += relative_humidity; + humidity_forecast_count += 1.0; + } + if let Some(wind_speed) = forecast_instant.wind_speed { + wind_forecasts.push(Wind { + speed: wind_speed, + degrees: forecast_instant.wind_from_direction, + }); + } + } + temp_forecast /= temp_forecast_count; + humidity_forecast /= humidity_forecast_count; + let Wind { + speed: wind_forecast, + degrees: direction_forecast, + } = average_wind(&wind_forecasts); + + Some(Forecast { + temp: temp_forecast, + apparent: australian_apparent_temp(temp_forecast, humidity_forecast, wind_forecast), + humidity, + wind: wind_forecast, + wind_kmh: wind_forecast * 3.6, + wind_direction: direction_forecast, + }) + }; + Ok(WeatherResult { location: "Unknown".to_string(), temp, @@ -172,8 +226,9 @@ impl WeatherProvider for Service<'_> { weather: translated.clone(), weather_verbose: translated, wind: wind_speed, - wind_kmh: instant.wind_speed.unwrap_or_default() * 3.6, - wind_direction: convert_wind_direction(instant.wind_from_direction).into(), + wind_kmh: wind_speed * 3.6, + wind_direction: instant.wind_from_direction, + forecast, icon: weather_to_icon(summary, is_night), }) } diff --git a/src/blocks/weather/open_weather_map.rs b/src/blocks/weather/open_weather_map.rs index 6293ba1031..e6e681ded2 100644 --- a/src/blocks/weather/open_weather_map.rs +++ b/src/blocks/weather/open_weather_map.rs @@ -1,13 +1,15 @@ use super::*; use chrono::Utc; +use serde::{de, Deserializer}; -pub(super) const URL: &str = "https://api.openweathermap.org/data/2.5/weather"; +pub(super) const CURRENT_URL: &str = "https://api.openweathermap.org/data/2.5/weather"; +pub(super) const FORECAST_URL: &str = "https://api.openweathermap.org/data/2.5/forecast"; pub(super) const API_KEY_ENV: &str = "OPENWEATHERMAP_API_KEY"; pub(super) const CITY_ID_ENV: &str = "OPENWEATHERMAP_CITY_ID"; pub(super) const PLACE_ENV: &str = "OPENWEATHERMAP_PLACE"; -#[derive(Deserialize, Debug)] -#[serde(tag = "name", rename_all = "lowercase")] +#[derive(Deserialize, Debug, SmartDefault)] +#[serde(tag = "name", rename_all = "lowercase", deny_unknown_fields, default)] pub struct Config { #[serde(default = "getenv_openweathermap_api_key")] api_key: Option, @@ -18,8 +20,30 @@ pub struct Config { coordinates: Option<(String, String)>, #[serde(default)] units: UnitSystem, - #[serde(default = "default_lang")] + #[default("en")] lang: String, + #[default(12)] + #[serde(deserialize_with = "deserialize_forecast_hours")] + forecast_hours: usize, +} + +pub fn deserialize_forecast_hours<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + usize::deserialize(deserializer).and_then(|hours| { + if hours % 3 != 0 && hours > 120 { + Err(de::Error::custom( + "'forecast_hours' is not divisible by 3 and must be <= 120", + )) + } else if hours % 3 != 0 { + Err(de::Error::custom("'forecast_hours' is not divisible by 3")) + } else if hours > 120 { + Err(de::Error::custom("'forecast_hours' must be <= 120")) + } else { + Ok(hours) + } + }) } pub(super) struct Service<'a> { @@ -41,12 +65,21 @@ fn getenv_openweathermap_city_id() -> Option { fn getenv_openweathermap_place() -> Option { std::env::var(PLACE_ENV).ok() } -fn default_lang() -> String { - "en".into() + +#[derive(Deserialize, Debug)] +struct ApiForecastResponse { + list: Vec, +} + +#[derive(Deserialize, Debug)] +struct ApiInstantResponse { + // weather: Vec, + main: ApiMain, + wind: ApiWind, } #[derive(Deserialize, Debug)] -struct ApiResponse { +struct ApiCurrentResponse { weather: Vec, main: ApiMain, wind: ApiWind, @@ -81,7 +114,11 @@ struct ApiWeather { #[async_trait] impl WeatherProvider for Service<'_> { - async fn get_weather(&self, autolocated: Option) -> Result { + async fn get_weather( + &self, + autolocated: Option, + need_forecast: bool, + ) -> Result { let api_key = self.config.api_key.as_ref().or_error(|| { format!("missing key 'service.api_key' and environment variable {API_KEY_ENV}",) })?; @@ -99,8 +136,8 @@ impl WeatherProvider for Service<'_> { .error("no location was provided")?; // Refer to https://openweathermap.org/current - let url = format!( - "{URL}?{location_query}&appid={api_key}&units={units}&lang={lang}", + let current_url = format!( + "{CURRENT_URL}?{location_query}&appid={api_key}&units={units}&lang={lang}", units = match self.config.units { UnitSystem::Metric => "metric", UnitSystem::Imperial => "imperial", @@ -108,33 +145,97 @@ impl WeatherProvider for Service<'_> { lang = self.config.lang, ); - let data: ApiResponse = REQWEST_CLIENT - .get(url) + let current_data: ApiCurrentResponse = REQWEST_CLIENT + .get(current_url) .send() .await - .error("Forecast request failed")? + .error("Current weather request failed")? .json() .await - .error("Forecast request failed")?; + .error("Current weather request failed")?; let now = Utc::now().timestamp(); - let is_night = data.sys.sunrise >= now || now >= data.sys.sunset; + let is_night = current_data.sys.sunrise >= now || now >= current_data.sys.sunset; + + let forecast = if !need_forecast || self.config.forecast_hours == 0 { + None + } else { + let mut temp_forecast = 0.0; + let mut apparent_forecast = 0.0; + let mut humidity_forecast = 0.0; + let mut wind_forecasts = Vec::new(); + // Refer to https://openweathermap.org/forecast5 + let forecast_url = format!( + "{FORECAST_URL}?{location_query}&appid={api_key}&units={units}&lang={lang}&cnt={cnt}", + units = match self.config.units { + UnitSystem::Metric => "metric", + UnitSystem::Imperial => "imperial", + }, + lang = self.config.lang, + cnt = self.config.forecast_hours / 3, + ); + + let forecast_data: ApiForecastResponse = REQWEST_CLIENT + .get(forecast_url) + .send() + .await + .error("Forecast weather request failed")? + .json() + .await + .error("Forecast weather request failed")?; + + let mut forecast_count = 0.0; + for forecast_instant in forecast_data.list { + let instant_main = forecast_instant.main; + temp_forecast += instant_main.temp; + apparent_forecast += instant_main.feels_like; + humidity_forecast += instant_main.humidity; + forecast_count += 1.0; + + let instant_wind = forecast_instant.wind; + wind_forecasts.push(Wind { + speed: instant_wind.speed, + degrees: instant_wind.deg, + }); + } + temp_forecast /= forecast_count; + apparent_forecast /= forecast_count; + humidity_forecast /= forecast_count; + let Wind { + speed: wind_forecast, + degrees: direction_forecast, + } = average_wind(&wind_forecasts); + + Some(Forecast { + temp: temp_forecast, + apparent: apparent_forecast, + humidity: humidity_forecast, + wind: wind_forecast, + wind_kmh: wind_forecast + * match self.config.units { + UnitSystem::Metric => 3.6, + UnitSystem::Imperial => 3.6 * 0.447, + }, + wind_direction: direction_forecast, + }) + }; Ok(WeatherResult { - location: data.name, - temp: data.main.temp, - apparent: data.main.feels_like, - humidity: data.main.humidity, - weather: data.weather[0].main.clone(), - weather_verbose: data.weather[0].description.clone(), - wind: data.wind.speed, - wind_kmh: data.wind.speed + location: current_data.name, + temp: current_data.main.temp, + apparent: current_data.main.feels_like, + humidity: current_data.main.humidity, + weather: current_data.weather[0].main.clone(), + weather_verbose: current_data.weather[0].description.clone(), + wind: current_data.wind.speed, + wind_kmh: current_data.wind.speed * match self.config.units { UnitSystem::Metric => 3.6, UnitSystem::Imperial => 3.6 * 0.447, }, - wind_direction: convert_wind_direction(data.wind.deg).into(), - icon: match data.weather[0].main.as_str() { + wind_direction: current_data.wind.deg, + forecast, + icon: match current_data.weather[0].main.as_str() { "Clear" => WeatherIcon::Clear { is_night }, "Rain" | "Drizzle" => WeatherIcon::Rain { is_night }, "Clouds" => WeatherIcon::Clouds { is_night },