Compare commits

..

No commits in common. "main" and "v1.9.9-pre-1" have entirely different histories.

43 changed files with 888 additions and 3477 deletions

0
.gitmodules vendored Normal file
View file

View file

@ -1,32 +0,0 @@
[formatting]
indent_string = "\t"
reorder_keys = false
reorder_arrays = false
reorder_inline_tables = true
[[rule]]
include = ["**/Cargo.toml"]
[rule.schema]
path = "https://json.schemastore.org/cargo.json"
enabled = true
# schema definition for .cargo/config.toml is not yet available
# see https://github.com/rust-lang/cargo/issues/12883
[[rule]]
include = ["**/Cargo.toml"]
keys = [
"build-dependencies",
"dependencies",
"dev-dependencies",
"target.*.build-dependencies",
"target.*.dependencies",
"target.*.dev-dependencies",
"workspace.dependencies",
"workspace.lints.clippy",
]
[rule.formatting]
reorder_keys = true
reorder_arrays = true

45
Cargo.lock generated
View file

@ -185,9 +185,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.7.0" version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
dependencies = [ dependencies = [
"serde", "serde",
] ]
@ -239,23 +239,20 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "charge-controller-supervisor" name = "charge-controller-supervisor"
version = "1.9.9-pre-30" version = "1.9.9-pre"
dependencies = [ dependencies = [
"bitflags 2.7.0",
"chrono", "chrono",
"clap", "clap",
"env_logger", "env_logger",
"eyre", "eyre",
"futures", "futures",
"include_dir",
"lazy_static", "lazy_static",
"log", "log",
"notify-debouncer-mini",
"prometheus", "prometheus",
"rocket", "rocket",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.11", "serialport",
"tokio", "tokio",
"tokio-modbus", "tokio-modbus",
"tokio-serial", "tokio-serial",
@ -383,7 +380,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7"
dependencies = [ dependencies = [
"bitflags 2.7.0", "bitflags 2.6.0",
"proc-macro2", "proc-macro2",
"proc-macro2-diagnostics", "proc-macro2-diagnostics",
"quote", "quote",
@ -1171,7 +1168,7 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [ dependencies = [
"bitflags 2.7.0", "bitflags 2.6.0",
"libc", "libc",
"redox_syscall", "redox_syscall",
] ]
@ -1362,7 +1359,7 @@ version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
dependencies = [ dependencies = [
"bitflags 2.7.0", "bitflags 2.6.0",
"filetime", "filetime",
"fsevent-sys", "fsevent-sys",
"inotify", "inotify",
@ -1593,7 +1590,7 @@ dependencies = [
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2", "socket2",
"thiserror 2.0.11", "thiserror 2.0.9",
"tokio", "tokio",
"tracing", "tracing",
] ]
@ -1612,7 +1609,7 @@ dependencies = [
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"slab", "slab",
"thiserror 2.0.11", "thiserror 2.0.9",
"tinyvec", "tinyvec",
"tracing", "tracing",
"web-time", "web-time",
@ -1677,7 +1674,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834"
dependencies = [ dependencies = [
"bitflags 2.7.0", "bitflags 2.6.0",
] ]
[[package]] [[package]]
@ -1890,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
dependencies = [ dependencies = [
"base64 0.21.7", "base64 0.21.7",
"bitflags 2.7.0", "bitflags 2.6.0",
"serde", "serde",
"serde_derive", "serde_derive",
] ]
@ -1913,7 +1910,7 @@ version = "0.38.42"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85"
dependencies = [ dependencies = [
"bitflags 2.7.0", "bitflags 2.6.0",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
@ -2055,7 +2052,7 @@ version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "779e2977f0cc2ff39708fef48f96f3768ac8ddd8c6caaaab82e83bd240ef99b2" checksum = "779e2977f0cc2ff39708fef48f96f3768ac8ddd8c6caaaab82e83bd240ef99b2"
dependencies = [ dependencies = [
"bitflags 2.7.0", "bitflags 2.6.0",
"cfg-if", "cfg-if",
"core-foundation", "core-foundation",
"core-foundation-sys", "core-foundation-sys",
@ -2205,7 +2202,7 @@ dependencies = [
[[package]] [[package]]
name = "tesla-charge-controller" name = "tesla-charge-controller"
version = "1.9.9-pre-30" version = "1.9.9-pre"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
@ -2224,7 +2221,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serialport", "serialport",
"thiserror 2.0.11", "thiserror 2.0.9",
"tokio", "tokio",
"tokio-modbus", "tokio-modbus",
"tokio-serial", "tokio-serial",
@ -2241,11 +2238,11 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.11" version = "2.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc"
dependencies = [ dependencies = [
"thiserror-impl 2.0.11", "thiserror-impl 2.0.9",
] ]
[[package]] [[package]]
@ -2261,9 +2258,9 @@ dependencies = [
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.11" version = "2.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -2378,7 +2375,7 @@ dependencies = [
"futures-util", "futures-util",
"log", "log",
"smallvec", "smallvec",
"thiserror 2.0.11", "thiserror 2.0.9",
"tokio", "tokio",
"tokio-util", "tokio-util",
] ]

View file

@ -4,32 +4,15 @@ default-members = ["charge-controller-supervisor"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "1.9.9-pre-30" version = "1.9.9-pre"
[workspace.lints.clippy] [workspace.lints.clippy]
pedantic = "warn" pedantic = "warn"
branches_sharing_code = "warn"
derive_partial_eq_without_eq = "warn"
equatable_if_let = "warn"
fallible_impl_from = "warn"
large_stack_frames = "warn"
missing_const_for_fn = "warn"
needless_collect = "warn"
needless_pass_by_ref_mut = "warn"
or_fun_call = "warn"
redundant_clone = "warn"
significant_drop_in_scrutinee = "warn"
significant_drop_tightening = "warn"
too_long_first_doc_paragraph = "warn"
trait_duplication_in_bounds = "warn"
cast-possible-truncation = { level = "allow", priority = 1 }
cast-precision-loss = { level = "allow", priority = 1 }
default-trait-access = { level = "allow", priority = 1 }
missing-errors-doc = { level = "allow", priority = 1 }
missing-panics-doc = { level = "allow", priority = 1 }
module-name-repetitions = { level = "allow", priority = 1 } module-name-repetitions = { level = "allow", priority = 1 }
similar-names = { level = "allow", priority = 1 }
struct-excessive-bools = { level = "allow", priority = 1 } struct-excessive-bools = { level = "allow", priority = 1 }
too-many-lines = { level = "allow", priority = 1 } too-many-lines = { level = "allow", priority = 1 }
default-trait-access = { level = "allow", priority = 1 }
cast-precision-loss = { level = "allow", priority = 1 }
cast-possible-truncation = { level = "allow", priority = 1 }
missing-errors-doc = { level = "allow", priority = 1 }
missing-panics-doc = { level = "allow", priority = 1 }

Binary file not shown.

View file

@ -12,24 +12,21 @@ systemd-units = { enable = false }
depends = "" depends = ""
[dependencies] [dependencies]
bitflags = { version = "2.7.0", features = ["serde"] }
chrono = "0.4.39"
clap = { version = "4.5.23", features = ["derive"] } clap = { version = "4.5.23", features = ["derive"] }
env_logger = "0.11.6"
eyre = "0.6.12"
futures = "0.3.31"
include_dir = "0.7.4"
lazy_static = "1.5.0"
log = "0.4.22"
notify-debouncer-mini = { version = "0.5.0", default-features = false }
prometheus = "0.13.4"
rocket = { version = "0.5.1", features = ["json"] }
serde = { version = "1.0.216", features = ["derive"] } serde = { version = "1.0.216", features = ["derive"] }
serde_json = "1.0.134" serde_json = "1.0.134"
thiserror = "2.0.11"
tokio = { version = "1.42.0", features = ["full"] } tokio = { version = "1.42.0", features = ["full"] }
rocket = { version = "0.5.1", features = ["json"] }
chrono = "0.4.39"
prometheus = "0.13.4"
env_logger = "0.11.6"
log = "0.4.22"
serialport = "4.6.1"
futures = "0.3.31"
tokio-modbus = "0.16.1" tokio-modbus = "0.16.1"
tokio-serial = "5.4.4" tokio-serial = "5.4.4"
lazy_static = "1.5.0"
eyre = "0.6.12"
[lints] [lints]
workspace = true workspace = true

View file

@ -7,6 +7,7 @@ StartLimitIntervalSec=0
Type=simple Type=simple
Restart=always Restart=always
RestartSec=10s RestartSec=10s
User=tesla
Environment="RUST_LOG=error,warn" Environment="RUST_LOG=error,warn"
Environment="LOG_TIMESTAMP=false" Environment="LOG_TIMESTAMP=false"
ExecStart=/usr/bin/charge-controller-supervisor watch ExecStart=/usr/bin/charge-controller-supervisor watch

View file

@ -1,208 +1,41 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
static CONFIG_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new(); #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
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<()>,
}
pub fn init_config(path: impl AsRef<std::path::Path>) -> Option<ConfigWatcher> {
let _ = CONFIG_PATH.get_or_init(|| path.as_ref().to_path_buf());
log::trace!("loading config...");
let config = Config::load(&path).unwrap();
if let Err(e) = config.save() {
log::warn!("couldn't save updated config: {e:?}");
}
log::trace!("watching config for changes...");
let config_watcher = ConfigWatcher::new(&path);
CONFIG.set(tokio::sync::RwLock::new(config)).unwrap();
config_watcher
}
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 config = Config::load(&config_path).unwrap();
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<()> {
let mut h = CONFIG
.get()
.ok_or_else(|| eyre::eyre!("could not get config"))?
.write()
.await;
if h.charge_controllers != config.charge_controllers
|| h.primary_charge_controller != config.primary_charge_controller
{
log::warn!("charge controller configuration changed on disk; please restart");
}
*h = config;
drop(h);
Ok(())
}
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(tag = "version")]
pub enum ConfigStorage {
#[serde(rename = "1")]
V1(outdated::ConfigV1),
#[serde(rename = "2")]
V2(Config),
}
mod outdated;
impl Default for ConfigStorage {
fn default() -> Self {
Self::from_latest(Default::default())
}
}
impl ConfigStorage {
pub const fn from_latest(config: Config) -> Self {
Self::V2(config)
}
pub fn into_latest(self) -> Config {
match self {
ConfigStorage::V1(v1) => v1.into(),
ConfigStorage::V2(config) => config,
}
}
fn load(path: impl AsRef<std::path::Path>) -> eyre::Result<Self> {
Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
}
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())
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
#[serde(default)] #[serde(default)]
pub struct Config { pub struct Config {
pub primary_charge_controller: String, pub primary_charge_controller: String,
pub enable_secondary_control: bool,
pub charge_controllers: Vec<ChargeControllerConfig>, pub charge_controllers: Vec<ChargeControllerConfig>,
} }
impl Config { #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
fn load(path: impl AsRef<std::path::Path>) -> eyre::Result<Self> { pub enum ChargeControllerConfig {
let storage = ConfigStorage::load(path)?; Pl {
Ok(storage.into_latest()) serial_port: String,
} baud_rate: u32,
timeout_milliseconds: u64,
watch_interval_seconds: u64,
},
Tristar {
serial_port: String,
baud_rate: u32,
watch_interval_seconds: u64,
},
}
fn save(&self) -> eyre::Result<()> { impl ChargeControllerConfig {
let as_storage = ConfigStorage::from_latest(self.clone()); pub fn interval(&self) -> u64 {
as_storage.save()?; match self {
Ok(()) ChargeControllerConfig::Pl {
serial_port: _,
baud_rate: _,
timeout_milliseconds: _,
watch_interval_seconds,
}
| ChargeControllerConfig::Tristar {
serial_port: _,
baud_rate: _,
watch_interval_seconds,
} => *watch_interval_seconds,
}
} }
} }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct ChargeControllerConfig {
pub name: String,
pub watch_interval_seconds: u64,
pub variant: ChargeControllerVariant,
#[serde(default)]
pub follow_primary: bool,
pub transport: Transport,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum ChargeControllerVariant {
Tristar,
Pl { timeout_milliseconds: u64 },
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Transport {
Serial { port: String, baud_rate: u32 },
Tcp { ip: std::net::IpAddr, port: u16 },
}

View file

@ -1,72 +0,0 @@
pub use v1::ConfigV1;
mod v1 {
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
#[serde(default)]
pub struct ConfigV1 {
primary_charge_controller: String,
enable_secondary_control: bool,
charge_controllers: Vec<ChargeControllerConfigV1>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
struct ChargeControllerConfigV1 {
name: String,
serial_port: String,
baud_rate: u32,
watch_interval_seconds: u64,
variant: ChargeControllerVariantV1,
#[serde(default)]
follow_primary: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
enum ChargeControllerVariantV1 {
Tristar,
Pl { timeout_milliseconds: u64 },
}
impl From<ChargeControllerConfigV1> for crate::config::ChargeControllerConfig {
fn from(value: ChargeControllerConfigV1) -> Self {
Self {
name: value.name,
transport: crate::config::Transport::Serial {
port: value.serial_port,
baud_rate: value.baud_rate,
},
watch_interval_seconds: value.watch_interval_seconds,
variant: value.variant.into(),
follow_primary: value.follow_primary,
}
}
}
impl From<ChargeControllerVariantV1> for crate::config::ChargeControllerVariant {
fn from(value: ChargeControllerVariantV1) -> Self {
match value {
ChargeControllerVariantV1::Tristar => Self::Tristar,
ChargeControllerVariantV1::Pl {
timeout_milliseconds,
} => Self::Pl {
timeout_milliseconds,
},
}
}
}
impl From<ConfigV1> for crate::config::Config {
fn from(value: ConfigV1) -> Self {
Self {
primary_charge_controller: value.primary_charge_controller,
enable_secondary_control: value.enable_secondary_control,
charge_controllers: value
.charge_controllers
.into_iter()
.map(Into::into)
.collect(),
}
}
}
}

View file

@ -1,221 +0,0 @@
use crate::storage::ControllerData;
mod pl;
mod tristar;
pub struct Controller {
name: String,
interval: std::time::Duration,
inner: ControllerInner,
data: std::sync::Arc<ControllerData>,
follow_voltage: bool,
voltage_rx: tokio::sync::watch::Receiver<VoltageCommand>,
voltage_tx: Option<tokio::sync::watch::Sender<VoltageCommand>>,
settings_last_read: Option<std::time::Instant>,
}
#[derive(Default, serde::Serialize, Clone)]
pub struct CommonData {
pub battery_voltage: f64,
pub target_voltage: f64,
pub battery_temp: f64,
}
#[derive(serde::Serialize, Clone)]
#[serde(tag = "model")]
pub enum ControllerState {
Pl(pl::PlState),
Tristar(tristar::TristarState),
}
impl ControllerState {
pub fn common(&self) -> CommonData {
match self {
Self::Pl(pl_state) => crate::controller::CommonData {
battery_voltage: pl_state.battery_voltage,
target_voltage: pl_state.target_voltage,
battery_temp: pl_state.battery_temp,
},
Self::Tristar(tristar_state) => crate::controller::CommonData {
battery_voltage: tristar_state.battery_voltage,
target_voltage: tristar_state.target_voltage,
battery_temp: f64::from(tristar_state.battery_temp),
},
}
}
}
#[derive(serde::Serialize, Clone)]
#[serde(tag = "model")]
pub enum ControllerSettings {
Pl(pl::PlSettings),
Tristar(tristar::TristarSettings),
}
#[derive(Clone, Copy, Debug)]
pub enum VoltageCommand {
None,
Set(f64),
}
impl Controller {
pub async fn new(
config: crate::config::ChargeControllerConfig,
voltage_rx: tokio::sync::watch::Receiver<VoltageCommand>,
) -> eyre::Result<Self> {
let inner = match config.variant {
crate::config::ChargeControllerVariant::Tristar => ControllerInner::Tristar(
tristar::Tristar::new(&config.name, &config.transport).await?,
),
crate::config::ChargeControllerVariant::Pl {
timeout_milliseconds,
} => match &config.transport {
crate::config::Transport::Serial { port, baud_rate } => ControllerInner::Pl(
pl::Pli::new(port, &config.name, *baud_rate, timeout_milliseconds)?,
),
crate::config::Transport::Tcp { ip: _, port: _ } => {
return Err(eyre::eyre!("pl doesn't support tcp"))
}
},
};
let data = std::sync::Arc::new(ControllerData::new());
Ok(Self {
name: config.name,
interval: std::time::Duration::from_secs(config.watch_interval_seconds),
inner,
data,
voltage_rx,
voltage_tx: None,
settings_last_read: None,
follow_voltage: config.follow_primary,
})
}
pub fn get_data_ptr(&self) -> std::sync::Arc<ControllerData> {
self.data.clone()
}
pub async fn refresh(&mut self) -> eyre::Result<()> {
let data = self.inner.refresh().await?;
if let Some(tx) = self.voltage_tx.as_mut() {
if crate::config::access_config()
.await
.enable_secondary_control
{
let target = data.common().target_voltage;
log::debug!(
"tristar {}: primary: sending target voltage {}",
self.name,
target
);
tx.send(VoltageCommand::Set(target))?;
}
}
*self.data.write_state().await = Some(data);
if self.needs_new_settings() {
match self.inner.get_settings().await {
Ok(s) => {
*self.data.write_settings().await = Some(s);
self.settings_last_read = Some(std::time::Instant::now());
}
Err(e) => {
log::error!("couldn't read config from {}: {e:?}", self.name);
}
}
}
Ok(())
}
pub const fn timeout_interval(&self) -> std::time::Duration {
self.interval
}
pub fn name(&self) -> &str {
self.name.as_str()
}
pub fn set_tx_to_secondary(&mut self, tx: tokio::sync::watch::Sender<VoltageCommand>) {
assert!(
!self.follow_voltage,
"trying to set {} as primary when it is also a secondary!",
self.name
);
self.voltage_tx = Some(tx);
}
pub fn get_rx(&mut self) -> &mut tokio::sync::watch::Receiver<VoltageCommand> {
&mut self.voltage_rx
}
pub async fn process_command(&mut self, command: VoltageCommand) -> eyre::Result<()> {
match command {
VoltageCommand::Set(target_voltage) => {
if self.follow_voltage {
self.inner.set_target_voltage(target_voltage).await
} else {
Ok(())
}
}
VoltageCommand::None => {
// todo: disable voltage control
Ok(())
}
}
}
pub fn needs_new_settings(&self) -> bool {
self.settings_last_read.is_none_or(|t| {
std::time::Instant::now().duration_since(t) >= std::time::Duration::from_secs(60 * 60)
})
}
}
#[expect(clippy::large_enum_variant)]
pub enum ControllerInner {
Pl(pl::Pli),
Tristar(tristar::Tristar),
}
impl ControllerInner {
pub async fn refresh(&mut self) -> eyre::Result<ControllerState> {
match self {
ControllerInner::Pl(pli) => {
let pl_data = pli.refresh().await?;
Ok(ControllerState::Pl(pl_data))
}
ControllerInner::Tristar(tristar) => {
let tristar_data = tristar.refresh().await?;
Ok(ControllerState::Tristar(tristar_data))
}
}
}
pub async fn get_settings(&mut self) -> eyre::Result<ControllerSettings> {
match self {
ControllerInner::Pl(pl) => {
let settings = pl.read_settings().await?;
Ok(ControllerSettings::Pl(settings))
}
ControllerInner::Tristar(tristar) => {
let settings = tristar.read_settings().await?;
Ok(ControllerSettings::Tristar(settings))
}
}
}
pub async fn set_target_voltage(&mut self, target_voltage: f64) -> eyre::Result<()> {
match self {
ControllerInner::Pl(_) => todo!(),
ControllerInner::Tristar(tristar) => tristar.set_target_voltage(target_voltage).await,
}
}
}

View file

@ -1,671 +0,0 @@
use chrono::Timelike;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_serial::{SerialPort, SerialPortBuilderExt};
use crate::gauges::{
BATTERY_TEMP, BATTERY_VOLTAGE, CHARGE_STATE, INPUT_CURRENT, PL_DUTY_CYCLE, PL_LOAD_CURRENT,
TARGET_VOLTAGE,
};
pub struct Pli {
friendly_name: String,
port: tokio_serial::SerialStream,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
pub struct PlState {
pub battery_voltage: f64,
pub target_voltage: f64,
pub duty_cycle: f64,
pub internal_charge_current: f64,
pub internal_load_current: f64,
pub battery_temp: f64,
pub regulator_state: RegulatorState,
// pub internal_charge_ah_accumulator: u16,
// pub external_charge_ah_accumulator: u16,
// pub internal_load_ah_accumulator: u16,
// pub external_load_ah_accumulator: u16,
// pub internal_charge_ah: u16,
// pub external_charge_ah: u16,
// pub internal_load_ah: u16,
// pub external_load_ah: u16,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum RegulatorState {
Boost,
Equalise,
Absorption,
Float,
}
impl From<u8> for RegulatorState {
fn from(value: u8) -> Self {
match value & 0b11 {
0b00 => Self::Boost,
0b01 => Self::Equalise,
0b10 => Self::Absorption,
0b11 => Self::Float,
_ => unreachable!(),
}
}
}
impl From<RegulatorState> for u8 {
fn from(value: RegulatorState) -> Self {
match value {
RegulatorState::Boost => 0b00,
RegulatorState::Equalise => 0b01,
RegulatorState::Absorption => 0b10,
RegulatorState::Float => 0b11,
}
}
}
impl Default for RegulatorState {
fn default() -> Self {
Self::Absorption
}
}
fn set_regulator_gauges(state: RegulatorState, label: &str) {
let boost = CHARGE_STATE.with_label_values(&[label, "boost"]);
let equalise = CHARGE_STATE.with_label_values(&[label, "equalise"]);
let absorption = CHARGE_STATE.with_label_values(&[label, "absorption"]);
let float = CHARGE_STATE.with_label_values(&[label, "float"]);
match state {
RegulatorState::Boost => {
boost.set(1);
equalise.set(0);
absorption.set(0);
float.set(0);
}
RegulatorState::Equalise => {
boost.set(0);
equalise.set(1);
absorption.set(0);
float.set(0);
}
RegulatorState::Absorption => {
boost.set(0);
equalise.set(0);
absorption.set(1);
float.set(0);
}
RegulatorState::Float => {
boost.set(0);
equalise.set(0);
absorption.set(0);
float.set(1);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlSettings {
load_disconnect_voltage: f64,
load_reconnect_voltage: f64,
delay_before_disconnect_minutes: u8,
days_between_boost: u8,
absorption_time: u8,
hysteresis: u8,
boost_return_voltage: f64,
charge_current_limit: u8,
battery_2_regulation_voltage: f64,
days_between_equalization: u8,
equalization_length: u8,
absorption_voltage: f64,
equalization_voltage: f64,
float_voltage: f64,
boost_voltage: f64,
program: Program,
system_voltage: SystemVoltage,
battery_capacity_ah: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Program {
Prog0,
Prog1,
Prog2,
Prog3,
Prog4,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SystemVoltage {
V12,
V24,
V32,
V36,
V48,
Unknown,
}
#[expect(dead_code, reason = "writing settings is not yet implemented")]
#[derive(Debug, Clone, Copy)]
pub enum PliRequest {
ReadRam(u8),
ReadEeprom(u8),
SyncTime,
SetRegulatorState(RegulatorState),
}
impl Pli {
pub fn new(
serial_port: &str,
friendly_name: &str,
baud_rate: u32,
timeout: u64,
) -> Result<Self, tokio_serial::Error> {
let port = tokio_serial::new(serial_port, baud_rate)
.timeout(std::time::Duration::from_millis(timeout))
.open_native_async()?;
Ok(Self {
friendly_name: friendly_name.to_owned(),
port,
})
}
pub async fn refresh(&mut self) -> eyre::Result<PlState> {
let new_state = self.read_state().await?;
BATTERY_VOLTAGE
.with_label_values(&[&self.friendly_name])
.set(new_state.battery_voltage);
TARGET_VOLTAGE
.with_label_values(&[&self.friendly_name])
.set(new_state.target_voltage);
PL_DUTY_CYCLE
.with_label_values(&[&self.friendly_name])
.set(new_state.duty_cycle);
INPUT_CURRENT
.with_label_values(&[&self.friendly_name])
.set(new_state.internal_charge_current);
PL_LOAD_CURRENT
.with_label_values(&[&self.friendly_name])
.set(new_state.internal_load_current);
BATTERY_TEMP
.with_label_values(&[&self.friendly_name])
.set(new_state.battery_temp);
set_regulator_gauges(new_state.regulator_state, &self.friendly_name);
Ok(new_state)
}
#[expect(dead_code, reason = "writing settings is not yet implemented")]
pub async fn process_request(&mut self, message: PliRequest) -> eyre::Result<()> {
match message {
PliRequest::ReadRam(address) => {
let data = self.read_ram_with_retires(address).await?;
log::warn!("Read RAM at {address}: data {data}");
}
PliRequest::ReadEeprom(address) => {
let data = self.read_eeprom(address).await?;
log::warn!("Read EEPROM at {address}: data {data}");
}
PliRequest::SyncTime => {
let now = chrono::Local::now();
let timestamp = (((now.hour() * 10) + (now.minute() / 6)) & 0xFF) as u8;
let min = (now.minute() % 6) as u8;
let sec = (now.second() / 2).min(29) as u8;
self.write_ram(PlRamAddress::Hour, timestamp).await?;
self.write_ram(PlRamAddress::Min, min).await?;
self.write_ram(PlRamAddress::Sec, sec).await?;
log::warn!(
"Set time: {now} corresponds to {timestamp} + minutes {min} + seconds {sec}"
);
}
PliRequest::SetRegulatorState(state) => {
log::warn!("Setting regulator state to {state:?}");
self.write_ram(PlRamAddress::Rstate, state.into()).await?;
}
}
Ok(())
}
pub async fn read_settings(&mut self) -> eyre::Result<PlSettings> {
let load_disconnect_voltage =
f64::from(self.read_eeprom(PlEepromAddress::LOff).await?) * (4. / 10.);
let load_reconnect_voltage =
f64::from(self.read_eeprom(PlEepromAddress::LOn).await?) * (4. / 10.);
let delay_before_disconnect_minutes = self.read_eeprom(PlEepromAddress::LDel).await?;
let days_between_boost = self.read_eeprom(PlEepromAddress::BstFreq).await?;
let absorption_time = self.read_eeprom(PlEepromAddress::ATim).await?;
let hysteresis = self.read_eeprom(PlEepromAddress::Hyst).await?;
let boost_return_voltage =
f64::from(self.read_eeprom(PlEepromAddress::BRet).await?) * (4. / 10.);
let charge_current_limit = self.read_eeprom(PlEepromAddress::CurLim).await?;
let battery_2_regulation_voltage =
f64::from(self.read_eeprom(PlEepromAddress::Bat2).await?) * (4. / 10.);
let days_between_equalization = self.read_eeprom(PlEepromAddress::EqFreq).await?;
let equalization_length = self.read_eeprom(PlEepromAddress::ETim).await?;
let absorption_voltage =
f64::from(self.read_eeprom(PlEepromAddress::AbsV).await?) * (4. / 10.);
let equalization_voltage =
f64::from(self.read_eeprom(PlEepromAddress::EMax).await?) * (4. / 10.);
let float_voltage = f64::from(self.read_eeprom(PlEepromAddress::FltV).await?) * (4. / 10.);
let boost_voltage = f64::from(self.read_eeprom(PlEepromAddress::BMax).await?) * (4. / 10.);
let volt = self.read_eeprom(PlEepromAddress::Volt).await?;
let program = match volt >> 4 {
0 => Program::Prog0,
1 => Program::Prog1,
2 => Program::Prog2,
3 => Program::Prog3,
4 => Program::Prog4,
_ => Program::Unknown,
};
let system_voltage = match volt & 0xF {
0 => SystemVoltage::V12,
1 => SystemVoltage::V24,
2 => SystemVoltage::V32,
3 => SystemVoltage::V36,
4 => SystemVoltage::V48,
_ => SystemVoltage::Unknown,
};
let battery_cap = usize::from(self.read_eeprom(PlEepromAddress::BCap).await?);
let battery_capacity_ah = if battery_cap > 50 {
(50 * 20) + ((battery_cap - 50) * 100)
} else {
battery_cap * 20
};
Ok(PlSettings {
load_disconnect_voltage,
load_reconnect_voltage,
delay_before_disconnect_minutes,
days_between_boost,
absorption_time,
hysteresis,
boost_return_voltage,
charge_current_limit,
battery_2_regulation_voltage,
days_between_equalization,
equalization_length,
absorption_voltage,
equalization_voltage,
float_voltage,
boost_voltage,
program,
system_voltage,
battery_capacity_ah,
})
}
async fn read_state(&mut self) -> eyre::Result<PlState> {
// let int_charge_acc_low = self.read_ram(PlRamAddress::Ciacc1).await?;
// let int_charge_acc = self.read_ram(PlRamAddress::Ciacc2).await?;
// let int_charge_acc_high = self.read_ram(PlRamAddress::Ciacc3).await?;
// let mut internal_charge_ah_accumulator = u16::from(int_charge_acc_high) << 9;
// internal_charge_ah_accumulator |= u16::from(int_charge_acc) << 1;
// internal_charge_ah_accumulator |= u16::from(int_charge_acc_low & 0b1);
// let int_charge_low = self.read_ram(PlRamAddress::Ciahl).await?;
// let int_charge_high = self.read_ram(PlRamAddress::Ciahh).await?;
// let internal_charge_ah = u16::from_le_bytes([int_charge_low, int_charge_high]);
// let int_load_acc_low = self.read_ram(PlRamAddress::Liacc1).await?;
// let int_load_acc = self.read_ram(PlRamAddress::Liacc2).await?;
// let int_load_acc_high = self.read_ram(PlRamAddress::Liacc3).await?;
// let mut internal_load_ah_accumulator = u16::from(int_load_acc_high) << 9;
// internal_load_ah_accumulator |= u16::from(int_load_acc) << 1;
// internal_load_ah_accumulator |= u16::from(int_load_acc_low & 0b1);
// let int_load_low = self.read_ram(PlRamAddress::Liahl).await?;
// let int_load_high = self.read_ram(PlRamAddress::Liahh).await?;
// let internal_load_ah = u16::from_le_bytes([int_load_low, int_load_high]);
// let ext_charge_acc_low = self.read_ram(PlRamAddress::Ceacc1).await?;
// let ext_charge_acc = self.read_ram(PlRamAddress::Ceacc2).await?;
// let ext_charge_acc_high = self.read_ram(PlRamAddress::Ceacc3).await?;
// let mut external_charge_ah_accumulator = u16::from(ext_charge_acc_high) << 9;
// external_charge_ah_accumulator |= u16::from(ext_charge_acc) << 1;
// external_charge_ah_accumulator |= u16::from(ext_charge_acc_low & 0b1);
// let ext_charge_low = self.read_ram(PlRamAddress::Ceahl).await?;
// let ext_charge_high = self.read_ram(PlRamAddress::Ceahh).await?;
// let external_charge_ah = u16::from_le_bytes([ext_charge_low, ext_charge_high]);
// let ext_load_acc_low = self.read_ram(PlRamAddress::Leacc1).await?;
// let ext_load_acc = self.read_ram(PlRamAddress::Leacc2).await?;
// let ext_load_acc_high = self.read_ram(PlRamAddress::Leacc3).await?;
// let mut external_load_ah_accumulator = u16::from(ext_load_acc_high) << 9;
// external_load_ah_accumulator |= u16::from(ext_load_acc) << 1;
// external_load_ah_accumulator |= u16::from(ext_load_acc_low & 0b1);
// let ext_load_low = self.read_ram(PlRamAddress::Leahl).await?;
// let ext_load_high = self.read_ram(PlRamAddress::Leahh).await?;
// let external_load_ah = u16::from_le_bytes([ext_load_low, ext_load_high]);
Ok(PlState {
battery_voltage: f64::from(self.read_ram_with_retires(PlRamAddress::Batv).await?)
* (4. / 10.),
target_voltage: f64::from(self.read_ram_with_retires(PlRamAddress::Vreg).await?)
* (4. / 10.),
duty_cycle: f64::from(self.read_ram_with_retires(PlRamAddress::Dutycyc).await?) / 255.,
internal_charge_current: f64::from(
self.read_ram_with_retires(PlRamAddress::Cint).await?,
) * (4. / 10.),
internal_load_current: f64::from(self.read_ram_with_retires(PlRamAddress::Lint).await?)
* (2. / 10.),
battery_temp: f64::from(self.read_ram_with_retires(PlRamAddress::Battemp).await?),
regulator_state: self
.read_ram_with_retires(PlRamAddress::Rstate)
.await?
.into(),
// internal_charge_ah_accumulator,
// external_charge_ah_accumulator,
// internal_load_ah_accumulator,
// external_load_ah_accumulator,
// internal_charge_ah,
// external_charge_ah,
// internal_load_ah,
// external_load_ah,
})
}
async fn send_command(&mut self, req: [u8; 4]) -> eyre::Result<()> {
self.flush()?;
self.port.write_all(&req).await?;
Ok(())
}
fn flush(&self) -> eyre::Result<()> {
self.port.clear(tokio_serial::ClearBuffer::All)?;
Ok(())
}
async fn receive<const LENGTH: usize>(&mut self) -> eyre::Result<[u8; LENGTH]> {
let mut buf = [0; LENGTH];
self.port.read_exact(&mut buf).await?;
Ok(buf)
}
async fn read_ram_with_retires<T>(&mut self, address: T) -> eyre::Result<u8>
where
T: Into<u8>,
{
const READ_TRIES: usize = 3;
let address: u8 = address.into();
let mut last_err = None;
for _ in 0..READ_TRIES {
match self.read_ram_single(address).await {
Ok(v) => return Ok(v),
Err(e) => last_err = Some(e),
}
}
Err(last_err.unwrap_or_else(|| {
eyre::eyre!(
"no error was stored in read_ram_with_retries: this should be unreachable??"
)
}))
}
async fn read_ram_single<T>(&mut self, address: T) -> eyre::Result<u8>
where
T: Into<u8>,
{
self.send_command(command(20, address.into(), 0)).await?;
let response = self.get_response().await?;
match response {
Ok(()) => Ok(self.receive::<1>().await?[0]),
Err(e) => Err(e.into()),
}
}
async fn write_ram<T>(&mut self, address: T, data: u8) -> eyre::Result<()>
where
T: Into<u8>,
{
self.send_command(command(152, address.into(), data))
.await?;
Ok(())
}
async fn read_eeprom<T>(&mut self, address: T) -> eyre::Result<u8>
where
T: Into<u8>,
{
self.send_command(command(72, address.into(), 0)).await?;
let response = self.get_response().await?;
match response {
Ok(()) => Ok(self.receive::<1>().await?[0]),
Err(e) => Err(e.into()),
}
}
async fn get_response(&mut self) -> eyre::Result<Result<(), PlError>> {
let res = self.receive::<1>().await?[0];
Ok(PlError::parse(res))
}
}
#[derive(thiserror::Error, Debug)]
enum PlError {
#[error("No comms or corrupt comms")]
NoComms,
#[error("Loopback response code")]
Loopback,
#[error("Timeout error")]
Timeout,
#[error("Checksum error in PLI receive data.")]
Checksum,
#[error("Command received by PLI is not recognised.")]
CommandNotRecognised,
#[error("Unused - or could be returning PL40 version!")]
Unused1,
#[error("Processor did not receive a reply to request.")]
NoReply,
#[error("Error in reply from PL.")]
ErrorFromPl,
#[error("<not used> #2")]
Unused2,
#[error("<not used> #3")]
Unused3,
#[error("Unknown: {0:#X?}")]
Unknown(u8),
}
impl PlError {
const fn parse(value: u8) -> Result<(), Self> {
match value {
200 => Ok(()),
5 => Err(Self::NoComms),
128 => Err(Self::Loopback),
129 => Err(Self::Timeout),
130 => Err(Self::Checksum),
131 => Err(Self::CommandNotRecognised),
132 => Err(Self::Unused1),
133 => Err(Self::NoReply),
134 => Err(Self::ErrorFromPl),
135 => Err(Self::Unused2),
136 => Err(Self::Unused3),
v => Err(Self::Unknown(v)),
}
}
}
#[allow(dead_code)]
enum PlRamAddress {
Dutycyc,
Sec,
Min,
Hour,
Batv,
Battemp,
Rstate,
Vreg,
Cint,
Lint,
Ciacc1,
Ciacc2,
Ciacc3,
Ciahl,
Ciahh,
Ceacc1,
Ceacc2,
Ceacc3,
Ceahl,
Ceahh,
Liacc1,
Liacc2,
Liacc3,
Liahl,
Liahh,
Leacc1,
Leacc2,
Leacc3,
Leahl,
Leahh,
}
impl From<PlRamAddress> for u8 {
fn from(value: PlRamAddress) -> Self {
match value {
PlRamAddress::Dutycyc => 39,
PlRamAddress::Sec => 46,
PlRamAddress::Min => 47,
PlRamAddress::Hour => 48,
PlRamAddress::Batv => 50,
PlRamAddress::Battemp => 52,
PlRamAddress::Rstate => 101,
PlRamAddress::Vreg => 105,
PlRamAddress::Cint => 213,
PlRamAddress::Lint => 217,
PlRamAddress::Ciacc1 => 0xB9,
PlRamAddress::Ciacc2 => 0xBA,
PlRamAddress::Ciacc3 => 0xBB,
PlRamAddress::Ciahl => 0xBC,
PlRamAddress::Ciahh => 0xBD,
PlRamAddress::Ceacc1 => 0xBE,
PlRamAddress::Ceacc2 => 0xBF,
PlRamAddress::Ceacc3 => 0xC0,
PlRamAddress::Ceahl => 0xC1,
PlRamAddress::Ceahh => 0xC2,
PlRamAddress::Liacc1 => 0xC3,
PlRamAddress::Liacc2 => 0xC4,
PlRamAddress::Liacc3 => 0xC5,
PlRamAddress::Liahl => 0xC6,
PlRamAddress::Liahh => 0xC7,
PlRamAddress::Leacc1 => 0xC8,
PlRamAddress::Leacc2 => 0xC9,
PlRamAddress::Leacc3 => 0xCA,
PlRamAddress::Leahl => 0xCB,
PlRamAddress::Leahh => 0xCC,
}
}
}
#[allow(dead_code)]
enum PlEepromAddress {
// calibration
BCals,
BCal12,
BCal24,
BCal48,
ChargeOffset,
ChargeGain,
LoadOffset,
LoadGain,
BatTmpOffset,
BatTmpGain,
SolarOffset,
SolarGain,
BatsenOffset,
BatsenGain,
// settings
GOn,
GOff,
GDel,
GExF,
GRun,
LOff,
LOn,
LDel,
ASet,
BstFreq,
ATim,
Hyst,
BRet,
CurLim,
Bat2,
ESet1,
ESet2,
ESet3,
EqFreq,
ETim,
AbsV,
EMax,
FltV,
BMax,
LGSet,
PwmE,
SStop,
EtMod,
GMod,
Volt,
BCap,
HistoryDataPtr,
}
impl From<PlEepromAddress> for u8 {
fn from(value: PlEepromAddress) -> Self {
match value {
PlEepromAddress::BCals => 0x00,
PlEepromAddress::BCal12 => 0x01,
PlEepromAddress::BCal24 => 0x02,
PlEepromAddress::BCal48 => 0x03,
PlEepromAddress::ChargeOffset => 0x04,
PlEepromAddress::ChargeGain => 0x05,
PlEepromAddress::LoadOffset => 0x06,
PlEepromAddress::LoadGain => 0x07,
PlEepromAddress::BatTmpOffset => 0x08,
PlEepromAddress::BatTmpGain => 0x09,
PlEepromAddress::SolarOffset => 0x0A,
PlEepromAddress::SolarGain => 0x0B,
PlEepromAddress::BatsenOffset => 0x0C,
PlEepromAddress::BatsenGain => 0x0D,
PlEepromAddress::GOn => 0x0E,
PlEepromAddress::GOff => 0x0F,
PlEepromAddress::GDel => 0x10,
PlEepromAddress::GExF => 0x11,
PlEepromAddress::GRun => 0x12,
PlEepromAddress::LOff => 0x13,
PlEepromAddress::LOn => 0x14,
PlEepromAddress::LDel => 0x15,
PlEepromAddress::ASet => 0x16,
PlEepromAddress::BstFreq => 0x17,
PlEepromAddress::ATim => 0x18,
PlEepromAddress::Hyst => 0x19,
PlEepromAddress::BRet => 0x1A,
PlEepromAddress::CurLim => 0x1B,
PlEepromAddress::Bat2 => 0x1C,
PlEepromAddress::ESet1 => 0x1D,
PlEepromAddress::ESet2 => 0x1E,
PlEepromAddress::ESet3 => 0x1F,
PlEepromAddress::EqFreq => 0x20,
PlEepromAddress::ETim => 0x21,
PlEepromAddress::AbsV => 0x22,
PlEepromAddress::EMax => 0x23,
PlEepromAddress::FltV => 0x24,
PlEepromAddress::BMax => 0x25,
PlEepromAddress::LGSet => 0x26,
PlEepromAddress::PwmE => 0x27,
PlEepromAddress::SStop => 0x28,
PlEepromAddress::EtMod => 0x29,
PlEepromAddress::GMod => 0x2A,
PlEepromAddress::Volt => 0x2B,
PlEepromAddress::BCap => 0x2c,
PlEepromAddress::HistoryDataPtr => 0x2d,
}
}
}
const fn command(operation: u8, address: u8, data: u8) -> [u8; 4] {
[operation, address, data, !operation]
}

File diff suppressed because it is too large Load diff

View file

@ -1,192 +0,0 @@
pub struct ModbusTimeout {
context: Option<tokio_modbus::client::Context>,
transport_settings: crate::config::Transport,
counters: Counters,
}
const MODBUS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
async fn connect(
transport_settings: &crate::config::Transport,
) -> eyre::Result<tokio_modbus::client::Context> {
let slave = tokio_modbus::Slave(super::DEVICE_ID);
let modbus = match transport_settings {
crate::config::Transport::Serial { port, baud_rate } => {
let modbus_serial =
tokio_serial::SerialStream::open(&tokio_serial::new(port, *baud_rate))?;
tokio_modbus::client::rtu::attach_slave(modbus_serial, slave)
}
crate::config::Transport::Tcp { ip, port } => {
let modbus_tcp = tokio::net::TcpStream::connect((*ip, *port)).await?;
tokio_modbus::client::tcp::attach_slave(modbus_tcp, slave)
}
};
Ok(modbus)
}
type ModbusDeviceResult<T> = Result<T, tokio_modbus::ExceptionCode>;
type ModbusResult<T> = Result<ModbusDeviceResult<T>, tokio_modbus::Error>;
type ContextFuture<'a, R> = dyn std::future::Future<Output = ModbusResult<R>> + Send + 'a;
type ContextFn<R, D> =
fn(&mut tokio_modbus::client::Context, D) -> std::pin::Pin<Box<ContextFuture<'_, R>>>;
trait TryInsert {
type T;
async fn get_or_try_insert_with<F, R, Fut>(&mut self, f: F) -> Result<&mut Self::T, R>
where
Fut: std::future::Future<Output = Result<Self::T, R>>,
F: FnOnce() -> Fut;
}
impl<T> TryInsert for Option<T> {
type T = T;
async fn get_or_try_insert_with<F, R, Fut>(&mut self, f: F) -> Result<&mut Self::T, R>
where
Fut: std::future::Future<Output = Result<Self::T, R>>,
F: FnOnce() -> Fut,
{
if self.is_none() {
let got = f().await?;
*self = Some(got);
}
// a `None` variant for `self` would have been replaced by a `Some` variant
// in the code above, or the ? would have caused an early return
Ok(self.as_mut().unwrap())
}
}
#[derive(Default, Debug)]
struct Counters {
gateway: usize,
timeout: usize,
protocol: usize,
}
const MAX_ERRORS: usize = 2;
impl Counters {
fn reset(&mut self) {
*self = Self::default();
}
const fn any_above_max(&self) -> bool {
self.gateway > MAX_ERRORS || self.timeout > MAX_ERRORS || self.protocol > MAX_ERRORS
}
}
const NUM_TRIES: usize = 3;
impl ModbusTimeout {
pub async fn new(transport_settings: crate::config::Transport) -> eyre::Result<Self> {
let context = Some(connect(&transport_settings).await?);
Ok(Self {
context,
transport_settings,
counters: Counters::default(),
})
}
async fn with_context<R, D: Copy>(
&mut self,
f: ContextFn<R, D>,
data: D,
) -> eyre::Result<Result<R, tokio_modbus::ExceptionCode>> {
let mut last_err = None;
for _ in 0..NUM_TRIES {
if let Ok(context) = self
.context
.get_or_try_insert_with(async || connect(&self.transport_settings).await)
.await
{
let res = tokio::time::timeout(MODBUS_TIMEOUT, f(context, data)).await;
match res {
Ok(Ok(Err(e)))
if e == tokio_modbus::ExceptionCode::GatewayTargetDevice
|| e == tokio_modbus::ExceptionCode::GatewayPathUnavailable =>
{
log::warn!("gateway error: {e:?}");
last_err = Some(e.into());
self.counters.gateway += 1;
}
Ok(Ok(v)) => {
self.counters.reset();
return Ok(v);
}
Ok(Err(tokio_modbus::Error::Protocol(e))) => {
// protocol error
log::warn!("protocol error: {e:?}");
last_err = Some(e.into());
self.counters.protocol += 1;
}
Ok(Err(tokio_modbus::Error::Transport(e))) => {
// transport error
log::warn!("reconnecting due to transport error: {e:?}");
last_err = Some(e.into());
self.context = None;
}
Err(_) => {
// timeout
last_err = Some(eyre::eyre!("timeout"));
self.counters.timeout += 1;
}
}
if self.counters.any_above_max() {
self.context = None;
log::warn!(
"reconnecting due to multiple errors without a successful operation: {:?}",
self.counters
);
self.counters.reset();
}
} else {
// failed to reconnect
return Err(eyre::eyre!("failed to reconnect to controller"));
}
}
Err(last_err.unwrap_or_else(|| eyre::eyre!("unknown last error????")))
}
pub async fn write_single_register(
&mut self,
addr: tokio_modbus::Address,
word: u16,
) -> eyre::Result<ModbusDeviceResult<()>> {
async fn write(
context: &mut tokio_modbus::client::Context,
addr: tokio_modbus::Address,
word: u16,
) -> ModbusResult<()> {
use tokio_modbus::client::Writer;
context.write_single_register(addr, word).await
}
let fut: ContextFn<(), _> = |context, (addr, word)| Box::pin(write(context, addr, word));
let r = self.with_context(fut, (addr, word)).await?;
Ok(r)
}
pub async fn read_holding_registers(
&mut self,
addr: tokio_modbus::Address,
cnt: tokio_modbus::Quantity,
) -> eyre::Result<ModbusDeviceResult<Vec<u16>>> {
async fn read(
context: &mut tokio_modbus::client::Context,
addr: tokio_modbus::Address,
cnt: tokio_modbus::Quantity,
) -> ModbusResult<Vec<u16>> {
use tokio_modbus::client::Reader;
context.read_holding_registers(addr, cnt).await
}
let fut: ContextFn<_, _> = |context, (addr, cnt)| Box::pin(read(context, addr, cnt));
let res = self.with_context(fut, (addr, cnt)).await?;
Ok(res)
}
}

View file

@ -41,8 +41,6 @@ lazy_static! {
&[PL_LABEL] &[PL_LABEL]
) )
.unwrap(); .unwrap();
pub static ref HEATSINK_TEMP: GaugeVec =
register_gauge_vec!("heatsink_temp", "Heatsink temperature", &[TRISTAR_LABEL]).unwrap();
pub static ref TRISTAR_INPUT_VOLTAGE: GaugeVec = pub static ref TRISTAR_INPUT_VOLTAGE: GaugeVec =
register_gauge_vec!("tristar_input_voltage", "Input voltage", &[TRISTAR_LABEL]).unwrap(); register_gauge_vec!("tristar_input_voltage", "Input voltage", &[TRISTAR_LABEL]).unwrap();
pub static ref TRISTAR_CHARGE_CURRENT: GaugeVec = pub static ref TRISTAR_CHARGE_CURRENT: GaugeVec =
@ -69,16 +67,4 @@ lazy_static! {
&[TRISTAR_LABEL] &[TRISTAR_LABEL]
) )
.unwrap(); .unwrap();
pub static ref TRISTAR_TOTAL_AH_CHARGE_DAILY: GaugeVec = register_gauge_vec!(
"tristar_total_ah_charge_daily",
"Total charge daily (Ah)",
&[TRISTAR_LABEL]
)
.unwrap();
pub static ref TRISTAR_TOTAL_WH_CHARGE_DAILY: GaugeVec = register_gauge_vec!(
"tristar_total_wh_charge_daily",
"Total charge daily (Wh)",
&[TRISTAR_LABEL]
)
.unwrap();
} }

View file

@ -1,5 +1,3 @@
#![feature(let_chains)]
use clap::Parser; use clap::Parser;
use futures::StreamExt; use futures::StreamExt;
use std::path::PathBuf; use std::path::PathBuf;
@ -23,10 +21,9 @@ enum Commands {
GenerateConfig, GenerateConfig,
} }
mod controller;
mod gauges; mod gauges;
mod pl;
mod storage; mod tristar;
mod web; mod web;
@ -53,38 +50,63 @@ async fn main() {
} }
} }
enum Controller {
Pl(pl::Pli),
Tristar(tristar::Tristar),
}
impl Controller {
async fn refresh(&mut self) -> eyre::Result<()> {
match self {
Controller::Pl(pli) => {
pli.refresh()?;
}
Controller::Tristar(tristar) => {
tristar.refresh().await?;
}
}
Ok(())
}
fn name(&self) -> String {
match self {
Controller::Pl(pli) => pli.name().to_owned(),
Controller::Tristar(tristar) => tristar.name().to_owned(),
}
}
}
impl config::ChargeControllerConfig {
fn connect(&self) -> eyre::Result<Controller> {
match self {
config::ChargeControllerConfig::Pl {
serial_port,
baud_rate,
timeout_milliseconds,
watch_interval_seconds: _,
} => {
let pl = pl::Pli::new(serial_port.to_owned(), *baud_rate, *timeout_milliseconds)?;
Ok(Controller::Pl(pl))
}
config::ChargeControllerConfig::Tristar {
serial_port,
baud_rate,
watch_interval_seconds: _,
} => {
let tristar = tristar::Tristar::new(serial_port.to_owned(), *baud_rate)?;
Ok(Controller::Tristar(tristar))
}
}
}
}
async fn run() -> eyre::Result<()> { async fn run() -> eyre::Result<()> {
let args = Args::parse(); let args = Args::parse();
match args.command { match args.command {
Commands::Watch => watch(args).await, Commands::Watch => watch(args).await,
Commands::GenerateConfig => { Commands::GenerateConfig => {
let mut config = config::Config::default(); let config = config::Config::default();
config
.charge_controllers
.push(config::ChargeControllerConfig {
name: String::from("tcp"),
transport: config::Transport::Tcp {
ip: std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 102)),
port: 420,
},
watch_interval_seconds: 0,
variant: config::ChargeControllerVariant::Tristar,
follow_primary: false,
});
config
.charge_controllers
.push(config::ChargeControllerConfig {
name: String::from("serial"),
transport: config::Transport::Serial {
port: "/dev/someport".to_string(),
baud_rate: 69,
},
watch_interval_seconds: 0,
variant: config::ChargeControllerVariant::Tristar,
follow_primary: false,
});
let config = config::ConfigStorage::from_latest(config);
let json = serde_json::to_string_pretty(&config)?; let json = serde_json::to_string_pretty(&config)?;
println!("{json}"); println!("{json}");
Ok(()) Ok(())
@ -93,70 +115,26 @@ async fn run() -> eyre::Result<()> {
} }
async fn watch(args: Args) -> eyre::Result<()> { async fn watch(args: Args) -> eyre::Result<()> {
let _w = config::init_config(&args.config); let config: config::Config = serde_json::from_reader(std::fs::File::open(args.config)?)?;
let (storage, follow_voltage_tx, mut controller_tasks) = {
let config = config::access_config().await; let mut controllers = futures::stream::FuturesUnordered::new();
if config
.charge_controllers for controller in config.charge_controllers {
.iter() match controller.connect() {
.any(|cc| cc.follow_primary && cc.name == config.primary_charge_controller) Ok(v) => controllers.push(run_loop(v, controller.interval())),
{ Err(e) => log::error!("couldn't connect to {controller:?}: {e:?}"),
return Err(eyre::eyre!(
"primary charge controller is set to follow primary!"
));
} }
}
let mut controllers = Vec::new(); let server = web::rocket();
let mut map = std::collections::HashMap::new();
let (voltage_tx, voltage_rx) =
tokio::sync::watch::channel(controller::VoltageCommand::None);
for config in &config.charge_controllers {
let n = config.name.clone();
match controller::Controller::new(config.clone(), voltage_rx.clone()).await {
Ok(v) => {
map.insert(n, v.get_data_ptr());
controllers.push(v);
}
Err(e) => log::error!("couldn't connect to {}: {e:?}", n),
}
}
if let Some(primary) = controllers
.iter_mut()
.find(|c| c.name() == config.primary_charge_controller)
{
primary.set_tx_to_secondary(voltage_tx.clone());
}
drop(config);
let controller_tasks = futures::stream::FuturesUnordered::new();
for controller in controllers {
controller_tasks.push(run_loop(controller));
}
(
storage::AllControllers::new(map),
voltage_tx,
controller_tasks,
)
};
let server = web::rocket(web::ServerState::new(
&config::access_config().await.primary_charge_controller,
storage,
follow_voltage_tx,
));
let server_task = tokio::task::spawn(server.launch()); let server_task = tokio::task::spawn(server.launch());
log::warn!("...started!");
tokio::select! { tokio::select! {
v = controller_tasks.next() => { v = controllers.next() => {
match v { match v {
Some(Err(e)) => { Some(Err(e)) => {
log::error!("{e:?}"); log::error!("{e:?}");
std::process::exit(1);
} }
_ => { _ => {
log::error!("no controller tasks left???"); log::error!("no controller tasks left???");
@ -164,40 +142,19 @@ async fn watch(args: Args) -> eyre::Result<()> {
} }
} }
v = server_task => { v = server_task => {
if let Err(e)=v { log::error!("server exited: {v:#?}");
log::error!("server exited: {e:#?}");
std::process::exit(1);
} else {
std::process::exit(0);
}
} }
} }
std::process::exit(1) std::process::exit(1)
} }
async fn run_loop(mut controller: controller::Controller) -> eyre::Result<()> { async fn run_loop(mut controller: Controller, timeout_interval: u64) -> eyre::Result<()> {
let mut timeout = tokio::time::interval(controller.timeout_interval()); let mut timeout = tokio::time::interval(std::time::Duration::from_secs(timeout_interval));
timeout.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop { loop {
let rx = controller.get_rx(); timeout.tick().await;
tokio::select! { if let Err(e) = controller.refresh().await {
_ = timeout.tick() => { log::warn!("error reading controller {}: {e:?}", controller.name());
do_refresh(&mut controller).await;
}
Ok(()) = rx.changed() => {
let command = *rx.borrow();
if let Err(e) = controller.process_command(command).await {
log::error!("controller {} failed to process command: {e}", controller.name());
}
}
} }
} }
} }
async fn do_refresh(controller: &mut controller::Controller) {
if let Err(e) = controller.refresh().await {
log::warn!("error reading controller {}: {e:?}", controller.name());
}
}

View file

@ -0,0 +1,295 @@
use std::{
io::Write,
sync::{Arc, RwLock},
time::Duration,
};
use chrono::Timelike;
use serde::{Deserialize, Serialize};
use serialport::SerialPort;
use crate::gauges::{
BATTERY_TEMP, BATTERY_VOLTAGE, CHARGE_STATE, INPUT_CURRENT, PL_DUTY_CYCLE, PL_LOAD_CURRENT,
TARGET_VOLTAGE,
};
pub struct Pli {
pub state: Arc<RwLock<PlState>>,
port_name: String,
port: Box<dyn SerialPort>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
pub struct PlState {
pub battery_voltage: f64,
pub target_voltage: f64,
pub duty_cycle: f64,
pub internal_charge_current: f64,
pub internal_load_current: f64,
pub battery_temp: f64,
pub regulator_state: RegulatorState,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum RegulatorState {
Boost,
Equalise,
Absorption,
Float,
}
impl From<u8> for RegulatorState {
fn from(value: u8) -> Self {
match value & 0b11 {
0b00 => Self::Boost,
0b01 => Self::Equalise,
0b10 => Self::Absorption,
0b11 => Self::Float,
_ => unreachable!(),
}
}
}
impl From<RegulatorState> for u8 {
fn from(value: RegulatorState) -> Self {
match value {
RegulatorState::Boost => 0b00,
RegulatorState::Equalise => 0b01,
RegulatorState::Absorption => 0b10,
RegulatorState::Float => 0b11,
}
}
}
impl Default for RegulatorState {
fn default() -> Self {
Self::Absorption
}
}
fn set_regulator_gauges(state: RegulatorState, label: &str) {
let boost = CHARGE_STATE.with_label_values(&[label, "boost"]);
let equalise = CHARGE_STATE.with_label_values(&[label, "equalise"]);
let absorption = CHARGE_STATE.with_label_values(&[label, "absorption"]);
let float = CHARGE_STATE.with_label_values(&[label, "float"]);
match state {
RegulatorState::Boost => {
boost.set(1);
equalise.set(0);
absorption.set(0);
float.set(0);
}
RegulatorState::Equalise => {
boost.set(0);
equalise.set(1);
absorption.set(0);
float.set(0);
}
RegulatorState::Absorption => {
boost.set(0);
equalise.set(0);
absorption.set(1);
float.set(0);
}
RegulatorState::Float => {
boost.set(0);
equalise.set(0);
absorption.set(0);
float.set(1);
}
}
}
#[expect(dead_code, reason = "writing settings is not yet implemented")]
#[derive(Debug, Clone, Copy)]
pub enum PliRequest {
ReadRam(u8),
ReadEeprom(u8),
SyncTime,
SetRegulatorState(RegulatorState),
}
impl Pli {
pub fn new(
serial_port: String,
baud_rate: u32,
timeout: u64,
) -> Result<Self, serialport::Error> {
let port = serialport::new(serial_port.clone(), baud_rate)
.timeout(Duration::from_millis(timeout))
.open()?;
Ok(Self {
state: Arc::new(RwLock::new(Default::default())),
port_name: serial_port,
port,
})
}
pub fn refresh(&mut self) -> eyre::Result<()> {
let new_state = self.read_state()?;
BATTERY_VOLTAGE
.with_label_values(&[&self.port_name])
.set(new_state.battery_voltage);
TARGET_VOLTAGE
.with_label_values(&[&self.port_name])
.set(new_state.target_voltage);
PL_DUTY_CYCLE
.with_label_values(&[&self.port_name])
.set(new_state.duty_cycle);
INPUT_CURRENT
.with_label_values(&[&self.port_name])
.set(new_state.internal_charge_current);
PL_LOAD_CURRENT
.with_label_values(&[&self.port_name])
.set(new_state.internal_load_current);
BATTERY_TEMP
.with_label_values(&[&self.port_name])
.set(new_state.battery_temp);
set_regulator_gauges(new_state.regulator_state, &self.port_name);
*self.state.write().expect("PLI state handler panicked!!") = new_state;
Ok(())
}
#[expect(dead_code, reason = "writing settings is not yet implemented")]
pub fn process_request(&mut self, message: PliRequest) -> eyre::Result<()> {
match message {
PliRequest::ReadRam(address) => {
let data = self.read_ram(address)?;
log::warn!("Read RAM at {address}: data {data}");
}
PliRequest::ReadEeprom(address) => {
let data = self.read_eeprom(address)?;
log::warn!("Read EEPROM at {address}: data {data}");
}
PliRequest::SyncTime => {
let now = chrono::Local::now();
let timestamp = (((now.hour() * 10) + (now.minute() / 6)) & 0xFF) as u8;
let min = (now.minute() % 6) as u8;
let sec = (now.second() / 2).min(29) as u8;
self.write_ram(PlRamAddress::Hour, timestamp)?;
self.write_ram(PlRamAddress::Min, min)?;
self.write_ram(PlRamAddress::Sec, sec)?;
log::warn!(
"Set time: {now} corresponds to {timestamp} + minutes {min} + seconds {sec}"
);
}
PliRequest::SetRegulatorState(state) => {
log::warn!("Setting regulator state to {state:?}");
self.write_ram(PlRamAddress::Rstate, state.into())?;
}
}
Ok(())
}
fn read_state(&mut self) -> eyre::Result<PlState> {
Ok(PlState {
battery_voltage: f64::from(self.read_ram(PlRamAddress::Batv)?) * (4. / 10.),
target_voltage: f64::from(self.read_ram(PlRamAddress::Vreg)?) * (4. / 10.),
duty_cycle: f64::from(self.read_ram(PlRamAddress::Dutycyc)?) / 255.,
internal_charge_current: f64::from(self.read_ram(PlRamAddress::Cint)?) * (4. / 10.),
internal_load_current: f64::from(self.read_ram(PlRamAddress::Lint)?) * (4. / 10.),
battery_temp: f64::from(self.read_ram(PlRamAddress::Battemp)?),
regulator_state: self.read_ram(PlRamAddress::Rstate)?.into(),
})
}
fn send_command(&mut self, req: [u8; 4]) -> eyre::Result<()> {
self.flush()?;
self.port.write_all(&req)?;
Ok(())
}
fn flush(&mut self) -> eyre::Result<()> {
self.port.flush()?;
while let Ok(num) = self.port.bytes_to_read() {
if num == 0 {
return Ok(());
}
let _ = self.port.read(&mut [0; 8]);
}
Ok(())
}
fn receive<const LENGTH: usize>(&mut self) -> eyre::Result<[u8; LENGTH]> {
let mut buf = [0; LENGTH];
self.port.read_exact(&mut buf)?;
Ok(buf)
}
fn read_ram<T>(&mut self, address: T) -> eyre::Result<u8>
where
T: Into<u8>,
{
self.send_command(command(20, address.into(), 0))?;
let buf = self.receive::<1>()?;
if buf[0] == 200 {
Ok(self.receive::<1>()?[0])
} else {
Err(eyre::eyre!("read error: result is {}", buf[0]))
}
}
fn write_ram<T>(&mut self, address: T, data: u8) -> eyre::Result<()>
where
T: Into<u8>,
{
self.send_command(command(152, address.into(), data))?;
Ok(())
}
fn read_eeprom<T>(&mut self, address: T) -> eyre::Result<u8>
where
T: Into<u8>,
{
self.send_command(command(72, address.into(), 0))?;
let buf = self.receive::<1>()?;
if buf[0] == 200 {
Ok(self.receive::<1>()?[0])
} else {
Err(eyre::eyre!("read error: result is {}", buf[0]))
}
}
pub fn name(&self) -> &str {
&self.port_name
}
}
enum PlRamAddress {
Dutycyc,
Sec,
Min,
Hour,
Batv,
Battemp,
Rstate,
Vreg,
Cint,
Lint,
}
impl From<PlRamAddress> for u8 {
fn from(value: PlRamAddress) -> Self {
match value {
PlRamAddress::Dutycyc => 39,
PlRamAddress::Sec => 46,
PlRamAddress::Min => 47,
PlRamAddress::Hour => 48,
PlRamAddress::Batv => 50,
PlRamAddress::Battemp => 52,
PlRamAddress::Rstate => 101,
PlRamAddress::Vreg => 105,
PlRamAddress::Cint => 213,
PlRamAddress::Lint => 217,
}
}
}
fn command(operation: u8, address: u8, data: u8) -> [u8; 4] {
[operation, address, data, !operation]
}

View file

@ -1,61 +0,0 @@
pub struct AllControllers {
map: std::collections::HashMap<String, std::sync::Arc<ControllerData>>,
}
impl AllControllers {
pub const fn new(
map: std::collections::HashMap<String, std::sync::Arc<ControllerData>>,
) -> Self {
Self { map }
}
pub fn controller_names(&self) -> impl Iterator<Item = &String> {
self.map.keys()
}
pub fn get(&self, name: &str) -> Option<&std::sync::Arc<ControllerData>> {
self.map.get(name)
}
pub fn all_data(&self) -> impl Iterator<Item = (&String, &std::sync::Arc<ControllerData>)> {
self.map.iter()
}
}
pub struct ControllerData {
state: tokio::sync::RwLock<Option<crate::controller::ControllerState>>,
settings: tokio::sync::RwLock<Option<crate::controller::ControllerSettings>>,
}
impl ControllerData {
pub const fn new() -> Self {
Self {
state: tokio::sync::RwLock::const_new(None),
settings: tokio::sync::RwLock::const_new(None),
}
}
pub async fn write_state(
&self,
) -> tokio::sync::RwLockWriteGuard<Option<crate::controller::ControllerState>> {
self.state.write().await
}
pub async fn read_state(
&self,
) -> tokio::sync::RwLockReadGuard<Option<crate::controller::ControllerState>> {
self.state.read().await
}
pub async fn write_settings(
&self,
) -> tokio::sync::RwLockWriteGuard<Option<crate::controller::ControllerSettings>> {
self.settings.write().await
}
pub async fn read_settings(
&self,
) -> tokio::sync::RwLockReadGuard<Option<crate::controller::ControllerSettings>> {
self.settings.read().await
}
}

View file

@ -0,0 +1,329 @@
use prometheus::core::{AtomicI64, GenericGauge};
use tokio_modbus::client::Reader;
use crate::gauges::{
BATTERY_TEMP, BATTERY_VOLTAGE, CHARGE_STATE, INPUT_CURRENT, TARGET_VOLTAGE,
TRISTAR_CHARGE_CURRENT, TRISTAR_INPUT_VOLTAGE, TRISTAR_MAX_ARRAY_POWER,
TRISTAR_MAX_ARRAY_VOLTAGE, TRISTAR_OPEN_CIRCUIT_VOLTAGE, TRISTAR_POWER_IN, TRISTAR_POWER_OUT,
};
const DEVICE_ID: u8 = 0x01;
const RAM_DATA_SIZE: u16 = 0x005B;
#[derive(Debug, Clone, Copy)]
pub struct Scaling {
pub v_scale: f64,
pub i_scale: f64,
}
impl Scaling {
fn from(data: &[u16]) -> Self {
Self::from_internal(data[0], data[1], data[2], data[3])
}
fn from_internal(v_pu_hi: u16, v_pu_lo: u16, i_pu_hi: u16, i_pu_lo: u16) -> Self {
Self {
v_scale: f64::from(v_pu_hi) + (f64::from(v_pu_lo) / f64::powf(2., 16.)),
i_scale: f64::from(i_pu_hi) + (f64::from(i_pu_lo) / f64::powf(2., 16.)),
}
}
fn get_voltage(&self, data: u16) -> f64 {
f64::from(data) * self.v_scale * f64::powf(2., -15.)
}
fn get_current(&self, data: u16) -> f64 {
f64::from(data) * self.i_scale * f64::powf(2., -15.)
}
fn get_power(&self, data: u16) -> f64 {
f64::from(data) * self.v_scale * self.i_scale * f64::powf(2., -17.)
}
}
pub struct Tristar {
state: TristarState,
port_name: String,
modbus: tokio_modbus::client::Context,
charge_state_gauges: ChargeStateGauges,
consecutive_errors: usize,
}
#[derive(Default, Debug, Clone, Copy)]
pub struct TristarState {
battery_voltage: f64,
target_voltage: f64,
input_current: f64,
battery_temp: u16,
charge_state: ChargeState,
tristar_input_voltage: f64,
tristar_charge_current: f64,
tristar_power_out: f64,
tristar_power_in: f64,
tristar_max_array_power: f64,
tristar_max_array_voltage: f64,
tristar_open_circuit_voltage: f64,
}
impl TristarState {
fn from_ram(ram: &[u16]) -> Self {
let scaling = Scaling::from(ram);
Self {
battery_voltage: scaling.get_voltage(ram[TristarRamAddress::AdcVbFMed]),
target_voltage: scaling.get_voltage(ram[TristarRamAddress::VbRef]),
input_current: scaling.get_current(ram[TristarRamAddress::AdcIaFShadow]),
battery_temp: ram[TristarRamAddress::Tbatt],
charge_state: ChargeState::from(ram[TristarRamAddress::ChargeState]),
tristar_input_voltage: scaling.get_voltage(ram[TristarRamAddress::AdcVaF]),
tristar_charge_current: scaling.get_current(ram[TristarRamAddress::AdcIbFShadow]),
tristar_power_out: scaling.get_power(ram[TristarRamAddress::PowerOutShadow]),
tristar_power_in: scaling.get_power(ram[TristarRamAddress::PowerInShadow]),
tristar_max_array_power: scaling.get_power(ram[TristarRamAddress::SweepPinMax]),
tristar_max_array_voltage: scaling.get_voltage(ram[TristarRamAddress::SweepVmp]),
tristar_open_circuit_voltage: scaling.get_voltage(ram[TristarRamAddress::SweepVoc]),
}
}
}
#[derive(Debug, Clone, Copy)]
enum ChargeState {
Start,
NightCheck,
Disconnect,
Night,
Fault,
Mppt,
Absorption,
Float,
Equalize,
Slave,
Unknown,
}
impl Default for ChargeState {
fn default() -> Self {
Self::Unknown
}
}
impl From<u16> for ChargeState {
fn from(value: u16) -> Self {
match value {
0 => Self::Start,
1 => Self::NightCheck,
2 => Self::Disconnect,
3 => Self::Night,
4 => Self::Fault,
5 => Self::Mppt,
6 => Self::Absorption,
7 => Self::Float,
8 => Self::Equalize,
9 => Self::Slave,
_ => {
log::warn!("Unknown chargestate value: {value}");
Self::Unknown
}
}
}
}
struct ChargeStateGauges {
start: GenericGauge<AtomicI64>,
night_check: GenericGauge<AtomicI64>,
disconnect: GenericGauge<AtomicI64>,
night: GenericGauge<AtomicI64>,
fault: GenericGauge<AtomicI64>,
mppt: GenericGauge<AtomicI64>,
absorption: GenericGauge<AtomicI64>,
float: GenericGauge<AtomicI64>,
equalize: GenericGauge<AtomicI64>,
slave: GenericGauge<AtomicI64>,
unknown: GenericGauge<AtomicI64>,
}
impl ChargeStateGauges {
fn new(label: &str) -> Self {
let start = CHARGE_STATE.with_label_values(&[label, "start"]);
let night_check = CHARGE_STATE.with_label_values(&[label, "night_check"]);
let disconnect = CHARGE_STATE.with_label_values(&[label, "disconnect"]);
let night = CHARGE_STATE.with_label_values(&[label, "night"]);
let fault = CHARGE_STATE.with_label_values(&[label, "fault"]);
let mppt = CHARGE_STATE.with_label_values(&[label, "mppt"]);
let absorption = CHARGE_STATE.with_label_values(&[label, "absorption"]);
let float = CHARGE_STATE.with_label_values(&[label, "float"]);
let equalize = CHARGE_STATE.with_label_values(&[label, "equalize"]);
let slave = CHARGE_STATE.with_label_values(&[label, "slave"]);
let unknown = CHARGE_STATE.with_label_values(&[label, "unknown"]);
Self {
start,
night_check,
disconnect,
night,
fault,
mppt,
absorption,
float,
equalize,
slave,
unknown,
}
}
fn zero_all(&mut self) {
self.start.set(0);
self.night_check.set(0);
self.disconnect.set(0);
self.night.set(0);
self.fault.set(0);
self.mppt.set(0);
self.absorption.set(0);
self.float.set(0);
self.equalize.set(0);
self.slave.set(0);
self.unknown.set(0);
}
fn set(&mut self, state: ChargeState) {
match state {
ChargeState::Start => {
self.zero_all();
self.start.set(1);
}
ChargeState::NightCheck => {
self.zero_all();
self.night_check.set(1);
}
ChargeState::Disconnect => {
self.zero_all();
self.disconnect.set(1);
}
ChargeState::Night => {
self.zero_all();
self.night.set(1);
}
ChargeState::Fault => {
self.zero_all();
self.fault.set(1);
}
ChargeState::Mppt => {
self.zero_all();
self.mppt.set(1);
}
ChargeState::Absorption => {
self.zero_all();
self.absorption.set(1);
}
ChargeState::Float => {
self.zero_all();
self.float.set(1);
}
ChargeState::Equalize => {
self.zero_all();
self.equalize.set(1);
}
ChargeState::Slave => {
self.zero_all();
self.slave.set(1);
}
ChargeState::Unknown => {
self.zero_all();
self.unknown.set(1);
}
}
}
}
impl Tristar {
pub fn new(serial_port: String, baud_rate: u32) -> eyre::Result<Self> {
let modbus_serial =
tokio_serial::SerialStream::open(&tokio_serial::new(&serial_port, baud_rate))?;
let slave = tokio_modbus::Slave(DEVICE_ID);
let modbus = tokio_modbus::client::rtu::attach_slave(modbus_serial, slave);
let charge_state_gauges = ChargeStateGauges::new(&serial_port);
Ok(Self {
state: Default::default(),
port_name: serial_port,
modbus,
charge_state_gauges,
consecutive_errors: 0,
})
}
pub async fn refresh(&mut self) -> eyre::Result<()> {
let new_state = self.get_data().await?;
self.consecutive_errors = 0;
BATTERY_VOLTAGE
.with_label_values(&[&self.port_name])
.set(new_state.battery_voltage);
TARGET_VOLTAGE
.with_label_values(&[&self.port_name])
.set(new_state.target_voltage);
INPUT_CURRENT
.with_label_values(&[&self.port_name])
.set(new_state.input_current);
BATTERY_TEMP
.with_label_values(&[&self.port_name])
.set(new_state.battery_temp.into());
TRISTAR_INPUT_VOLTAGE
.with_label_values(&[&self.port_name])
.set(new_state.tristar_input_voltage);
TRISTAR_CHARGE_CURRENT
.with_label_values(&[&self.port_name])
.set(new_state.tristar_charge_current);
TRISTAR_POWER_OUT
.with_label_values(&[&self.port_name])
.set(new_state.tristar_power_out);
TRISTAR_POWER_IN
.with_label_values(&[&self.port_name])
.set(new_state.tristar_power_in);
TRISTAR_MAX_ARRAY_POWER
.with_label_values(&[&self.port_name])
.set(new_state.tristar_max_array_power);
TRISTAR_MAX_ARRAY_VOLTAGE
.with_label_values(&[&self.port_name])
.set(new_state.tristar_max_array_voltage);
TRISTAR_OPEN_CIRCUIT_VOLTAGE
.with_label_values(&[&self.port_name])
.set(new_state.tristar_open_circuit_voltage);
self.charge_state_gauges.set(new_state.charge_state);
self.state = new_state;
Ok(())
}
pub fn name(&self) -> &str {
&self.port_name
}
async fn get_data(&mut self) -> eyre::Result<TristarState> {
let data = self
.modbus
.read_holding_registers(0x0000, RAM_DATA_SIZE + 1)
.await??;
Ok(TristarState::from_ram(&data))
}
}
enum TristarRamAddress {
AdcVbFMed = 0x0018,
AdcVaF = 0x001B,
AdcIbFShadow = 0x001C,
AdcIaFShadow = 0x001D,
Tbatt = 0x0025,
ChargeState = 0x0032,
VbRef = 0x0033,
PowerOutShadow = 0x003A,
PowerInShadow = 0x003B,
SweepPinMax = 0x003C,
SweepVmp = 0x003D,
SweepVoc = 0x003E,
}
impl std::ops::Index<TristarRamAddress> for [u16] {
type Output = u16;
fn index(&self, index: TristarRamAddress) -> &Self::Output {
&self[index as usize]
}
}

View file

@ -1,173 +1,7 @@
use rocket::{get, post, routes, serde::json::Json, State}; use rocket::{get, routes};
use crate::storage::AllControllers; pub fn rocket() -> rocket::Rocket<rocket::Build> {
rocket::build().mount("/", routes![metrics,])
mod static_handler;
pub struct ServerState {
primary_name: String,
data: AllControllers,
tx_to_controllers: tokio::sync::watch::Sender<crate::controller::VoltageCommand>,
}
impl ServerState {
pub fn new(
primary_name: &impl ToString,
data: AllControllers,
tx_to_controllers: tokio::sync::watch::Sender<crate::controller::VoltageCommand>,
) -> Self {
let primary_name = primary_name.to_string();
Self {
primary_name,
data,
tx_to_controllers,
}
}
}
pub fn rocket(state: ServerState) -> rocket::Rocket<rocket::Build> {
// serve the html from disk if running in a debug build
// this allows editing the webpage without having to rebuild the executable
// but in release builds, bundle the entire webapp folder into the exe
let fileserver: Vec<rocket::Route> = if cfg!(debug_assertions) {
rocket::fs::FileServer::from(format!(
"{}/webapp",
std::env::var("CARGO_MANIFEST_DIR").unwrap()
))
.into()
} else {
static_handler::UiStatic {}.into()
};
rocket::build()
.attach(Cors)
.manage(state)
.mount("/", fileserver)
.mount(
"/",
routes![
metrics,
interfaces,
all_interfaces,
all_interfaces_full,
all_interfaces_settings,
primary_interface,
interface,
interface_full,
interface_settings,
get_control,
enable_control,
disable_control
],
)
}
#[get("/interfaces")]
fn interfaces(state: &State<ServerState>) -> Json<Vec<String>> {
Json(state.data.controller_names().cloned().collect())
}
#[get("/interfaces/primary")]
async fn primary_interface(
state: &State<ServerState>,
) -> Result<Json<crate::controller::CommonData>, ServerError> {
let s = state
.data
.get(&state.primary_name)
.ok_or(ServerError::InvalidPrimaryName)?
.read_state()
.await;
Ok(Json(s.as_ref().ok_or(ServerError::NoData)?.common()))
}
#[get("/interfaces/data")]
async fn all_interfaces(
state: &State<ServerState>,
) -> Json<Vec<(String, crate::controller::CommonData)>> {
let mut data = Vec::new();
for (k, v) in state.data.all_data() {
let v = v.read_state().await;
if let Some(v) = v.as_ref() {
data.push((k.clone(), v.common().clone()));
}
}
Json(data)
}
#[get("/interfaces/data/full")]
async fn all_interfaces_full(
state: &State<ServerState>,
) -> Json<Vec<(String, crate::controller::ControllerState)>> {
let mut data = Vec::new();
for (k, v) in state.data.all_data() {
let v = v.read_state().await;
if let Some(v) = v.as_ref() {
data.push((k.clone(), v.clone()));
}
}
Json(data)
}
#[get("/interfaces/settings")]
async fn all_interfaces_settings(
state: &State<ServerState>,
) -> Json<Vec<(String, crate::controller::ControllerSettings)>> {
let mut data = Vec::new();
for (k, v) in state.data.all_data() {
let v = v.read_settings().await;
if let Some(v) = v.as_ref() {
data.push((k.clone(), v.clone()));
}
}
Json(data)
}
#[get("/interface/<name>")]
async fn interface(
name: &str,
state: &State<ServerState>,
) -> Result<Json<crate::controller::CommonData>, ServerError> {
let data = state
.data
.get(name)
.ok_or(ServerError::NotFound)?
.read_state()
.await;
Ok(Json(data.as_ref().ok_or(ServerError::NoData)?.common()))
}
#[get("/interface/<name>/full")]
async fn interface_full(
name: &str,
state: &State<ServerState>,
) -> Result<Json<crate::controller::ControllerState>, ServerError> {
let data = state
.data
.get(name)
.ok_or(ServerError::NotFound)?
.read_state()
.await;
Ok(Json(data.as_ref().ok_or(ServerError::NoData)?.clone()))
}
#[get("/interface/<name>/settings")]
async fn interface_settings(
name: &str,
state: &State<ServerState>,
) -> Result<Json<crate::controller::ControllerSettings>, ServerError> {
let data = state
.data
.get(name)
.ok_or(ServerError::NotFound)?
.read_settings()
.await;
Ok(Json(data.as_ref().ok_or(ServerError::NoData)?.clone()))
} }
#[get("/metrics")] #[get("/metrics")]
@ -178,45 +12,8 @@ fn metrics() -> Result<String, ServerError> {
) )
} }
#[derive(serde::Serialize)]
struct ControlState {
enabled: bool,
}
#[get("/control")]
async fn get_control() -> Json<ControlState> {
let enabled = crate::config::access_config()
.await
.enable_secondary_control;
Json(ControlState { enabled })
}
#[post("/control/enable")]
async fn enable_control() {
log::warn!("enabling control");
crate::config::write_to_config()
.await
.enable_secondary_control = true;
}
#[post("/control/disable")]
async fn disable_control(state: &State<ServerState>) -> Result<(), ServerError> {
log::warn!("disabling control");
crate::config::write_to_config()
.await
.enable_secondary_control = false;
state
.tx_to_controllers
.send(crate::controller::VoltageCommand::None)?;
Ok(())
}
enum ServerError { enum ServerError {
Prometheus, Prometheus,
NotFound,
InvalidPrimaryName,
NoData,
ControllerTx,
} }
impl From<prometheus::Error> for ServerError { impl From<prometheus::Error> for ServerError {
@ -225,55 +22,10 @@ impl From<prometheus::Error> for ServerError {
} }
} }
impl<T> From<tokio::sync::watch::error::SendError<T>> for ServerError {
fn from(_: tokio::sync::watch::error::SendError<T>) -> Self {
Self::ControllerTx
}
}
impl<'a> rocket::response::Responder<'a, 'a> for ServerError { impl<'a> rocket::response::Responder<'a, 'a> for ServerError {
fn respond_to(self, _: &'a rocket::Request<'_>) -> rocket::response::Result<'a> { fn respond_to(self, _: &'a rocket::Request<'_>) -> rocket::response::Result<'a> {
Err(match self { Err(match self {
Self::NotFound => rocket::http::Status::NotFound, ServerError::Prometheus => rocket::http::Status::InternalServerError,
Self::InvalidPrimaryName => rocket::http::Status::ServiceUnavailable,
Self::ControllerTx | Self::NoData | Self::Prometheus => {
rocket::http::Status::InternalServerError
}
}) })
} }
} }
pub struct Cors;
#[rocket::async_trait]
impl rocket::fairing::Fairing for Cors {
fn info(&self) -> rocket::fairing::Info {
rocket::fairing::Info {
name: "Add CORS headers to responses",
kind: rocket::fairing::Kind::Response,
}
}
async fn on_response<'r>(
&self,
_request: &'r rocket::Request<'_>,
response: &mut rocket::Response<'r>,
) {
response.set_header(rocket::http::Header::new(
"Access-Control-Allow-Origin",
"*",
));
response.set_header(rocket::http::Header::new(
"Access-Control-Allow-Methods",
"POST, GET, PATCH, OPTIONS",
));
response.set_header(rocket::http::Header::new(
"Access-Control-Allow-Headers",
"*",
));
response.set_header(rocket::http::Header::new(
"Access-Control-Allow-Credentials",
"true",
));
}
}

View file

@ -1,83 +0,0 @@
use include_dir::{include_dir, Dir};
use rocket::{
http::{ContentType, Status},
outcome::IntoOutcome,
response::Responder,
route::{Handler, Outcome},
Data, Request,
};
use std::path::PathBuf;
static UI_DIR_FILES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/webapp");
#[derive(Clone, Copy, Debug)]
pub(super) struct UiStatic {}
impl From<UiStatic> for Vec<rocket::Route> {
fn from(server: UiStatic) -> Self {
vec![rocket::Route::ranked(
None,
rocket::http::Method::Get,
"/<path..>",
server,
)]
}
}
#[rocket::async_trait]
impl Handler for UiStatic {
async fn handle<'r>(&self, req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r> {
use rocket::http::uri::fmt::Path;
let path = req
.segments::<rocket::http::uri::Segments<'_, Path>>(0..)
.ok()
.and_then(|segments| segments.to_path_buf(true).ok());
match path {
Some(p) => {
if p.as_os_str() == "" {
let index = UI_DIR_FILES.get_file("index.html").map(|v| RawHtml {
data: v.contents().to_vec(),
name: PathBuf::from("index.html"),
});
index.respond_to(req).or_forward((data, Status::NotFound))
} else {
let plus_index = p.join("index.html");
let file = UI_DIR_FILES
.get_file(&p)
.map(|v| RawHtml {
data: v.contents().to_vec(),
name: p,
})
.or_else(|| {
UI_DIR_FILES.get_file(&plus_index).map(|v| RawHtml {
data: v.contents().to_vec(),
name: plus_index,
})
});
file.respond_to(req).or_forward((data, Status::NotFound))
}
}
None => Outcome::forward(data, Status::NotFound),
}
}
}
struct RawHtml {
data: Vec<u8>,
name: PathBuf,
}
impl<'r> Responder<'r, 'static> for RawHtml {
fn respond_to(self, request: &'r Request<'_>) -> rocket::response::Result<'static> {
let mut response = self.data.respond_to(request)?;
if let Some(ext) = self.name.extension()
&& let Some(ct) = ContentType::from_extension(&ext.to_string_lossy())
{
response.set_header(ct);
}
Ok(response)
}
}

View file

@ -1,26 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Charge Control Control</title>
<link id="favicon" rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤯</text></svg>">
<link rel="stylesheet" href="style.css">
<script src="script.js"></script>
</head>
<body onload="init_main()">
<div class="container">
<h3>Follow primary controller:</h3>
<div class="selector" id="control-selector">
<input id="control-enabled" type="radio" name="control" onclick="enable_automatic_control()">
<label for="control-enabled">enabled</label>
<input id="control-disabled" type="radio" name="control" onclick="disable_automatic_control()">
<label for="control-disabled">disabled</label>
</div>
</div>
</body>
</html>

View file

@ -1,80 +0,0 @@
const api_url =
window.location.protocol +
"//" +
window.location.hostname +
":" +
window.location.port;
const delay = (time) => {
return new Promise((resolve) => setTimeout(resolve, time));
};
Object.prototype.disable = function () {
var that = this;
for (var i = 0, len = that.length; i < len; i++) {
that[i].disabled = true;
}
return that;
};
Object.prototype.enable = function () {
var that = this;
for (var i = 0, len = that.length; i < len; i++) {
that[i].disabled = false;
}
return that;
};
function init_main() {
refresh_buttons();
}
function disable_automatic_control() {
if (is_automatic_control) {
document.getElementById("control-disabled").checked = true;
document.body.classList.add("loading");
fetch(api_url + "/control/disable", { method: "POST" }).then(
async (response) => {
let delayres = await delay(100);
refresh_buttons();
}
);
}
}
function enable_automatic_control() {
if (!is_automatic_control) {
document.getElementById("control-enabled").checked = true;
document.body.classList.add("loading");
fetch(api_url + "/control/enable", { method: "POST" }).then(
async (response) => {
let delayres = await delay(100);
refresh_buttons();
}
);
}
}
function update_control_buttons(data) {
document.body.classList.remove("loading");
console.log("got data");
console.log(data);
is_automatic_control = data.enabled;
if (is_automatic_control) {
document.getElementById("control-enabled").checked = true;
} else {
document.getElementById("control-disabled").checked = true;
}
}
function refresh_buttons() {
console.log("get data");
console.log(api_url);
fetch(api_url + "/control").then(async (response) => {
console.log("got response");
json = await response.json();
console.log(json);
update_control_buttons(json);
});
}

View file

@ -1,101 +0,0 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
background-color: #333;
color: #333;
}
.container {
max-width: 40rem;
margin: auto;
padding: 0.5rem 2rem 2rem 2rem;
border-radius: 10px;
background-color: #faf9fd;
}
.outlink {
display: block;
font-weight: bold;
margin-top: 0.5rem;
}
a.outlink {
text-decoration: none;
color: rgb(52, 52, 246);
}
a.outlink:hover {
color: rgb(110, 100, 255);
}
.loading,
.loading * {
cursor: progress;
}
.disabled,
.disabled * {
cursor: not-allowed;
}
.selector {
padding: 1rem;
background-color: gray;
color: #333;
width: max-content;
border: 0.2rem;
border-radius: 6px;
}
label {
padding: 0.5rem 1rem;
margin: 0.5rem;
font-weight: bold;
transition: all 0.2s 0s ease;
border-radius: 4px;
text-align: center;
}
input[type="radio"] {
display: none;
}
input[type="radio"]:checked + label {
background-color: white;
}
input[type="radio"]:checked:disabled + label {
background-color: #ddd;
}
input[type="radio"]:disabled + label {
color: #666;
}
@media (width > 600px) {
.container {
margin-top: 2rem;
}
}
@media (prefers-color-scheme: dark) {
body {
background-color: #191919;
}
.container {
background-color: #444;
color: #f0f0f0;
}
a.outlink {
text-decoration: none;
/* color: rgb(152, 152, 242); */
color: rgb(125, 125, 250);
/* color: rgb(94, 94, 252); */
}
a.outlink:hover {
color: rgb(130, 120, 255);
}
}

2
control-loop.txt Normal file
View file

@ -0,0 +1,2 @@
targeting pl's target voltage
if load current > 0 or duty cycle < ~0.8 then there is power to spare

View file

@ -1,3 +0,0 @@
[toolchain]
channel = "nightly-2025-01-16"
targets = ["aarch64-unknown-linux-musl"]

12
string_fields.txt Normal file
View file

@ -0,0 +1,12 @@
cabin_overheat_protection:
FanOnly
Off
On
climate_keeper_mode:
off
hvac_auto_request:
Override
On
conn_charge_cable:
<invalid>
IEC

View file

@ -12,30 +12,29 @@ systemd-units = { enable = false }
depends = "charge-controller-supervisor" depends = "charge-controller-supervisor"
[dependencies] [dependencies]
chrono = "0.4.39"
clap = { version = "4.5.23", features = ["derive"] } clap = { version = "4.5.23", features = ["derive"] }
env_logger = "0.11.6"
eyre = "0.6.12"
if_chain = "1.0.2"
include_dir = "0.7.4"
lazy_static = "1.5.0"
log = "0.4.22"
notify-debouncer-mini = { version = "0.5.0", default-features = false }
prometheus = "0.13.4"
rand = "0.8.5"
reqwest = { version = "0.12.9", default-features = false, features = [
"json",
"rustls-tls",
] }
rocket = { version = "0.5.1", features = ["json"] }
ron = "0.8.1" ron = "0.8.1"
serde = { version = "1.0.216", features = ["derive"] } serde = { version = "1.0.216", features = ["derive"] }
serde_json = "1.0.134" serde_json = "1.0.134"
serialport = "4.6.1"
thiserror = "2.0.9"
tokio = { version = "1.42.0", features = ["full"] } tokio = { version = "1.42.0", features = ["full"] }
thiserror = "2.0.9"
rocket = { version = "0.5.1", features = ["json"] }
include_dir = "0.7.4"
chrono = "0.4.39"
prometheus = "0.13.4"
env_logger = "0.11.6"
log = "0.4.22"
serialport = "4.6.1"
tokio-modbus = "0.16.1" tokio-modbus = "0.16.1"
tokio-serial = "5.4.4" tokio-serial = "5.4.4"
if_chain = "1.0.2"
notify-debouncer-mini = { version = "0.5.0", default-features = false }
lazy_static = "1.5.0"
rand = "0.8.5"
reqwest = { version = "0.12.9", default-features = false, features = [
"rustls-tls",
] }
eyre = "0.6.12"
[lints] [lints]
workspace = true workspace = true

View file

@ -83,7 +83,6 @@ impl Vehicle {
.timeout(std::time::Duration::from_secs(5)) .timeout(std::time::Duration::from_secs(5))
.send() .send()
.await?; .await?;
let response: ApiResponseOuter = data.json().await?; let response: ApiResponseOuter = data.json().await?;
let response = response.response; let response = response.response;
@ -95,7 +94,6 @@ impl Vehicle {
Ok(state.charge_state) Ok(state.charge_state)
} }
#[expect(dead_code, reason = "active charge control not yet implemented")]
pub async fn set_charging_amps(&self, charging_amps: i64) -> eyre::Result<()> { pub async fn set_charging_amps(&self, charging_amps: i64) -> eyre::Result<()> {
self.client self.client
.post(format!( .post(format!(

View file

@ -42,36 +42,34 @@ impl Car {
} }
} }
pub const fn vehicle(&self) -> &http::Vehicle { pub fn vehicle(&self) -> &http::Vehicle {
&self.vehicle &self.vehicle
} }
pub const fn state(&self) -> &tokio::sync::RwLock<CarState> { pub fn state(&self) -> &tokio::sync::RwLock<CarState> {
&self.state &self.state
} }
} }
impl CarState { impl CarState {
pub async fn charge_state(&self) -> Option<&ChargeState> { pub fn charge_state(&self) -> Option<&ChargeState> {
if self.is_outdated().await { if self.is_outdated() {
None None
} else { } else {
self.charge_state.as_ref() self.charge_state.as_ref()
} }
} }
pub async fn is_charging(&self) -> bool { pub fn is_charging(&self) -> bool {
self.charge_state() self.charge_state().is_some_and(ChargeState::is_charging)
.await
.is_some_and(ChargeState::is_charging)
} }
pub fn data_age(&self) -> std::time::Duration { pub fn data_age(&self) -> std::time::Duration {
std::time::Instant::now().duration_since(self.data_received) std::time::Instant::now().duration_since(self.data_received)
} }
async fn is_outdated(&self) -> bool { fn is_outdated(&self) -> bool {
self.data_age() > crate::config::access_config().await.car_state_interval self.data_age() > crate::CONFIG.read().car_state_interval
} }
} }
@ -153,7 +151,7 @@ impl ChargeState {
} }
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub enum ChargingState { pub enum ChargingState {
Charging, Charging,
Stopped, Stopped,

View file

@ -1,114 +1,5 @@
use serde::{Deserialize, Serialize}; 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_else(|| 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)] #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(default)] #[serde(default)]
pub struct Config { pub struct Config {
@ -143,6 +34,20 @@ 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)] #[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq)]
#[serde(default)] #[serde(default)]
pub struct PidControls { pub struct PidControls {
@ -162,41 +67,3 @@ 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);
}
}

View file

@ -4,7 +4,6 @@ pub struct VehicleController {
control_state: ChargeRateControllerState, control_state: ChargeRateControllerState,
} }
#[expect(dead_code, reason = "not all states are currently in use")]
pub enum ChargeRateControllerState { pub enum ChargeRateControllerState {
Inactive, Inactive,
Charging { rate_amps: i64 }, Charging { rate_amps: i64 },
@ -15,7 +14,7 @@ pub enum InterfaceRequest {
} }
impl VehicleController { impl VehicleController {
pub const fn new( pub fn new(
car: std::sync::Arc<crate::api::Car>, car: std::sync::Arc<crate::api::Car>,
requests: tokio::sync::mpsc::UnboundedReceiver<InterfaceRequest>, requests: tokio::sync::mpsc::UnboundedReceiver<InterfaceRequest>,
) -> Self { ) -> Self {
@ -26,35 +25,14 @@ 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) { pub async fn run_cycle(&mut self) {
let age = self.car.state().read().await.data_age(); let age = self.car.state().read().await.data_age();
if age >= crate::config::access_config().await.car_state_interval { if age >= crate::CONFIG.read().car_state_interval {
self.car.update().await; self.car.update().await;
} }
match self.control_state { match self.control_state {
ChargeRateControllerState::Inactive => { ChargeRateControllerState::Inactive => {
let car_state = self.car.state().read().await; if let Some(state) = self.car.state().read().await.charge_state() {
let state = car_state.charge_state().await;
if let Some(state) = state {
if state.is_charging() { if state.is_charging() {
self.control_state = ChargeRateControllerState::Charging { self.control_state = ChargeRateControllerState::Charging {
rate_amps: state.charge_amps, rate_amps: state.charge_amps,
@ -62,19 +40,19 @@ impl VehicleController {
} }
} }
} }
ChargeRateControllerState::Charging { rate_amps: _ } => todo!(), ChargeRateControllerState::Charging { rate_amps } => todo!(),
} }
} }
#[expect( pub async fn process_requests(&mut self) -> eyre::Result<()> {
clippy::needless_pass_by_ref_mut, while let Some(req) = self.requests.recv().await {
reason = "this will eventually need to mutate self" match req {
)] InterfaceRequest::FlashLights => {
pub async fn process_requests(&mut self, req: InterfaceRequest) { self.car.vehicle().flash_lights().await?;
if let Err(e) = match req { }
InterfaceRequest::FlashLights => self.car.vehicle().flash_lights().await, }
} {
log::error!("failed to execute request: {e:?}");
} }
Ok(())
} }
} }

View file

@ -1,5 +1,3 @@
#![allow(clippy::significant_drop_tightening)]
use std::path::PathBuf; use std::path::PathBuf;
use clap::Parser; use clap::Parser;
@ -9,13 +7,16 @@ mod config;
mod control; mod control;
mod server; mod server;
static CONFIG: common::config::ConfigSingleton<config::Config> =
common::config::ConfigSingleton::new();
#[derive(clap::Parser, Debug, Clone)] #[derive(clap::Parser, Debug, Clone)]
#[clap(author, version, about, long_about = None)] #[clap(author, version, about, long_about = None)]
struct Args { struct Args {
#[command(subcommand)] #[command(subcommand)]
command: Commands, command: Commands,
#[clap(long, default_value = "/etc/tesla-charge-controller/config.json")] #[clap(long, default_value = "/etc/tesla-charge-controller")]
config: PathBuf, config_dir: PathBuf,
} }
#[derive(clap::Subcommand, Debug, Clone)] #[derive(clap::Subcommand, Debug, Clone)]
@ -28,55 +29,26 @@ enum Commands {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
env_logger::builder() common::init_log();
.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();
let args = Args::parse(); if let Err(e) = run().await {
match args.command { log::error!("{e:?}");
Commands::Watch => { std::process::exit(1);
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(args: Args) -> eyre::Result<()> { async fn run() -> eyre::Result<()> {
let _config_watcher = config::init_config(args.config); let args = Args::parse();
CONFIG.load(args.config_dir.join("tesla.json"))?;
let car = { let car = api::Car::new(&CONFIG.read().car_vin);
let config = config::access_config().await;
api::Car::new(&config.car_vin)
};
let car = std::sync::Arc::new(car); let car = std::sync::Arc::new(car);
let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let mut vehicle_controller = control::VehicleController::new(car.clone(), rx); let mut vehicle_controller = control::VehicleController::new(car.clone(), rx);
let server = server::launch_server(server::ServerState::new(car, tx)); let server = server::launch_server(server::ServerState::new(car, tx));
server.await;
tokio::select! { vehicle_controller.run_cycle().await;
() = server => {}
() = vehicle_controller.run() => {}
}
Ok(()) Ok(())
} }

View file

@ -11,11 +11,7 @@ use rocket::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use crate::{ use crate::{api::Car, control::InterfaceRequest};
api::Car,
config::{access_config, write_to_config},
control::InterfaceRequest,
};
use self::static_handler::UiStatic; use self::static_handler::UiStatic;
@ -27,7 +23,7 @@ pub struct ServerState {
} }
impl ServerState { impl ServerState {
pub const fn new(car: Arc<Car>, api_requests: UnboundedSender<InterfaceRequest>) -> Self { pub fn new(car: Arc<Car>, api_requests: UnboundedSender<InterfaceRequest>) -> Self {
Self { car, api_requests } Self { car, api_requests }
} }
} }
@ -87,7 +83,6 @@ async fn car_state(
.read() .read()
.await .await
.charge_state() .charge_state()
.await
.ok_or(ServerError::NoData)? .ok_or(ServerError::NoData)?
.clone(), .clone(),
)) ))
@ -103,8 +98,8 @@ struct ControlState {
#[get("/control-state")] #[get("/control-state")]
async fn control_state(state: &State<ServerState>) -> Result<Json<ControlState>, ServerError> { async fn control_state(state: &State<ServerState>) -> Result<Json<ControlState>, ServerError> {
let is_charging = state.car.state().read().await.is_charging().await; let is_charging = state.car.state().read().await.is_charging();
let config = access_config().await; let config = crate::CONFIG.read();
let control_enable = config.control_enable; let control_enable = config.control_enable;
let max_rate = config.max_rate; let max_rate = config.max_rate;
let min_rate = config.min_rate; let min_rate = config.min_rate;
@ -124,29 +119,29 @@ fn flash(state: &State<ServerState>, remote_addr: std::net::IpAddr) {
} }
#[post("/disable-control")] #[post("/disable-control")]
async fn disable_control(remote_addr: std::net::IpAddr) { fn disable_control(remote_addr: std::net::IpAddr) {
log::warn!("disabling control: {remote_addr:?}"); log::warn!("disabling control: {remote_addr:?}");
write_to_config().await.control_enable = false; crate::CONFIG.write().control_enable = false;
} }
#[post("/enable-control")] #[post("/enable-control")]
async fn enable_control(remote_addr: std::net::IpAddr) { fn enable_control(remote_addr: std::net::IpAddr) {
log::warn!("enabling control: {remote_addr:?}"); log::warn!("enabling control: {remote_addr:?}");
write_to_config().await.control_enable = true; crate::CONFIG.write().control_enable = true;
} }
#[post("/shutoff/voltage/<voltage>")] #[post("/shutoff/voltage/<voltage>")]
async fn set_shutoff(voltage: f64, remote_addr: std::net::IpAddr) { fn set_shutoff(voltage: f64, remote_addr: std::net::IpAddr) {
log::warn!("setting shutoff voltage: {remote_addr:?}"); log::warn!("setting shutoff voltage: {remote_addr:?}");
let voltage = voltage.clamp(40., 60.); let voltage = voltage.clamp(40., 60.);
write_to_config().await.shutoff_voltage = voltage; crate::CONFIG.write().shutoff_voltage = voltage;
} }
#[post("/shutoff/time/<time>")] #[post("/shutoff/time/<time>")]
async fn set_shutoff_time(time: u64, remote_addr: std::net::IpAddr) { fn set_shutoff_time(time: u64, remote_addr: std::net::IpAddr) {
log::warn!("setting shutoff time: {remote_addr:?}"); log::warn!("setting shutoff time: {remote_addr:?}");
let time = time.clamp(5, 120); let time = time.clamp(5, 120);
write_to_config().await.shutoff_voltage_time_seconds = time; crate::CONFIG.write().shutoff_voltage_time_seconds = time;
} }
#[derive(Clone, Copy, Serialize, Deserialize, Debug)] #[derive(Clone, Copy, Serialize, Deserialize, Debug)]
@ -156,8 +151,8 @@ struct ShutoffStatus {
} }
#[get("/shutoff/status")] #[get("/shutoff/status")]
async fn shutoff_status() -> Json<ShutoffStatus> { fn shutoff_status() -> Json<ShutoffStatus> {
let config = access_config().await; let config = crate::CONFIG.read();
Json(ShutoffStatus { Json(ShutoffStatus {
voltage: config.shutoff_voltage, voltage: config.shutoff_voltage,
time: config.shutoff_voltage_time_seconds, time: config.shutoff_voltage_time_seconds,
@ -165,48 +160,46 @@ async fn shutoff_status() -> Json<ShutoffStatus> {
} }
#[post("/set-max/<limit>")] #[post("/set-max/<limit>")]
async fn set_max(limit: i64, remote_addr: std::net::IpAddr) { fn set_max(limit: i64, remote_addr: std::net::IpAddr) {
log::warn!("setting max: {remote_addr:?}"); log::warn!("setting max: {remote_addr:?}");
let mut config = write_to_config().await; let limit = limit.clamp(crate::CONFIG.read().min_rate, 32);
let limit = limit.clamp(config.min_rate, 32); crate::CONFIG.write().max_rate = limit;
config.max_rate = limit;
} }
#[post("/set-min/<limit>")] #[post("/set-min/<limit>")]
async fn set_min(limit: i64, remote_addr: std::net::IpAddr) { fn set_min(limit: i64, remote_addr: std::net::IpAddr) {
log::warn!("setting min: {remote_addr:?}"); log::warn!("setting min: {remote_addr:?}");
let mut config = write_to_config().await; let limit = limit.clamp(0, crate::CONFIG.read().max_rate);
let limit = limit.clamp(0, config.max_rate); crate::CONFIG.write().min_rate = limit;
config.min_rate = limit;
} }
#[post("/pid-settings/proportional/<gain>")] #[post("/pid-settings/proportional/<gain>")]
async fn set_proportional_gain(gain: f64, remote_addr: std::net::IpAddr) { fn set_proportional_gain(gain: f64, remote_addr: std::net::IpAddr) {
log::warn!("setting proportional gain: {remote_addr:?}"); log::warn!("setting proportional gain: {remote_addr:?}");
write_to_config().await.pid_controls.proportional_gain = gain; crate::CONFIG.write().pid_controls.proportional_gain = gain;
} }
#[post("/pid-settings/derivative/<gain>")] #[post("/pid-settings/derivative/<gain>")]
async fn set_derivative_gain(gain: f64, remote_addr: std::net::IpAddr) { fn set_derivative_gain(gain: f64, remote_addr: std::net::IpAddr) {
log::warn!("setting derivative gain: {remote_addr:?}"); log::warn!("setting derivative gain: {remote_addr:?}");
write_to_config().await.pid_controls.derivative_gain = gain; crate::CONFIG.write().pid_controls.derivative_gain = gain;
} }
#[post("/pid-settings/loop_time_seconds/<time>")] #[post("/pid-settings/loop_time_seconds/<time>")]
async fn set_pid_loop_length(time: u64, remote_addr: std::net::IpAddr) { fn set_pid_loop_length(time: u64, remote_addr: std::net::IpAddr) {
log::warn!("setting pid loop interval: {remote_addr:?}"); log::warn!("setting pid loop interval: {remote_addr:?}");
write_to_config().await.pid_controls.loop_time_seconds = time; crate::CONFIG.write().pid_controls.loop_time_seconds = time;
} }
#[post("/pid-settings/load_divisor/<divisor>")] #[post("/pid-settings/load_divisor/<divisor>")]
async fn set_load_divisor(divisor: f64, remote_addr: std::net::IpAddr) { fn set_load_divisor(divisor: f64, remote_addr: std::net::IpAddr) {
log::warn!("setting load divisor interval: {remote_addr:?}"); log::warn!("setting load divisor interval: {remote_addr:?}");
write_to_config().await.pid_controls.load_divisor = divisor; crate::CONFIG.write().pid_controls.load_divisor = divisor;
} }
#[get("/pid-settings/status")] #[get("/pid-settings/status")]
async fn pid_settings() -> Json<crate::config::PidControls> { fn pid_settings() -> Json<crate::config::PidControls> {
Json(access_config().await.pid_controls) Json(crate::CONFIG.read().pid_controls)
} }
#[get("/metrics")] #[get("/metrics")]

View file

@ -52,12 +52,10 @@ impl Handler for UiStatic {
data: v.contents().to_vec(), data: v.contents().to_vec(),
name: p, name: p,
}) })
.or_else(|| { .or(UI_DIR_FILES.get_file(&plus_index).map(|v| RawHtml {
UI_DIR_FILES.get_file(&plus_index).map(|v| RawHtml { data: v.contents().to_vec(),
data: v.contents().to_vec(), name: plus_index,
name: plus_index, }));
})
});
file.respond_to(req).or_forward((data, Status::NotFound)) file.respond_to(req).or_forward((data, Status::NotFound))
} }
} }

7
watch.sh Executable file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env bash
(
trap 'kill 0' SIGINT
cargo watch -w "src" -x check -s 'touch .trigger' &
RUST_LOG=error,warn,info cargo watch --no-vcs-ignores -w .trigger -x "run -- --config-dir test-config watch"
)