Compare commits

..

68 commits

Author SHA1 Message Date
7ffdfd1fc3 ccs: log message when server spawned 2025-01-18 12:00:57 +11:00
9d12bce452 tcc: fix clippy lints 2025-01-18 11:58:22 +11:00
b549ceebab update toolchain 2025-01-18 11:52:32 +11:00
b28026820c rename .gitea -> .forgejo 2025-01-18 11:18:11 +11:00
ab3cd28f83 v1.9.9-pre-30: revert always send voltages
All checks were successful
Build and release .deb / Release (push) Successful in 54s
2025-01-16 11:30:22 +11:00
cec26d8cfb v1.9.9-pre-29: always send but don't follow target
All checks were successful
Build and release .deb / Release (push) Successful in 56s
for debugging
2025-01-16 11:21:36 +11:00
186d8fc71a ccs: use tokio::sync::watch for tx to controllers 2025-01-16 11:15:31 +11:00
05edfe6b84 release v1.9.9-pre-28
All checks were successful
Build and release .deb / Release (push) Successful in 56s
2025-01-15 12:11:34 +11:00
c4c99eff1d ccs: set refresh missedtickbehavior 2025-01-15 12:09:57 +11:00
76c33534be ccs: modbus wrapper: consolidate max errors 2025-01-15 12:06:23 +11:00
9bd1243129 ccs: tristar modbus wrapper: actually retry 2025-01-15 12:05:41 +11:00
667f51d375 ccs: reinstate ControllerInner large_enum_variant 2025-01-15 12:04:36 +11:00
9a87b4f820 release v1.9.9-pre-27
All checks were successful
Build and release .deb / Release (push) Successful in 59s
2025-01-14 19:47:25 +11:00
37c7412df1 ccs: tristar: wrap modbus 2025-01-14 19:46:37 +11:00
03d6278ba9 release v1.9.9-pre-26
All checks were successful
Build and release .deb / Release (push) Successful in 57s
2025-01-13 14:48:25 +11:00
7508459414 ccs: pl: get settings 2025-01-13 14:48:04 +11:00
b28f39667d ccs: pull settings timer out of tristar 2025-01-13 13:28:52 +11:00
d0018b7953 release v1.9.9-pre-25
All checks were successful
Build and release .deb / Release (push) Successful in 1m0s
2025-01-13 13:02:21 +11:00
b5121ce7f4 ccs: tristar: parse dip switches 2025-01-13 12:15:05 +11:00
8b2ef6513f ccs: pl: properly parse errors 2025-01-13 12:15:03 +11:00
12bc89ede6 ccs: tristar: parse alarms, faults and flags 2025-01-13 12:15:03 +11:00
ae9091c95e v1.9.9-pre-24: ccs: tristar: parse serial
All checks were successful
Build and release .deb / Release (push) Successful in 55s
2025-01-10 15:38:12 +11:00
21f3a176ab release v1.9.9-pre-23
All checks were successful
Build and release .deb / Release (push) Successful in 57s
2025-01-10 15:22:35 +11:00
ade01e4a91 ccs: web: get settings for a single interface 2025-01-10 15:22:22 +11:00
edfc0373db clippy expect 2025-01-10 15:09:16 +11:00
f4f5e4bd3e release v1.9.9-pre-22
All checks were successful
Build and release .deb / Release (push) Successful in 1m0s
2025-01-10 14:57:08 +11:00
8c8aba919d ccs: tristar: allow network data read to fail 2025-01-10 14:56:55 +11:00
b8c2a8c114 ccs: tristar: reconnect on fail 2025-01-10 14:55:09 +11:00
4f2534b5a0 release v1.9.9-pre-21
All checks were successful
Build and release .deb / Release (push) Successful in 56s
2025-01-10 14:10:04 +11:00
4905a89b33 ccs: tristar: periodically fetch settings 2025-01-10 14:09:15 +11:00
a908490bb0 ccs: tristar: expose settings 2025-01-10 13:57:23 +11:00
ed82c3444e ccs: abstraction for data storage 2025-01-10 12:03:38 +11:00
88144693a8 ccs: get full controller state via web 2025-01-10 11:38:10 +11:00
0b53d347dc add clippy lints 2025-01-10 09:59:30 +11:00
9e19f669c5 ccs: store full data from tristar 2025-01-10 09:52:57 +11:00
2fbae49297 ccs: pl and tristar modules underneath controller 2025-01-10 09:28:06 +11:00
e914f2fd10 v1.9.9-pre-20: fix modbus device id
All checks were successful
Build and release .deb / Release (push) Successful in 56s
2025-01-09 15:53:17 +11:00
f41e763743 release v1.9.9-pre-19
All checks were successful
Build and release .deb / Release (push) Successful in 54s
2025-01-09 13:02:05 +11:00
23fe7acbb0 ccs: modbus tcp 2025-01-09 13:01:41 +11:00
813f26500c release v1.9.9-pre-18
All checks were successful
Build and release .deb / Release (push) Successful in 54s
2025-01-09 12:54:38 +11:00
8bc7c8e17c ccs: tristar: proper timeout on serial
tokio_serial::SerialStream ignored timeouts??? and now this will
actually prevent charge controllers being added if they don't respond
via modbus
2025-01-09 12:22:56 +11:00
47e711f111 release v1.9.9-pre-17
All checks were successful
Build and release .deb / Release (push) Successful in 54s
2025-01-09 11:45:38 +11:00
93aeff17cb config: save updated config on load 2025-01-09 11:45:14 +11:00
7e664848dd release v1.9.9-pre-15
All checks were successful
Build and release .deb / Release (push) Successful in 52s
2025-01-09 10:00:02 +11:00
cc2f493401 ccs: pl: retry reads 2025-01-09 09:51:30 +11:00
7ceaf73037 ccs: migratable config 2025-01-09 09:20:09 +11:00
8b26a8d6bb ccs: tristar: explicit serial timeout
All checks were successful
Build and release .deb / Release (push) Successful in 53s
2025-01-03 14:16:34 +11:00
9c87f12a7d pl: charge/load ah
commented out for now; because reading from the pl takes ages
2024-12-31 15:35:19 +11:00
97b5cfc370 v1.9.9-pre-13: optionally log pl accumulators
All checks were successful
Build and release .deb / Release (push) Successful in 57s
2024-12-31 15:05:18 +11:00
9272aa20ad v1.9.9-pre-12: fix pl load current
All checks were successful
Build and release .deb / Release (push) Successful in 55s
2024-12-30 22:07:28 +11:00
4445f874ea release v1.9.9-pre-11
All checks were successful
Build and release .deb / Release (push) Successful in 52s
2024-12-30 20:45:37 +11:00
5a0265f6df ccs: send disable voltage when follow disabled 2024-12-30 20:45:27 +11:00
ea3a88eb4a release v1.9.9-pre-10
All checks were successful
Build and release .deb / Release (push) Successful in 1m55s
2024-12-30 20:30:59 +11:00
ad8a7b253f ccs: disable from web 2024-12-30 20:30:45 +11:00
ec8bbdbd41 nightly toolchain 2024-12-30 20:28:29 +11:00
5359d679cb ccs: disable/enable control from endpoint 2024-12-30 20:00:45 +11:00
fbe4214918 format toml 2024-12-30 19:52:11 +11:00
a1a80276c9 ccs: clippy 2024-12-30 15:16:04 +11:00
51aec2ce00 release v1.9.9-pre-9
All checks were successful
Build and release .deb / Release (push) Successful in 52s
2024-12-30 14:10:14 +11:00
46e23d1da1 ccs: actually follow primary! please be careful 2024-12-30 14:09:43 +11:00
296e1d0ccf release v1.9.9-pre-8
All checks were successful
Build and release .deb / Release (push) Successful in 53s
2024-12-30 13:47:19 +11:00
11503615de ccs: follow primary
so far only printing so that we dont cook it
2024-12-30 13:47:01 +11:00
59994c75c1 tesla charge rate controller: builds 2024-12-29 16:20:59 +11:00
786701794e release v1.9.9-pre-7
All checks were successful
Build and release .deb / Release (push) Successful in 53s
2024-12-29 12:28:31 +11:00
8a48645b53 tristar: add gauges and fix negative temperatures 2024-12-29 12:28:15 +11:00
1552a10518 v1.9.9-pre-6: clean exit
All checks were successful
Build and release .deb / Release (push) Successful in 51s
2024-12-28 22:57:13 +11:00
0205b9be37 cleanup 2024-12-28 22:53:01 +11:00
34a735deec v1.9.9-pre-5: primary interface endpoint
All checks were successful
Build and release .deb / Release (push) Successful in 50s
2024-12-28 22:45:31 +11:00
42 changed files with 3381 additions and 848 deletions

0
.gitmodules vendored
View file

32
.taplo.toml Normal file
View file

@ -0,0 +1,32 @@
[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

44
Cargo.lock generated
View file

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

View file

@ -4,15 +4,32 @@ default-members = ["charge-controller-supervisor"]
resolver = "2"
[workspace.package]
version = "1.9.9-pre-4"
version = "1.9.9-pre-30"
[workspace.lints.clippy]
pedantic = "warn"
module-name-repetitions = { level = "allow", priority = 1 }
struct-excessive-bools = { 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 }
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 }
similar-names = { level = "allow", priority = 1 }
struct-excessive-bools = { level = "allow", priority = 1 }
too-many-lines = { level = "allow", priority = 1 }

Binary file not shown.

View file

@ -12,20 +12,24 @@ systemd-units = { enable = false }
depends = ""
[dependencies]
bitflags = { version = "2.7.0", features = ["serde"] }
chrono = "0.4.39"
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_json = "1.0.134"
thiserror = "2.0.11"
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"
futures = "0.3.31"
tokio-modbus = "0.16.1"
tokio-serial = "5.4.4"
lazy_static = "1.5.0"
eyre = "0.6.12"
[lints]
workspace = true

View file

@ -1,23 +1,208 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
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<()>,
}
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)]
pub struct Config {
pub primary_charge_controller: String,
pub enable_secondary_control: bool,
pub charge_controllers: Vec<ChargeControllerConfig>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct ChargeControllerConfig {
pub name: String,
pub serial_port: String,
pub baud_rate: u32,
pub watch_interval_seconds: u64,
pub variant: ChargeControllerVariant,
impl Config {
fn load(path: impl AsRef<std::path::Path>) -> eyre::Result<Self> {
let storage = ConfigStorage::load(path)?;
Ok(storage.into_latest())
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
fn save(&self) -> eyre::Result<()> {
let as_storage = ConfigStorage::from_latest(self.clone());
as_storage.save()?;
Ok(())
}
}
#[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

@ -0,0 +1,72 @@
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,8 +1,17 @@
use crate::storage::ControllerData;
mod pl;
mod tristar;
pub struct Controller {
name: String,
interval: std::time::Duration,
inner: ControllerInner,
data: std::sync::Arc<tokio::sync::RwLock<CommonData>>,
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)]
@ -12,70 +21,201 @@ pub struct CommonData {
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 fn new(config: crate::config::ChargeControllerConfig) -> eyre::Result<Self> {
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(
crate::tristar::Tristar::new(&config.serial_port, &config.name, config.baud_rate)?,
tristar::Tristar::new(&config.name, &config.transport).await?,
),
crate::config::ChargeControllerVariant::Pl {
timeout_milliseconds,
} => ControllerInner::Pl(crate::pl::Pli::new(
&config.serial_port,
&config.name,
config.baud_rate,
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 = CommonData::default();
let data = std::sync::Arc::new(tokio::sync::RwLock::new(data));
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<tokio::sync::RwLock<CommonData>> {
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?;
*self.data.write().await = data;
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 fn timeout_interval(&self) -> std::time::Duration {
pub const fn timeout_interval(&self) -> std::time::Duration {
self.interval
}
pub fn name(&self) -> &str {
&self.name
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(crate::pl::Pli),
Tristar(crate::tristar::Tristar),
Pl(pl::Pli),
Tristar(tristar::Tristar),
}
impl ControllerInner {
pub async fn refresh(&mut self) -> eyre::Result<CommonData> {
pub async fn refresh(&mut self) -> eyre::Result<ControllerState> {
match self {
ControllerInner::Pl(pli) => {
let pl_data = pli.refresh().await?;
Ok(pl_data)
Ok(ControllerState::Pl(pl_data))
}
ControllerInner::Tristar(tristar) => {
let tristar_data = tristar.refresh().await?;
Ok(tristar_data)
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

@ -0,0 +1,671 @@
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

@ -0,0 +1,192 @@
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,6 +41,8 @@ lazy_static! {
&[PL_LABEL]
)
.unwrap();
pub static ref HEATSINK_TEMP: GaugeVec =
register_gauge_vec!("heatsink_temp", "Heatsink temperature", &[TRISTAR_LABEL]).unwrap();
pub static ref TRISTAR_INPUT_VOLTAGE: GaugeVec =
register_gauge_vec!("tristar_input_voltage", "Input voltage", &[TRISTAR_LABEL]).unwrap();
pub static ref TRISTAR_CHARGE_CURRENT: GaugeVec =
@ -67,4 +69,16 @@ lazy_static! {
&[TRISTAR_LABEL]
)
.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,3 +1,5 @@
#![feature(let_chains)]
use clap::Parser;
use futures::StreamExt;
use std::path::PathBuf;
@ -23,8 +25,8 @@ enum Commands {
mod controller;
mod gauges;
mod pl;
mod tristar;
mod storage;
mod web;
@ -57,7 +59,32 @@ async fn run() -> eyre::Result<()> {
match args.command {
Commands::Watch => watch(args).await,
Commands::GenerateConfig => {
let config = config::Config::default();
let mut 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)?;
println!("{json}");
Ok(())
@ -66,31 +93,70 @@ async fn run() -> eyre::Result<()> {
}
async fn watch(args: Args) -> eyre::Result<()> {
let config: config::Config = serde_json::from_reader(std::fs::File::open(args.config)?)?;
let _w = config::init_config(&args.config);
let (storage, follow_voltage_tx, mut controller_tasks) = {
let config = config::access_config().await;
if config
.charge_controllers
.iter()
.any(|cc| cc.follow_primary && cc.name == config.primary_charge_controller)
{
return Err(eyre::eyre!(
"primary charge controller is set to follow primary!"
));
}
let mut controllers = futures::stream::FuturesUnordered::new();
let mut controllers = Vec::new();
let mut map = std::collections::HashMap::new();
for config in config.charge_controllers {
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) {
match controller::Controller::new(config.clone(), voltage_rx.clone()).await {
Ok(v) => {
map.insert(n, v.get_data_ptr());
controllers.push(run_loop(v));
controllers.push(v);
}
Err(e) => log::error!("couldn't connect to {}: {e:?}", n),
}
}
let server = web::rocket(web::ServerState::new(map));
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());
log::warn!("...started!");
tokio::select! {
v = controllers.next() => {
v = controller_tasks.next() => {
match v {
Some(Err(e)) => {
log::error!("{e:?}");
std::process::exit(1);
}
_ => {
log::error!("no controller tasks left???");
@ -98,7 +164,12 @@ async fn watch(args: Args) -> eyre::Result<()> {
}
}
v = server_task => {
log::error!("server exited: {v:#?}");
if let Err(e)=v {
log::error!("server exited: {e:#?}");
std::process::exit(1);
} else {
std::process::exit(0);
}
}
}
@ -107,10 +178,26 @@ async fn watch(args: Args) -> eyre::Result<()> {
async fn run_loop(mut controller: controller::Controller) -> eyre::Result<()> {
let mut timeout = tokio::time::interval(controller.timeout_interval());
timeout.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
timeout.tick().await;
let rx = controller.get_rx();
tokio::select! {
_ = timeout.tick() => {
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

@ -1,283 +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,
}
#[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: &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<crate::controller::CommonData> {
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(crate::controller::CommonData {
battery_voltage: new_state.battery_voltage,
target_voltage: new_state.target_voltage,
battery_temp: new_state.battery_temp,
})
}
#[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(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(())
}
async fn read_state(&mut self) -> eyre::Result<PlState> {
Ok(PlState {
battery_voltage: f64::from(self.read_ram(PlRamAddress::Batv).await?) * (4. / 10.),
target_voltage: f64::from(self.read_ram(PlRamAddress::Vreg).await?) * (4. / 10.),
duty_cycle: f64::from(self.read_ram(PlRamAddress::Dutycyc).await?) / 255.,
internal_charge_current: f64::from(self.read_ram(PlRamAddress::Cint).await?)
* (4. / 10.),
internal_load_current: f64::from(self.read_ram(PlRamAddress::Lint).await?) * (4. / 10.),
battery_temp: f64::from(self.read_ram(PlRamAddress::Battemp).await?),
regulator_state: self.read_ram(PlRamAddress::Rstate).await?.into(),
})
}
async fn send_command(&mut self, req: [u8; 4]) -> eyre::Result<()> {
self.flush()?;
self.port.write_all(&req).await?;
Ok(())
}
fn flush(&mut 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<T>(&mut self, address: T) -> eyre::Result<u8>
where
T: Into<u8>,
{
self.send_command(command(20, address.into(), 0)).await?;
let buf = self.receive::<1>().await?;
if buf[0] == 200 {
Ok(self.receive::<1>().await?[0])
} else {
Err(eyre::eyre!("read error: result is {}", buf[0]))
}
}
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 buf = self.receive::<1>().await?;
if buf[0] == 200 {
Ok(self.receive::<1>().await?[0])
} else {
Err(eyre::eyre!("read error: result is {}", buf[0]))
}
}
}
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

@ -0,0 +1,61 @@
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

@ -1,326 +0,0 @@
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 {
friendly_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: &str, friendly_name: &str, 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(friendly_name);
Ok(Self {
friendly_name: friendly_name.to_owned(),
modbus,
charge_state_gauges,
consecutive_errors: 0,
})
}
pub async fn refresh(&mut self) -> eyre::Result<crate::controller::CommonData> {
let new_state = self.get_data().await?;
self.consecutive_errors = 0;
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);
INPUT_CURRENT
.with_label_values(&[&self.friendly_name])
.set(new_state.input_current);
BATTERY_TEMP
.with_label_values(&[&self.friendly_name])
.set(new_state.battery_temp.into());
TRISTAR_INPUT_VOLTAGE
.with_label_values(&[&self.friendly_name])
.set(new_state.tristar_input_voltage);
TRISTAR_CHARGE_CURRENT
.with_label_values(&[&self.friendly_name])
.set(new_state.tristar_charge_current);
TRISTAR_POWER_OUT
.with_label_values(&[&self.friendly_name])
.set(new_state.tristar_power_out);
TRISTAR_POWER_IN
.with_label_values(&[&self.friendly_name])
.set(new_state.tristar_power_in);
TRISTAR_MAX_ARRAY_POWER
.with_label_values(&[&self.friendly_name])
.set(new_state.tristar_max_array_power);
TRISTAR_MAX_ARRAY_VOLTAGE
.with_label_values(&[&self.friendly_name])
.set(new_state.tristar_max_array_voltage);
TRISTAR_OPEN_CIRCUIT_VOLTAGE
.with_label_values(&[&self.friendly_name])
.set(new_state.tristar_open_circuit_voltage);
self.charge_state_gauges.set(new_state.charge_state);
Ok(crate::controller::CommonData {
battery_voltage: new_state.battery_voltage,
target_voltage: new_state.target_voltage,
battery_temp: f64::from(new_state.battery_temp),
})
}
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,32 +1,83 @@
use rocket::{get, routes, serde::json::Json, State};
use rocket::{get, post, routes, serde::json::Json, State};
use crate::storage::AllControllers;
mod static_handler;
pub struct ServerState {
map: std::collections::HashMap<
String,
std::sync::Arc<tokio::sync::RwLock<crate::controller::CommonData>>,
>,
primary_name: String,
data: AllControllers,
tx_to_controllers: tokio::sync::watch::Sender<crate::controller::VoltageCommand>,
}
impl ServerState {
pub fn new(
map: std::collections::HashMap<
String,
std::sync::Arc<tokio::sync::RwLock<crate::controller::CommonData>>,
>,
primary_name: &impl ToString,
data: AllControllers,
tx_to_controllers: tokio::sync::watch::Sender<crate::controller::VoltageCommand>,
) -> Self {
Self { map }
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("/", routes![metrics, interfaces, all_interfaces, interface])
.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.map.keys().cloned().collect())
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")]
@ -35,8 +86,43 @@ async fn all_interfaces(
) -> Json<Vec<(String, crate::controller::CommonData)>> {
let mut data = Vec::new();
for (k, v) in &state.map {
data.push((k.clone(), v.read().await.clone()));
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)
@ -48,13 +134,40 @@ async fn interface(
state: &State<ServerState>,
) -> Result<Json<crate::controller::CommonData>, ServerError> {
let data = state
.map
.data
.get(name)
.ok_or(ServerError::NotFound)?
.read()
.await
.clone();
Ok(Json(data))
.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")]
@ -65,9 +178,45 @@ 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 {
Prometheus,
NotFound,
InvalidPrimaryName,
NoData,
ControllerTx,
}
impl From<prometheus::Error> for ServerError {
@ -76,11 +225,55 @@ 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 {
fn respond_to(self, _: &'a rocket::Request<'_>) -> rocket::response::Result<'a> {
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

@ -0,0 +1,83 @@
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

@ -0,0 +1,26 @@
<!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

@ -0,0 +1,80 @@
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

@ -0,0 +1,101 @@
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);
}
}

View file

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

3
rust-toolchain.toml Normal file
View file

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

View file

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

View file

@ -12,29 +12,30 @@ systemd-units = { enable = false }
depends = "charge-controller-supervisor"
[dependencies]
chrono = "0.4.39"
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"
serde = { version = "1.0.216", features = ["derive"] }
serde_json = "1.0.134"
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"
thiserror = "2.0.9"
tokio = { version = "1.42.0", features = ["full"] }
tokio-modbus = "0.16.1"
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]
workspace = true

View file

@ -83,6 +83,7 @@ impl Vehicle {
.timeout(std::time::Duration::from_secs(5))
.send()
.await?;
let response: ApiResponseOuter = data.json().await?;
let response = response.response;
@ -94,6 +95,7 @@ impl Vehicle {
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<()> {
self.client
.post(format!(

View file

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

View file

@ -1,5 +1,114 @@
use serde::{Deserialize, Serialize};
static CONFIG_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
pub(super) static CONFIG: std::sync::OnceLock<tokio::sync::RwLock<Config>> =
std::sync::OnceLock::new();
pub async fn access_config<'a>() -> tokio::sync::RwLockReadGuard<'a, Config> {
CONFIG.get().unwrap().read().await
}
pub(super) struct ConfigWatcher {
_debouncer: notify_debouncer_mini::Debouncer<notify_debouncer_mini::notify::RecommendedWatcher>,
_handle: tokio::task::JoinHandle<()>,
}
impl ConfigWatcher {
pub fn new(path: impl AsRef<std::path::Path>) -> Option<Self> {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut debouncer =
notify_debouncer_mini::new_debouncer(std::time::Duration::from_secs(1), move |v| {
tx.send(v).unwrap();
})
.ok()?;
debouncer
.watcher()
.watch(
path.as_ref(),
notify_debouncer_mini::notify::RecursiveMode::NonRecursive,
)
.ok()?;
let config_path = std::path::PathBuf::from(path.as_ref());
let handle = tokio::task::spawn(async move {
loop {
match rx.recv().await {
Some(Ok(_event)) => {
let mut config = Config::load(&config_path);
config.validate();
if let Err(e) = overwrite_config(config).await {
log::error!("{e:?}");
}
}
Some(Err(e)) => log::error!("Error {e:?} from watcher"),
None => {}
}
}
});
Some(Self {
_debouncer: debouncer,
_handle: handle,
})
}
}
async fn overwrite_config(config: Config) -> eyre::Result<()> {
*CONFIG
.get()
.ok_or_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)]
#[serde(default)]
pub struct Config {
@ -34,20 +143,6 @@ impl Default for Config {
}
}
impl common::config::ConfigTrait for Config {
fn load_and_save_defaults(path: impl AsRef<std::path::Path>) -> Self {
todo!()
}
fn save(&self) -> eyre::Result<()> {
todo!()
}
fn is_valid(&self) -> bool {
todo!()
}
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq)]
#[serde(default)]
pub struct PidControls {
@ -67,3 +162,41 @@ impl Default for PidControls {
}
}
}
impl Config {
fn load(path: impl AsRef<std::path::Path>) -> Self {
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
}
fn save_to(&self, path: impl AsRef<std::path::Path>) -> eyre::Result<()> {
Ok(serde_json::ser::to_writer_pretty(
std::io::BufWriter::new(std::fs::File::create(path)?),
self,
)?)
}
fn save(&self) -> eyre::Result<()> {
self.save_to(CONFIG_PATH.get().unwrap())
}
fn load_and_save_defaults(path: impl AsRef<std::path::Path>) -> Self {
let mut config = Self::load(&path);
config.validate();
let result = config.save_to(path);
if let Err(e) = result {
log::error!("Failed to save config: {e:#?}",);
}
config
}
fn validate(&mut self) {
self.shutoff_voltage = self.shutoff_voltage.clamp(40.0, 60.0);
self.max_rate = self.max_rate.clamp(0, 30);
self.min_rate = self.min_rate.clamp(0, self.max_rate);
// self.duty_cycle_too_high = self.duty_cycle_too_high.clamp(0.0, 1.0);
// self.duty_cycle_too_low = self.duty_cycle_too_low.clamp(0.0, 1.0);
self.pid_controls.proportional_gain = self.pid_controls.proportional_gain.clamp(0.0, 50.0);
self.pid_controls.derivative_gain = self.pid_controls.derivative_gain.clamp(0.0, 50.0);
self.pid_controls.load_divisor = self.pid_controls.load_divisor.clamp(1.0, 50.0);
}
}

View file

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

View file

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

View file

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

View file

@ -52,10 +52,12 @@ impl Handler for UiStatic {
data: v.contents().to_vec(),
name: p,
})
.or(UI_DIR_FILES.get_file(&plus_index).map(|v| RawHtml {
.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))
}
}

View file

@ -1,7 +0,0 @@
#!/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"
)