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
32fn show_default(default: &impl Display, help: &str) -> String {
35 format!("{help} [default: {default}]")
36}
37#[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 #[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 Init(InitArgs),
75 Monitor,
77 AddLocation,
79 EditOpts,
81 Daily(DailyArgs),
83 Hourly(HourlyArgs),
85 Current(CurrentArgs),
87 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 #[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 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 #[arg(short, long)]
234 check: bool,
235 #[arg(short, long)]
237 fstring: Option<String>,
238 #[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 #[arg(short, long)]
279 check: bool,
280 #[arg(short, long)]
282 force_check: bool,
283 #[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 #[arg(short, long)]
382 check: bool,
383 #[arg(short, long)]
385 force_check: bool,
386 #[arg(short = 'H', long, default_value_t = 12)]
388 hours: usize,
389 #[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 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 #[arg(short = 'F', long = "feature")]
498 pub features: Option<Vec<RadarImageFeature>>,
499 #[arg(short, long = "radar-type")]
501 pub radar_types: Option<Vec<RadarType>>,
502 #[arg(short = 'R', long)]
504 #[serde(skip_serializing_if = "std::ops::Not::not")]
505 pub remove_header: bool,
506 #[arg(short = 'f', long)]
508 #[serde(skip_serializing_if = "std::ops::Not::not")]
509 pub force: bool,
510 #[arg(short = 'p', long)]
512 #[serde(skip_serializing_if = "std::ops::Not::not")]
513 pub create_png: bool,
514 #[arg(short = 'a', long)]
516 #[serde(skip_serializing_if = "std::ops::Not::not")]
517 pub create_apng: bool,
518 #[arg(short = 'v', long)]
520 #[serde(skip_serializing_if = "std::ops::Not::not")]
521 pub open_mpv: bool,
522 #[arg(short = 'd', long = "frame-delay")]
524 pub frame_delay_ms: Option<u16>,
525 #[arg(short, long)]
527 pub max_frames: Option<u64>,
528 #[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 #[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 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}