i3status_rs/blocks/weather/
met_no.rs

1use 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    // time: String,
74}
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        // Times of day can be day, night, and polartwilight
92        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_12_hours: ForecastModelPeriod,
145    next_1_hours: Option<ForecastModelPeriod>,
146    // next_6_hours: ForecastModelPeriod,
147}
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" // There's a typo in the api it will be fixed in the next version to the following entry
287        | "lightsleetshowersandthunder"
288        | "lightssnowshowersandthunder"// There's a typo in the api it will be fixed in the next version to the following entry
289        | "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}