i3status_rs/blocks/weather/
nws.rs

1//! Support for using the US National Weather Service API.
2//!
3//! The API is documented [here](https://www.weather.gov/documentation/services-web-api).
4//! There is a corresponding [OpenAPI document](https://api.weather.gov/openapi.json). The forecast
5//! descriptions are translated into the set of supported icons as best as possible, and a more
6//! complete summary forecast is available in the `weather_verbose` format key. The full NWS list
7//! of icons and corresponding descriptions can be found [here](https://api.weather.gov/icons),
8//! though these are slated for deprecation.
9//!
10//! All data is gathered using the hourly weather forecast service, after resolving from latitude &
11//! longitude coordinates to a specific forecast office and grid point.
12//!
13
14use super::*;
15use serde::Deserialize;
16
17const API_URL: &str = "https://api.weather.gov/";
18
19const MPH_TO_KPH: f64 = 1.609344;
20
21#[derive(Deserialize, Debug, SmartDefault)]
22#[serde(tag = "name", rename_all = "lowercase", deny_unknown_fields, default)]
23pub struct Config {
24    coordinates: Option<(String, String)>,
25    #[default(12)]
26    forecast_hours: usize,
27    #[serde(default)]
28    units: UnitSystem,
29}
30
31#[derive(Clone, Debug)]
32struct LocationInfo {
33    query: String,
34    name: String,
35    lat: f64,
36    lon: f64,
37}
38
39pub(super) struct Service<'a> {
40    config: &'a Config,
41    location: Option<LocationInfo>,
42}
43
44impl<'a> Service<'a> {
45    pub(super) async fn new(autolocate: bool, config: &'a Config) -> Result<Service<'a>> {
46        let location = if autolocate {
47            None
48        } else {
49            let coords = config.coordinates.as_ref().error("no location given")?;
50            Some(
51                Self::get_location_query(
52                    coords.0.parse().error("Unable to convert string to f64")?,
53                    coords.1.parse().error("Unable to convert string to f64")?,
54                    config.units,
55                )
56                .await?,
57            )
58        };
59        Ok(Self { config, location })
60    }
61
62    async fn get_location_query(lat: f64, lon: f64, units: UnitSystem) -> Result<LocationInfo> {
63        let points_url = format!("{API_URL}/points/{lat},{lon}");
64
65        let response: ApiPoints = REQWEST_CLIENT
66            .get(points_url)
67            .send()
68            .await
69            .error("Zone resolution request failed")?
70            .json()
71            .await
72            .error("Failed to parse zone resolution request")?;
73        let mut query = response.properties.forecast_hourly;
74        query.push_str(match units {
75            UnitSystem::Metric => "?units=si",
76            UnitSystem::Imperial => "?units=us",
77        });
78        let location = response.properties.relative_location.properties;
79        let name = format!("{}, {}", location.city, location.state);
80        Ok(LocationInfo {
81            query,
82            name,
83            lat,
84            lon,
85        })
86    }
87}
88
89#[derive(Deserialize, Debug)]
90struct ApiPoints {
91    properties: ApiPointsProperties,
92}
93
94#[derive(Deserialize, Debug)]
95#[serde(rename_all = "camelCase")]
96struct ApiPointsProperties {
97    forecast_hourly: String,
98    relative_location: ApiRelativeLocation,
99}
100
101#[derive(Deserialize, Debug)]
102#[serde(rename_all = "camelCase")]
103struct ApiRelativeLocation {
104    properties: ApiRelativeLocationProperties,
105}
106
107#[derive(Deserialize, Debug)]
108#[serde(rename_all = "camelCase")]
109struct ApiRelativeLocationProperties {
110    city: String,
111    state: String,
112}
113
114#[derive(Deserialize, Debug)]
115struct ApiForecastResponse {
116    properties: ApiForecastProperties,
117}
118
119#[derive(Deserialize, Debug)]
120struct ApiForecastProperties {
121    periods: Vec<ApiForecast>,
122}
123
124#[derive(Deserialize, Debug)]
125#[serde(rename_all = "camelCase")]
126struct ApiValue {
127    value: f64,
128    unit_code: String,
129}
130
131#[derive(Deserialize, Debug)]
132#[serde(rename_all = "camelCase")]
133struct ApiForecast {
134    is_daytime: bool,
135    temperature: ApiValue,
136    relative_humidity: ApiValue,
137    wind_speed: ApiValue,
138    wind_direction: String,
139    short_forecast: String,
140}
141
142impl ApiForecast {
143    fn wind_direction(&self) -> Option<f64> {
144        let dir = match self.wind_direction.as_str() {
145            "N" => 0,
146            "NNE" => 1,
147            "NE" => 2,
148            "ENE" => 3,
149            "E" => 4,
150            "ESE" => 5,
151            "SE" => 6,
152            "SSE" => 7,
153            "S" => 8,
154            "SSW" => 9,
155            "SW" => 10,
156            "WSW" => 11,
157            "W" => 12,
158            "WNW" => 13,
159            "NW" => 14,
160            "NNW" => 15,
161            _ => return None,
162        };
163        Some((dir as f64) * (360.0 / 16.0))
164    }
165
166    fn icon_to_word(icon: WeatherIcon) -> String {
167        match icon {
168            WeatherIcon::Clear { .. } => "Clear",
169            WeatherIcon::Clouds { .. } => "Clouds",
170            WeatherIcon::Fog { .. } => "Fog",
171            WeatherIcon::Thunder { .. } => "Thunder",
172            WeatherIcon::Rain { .. } => "Rain",
173            WeatherIcon::Snow => "Snow",
174            WeatherIcon::Default => "Unknown",
175        }
176        .to_string()
177    }
178
179    fn wind_speed(&self) -> f64 {
180        if self.wind_speed.unit_code.ends_with("km_h-1") {
181            // m/s
182            self.wind_speed.value / 3.6
183        } else {
184            // mph
185            self.wind_speed.value
186        }
187    }
188
189    fn wind_kmh(&self) -> f64 {
190        if self.wind_speed.unit_code.ends_with("km_h-1") {
191            self.wind_speed.value
192        } else {
193            self.wind_speed.value * MPH_TO_KPH
194        }
195    }
196
197    fn apparent_temp(&self) -> f64 {
198        let temp = if self.temperature.unit_code.ends_with("degC") {
199            self.temperature.value
200        } else {
201            (self.temperature.value - 32.0) * 5.0 / 9.0
202        };
203        let humidity = self.relative_humidity.value;
204        // wind_speed in m/s
205        let wind_speed = self.wind_kmh() / 3.6;
206        let apparent = australian_apparent_temp(temp, humidity, wind_speed);
207        if self.temperature.unit_code.ends_with("degC") {
208            apparent
209        } else {
210            (apparent * 9.0 / 5.0) + 32.0
211        }
212    }
213
214    fn to_moment(&self) -> WeatherMoment {
215        let icon = short_forecast_to_icon(&self.short_forecast, !self.is_daytime);
216        let weather = Self::icon_to_word(icon);
217        WeatherMoment {
218            icon,
219            weather,
220            weather_verbose: self.short_forecast.clone(),
221            temp: self.temperature.value,
222            apparent: self.apparent_temp(),
223            humidity: self.relative_humidity.value,
224            wind: self.wind_speed(),
225            wind_kmh: self.wind_kmh(),
226            wind_direction: self.wind_direction(),
227        }
228    }
229
230    fn to_aggregate(&self) -> ForecastAggregateSegment {
231        ForecastAggregateSegment {
232            temp: Some(self.temperature.value),
233            apparent: Some(self.apparent_temp()),
234            humidity: Some(self.relative_humidity.value),
235            wind: Some(self.wind_speed()),
236            wind_kmh: Some(self.wind_kmh()),
237            wind_direction: self.wind_direction(),
238        }
239    }
240}
241
242#[async_trait]
243impl WeatherProvider for Service<'_> {
244    async fn get_weather(
245        &self,
246        autolocated: Option<&Coordinates>,
247        need_forecast: bool,
248    ) -> Result<WeatherResult> {
249        let location = if let Some(coords) = autolocated {
250            Self::get_location_query(coords.latitude, coords.longitude, self.config.units).await?
251        } else {
252            self.location.clone().error("No location was provided")?
253        };
254
255        let (sunrise, sunset) = calculate_sunrise_sunset(location.lat, location.lon, None)?;
256
257        let data: ApiForecastResponse = REQWEST_CLIENT
258            .get(location.query)
259            .header(
260                "Feature-Flags",
261                "forecast_wind_speed_qv,forecast_temperature_qv",
262            )
263            .send()
264            .await
265            .error("weather request failed")?
266            .json()
267            .await
268            .error("parsing weather data failed")?;
269
270        let data = data.properties.periods;
271        let current_weather = data.first().error("No current weather")?.to_moment();
272
273        if !need_forecast || self.config.forecast_hours == 0 {
274            return Ok(WeatherResult {
275                location: location.name,
276                current_weather,
277                forecast: None,
278                sunrise,
279                sunset,
280            });
281        }
282
283        let data_agg: Vec<ForecastAggregateSegment> = data
284            .iter()
285            .take(self.config.forecast_hours)
286            .map(|f| f.to_aggregate())
287            .collect();
288
289        let fin = data.last().error("no weather available")?.to_moment();
290
291        let forecast = Some(Forecast::new(&data_agg, fin));
292
293        Ok(WeatherResult {
294            location: location.name,
295            current_weather,
296            forecast,
297            sunrise,
298            sunset,
299        })
300    }
301}
302
303/// Try to turn the short forecast into an icon.
304///
305/// The official API has an icon field, but it's been marked as deprecated.
306/// Unfortunately, the short forecast cannot actually be fully enumerated, so
307/// we're reduced to checking for the presence of specific strings.
308fn short_forecast_to_icon(weather: &str, is_night: bool) -> WeatherIcon {
309    let weather = weather.to_lowercase();
310    // snow, flurries, flurry, blizzard
311    if weather.contains("snow") || weather.contains("flurr") || weather.contains("blizzard") {
312        return WeatherIcon::Snow;
313    }
314    // thunderstorms
315    if weather.contains("thunder") {
316        return WeatherIcon::Thunder { is_night };
317    }
318    // fog or mist
319    if weather.contains("fog") || weather.contains("mist") {
320        return WeatherIcon::Fog { is_night };
321    }
322    // rain, rainy, shower, drizzle (drizzle might not be present)
323    if weather.contains("rain") || weather.contains("shower") || weather.contains("drizzle") {
324        return WeatherIcon::Rain { is_night };
325    }
326    // cloudy, clouds, partly cloudy, overcast, etc.
327    if weather.contains("cloud") || weather.contains("overcast") {
328        return WeatherIcon::Clouds { is_night };
329    }
330    // clear (night), sunny (day). "Mostly sunny" / "Mostly clear" fit here too
331    if weather.contains("clear") || weather.contains("sunny") {
332        return WeatherIcon::Clear { is_night };
333    }
334    WeatherIcon::Default
335}