1use crate::client::Client;
2use crate::daily::DailyForecast;
3use crate::descriptor::IconDescriptor;
4use crate::hourly::HourlyForecast;
5use crate::observation::Observation;
6use crate::util::format_duration;
7use crate::warning::Warning;
8use anyhow::{anyhow, Result};
9use chrono::{DateTime, Duration, Local, NaiveTime, Utc};
10use serde::{Deserialize, Serialize};
11use serde_with::DurationSeconds;
12use std::{collections::VecDeque, str::FromStr};
13use strum_macros::{AsRefStr, EnumIter, EnumString};
14use tracing::debug;
15
16#[derive(Debug, Serialize, Deserialize)]
17pub struct Weather {
18 pub geohash: String,
19 pub observations: VecDeque<Observation>,
20 pub daily_forecast: DailyForecast,
21 pub hourly_forecast: HourlyForecast,
22 pub warnings: Vec<Warning>,
23 pub next_observation_due: DateTime<Utc>,
24 pub next_daily_due: DateTime<Utc>,
25 pub next_hourly_due: DateTime<Utc>,
26 pub next_warning_due: DateTime<Utc>,
27 pub opts: WeatherOptions,
28}
29
30#[serde_with::serde_as]
31#[derive(Debug, Serialize, Deserialize)]
32pub struct WeatherOptions {
33 pub past_observation_amount: usize,
34 pub check_observations: bool,
35 #[serde_as(as = "DurationSeconds<i64>")]
37 pub update_delay: Duration,
38 #[serde_as(as = "DurationSeconds<i64>")]
39 pub observation_update_frequency: Duration,
40 #[serde_as(as = "DurationSeconds<i64>")]
41 pub observation_overdue_delay: Duration,
42 #[serde_as(as = "DurationSeconds<i64>")]
43 pub observation_missing_delay: Duration,
44 #[serde_as(as = "DurationSeconds<i64>")]
45 pub hourly_update_frequency: Duration,
46 #[serde_as(as = "DurationSeconds<i64>")]
47 pub hourly_overdue_delay: Duration,
48 #[serde_as(as = "DurationSeconds<i64>")]
49 pub daily_update_frequency: Duration,
50 #[serde_as(as = "DurationSeconds<i64>")]
51 pub daily_overdue_delay: Duration,
52 pub use_daily_next_issue_time: bool,
55 #[serde_as(as = "DurationSeconds<i64>")]
56 pub warning_update_frequency: Duration,
57}
58
59impl Default for WeatherOptions {
60 fn default() -> Self {
61 Self {
62 past_observation_amount: 6 * 24 * 2,
63 check_observations: true,
64 update_delay: Duration::minutes(2),
65 observation_update_frequency: Duration::minutes(10),
66 observation_overdue_delay: Duration::minutes(2),
67 observation_missing_delay: Duration::hours(1),
68 hourly_update_frequency: Duration::hours(3),
69 hourly_overdue_delay: Duration::hours(1),
70 daily_update_frequency: Duration::hours(1),
71 use_daily_next_issue_time: false,
72 daily_overdue_delay: Duration::minutes(30),
73 warning_update_frequency: Duration::minutes(30),
74 }
75 }
76}
77
78impl Weather {
79 pub fn observation(&self) -> Option<&Observation> {
80 self.observations.front()
81 }
82 pub fn update_if_due(&mut self, client: &Client) -> Result<(bool, DateTime<Utc>)> {
83 let now = Utc::now();
84 let mut was_updated = false;
85
86 if self.opts.check_observations && now > self.next_observation_due {
87 if let Some(observation) = client.get_observation(&self.geohash)? {
88 self.update_observation(now, observation);
89 } else {
90 self.next_observation_due = now + self.opts.observation_missing_delay;
91 }
92 was_updated = true;
93 }
94
95 if now > self.next_hourly_due {
96 let hourly = client.get_hourly(&self.geohash)?;
97 self.update_hourly(now, hourly);
98 was_updated = true;
99 }
100
101 if now > self.next_daily_due {
102 let daily = client.get_daily(&self.geohash)?;
103 self.update_daily(now, daily);
104 was_updated = true;
105 }
106
107 if now > self.next_warning_due {
108 self.warnings = client.get_warnings(&self.geohash)?;
109 self.next_warning_due = now + self.opts.warning_update_frequency;
110 was_updated = true;
111 }
112
113 let next_datetimes = [
114 self.next_observation_due,
115 self.next_hourly_due,
116 self.next_daily_due,
117 self.next_warning_due,
118 ];
119 let next_check = next_datetimes.iter().min().unwrap();
120
121 Ok((was_updated, *next_check))
122 }
123
124 pub fn update_observation(&mut self, now: DateTime<Utc>, observation: Observation) {
125 if let Some(last) = self.observation() {
126 if observation.issue_time == last.issue_time {
127 debug!(
128 "{} observation overdue. Next check in {}",
129 &self.geohash,
130 format_duration(self.opts.observation_overdue_delay)
131 );
132 self.next_observation_due = now + self.opts.observation_overdue_delay;
133 return;
134 }
135 }
136
137 self.next_observation_due = observation.issue_time
138 + self.opts.observation_update_frequency
139 + self.opts.update_delay;
140 if now > self.next_observation_due {
141 self.next_observation_due = now + self.opts.observation_overdue_delay;
142 }
143
144 debug!(
145 "{} new observation received. Next check in {}",
146 &self.geohash,
147 format_duration(self.next_observation_due - now)
148 );
149 self.observations.push_front(observation);
150 if self.observations.len() > self.opts.past_observation_amount {
151 self.observations.pop_back();
152 }
153 }
154
155 pub fn update_hourly(&mut self, now: DateTime<Utc>, hourly: HourlyForecast) {
156 let last = &self.hourly_forecast;
157 if hourly.issue_time == last.issue_time {
158 debug!(
159 "{} hourly forecast overdue. Next check in {}",
160 &self.geohash,
161 format_duration(self.opts.hourly_overdue_delay)
162 );
163 self.next_hourly_due = now + self.opts.hourly_overdue_delay;
164 self.hourly_forecast = hourly;
166 return;
167 }
168
169 self.next_hourly_due =
170 hourly.issue_time + self.opts.hourly_update_frequency + self.opts.update_delay;
171 debug!(
172 "{} new hourly forecast received. Next check in {}",
173 &self.geohash,
174 format_duration(self.next_hourly_due - now)
175 );
176 self.hourly_forecast = hourly;
177 }
178
179 pub fn update_daily(&mut self, now: DateTime<Utc>, new_daily: DailyForecast) {
180 let last = &self.daily_forecast;
181 if new_daily.issue_time == last.issue_time {
182 self.next_daily_due = if self.opts.use_daily_next_issue_time {
183 debug!(
184 "{} daily forecast overdue. Next check in {}",
185 &self.geohash,
186 format_duration(self.opts.daily_overdue_delay)
187 );
188 now + self.opts.daily_overdue_delay
189 } else {
190 debug!(
191 "{} No new daily forecast. Next check in {}",
192 &self.geohash,
193 format_duration(self.opts.daily_update_frequency)
194 );
195 now + self.opts.daily_update_frequency
196 };
197 return;
198 }
199
200 self.next_daily_due = if self.opts.use_daily_next_issue_time {
201 if let Some(next) = new_daily.next_issue_time {
202 next + self.opts.update_delay
203 } else {
204 new_daily.issue_time + self.opts.daily_update_frequency + self.opts.update_delay
205 }
206 } else {
207 now + self.opts.daily_update_frequency
208 };
209 debug!(
210 "{} new daily forecast received. Next check in {}",
211 &self.geohash,
212 format_duration(self.next_daily_due - now)
213 );
214 self.daily_forecast = new_daily;
215 }
216
217 pub fn current(&self) -> CurrentWeather {
218 let now = Utc::now();
219 let observation = self.observation();
220 let hourly = self
221 .hourly_forecast
222 .data
223 .iter()
224 .find(|h| now > h.time)
225 .unwrap();
226 let mut days = self.daily_forecast.days.iter();
227 let today = days.next().unwrap();
228 let tomorrow = days.next().unwrap();
229 let today_max = today.temp_max.unwrap_or(-9999.0);
232 let overnight_min = tomorrow.temp_min.unwrap_or(-9999.0);
233 let tomorrow_max = tomorrow.temp_max.unwrap_or(-9999.0);
234
235 let (temp, temp_feels_like, max_temp, wind_speed, wind_direction, gust) =
236 if let Some(obs) = observation {
237 let wind_direction = if let Some(dir) = &obs.wind.direction {
238 dir
239 } else {
240 &hourly.wind.direction
241 };
242 (
243 obs.temp,
244 obs.temp_feels_like,
245 f32::max(obs.max_temp.value, today_max),
246 obs.wind.speed_kilometre,
247 wind_direction,
248 obs.gust.speed_kilometre,
249 )
250 } else {
251 (
252 hourly.temp,
253 hourly.temp_feels_like,
254 today_max,
255 hourly.wind.speed_kilometre,
256 &hourly.wind.direction,
257 hourly.wind.gust_speed_kilometre,
258 )
259 };
260
261 let current_time = now.with_timezone(&Local).time();
262 let start = NaiveTime::from_hms_opt(6, 0, 0).unwrap();
263 let end = NaiveTime::from_hms_opt(18, 0, 0).unwrap();
264 let next_is_max = current_time > start && current_time < end;
265
266 let (next_temp, next_label, later_temp, later_label) = if next_is_max {
267 (max_temp, "Max", overnight_min, "Overnight min")
268 } else {
269 (overnight_min, "Overnight min", tomorrow_max, "Tomorrow max")
270 };
271
272 CurrentWeather {
273 temp,
274 temp_feels_like,
275 max_temp,
276 next_temp,
277 next_label,
278 later_temp,
279 later_label,
280 overnight_min, tomorrow_max,
282 rain_since_9am: observation.and_then(|obs| obs.rain_since_9am),
283 extended_text: &today.extended_text,
284 short_text: &today.short_text,
285 humidity: observation.as_ref().map(|obs| obs.humidity),
286 hourly_rain_chance: hourly.rain.chance,
287 hourly_rain_min: hourly.rain.amount.min,
288 hourly_rain_max: hourly.rain.amount.max.unwrap_or(0),
289 today_rain_chance: today.rain.chance.unwrap_or(0),
290 today_rain_min: today.rain.amount.min.unwrap_or(0),
291 today_rain_max: today.rain.amount.max.unwrap_or(0),
292 wind_speed,
293 wind_direction,
294 gust,
295 relative_humidity: hourly.relative_humidity,
296 uv: hourly.uv,
297 icon: hourly.icon_descriptor.get_icon_emoji(hourly.is_night),
298 icon_descriptor: &hourly.icon_descriptor,
299 is_night: hourly.is_night,
300 }
301 }
302}
303
304pub struct CurrentWeather<'a> {
305 pub temp: f32,
306 pub temp_feels_like: f32,
307 pub max_temp: f32,
308 pub next_temp: f32,
309 pub later_temp: f32,
310 pub next_label: &'a str,
311 pub later_label: &'a str,
312 pub overnight_min: f32,
313 pub tomorrow_max: f32,
314 pub rain_since_9am: Option<f32>,
315 pub today_rain_chance: u8,
316 pub today_rain_min: u16,
317 pub today_rain_max: u16,
318 pub hourly_rain_chance: u8,
319 pub hourly_rain_min: u16,
320 pub hourly_rain_max: u16,
321 pub humidity: Option<u8>,
322 pub relative_humidity: u8,
323 pub uv: u8,
324 pub icon: &'a str,
325 pub short_text: &'a Option<String>,
326 pub extended_text: &'a Option<String>,
327 pub icon_descriptor: &'a IconDescriptor,
328 pub is_night: bool,
329 pub wind_speed: u8,
330 pub wind_direction: &'a str,
331 pub gust: u8,
332}
333
334impl<'a> CurrentWeather<'a> {
335 pub fn process_fstring(&self, fstring: &str) -> Result<String> {
338 let mut pos = 0;
339 let mut remainder = fstring;
340 let mut output = String::new();
341 while !remainder.is_empty() {
342 if let Some(next) = remainder.find('{') {
343 output.push_str(&remainder[..next]);
344 let start = next + 1;
345 let Some(end) = remainder.find('}') else {
346 return Err(anyhow!("{fstring} is not a valid format string"));
347 };
348 let key = &remainder[start..end];
349 let Ok(fstring_key) = FstringKey::from_str(key) else {
350 return Err(anyhow!("{} is not a valid key", key));
351 };
352 fstring_key.push_value(&mut output, self);
353 pos = pos + end + 1;
354 remainder = &fstring[pos..];
355 } else {
356 output.push_str(remainder);
357 break;
358 }
359 }
360
361 Ok(output)
362 }
363}
364
365#[derive(AsRefStr, EnumString, EnumIter)]
366#[strum(serialize_all = "snake_case")]
367pub enum FstringKey {
368 Temp,
369 TempFeelsLike,
370 Icon,
371 NextTemp,
372 NextLabel,
373 LaterTemp,
374 LaterLabel,
375 MaxTemp,
376 OvernightMin,
377 TomorrowMax,
378 #[strum(serialize = "rain_since_9am")]
379 RainSince9am,
380 HourlyRainChance,
381 HourlyRainMin,
382 HourlyRainMax,
383 TodayRainChance,
384 TodayRainMin,
385 TodayRainMax,
386 ShortText,
387 ExtendedText,
388 WindSpeed,
389 WindDirection,
390 WindGust,
391}
392
393impl FstringKey {
394 fn push_value(&self, s: &mut String, w: &CurrentWeather) {
395 match self {
396 Self::Temp => s.push_str(&w.temp.to_string()),
397 Self::TempFeelsLike => s.push_str(&w.temp_feels_like.to_string()),
398 Self::Icon => s.push_str(w.icon),
399 Self::NextTemp => s.push_str(&w.next_temp.to_string()),
400 Self::NextLabel => s.push_str(w.next_label),
401 Self::LaterTemp => s.push_str(&w.later_temp.to_string()),
402 Self::LaterLabel => s.push_str(w.later_label),
403 Self::MaxTemp => s.push_str(&w.max_temp.to_string()),
404 Self::OvernightMin => s.push_str(&w.overnight_min.to_string()),
405 Self::TomorrowMax => s.push_str(&w.tomorrow_max.to_string()),
406 Self::RainSince9am => {
407 if let Some(rain) = w.rain_since_9am {
410 s.push_str(&rain.to_string())
411 } else {
412 s.push_str("??")
413 }
414 }
415 Self::HourlyRainChance => s.push_str(&w.hourly_rain_chance.to_string()),
416 Self::HourlyRainMin => s.push_str(&w.hourly_rain_min.to_string()),
417 Self::HourlyRainMax => s.push_str(&w.hourly_rain_max.to_string()),
418 Self::TodayRainChance => s.push_str(&w.today_rain_chance.to_string()),
419 Self::TodayRainMin => s.push_str(&w.today_rain_min.to_string()),
420 Self::TodayRainMax => s.push_str(&w.today_rain_max.to_string()),
421 Self::ShortText => s.push_str(w.short_text.as_ref().unwrap_or(&String::new())),
422 Self::ExtendedText => s.push_str(w.extended_text.as_ref().unwrap_or(&String::new())),
423 Self::WindSpeed => s.push_str(&w.wind_speed.to_string()),
424 Self::WindDirection => s.push_str(w.wind_direction),
425 Self::WindGust => s.push_str(&w.gust.to_string()),
426 }
427 }
428}