bom_buddy/
weather.rs

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    /// A delay to account for lag between issue time and appearance in API
36    #[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    // The BOM will sometimes issue a new forecast before the advertised next_issue_time
53    // so we provide the option to ignore that and just check at a regular interval
54    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            // Previous hours will be removed in the API response even if the issue time is the same
165            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        // temp_max should only ever be None on the last day of the forecast
230        // provide an obviously wrong value rather than crashing if it is None
231        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, // TODO: check what happens after midnight
281            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    /// Process a user-provided format string e.g. "{icon} {temp} ({temp_feels_like})".
336    /// Just a basic implementation that doesn't handle mismatched curly brackets
337    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                // API usually returns 0 if there hasn't been rain
408                // so take None to mean data unavailable
409                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}