Skip to main content

i3status_rs/blocks/weather/
met_no.rs

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