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