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