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: 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 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 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 if let Some(place) = config.place.as_ref() {
150 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 if let Some(zip) = config.zip.as_ref() {
172 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 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 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 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 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, ¤t_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}