Compare commits

..

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

42 changed files with 832 additions and 3365 deletions

0
.gitmodules vendored Normal file
View file

View file

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

44
Cargo.lock generated
View file

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

View file

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

Binary file not shown.

View file

@ -12,24 +12,20 @@ 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,208 +1,23 @@
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<()>,
}
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)]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
#[serde(default)]
pub struct Config {
pub primary_charge_controller: String,
pub enable_secondary_control: bool,
pub charge_controllers: Vec<ChargeControllerConfig>,
}
impl Config {
fn load(path: impl AsRef<std::path::Path>) -> eyre::Result<Self> {
let storage = ConfigStorage::load(path)?;
Ok(storage.into_latest())
}
fn save(&self) -> eyre::Result<()> {
let as_storage = ConfigStorage::from_latest(self.clone());
as_storage.save()?;
Ok(())
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[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,
#[serde(default)]
pub follow_primary: bool,
pub transport: Transport,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub enum ChargeControllerVariant {
Tristar,
Pl { timeout_milliseconds: u64 },
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Transport {
Serial { port: String, baud_rate: u32 },
Tcp { ip: std::net::IpAddr, port: u16 },
}

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -41,8 +41,6 @@ lazy_static! {
&[PL_LABEL]
)
.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 =
@ -69,16 +67,4 @@ 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,5 +1,3 @@
#![feature(let_chains)]
use clap::Parser;
use futures::StreamExt;
use std::path::PathBuf;
@ -25,8 +23,8 @@ enum Commands {
mod controller;
mod gauges;
mod storage;
mod pl;
mod tristar;
mod web;
@ -59,32 +57,7 @@ async fn run() -> eyre::Result<()> {
match args.command {
Commands::Watch => watch(args).await,
Commands::GenerateConfig => {
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 config = config::Config::default();
let json = serde_json::to_string_pretty(&config)?;
println!("{json}");
Ok(())
@ -93,70 +66,31 @@ async fn run() -> eyre::Result<()> {
}
async fn watch(args: Args) -> eyre::Result<()> {
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 config: config::Config = serde_json::from_reader(std::fs::File::open(args.config)?)?;
let mut controllers = Vec::new();
let mut controllers = futures::stream::FuturesUnordered::new();
let mut map = std::collections::HashMap::new();
let (voltage_tx, voltage_rx) =
tokio::sync::watch::channel(controller::VoltageCommand::None);
for config in &config.charge_controllers {
for config in config.charge_controllers {
let n = config.name.clone();
match controller::Controller::new(config.clone(), voltage_rx.clone()).await {
match controller::Controller::new(config) {
Ok(v) => {
map.insert(n, v.get_data_ptr());
controllers.push(v);
controllers.push(run_loop(v));
}
Err(e) => log::error!("couldn't connect to {}: {e:?}", n),
}
}
if let Some(primary) = controllers
.iter_mut()
.find(|c| c.name() == config.primary_charge_controller)
{
primary.set_tx_to_secondary(voltage_tx.clone());
}
drop(config);
let controller_tasks = futures::stream::FuturesUnordered::new();
for controller in controllers {
controller_tasks.push(run_loop(controller));
}
(
storage::AllControllers::new(map),
voltage_tx,
controller_tasks,
)
};
let server = web::rocket(web::ServerState::new(
&config::access_config().await.primary_charge_controller,
storage,
follow_voltage_tx,
));
let server = web::rocket(web::ServerState::new(map));
let server_task = tokio::task::spawn(server.launch());
log::warn!("...started!");
tokio::select! {
v = controller_tasks.next() => {
v = controllers.next() => {
match v {
Some(Err(e)) => {
log::error!("{e:?}");
std::process::exit(1);
}
_ => {
log::error!("no controller tasks left???");
@ -164,12 +98,7 @@ async fn watch(args: Args) -> eyre::Result<()> {
}
}
v = server_task => {
if let Err(e)=v {
log::error!("server exited: {e:#?}");
std::process::exit(1);
} else {
std::process::exit(0);
}
log::error!("server exited: {v:#?}");
}
}
@ -178,26 +107,10 @@ 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 {
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) {
timeout.tick().await;
if let Err(e) = controller.refresh().await {
log::warn!("error reading controller {}: {e:?}", controller.name());
}
}
}

View file

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

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

View file

@ -0,0 +1,326 @@
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,83 +1,32 @@
use rocket::{get, post, routes, serde::json::Json, State};
use crate::storage::AllControllers;
mod static_handler;
use rocket::{get, routes, serde::json::Json, State};
pub struct ServerState {
primary_name: String,
data: AllControllers,
tx_to_controllers: tokio::sync::watch::Sender<crate::controller::VoltageCommand>,
map: std::collections::HashMap<
String,
std::sync::Arc<tokio::sync::RwLock<crate::controller::CommonData>>,
>,
}
impl ServerState {
pub fn new(
primary_name: &impl ToString,
data: AllControllers,
tx_to_controllers: tokio::sync::watch::Sender<crate::controller::VoltageCommand>,
map: std::collections::HashMap<
String,
std::sync::Arc<tokio::sync::RwLock<crate::controller::CommonData>>,
>,
) -> Self {
let primary_name = primary_name.to_string();
Self {
primary_name,
data,
tx_to_controllers,
}
Self { map }
}
}
pub fn rocket(state: ServerState) -> rocket::Rocket<rocket::Build> {
// serve the html from disk if running in a debug build
// this allows editing the webpage without having to rebuild the executable
// but in release builds, bundle the entire webapp folder into the exe
let fileserver: Vec<rocket::Route> = if cfg!(debug_assertions) {
rocket::fs::FileServer::from(format!(
"{}/webapp",
std::env::var("CARGO_MANIFEST_DIR").unwrap()
))
.into()
} else {
static_handler::UiStatic {}.into()
};
rocket::build()
.attach(Cors)
.manage(state)
.mount("/", fileserver)
.mount(
"/",
routes![
metrics,
interfaces,
all_interfaces,
all_interfaces_full,
all_interfaces_settings,
primary_interface,
interface,
interface_full,
interface_settings,
get_control,
enable_control,
disable_control
],
)
.mount("/", routes![metrics, interfaces, all_interfaces, interface])
}
#[get("/interfaces")]
fn interfaces(state: &State<ServerState>) -> Json<Vec<String>> {
Json(state.data.controller_names().cloned().collect())
}
#[get("/interfaces/primary")]
async fn primary_interface(
state: &State<ServerState>,
) -> Result<Json<crate::controller::CommonData>, ServerError> {
let s = state
.data
.get(&state.primary_name)
.ok_or(ServerError::InvalidPrimaryName)?
.read_state()
.await;
Ok(Json(s.as_ref().ok_or(ServerError::NoData)?.common()))
Json(state.map.keys().cloned().collect())
}
#[get("/interfaces/data")]
@ -86,43 +35,8 @@ async fn all_interfaces(
) -> Json<Vec<(String, crate::controller::CommonData)>> {
let mut data = Vec::new();
for (k, v) in state.data.all_data() {
let v = v.read_state().await;
if let Some(v) = v.as_ref() {
data.push((k.clone(), v.common().clone()));
}
}
Json(data)
}
#[get("/interfaces/data/full")]
async fn all_interfaces_full(
state: &State<ServerState>,
) -> Json<Vec<(String, crate::controller::ControllerState)>> {
let mut data = Vec::new();
for (k, v) in state.data.all_data() {
let v = v.read_state().await;
if let Some(v) = v.as_ref() {
data.push((k.clone(), v.clone()));
}
}
Json(data)
}
#[get("/interfaces/settings")]
async fn all_interfaces_settings(
state: &State<ServerState>,
) -> Json<Vec<(String, crate::controller::ControllerSettings)>> {
let mut data = Vec::new();
for (k, v) in state.data.all_data() {
let v = v.read_settings().await;
if let Some(v) = v.as_ref() {
data.push((k.clone(), v.clone()));
}
for (k, v) in &state.map {
data.push((k.clone(), v.read().await.clone()));
}
Json(data)
@ -134,40 +48,13 @@ async fn interface(
state: &State<ServerState>,
) -> Result<Json<crate::controller::CommonData>, ServerError> {
let data = state
.data
.map
.get(name)
.ok_or(ServerError::NotFound)?
.read_state()
.await;
Ok(Json(data.as_ref().ok_or(ServerError::NoData)?.common()))
}
#[get("/interface/<name>/full")]
async fn interface_full(
name: &str,
state: &State<ServerState>,
) -> Result<Json<crate::controller::ControllerState>, ServerError> {
let data = state
.data
.get(name)
.ok_or(ServerError::NotFound)?
.read_state()
.await;
Ok(Json(data.as_ref().ok_or(ServerError::NoData)?.clone()))
}
#[get("/interface/<name>/settings")]
async fn interface_settings(
name: &str,
state: &State<ServerState>,
) -> Result<Json<crate::controller::ControllerSettings>, ServerError> {
let data = state
.data
.get(name)
.ok_or(ServerError::NotFound)?
.read_settings()
.await;
Ok(Json(data.as_ref().ok_or(ServerError::NoData)?.clone()))
.read()
.await
.clone();
Ok(Json(data))
}
#[get("/metrics")]
@ -178,45 +65,9 @@ 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 {
@ -225,55 +76,11 @@ 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,
Self::InvalidPrimaryName => rocket::http::Status::ServiceUnavailable,
Self::ControllerTx | Self::NoData | Self::Prometheus => {
rocket::http::Status::InternalServerError
}
ServerError::Prometheus => rocket::http::Status::InternalServerError,
})
}
}
pub struct Cors;
#[rocket::async_trait]
impl rocket::fairing::Fairing for Cors {
fn info(&self) -> rocket::fairing::Info {
rocket::fairing::Info {
name: "Add CORS headers to responses",
kind: rocket::fairing::Kind::Response,
}
}
async fn on_response<'r>(
&self,
_request: &'r rocket::Request<'_>,
response: &mut rocket::Response<'r>,
) {
response.set_header(rocket::http::Header::new(
"Access-Control-Allow-Origin",
"*",
));
response.set_header(rocket::http::Header::new(
"Access-Control-Allow-Methods",
"POST, GET, PATCH, OPTIONS",
));
response.set_header(rocket::http::Header::new(
"Access-Control-Allow-Headers",
"*",
));
response.set_header(rocket::http::Header::new(
"Access-Control-Allow-Credentials",
"true",
));
}
}

View file

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

View file

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

View file

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

View file

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

2
control-loop.txt Normal file
View file

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

View file

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

12
string_fields.txt Normal file
View file

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

View file

@ -12,30 +12,29 @@ systemd-units = { enable = false }
depends = "charge-controller-supervisor"
[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"
serialport = "4.6.1"
thiserror = "2.0.9"
tokio = { version = "1.42.0", features = ["full"] }
thiserror = "2.0.9"
rocket = { version = "0.5.1", features = ["json"] }
include_dir = "0.7.4"
chrono = "0.4.39"
prometheus = "0.13.4"
env_logger = "0.11.6"
log = "0.4.22"
serialport = "4.6.1"
tokio-modbus = "0.16.1"
tokio-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,7 +83,6 @@ impl Vehicle {
.timeout(std::time::Duration::from_secs(5))
.send()
.await?;
let response: ApiResponseOuter = data.json().await?;
let response = response.response;
@ -95,7 +94,6 @@ 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,36 +42,34 @@ impl Car {
}
}
pub const fn vehicle(&self) -> &http::Vehicle {
pub fn vehicle(&self) -> &http::Vehicle {
&self.vehicle
}
pub const fn state(&self) -> &tokio::sync::RwLock<CarState> {
pub fn state(&self) -> &tokio::sync::RwLock<CarState> {
&self.state
}
}
impl CarState {
pub async fn charge_state(&self) -> Option<&ChargeState> {
if self.is_outdated().await {
pub fn charge_state(&self) -> Option<&ChargeState> {
if self.is_outdated() {
None
} else {
self.charge_state.as_ref()
}
}
pub async fn is_charging(&self) -> bool {
self.charge_state()
.await
.is_some_and(ChargeState::is_charging)
pub fn is_charging(&self) -> bool {
self.charge_state().is_some_and(ChargeState::is_charging)
}
pub fn data_age(&self) -> std::time::Duration {
std::time::Instant::now().duration_since(self.data_received)
}
async fn is_outdated(&self) -> bool {
self.data_age() > crate::config::access_config().await.car_state_interval
fn is_outdated(&self) -> bool {
self.data_age() > crate::CONFIG.read().car_state_interval
}
}
@ -153,7 +151,7 @@ impl ChargeState {
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub enum ChargingState {
Charging,
Stopped,

View file

@ -1,114 +1,5 @@
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 {
@ -143,6 +34,20 @@ impl Default for Config {
}
}
impl common::config::ConfigTrait for Config {
fn load_and_save_defaults(path: impl AsRef<std::path::Path>) -> Self {
todo!()
}
fn save(&self) -> eyre::Result<()> {
todo!()
}
fn is_valid(&self) -> bool {
todo!()
}
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq)]
#[serde(default)]
pub struct PidControls {
@ -162,41 +67,3 @@ impl Default for PidControls {
}
}
}
impl Config {
fn load(path: impl AsRef<std::path::Path>) -> Self {
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
}
fn save_to(&self, path: impl AsRef<std::path::Path>) -> eyre::Result<()> {
Ok(serde_json::ser::to_writer_pretty(
std::io::BufWriter::new(std::fs::File::create(path)?),
self,
)?)
}
fn save(&self) -> eyre::Result<()> {
self.save_to(CONFIG_PATH.get().unwrap())
}
fn load_and_save_defaults(path: impl AsRef<std::path::Path>) -> Self {
let mut config = Self::load(&path);
config.validate();
let result = config.save_to(path);
if let Err(e) = result {
log::error!("Failed to save config: {e:#?}",);
}
config
}
fn validate(&mut self) {
self.shutoff_voltage = self.shutoff_voltage.clamp(40.0, 60.0);
self.max_rate = self.max_rate.clamp(0, 30);
self.min_rate = self.min_rate.clamp(0, self.max_rate);
// self.duty_cycle_too_high = self.duty_cycle_too_high.clamp(0.0, 1.0);
// self.duty_cycle_too_low = self.duty_cycle_too_low.clamp(0.0, 1.0);
self.pid_controls.proportional_gain = self.pid_controls.proportional_gain.clamp(0.0, 50.0);
self.pid_controls.derivative_gain = self.pid_controls.derivative_gain.clamp(0.0, 50.0);
self.pid_controls.load_divisor = self.pid_controls.load_divisor.clamp(1.0, 50.0);
}
}

View file

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

View file

@ -1,5 +1,3 @@
#![allow(clippy::significant_drop_tightening)]
use std::path::PathBuf;
use clap::Parser;
@ -9,13 +7,16 @@ 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.json")]
config: PathBuf,
#[clap(long, default_value = "/etc/tesla-charge-controller")]
config_dir: PathBuf,
}
#[derive(clap::Subcommand, Debug, Clone)]
@ -28,55 +29,26 @@ enum Commands {
#[tokio::main]
async fn main() {
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();
common::init_log();
let args = Args::parse();
match args.command {
Commands::Watch => {
if let Err(e) = run(args).await {
if let Err(e) = run().await {
log::error!("{e:?}");
std::process::exit(1);
}
}
Commands::GenerateConfig => {
let config = config::Config::default();
match serde_json::to_string_pretty(&config) {
Ok(config_json) => {
println!("{config_json}");
}
Err(e) => {
eprintln!("error serializing config: {e:?}");
}
}
}
}
}
async fn run(args: Args) -> eyre::Result<()> {
let _config_watcher = config::init_config(args.config);
async fn run() -> eyre::Result<()> {
let args = Args::parse();
CONFIG.load(args.config_dir.join("tesla.json"))?;
let car = {
let config = config::access_config().await;
api::Car::new(&config.car_vin)
};
let car = api::Car::new(&CONFIG.read().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;
tokio::select! {
() = server => {}
() = vehicle_controller.run() => {}
}
vehicle_controller.run_cycle().await;
Ok(())
}

View file

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

View file

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

7
watch.sh Executable file
View file

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