Skip to main content

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