i3status_rs/blocks/weather/
nws.rs1use 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 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
284fn short_forecast_to_icon(weather: &str, is_night: bool) -> WeatherIcon {
290 let weather = weather.to_lowercase();
291 if weather.contains("snow") || weather.contains("flurr") || weather.contains("blizzard") {
293 return WeatherIcon::Snow;
294 }
295 if weather.contains("thunder") {
297 return WeatherIcon::Thunder { is_night };
298 }
299 if weather.contains("fog") || weather.contains("mist") {
301 return WeatherIcon::Fog { is_night };
302 }
303 if weather.contains("rain") || weather.contains("shower") || weather.contains("drizzle") {
305 return WeatherIcon::Rain { is_night };
306 }
307 if weather.contains("cloud") || weather.contains("overcast") {
309 return WeatherIcon::Clouds { is_night };
310 }
311 if weather.contains("clear") || weather.contains("sunny") {
313 return WeatherIcon::Clear { is_night };
314 }
315 WeatherIcon::Default
316}