i3status_rs/blocks/weather/
open_weather_map.rs1use super::*;
2use chrono::{DateTime, Utc};
3use serde::{Deserializer, de};
4
5pub(super) const GEO_URL: &str = "https://api.openweathermap.org/geo/1.0";
6pub(super) const CURRENT_URL: &str = "https://api.openweathermap.org/data/2.5/weather";
7pub(super) const FORECAST_URL: &str = "https://api.openweathermap.org/data/2.5/forecast";
8pub(super) const API_KEY_ENV: &str = "OPENWEATHERMAP_API_KEY";
9pub(super) const CITY_ID_ENV: &str = "OPENWEATHERMAP_CITY_ID";
10pub(super) const PLACE_ENV: &str = "OPENWEATHERMAP_PLACE";
11pub(super) const ZIP_ENV: &str = "OPENWEATHERMAP_ZIP";
12
13#[derive(Deserialize, Debug, SmartDefault)]
14#[serde(tag = "name", rename_all = "lowercase", deny_unknown_fields, default)]
15pub struct Config {
16 #[serde(default = "getenv_openweathermap_api_key")]
17 api_key: Option<String>,
18 #[serde(default = "getenv_openweathermap_city_id")]
19 city_id: Option<String>,
20 #[serde(default = "getenv_openweathermap_place")]
21 place: Option<String>,
22 #[serde(default = "getenv_openweathermap_zip")]
23 zip: Option<String>,
24 coordinates: Option<(String, String)>,
25 #[serde(default)]
26 units: UnitSystem,
27 #[default("en")]
28 lang: String,
29 #[default(12)]
30 #[serde(deserialize_with = "deserialize_forecast_hours")]
31 forecast_hours: usize,
32}
33
34pub fn deserialize_forecast_hours<'de, D>(deserializer: D) -> Result<usize, D::Error>
35where
36 D: Deserializer<'de>,
37{
38 usize::deserialize(deserializer).and_then(|hours| {
39 if hours % 3 != 0 && hours > 120 {
40 Err(de::Error::custom(
41 "'forecast_hours' is not divisible by 3 and must be <= 120",
42 ))
43 } else if hours % 3 != 0 {
44 Err(de::Error::custom("'forecast_hours' is not divisible by 3"))
45 } else if hours > 120 {
46 Err(de::Error::custom("'forecast_hours' must be <= 120"))
47 } else {
48 Ok(hours)
49 }
50 })
51}
52
53pub(super) struct Service<'a> {
54 api_key: &'a String,
55 units: &'a UnitSystem,
56 lang: &'a String,
57 location_query: Option<String>,
58 forecast_hours: usize,
59}
60
61impl<'a> Service<'a> {
62 pub(super) async fn new(autolocate: bool, config: &'a Config) -> Result<Service<'a>> {
63 let api_key = config.api_key.as_ref().or_error(|| {
64 format!("missing key 'service.api_key' and environment variable {API_KEY_ENV}",)
65 })?;
66 Ok(Self {
67 api_key,
68 units: &config.units,
69 lang: &config.lang,
70 location_query: Service::get_location_query(autolocate, api_key, config).await?,
71 forecast_hours: config.forecast_hours,
72 })
73 }
74
75 async fn get_location_query(
76 autolocate: bool,
77 api_key: &String,
78 config: &Config,
79 ) -> Result<Option<String>> {
80 if autolocate {
81 return Ok(None);
82 }
83
84 let mut location_query = config
85 .coordinates
86 .as_ref()
87 .map(|(lat, lon)| format!("lat={lat}&lon={lon}"))
88 .or_else(|| config.city_id.as_ref().map(|x| format!("id={x}")));
89
90 location_query = match location_query {
91 Some(x) => Some(x),
92 None => match config.place.as_ref() {
93 Some(place) => {
94 let url = format!("{GEO_URL}/direct?q={place}&appid={api_key}");
95
96 REQWEST_CLIENT
97 .get(url)
98 .send()
99 .await
100 .error("Geo request failed")?
101 .json::<Vec<CityCoord>>()
102 .await
103 .error("Geo failed to parse json")?
104 .first()
105 .map(|city| format!("lat={}&lon={}", city.lat, city.lon))
106 }
107 None => None,
108 },
109 };
110
111 location_query = match location_query {
112 Some(x) => Some(x),
113 None => match config.zip.as_ref() {
114 Some(zip) => {
115 let url = format!("{GEO_URL}/zip?zip={zip}&appid={api_key}");
116 let city: CityCoord = REQWEST_CLIENT
117 .get(url)
118 .send()
119 .await
120 .error("Geo request failed")?
121 .json()
122 .await
123 .error("Geo failed to parse json")?;
124
125 Some(format!("lat={}&lon={}", city.lat, city.lon))
126 }
127 None => None,
128 },
129 };
130
131 Ok(location_query)
132 }
133}
134
135fn getenv_openweathermap_api_key() -> Option<String> {
136 std::env::var(API_KEY_ENV).ok()
137}
138fn getenv_openweathermap_city_id() -> Option<String> {
139 std::env::var(CITY_ID_ENV).ok()
140}
141fn getenv_openweathermap_place() -> Option<String> {
142 std::env::var(PLACE_ENV).ok()
143}
144fn getenv_openweathermap_zip() -> Option<String> {
145 std::env::var(ZIP_ENV).ok()
146}
147
148#[derive(Deserialize, Debug)]
149struct ApiForecastResponse {
150 list: Vec<ApiInstantResponse>,
151}
152
153#[derive(Deserialize, Debug)]
154struct ApiInstantResponse {
155 weather: Vec<ApiWeather>,
156 main: ApiMain,
157 wind: ApiWind,
158 dt: i64,
159}
160
161impl ApiInstantResponse {
162 fn wind_kmh(&self, units: &UnitSystem) -> f64 {
163 self.wind.speed
164 * match units {
165 UnitSystem::Metric => 3.6,
166 UnitSystem::Imperial => 3.6 * 0.447,
167 }
168 }
169
170 fn to_moment(&self, units: &UnitSystem, current_data: &ApiCurrentResponse) -> WeatherMoment {
171 let is_night = current_data.sys.sunrise >= self.dt || self.dt >= current_data.sys.sunset;
172
173 WeatherMoment {
174 icon: weather_to_icon(self.weather[0].main.as_str(), is_night),
175 weather: self.weather[0].main.clone(),
176 weather_verbose: self.weather[0].description.clone(),
177 temp: self.main.temp,
178 apparent: self.main.feels_like,
179 humidity: self.main.humidity,
180 wind: self.wind.speed,
181 wind_kmh: self.wind_kmh(units),
182 wind_direction: self.wind.deg,
183 }
184 }
185
186 fn to_aggregate(&self, units: &UnitSystem) -> ForecastAggregateSegment {
187 ForecastAggregateSegment {
188 temp: Some(self.main.temp),
189 apparent: Some(self.main.feels_like),
190 humidity: Some(self.main.humidity),
191 wind: Some(self.wind.speed),
192 wind_kmh: Some(self.wind_kmh(units)),
193 wind_direction: self.wind.deg,
194 }
195 }
196}
197
198#[derive(Deserialize, Debug)]
199struct ApiCurrentResponse {
200 #[serde(flatten)]
201 instant: ApiInstantResponse,
202 sys: ApiSys,
203 name: String,
204}
205
206impl ApiCurrentResponse {
207 fn to_moment(&self, units: &UnitSystem) -> WeatherMoment {
208 self.instant.to_moment(units, self)
209 }
210}
211
212#[derive(Deserialize, Debug)]
213struct ApiWind {
214 speed: f64,
215 deg: Option<f64>,
216}
217
218#[derive(Deserialize, Debug)]
219struct ApiMain {
220 temp: f64,
221 feels_like: f64,
222 humidity: f64,
223}
224
225#[derive(Deserialize, Debug)]
226struct ApiSys {
227 sunrise: i64,
228 sunset: i64,
229}
230
231#[derive(Deserialize, Debug)]
232struct ApiWeather {
233 main: String,
234 description: String,
235}
236
237#[derive(Deserialize, Debug)]
238struct CityCoord {
239 lat: f64,
240 lon: f64,
241}
242
243#[async_trait]
244impl WeatherProvider for Service<'_> {
245 async fn get_weather(
246 &self,
247 autolocated: Option<&Coordinates>,
248 need_forecast: bool,
249 ) -> Result<WeatherResult> {
250 let location_query = autolocated
251 .as_ref()
252 .map(|al| format!("lat={}&lon={}", al.latitude, al.longitude))
253 .or_else(|| self.location_query.clone())
254 .error("no location was provided")?;
255
256 let current_url = format!(
258 "{CURRENT_URL}?{location_query}&appid={api_key}&units={units}&lang={lang}",
259 api_key = self.api_key,
260 units = match self.units {
261 UnitSystem::Metric => "metric",
262 UnitSystem::Imperial => "imperial",
263 },
264 lang = self.lang,
265 );
266
267 let current_data: ApiCurrentResponse = REQWEST_CLIENT
268 .get(current_url)
269 .send()
270 .await
271 .error("Current weather request failed")?
272 .json()
273 .await
274 .error("Current weather request failed")?;
275
276 let current_weather = current_data.to_moment(self.units);
277
278 let sunrise = DateTime::<Utc>::from_timestamp(current_data.sys.sunrise, 0)
279 .error("Unable to convert timestamp to DateTime")?;
280
281 let sunset = DateTime::<Utc>::from_timestamp(current_data.sys.sunset, 0)
282 .error("Unable to convert timestamp to DateTime")?;
283
284 if !need_forecast || self.forecast_hours == 0 {
285 return Ok(WeatherResult {
286 location: current_data.name,
287 current_weather,
288 forecast: None,
289 sunrise,
290 sunset,
291 });
292 }
293
294 let forecast_url = format!(
296 "{FORECAST_URL}?{location_query}&appid={api_key}&units={units}&lang={lang}&cnt={cnt}",
297 api_key = self.api_key,
298 units = match self.units {
299 UnitSystem::Metric => "metric",
300 UnitSystem::Imperial => "imperial",
301 },
302 lang = self.lang,
303 cnt = self.forecast_hours / 3,
304 );
305
306 let forecast_data: ApiForecastResponse = REQWEST_CLIENT
307 .get(forecast_url)
308 .send()
309 .await
310 .error("Forecast weather request failed")?
311 .json()
312 .await
313 .error("Forecast weather request failed")?;
314
315 let data_agg: Vec<ForecastAggregateSegment> = forecast_data
316 .list
317 .iter()
318 .take(self.forecast_hours)
319 .map(|f| f.to_aggregate(self.units))
320 .collect();
321
322 let fin = forecast_data
323 .list
324 .last()
325 .error("no weather available")?
326 .to_moment(self.units, ¤t_data);
327
328 let forecast = Some(Forecast::new(&data_agg, fin));
329
330 Ok(WeatherResult {
331 location: current_data.name,
332 current_weather,
333 forecast,
334 sunrise,
335 sunset,
336 })
337 }
338}
339
340fn weather_to_icon(weather: &str, is_night: bool) -> WeatherIcon {
341 match weather {
342 "Clear" => WeatherIcon::Clear { is_night },
343 "Rain" | "Drizzle" => WeatherIcon::Rain { is_night },
344 "Clouds" => WeatherIcon::Clouds { is_night },
345 "Fog" | "Mist" => WeatherIcon::Fog { is_night },
346 "Thunderstorm" => WeatherIcon::Thunder { is_night },
347 "Snow" => WeatherIcon::Snow,
348 _ => WeatherIcon::Default,
349 }
350}