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) = 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: instant.wind_speed,
134 wind_kmh: instant.wind_speed.map(|t| t * 3.6),
135 wind_direction: instant.wind_from_direction,
136 }
137 }
138}
139
140#[derive(Deserialize, Debug)]
141struct ForecastData {
142 instant: ForecastModelInstant,
143 next_1_hours: Option<ForecastModelPeriod>,
145 }
147
148#[derive(Deserialize, Debug)]
149struct ForecastModelInstant {
150 details: ForecastTimeInstant,
151}
152
153#[derive(Deserialize, Debug)]
154struct ForecastModelPeriod {
155 summary: ForecastSummary,
156}
157
158#[derive(Deserialize, Debug)]
159struct ForecastSummary {
160 symbol_code: String,
161}
162
163#[derive(Deserialize, Debug, Default)]
164struct ForecastTimeInstant {
165 air_temperature: Option<f64>,
166 wind_from_direction: Option<f64>,
167 wind_speed: Option<f64>,
168 relative_humidity: Option<f64>,
169}
170
171static LEGENDS: LazyLock<Option<LegendsStore>> =
172 LazyLock::new(|| serde_json::from_str(include_str!("met_no_legends.json")).ok());
173
174const FORECAST_URL: &str = "https://api.met.no/weatherapi/locationforecast/2.0/compact";
175
176#[async_trait]
177impl WeatherProvider for Service<'_> {
178 async fn get_weather(
179 &self,
180 autolocated: Option<&IPAddressInfo>,
181 need_forecast: bool,
182 ) -> Result<WeatherResult> {
183 let (lat, lon) = autolocated
184 .as_ref()
185 .map(|loc| (loc.latitude.to_string(), loc.longitude.to_string()))
186 .or_else(|| self.config.coordinates.clone())
187 .error("No location given")?;
188
189 let altitude = if let Some(altitude) = &self.config.altitude {
190 Some(altitude.parse().error("Unable to convert string to f64")?)
191 } else {
192 None
193 };
194
195 let (sunrise, sunset) = calculate_sunrise_sunset(
196 lat.parse().error("Unable to convert string to f64")?,
197 lon.parse().error("Unable to convert string to f64")?,
198 altitude,
199 )?;
200
201 let querystr: HashMap<&str, String> = map! {
202 "lat" => &lat,
203 "lon" => &lon,
204 [if let Some(alt) = &self.config.altitude] "altitude" => alt,
205 };
206
207 let data: ForecastResponse = REQWEST_CLIENT
208 .get(FORECAST_URL)
209 .query(&querystr)
210 .header(reqwest::header::CONTENT_TYPE, "application/json")
211 .send()
212 .await
213 .error("Forecast request failed")?
214 .json()
215 .await
216 .error("Forecast request failed")?;
217
218 let forecast_hours = self.config.forecast_hours;
219 let location_name = autolocated.map_or("Unknown".to_string(), |c| c.city.clone());
220
221 let current_weather = data.properties.timeseries.first().unwrap().to_moment(self);
222
223 if !need_forecast || forecast_hours == 0 {
224 return Ok(WeatherResult {
225 location: location_name,
226 current_weather,
227 forecast: None,
228 sunrise,
229 sunset,
230 });
231 }
232
233 if data.properties.timeseries.len() < forecast_hours {
234 return Err(Error::new(format!(
235 "Unable to fetch the specified number of forecast_hours specified {}, only {} hours available",
236 forecast_hours,
237 data.properties.timeseries.len()
238 )))?;
239 }
240
241 let data_agg: Vec<ForecastAggregateSegment> = data
242 .properties
243 .timeseries
244 .iter()
245 .take(forecast_hours)
246 .map(|f| f.to_aggregate())
247 .collect();
248
249 let fin = data.properties.timeseries[forecast_hours - 1].to_moment(self);
250
251 let forecast = Some(Forecast::new(&data_agg, fin));
252
253 Ok(WeatherResult {
254 location: location_name,
255 current_weather,
256 forecast,
257 sunset,
258 sunrise,
259 })
260 }
261}
262
263fn weather_to_icon(weather: &str, is_night: bool) -> WeatherIcon {
264 match weather {
265 "cloudy" | "partlycloudy" | "fair" => WeatherIcon::Clouds{is_night},
266 "fog" => WeatherIcon::Fog{is_night},
267 "clearsky" => WeatherIcon::Clear{is_night},
268 "heavyrain" | "heavyrainshowers" | "lightrain" | "lightrainshowers" | "rain"
269 | "rainshowers" => WeatherIcon::Rain{is_night},
270 "rainandthunder"
271 | "heavyrainandthunder"
272 | "rainshowersandthunder"
273 | "sleetandthunder"
274 | "sleetshowersandthunder"
275 | "snowandthunder"
276 | "snowshowersandthunder"
277 | "heavyrainshowersandthunder"
278 | "heavysleetandthunder"
279 | "heavysleetshowersandthunder"
280 | "heavysnowandthunder"
281 | "heavysnowshowersandthunder"
282 | "lightsleetandthunder"
283 | "lightrainandthunder"
284 | "lightsnowandthunder"
285 | "lightssleetshowersandthunder" | "lightsleetshowersandthunder"
287 | "lightssnowshowersandthunder"| "lightsnowshowersandthunder"
289 | "lightrainshowersandthunder" => WeatherIcon::Thunder{is_night},
290 "heavysleet" | "heavysleetshowers" | "heavysnow" | "heavysnowshowers" | "lightsleet"
291 | "lightsleetshowers" | "lightsnow" | "lightsnowshowers" | "sleet" | "sleetshowers"
292 | "snow" | "snowshowers" => WeatherIcon::Snow,
293 _ => WeatherIcon::Default,
294 }
295}