bom_buddy/
cli.rs

1use crate::client::Client;
2use crate::config::Config;
3use crate::ftp::FtpClient;
4use crate::location::SearchResult;
5use crate::logging::{setup_logging, LogLevel};
6use crate::persistence::Database;
7use crate::radar::{
8    get_radar_image_managers, update_radar_images, Radar, RadarImageFeature, RadarImageManager,
9    RadarImageOptions, RadarType,
10};
11use crate::services::{create_location, get_nearby_radars, ids_to_locations, update_if_due};
12use crate::station::StationsTable;
13use crate::util::{format_duration, remove_if_exists};
14use crate::weather::{FstringKey, WeatherOptions};
15use anyhow::{anyhow, Result};
16use chrono::{Duration, Local, Utc};
17use clap::{Parser, Subcommand};
18use comfy_table::modifiers::UTF8_ROUND_CORNERS;
19use comfy_table::presets::UTF8_FULL;
20use comfy_table::*;
21use inquire::{Select, Text};
22use serde::{Deserialize, Serialize};
23use serde_with::skip_serializing_none;
24use std::collections::BTreeMap;
25use std::fmt::Display;
26use std::io::IsTerminal;
27use std::path::PathBuf;
28use std::thread::sleep;
29use strum::IntoEnumIterator;
30use tracing::{debug, error, info, trace};
31
32// Hacky way to display a default config value on the CLI.
33// Can't actually set a default value since it would override the config file
34fn show_default(default: &impl Display, help: &str) -> String {
35    format!("{help} [default: {default}]")
36}
37/// Australian weather tool
38#[skip_serializing_none]
39#[derive(Parser, Debug, Serialize, Deserialize)]
40#[command(version, about, long_about = None)]
41pub struct Cli {
42    #[arg(short, long, value_name = "FILE",
43        help = show_default(&Config::default().main.db_path.display(), "Database file"))]
44    pub db_path: Option<PathBuf>,
45
46    #[arg(short, long = "config", value_name = "FILE",
47        help = show_default(&Config::default_path().display(), "Config file"))]
48    pub config_path: Option<PathBuf>,
49
50    #[arg(short = 'L', long, value_name = "FILE",
51        help = show_default(&Config::default().main.logging.file_path.display(), "Log file"))]
52    pub log_path: Option<PathBuf>,
53
54    #[arg(short, long,  value_name = "LEVEL",
55        help = show_default(&Config::default().main.logging.console_level, "Console log level"))]
56    pub log_level: Option<LogLevel>,
57
58    #[arg(short = 'f', long, value_name = "LEVEL",
59        help = show_default(&Config::default().main.logging.file_level, "File log level"))]
60    pub log_file_level: Option<LogLevel>,
61
62    /// Suburb followed by geohash e.g. Canberra-r3dp5hh (overrides config)
63    #[arg(short = 'i', long = "location-id", value_name = "ID")]
64    pub locations: Option<Vec<String>>,
65
66    #[command(subcommand)]
67    #[serde(skip)]
68    pub command: Option<Commands>,
69}
70
71#[derive(Debug, Subcommand)]
72pub enum Commands {
73    /// Initialize the database and setup your location
74    Init(InitArgs),
75    /// Run continuously and check the weather when an update is due.
76    Monitor,
77    /// Search for a location and save it in the config file
78    AddLocation,
79    /// Edit options used when updating the weather
80    EditOpts,
81    /// Display the 7-day forecast
82    Daily(DailyArgs),
83    /// Display the hourly forecast
84    Hourly(HourlyArgs),
85    /// Display the current weather
86    Current(CurrentArgs),
87    /// Download and view radar images
88    Radar(RadarArgs),
89}
90
91pub fn cli() -> Result<()> {
92    let args = Cli::parse();
93    let mut config = Config::from_cli(&args)?;
94    let _guard = setup_logging(&config.main.logging);
95    trace!("Command line arguments: {:#?}", &args);
96    trace!("Config: {:#?}", &config);
97
98    match &args.command {
99        Some(Commands::Init(args)) => init(&mut config, args)?,
100        Some(Commands::Monitor) => monitor(&config)?,
101        Some(Commands::AddLocation) => add_location(&mut config)?,
102        Some(Commands::EditOpts) => edit_weather_opts(&config)?,
103        Some(Commands::Daily(args)) => daily(&config, args)?,
104        Some(Commands::Hourly(args)) => hourly(&config, args)?,
105        Some(Commands::Current(args)) => current(&config, args)?,
106        Some(Commands::Radar(args)) => radar(&config, args.monitor)?,
107        None => {}
108    }
109    Ok(())
110}
111
112#[derive(Parser, Debug, Serialize, Deserialize)]
113pub struct InitArgs {
114    /// Overwrite any existing database and config
115    #[arg(short, long)]
116    pub force: bool,
117}
118fn init(config: &mut Config, args: &InitArgs) -> Result<()> {
119    if args.force {
120        remove_if_exists(&config.main.db_path)?;
121    } else if config.main.db_path.exists() {
122        return Err(anyhow!(
123            "{} already exists. Use bom-buddy init --force to overwrite",
124            &config.main.db_path.display()
125        ));
126    }
127    let client = config.get_client();
128    let mut db = config.get_database()?;
129    db.init()?;
130    info!("Downloading weather stations");
131    let stations = client.get_station_list()?;
132    info!("Inserting weather stations into database");
133    let stations = StationsTable::new(&stations);
134    // Skip discontinued stations and those in Antarctica
135    let stations = stations.filter(|s| s.end.is_none() && s.state != "ANT");
136    db.insert_stations(stations)?;
137    let mut ftp = FtpClient::new()?;
138    info!("Downloading radar data");
139    let all_radars: Vec<Radar> = ftp.get_public_radars()?.collect();
140    let legends = ftp.get_radar_legends()?;
141    info!("Inserting radars into database");
142    db.insert_radars(&all_radars, &legends)?;
143    let result = search_for_location(&client)?;
144    let location = create_location(result, &client, &db)?;
145    config.add_location(&location)?;
146    let nearby_radars = get_nearby_radars(&location, &all_radars);
147    let radar_id = if nearby_radars.len() == 1 {
148        info!("Selecting only nearby radar {}", nearby_radars[0]);
149        nearby_radars[0].id
150    } else {
151        let selection = Select::new("Select a Radar", nearby_radars).prompt()?;
152        selection.id
153    };
154    let radar = all_radars.iter().find(|r| r.id == radar_id).unwrap();
155    config.add_radar(radar)?;
156    Ok(())
157}
158
159fn monitor(config: &Config) -> Result<()> {
160    if config.main.locations.is_empty() {
161        return Err(anyhow!("No locations specified"));
162    }
163    let client = config.get_client();
164    let database = config.get_database()?;
165    let mut locations = ids_to_locations(&config.main.locations, &client, &database)?;
166
167    for location in &locations {
168        info!("Monitoring weather for {}", location.id);
169    }
170    loop {
171        let next_check = update_if_due(&mut locations, &client, &database)?;
172        let sleep_duration = (next_check - Utc::now()).max(Duration::seconds(1));
173        debug!("Next weather update in {}", format_duration(sleep_duration));
174        sleep((sleep_duration + Duration::seconds(1)).to_std().unwrap());
175    }
176}
177
178fn add_location(config: &mut Config) -> Result<()> {
179    let client = config.get_client();
180    let database = config.get_database()?;
181    let result = search_for_location(&client)?;
182    let location = create_location(result, &client, &database)?;
183    config.add_location(&location)?;
184    Ok(())
185}
186
187fn search_for_location(client: &Client) -> Result<SearchResult> {
188    loop {
189        let input = Text::new("Enter your suburb").prompt().unwrap();
190        let results = client.search(&input)?;
191        if results.is_empty() {
192            info!("No search results for {input}");
193            continue;
194        } else if results.len() == 1 {
195            let result = &results[0];
196            info!("Selecting only result: {result}");
197            return Ok(result.clone());
198        };
199
200        let selection = match Select::new("Select a result: ", results).prompt() {
201            Ok(s) => s,
202            Err(_) => {
203                error!("An error occured. Please try again.");
204                continue;
205            }
206        };
207        return Ok(selection);
208    }
209}
210
211fn edit_weather_opts(config: &Config) -> Result<()> {
212    let client = config.get_client();
213    let database = config.get_database()?;
214    let mut locations = ids_to_locations(&config.main.locations, &client, &database)?;
215    let mut location_opts = BTreeMap::new();
216    for location in &locations {
217        location_opts.insert(&location.id, &location.weather.opts);
218    }
219    let to_edit = serde_yaml::to_string(&location_opts)?;
220    let mut builder = tempfile::Builder::new();
221    let edited = edit::edit_with_builder(to_edit, builder.suffix(".yml"))?;
222    let mut edited_opts: BTreeMap<String, WeatherOptions> = serde_yaml::from_str(&edited)?;
223    for location in &mut locations {
224        location.weather.opts = edited_opts.remove(&location.id).unwrap();
225        database.update_weather(location)?;
226    }
227    Ok(())
228}
229
230#[derive(Parser, Debug, Serialize, Deserialize)]
231pub struct CurrentArgs {
232    /// Check for updates if due
233    #[arg(short, long)]
234    check: bool,
235    /// Custom format string
236    #[arg(short, long)]
237    fstring: Option<String>,
238    /// List the keys that can be used in an fstring
239    #[arg(short, long)]
240    list_keys: bool,
241}
242
243fn current(config: &Config, args: &CurrentArgs) -> Result<()> {
244    if args.list_keys {
245        for key in FstringKey::iter() {
246            println!("{}", key.as_ref());
247        }
248        return Ok(());
249    }
250    if config.main.locations.is_empty() {
251        return Err(anyhow!("No locations specified"));
252    }
253    let client = config.get_client();
254    let database = config.get_database()?;
255    let mut locations = ids_to_locations(&config.main.locations, &client, &database)?;
256    if args.check {
257        update_if_due(&mut locations, &client, &database)?;
258    }
259    let fstring = args
260        .fstring
261        .as_ref()
262        .unwrap_or(&config.main.current_fstring);
263    for location in locations {
264        let current = location.weather.current();
265        let output = current.process_fstring(fstring)?;
266        if std::io::stdout().is_terminal() {
267            println!("{output}");
268        } else {
269            print!("{output}");
270        }
271    }
272    Ok(())
273}
274
275#[derive(Parser, Debug, Serialize, Deserialize)]
276pub struct DailyArgs {
277    /// Check for updates if due
278    #[arg(short, long)]
279    check: bool,
280    /// Force an update even if a new forecast isn't due
281    #[arg(short, long)]
282    force_check: bool,
283    /// Show the extended description for each day's forecast
284    #[arg(short, long)]
285    extended: bool,
286}
287
288fn daily(config: &Config, args: &DailyArgs) -> Result<()> {
289    if config.main.locations.is_empty() {
290        return Err(anyhow!("No locations specified"));
291    }
292    let client = config.get_client();
293    let database = config.get_database()?;
294    let mut locations = ids_to_locations(&config.main.locations, &client, &database)?;
295
296    if args.force_check {
297        for location in &mut locations {
298            let new_daily = client.get_daily(&location.geohash)?;
299            location.weather.update_daily(Utc::now(), new_daily);
300            database.update_weather(location)?;
301        }
302    } else if args.check {
303        update_if_due(&mut locations, &client, &database)?;
304    }
305
306    for location in locations {
307        let mut table = Table::new();
308
309        let issued = location
310            .weather
311            .daily_forecast
312            .issue_time
313            .with_timezone(&Local)
314            .format("%r on %a %d %b");
315
316        let header = format!("Forecast for {} issued at {}", location, issued);
317        println!("{header}");
318        table
319            .load_preset(UTF8_FULL)
320            .apply_modifier(UTF8_ROUND_CORNERS)
321            .set_content_arrangement(ContentArrangement::Dynamic)
322            .set_header(vec!["Day", "Min", "Max", "Rain", "Chance", "Description"]);
323
324        for day in &location.weather.daily_forecast.days {
325            let date = day
326                .date
327                .with_timezone(&Local)
328                .format("%a %d %b")
329                .to_string();
330
331            let max = day.temp_max.map_or("".to_string(), |t| t.to_string());
332            let min = day.temp_min.map_or("".to_string(), |t| t.to_string());
333            let mut extended = day.extended_text.clone().unwrap_or(String::new());
334            let description = if args.extended {
335                extended
336            } else {
337                let short = day.short_text.clone().unwrap_or(String::new());
338                if short.is_empty() && !extended.is_empty() {
339                    if let Some(idx) = extended.find('.') {
340                        extended.truncate(idx + 1);
341                    }
342                    extended
343                } else {
344                    short
345                }
346            };
347
348            let rain = if day.rain.amount.max.is_some() && day.rain.amount.lower_range.is_some() {
349                format!(
350                    "{}-{}{}",
351                    day.rain.amount.lower_range.unwrap(),
352                    day.rain.amount.max.unwrap(),
353                    day.rain.amount.units
354                )
355            } else {
356                "0mm".to_string()
357            };
358            let chance = if let Some(chance) = day.rain.chance {
359                format!("{}%", chance)
360            } else {
361                String::new()
362            };
363
364            table.add_row(vec![
365                Cell::new(&date),
366                Cell::new(&min),
367                Cell::new(&max),
368                Cell::new(&rain),
369                Cell::new(&chance),
370                Cell::new(&description),
371            ]);
372        }
373        println!("{table}");
374    }
375    Ok(())
376}
377
378#[derive(Parser, Debug, Serialize, Deserialize)]
379pub struct HourlyArgs {
380    /// Check for updates if due
381    #[arg(short, long)]
382    check: bool,
383    /// Force an update even if a new forecast isn't due
384    #[arg(short, long)]
385    force_check: bool,
386    /// How many hours to show (max 72)
387    #[arg(short = 'H', long, default_value_t = 12)]
388    hours: usize,
389    /// Show the 'feels like' temp in brackets if it differs from the actual temp
390    #[arg(short = 'l', long)]
391    feels_like: bool,
392}
393
394fn hourly(config: &Config, args: &HourlyArgs) -> Result<()> {
395    if config.main.locations.is_empty() {
396        return Err(anyhow!("No locations specified"));
397    }
398    let client = config.get_client();
399    let database = config.get_database()?;
400    let mut locations = ids_to_locations(&config.main.locations, &client, &database)?;
401
402    if args.force_check {
403        for location in &mut locations {
404            let new_hourly = client.get_hourly(&location.geohash)?;
405            location.weather.update_hourly(Utc::now(), new_hourly);
406            database.update_weather(location)?;
407        }
408    } else if args.check {
409        update_if_due(&mut locations, &client, &database)?;
410    }
411
412    for location in locations {
413        let mut table = Table::new();
414        table
415            .load_preset(UTF8_FULL)
416            .apply_modifier(UTF8_ROUND_CORNERS)
417            .set_content_arrangement(ContentArrangement::Dynamic);
418
419        let issue_time = location
420            .weather
421            .hourly_forecast
422            .issue_time
423            .with_timezone(&Local)
424            .format("%r on %a %d %b");
425        let title = format!("Hourly forecast for {} issued at {}", location, issue_time);
426
427        let todo = location
428            .weather
429            .hourly_forecast
430            .data
431            .iter()
432            .filter(|h| h.next_forecast_period > Utc::now())
433            .take(args.hours);
434
435        let show_rain = todo.clone().any(|h| h.rain.chance > 0);
436        // TODO: Make this configurable. Perhaps let the user specify column names paired with
437        // an fstring that's used to generate the row text.
438        let columns = if show_rain {
439            vec![
440                "Time", "Temp", "Desc", "Rain", "Chance", "Wind", "Gust", "Humidity",
441            ]
442        } else {
443            vec!["Time", "Temp", "Desc", "Wind", "Gust", "Humidity"]
444        };
445        table.set_header(columns);
446
447        for hour in todo {
448            let time = hour.time.with_timezone(&Local).format("%a %r").to_string();
449            let chance = format!("{}%", hour.rain.chance);
450            let wind = format!("{} {}", hour.wind.speed_kilometre, hour.wind.direction);
451            let gust = format!("{}", hour.wind.gust_speed_kilometre);
452            let temp = if args.feels_like && hour.temp != hour.temp_feels_like {
453                format!("{} ({})", hour.temp, hour.temp_feels_like)
454            } else {
455                hour.temp.to_string()
456            };
457            let desc = hour.icon_descriptor.get_description(hour.is_night);
458
459            let cells = if show_rain {
460                let rain = if let Some(max) = hour.rain.amount.max {
461                    format!("{}-{}{}", hour.rain.amount.min, max, hour.rain.amount.units)
462                } else {
463                    "0mm".to_string()
464                };
465                vec![
466                    Cell::new(&time),
467                    Cell::new(&temp),
468                    Cell::new(desc),
469                    Cell::new(&rain),
470                    Cell::new(&chance),
471                    Cell::new(&wind),
472                    Cell::new(&gust),
473                    Cell::new(format!("{}%", &hour.relative_humidity)),
474                ]
475            } else {
476                vec![
477                    Cell::new(&time),
478                    Cell::new(temp),
479                    Cell::new(desc),
480                    Cell::new(&wind),
481                    Cell::new(&gust),
482                    Cell::new(format!("{}%", &hour.relative_humidity)),
483                ]
484            };
485            table.add_row(cells);
486        }
487        println!("{title}");
488        println!("{table}");
489    }
490    Ok(())
491}
492
493#[skip_serializing_none]
494#[derive(Parser, Debug, Deserialize, Serialize)]
495pub struct RadarArgs {
496    /// Can be specified multiple times
497    #[arg(short = 'F', long = "feature")]
498    pub features: Option<Vec<RadarImageFeature>>,
499    /// Can be specified multiple times
500    #[arg(short, long = "radar-type")]
501    pub radar_types: Option<Vec<RadarType>>,
502    /// Remove the header at the top of each image
503    #[arg(short = 'R', long)]
504    #[serde(skip_serializing_if = "std::ops::Not::not")]
505    pub remove_header: bool,
506    /// Re-generate images that already exist
507    #[arg(short = 'f', long)]
508    #[serde(skip_serializing_if = "std::ops::Not::not")]
509    pub force: bool,
510    /// Create PNG files for each radar image
511    #[arg(short = 'p', long)]
512    #[serde(skip_serializing_if = "std::ops::Not::not")]
513    pub create_png: bool,
514    /// Combine all images into an animated PNG file
515    #[arg(short = 'a', long)]
516    #[serde(skip_serializing_if = "std::ops::Not::not")]
517    pub create_apng: bool,
518    /// View the images as a loop in MPV
519    #[arg(short = 'v', long)]
520    #[serde(skip_serializing_if = "std::ops::Not::not")]
521    pub open_mpv: bool,
522    /// Time between each frame in milliseconds (applies to APNG and MPV)
523    #[arg(short = 'd', long = "frame-delay")]
524    pub frame_delay_ms: Option<u16>,
525    /// Maximum amount of frames to create
526    #[arg(short, long)]
527    pub max_frames: Option<u64>,
528    /// Output directory for image files
529    #[arg(short = 'o', long, value_name = "DIR",
530        help = show_default(&RadarImageOptions::default().image_dir.display(),
531        "Output directory for image files"))]
532    pub image_dir: Option<PathBuf>,
533    #[arg(short = 'I', long, value_name = "DIR",
534        help = show_default(&RadarImageOptions::default().mpv_ipc_dir.display(),
535        "Runtime directory for MPV IPC sockets"))]
536    pub mpv_ipc_dir: Option<PathBuf>,
537    /// Run continuously and fetch new radar images when available
538    #[serde(skip)]
539    #[arg(short = 'M', long)]
540    pub monitor: bool,
541}
542
543fn radar(config: &Config, monitor: bool) -> Result<()> {
544    let mut db = config.get_database()?;
545    let mut ftp = FtpClient::new()?;
546    let mut managers = Vec::new();
547    for radar in &config.main.radars {
548        info!("Fetching radar images for {}", &radar.name);
549        managers.extend(get_radar_image_managers(
550            radar.id,
551            &mut db,
552            &mut ftp,
553            &radar.opts,
554        )?);
555    }
556
557    let mut next_check = update_radar_images(&mut managers, &mut db, &mut ftp)?;
558    manage_radar_images(&mut managers, &mut db)?;
559
560    if !monitor {
561        return Ok(());
562    }
563
564    loop {
565        let sleep_duration = next_check - Utc::now();
566        // The FTP connection will timeout irrecoverably if we wait too long without checking
567        let sleep_duration = sleep_duration.min(Duration::seconds(150));
568        debug!(
569            "Next check for radar images in {} seconds",
570            sleep_duration.num_seconds()
571        );
572        if sleep_duration > Duration::seconds(0) {
573            sleep(sleep_duration.to_std().unwrap());
574        }
575        next_check = update_radar_images(&mut managers, &mut db, &mut ftp)?;
576        manage_radar_images(&mut managers, &mut db)?;
577    }
578}
579
580fn manage_radar_images(managers: &mut Vec<RadarImageManager>, db: &mut Database) -> Result<()> {
581    for manager in managers {
582        if manager.opts.create_png {
583            info!(
584                "Writing radar PNG files to {}",
585                &manager.opts.image_dir.display()
586            );
587            manager.write_pngs()?;
588        }
589        if manager.opts.create_apng {
590            info!(
591                "Writing radar APNG file to {}",
592                &manager.opts.image_dir.display()
593            );
594            manager.create_apng()?;
595        }
596        if manager.opts.open_mpv {
597            info!("Opening radar images in MPV");
598            manager.open_images()?;
599        }
600        let removed = manager.prune()?;
601        db.delete_radar_data_layers(&removed)?;
602    }
603    Ok(())
604}