Skip to main content

i3status_rs/blocks/weather/
open_weather_map.rs

1use super::*;
2use crate::util::mps_to_kmh;
3use chrono::{DateTime, Utc};
4use reqwest::Url;
5use serde::{Deserializer, de};
6
7pub(super) const GEO_URL: &str = "https://api.openweathermap.org/geo/1.0/";
8pub(super) const CURRENT_URL: &str = "https://api.openweathermap.org/data/2.5/weather";
9pub(super) const FORECAST_URL: &str = "https://api.openweathermap.org/data/2.5/forecast";
10pub(super) const API_KEY_ENV: &str = "OPENWEATHERMAP_API_KEY";
11pub(super) const CITY_ID_ENV: &str = "OPENWEATHERMAP_CITY_ID";
12pub(super) const PLACE_ENV: &str = "OPENWEATHERMAP_PLACE";
13pub(super) const ZIP_ENV: &str = "OPENWEATHERMAP_ZIP";
14
15#[derive(Deserialize, Debug, SmartDefault)]
16#[serde(tag = "name", rename_all = "lowercase", deny_unknown_fields, default)]
17pub struct Config {
18    #[serde(default = "getenv_openweathermap_api_key")]
19    api_key: Option<String>,
20    #[serde(default = "getenv_openweathermap_city_id")]
21    city_id: Option<String>,
22    #[serde(default = "getenv_openweathermap_place")]
23    place: Option<String>,
24    #[serde(default = "getenv_openweathermap_zip")]
25    zip: Option<String>,
26    coordinates: Option<(String, String)>,
27    #[serde(default)]
28    pub(super) units: UnitSystem,
29    #[default("en")]
30    lang: String,
31    #[default(12)]
32    #[serde(deserialize_with = "deserialize_forecast_hours")]
33    forecast_hours: usize,
34}
35
36pub fn deserialize_forecast_hours<'de, D>(deserializer: D) -> Result<usize, D::Error>
37where
38    D: Deserializer<'de>,
39{
40    usize::deserialize(deserializer).and_then(|hours| {
41        if hours % 3 != 0 && hours > 120 {
42            Err(de::Error::custom(
43                "'forecast_hours' is not divisible by 3 and must be <= 120",
44            ))
45        } else if hours % 3 != 0 {
46            Err(de::Error::custom("'forecast_hours' is not divisible by 3"))
47        } else if hours > 120 {
48            Err(de::Error::custom("'forecast_hours' must be <= 120"))
49        } else {
50            Ok(hours)
51        }
52    })
53}
54
55pub(super) struct Service<'a> {
56    api_key: &'a String,
57    lang: &'a String,
58    location_query: Option<LocationSpecifier>,
59    forecast_hours: usize,
60}
61
62#[derive(Clone)]
63enum LocationSpecifier {
64    CityCoord(CityCoord),
65    LocationId(u32),
66}
67
68impl LocationSpecifier {
69    fn as_query_params(&self) -> Vec<(&str, String)> {
70        match self {
71            LocationSpecifier::CityCoord(city) => {
72                vec![("lat", city.lat.to_string()), ("lon", city.lon.to_string())]
73            }
74            LocationSpecifier::LocationId(id) => vec![("id", id.to_string())],
75        }
76    }
77}
78
79fn parse_coord(value: &str, name: &str) -> Result<f64, Error> {
80    value
81        .parse::<f64>()
82        .or_error(|| format!("Invalid {} '{}': expected an f64", name, value))
83}
84
85impl TryFrom<(&String, &String)> for LocationSpecifier {
86    type Error = Error;
87
88    fn try_from(coords: (&String, &String)) -> Result<Self, Self::Error> {
89        let lat = parse_coord(coords.0, "latitude")?;
90        let lon = parse_coord(coords.1, "longitude")?;
91
92        Ok(LocationSpecifier::CityCoord(CityCoord { lat, lon }))
93    }
94}
95
96impl TryFrom<&String> for LocationSpecifier {
97    type Error = Error;
98
99    fn try_from(id: &String) -> Result<Self, Self::Error> {
100        let id = id
101            .parse::<u32>()
102            .or_error(|| format!("Invalid city id '{}': expected a u32", id))?;
103
104        Ok(LocationSpecifier::LocationId(id))
105    }
106}
107
108impl<'a> Service<'a> {
109    pub(super) async fn new(autolocate: bool, config: &'a Config) -> Result<Service<'a>> {
110        let api_key = config.api_key.as_ref().or_error(|| {
111            format!("missing key 'service.api_key' and environment variable {API_KEY_ENV}",)
112        })?;
113        Ok(Self {
114            api_key,
115            lang: &config.lang,
116            location_query: get_location_query(autolocate, config).await?,
117            forecast_hours: config.forecast_hours,
118        })
119    }
120}
121
122fn getenv_openweathermap_api_key() -> Option<String> {
123    std::env::var(API_KEY_ENV).ok()
124}
125
126fn getenv_openweathermap_city_id() -> Option<String> {
127    std::env::var(CITY_ID_ENV).ok()
128}
129
130fn getenv_openweathermap_place() -> Option<String> {
131    std::env::var(PLACE_ENV).ok()
132}
133
134fn getenv_openweathermap_zip() -> Option<String> {
135    std::env::var(ZIP_ENV).ok()
136}
137
138async fn get_location_query(
139    autolocate: bool,
140    config: &Config,
141) -> Result<Option<LocationSpecifier>> {
142    if autolocate {
143        return Ok(None);
144    }
145
146    // Try by coordinates from config
147    if let Some((lat, lon)) = config.coordinates.as_ref() {
148        return Ok(Some(LocationSpecifier::try_from((lat, lon)).error(
149            "Invalid coordinates: failed to parse latitude or longitude from string to f64",
150        )?));
151    }
152
153    // Try by city ID from config
154    if let Some(id) = config.city_id.as_ref() {
155        return Ok(Some(LocationSpecifier::try_from(id).error(
156            "Invalid city id: failed to parse it from string to u32",
157        )?));
158    }
159
160    let geo_url = Url::parse(GEO_URL).error("Failed to parse the hard-coded GEO_URL")?;
161    let api_key = config
162        .api_key
163        .as_ref()
164        .error("API key not provided (eg. via API_KEY_ENV environment variable)")?;
165
166    // Try by place name
167    if let Some(place) = config.place.as_ref() {
168        // "{GEO_URL}/direct?q={place}&appid={api_key}"
169        //
170        // Cannot use reqwest's `query_pairs_mut().append_pair()` because it percent-encodes
171        // the comma in the place name (ie., it turns "London,UK" into "London%2CUK"), which
172        // causes the request to 404.
173        let url = geo_url
174            .join(&format!("direct?q={place}&appid={api_key}"))
175            .error("Failed to join geo_url")?;
176
177        let city: Option<LocationSpecifier> = REQWEST_CLIENT
178            .get(url)
179            .send()
180            .await
181            .error("Geo request failed")?
182            .json::<Vec<CityCoord>>()
183            .await
184            .error("Geo failed to parse JSON")?
185            .first()
186            .map(|city| LocationSpecifier::CityCoord(*city));
187
188        return Ok(city);
189    }
190
191    // Try by zip code
192    if let Some(zip) = config.zip.as_ref() {
193        // "{GEO_URL}/zip?zip={zip}&appid={api_key}"
194        let mut url = geo_url.join("zip").error("Failed to join geo_url")?;
195        url.query_pairs_mut()
196            .append_pair("zip", zip)
197            .append_pair("appid", api_key);
198
199        let city: CityCoord = REQWEST_CLIENT
200            .get(url)
201            .send()
202            .await
203            .error("Geo request failed")?
204            .json()
205            .await
206            .error("Geo failed to parse JSON")?;
207
208        return Ok(Some(LocationSpecifier::CityCoord(city)));
209    }
210
211    Ok(None)
212}
213
214#[derive(Deserialize, Debug)]
215struct ApiForecastResponse {
216    list: Vec<ApiInstantResponse>,
217}
218
219#[derive(Deserialize, Debug)]
220struct ApiInstantResponse {
221    weather: Vec<ApiWeather>,
222    main: ApiMain,
223    wind: ApiWind,
224    dt: i64,
225}
226
227impl ApiInstantResponse {
228    fn to_moment(&self, current_data: &ApiCurrentResponse) -> WeatherMoment {
229        let is_night = current_data.sys.sunrise >= self.dt || self.dt >= current_data.sys.sunset;
230
231        WeatherMoment {
232            icon: weather_to_icon(self.weather[0].main.as_str(), is_night),
233            weather: self.weather[0].main.clone(),
234            weather_verbose: self.weather[0].description.clone(),
235            temp: self.main.temp,
236            apparent: self.main.feels_like,
237            humidity: self.main.humidity,
238            wind_kmh: mps_to_kmh(self.wind.speed),
239            wind_direction: self.wind.deg,
240        }
241    }
242
243    fn to_aggregate(&self) -> ForecastAggregateSegment {
244        ForecastAggregateSegment {
245            temp: Some(self.main.temp),
246            apparent: Some(self.main.feels_like),
247            humidity: Some(self.main.humidity),
248            wind_kmh: Some(mps_to_kmh(self.wind.speed)),
249            wind_direction: self.wind.deg,
250        }
251    }
252}
253
254#[derive(Deserialize, Debug)]
255struct ApiCurrentResponse {
256    #[serde(flatten)]
257    instant: ApiInstantResponse,
258    sys: ApiSys,
259    name: String,
260}
261
262impl ApiCurrentResponse {
263    fn to_moment(&self) -> WeatherMoment {
264        self.instant.to_moment(self)
265    }
266}
267
268#[derive(Deserialize, Debug)]
269struct ApiWind {
270    speed: f64,
271    deg: Option<f64>,
272}
273
274#[derive(Deserialize, Debug)]
275struct ApiMain {
276    temp: f64,
277    feels_like: f64,
278    humidity: f64,
279}
280
281#[derive(Deserialize, Debug)]
282struct ApiSys {
283    sunrise: i64,
284    sunset: i64,
285}
286
287#[derive(Deserialize, Debug)]
288struct ApiWeather {
289    main: String,
290    description: String,
291}
292
293#[derive(Deserialize, Debug, Copy, Clone)]
294struct CityCoord {
295    lat: f64,
296    lon: f64,
297}
298
299#[async_trait]
300impl WeatherProvider for Service<'_> {
301    async fn get_weather(
302        &self,
303        autolocated: Option<&IPAddressInfo>,
304        need_forecast: bool,
305    ) -> Result<WeatherResult> {
306        let location_specifier = autolocated
307            .as_ref()
308            .map(|al| {
309                LocationSpecifier::CityCoord(CityCoord {
310                    lat: al.latitude,
311                    lon: al.longitude,
312                })
313            })
314            .or_else(|| self.location_query.clone())
315            .error("no location was provided")?;
316
317        // Refer to https://openweathermap.org/current
318        let current_url =
319            Url::parse(CURRENT_URL).error("Failed to parse the hard-coded constant CURRENT_URL")?;
320
321        let common_query_params = [
322            ("appid", self.api_key.as_str()),
323            ("units", "metric"),
324            ("lang", self.lang.as_str()),
325        ];
326
327        // "{CURRENT_URL}?{location_query}&appid={api_key}&units={units}&lang={lang}"
328        let current_data: ApiCurrentResponse = REQWEST_CLIENT
329            .get(current_url)
330            .query(&location_specifier.as_query_params())
331            .query(&common_query_params)
332            .send()
333            .await
334            .error("Current weather request failed")?
335            .json()
336            .await
337            .error("Current weather request failed")?;
338
339        let current_weather = current_data.to_moment();
340
341        let sunrise = Some(
342            DateTime::<Utc>::from_timestamp(current_data.sys.sunrise, 0)
343                .error("Unable to convert timestamp to DateTime")?,
344        );
345
346        let sunset = Some(
347            DateTime::<Utc>::from_timestamp(current_data.sys.sunset, 0)
348                .error("Unable to convert timestamp to DateTime")?,
349        );
350
351        if !need_forecast || self.forecast_hours == 0 {
352            return Ok(WeatherResult {
353                location: current_data.name,
354                current_weather,
355                forecast: None,
356                sunrise,
357                sunset,
358            });
359        }
360
361        // Refer to https://openweathermap.org/forecast5
362        let forecast_url = Url::parse(FORECAST_URL)
363            .error("Failed to parse the hard-coded constant FORECAST_URL")?;
364
365        let forecast_query_params = [("cnt", &(self.forecast_hours / 3).to_string())];
366
367        // "{FORECAST_URL}?{location_query}&appid={api_key}&units={units}&lang={lang}&cnt={cnt}",
368        let forecast_data: ApiForecastResponse = REQWEST_CLIENT
369            .get(forecast_url)
370            .query(&location_specifier.as_query_params())
371            .query(&common_query_params)
372            .query(&forecast_query_params)
373            .send()
374            .await
375            .error("Forecast weather request failed")?
376            .json()
377            .await
378            .error("Forecast weather request failed")?;
379
380        let data_agg: Vec<ForecastAggregateSegment> = forecast_data
381            .list
382            .iter()
383            .take(self.forecast_hours)
384            .map(|f| f.to_aggregate())
385            .collect();
386
387        let fin = forecast_data
388            .list
389            .last()
390            .error("no weather available")?
391            .to_moment(&current_data);
392
393        let forecast = Some(Forecast::new(&data_agg, fin));
394
395        Ok(WeatherResult {
396            location: current_data.name,
397            current_weather,
398            forecast,
399            sunrise,
400            sunset,
401        })
402    }
403}
404
405fn weather_to_icon(weather: &str, is_night: bool) -> WeatherIcon {
406    match weather {
407        "Clear" => WeatherIcon::Clear { is_night },
408        "Rain" | "Drizzle" => WeatherIcon::Rain { is_night },
409        "Clouds" => WeatherIcon::Clouds { is_night },
410        "Fog" | "Mist" => WeatherIcon::Fog { is_night },
411        "Thunderstorm" => WeatherIcon::Thunder { is_night },
412        "Snow" => WeatherIcon::Snow,
413        _ => WeatherIcon::Default,
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[ignore]
422    #[tokio::test]
423    async fn using_place_resolves_correctly() -> Result<()> {
424        let config = Config {
425            api_key: getenv_openweathermap_api_key(),
426            place: Some("Zurich,CH".to_string()),
427            ..Default::default()
428        };
429
430        let Some(LocationSpecifier::CityCoord(CityCoord { lat, lon })) =
431            get_location_query(false, &config).await?
432        else {
433            panic!("no location specifier found (eg., OpenWeatherMap returned empty result)");
434        };
435
436        assert_eq!(&format!("{lat:.1}"), "47.4");
437        assert_eq!(&format!("{lon:.1}"), "8.5");
438
439        Ok(())
440    }
441}