i3status_rs/blocks/weather/
nws.rs1use 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 self.wind_speed.value / 3.6
183 } else {
184 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 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
303fn short_forecast_to_icon(weather: &str, is_night: bool) -> WeatherIcon {
309 let weather = weather.to_lowercase();
310 if weather.contains("snow") || weather.contains("flurr") || weather.contains("blizzard") {
312 return WeatherIcon::Snow;
313 }
314 if weather.contains("thunder") {
316 return WeatherIcon::Thunder { is_night };
317 }
318 if weather.contains("fog") || weather.contains("mist") {
320 return WeatherIcon::Fog { is_night };
321 }
322 if weather.contains("rain") || weather.contains("shower") || weather.contains("drizzle") {
324 return WeatherIcon::Rain { is_night };
325 }
326 if weather.contains("cloud") || weather.contains("overcast") {
328 return WeatherIcon::Clouds { is_night };
329 }
330 if weather.contains("clear") || weather.contains("sunny") {
332 return WeatherIcon::Clear { is_night };
333 }
334 WeatherIcon::Default
335}