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: Service::get_location_query(autolocate, api_key, config).await?,
118            forecast_hours: config.forecast_hours,
119        })
120    }
121
122    async fn get_location_query(
123        autolocate: bool,
124        api_key: &str,
125        config: &Config,
126    ) -> Result<Option<LocationSpecifier>> {
127        if autolocate {
128            return Ok(None);
129        }
130
131        // Try by coordinates from config
132        if let Some((lat, lon)) = config.coordinates.as_ref() {
133            return Ok(Some(LocationSpecifier::try_from((lat, lon)).error(
134                "Invalid coordinates: failed to parse latitude or longitude from string to f64",
135            )?));
136        }
137
138        // Try by city ID from config
139        if let Some(id) = config.city_id.as_ref() {
140            return Ok(Some(LocationSpecifier::try_from(id).error(
141                "Invalid city id: failed to parse it from string to u32",
142            )?));
143        }
144
145        let geo_url =
146            Url::parse(GEO_URL).error("Failed to parse the hard-coded constant GEO_URL")?;
147
148        // Try by place name
149        if let Some(place) = config.place.as_ref() {
150            // "{GEO_URL}/direct?q={place}&appid={api_key}"
151            let mut url = geo_url.join("direct").error("Failed to join geo_url")?;
152            url.query_pairs_mut()
153                .append_pair("q", place)
154                .append_pair("appid", api_key);
155
156            let city: Option<LocationSpecifier> = REQWEST_CLIENT
157                .get(url)
158                .send()
159                .await
160                .error("Geo request failed")?
161                .json::<Vec<CityCoord>>()
162                .await
163                .error("Geo failed to parse JSON")?
164                .first()
165                .map(|city| LocationSpecifier::CityCoord(*city));
166
167            return Ok(city);
168        }
169
170        // Try by zip code
171        if let Some(zip) = config.zip.as_ref() {
172            // "{GEO_URL}/zip?zip={zip}&appid={api_key}"
173            let mut url = geo_url.join("zip").error("Failed to join geo_url")?;
174            url.query_pairs_mut()
175                .append_pair("zip", zip)
176                .append_pair("appid", api_key);
177
178            let city: CityCoord = REQWEST_CLIENT
179                .get(url)
180                .send()
181                .await
182                .error("Geo request failed")?
183                .json()
184                .await
185                .error("Geo failed to parse JSON")?;
186
187            return Ok(Some(LocationSpecifier::CityCoord(city)));
188        }
189
190        Ok(None)
191    }
192}
193
194fn getenv_openweathermap_api_key() -> Option<String> {
195    std::env::var(API_KEY_ENV).ok()
196}
197fn getenv_openweathermap_city_id() -> Option<String> {
198    std::env::var(CITY_ID_ENV).ok()
199}
200fn getenv_openweathermap_place() -> Option<String> {
201    std::env::var(PLACE_ENV).ok()
202}
203fn getenv_openweathermap_zip() -> Option<String> {
204    std::env::var(ZIP_ENV).ok()
205}
206
207#[derive(Deserialize, Debug)]
208struct ApiForecastResponse {
209    list: Vec<ApiInstantResponse>,
210}
211
212#[derive(Deserialize, Debug)]
213struct ApiInstantResponse {
214    weather: Vec<ApiWeather>,
215    main: ApiMain,
216    wind: ApiWind,
217    dt: i64,
218}
219
220impl ApiInstantResponse {
221    fn wind_kmh(&self, units: &UnitSystem) -> f64 {
222        self.wind.speed
223            * match units {
224                UnitSystem::Metric => 3.6,
225                UnitSystem::Imperial => 3.6 * 0.447,
226            }
227    }
228
229    fn to_moment(&self, units: &UnitSystem, current_data: &ApiCurrentResponse) -> WeatherMoment {
230        let is_night = current_data.sys.sunrise >= self.dt || self.dt >= current_data.sys.sunset;
231
232        WeatherMoment {
233            icon: weather_to_icon(self.weather[0].main.as_str(), is_night),
234            weather: self.weather[0].main.clone(),
235            weather_verbose: self.weather[0].description.clone(),
236            temp: self.main.temp,
237            apparent: self.main.feels_like,
238            humidity: self.main.humidity,
239            wind: self.wind.speed,
240            wind_kmh: self.wind_kmh(units),
241            wind_direction: self.wind.deg,
242        }
243    }
244
245    fn to_aggregate(&self, units: &UnitSystem) -> ForecastAggregateSegment {
246        ForecastAggregateSegment {
247            temp: Some(self.main.temp),
248            apparent: Some(self.main.feels_like),
249            humidity: Some(self.main.humidity),
250            wind: Some(self.wind.speed),
251            wind_kmh: Some(self.wind_kmh(units)),
252            wind_direction: self.wind.deg,
253        }
254    }
255}
256
257#[derive(Deserialize, Debug)]
258struct ApiCurrentResponse {
259    #[serde(flatten)]
260    instant: ApiInstantResponse,
261    sys: ApiSys,
262    name: String,
263}
264
265impl ApiCurrentResponse {
266    fn to_moment(&self, units: &UnitSystem) -> WeatherMoment {
267        self.instant.to_moment(units, self)
268    }
269}
270
271#[derive(Deserialize, Debug)]
272struct ApiWind {
273    speed: f64,
274    deg: Option<f64>,
275}
276
277#[derive(Deserialize, Debug)]
278struct ApiMain {
279    temp: f64,
280    feels_like: f64,
281    humidity: f64,
282}
283
284#[derive(Deserialize, Debug)]
285struct ApiSys {
286    sunrise: i64,
287    sunset: i64,
288}
289
290#[derive(Deserialize, Debug)]
291struct ApiWeather {
292    main: String,
293    description: String,
294}
295
296#[derive(Deserialize, Debug, Copy, Clone)]
297struct CityCoord {
298    lat: f64,
299    lon: f64,
300}
301
302#[async_trait]
303impl WeatherProvider for Service<'_> {
304    async fn get_weather(
305        &self,
306        autolocated: Option<&IPAddressInfo>,
307        need_forecast: bool,
308    ) -> Result<WeatherResult> {
309        let location_specifier = autolocated
310            .as_ref()
311            .map(|al| {
312                LocationSpecifier::CityCoord(CityCoord {
313                    lat: al.latitude,
314                    lon: al.longitude,
315                })
316            })
317            .or_else(|| self.location_query.clone())
318            .error("no location was provided")?;
319
320        // Refer to https://openweathermap.org/current
321        let current_url =
322            Url::parse(CURRENT_URL).error("Failed to parse the hard-coded constant CURRENT_URL")?;
323
324        let common_query_params = [
325            ("appid", self.api_key.as_str()),
326            ("units", self.units.as_ref()),
327            ("lang", self.lang.as_str()),
328        ];
329
330        // "{CURRENT_URL}?{location_query}&appid={api_key}&units={units}&lang={lang}"
331        let current_data: ApiCurrentResponse = REQWEST_CLIENT
332            .get(current_url)
333            .query(&location_specifier.as_query_params())
334            .query(&common_query_params)
335            .send()
336            .await
337            .error("Current weather request failed")?
338            .json()
339            .await
340            .error("Current weather request failed")?;
341
342        let current_weather = current_data.to_moment(self.units);
343
344        let sunrise = DateTime::<Utc>::from_timestamp(current_data.sys.sunrise, 0)
345            .error("Unable to convert timestamp to DateTime")?;
346
347        let sunset = DateTime::<Utc>::from_timestamp(current_data.sys.sunset, 0)
348            .error("Unable to convert timestamp to DateTime")?;
349
350        if !need_forecast || self.forecast_hours == 0 {
351            return Ok(WeatherResult {
352                location: current_data.name,
353                current_weather,
354                forecast: None,
355                sunrise,
356                sunset,
357            });
358        }
359
360        // Refer to https://openweathermap.org/forecast5
361        let forecast_url = Url::parse(FORECAST_URL)
362            .error("Failed to parse the hard-coded constant FORECAST_URL")?;
363
364        let forecast_query_params = [("cnt", &(self.forecast_hours / 3).to_string())];
365
366        // "{FORECAST_URL}?{location_query}&appid={api_key}&units={units}&lang={lang}&cnt={cnt}",
367        let forecast_data: ApiForecastResponse = REQWEST_CLIENT
368            .get(forecast_url)
369            .query(&location_specifier.as_query_params())
370            .query(&common_query_params)
371            .query(&forecast_query_params)
372            .send()
373            .await
374            .error("Forecast weather request failed")?
375            .json()
376            .await
377            .error("Forecast weather request failed")?;
378
379        let data_agg: Vec<ForecastAggregateSegment> = forecast_data
380            .list
381            .iter()
382            .take(self.forecast_hours)
383            .map(|f| f.to_aggregate(self.units))
384            .collect();
385
386        let fin = forecast_data
387            .list
388            .last()
389            .error("no weather available")?
390            .to_moment(self.units, &current_data);
391
392        let forecast = Some(Forecast::new(&data_agg, fin));
393
394        Ok(WeatherResult {
395            location: current_data.name,
396            current_weather,
397            forecast,
398            sunrise,
399            sunset,
400        })
401    }
402}
403
404fn weather_to_icon(weather: &str, is_night: bool) -> WeatherIcon {
405    match weather {
406        "Clear" => WeatherIcon::Clear { is_night },
407        "Rain" | "Drizzle" => WeatherIcon::Rain { is_night },
408        "Clouds" => WeatherIcon::Clouds { is_night },
409        "Fog" | "Mist" => WeatherIcon::Fog { is_night },
410        "Thunderstorm" => WeatherIcon::Thunder { is_night },
411        "Snow" => WeatherIcon::Snow,
412        _ => WeatherIcon::Default,
413    }
414}