i3status_rs/blocks/weather/
open_weather_map.rs

1use super::*;
2use chrono::{DateTime, Utc};
3use serde::{Deserializer, de};
4
5pub(super) const GEO_URL: &str = "https://api.openweathermap.org/geo/1.0";
6pub(super) const CURRENT_URL: &str = "https://api.openweathermap.org/data/2.5/weather";
7pub(super) const FORECAST_URL: &str = "https://api.openweathermap.org/data/2.5/forecast";
8pub(super) const API_KEY_ENV: &str = "OPENWEATHERMAP_API_KEY";
9pub(super) const CITY_ID_ENV: &str = "OPENWEATHERMAP_CITY_ID";
10pub(super) const PLACE_ENV: &str = "OPENWEATHERMAP_PLACE";
11pub(super) const ZIP_ENV: &str = "OPENWEATHERMAP_ZIP";
12
13#[derive(Deserialize, Debug, SmartDefault)]
14#[serde(tag = "name", rename_all = "lowercase", deny_unknown_fields, default)]
15pub struct Config {
16    #[serde(default = "getenv_openweathermap_api_key")]
17    api_key: Option<String>,
18    #[serde(default = "getenv_openweathermap_city_id")]
19    city_id: Option<String>,
20    #[serde(default = "getenv_openweathermap_place")]
21    place: Option<String>,
22    #[serde(default = "getenv_openweathermap_zip")]
23    zip: Option<String>,
24    coordinates: Option<(String, String)>,
25    #[serde(default)]
26    units: UnitSystem,
27    #[default("en")]
28    lang: String,
29    #[default(12)]
30    #[serde(deserialize_with = "deserialize_forecast_hours")]
31    forecast_hours: usize,
32}
33
34pub fn deserialize_forecast_hours<'de, D>(deserializer: D) -> Result<usize, D::Error>
35where
36    D: Deserializer<'de>,
37{
38    usize::deserialize(deserializer).and_then(|hours| {
39        if hours % 3 != 0 && hours > 120 {
40            Err(de::Error::custom(
41                "'forecast_hours' is not divisible by 3 and must be <= 120",
42            ))
43        } else if hours % 3 != 0 {
44            Err(de::Error::custom("'forecast_hours' is not divisible by 3"))
45        } else if hours > 120 {
46            Err(de::Error::custom("'forecast_hours' must be <= 120"))
47        } else {
48            Ok(hours)
49        }
50    })
51}
52
53pub(super) struct Service<'a> {
54    api_key: &'a String,
55    units: &'a UnitSystem,
56    lang: &'a String,
57    location_query: Option<String>,
58    forecast_hours: usize,
59}
60
61impl<'a> Service<'a> {
62    pub(super) async fn new(autolocate: bool, config: &'a Config) -> Result<Service<'a>> {
63        let api_key = config.api_key.as_ref().or_error(|| {
64            format!("missing key 'service.api_key' and environment variable {API_KEY_ENV}",)
65        })?;
66        Ok(Self {
67            api_key,
68            units: &config.units,
69            lang: &config.lang,
70            location_query: Service::get_location_query(autolocate, api_key, config).await?,
71            forecast_hours: config.forecast_hours,
72        })
73    }
74
75    async fn get_location_query(
76        autolocate: bool,
77        api_key: &String,
78        config: &Config,
79    ) -> Result<Option<String>> {
80        if autolocate {
81            return Ok(None);
82        }
83
84        let mut location_query = config
85            .coordinates
86            .as_ref()
87            .map(|(lat, lon)| format!("lat={lat}&lon={lon}"))
88            .or_else(|| config.city_id.as_ref().map(|x| format!("id={x}")));
89
90        location_query = match location_query {
91            Some(x) => Some(x),
92            None => match config.place.as_ref() {
93                Some(place) => {
94                    let url = format!("{GEO_URL}/direct?q={place}&appid={api_key}");
95
96                    REQWEST_CLIENT
97                        .get(url)
98                        .send()
99                        .await
100                        .error("Geo request failed")?
101                        .json::<Vec<CityCoord>>()
102                        .await
103                        .error("Geo failed to parse json")?
104                        .first()
105                        .map(|city| format!("lat={}&lon={}", city.lat, city.lon))
106                }
107                None => None,
108            },
109        };
110
111        location_query = match location_query {
112            Some(x) => Some(x),
113            None => match config.zip.as_ref() {
114                Some(zip) => {
115                    let url = format!("{GEO_URL}/zip?zip={zip}&appid={api_key}");
116                    let city: CityCoord = REQWEST_CLIENT
117                        .get(url)
118                        .send()
119                        .await
120                        .error("Geo request failed")?
121                        .json()
122                        .await
123                        .error("Geo failed to parse json")?;
124
125                    Some(format!("lat={}&lon={}", city.lat, city.lon))
126                }
127                None => None,
128            },
129        };
130
131        Ok(location_query)
132    }
133}
134
135fn getenv_openweathermap_api_key() -> Option<String> {
136    std::env::var(API_KEY_ENV).ok()
137}
138fn getenv_openweathermap_city_id() -> Option<String> {
139    std::env::var(CITY_ID_ENV).ok()
140}
141fn getenv_openweathermap_place() -> Option<String> {
142    std::env::var(PLACE_ENV).ok()
143}
144fn getenv_openweathermap_zip() -> Option<String> {
145    std::env::var(ZIP_ENV).ok()
146}
147
148#[derive(Deserialize, Debug)]
149struct ApiForecastResponse {
150    list: Vec<ApiInstantResponse>,
151}
152
153#[derive(Deserialize, Debug)]
154struct ApiInstantResponse {
155    weather: Vec<ApiWeather>,
156    main: ApiMain,
157    wind: ApiWind,
158    dt: i64,
159}
160
161impl ApiInstantResponse {
162    fn wind_kmh(&self, units: &UnitSystem) -> f64 {
163        self.wind.speed
164            * match units {
165                UnitSystem::Metric => 3.6,
166                UnitSystem::Imperial => 3.6 * 0.447,
167            }
168    }
169
170    fn to_moment(&self, units: &UnitSystem, current_data: &ApiCurrentResponse) -> WeatherMoment {
171        let is_night = current_data.sys.sunrise >= self.dt || self.dt >= current_data.sys.sunset;
172
173        WeatherMoment {
174            icon: weather_to_icon(self.weather[0].main.as_str(), is_night),
175            weather: self.weather[0].main.clone(),
176            weather_verbose: self.weather[0].description.clone(),
177            temp: self.main.temp,
178            apparent: self.main.feels_like,
179            humidity: self.main.humidity,
180            wind: self.wind.speed,
181            wind_kmh: self.wind_kmh(units),
182            wind_direction: self.wind.deg,
183        }
184    }
185
186    fn to_aggregate(&self, units: &UnitSystem) -> ForecastAggregateSegment {
187        ForecastAggregateSegment {
188            temp: Some(self.main.temp),
189            apparent: Some(self.main.feels_like),
190            humidity: Some(self.main.humidity),
191            wind: Some(self.wind.speed),
192            wind_kmh: Some(self.wind_kmh(units)),
193            wind_direction: self.wind.deg,
194        }
195    }
196}
197
198#[derive(Deserialize, Debug)]
199struct ApiCurrentResponse {
200    #[serde(flatten)]
201    instant: ApiInstantResponse,
202    sys: ApiSys,
203    name: String,
204}
205
206impl ApiCurrentResponse {
207    fn to_moment(&self, units: &UnitSystem) -> WeatherMoment {
208        self.instant.to_moment(units, self)
209    }
210}
211
212#[derive(Deserialize, Debug)]
213struct ApiWind {
214    speed: f64,
215    deg: Option<f64>,
216}
217
218#[derive(Deserialize, Debug)]
219struct ApiMain {
220    temp: f64,
221    feels_like: f64,
222    humidity: f64,
223}
224
225#[derive(Deserialize, Debug)]
226struct ApiSys {
227    sunrise: i64,
228    sunset: i64,
229}
230
231#[derive(Deserialize, Debug)]
232struct ApiWeather {
233    main: String,
234    description: String,
235}
236
237#[derive(Deserialize, Debug)]
238struct CityCoord {
239    lat: f64,
240    lon: f64,
241}
242
243#[async_trait]
244impl WeatherProvider for Service<'_> {
245    async fn get_weather(
246        &self,
247        autolocated: Option<&Coordinates>,
248        need_forecast: bool,
249    ) -> Result<WeatherResult> {
250        let location_query = autolocated
251            .as_ref()
252            .map(|al| format!("lat={}&lon={}", al.latitude, al.longitude))
253            .or_else(|| self.location_query.clone())
254            .error("no location was provided")?;
255
256        // Refer to https://openweathermap.org/current
257        let current_url = format!(
258            "{CURRENT_URL}?{location_query}&appid={api_key}&units={units}&lang={lang}",
259            api_key = self.api_key,
260            units = match self.units {
261                UnitSystem::Metric => "metric",
262                UnitSystem::Imperial => "imperial",
263            },
264            lang = self.lang,
265        );
266
267        let current_data: ApiCurrentResponse = REQWEST_CLIENT
268            .get(current_url)
269            .send()
270            .await
271            .error("Current weather request failed")?
272            .json()
273            .await
274            .error("Current weather request failed")?;
275
276        let current_weather = current_data.to_moment(self.units);
277
278        let sunrise = DateTime::<Utc>::from_timestamp(current_data.sys.sunrise, 0)
279            .error("Unable to convert timestamp to DateTime")?;
280
281        let sunset = DateTime::<Utc>::from_timestamp(current_data.sys.sunset, 0)
282            .error("Unable to convert timestamp to DateTime")?;
283
284        if !need_forecast || self.forecast_hours == 0 {
285            return Ok(WeatherResult {
286                location: current_data.name,
287                current_weather,
288                forecast: None,
289                sunrise,
290                sunset,
291            });
292        }
293
294        // Refer to https://openweathermap.org/forecast5
295        let forecast_url = format!(
296            "{FORECAST_URL}?{location_query}&appid={api_key}&units={units}&lang={lang}&cnt={cnt}",
297            api_key = self.api_key,
298            units = match self.units {
299                UnitSystem::Metric => "metric",
300                UnitSystem::Imperial => "imperial",
301            },
302            lang = self.lang,
303            cnt = self.forecast_hours / 3,
304        );
305
306        let forecast_data: ApiForecastResponse = REQWEST_CLIENT
307            .get(forecast_url)
308            .send()
309            .await
310            .error("Forecast weather request failed")?
311            .json()
312            .await
313            .error("Forecast weather request failed")?;
314
315        let data_agg: Vec<ForecastAggregateSegment> = forecast_data
316            .list
317            .iter()
318            .take(self.forecast_hours)
319            .map(|f| f.to_aggregate(self.units))
320            .collect();
321
322        let fin = forecast_data
323            .list
324            .last()
325            .error("no weather available")?
326            .to_moment(self.units, &current_data);
327
328        let forecast = Some(Forecast::new(&data_agg, fin));
329
330        Ok(WeatherResult {
331            location: current_data.name,
332            current_weather,
333            forecast,
334            sunrise,
335            sunset,
336        })
337    }
338}
339
340fn weather_to_icon(weather: &str, is_night: bool) -> WeatherIcon {
341    match weather {
342        "Clear" => WeatherIcon::Clear { is_night },
343        "Rain" | "Drizzle" => WeatherIcon::Rain { is_night },
344        "Clouds" => WeatherIcon::Clouds { is_night },
345        "Fog" | "Mist" => WeatherIcon::Fog { is_night },
346        "Thunderstorm" => WeatherIcon::Thunder { is_night },
347        "Snow" => WeatherIcon::Snow,
348        _ => WeatherIcon::Default,
349    }
350}