Skip to main content

i3status_rs/blocks/weather/
open_weather_map.rs

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