1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
use chrono::{DateTime, Duration, TimeZone, Utc};
use itertools::Itertools;
use log::warn;
use rev_lines::RevLines;
use std::{collections::HashMap, fs::File, io::BufReader};

use crate::{
    brain::{
        python_like::control::devices::{ActiveDevices, Device},
        BrainFailure,
    },
    brain_fail,
    config::DevicesFromFileConfig,
};

pub mod dummy;

pub struct DevicesFromFile {
    file: String,
    active_within_minutes: usize,
}

impl DevicesFromFile {
    pub fn create(config: &DevicesFromFileConfig) -> Self {
        Self::new(
            config.get_file().to_owned(),
            config.get_active_within_minutes(),
        )
    }

    pub fn new(file: String, active_within_minutes: usize) -> Self {
        Self {
            file,
            active_within_minutes,
        }
    }
}

impl ActiveDevices for DevicesFromFile {
    fn get_active_devices(&mut self, time: &DateTime<Utc>) -> Result<Vec<Device>, BrainFailure> {
        self.get_active_devices_within(time, self.active_within_minutes)
    }

    fn get_active_devices_within(
        &mut self,
        time: &DateTime<Utc>,
        minutes: usize,
    ) -> Result<Vec<Device>, BrainFailure> {
        let file = File::open(&self.file).map_err(|err| {
            brain_fail!(format!("Failed to open {} for reading: {}", self.file, err))
        })?;

        let rev_lines = RevLines::new(BufReader::new(file))
            .map_err(|err| brain_fail!(format!("Failed to read backwards: {}", err)))?;

        let mut device_map: HashMap<Device, DateTime<Utc>> = HashMap::new();

        let cut_off = time.clone() - Duration::seconds(60 * minutes as i64);

        for line in rev_lines {
            match parse_line(&line) {
                Err(msg) => {
                    warn!("Error parsing active device line '{}' => {}", line, msg);
                    continue;
                }
                Ok((device, time)) => {
                    if time < cut_off {
                        //println!("reached cut off time: {}", cut_off);
                        break;
                    }
                    device_map.entry(device).or_insert(time);
                }
            }
        }

        Ok(device_map.into_keys().collect_vec())
    }
}

/// Parse a arp log line. Currently in the format:
/// 2023-12-14T11:58:24+00:00 58:94:6b:b3:ab:7c 192.168.0.27 PlayroomServer
fn parse_line(s: &str) -> Result<(Device, DateTime<Utc>), String> {
    let mut split = s.split(' ');

    let time_part = split
        .next()
        .ok_or_else(|| "No time part separated by ' ' (1st column)".to_owned())?;

    let time = DateTime::parse_from_str(time_part, "%Y-%m-%dT%H:%M:%S%:z")
        .map(|dt| Utc.from_utc_datetime(&dt.naive_utc()))
        .map_err(|err| format!("Invalid date: '{}': {}", time_part, err))?;

    let _mac = split
        .next()
        .ok_or_else(|| "No mac addr part separated by ' ' (2nd column)".to_owned())?;

    let _ip = split
        .next()
        .ok_or_else(|| "No ip addr part separated by ' ' (3rd column)".to_owned())?;

    let device_name_part = split
        .next()
        .ok_or_else(|| "No device name part found separated by ' ' (4th column)".to_owned())?;

    if device_name_part.is_empty() {
        return Err("Device name empty!".to_owned());
    }

    let device = Device::new(device_name_part.to_owned());

    Ok((device, time))
}

#[allow(clippy::zero_prefixed_literal)]
#[cfg(test)]
mod test {
    use crate::brain::python_like::control::devices::{ActiveDevices, Device};
    use crate::io::devices::DevicesFromFile;
    use chrono::{NaiveDate, TimeZone, Utc};
    use itertools::Itertools;

    use super::parse_line;

    #[test]
    fn test_parse() {
        let s = "2023-02-12T09:59:54+00:00 cc:32:e5:7c:a5:94 192.168.0.17 TP-LINK";
        let (device, time) = parse_line(s).unwrap();
        assert_eq!(device, Device::new("TP-LINK".to_owned()));
        let expected_time = Utc.from_utc_datetime(
            &NaiveDate::from_ymd_opt(2023, 02, 12)
                .unwrap()
                .and_hms_opt(09, 59, 54)
                .unwrap(),
        );
        assert_eq!(time, expected_time);
    }

    #[test]
    fn test_parse_daylight_savings() {
        let s = "2023-03-26T19:06:44+01:00 58:94:6b:b3:ab:7c 192.168.0.27 PlayroomServer";
        let (device, time) = parse_line(s).unwrap();
        assert_eq!(device, Device::new("PlayroomServer".to_owned()));
        let expected_time = Utc.from_utc_datetime(
            &NaiveDate::from_ymd_opt(2023, 03, 26)
                .unwrap()
                .and_hms_opt(18, 06, 44)
                .unwrap(),
        );
        assert_eq!(time, expected_time);
    }

    #[test]
    fn test_parse_file() {
        let time = Utc.from_utc_datetime(
            &NaiveDate::from_ymd_opt(2023, 12, 14)
                .unwrap()
                .and_hms_opt(12, 58, 29)
                .unwrap(),
        );
        let mut devices_from_file =
            DevicesFromFile::new("test/python_brain/active_devices/arp-log.txt".to_owned(), 8);
        let mut active_devices = devices_from_file
            .get_active_devices(&time)
            .expect("Should work!")
            .into_iter()
            .map(|device| format!("{}", device))
            .sorted()
            .collect_vec();

        let mut expected: Vec<String> = vec![
            "PlayroomServer".into(),
            "VirginCableRouter".into(),
            "TP-LINK".into(),
            "OfficeComputer".into(),
            "LeoPhone".into(),
            "JamesComputer".into(),
            "Printer".into(),
            "InvensysControls".into(),
            "PI2".into(),
            "SittingRoomTV".into(),
            "JamesPhone".into(),
            // NOT TP-LINK2!
        ];
        expected.sort();

        assert_eq!(expected, active_devices);
    }
}