i3status_rs/blocks/weather/
open_weather_map.rs1use 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 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 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 if let Some(place) = config.place.as_ref() {
169 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 if let Some(zip) = config.zip.as_ref() {
194 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 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 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 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 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, ¤t_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}