bom_buddy/
radar.rs

1use crate::config::Config;
2use crate::ftp::FtpClient;
3use crate::persistence::Database;
4use anyhow::{anyhow, Context, Result};
5use chrono::{DateTime, Duration, NaiveDateTime, Utc};
6use clap::Parser;
7use image::codecs::png::PngDecoder;
8use image::io::Reader as ImageReader;
9use image::{imageops, DynamicImage, ImageOutputFormat, Rgba, RgbaImage};
10use mpvipc::*;
11use serde::{Deserialize, Serialize};
12use std::ffi::OsString;
13use std::fmt::Display;
14use std::fs;
15use std::io::BufWriter;
16use std::path::{Path, PathBuf};
17use std::process::Child;
18use std::str::FromStr;
19use std::thread::sleep;
20use strum::{Display, EnumCount};
21use strum_macros::{EnumIter, EnumString};
22use suppaftp::list::File;
23use tracing::{debug, info, warn};
24
25pub type RadarId = u32;
26
27#[derive(Clone, Debug)]
28pub struct Radar {
29    pub id: RadarId,
30    pub name: String,
31    pub longitude: f32,
32    pub latitude: f32,
33    pub full_name: String,
34    pub state: String,
35    pub r#type: String,
36    pub group: bool,
37}
38
39impl From<RadarData> for Radar {
40    fn from(data: RadarData) -> Self {
41        Self {
42            id: data.id as RadarId,
43            name: data.name,
44            longitude: data.longitude,
45            latitude: data.latitude,
46            full_name: data.full_name,
47            state: data.state,
48            r#type: data.r#type,
49            group: match data.group.as_str() {
50                "Yes" => true,
51                "No" => false,
52                _ => false,
53            },
54        }
55    }
56}
57
58#[derive(Debug, Deserialize, Serialize)]
59pub struct RadarData {
60    pub name: String,
61    pub longitude: f32,
62    pub latitude: f32,
63    pub id: f64,
64    pub full_name: String,
65    pub idrnn0name: String,
66    pub idrnn1name: String,
67    pub state: String,
68    pub r#type: String,
69    pub group: String,
70    pub status: String,
71    pub archive: String,
72    pub location_id: f64,
73}
74
75#[derive(
76    Clone,
77    Copy,
78    Debug,
79    Display,
80    EnumIter,
81    Ord,
82    PartialEq,
83    PartialOrd,
84    Eq,
85    Deserialize,
86    Serialize,
87    clap::ValueEnum,
88)]
89pub enum RadarType {
90    #[serde(rename = "64km")]
91    #[clap(name = "64km")]
92    SixtyFourKm,
93    #[clap(name = "128km")]
94    #[serde(rename = "128km")]
95    OneTwentyEightKm,
96    #[serde(rename = "256km")]
97    #[clap(name = "256km")]
98    TwoFiftySixKm,
99    #[serde(rename = "512km")]
100    #[clap(name = "512km")]
101    FiveTwelveKm,
102    #[serde(rename = "doppler")]
103    #[clap(name = "doppler")]
104    DopplerWind,
105    #[serde(rename = "5min")]
106    #[clap(name = "5min")]
107    AccumulatedFiveMin,
108    #[serde(rename = "1hour")]
109    #[clap(name = "1hour")]
110    AccumulatedOneHour,
111    #[serde(rename = "since9")]
112    #[clap(name = "since9")]
113    AccumulatedSinceNine,
114    #[serde(rename = "24hour")]
115    #[clap(name = "24hour")]
116    AccumulatedPreviousTwentyFour,
117}
118
119impl RadarType {
120    pub fn from_id(id: char) -> Result<Self> {
121        let radar_type = match id {
122            '1' => Self::FiveTwelveKm,
123            '2' => Self::TwoFiftySixKm,
124            '3' => Self::OneTwentyEightKm,
125            '4' => Self::SixtyFourKm,
126            'I' => Self::DopplerWind,
127            'A' => Self::AccumulatedFiveMin,
128            'B' => Self::AccumulatedOneHour,
129            'C' => Self::AccumulatedSinceNine,
130            'D' => Self::AccumulatedPreviousTwentyFour,
131            _ => return Err(anyhow!("{id} is not a valid radar type ID")),
132        };
133
134        Ok(radar_type)
135    }
136
137    pub fn id(&self) -> char {
138        match self {
139            Self::FiveTwelveKm => '1',
140            Self::TwoFiftySixKm => '2',
141            Self::OneTwentyEightKm => '3',
142            Self::SixtyFourKm => '4',
143            Self::DopplerWind => 'I',
144            Self::AccumulatedFiveMin => 'A',
145            Self::AccumulatedOneHour => 'B',
146            Self::AccumulatedSinceNine => 'C',
147            Self::AccumulatedPreviousTwentyFour => 'D',
148        }
149    }
150
151    pub fn size(&self) -> Self {
152        match self {
153            Self::DopplerWind
154            | Self::AccumulatedFiveMin
155            | Self::AccumulatedOneHour
156            | Self::AccumulatedSinceNine
157            | Self::AccumulatedPreviousTwentyFour => Self::OneTwentyEightKm,
158            _ => *self,
159        }
160    }
161
162    pub fn update_frequency(self) -> Duration {
163        match self {
164            Self::AccumulatedSinceNine => Duration::minutes(15),
165            Self::AccumulatedPreviousTwentyFour => Duration::days(1),
166            _ => Duration::minutes(5),
167        }
168    }
169
170    // A delay to accommodate lag between image timestamp and when it appears on FTP
171    pub fn check_after(self) -> Duration {
172        match self {
173            Self::AccumulatedSinceNine => Duration::minutes(15),
174            Self::AccumulatedPreviousTwentyFour => Duration::minutes(10),
175            _ => Duration::minutes(2),
176        }
177    }
178
179    pub fn min_image_count(self) -> i32 {
180        match self {
181            Self::AccumulatedPreviousTwentyFour => 10,
182            Self::AccumulatedSinceNine => 30,
183            _ => 18,
184        }
185    }
186
187    pub fn legend_type(&self) -> RadarLegendType {
188        match self {
189            Self::SixtyFourKm
190            | Self::OneTwentyEightKm
191            | Self::TwoFiftySixKm
192            | Self::FiveTwelveKm => RadarLegendType::Rainfall,
193            Self::DopplerWind => RadarLegendType::DopplerWind,
194            _ => RadarLegendType::AccumulatedRainfall,
195        }
196    }
197}
198
199#[derive(Debug, Clone, Display, EnumIter)]
200pub enum RadarLegendType {
201    Rainfall,
202    AccumulatedRainfall,
203    DopplerWind,
204}
205
206impl RadarLegendType {
207    pub fn id(&self) -> u8 {
208        match self {
209            Self::Rainfall => 0,
210            Self::AccumulatedRainfall => 1,
211            Self::DopplerWind => 2,
212        }
213    }
214}
215
216/// A static legend that serves as the base layer for a radar image.
217#[derive(Debug, Clone)]
218pub struct RadarImageLegend {
219    pub r#type: RadarLegendType,
220    pub png_buf: Vec<u8>,
221}
222
223/// A static layer that overlays geographical information onto a radar image
224#[derive(Debug)]
225pub struct RadarImageFeatureLayer {
226    pub feature: RadarImageFeature,
227    pub size: RadarType,
228    pub radar_id: RadarId,
229    pub png_buf: Vec<u8>,
230    pub filename: String,
231}
232
233#[derive(
234    Clone,
235    Display,
236    EnumString,
237    EnumIter,
238    EnumCount,
239    Debug,
240    PartialEq,
241    Deserialize,
242    Serialize,
243    clap::ValueEnum,
244)]
245#[strum(serialize_all = "lowercase")]
246#[serde(rename_all = "lowercase")]
247pub enum RadarImageFeature {
248    // Ordered by how the layers stack on the BOM website's radar viewer
249    Background,
250    Topography,
251    Range,
252    Waterways,
253    Roads,
254    #[strum(serialize = "wthrDistricts")]
255    #[serde(rename = "wthrDistricts")]
256    #[clap(name = "wthrDistricts")]
257    ForecastDistricts,
258    Rail,
259    Catchments,
260    Locations,
261}
262
263impl RadarImageFeatureLayer {
264    pub fn from_filename(name: &str) -> Result<Self> {
265        // e.g. IDR023.catchments.png
266        let (first, last) = get_dot_indices(name)?;
267        let radar_type_id: char = name[first - 1..first].parse()?;
268        let feature = &name[first + 1..last];
269
270        Ok(Self {
271            feature: RadarImageFeature::from_str(feature)?,
272            size: RadarType::from_id(radar_type_id)?,
273            radar_id: name[3..first - 1].parse()?,
274            png_buf: Vec::new(),
275            filename: name.to_string(),
276        })
277    }
278}
279
280/// A dynamic layer that changes each frame i.e. the actual data from the radar
281#[derive(Debug, Clone)]
282pub struct RadarImageDataLayer {
283    pub radar_type: RadarType,
284    pub png_buf: Vec<u8>,
285    pub radar_id: RadarId,
286    pub datetime: DateTime<Utc>,
287    pub filename: String,
288}
289
290impl PartialEq for RadarImageDataLayer {
291    fn eq(&self, other: &Self) -> bool {
292        self.filename == other.filename
293    }
294}
295
296impl RadarImageDataLayer {
297    pub fn from_filename(name: &str) -> Result<Self> {
298        // e.g. IDR023.T.202311130334.png
299        let (first, last) = get_dot_indices(name)?;
300        let radar_type_id: char = name[first - 1..first].parse()?;
301        let timestamp = &name[first + 3..last];
302        let utc = NaiveDateTime::parse_from_str(timestamp, "%Y%m%d%H%M")?;
303
304        Ok(Self {
305            radar_type: RadarType::from_id(radar_type_id)?,
306            png_buf: Vec::new(),
307            radar_id: name[3..first - 1].parse()?,
308            datetime: DateTime::from_naive_utc_and_offset(utc, Utc),
309            filename: name.to_string(),
310        })
311    }
312
313    pub fn next_datetime(&self) -> DateTime<Utc> {
314        self.datetime + self.radar_type.update_frequency()
315    }
316
317    pub fn expected_next(&self) -> Self {
318        Self {
319            radar_id: self.radar_id,
320            radar_type: self.radar_type,
321            png_buf: Vec::new(),
322            datetime: self.next_datetime(),
323            filename: self.next_filename(),
324        }
325    }
326
327    pub fn next_filename(&self) -> String {
328        format!(
329            "IDR{:02}{1}.T.{2}.png",
330            self.radar_id,
331            self.radar_type.id(),
332            self.next_datetime().format("%Y%m%d%H%M")
333        )
334    }
335}
336
337fn get_dot_indices(name: &str) -> Result<(usize, usize)> {
338    let err = Err(anyhow!("{name} is not a valid radar image file"));
339    let Some(first_dot) = name.find('.') else {
340        return err;
341    };
342    let Some(last_dot) = name.rfind('.') else {
343        return err;
344    };
345    if first_dot == last_dot {
346        return err;
347    };
348    Ok((first_dot, last_dot))
349}
350
351#[derive(Clone, Parser, Debug, Deserialize, Serialize)]
352pub struct RadarImageOptions {
353    pub features: Vec<RadarImageFeature>,
354    pub max_frames: Option<u64>,
355    pub radar_types: Vec<RadarType>,
356    pub remove_header: bool,
357    pub create_png: bool,
358    pub create_apng: bool,
359    pub frame_delay_ms: u16,
360    pub image_dir: PathBuf,
361    pub force: bool,
362    pub open_mpv: bool,
363    pub mpv_args: Vec<String>,
364    pub mpv_ipc_dir: PathBuf,
365}
366
367impl Default for RadarImageOptions {
368    fn default() -> Self {
369        Self {
370            features: vec![
371                RadarImageFeature::Background,
372                RadarImageFeature::Topography,
373                RadarImageFeature::Range,
374                RadarImageFeature::Locations,
375            ],
376            frame_delay_ms: 200,
377            max_frames: Some(24),
378            radar_types: vec![RadarType::OneTwentyEightKm],
379            remove_header: false,
380            create_png: true,
381            create_apng: false,
382            force: false,
383            open_mpv: false,
384            mpv_args: vec![
385                "--stop-screensaver=no".into(),
386                "--geometry=1024x1114".into(),
387                "--auto-window-resize=no".into(),
388                "--loop-playlist".into(),
389                "--no-osc".into(),
390                "--no-osd-bar".into(),
391                "--really-quiet".into(),
392            ],
393            mpv_ipc_dir: Config::default_dirs().run.join("mpv-ipc"),
394            image_dir: Config::default_dirs().state.join("radar-images"),
395        }
396    }
397}
398
399#[derive(Debug)]
400pub struct RadarImageComponents {
401    pub legend: RadarImageLegend,
402    pub feature_layers: Vec<RadarImageFeatureLayer>,
403    pub data_layers: Vec<RadarImageDataLayer>,
404}
405
406#[derive(Debug, Clone)]
407pub struct RadarImageFrame {
408    pub radar_type: RadarType,
409    pub image: DynamicImage,
410    pub radar_id: RadarId,
411    pub datetime: DateTime<Utc>,
412    pub path: PathBuf,
413}
414
415impl RadarImageFrame {
416    pub fn from_path(path: PathBuf) -> Result<Self> {
417        let name = path.file_name().unwrap().to_string_lossy();
418        let (first, last) = get_dot_indices(&name)?;
419        let radar_type_id: char = name[first - 1..first].parse()?;
420        let timestamp = &name[first + 3..last];
421        let utc = NaiveDateTime::parse_from_str(timestamp, "%Y%m%d%H%M")?;
422        let image = ImageReader::open(&path)?.decode()?;
423
424        Ok(Self {
425            radar_type: RadarType::from_id(radar_type_id)?,
426            radar_id: name[3..first - 1].parse()?,
427            datetime: DateTime::from_naive_utc_and_offset(utc, Utc),
428            path,
429            image,
430        })
431    }
432}
433
434impl PartialEq for RadarImageFrame {
435    fn eq(&self, other: &Self) -> bool {
436        self.path == other.path
437    }
438}
439
440fn decode_png(png_buf: &[u8]) -> Result<DynamicImage> {
441    let decoder = PngDecoder::new(png_buf)?;
442    let img = DynamicImage::from_decoder(decoder)?;
443    Ok(img)
444}
445
446pub fn get_radar_image_managers<'a>(
447    id: RadarId,
448    db: &'a mut Database,
449    ftp: &'a mut FtpClient,
450    opts: &'a RadarImageOptions,
451) -> Result<Vec<RadarImageManager>> {
452    let mut managers = Vec::new();
453
454    for radar_type in &opts.radar_types {
455        let feature_layers = if let Ok(layers) = db.get_radar_feature_layers(id, radar_type) {
456            layers
457        } else {
458            info!(
459                "Fetching feature layers for IDR{:02}{}",
460                id,
461                radar_type.id()
462            );
463            let layers = ftp.get_radar_feature_layers(id, *radar_type)?;
464            db.insert_radar_feature_layers(&layers)?;
465            layers
466        };
467
468        let data_layers =
469            if let Ok(layers) = db.get_radar_data_layers(id, radar_type, opts.max_frames) {
470                layers
471            } else {
472                Vec::new()
473            };
474
475        let legend = db.get_radar_legend(radar_type)?;
476        let manager = RadarImageManager::new(
477            id,
478            *radar_type,
479            legend,
480            data_layers,
481            feature_layers,
482            opts.clone(),
483        )?;
484        managers.push(manager);
485    }
486    Ok(managers)
487}
488
489pub fn fetch_new_data_layers(
490    id: RadarId,
491    db: &mut Database,
492    radar_type: &RadarType,
493    ftp: &mut FtpClient,
494    ftp_files: &mut Vec<File>,
495    opts: &RadarImageOptions,
496) -> Result<Vec<RadarImageDataLayer>> {
497    let now = Utc::now();
498
499    let mut new_layers = Vec::new();
500    let mut check_ftp_files = false;
501
502    let existing_names = db.get_radar_data_layer_names(radar_type)?;
503    info!(
504        "Fetching new data layers for IDR{:02}{}",
505        id,
506        radar_type.id()
507    );
508    if let Some(last_name) = existing_names.last() {
509        let mut last_layer = &RadarImageDataLayer::from_filename(last_name).unwrap();
510        loop {
511            let time_since_last = now - last_layer.datetime;
512            if time_since_last > radar_type.update_frequency() * radar_type.min_image_count() {
513                check_ftp_files = true;
514                break;
515            }
516
517            let mut next_layer = last_layer.expected_next();
518            if now < next_layer.datetime + next_layer.radar_type.check_after() {
519                ftp.keepalive()?;
520                break;
521            }
522
523            // Try to download images by their expected filename to avoid listing the whole
524            // directory. If an image is missing more than a minute later than expected,
525            // re-check the FTP in case the duration between files has changed
526            match ftp.get_radar_data_png(&next_layer.filename) {
527                Ok(png_buf) => {
528                    next_layer.png_buf = png_buf;
529                    new_layers.push(next_layer);
530                    last_layer = new_layers.last().unwrap();
531                }
532                Err(e) => {
533                    debug!(
534                        "Failed to download radar data layer {}. {e}",
535                        next_layer.filename
536                    );
537                    if now
538                        > next_layer.datetime
539                            + next_layer.radar_type.check_after()
540                            + Duration::minutes(1)
541                    {
542                        check_ftp_files = true;
543                    }
544                    break;
545                }
546            }
547        }
548    } else {
549        check_ftp_files = true;
550    }
551
552    if check_ftp_files {
553        if ftp_files.is_empty() {
554            for file in ftp.list_radar_data_layers()? {
555                ftp_files.push(file);
556            }
557        }
558        let last_layer = existing_names
559            .last()
560            .map(|n| RadarImageDataLayer::from_filename(n).unwrap());
561        let mut todo = Vec::new();
562        let prefix = format!("IDR{id:02}{}", radar_type.id());
563        let mut count = 0;
564        for file in ftp_files.iter().filter(|f| f.name().starts_with(&prefix)) {
565            count += 1;
566            let layer = RadarImageDataLayer::from_filename(file.name())?;
567            if existing_names.contains(&layer.filename) || new_layers.contains(&layer) {
568                continue;
569            }
570            if let Some(ref last) = last_layer {
571                if layer.datetime < last.datetime {
572                    continue;
573                }
574            }
575            todo.push(layer);
576        }
577        if let Some(max_frames) = opts.max_frames {
578            if todo.len() > max_frames as usize {
579                let idx = todo.len() - max_frames as usize;
580                todo.drain(..idx);
581            }
582        }
583        for layer in todo.iter_mut() {
584            layer.png_buf = ftp.get_radar_data_png(&layer.filename)?;
585        }
586        new_layers.append(&mut todo);
587        if count == 0 {
588            warn!(
589                "No images for {radar_type} found on FTP. \
590                Some radars have limited data available. Consider adjusting your config"
591            );
592        }
593    }
594    Ok(new_layers)
595}
596
597pub fn update_radar_images(
598    managers: &mut Vec<RadarImageManager>,
599    db: &mut Database,
600    ftp: &mut FtpClient,
601) -> Result<DateTime<Utc>> {
602    // Cache the FTP file list so we don't re-download it for each radar type
603    let mut ftp_files = Vec::new();
604    let mut next_datetimes = Vec::new();
605    for m in managers {
606        let new_data_layers =
607            fetch_new_data_layers(m.radar_id, db, &m.radar_type, ftp, &mut ftp_files, &m.opts)?;
608        if !new_data_layers.is_empty() {
609            db.insert_radar_data_layers(&new_data_layers)?;
610            m.add_data_layers(new_data_layers);
611        }
612        if let Some(last) = m.data_layers.last() {
613            next_datetimes.push(last.next_datetime() + last.radar_type.check_after());
614        }
615    }
616    let next_layer_due = next_datetimes.into_iter().min().unwrap();
617    let next_check = if next_layer_due > Utc::now() {
618        next_layer_due
619    } else {
620        Utc::now() + Duration::seconds(60)
621    };
622    Ok(next_check)
623}
624
625pub struct RadarImageManager {
626    image_dir: PathBuf,
627    radar_type: RadarType,
628    radar_id: RadarId,
629    legend: RadarImageLegend,
630    feature_layers: Vec<RadarImageFeatureLayer>,
631    data_layers: Vec<RadarImageDataLayer>,
632    frames: Vec<RadarImageFrame>,
633    pub opts: RadarImageOptions,
634    mpv: Option<MpvRadarViewer>,
635}
636
637impl Display for RadarImageManager {
638    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
639        write!(f, "IDR{:02}{}", self.radar_id, self.radar_type.id())
640    }
641}
642
643impl RadarImageManager {
644    pub fn new(
645        radar_id: RadarId,
646        radar_type: RadarType,
647        legend: RadarImageLegend,
648        data_layers: Vec<RadarImageDataLayer>,
649        feature_layers: Vec<RadarImageFeatureLayer>,
650        opts: RadarImageOptions,
651    ) -> Result<Self> {
652        let mpv = MpvRadarViewer::from_opts(radar_id, radar_type, &opts)?;
653        let image_dirname = format!("IDR{:02}{}", radar_id, radar_type.id());
654        let image_dir = opts.image_dir.join(image_dirname);
655        let mut frames = Vec::new();
656        if let Ok(entries) = fs::read_dir(&image_dir) {
657            for entry in entries {
658                if let Ok(frame) = RadarImageFrame::from_path(entry.unwrap().path()) {
659                    frames.push(frame);
660                }
661            }
662        }
663
664        Ok(Self {
665            mpv,
666            radar_id,
667            radar_type,
668            legend,
669            data_layers,
670            feature_layers,
671            frames,
672            image_dir,
673            opts,
674        })
675    }
676
677    fn construct_frames(&mut self) -> Result<()> {
678        let mut bottom_layer = decode_png(&self.legend.png_buf)?;
679        let mut top_layer = DynamicImage::ImageRgba8(RgbaImage::new(512, 512));
680
681        if self.opts.force {
682            self.frames.clear();
683        }
684
685        let mut todo = Vec::new();
686        for layer in &self.data_layers {
687            if !self.frames.iter().any(|f| f.datetime == layer.datetime) {
688                todo.push(layer);
689            }
690        }
691
692        if todo.is_empty() {
693            return Ok(());
694        }
695
696        for feature in &self.opts.features {
697            match feature {
698                RadarImageFeature::Background | RadarImageFeature::Topography => {
699                    self.overlay_feature(&mut bottom_layer, feature)?
700                }
701                _ => self.overlay_feature(&mut top_layer, feature)?,
702            }
703        }
704
705        for layer in todo {
706            debug!("Constructing frame for {}", layer.filename);
707            let mut data_layer = decode_png(&layer.png_buf)?;
708            if self.opts.remove_header {
709                self.remove_header(&mut data_layer);
710            }
711            let mut final_image = bottom_layer.clone();
712            imageops::overlay(&mut final_image, &data_layer, 0, 0);
713            imageops::overlay(&mut final_image, &top_layer, 0, 0);
714            let frame = RadarImageFrame {
715                radar_id: layer.radar_id,
716                radar_type: layer.radar_type,
717                path: self.image_dir.join(&layer.filename),
718                datetime: layer.datetime,
719                image: final_image,
720            };
721            self.frames.push(frame);
722        }
723        Ok(())
724    }
725
726    fn remove_header(&self, image: &mut DynamicImage) {
727        let DynamicImage::ImageRgba8(ref mut rgba_image) = image else {
728            return;
729        };
730        for y in 0..16 {
731            for x in 0..512 {
732                rgba_image.put_pixel(x, y, Rgba([0, 0, 0, 0]));
733            }
734        }
735    }
736
737    fn overlay_feature(&self, base: &mut DynamicImage, feature: &RadarImageFeature) -> Result<()> {
738        let Some(layer) = self.feature_layers.iter().find(|l| l.feature == *feature) else {
739            warn!("Missing {feature} feature");
740            return Ok(());
741        };
742        imageops::overlay(base, &decode_png(&layer.png_buf)?, 0, 0);
743        Ok(())
744    }
745
746    fn sort_frames(&mut self) {
747        self.frames
748            .sort_by(|a, b| a.datetime.partial_cmp(&b.datetime).unwrap());
749    }
750
751    fn remove_images(&mut self, idx: usize) -> Result<Vec<RadarImageDataLayer>> {
752        // Prevent a panic if the database is somehow out of sync with the filesystem
753        let didx = idx.min(self.data_layers.len());
754        let removed = self.data_layers.drain(..didx).collect();
755        for frame in self.frames.drain(..idx) {
756            if frame.path.exists() {
757                debug!("Deleting old radar image {}", frame.path.display());
758                fs::remove_file(&frame.path)?;
759            }
760        }
761        Ok(removed)
762    }
763
764    pub fn prune(&mut self) -> Result<Vec<RadarImageDataLayer>> {
765        let mut removed = Vec::new();
766        self.sort_frames();
767        if let Some(max_frames) = self.opts.max_frames {
768            if self.frames.len() > max_frames as usize {
769                let idx = self.frames.len() - max_frames as usize;
770                debug!("{self} frame limit of {max_frames} exceeded by {idx}");
771                removed.extend(self.remove_images(idx)?);
772            }
773        }
774
775        let mut iter = self.frames.iter().enumerate().peekable();
776        while let Some((idx, frame)) = iter.next() {
777            if let Some((_, next_frame)) = iter.peek() {
778                let gap = next_frame.datetime - frame.datetime;
779                let max_gap = frame.radar_type.update_frequency() * 4;
780                if next_frame.datetime - frame.datetime > max_gap {
781                    debug!(
782                        "{self} has gap of {} minutes between images. Removing old images",
783                        gap.num_minutes()
784                    );
785                    removed.extend(self.remove_images(idx + 1)?);
786                    break;
787                }
788            }
789        }
790        Ok(removed)
791    }
792    pub fn write_pngs(&mut self) -> Result<()> {
793        self.construct_frames()?;
794        fs::create_dir_all(&self.image_dir)?;
795        for frame in &self.frames {
796            if frame.path.exists() && !self.opts.force {
797                continue;
798            }
799            let file = fs::File::create(&frame.path)?;
800            let mut writer = BufWriter::new(file);
801            frame.image.write_to(&mut writer, ImageOutputFormat::Png)?;
802        }
803        Ok(())
804    }
805
806    pub fn add_data_layers(&mut self, mut layers: Vec<RadarImageDataLayer>) {
807        self.data_layers.append(&mut layers);
808    }
809
810    pub fn open_images(&mut self) -> Result<()> {
811        self.write_pngs()?;
812        self.sort_frames();
813        let paths: Vec<&Path> = self.frames.iter().map(|f| f.path.as_path()).collect();
814        let mpv = self.mpv.as_mut().unwrap();
815        mpv.open_images(&paths, &self.opts.mpv_args)?;
816        Ok(())
817    }
818
819    pub fn create_apng(&mut self) -> Result<()> {
820        self.construct_frames()?;
821        let start = self.frames.first().unwrap().datetime.format("%Y%m%d%H%M");
822        let end = self.frames.last().unwrap().datetime.format("%Y%m%d%H%M");
823        let filename = format!("{}.T.{}-{}.png", self, start, end);
824        let path = self.image_dir.join(filename);
825        let out_file = fs::File::create(path)?;
826        let mut writer = BufWriter::new(out_file);
827        let mut pngs = Vec::new();
828        for frame in &self.frames {
829            let png = apng::load_dynamic_image(frame.image.clone())?;
830            pngs.push(png);
831        }
832
833        let config = apng::create_config(&pngs, None).unwrap();
834        let mut encoder = apng::Encoder::new(&mut writer, config).unwrap();
835        let apng_frame = apng::Frame {
836            delay_num: Some(self.opts.frame_delay_ms),
837            delay_den: Some(1000),
838            ..Default::default()
839        };
840        encoder.encode_all(pngs, Some(&apng_frame)).unwrap();
841
842        Ok(())
843    }
844}
845
846struct MpvRadarViewer {
847    handle: Option<Child>,
848    socket_path: PathBuf,
849    radar_id: RadarId,
850    radar_type: RadarType,
851    frame_delay: f32,
852}
853
854impl MpvRadarViewer {
855    pub fn from_opts(
856        radar_id: RadarId,
857        radar_type: RadarType,
858        opts: &RadarImageOptions,
859    ) -> Result<Option<Self>> {
860        if opts.open_mpv {
861            let output = std::process::Command::new("mpv")
862                .arg("--version")
863                .output()
864                .context("Failed to open MPV")?;
865
866            let stdout = String::from_utf8_lossy(&output.stdout);
867            if !output.status.success() {
868                return Err(anyhow!("Failed to open MPV. {stdout}"));
869            } else {
870                debug!("Using {}", stdout);
871            }
872
873            let socket_name = format!("IDR{:02}{}.sock", radar_id, radar_type.id());
874            Ok(Some(Self {
875                radar_id,
876                radar_type,
877                socket_path: opts.mpv_ipc_dir.join(socket_name),
878                handle: None,
879                frame_delay: opts.frame_delay_ms as f32 / 1000.0,
880            }))
881        } else {
882            Ok(None)
883        }
884    }
885
886    pub fn open_images(&mut self, paths: &[&Path], args: &[String]) -> Result<()> {
887        let mut mpv_is_running = false;
888        if let Some(ref mut handle) = self.handle {
889            match handle.try_wait() {
890                Ok(Some(status)) => {
891                    warn!("MPV {} exited with: {}", self.socket_path.display(), status);
892                }
893                Ok(None) => mpv_is_running = true,
894                Err(e) => warn!("Error waiting for MPV {} {e}", self.socket_path.display()),
895            }
896        }
897        if !mpv_is_running {
898            self.start(paths, args)?;
899        }
900        let mpv = self.connect()?;
901        mpv.playlist_clear()?;
902        let command = MpvCommand::LoadFile {
903            file: paths[0].to_string_lossy().to_string(),
904            option: PlaylistAddOptions::Replace,
905        };
906        mpv.run_command(command)?;
907        for path in &paths[1..] {
908            let command = MpvCommand::LoadFile {
909                file: path.to_string_lossy().to_string(),
910                option: PlaylistAddOptions::Append,
911            };
912            mpv.run_command(command).unwrap();
913        }
914        Ok(())
915    }
916
917    fn connect(&self) -> Result<Mpv> {
918        let mut attempts = 0;
919        let mpv = loop {
920            match Mpv::connect(&self.socket_path.to_string_lossy()) {
921                Ok(mpv) => break mpv,
922                _ => {
923                    sleep(Duration::milliseconds(20).to_std().unwrap());
924                    attempts += 1;
925                    if attempts > 10 {
926                        return Err(anyhow!(
927                            "Failed to connect to MPV at {}",
928                            self.socket_path.display()
929                        ));
930                    }
931                }
932            }
933        };
934        Ok(mpv)
935    }
936
937    fn start(&mut self, image_paths: &[&Path], args: &[String]) -> Result<()> {
938        let mut ipc_arg = OsString::from("--input-ipc-server=");
939        ipc_arg.push(&self.socket_path);
940        let app_id = format!("mpv-radar-IDR{:02}{}", self.radar_id, self.radar_type.id());
941        fs::create_dir_all(self.socket_path.parent().unwrap())?;
942        let child = std::process::Command::new("mpv")
943            .arg(ipc_arg)
944            .arg(format!("--wayland-app-id={app_id}"))
945            .arg(format!("--x11-name={app_id}"))
946            .arg(format!("--image-display-duration={}", self.frame_delay))
947            .arg("--loop-file=no")
948            .args(args)
949            .args(image_paths)
950            .spawn()
951            .unwrap();
952
953        self.handle = Some(child);
954        Ok(())
955    }
956}