From 6de9953f7fdf042de5d82ef1466af6fc7e9c8d25 Mon Sep 17 00:00:00 2001 From: Bryan Malyn Date: Mon, 4 Sep 2023 16:35:50 -0500 Subject: [PATCH] Add weather forecast Only include forecast if needed Forecasts are only fetched if forecast_hours > 0 and the format has keys related to forecast --- cspell.yaml | 4 + src/blocks/weather.rs | 334 ++++++++++++++++++++++--- src/blocks/weather/met_no.rs | 197 +++++++++++---- src/blocks/weather/open_weather_map.rs | 263 ++++++++++++++++--- src/util.rs | 8 + 5 files changed, 686 insertions(+), 120 deletions(-) diff --git a/cspell.yaml b/cspell.yaml index b20af0eed8..ce603512c0 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -45,7 +45,10 @@ words: - docsrs - dunst - execvp + - favg - fcntl + - fmax + - fmin - getaddr - getenv - gibi @@ -55,6 +58,7 @@ words: - gsettings - HHMM - horiz + - hypot - ibus - iface - ifaces diff --git a/src/blocks/weather.rs b/src/blocks/weather.rs index 561ffa39a5..68175725db 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 service specific location config. | `false` //! `autolocate_interval` | Update interval for `autolocate` in seconds or "once" | `interval` @@ -33,6 +34,7 @@ //! `zip` | OpenWeatherMap 'By {zip code},{country code}' search query. See [here](https://openweathermap.org/api/geocoding-api#direct_zip). Consumes an additional API call | 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 `coordinates`, `city_id`, `place`, or `zip` is required. If more than one are supplied, `coordinates` takes precedence over `city_id` which takes precedence over `place` which takes precedence over `zip`. //! @@ -40,6 +42,8 @@ //! in which case they must be provided in the environment variables //! `OPENWEATHERMAP_API_KEY`, `OPENWEATHERMAP_CITY_ID`, `OPENWEATHERMAP_PLACE`, `OPENWEATHERMAP_ZIP`. //! +//! Forecasts are only fetched if forecast_hours > 0 and the format has keys related to forecast. +//! //! # met.no Options //! //! Key | Values | Required | Default @@ -48,23 +52,46 @@ //! `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 +//! ---------------------------------------------|-------------------------------------------------------------------------------|--------|----- +//! `location` | Location name (exact format depends on the service) | Text | - +//! `icon{,_ffin}` | Icon representing the weather | Icon | - +//! `weather{,_ffin}` | Textual brief description of the weather, e.g. "Raining" | Text | - +//! `weather_verbose{,_ffin}` | Textual verbose description of the weather, e.g. "overcast clouds" | Text | - +//! `temp{,_{favg,fmin,fmax,ffin}}` | Temperature | Number | degrees +//! `apparent{,_{favg,fmin,fmax,ffin}}` | Australian Apparent Temperature | Number | degrees +//! `humidity{,_{favg,fmin,fmax,ffin}}` | Humidity | Number | % +//! `wind{,_{favg,fmin,fmax,ffin}}` | Wind speed | Number | - +//! `wind_kmh{,_{favg,fmin,fmax,ffin}}` | Wind speed. The wind speed in km/h | Number | - +//! `direction{,_{favg,fmin,fmax,ffin}}` | Wind direction, e.g. "NE" | Text | - +//! `temp_forecast{,_{favg,fmin,fmax,ffin}}` | Temperature forecast (omitted if no forecast available) | Number | degrees +//! `apparent_forecast{,_{favg,fmin,fmax,ffin}}` | Australian Apparent Temperature forecast (omitted if no forecast available) | Number | degrees +//! `humidity_forecast{,_{favg,fmin,fmax,ffin}}` | Humidity forecast (omitted if no forecast available) | Number | % +//! `wind_forecast{,_{favg,fmin,fmax,ffin}}` | Wind speed forecast (omitted if no forecast available) | Number | - +//! `wind_kmh_forecast{,_{favg,fmin,fmax,ffin}}` | Wind speed forecast. The wind speed in km/h (omitted if no forecast available) | Number | - +//! `direction_forecast{,_{favg,fmin,fmax,ffin}}` | Wind direction forecast, e.g. "NE" (omitted if no forecast available) | Text | - +//! +//! +//! You can use the suffixes noted above to get the following: +//! +//! Suffix | Description +//! ----------|------------ +//! None | Current weather +//! `_favg` | Average forecast value +//! `_fmin` | Minimum forecast value +//! `_fmax` | Maximum forecast value +//! `_ffin` | Final forecast value +//! +//! +//! Action | Description | Default button +//! ----------------|-------------------------------------------|--------------- +//! `toggle_format` | Toggles between `format` and `format_alt` | Left //! //! # Example //! @@ -74,11 +101,13 @@ //! [[block]] //! block = "weather" //! format = " $icon $weather ($location) $temp, $wind m/s $direction " +//! format_alt = " $icon_ffin Forecast (9 hour avg) {$temp_favg ({$temp_fmin}-{$temp_fmax})|Unavailable} " //! [block.service] //! name = "openweathermap" //! api_key = "XXX" //! city_id = "5398563" //! units = "metric" +//! forecast_hours = 9 //! ``` //! //! # Used Icons @@ -99,6 +128,8 @@ use std::fmt; use std::sync::{Arc, Mutex}; use std::time::Instant; +use crate::formatting::Format; + use super::prelude::*; pub mod met_no; @@ -115,6 +146,7 @@ pub struct Config { pub interval: Seconds, #[serde(default)] pub format: FormatConfig, + pub format_alt: Option, pub service: WeatherService, #[serde(default)] pub autolocate: bool, @@ -127,8 +159,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)] @@ -168,38 +203,114 @@ impl WeatherIcon { } } -struct WeatherResult { - location: String, +#[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 WeatherMoment { + icon: WeatherIcon, + weather: String, + weather_verbose: String, temp: f64, apparent: f64, humidity: f64, - weather: String, - weather_verbose: String, wind: f64, wind_kmh: f64, - wind_direction: String, - icon: WeatherIcon, + wind_direction: Option, +} +struct ForecastAggregate { + temp: f64, + apparent: f64, + humidity: f64, + wind: f64, + wind_kmh: f64, + wind_direction: Option, +} + +struct WeatherResult { + location: String, + current_weather: WeatherMoment, + forecast: Option, +} + +struct Forecast { + avg: ForecastAggregate, + min: ForecastAggregate, + max: ForecastAggregate, + fin: WeatherMoment, } impl WeatherResult { fn into_values(self) -> Values { - map! { - "icon" => Value::icon(self.icon.to_icon_str()), + let mut values = map! { "location" => Value::text(self.location), - "temp" => Value::degrees(self.temp), - "apparent" => Value::degrees(self.apparent), - "humidity" => Value::percents(self.humidity), - "weather" => Value::text(self.weather), - "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), + //current_weather + "icon" => Value::icon(self.current_weather.icon.to_icon_str()), + "temp" => Value::degrees(self.current_weather.temp), + "apparent" => Value::degrees(self.current_weather.apparent), + "humidity" => Value::percents(self.current_weather.humidity), + "weather" => Value::text(self.current_weather.weather), + "weather_verbose" => Value::text(self.current_weather.weather_verbose), + "wind" => Value::number(self.current_weather.wind), + "wind_kmh" => Value::number(self.current_weather.wind_kmh), + "direction" => Value::text(convert_wind_direction(self.current_weather.wind_direction).into()), + }; + + if let Some(forecast) = self.forecast { + macro_rules! map_forecasts { + ({$($suffix: literal => $src: expr),* $(,)?}) => { + values.extend(map!{ + $( + concat!("temp_f", $suffix) => Value::degrees($src.temp), + concat!("apparent_f", $suffix) => Value::degrees($src.apparent), + concat!("humidity_f", $suffix) => Value::percents($src.humidity), + concat!("wind_f", $suffix) => Value::number($src.wind), + concat!("wind_kmh_f", $suffix) => Value::number($src.wind_kmh), + concat!("direction_f", $suffix) => Value::text(convert_wind_direction($src.wind_direction).into()), + )* + }); + }; + } + map_forecasts!({ + "avg" => forecast.avg, + "min" => forecast.min, + "max" => forecast.max, + "fin" => forecast.fin, + }); + + values.extend(map! { + "icon_ffin" => Value::icon(forecast.fin.icon.to_icon_str()), + "weather_ffin" => Value::text(forecast.fin.weather.clone()), + "weather_verbose_ffin" => Value::text(forecast.fin.weather_verbose.clone()), + + }); } + 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(service_config) => Box::new(met_no::Service::new(service_config)?), @@ -209,6 +320,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 { @@ -218,18 +332,53 @@ 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 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); + } + } + _ => (), + } + } + } + } +} - 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() => () +fn need_forecast(format: &Format, format_alt: &Option) -> bool { + fn has_forecast_key(format: &Format) -> bool { + macro_rules! format_suffix { + ($($suffix: literal),* $(,)?) => { + false + $( + || format.contains_key(concat!("temp_f", $suffix)) + || format.contains_key(concat!("apparent_f", $suffix)) + || format.contains_key(concat!("humidity_f", $suffix)) + || format.contains_key(concat!("wind_f", $suffix)) + || format.contains_key(concat!("wind_kmh_f", $suffix)) + || format.contains_key(concat!("direction_f", $suffix)) + )* + }; } + + format_suffix!("avg", "min", "max", "fin") + || format.contains_key("icon_ffin") + || format.contains_key("weather_ffin") + || format.contains_key("weather_verbose_ffin") } + has_forecast_key(format) || format_alt.as_ref().is_some_and(has_forecast_key) } #[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, SmartDefault)] @@ -332,9 +481,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..adf683516b 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> { @@ -23,6 +25,43 @@ impl<'a> Service<'a> { legend: LEGENDS.as_ref().error("Invalid legends file")?, }) } + + fn get_weather_instant(&self, forecast_data: &ForecastData) -> WeatherMoment { + let instant = &forecast_data.instant.details; + + let mut symbol_code_split = forecast_data + .next_1_hours + .as_ref() + .unwrap() + .summary + .symbol_code + .split('_'); + + let summary = symbol_code_split.next().unwrap(); + + // Times of day can be day, night, and polartwilight + let is_night = symbol_code_split + .next() + .map_or(false, |time_of_day| time_of_day == "night"); + + let translated = translate(self.legend, summary, &self.config.lang); + + let temp = instant.air_temperature.unwrap_or_default(); + let humidity = instant.relative_humidity.unwrap_or_default(); + let wind_speed = instant.wind_speed.unwrap_or_default(); + + WeatherMoment { + temp, + apparent: australian_apparent_temp(temp, humidity, wind_speed), + humidity, + weather: translated.clone(), + weather_verbose: translated, + wind: wind_speed, + wind_kmh: wind_speed * 3.6, + wind_direction: instant.wind_from_direction, + icon: weather_to_icon(summary, is_night), + } + } } #[derive(Deserialize)] @@ -109,23 +148,21 @@ fn translate(legend: &LegendsStore, summary: &str, lang: &ApiLanguage) -> String #[async_trait] impl WeatherProvider for Service<'_> { - async fn get_weather(&self, location: Option) -> Result { - let Config { - coordinates, - altitude, - lang, - } = &self.config; - + async fn get_weather( + &self, + location: Option, + need_forecast: bool, + ) -> Result { let (lat, lon) = location .as_ref() .map(|loc| (loc.latitude.to_string(), loc.longitude.to_string())) - .or_else(|| coordinates.clone()) + .or_else(|| self.config.coordinates.clone()) .error("No location given")?; let querystr: HashMap<&str, String> = map! { "lat" => lat, "lon" => lon, - [if let Some(alt) = altitude] "altitude" => alt, + [if let Some(alt) = &self.config.altitude] "altitude" => alt, }; let data: ForecastResponse = REQWEST_CLIENT @@ -139,42 +176,118 @@ impl WeatherProvider for Service<'_> { .await .error("Forecast request failed")?; - let first = &data.properties.timeseries.first().unwrap().data; - - let instant = &first.instant.details; - - let mut symbol_code_split = first - .next_1_hours - .as_ref() - .unwrap() - .summary - .symbol_code - .split('_'); - - let summary = symbol_code_split.next().unwrap(); - - // Times of day can be day, night, and polartwilight - let is_night = symbol_code_split - .next() - .map_or(false, |time_of_day| time_of_day == "night"); + let forecast_hours = self.config.forecast_hours; - let translated = translate(self.legend, summary, lang); + let forecast = if !need_forecast || forecast_hours == 0 { + None + } else { + let mut temp_avg = 0.0; + let mut temp_min = f64::MAX; + let mut temp_max = f64::MIN; + let mut temp_count = 0.0; + let mut humidity_avg = 0.0; + let mut humidity_min = f64::MAX; + let mut humidity_max = f64::MIN; + let mut humidity_count = 0.0; + let mut wind_forecasts = Vec::new(); + let mut apparent_avg = 0.0; + let mut apparent_min = f64::MAX; + let mut apparent_max = f64::MIN; + let mut apparent_count = 0.0; + 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_avg += air_temperature; + temp_min = temp_min.min(air_temperature); + temp_max = temp_max.max(air_temperature); + temp_count += 1.0; + } + if let Some(relative_humidity) = forecast_instant.relative_humidity { + humidity_avg += relative_humidity; + humidity_min = humidity_min.min(relative_humidity); + humidity_max = humidity_max.max(relative_humidity); + humidity_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, + }); + } + if let (Some(air_temperature), Some(relative_humidity), Some(wind_speed)) = ( + forecast_instant.air_temperature, + forecast_instant.relative_humidity, + forecast_instant.wind_speed, + ) { + let apparent = + australian_apparent_temp(air_temperature, relative_humidity, wind_speed); + apparent_avg += apparent; + apparent_min = apparent_min.min(apparent); + apparent_max = apparent_max.max(apparent); + apparent_count += 1.0; + } + } + temp_avg /= temp_count; + humidity_avg /= humidity_count; + apparent_avg /= apparent_count; + let Wind { + speed: wind_avg, + degrees: direction_avg, + } = average_wind(&wind_forecasts); + let Wind { + speed: wind_min, + degrees: direction_min, + } = wind_forecasts + .iter() + .min_by(|x, y| x.speed.total_cmp(&y.speed)) + .error("No min wind")?; + let Wind { + speed: wind_max, + degrees: direction_max, + } = wind_forecasts + .iter() + .min_by(|x, y| x.speed.total_cmp(&y.speed)) + .error("No max wind")?; - let temp = instant.air_temperature.unwrap_or_default(); - let humidity = instant.relative_humidity.unwrap_or_default(); - let wind_speed = instant.wind_speed.unwrap_or_default(); + Some(Forecast { + avg: ForecastAggregate { + temp: temp_avg, + apparent: apparent_avg, + humidity: humidity_avg, + wind: wind_avg, + wind_kmh: wind_avg * 3.6, + wind_direction: direction_avg, + }, + min: ForecastAggregate { + temp: temp_min, + apparent: apparent_min, + humidity: humidity_min, + wind: *wind_min, + wind_kmh: wind_min * 3.6, + wind_direction: *direction_min, + }, + max: ForecastAggregate { + temp: temp_max, + apparent: apparent_max, + humidity: humidity_max, + wind: *wind_max, + wind_kmh: wind_max * 3.6, + wind_direction: *direction_max, + }, + fin: self.get_weather_instant(&data.properties.timeseries[forecast_hours - 1].data), + }) + }; Ok(WeatherResult { location: "Unknown".to_string(), - temp, - apparent: australian_apparent_temp(temp, humidity, wind_speed), - humidity, - 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(), - icon: weather_to_icon(summary, is_night), + current_weather: self + .get_weather_instant(&data.properties.timeseries.first().unwrap().data), + forecast, }) } } diff --git a/src/blocks/weather/open_weather_map.rs b/src/blocks/weather/open_weather_map.rs index 9ebfa4c90f..ec9a73acd7 100644 --- a/src/blocks/weather/open_weather_map.rs +++ b/src/blocks/weather/open_weather_map.rs @@ -1,15 +1,16 @@ 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 GEO_URL: &str = "https://api.openweathermap.org/geo/1.0"; +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"; pub(super) const ZIP_ENV: &str = "OPENWEATHERMAP_ZIP"; -#[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, @@ -22,8 +23,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> { @@ -31,6 +54,7 @@ pub(super) struct Service<'a> { units: &'a UnitSystem, lang: &'a String, location_query: Option, + forecast_hours: usize, } impl<'a> Service<'a> { @@ -43,6 +67,7 @@ impl<'a> Service<'a> { units: &config.units, lang: &config.lang, location_query: Service::get_location_query(autolocate, api_key, config).await?, + forecast_hours: config.forecast_hours, }) } @@ -118,17 +143,28 @@ fn getenv_openweathermap_place() -> Option { fn getenv_openweathermap_zip() -> Option { std::env::var(ZIP_ENV).ok() } -fn default_lang() -> String { - "en".into() + +#[derive(Deserialize, Debug)] +struct ApiForecastResponse { + list: Vec, } #[derive(Deserialize, Debug)] -struct ApiResponse { +struct ApiInstantResponse { + weather: Vec, + main: ApiMain, + wind: ApiWind, + dt: i64, +} + +#[derive(Deserialize, Debug)] +struct ApiCurrentResponse { weather: Vec, main: ApiMain, wind: ApiWind, sys: ApiSys, name: String, + dt: i64, } #[derive(Deserialize, Debug)] @@ -164,15 +200,19 @@ struct CityCoord { #[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 location_query = autolocated .map(|al| format!("lat={}&lon={}", al.latitude, al.longitude)) .or_else(|| self.location_query.clone()) .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}", api_key = self.api_key, units = match self.units { UnitSystem::Metric => "metric", @@ -181,41 +221,186 @@ impl WeatherProvider for Service<'_> { lang = self.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 current_weather = { + let is_night = current_data.sys.sunrise >= current_data.dt + || current_data.dt >= current_data.sys.sunset; + WeatherMoment { + 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.units { + UnitSystem::Metric => 3.6, + UnitSystem::Imperial => 3.6 * 0.447, + }, + wind_direction: current_data.wind.deg, + icon: weather_to_icon(current_data.weather[0].main.as_str(), is_night), + } + }; - 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 - * match self.units { - UnitSystem::Metric => 3.6, - UnitSystem::Imperial => 3.6 * 0.447, + let forecast = if !need_forecast || self.forecast_hours == 0 { + None + } else { + // Refer to https://openweathermap.org/forecast5 + let forecast_url = format!( + "{FORECAST_URL}?{location_query}&appid={api_key}&units={units}&lang={lang}&cnt={cnt}", + api_key = self.api_key, + units = match self.units { + UnitSystem::Metric => "metric", + UnitSystem::Imperial => "imperial", }, - wind_direction: convert_wind_direction(data.wind.deg).into(), - icon: match data.weather[0].main.as_str() { - "Clear" => WeatherIcon::Clear { is_night }, - "Rain" | "Drizzle" => WeatherIcon::Rain { is_night }, - "Clouds" => WeatherIcon::Clouds { is_night }, - "Fog" | "Mist" => WeatherIcon::Fog { is_night }, - "Thunderstorm" => WeatherIcon::Thunder { is_night }, - "Snow" => WeatherIcon::Snow, - _ => WeatherIcon::Default, - }, + lang = self.lang, + cnt = self.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 temp_avg = 0.0; + let mut temp_min = f64::MAX; + let mut temp_max = f64::MIN; + let mut apparent_avg = 0.0; + let mut apparent_min = f64::MAX; + let mut apparent_max = f64::MIN; + let mut humidity_avg = 0.0; + let mut humidity_min = f64::MAX; + let mut humidity_max = f64::MIN; + let mut wind_forecasts = Vec::new(); + let mut forecast_count = 0.0; + for forecast_instant in &forecast_data.list { + let instant_main = &forecast_instant.main; + temp_avg += instant_main.temp; + temp_min = temp_min.min(instant_main.temp); + temp_max = temp_max.max(instant_main.temp); + apparent_avg += instant_main.feels_like; + apparent_min = apparent_min.min(instant_main.feels_like); + apparent_max = apparent_max.max(instant_main.feels_like); + humidity_avg += instant_main.humidity; + humidity_min = humidity_min.min(instant_main.humidity); + humidity_max = humidity_max.max(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_avg /= forecast_count; + apparent_avg /= forecast_count; + humidity_avg /= forecast_count; + let Wind { + speed: wind_avg, + degrees: direction_avg, + } = average_wind(&wind_forecasts); + let Wind { + speed: wind_min, + degrees: direction_min, + } = wind_forecasts + .iter() + .min_by(|x, y| x.speed.total_cmp(&y.speed)) + .error("No min wind")?; + let Wind { + speed: wind_max, + degrees: direction_max, + } = wind_forecasts + .iter() + .min_by(|x, y| x.speed.total_cmp(&y.speed)) + .error("No max wind")?; + + let fin_data = forecast_data.list.last().unwrap(); + let fin_is_night = + current_data.sys.sunrise >= fin_data.dt || fin_data.dt >= current_data.sys.sunset; + + Some(Forecast { + avg: ForecastAggregate { + temp: temp_avg, + apparent: apparent_avg, + humidity: humidity_avg, + wind: wind_avg, + wind_kmh: wind_avg + * match self.units { + UnitSystem::Metric => 3.6, + UnitSystem::Imperial => 3.6 * 0.447, + }, + wind_direction: direction_avg, + }, + min: ForecastAggregate { + temp: temp_min, + apparent: apparent_min, + humidity: humidity_min, + wind: *wind_min, + wind_kmh: wind_min + * match self.units { + UnitSystem::Metric => 3.6, + UnitSystem::Imperial => 3.6 * 0.447, + }, + wind_direction: *direction_min, + }, + max: ForecastAggregate { + temp: temp_max, + apparent: apparent_max, + humidity: humidity_max, + wind: *wind_max, + wind_kmh: wind_max + * match self.units { + UnitSystem::Metric => 3.6, + UnitSystem::Imperial => 3.6 * 0.447, + }, + wind_direction: *direction_max, + }, + fin: WeatherMoment { + icon: weather_to_icon(fin_data.weather[0].main.as_str(), fin_is_night), + weather: fin_data.weather[0].main.clone(), + weather_verbose: fin_data.weather[0].description.clone(), + temp: fin_data.main.temp, + apparent: fin_data.main.feels_like, + humidity: fin_data.main.humidity, + wind: fin_data.wind.speed, + wind_kmh: fin_data.wind.speed + * match self.units { + UnitSystem::Metric => 3.6, + UnitSystem::Imperial => 3.6 * 0.447, + }, + wind_direction: fin_data.wind.deg, + }, + }) + }; + + Ok(WeatherResult { + location: current_data.name, + current_weather, + forecast, }) } } + +fn weather_to_icon(weather: &str, is_night: bool) -> WeatherIcon { + match weather { + "Clear" => WeatherIcon::Clear { is_night }, + "Rain" | "Drizzle" => WeatherIcon::Rain { is_night }, + "Clouds" => WeatherIcon::Clouds { is_night }, + "Fog" | "Mist" => WeatherIcon::Fog { is_night }, + "Thunderstorm" => WeatherIcon::Thunder { is_night }, + "Snow" => WeatherIcon::Snow, + _ => WeatherIcon::Default, + } +} diff --git a/src/util.rs b/src/util.rs index 0e1ebb1ff4..a7ad8ac4e2 100644 --- a/src/util.rs +++ b/src/util.rs @@ -155,6 +155,14 @@ macro_rules! map { )* m }}; + ($( $key:expr => $value:expr ),* $(,)?) => {{ + #[allow(unused_mut)] + let mut m = ::std::collections::HashMap::new(); + $( + map!(@insert m, $key, $value); + )* + m + }}; (@insert $map:ident, $key:expr, $value:expr) => {{ $map.insert($key.into(), $value.into()); }};