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
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
use crate::brain::boost_active_rooms::update_boosted_rooms;
use crate::brain::boost_active_rooms::AppliedBoosts;
use crate::brain::immersion_heater::follow_ih_model;
use crate::brain::modes::heating_mode::{HeatingMode, SharedData};
use crate::brain::modes::intention::Intention;
use crate::brain::modes::{HeatingState, InfoCache};
use crate::brain::python_like::control::devices::Device;
use crate::brain::{modes, Brain, BrainFailure};
use crate::io::IOBundle;
use crate::time_util::mytime::TimeProvider;
use config::PythonBrainConfig;
use itertools::Itertools;
use log::{debug, error, info, trace, warn};
use std::collections::HashSet;
use std::time::{Duration, Instant};
use tokio::runtime::Runtime;

use super::modes::working_temp::WorkingTemperatureRange;

pub mod config;
pub mod control;

#[cfg(test)]
mod test;

// Functions for getting the max working temperature.

pub struct FallbackWorkingRange {
    previous: Option<(WorkingTemperatureRange, Instant)>,
    default: WorkingTemperatureRange,
}

impl FallbackWorkingRange {
    pub fn new(default: WorkingTemperatureRange) -> Self {
        FallbackWorkingRange {
            previous: None,
            default,
        }
    }

    pub fn get_fallback(&self) -> &WorkingTemperatureRange {
        const PREVIOUS_RANGE_VALID_FOR: Duration = Duration::from_secs(60 * 30);

        if let Some((range, updated)) = &self.previous {
            if (*updated + PREVIOUS_RANGE_VALID_FOR) > Instant::now() {
                warn!("Using last working range as fallback: {}", range);
                return range;
            }
        }
        warn!(
            "No recent previous range to use, using default {}",
            &self.default
        );
        &self.default
    }

    pub fn update(&mut self, range: WorkingTemperatureRange) {
        self.previous.replace((range, Instant::now()));
    }
}

pub struct PythonBrain {
    config: PythonBrainConfig,
    /// The current state. None if just started and need to figure out what state to be in.
    heating_mode: Option<HeatingMode>,
    shared_data: SharedData,
    applied_boosts: AppliedBoosts,
    /// Whether we just reloaded / just restarted
    /// This is used to print additional one-time debugging information.
    just_reloaded: bool,
}

impl PythonBrain {
    pub fn new(config: PythonBrainConfig) -> Self {
        Self {
            shared_data: SharedData::new(FallbackWorkingRange::new(
                config.default_working_range.clone(),
            )),
            config,
            heating_mode: None,
            applied_boosts: AppliedBoosts::new(),
            just_reloaded: true,
        }
    }

    fn provide_debug_info(
        &mut self,
        io_bundle: &mut IOBundle,
        time_provider: &impl TimeProvider,
    ) -> Result<(), BrainFailure> {
        // Provide information on what active devices have actually been seen.
        const CHECK_MINUTES: usize = 30;
        let active_devices: HashSet<Device> = io_bundle
            .active_devices()
            .get_active_devices_within(&time_provider.get_utc_time(), CHECK_MINUTES)?
            .into_iter()
            .collect();

        // Accumulate all devices and log which ones are found and which aren't
        let mut devices_in_config = HashSet::new();
        for part in self.config.get_boost_active_rooms().get_parts() {
            devices_in_config.insert(part.get_device().clone());
        }
        info!(
            "All devices used in config: {:?}",
            prettify_devices(devices_in_config.clone())
        );

        let mut found = HashSet::new();
        let mut not_found = HashSet::new();
        for device in devices_in_config.iter().cloned() {
            if active_devices.contains(&device) {
                found.insert(device);
            } else {
                not_found.insert(device);
            }
        }
        info!(
            "The following devices were found within the last {} minutes: {:?}",
            CHECK_MINUTES,
            prettify_devices(found)
        );
        info!(
            "The following devices were NOT found within the last {} minutes: {:?}",
            CHECK_MINUTES,
            prettify_devices(not_found)
        );

        let unused_devices =
            prettify_devices(active_devices.difference(&devices_in_config).cloned());
        info!(
            "The following devices were active but not used in the config: {:?}",
            unused_devices
        );

        Ok(())
    }
}

fn prettify_devices(list: impl IntoIterator<Item = Device>) -> Vec<String> {
    list.into_iter()
        .sorted()
        .map(|device| format!("{}", device))
        .collect_vec()
}

impl Default for PythonBrain {
    fn default() -> Self {
        PythonBrain::new(PythonBrainConfig::default())
    }
}

impl Brain for PythonBrain {
    fn run(
        &mut self,
        runtime: &Runtime,
        io_bundle: &mut IOBundle,
        time_provider: &impl TimeProvider,
    ) -> Result<(), BrainFailure> {
        if self.just_reloaded {
            self.provide_debug_info(io_bundle, time_provider)?;
            self.just_reloaded = false;
        }

        // Update our value of wiser's state if possible.
        match runtime
            .block_on(io_bundle.wiser().get_heating_on())
            .map(HeatingState::new)
        {
            Ok(wiser_heating_on_new) => {
                self.shared_data.last_successful_contact = Instant::now();
                if self.shared_data.last_wiser_state != wiser_heating_on_new {
                    self.shared_data.last_wiser_state = wiser_heating_on_new;
                    info!(target: "wiser", "Wiser heating state changed to {}", wiser_heating_on_new);
                }
            }
            Err(_) => {
                // The wiser hub often doesn't respond. If this happens, carry on heating for a maximum of 1 hour.
                error!(target: "wiser", "Failed to get whether heating was on. Using old value");
                if Instant::now() - self.shared_data.last_successful_contact
                    > Duration::from_secs(60 * 60)
                {
                    error!(target: "wiser", "Saying off - last successful contact too long ago: {}s ago", self.shared_data.last_successful_contact.elapsed().as_secs());
                    self.shared_data.last_wiser_state = HeatingState::OFF;
                }
            }
        }

        let working_temp_range = modes::heating_mode::get_working_temp_fn(
            self.shared_data.get_fallback_working_range(),
            io_bundle.wiser(),
            &self.config,
            runtime,
        );
        let mut wiser_heating_state = self.shared_data.last_wiser_state;

        let ignore_wiser_heating_slot = self
            .config
            .get_no_heating()
            .iter()
            .find(|slot| slot.contains(&time_provider.get_utc_time()));

        if let Some(slot) = ignore_wiser_heating_slot {
            debug!("Ignoring wiser heating due to slot: {slot}. Pretending its off. It was actually: {wiser_heating_state}");
            wiser_heating_state = HeatingState::OFF;
        }

        let mut info_cache = InfoCache::create(wiser_heating_state, working_temp_range);

        // Heating mode switches
        match &mut self.heating_mode {
            None => {
                warn!("No current mode - probably just started up - Running same logic as ending a state.");
                let intention = Intention::finish();
                let new_state = modes::heating_mode::handle_intention(
                    intention,
                    &mut info_cache,
                    io_bundle,
                    &self.config,
                    runtime,
                    &time_provider.get_utc_time(),
                )?;
                let mut new_mode = match new_state {
                    None => {
                        error!("Got no next state - should have had something since we didn't keep state. Going to off.");
                        HeatingMode::off()
                    }
                    Some(mode) => mode,
                };
                info!("Entering mode: {:?}", new_mode);
                new_mode.enter(&self.config, runtime, io_bundle)?;
                self.heating_mode = Some(new_mode);
                self.shared_data.notify_entered_state();
            }
            Some(cur_mode) => {
                trace!("Current mode: {:?}", cur_mode);
                let next_mode = cur_mode.update(
                    &mut self.shared_data,
                    runtime,
                    &self.config,
                    io_bundle,
                    &mut info_cache,
                    time_provider,
                )?;
                if let Some(next_mode) = next_mode {
                    if &next_mode != cur_mode {
                        info!("Transitioning from {:?} to {:?}", cur_mode, next_mode);
                        cur_mode.transition_to(next_mode, &self.config, runtime, io_bundle)?;
                        self.shared_data.notify_entered_state();
                    } else {
                        debug!("Current mode same as current. Not switching.");
                    }
                }
            }
        }

        // Immersion heater
        let temps = runtime.block_on(info_cache.get_temps(io_bundle.temperature_manager()));
        if temps.is_err() {
            error!(
                "Error retrieving temperatures: {}",
                temps.as_ref().unwrap_err()
            );
            if io_bundle.misc_controls().try_get_immersion_heater()? {
                error!("Turning off immersion heater since we didn't get temperatures");
                io_bundle.misc_controls().try_set_immersion_heater(false)?;
            }
            return Ok(());
        }
        let temps = temps.ok().unwrap();
        follow_ih_model(
            time_provider,
            &temps,
            io_bundle.misc_controls().as_ih(),
            self.config.get_immersion_heater_model(),
        )?;

        // Active device/room boosting.
        match io_bundle
            .active_devices()
            .get_active_devices(&time_provider.get_utc_time())
        {
            Ok(devices) => {
                match runtime.block_on(update_boosted_rooms(
                    &mut self.applied_boosts,
                    self.config.get_boost_active_rooms(),
                    devices,
                    io_bundle.wiser(),
                )) {
                    Ok(_) => {}
                    Err(error) => {
                        warn!("Error boosting active rooms: {}", error);
                    }
                }
            }
            Err(err) => error!("Error getting active devices: {}", err),
        }

        Ok(())
    }

    fn reload_config(&mut self) {
        match config::try_read_python_brain_config() {
            None => error!("Failed to read python brain config, keeping previous config"),
            Some(config) => {
                self.config = config;
                self.just_reloaded = true;
                info!("Reloaded config");
            }
        }
    }
}