i3status_rs/blocks/weather/
met_no.rs1use super::*;
2
3type LegendsStore = HashMap<String, LegendsResult>;
4
5#[derive(Deserialize, Debug, SmartDefault)]
6#[serde(tag = "name", rename_all = "lowercase", deny_unknown_fields, default)]
7pub struct Config {
8 coordinates: Option<(String, String)>,
9 altitude: Option<String>,
10 #[serde(default)]
11 lang: ApiLanguage,
12 #[default(12)]
13 forecast_hours: usize,
14}
15
16pub(super) struct Service<'a> {
17 config: &'a Config,
18 legend: &'static LegendsStore,
19}
20
21impl<'a> Service<'a> {
22 pub(super) fn new(config: &'a Config) -> Result<Service<'a>> {
23 Ok(Self {
24 config,
25 legend: LEGENDS.as_ref().error("Invalid legends file")?,
26 })
27 }
28
29 fn translate(&self, summary: &str) -> String {
30 self.legend
31 .get(summary)
32 .map(|res| match self.config.lang {
33 ApiLanguage::English => res.desc_en.as_str(),
34 ApiLanguage::NorwegianBokmaal => res.desc_nb.as_str(),
35 ApiLanguage::NorwegianNynorsk => res.desc_nn.as_str(),
36 })
37 .unwrap_or(summary)
38 .into()
39 }
40}
41
42#[derive(Deserialize)]
43struct LegendsResult {
44 desc_en: String,
45 desc_nb: String,
46 desc_nn: String,
47}
48
49#[derive(Deserialize, Debug, Clone, Default)]
50pub(super) enum ApiLanguage {
51 #[serde(rename = "en")]
52 #[default]
53 English,
54 #[serde(rename = "nn")]
55 NorwegianNynorsk,
56 #[serde(rename = "nb")]
57 NorwegianBokmaal,
58}
59
60#[derive(Deserialize, Debug)]
61struct ForecastResponse {
62 properties: ForecastProperties,
63}
64
65#[derive(Deserialize, Debug)]
66struct ForecastProperties {
67 timeseries: Vec<ForecastTimeStep>,
68}
69
70#[derive(Deserialize, Debug)]
71struct ForecastTimeStep {
72 data: ForecastData,
73 }
75
76impl ForecastTimeStep {
77 fn to_moment(&self, service: &Service) -> WeatherMoment {
78 let instant = &self.data.instant.details;
79
80 let mut symbol_code_split = self
81 .data
82 .next_1_hours
83 .as_ref()
84 .unwrap()
85 .summary
86 .symbol_code
87 .split('_');
88
89 let summary = symbol_code_split.next().unwrap();
90
91 let is_night = symbol_code_split.next() == Some("night");
93
94 let translated = service.translate(summary);
95
96 let temp = instant.air_temperature.unwrap_or_default();
97 let humidity = instant.relative_humidity.unwrap_or_default();
98 let wind_speed = instant.wind_speed.unwrap_or_default();
99
100 WeatherMoment {
101 temp,
102 apparent: australian_apparent_temp(temp, humidity, wind_speed),
103 humidity,
104 weather: translated.clone(),
105 weather_verbose: translated,
106 wind: wind_speed,
107 wind_kmh: wind_speed * 3.6,
108 wind_direction: instant.wind_from_direction,
109 icon: weather_to_icon(summary, is_night),
110 }
111 }
112
113 fn to_aggregate(&self) -> ForecastAggregateSegment {
114 let instant = &self.data.instant.details;
115
116 let apparent = if let (Some(air_temperature), Some(relative_humidity), Some(wind_speed)) = (
117 instant.air_temperature,
118 instant.relative_humidity,
119 instant.wind_speed,
120 ) {
121 Some(australian_apparent_temp(
122 air_temperature,
123 relative_humidity,
124 wind_speed,
125 ))
126 } else {
127 None
128 };
129
130 ForecastAggregateSegment {
131 temp: instant.air_temperature,
132 apparent,
133 humidity: instant.relative_humidity,
134 wind: instant.wind_speed,
135 wind_kmh: instant.wind_speed.map(|t| t * 3.6),
136 wind_direction: instant.wind_from_direction,
137 }
138 }
139}
140
141#[derive(Deserialize, Debug)]
142struct ForecastData {
143 instant: ForecastModelInstant,
144 next_1_hours: Option<ForecastModelPeriod>,
146 }
148
149#[derive(Deserialize, Debug)]
150struct ForecastModelInstant {
151 details: ForecastTimeInstant,
152}
153
154#[derive(Deserialize, Debug)]
155struct ForecastModelPeriod {
156 summary: ForecastSummary,
157}
158
159#[derive(Deserialize, Debug)]
160struct ForecastSummary {
161 symbol_code: String,
162}
163
164#[derive(Deserialize, Debug, Default)]
165struct ForecastTimeInstant {
166 air_temperature: Option<f64>,
167 wind_from_direction: Option<f64>,
168 wind_speed: Option<f64>,
169 relative_humidity: Option<f64>,
170}
171
172static LEGENDS: LazyLock<Option<LegendsStore>> =
173 LazyLock::new(|| serde_json::from_str(include_str!("met_no_legends.json")).ok());
174
175const FORECAST_URL: &str = "https://api.met.no/weatherapi/locationforecast/2.0/compact";
176
177#[async_trait]
178impl WeatherProvider for Service<'_> {
179 async fn get_weather(
180 &self,
181 location: Option<&Coordinates>,
182 need_forecast: bool,
183 ) -> Result<WeatherResult> {
184 let (lat, lon) = location
185 .as_ref()
186 .map(|loc| (loc.latitude.to_string(), loc.longitude.to_string()))
187 .or_else(|| self.config.coordinates.clone())
188 .error("No location given")?;
189
190 let altitude = if let Some(altitude) = &self.config.altitude {
191 Some(altitude.parse().error("Unable to convert string to f64")?)
192 } else {
193 None
194 };
195
196 let (sunrise, sunset) = calculate_sunrise_sunset(
197 lat.parse().error("Unable to convert string to f64")?,
198 lon.parse().error("Unable to convert string to f64")?,
199 altitude,
200 )?;
201
202 let querystr: HashMap<&str, String> = map! {
203 "lat" => &lat,
204 "lon" => &lon,
205 [if let Some(alt) = &self.config.altitude] "altitude" => alt,
206 };
207
208 let data: ForecastResponse = REQWEST_CLIENT
209 .get(FORECAST_URL)
210 .query(&querystr)
211 .header(reqwest::header::CONTENT_TYPE, "application/json")
212 .send()
213 .await
214 .error("Forecast request failed")?
215 .json()
216 .await
217 .error("Forecast request failed")?;
218
219 let forecast_hours = self.config.forecast_hours;
220 let location_name = location.map_or("Unknown".to_string(), |c| c.city.clone());
221
222 let current_weather = data.properties.timeseries.first().unwrap().to_moment(self);
223
224 if !need_forecast || forecast_hours == 0 {
225 return Ok(WeatherResult {
226 location: location_name,
227 current_weather,
228 forecast: None,
229 sunrise,
230 sunset,
231 });
232 }
233
234 if data.properties.timeseries.len() < forecast_hours {
235 return Err(Error::new(format!(
236 "Unable to fetch the specified number of forecast_hours specified {}, only {} hours available",
237 forecast_hours,
238 data.properties.timeseries.len()
239 )))?;
240 }
241
242 let data_agg: Vec<ForecastAggregateSegment> = data
243 .properties
244 .timeseries
245 .iter()
246 .take(forecast_hours)
247 .map(|f| f.to_aggregate())
248 .collect();
249
250 let fin = data.properties.timeseries[forecast_hours - 1].to_moment(self);
251
252 let forecast = Some(Forecast::new(&data_agg, fin));
253
254 Ok(WeatherResult {
255 location: location_name,
256 current_weather,
257 forecast,
258 sunset,
259 sunrise,
260 })
261 }
262}
263
264fn weather_to_icon(weather: &str, is_night: bool) -> WeatherIcon {
265 match weather {
266 "cloudy" | "partlycloudy" | "fair" => WeatherIcon::Clouds{is_night},
267 "fog" => WeatherIcon::Fog{is_night},
268 "clearsky" => WeatherIcon::Clear{is_night},
269 "heavyrain" | "heavyrainshowers" | "lightrain" | "lightrainshowers" | "rain"
270 | "rainshowers" => WeatherIcon::Rain{is_night},
271 "rainandthunder"
272 | "heavyrainandthunder"
273 | "rainshowersandthunder"
274 | "sleetandthunder"
275 | "sleetshowersandthunder"
276 | "snowandthunder"
277 | "snowshowersandthunder"
278 | "heavyrainshowersandthunder"
279 | "heavysleetandthunder"
280 | "heavysleetshowersandthunder"
281 | "heavysnowandthunder"
282 | "heavysnowshowersandthunder"
283 | "lightsleetandthunder"
284 | "lightrainandthunder"
285 | "lightsnowandthunder"
286 | "lightssleetshowersandthunder" | "lightsleetshowersandthunder"
288 | "lightssnowshowersandthunder"| "lightsnowshowersandthunder"
290 | "lightrainshowersandthunder" => WeatherIcon::Thunder{is_night},
291 "heavysleet" | "heavysleetshowers" | "heavysnow" | "heavysnowshowers" | "lightsleet"
292 | "lightsleetshowers" | "lightsnow" | "lightsnowshowers" | "sleet" | "sleetshowers"
293 | "snow" | "snowshowers" => WeatherIcon::Snow,
294 _ => WeatherIcon::Default,
295 }
296}