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) = 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_12_hours: ForecastModelPeriod,
144    next_1_hours: Option<ForecastModelPeriod>,
145    // next_6_hours: ForecastModelPeriod,
146}
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" // There's a typo in the api it will be fixed in the next version to the following entry
286        | "lightsleetshowersandthunder"
287        | "lightssnowshowersandthunder"// There's a typo in the api it will be fixed in the next version to the following entry
288        | "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}