From 59994c75c105c48c9644668e8cb98c9703617f82 Mon Sep 17 00:00:00 2001
From: Alex Janka <alex@alexjanka.com>
Date: Sun, 29 Dec 2024 16:20:59 +1100
Subject: [PATCH] tesla charge rate controller: builds

---
 tesla-charge-controller/Cargo.toml            |   1 +
 tesla-charge-controller/src/api/http.rs       |   1 +
 tesla-charge-controller/src/api/mod.rs        |  14 +-
 tesla-charge-controller/src/config.rs         | 161 ++++++++++++++++--
 tesla-charge-controller/src/control.rs        |  36 ++--
 tesla-charge-controller/src/main.rs           |  56 ++++--
 tesla-charge-controller/src/server/mod.rs     |  65 +++----
 .../webapp}/index.html                        |   0
 .../webapp}/info/index.html                   |   0
 .../webapp}/pid/index.html                    |   0
 .../webapp}/regulator/index.html              |   0
 .../webapp}/script.js                         |   0
 .../webapp}/shutoff/index.html                |   0
 .../webapp}/style.css                         |   0
 14 files changed, 259 insertions(+), 75 deletions(-)
 rename {webapp => tesla-charge-controller/webapp}/index.html (100%)
 rename {webapp => tesla-charge-controller/webapp}/info/index.html (100%)
 rename {webapp => tesla-charge-controller/webapp}/pid/index.html (100%)
 rename {webapp => tesla-charge-controller/webapp}/regulator/index.html (100%)
 rename {webapp => tesla-charge-controller/webapp}/script.js (100%)
 rename {webapp => tesla-charge-controller/webapp}/shutoff/index.html (100%)
 rename {webapp => tesla-charge-controller/webapp}/style.css (100%)

diff --git a/tesla-charge-controller/Cargo.toml b/tesla-charge-controller/Cargo.toml
index 69c3254..86ad467 100644
--- a/tesla-charge-controller/Cargo.toml
+++ b/tesla-charge-controller/Cargo.toml
@@ -33,6 +33,7 @@ lazy_static = "1.5.0"
 rand = "0.8.5"
 reqwest = { version = "0.12.9", default-features = false, features = [
 	"rustls-tls",
+	"json",
 ] }
 eyre = "0.6.12"
 
diff --git a/tesla-charge-controller/src/api/http.rs b/tesla-charge-controller/src/api/http.rs
index 30153ff..477db16 100644
--- a/tesla-charge-controller/src/api/http.rs
+++ b/tesla-charge-controller/src/api/http.rs
@@ -83,6 +83,7 @@ impl Vehicle {
             .timeout(std::time::Duration::from_secs(5))
             .send()
             .await?;
+
         let response: ApiResponseOuter = data.json().await?;
         let response = response.response;
 
diff --git a/tesla-charge-controller/src/api/mod.rs b/tesla-charge-controller/src/api/mod.rs
index 01e7bf5..c3777d9 100644
--- a/tesla-charge-controller/src/api/mod.rs
+++ b/tesla-charge-controller/src/api/mod.rs
@@ -52,24 +52,26 @@ impl Car {
 }
 
 impl CarState {
-    pub fn charge_state(&self) -> Option<&ChargeState> {
-        if self.is_outdated() {
+    pub async fn charge_state(&self) -> Option<&ChargeState> {
+        if self.is_outdated().await {
             None
         } else {
             self.charge_state.as_ref()
         }
     }
 
-    pub fn is_charging(&self) -> bool {
-        self.charge_state().is_some_and(ChargeState::is_charging)
+    pub async fn is_charging(&self) -> bool {
+        self.charge_state()
+            .await
+            .is_some_and(ChargeState::is_charging)
     }
 
     pub fn data_age(&self) -> std::time::Duration {
         std::time::Instant::now().duration_since(self.data_received)
     }
 
-    fn is_outdated(&self) -> bool {
-        self.data_age() > crate::CONFIG.read().car_state_interval
+    async fn is_outdated(&self) -> bool {
+        self.data_age() > crate::config::access_config().await.car_state_interval
     }
 }
 
diff --git a/tesla-charge-controller/src/config.rs b/tesla-charge-controller/src/config.rs
index 2c8c811..579612c 100644
--- a/tesla-charge-controller/src/config.rs
+++ b/tesla-charge-controller/src/config.rs
@@ -1,5 +1,114 @@
 use serde::{Deserialize, Serialize};
 
+static CONFIG_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
+pub(super) static CONFIG: std::sync::OnceLock<tokio::sync::RwLock<Config>> =
+    std::sync::OnceLock::new();
+
+pub async fn access_config<'a>() -> tokio::sync::RwLockReadGuard<'a, Config> {
+    CONFIG.get().unwrap().read().await
+}
+
+pub(super) struct ConfigWatcher {
+    _debouncer: notify_debouncer_mini::Debouncer<notify_debouncer_mini::notify::RecommendedWatcher>,
+    _handle: tokio::task::JoinHandle<()>,
+}
+
+impl ConfigWatcher {
+    pub fn new(path: impl AsRef<std::path::Path>) -> Option<Self> {
+        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
+
+        let mut debouncer =
+            notify_debouncer_mini::new_debouncer(std::time::Duration::from_secs(1), move |v| {
+                tx.send(v).unwrap();
+            })
+            .ok()?;
+
+        debouncer
+            .watcher()
+            .watch(
+                path.as_ref(),
+                notify_debouncer_mini::notify::RecursiveMode::NonRecursive,
+            )
+            .ok()?;
+
+        let config_path = std::path::PathBuf::from(path.as_ref());
+
+        let handle = tokio::task::spawn(async move {
+            loop {
+                match rx.recv().await {
+                    Some(Ok(_event)) => {
+                        let mut config = Config::load(&config_path);
+                        config.validate();
+
+                        if let Err(e) = overwrite_config(config).await {
+                            log::error!("{e:?}");
+                        }
+                    }
+                    Some(Err(e)) => log::error!("Error {e:?} from watcher"),
+                    None => {}
+                }
+            }
+        });
+
+        Some(Self {
+            _debouncer: debouncer,
+            _handle: handle,
+        })
+    }
+}
+
+async fn overwrite_config(config: Config) -> eyre::Result<()> {
+    *CONFIG
+        .get()
+        .ok_or(eyre::eyre!("could not get config"))?
+        .write()
+        .await = config;
+    Ok(())
+}
+
+pub fn init_config(path: impl AsRef<std::path::Path>) -> Option<ConfigWatcher> {
+    log::trace!("loading config...");
+    let config = Config::load_and_save_defaults(&path);
+    log::trace!("watching config for changes...");
+    let config_watcher = ConfigWatcher::new(&path);
+    let _ = CONFIG_PATH.get_or_init(|| path.as_ref().to_path_buf());
+    CONFIG.set(tokio::sync::RwLock::new(config)).unwrap();
+
+    config_watcher
+}
+
+pub struct ConfigHandle<'a> {
+    handle: tokio::sync::RwLockWriteGuard<'a, Config>,
+}
+
+impl<'a> core::ops::Deref for ConfigHandle<'a> {
+    type Target = tokio::sync::RwLockWriteGuard<'a, Config>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.handle
+    }
+}
+
+impl std::ops::DerefMut for ConfigHandle<'_> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.handle
+    }
+}
+
+impl Drop for ConfigHandle<'_> {
+    fn drop(&mut self) {
+        if let Err(e) = self.save() {
+            log::error!("error saving config on drop of handle: {e:?}");
+        }
+    }
+}
+
+pub async fn write_to_config<'a>() -> ConfigHandle<'a> {
+    ConfigHandle {
+        handle: CONFIG.get().unwrap().write().await,
+    }
+}
+
 #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
 #[serde(default)]
 pub struct Config {
@@ -34,20 +143,6 @@ impl Default for Config {
     }
 }
 
-impl common::config::ConfigTrait for Config {
-    fn load_and_save_defaults(path: impl AsRef<std::path::Path>) -> Self {
-        todo!()
-    }
-
-    fn save(&self) -> eyre::Result<()> {
-        todo!()
-    }
-
-    fn is_valid(&self) -> bool {
-        todo!()
-    }
-}
-
 #[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq)]
 #[serde(default)]
 pub struct PidControls {
@@ -67,3 +162,41 @@ impl Default for PidControls {
         }
     }
 }
+
+impl Config {
+    fn load(path: impl AsRef<std::path::Path>) -> Self {
+        serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
+    }
+
+    fn save_to(&self, path: impl AsRef<std::path::Path>) -> eyre::Result<()> {
+        Ok(serde_json::ser::to_writer_pretty(
+            std::io::BufWriter::new(std::fs::File::create(path)?),
+            self,
+        )?)
+    }
+
+    fn save(&self) -> eyre::Result<()> {
+        self.save_to(CONFIG_PATH.get().unwrap())
+    }
+
+    fn load_and_save_defaults(path: impl AsRef<std::path::Path>) -> Self {
+        let mut config = Self::load(&path);
+        config.validate();
+        let result = config.save_to(path);
+        if let Err(e) = result {
+            log::error!("Failed to save config: {e:#?}",);
+        }
+        config
+    }
+
+    fn validate(&mut self) {
+        self.shutoff_voltage = self.shutoff_voltage.clamp(40.0, 60.0);
+        self.max_rate = self.max_rate.clamp(0, 30);
+        self.min_rate = self.min_rate.clamp(0, self.max_rate);
+        // self.duty_cycle_too_high = self.duty_cycle_too_high.clamp(0.0, 1.0);
+        // self.duty_cycle_too_low = self.duty_cycle_too_low.clamp(0.0, 1.0);
+        self.pid_controls.proportional_gain = self.pid_controls.proportional_gain.clamp(0.0, 50.0);
+        self.pid_controls.derivative_gain = self.pid_controls.derivative_gain.clamp(0.0, 50.0);
+        self.pid_controls.load_divisor = self.pid_controls.load_divisor.clamp(1.0, 50.0);
+    }
+}
diff --git a/tesla-charge-controller/src/control.rs b/tesla-charge-controller/src/control.rs
index bae5be1..5769d40 100644
--- a/tesla-charge-controller/src/control.rs
+++ b/tesla-charge-controller/src/control.rs
@@ -25,14 +25,32 @@ impl VehicleController {
         }
     }
 
+    pub async fn run(&mut self) -> ! {
+        let (mut car_state_tick, mut pid_loop_tick) = {
+            let config = crate::config::access_config().await;
+            let car_state_tick = tokio::time::interval(config.car_state_interval);
+            let pid_loop_tick = tokio::time::interval(std::time::Duration::from_secs(
+                config.pid_controls.loop_time_seconds,
+            ));
+            (car_state_tick, pid_loop_tick)
+        };
+        loop {
+            tokio::select! {
+                _ = car_state_tick.tick() => { self.car.update().await; },
+                _ = pid_loop_tick.tick() => { self.run_cycle().await; }
+                Some(req) = self.requests.recv() => { self.process_requests(req).await; }
+            }
+        }
+    }
+
     pub async fn run_cycle(&mut self) {
         let age = self.car.state().read().await.data_age();
-        if age >= crate::CONFIG.read().car_state_interval {
+        if age >= crate::config::access_config().await.car_state_interval {
             self.car.update().await;
         }
         match self.control_state {
             ChargeRateControllerState::Inactive => {
-                if let Some(state) = self.car.state().read().await.charge_state() {
+                if let Some(state) = self.car.state().read().await.charge_state().await {
                     if state.is_charging() {
                         self.control_state = ChargeRateControllerState::Charging {
                             rate_amps: state.charge_amps,
@@ -44,15 +62,11 @@ impl VehicleController {
         }
     }
 
-    pub async fn process_requests(&mut self) -> eyre::Result<()> {
-        while let Some(req) = self.requests.recv().await {
-            match req {
-                InterfaceRequest::FlashLights => {
-                    self.car.vehicle().flash_lights().await?;
-                }
-            }
+    pub async fn process_requests(&mut self, req: InterfaceRequest) {
+        if let Err(e) = match req {
+            InterfaceRequest::FlashLights => self.car.vehicle().flash_lights().await,
+        } {
+            log::error!("failed to execute request: {e:?}");
         }
-
-        Ok(())
     }
 }
diff --git a/tesla-charge-controller/src/main.rs b/tesla-charge-controller/src/main.rs
index 08071e1..42ad162 100644
--- a/tesla-charge-controller/src/main.rs
+++ b/tesla-charge-controller/src/main.rs
@@ -7,16 +7,13 @@ mod config;
 mod control;
 mod server;
 
-static CONFIG: common::config::ConfigSingleton<config::Config> =
-    common::config::ConfigSingleton::new();
-
 #[derive(clap::Parser, Debug, Clone)]
 #[clap(author, version, about, long_about = None)]
 struct Args {
     #[command(subcommand)]
     command: Commands,
-    #[clap(long, default_value = "/etc/tesla-charge-controller")]
-    config_dir: PathBuf,
+    #[clap(long, default_value = "/etc/tesla-charge-controller/config.json")]
+    config: PathBuf,
 }
 
 #[derive(clap::Subcommand, Debug, Clone)]
@@ -29,26 +26,55 @@ enum Commands {
 
 #[tokio::main]
 async fn main() {
-    common::init_log();
+    env_logger::builder()
+        .format_module_path(false)
+        .format_timestamp(
+            if std::env::var("LOG_TIMESTAMP").is_ok_and(|v| v == "false") {
+                None
+            } else {
+                Some(env_logger::TimestampPrecision::Seconds)
+            },
+        )
+        .init();
 
-    if let Err(e) = run().await {
-        log::error!("{e:?}");
-        std::process::exit(1);
+    let args = Args::parse();
+    match args.command {
+        Commands::Watch => {
+            if let Err(e) = run(args).await {
+                log::error!("{e:?}");
+                std::process::exit(1);
+            }
+        }
+        Commands::GenerateConfig => {
+            let config = config::Config::default();
+            match serde_json::to_string_pretty(&config) {
+                Ok(config_json) => {
+                    println!("{config_json}");
+                }
+                Err(e) => {
+                    eprintln!("error serializing config: {e:?}");
+                }
+            }
+        }
     }
 }
 
-async fn run() -> eyre::Result<()> {
-    let args = Args::parse();
-    CONFIG.load(args.config_dir.join("tesla.json"))?;
+async fn run(args: Args) -> eyre::Result<()> {
+    let _config_watcher = config::init_config(args.config);
 
-    let car = api::Car::new(&CONFIG.read().car_vin);
+    let car = {
+        let config = config::access_config().await;
+        api::Car::new(&config.car_vin)
+    };
     let car = std::sync::Arc::new(car);
     let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
     let mut vehicle_controller = control::VehicleController::new(car.clone(), rx);
     let server = server::launch_server(server::ServerState::new(car, tx));
-    server.await;
 
-    vehicle_controller.run_cycle().await;
+    tokio::select! {
+        () = server => {}
+        () = vehicle_controller.run() => {}
+    }
 
     Ok(())
 }
diff --git a/tesla-charge-controller/src/server/mod.rs b/tesla-charge-controller/src/server/mod.rs
index fb74bc2..1684acc 100644
--- a/tesla-charge-controller/src/server/mod.rs
+++ b/tesla-charge-controller/src/server/mod.rs
@@ -11,7 +11,11 @@ use rocket::{
 use serde::{Deserialize, Serialize};
 use tokio::sync::mpsc::UnboundedSender;
 
-use crate::{api::Car, control::InterfaceRequest};
+use crate::{
+    api::Car,
+    config::{access_config, write_to_config},
+    control::InterfaceRequest,
+};
 
 use self::static_handler::UiStatic;
 
@@ -83,6 +87,7 @@ async fn car_state(
             .read()
             .await
             .charge_state()
+            .await
             .ok_or(ServerError::NoData)?
             .clone(),
     ))
@@ -98,8 +103,8 @@ struct ControlState {
 
 #[get("/control-state")]
 async fn control_state(state: &State<ServerState>) -> Result<Json<ControlState>, ServerError> {
-    let is_charging = state.car.state().read().await.is_charging();
-    let config = crate::CONFIG.read();
+    let is_charging = state.car.state().read().await.is_charging().await;
+    let config = access_config().await;
     let control_enable = config.control_enable;
     let max_rate = config.max_rate;
     let min_rate = config.min_rate;
@@ -119,29 +124,29 @@ fn flash(state: &State<ServerState>, remote_addr: std::net::IpAddr) {
 }
 
 #[post("/disable-control")]
-fn disable_control(remote_addr: std::net::IpAddr) {
+async fn disable_control(remote_addr: std::net::IpAddr) {
     log::warn!("disabling control: {remote_addr:?}");
-    crate::CONFIG.write().control_enable = false;
+    write_to_config().await.control_enable = false;
 }
 
 #[post("/enable-control")]
-fn enable_control(remote_addr: std::net::IpAddr) {
+async fn enable_control(remote_addr: std::net::IpAddr) {
     log::warn!("enabling control: {remote_addr:?}");
-    crate::CONFIG.write().control_enable = true;
+    write_to_config().await.control_enable = true;
 }
 
 #[post("/shutoff/voltage/<voltage>")]
-fn set_shutoff(voltage: f64, remote_addr: std::net::IpAddr) {
+async fn set_shutoff(voltage: f64, remote_addr: std::net::IpAddr) {
     log::warn!("setting shutoff voltage: {remote_addr:?}");
     let voltage = voltage.clamp(40., 60.);
-    crate::CONFIG.write().shutoff_voltage = voltage;
+    write_to_config().await.shutoff_voltage = voltage;
 }
 
 #[post("/shutoff/time/<time>")]
-fn set_shutoff_time(time: u64, remote_addr: std::net::IpAddr) {
+async fn set_shutoff_time(time: u64, remote_addr: std::net::IpAddr) {
     log::warn!("setting shutoff time: {remote_addr:?}");
     let time = time.clamp(5, 120);
-    crate::CONFIG.write().shutoff_voltage_time_seconds = time;
+    write_to_config().await.shutoff_voltage_time_seconds = time;
 }
 
 #[derive(Clone, Copy, Serialize, Deserialize, Debug)]
@@ -151,8 +156,8 @@ struct ShutoffStatus {
 }
 
 #[get("/shutoff/status")]
-fn shutoff_status() -> Json<ShutoffStatus> {
-    let config = crate::CONFIG.read();
+async fn shutoff_status() -> Json<ShutoffStatus> {
+    let config = access_config().await;
     Json(ShutoffStatus {
         voltage: config.shutoff_voltage,
         time: config.shutoff_voltage_time_seconds,
@@ -160,46 +165,48 @@ fn shutoff_status() -> Json<ShutoffStatus> {
 }
 
 #[post("/set-max/<limit>")]
-fn set_max(limit: i64, remote_addr: std::net::IpAddr) {
+async fn set_max(limit: i64, remote_addr: std::net::IpAddr) {
     log::warn!("setting max: {remote_addr:?}");
-    let limit = limit.clamp(crate::CONFIG.read().min_rate, 32);
-    crate::CONFIG.write().max_rate = limit;
+    let mut config = write_to_config().await;
+    let limit = limit.clamp(config.min_rate, 32);
+    config.max_rate = limit;
 }
 
 #[post("/set-min/<limit>")]
-fn set_min(limit: i64, remote_addr: std::net::IpAddr) {
+async fn set_min(limit: i64, remote_addr: std::net::IpAddr) {
     log::warn!("setting min: {remote_addr:?}");
-    let limit = limit.clamp(0, crate::CONFIG.read().max_rate);
-    crate::CONFIG.write().min_rate = limit;
+    let mut config = write_to_config().await;
+    let limit = limit.clamp(0, config.max_rate);
+    config.min_rate = limit;
 }
 
 #[post("/pid-settings/proportional/<gain>")]
-fn set_proportional_gain(gain: f64, remote_addr: std::net::IpAddr) {
+async fn set_proportional_gain(gain: f64, remote_addr: std::net::IpAddr) {
     log::warn!("setting proportional gain: {remote_addr:?}");
-    crate::CONFIG.write().pid_controls.proportional_gain = gain;
+    write_to_config().await.pid_controls.proportional_gain = gain;
 }
 
 #[post("/pid-settings/derivative/<gain>")]
-fn set_derivative_gain(gain: f64, remote_addr: std::net::IpAddr) {
+async fn set_derivative_gain(gain: f64, remote_addr: std::net::IpAddr) {
     log::warn!("setting derivative gain: {remote_addr:?}");
-    crate::CONFIG.write().pid_controls.derivative_gain = gain;
+    write_to_config().await.pid_controls.derivative_gain = gain;
 }
 
 #[post("/pid-settings/loop_time_seconds/<time>")]
-fn set_pid_loop_length(time: u64, remote_addr: std::net::IpAddr) {
+async fn set_pid_loop_length(time: u64, remote_addr: std::net::IpAddr) {
     log::warn!("setting pid loop interval: {remote_addr:?}");
-    crate::CONFIG.write().pid_controls.loop_time_seconds = time;
+    write_to_config().await.pid_controls.loop_time_seconds = time;
 }
 
 #[post("/pid-settings/load_divisor/<divisor>")]
-fn set_load_divisor(divisor: f64, remote_addr: std::net::IpAddr) {
+async fn set_load_divisor(divisor: f64, remote_addr: std::net::IpAddr) {
     log::warn!("setting load divisor interval: {remote_addr:?}");
-    crate::CONFIG.write().pid_controls.load_divisor = divisor;
+    write_to_config().await.pid_controls.load_divisor = divisor;
 }
 
 #[get("/pid-settings/status")]
-fn pid_settings() -> Json<crate::config::PidControls> {
-    Json(crate::CONFIG.read().pid_controls)
+async fn pid_settings() -> Json<crate::config::PidControls> {
+    Json(access_config().await.pid_controls)
 }
 
 #[get("/metrics")]
diff --git a/webapp/index.html b/tesla-charge-controller/webapp/index.html
similarity index 100%
rename from webapp/index.html
rename to tesla-charge-controller/webapp/index.html
diff --git a/webapp/info/index.html b/tesla-charge-controller/webapp/info/index.html
similarity index 100%
rename from webapp/info/index.html
rename to tesla-charge-controller/webapp/info/index.html
diff --git a/webapp/pid/index.html b/tesla-charge-controller/webapp/pid/index.html
similarity index 100%
rename from webapp/pid/index.html
rename to tesla-charge-controller/webapp/pid/index.html
diff --git a/webapp/regulator/index.html b/tesla-charge-controller/webapp/regulator/index.html
similarity index 100%
rename from webapp/regulator/index.html
rename to tesla-charge-controller/webapp/regulator/index.html
diff --git a/webapp/script.js b/tesla-charge-controller/webapp/script.js
similarity index 100%
rename from webapp/script.js
rename to tesla-charge-controller/webapp/script.js
diff --git a/webapp/shutoff/index.html b/tesla-charge-controller/webapp/shutoff/index.html
similarity index 100%
rename from webapp/shutoff/index.html
rename to tesla-charge-controller/webapp/shutoff/index.html
diff --git a/webapp/style.css b/tesla-charge-controller/webapp/style.css
similarity index 100%
rename from webapp/style.css
rename to tesla-charge-controller/webapp/style.css