Compare commits

..

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

23 changed files with 620 additions and 1991 deletions

42
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,9 +239,8 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "charge-controller-supervisor"
version = "1.9.9-pre-30"
version = "1.9.9-pre-15"
dependencies = [
"bitflags 2.7.0",
"chrono",
"clap",
"env_logger",
@ -255,7 +254,6 @@ dependencies = [
"rocket",
"serde",
"serde_json",
"thiserror 2.0.11",
"tokio",
"tokio-modbus",
"tokio-serial",
@ -383,7 +381,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 +1169,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 +1360,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 +1591,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.11",
"thiserror 2.0.9",
"tokio",
"tracing",
]
@ -1612,7 +1610,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.11",
"thiserror 2.0.9",
"tinyvec",
"tracing",
"web-time",
@ -1677,7 +1675,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 +1888,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 +1911,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 +2053,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 +2203,7 @@ dependencies = [
[[package]]
name = "tesla-charge-controller"
version = "1.9.9-pre-30"
version = "1.9.9-pre-15"
dependencies = [
"chrono",
"clap",
@ -2224,7 +2222,7 @@ dependencies = [
"serde",
"serde_json",
"serialport",
"thiserror 2.0.11",
"thiserror 2.0.9",
"tokio",
"tokio-modbus",
"tokio-serial",
@ -2241,11 +2239,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 +2259,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 +2376,7 @@ dependencies = [
"futures-util",
"log",
"smallvec",
"thiserror 2.0.11",
"thiserror 2.0.9",
"tokio",
"tokio-util",
]

View file

@ -4,32 +4,16 @@ default-members = ["charge-controller-supervisor"]
resolver = "2"
[workspace.package]
version = "1.9.9-pre-30"
version = "1.9.9-pre-15"
[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 }

View file

@ -12,7 +12,6 @@ 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"
@ -26,7 +25,6 @@ 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"] }
tokio-modbus = "0.16.1"
tokio-serial = "5.4.4"

View file

@ -14,14 +14,11 @@ pub(super) struct ConfigWatcher {
}
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);
let _ = CONFIG_PATH.get_or_init(|| path.as_ref().to_path_buf());
CONFIG.set(tokio::sync::RwLock::new(config)).unwrap();
config_watcher
@ -73,7 +70,7 @@ impl ConfigWatcher {
async fn overwrite_config(config: Config) -> eyre::Result<()> {
let mut h = CONFIG
.get()
.ok_or_else(|| eyre::eyre!("could not get config"))?
.ok_or(eyre::eyre!("could not get config"))?
.write()
.await;
if h.charge_controllers != config.charge_controllers
@ -82,7 +79,6 @@ async fn overwrite_config(config: Config) -> eyre::Result<()> {
log::warn!("charge controller configuration changed on disk; please restart");
}
*h = config;
drop(h);
Ok(())
}
@ -122,13 +118,9 @@ pub async fn write_to_config<'a>() -> ConfigHandle<'a> {
#[serde(tag = "version")]
pub enum ConfigStorage {
#[serde(rename = "1")]
V1(outdated::ConfigV1),
#[serde(rename = "2")]
V2(Config),
V1(Config),
}
mod outdated;
impl Default for ConfigStorage {
fn default() -> Self {
Self::from_latest(Default::default())
@ -136,15 +128,8 @@ impl Default for ConfigStorage {
}
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,
}
const fn from_latest(config: Config) -> Self {
Self::V1(config)
}
fn load(path: impl AsRef<std::path::Path>) -> eyre::Result<Self> {
@ -161,9 +146,15 @@ impl ConfigStorage {
fn save(&self) -> eyre::Result<()> {
self.save_to(CONFIG_PATH.get().unwrap())
}
fn into_latest(self) -> Config {
match self {
ConfigStorage::V1(config) => config,
}
}
}
#[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,
@ -184,25 +175,19 @@ impl Config {
}
}
#[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,10 @@
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>>,
voltage_rx: Option<tokio::sync::mpsc::UnboundedReceiver<VoltageCommand>>,
voltage_tx: Option<MultiTx>,
}
#[derive(Default, serde::Serialize, Clone)]
@ -21,79 +14,57 @@ 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(
pub fn new(
config: crate::config::ChargeControllerConfig,
voltage_rx: tokio::sync::watch::Receiver<VoltageCommand>,
) -> eyre::Result<Self> {
) -> eyre::Result<(
Self,
Option<tokio::sync::mpsc::UnboundedSender<VoltageCommand>>,
)> {
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();
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,
})
let data = std::sync::Arc::new(tokio::sync::RwLock::new(data));
let (voltage_tx, voltage_rx) = if config.follow_primary {
let (a, b) = tokio::sync::mpsc::unbounded_channel();
(Some(a), Some(b))
} else {
(None, None)
};
Ok((
Self {
name: config.name,
interval: std::time::Duration::from_secs(config.watch_interval_seconds),
inner,
data,
voltage_rx,
voltage_tx: None,
},
voltage_tx,
))
}
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()
}
@ -105,44 +76,32 @@ impl Controller {
.await
.enable_secondary_control
{
let target = data.common().target_voltage;
log::debug!(
"tristar {}: primary: sending target voltage {}",
self.name,
target
data.target_voltage
);
tx.send(VoltageCommand::Set(target))?;
tx.send_to_all(VoltageCommand::Set(data.target_voltage));
}
}
*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()
&self.name
}
pub fn set_tx_to_secondary(&mut self, tx: tokio::sync::watch::Sender<VoltageCommand>) {
pub fn set_tx_to_secondary(&mut self, tx: MultiTx) {
assert!(
!self.follow_voltage,
self.voltage_rx.is_none(),
"trying to set {} as primary when it is also a secondary!",
self.name
);
@ -150,64 +109,47 @@ impl Controller {
self.voltage_tx = Some(tx);
}
pub fn get_rx(&mut self) -> &mut tokio::sync::watch::Receiver<VoltageCommand> {
&mut self.voltage_rx
pub fn get_rx(&mut self) -> Option<&mut tokio::sync::mpsc::UnboundedReceiver<VoltageCommand>> {
self.voltage_rx.as_mut()
}
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(())
self.inner.set_target_voltage(target_voltage).await
}
}
}
}
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)
})
#[derive(Clone)]
pub struct MultiTx(pub Vec<tokio::sync::mpsc::UnboundedSender<VoltageCommand>>);
impl MultiTx {
pub fn send_to_all(&self, command: VoltageCommand) {
for sender in &self.0 {
if let Err(e) = sender.send(command) {
log::error!("failed to send command {command:?}: {e:?}");
}
}
}
}
#[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))
}
}
}
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))
Ok(tristar_data)
}
}
}

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

@ -25,8 +25,8 @@ enum Commands {
mod controller;
mod gauges;
mod storage;
mod pl;
mod tristar;
mod web;
@ -59,32 +59,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::ConfigStorage::default();
let json = serde_json::to_string_pretty(&config)?;
println!("{json}");
Ok(())
@ -94,7 +69,7 @@ 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 (map, follow_voltage_tx, mut controller_tasks) = {
let config = config::access_config().await;
if config
.charge_controllers
@ -108,49 +83,45 @@ async fn watch(args: Args) -> eyre::Result<()> {
let mut controllers = Vec::new();
let mut map = std::collections::HashMap::new();
let (voltage_tx, voltage_rx) =
tokio::sync::watch::channel(controller::VoltageCommand::None);
let mut follow_voltage_tx = Vec::new();
for config in &config.charge_controllers {
let n = config.name.clone();
match controller::Controller::new(config.clone(), voltage_rx.clone()).await {
Ok(v) => {
match controller::Controller::new(config.clone()) {
Ok((v, voltage_tx)) => {
map.insert(n, v.get_data_ptr());
controllers.push(v);
if let Some(voltage_tx) = voltage_tx {
follow_voltage_tx.push(voltage_tx);
}
}
Err(e) => log::error!("couldn't connect to {}: {e:?}", n),
}
}
let follow_voltage_tx = controller::MultiTx(follow_voltage_tx);
if let Some(primary) = controllers
.iter_mut()
.find(|c| c.name() == config.primary_charge_controller)
{
primary.set_tx_to_secondary(voltage_tx.clone());
primary.set_tx_to_secondary(follow_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,
)
(map, follow_voltage_tx, controller_tasks)
};
let server = web::rocket(web::ServerState::new(
&config::access_config().await.primary_charge_controller,
storage,
map,
follow_voltage_tx,
));
let server_task = tokio::task::spawn(server.launch());
log::warn!("...started!");
tokio::select! {
v = controller_tasks.next() => {
@ -178,20 +149,22 @@ 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());
if let Some(rx) = controller.get_rx() {
tokio::select! {
_ = timeout.tick() => {
do_refresh(&mut controller).await;
}
Some(command) = rx.recv() => {
if let Err(e) = controller.process_command(command).await {
log::error!("controller {} failed to process command: {e}", controller.name());
}
}
}
} else {
timeout.tick().await;
do_refresh(&mut controller).await;
}
}
}

View file

@ -103,48 +103,6 @@ fn set_regulator_gauges(state: RegulatorState, label: &str) {
}
}
#[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 {
@ -171,7 +129,7 @@ impl Pli {
})
}
pub async fn refresh(&mut self) -> eyre::Result<PlState> {
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])
@ -194,7 +152,11 @@ impl Pli {
set_regulator_gauges(new_state.regulator_state, &self.friendly_name);
Ok(new_state)
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")]
@ -228,75 +190,6 @@ impl Pli {
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?;
@ -371,7 +264,7 @@ impl Pli {
Ok(())
}
fn flush(&self) -> eyre::Result<()> {
fn flush(&mut self) -> eyre::Result<()> {
self.port.clear(tokio_serial::ClearBuffer::All)?;
Ok(())
}
@ -397,11 +290,9 @@ impl Pli {
}
}
Err(last_err.unwrap_or_else(|| {
eyre::eyre!(
"no error was stored in read_ram_with_retries: this should be unreachable??"
)
}))
Err(last_err.unwrap_or(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>
@ -409,10 +300,11 @@ impl Pli {
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()),
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]))
}
}
@ -430,61 +322,11 @@ impl Pli {
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)),
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]))
}
}
}
@ -560,112 +402,6 @@ impl From<PlRamAddress> for u8 {
}
}
#[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] {
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,428 @@
use prometheus::core::{AtomicI64, GenericGauge};
use tokio_modbus::client::{Reader, Writer};
use crate::gauges::{
BATTERY_TEMP, BATTERY_VOLTAGE, CHARGE_STATE, HEATSINK_TEMP, 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,
TRISTAR_TOTAL_AH_CHARGE_DAILY, TRISTAR_TOTAL_WH_CHARGE_DAILY,
};
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.)
}
#[expect(clippy::cast_sign_loss)]
fn inverse_voltage(&self, voltage: f64) -> u16 {
(voltage / (self.v_scale * f64::powf(2., -15.))) as u16
}
}
pub struct Tristar {
friendly_name: String,
modbus: tokio_modbus::client::Context,
charge_state_gauges: ChargeStateGauges,
consecutive_errors: usize,
scaling: Option<Scaling>,
}
#[derive(Default, Debug, Clone, Copy)]
pub struct TristarState {
scaling: Option<Scaling>,
battery_voltage: f64,
target_voltage: f64,
input_current: f64,
battery_temp: i16,
heatsink_temp: i16,
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,
daily_charge_amp_hours: f64,
daily_charge_watt_hours: f64,
}
fn signed(val: u16) -> i16 {
match i16::try_from(val) {
Ok(v) => v,
Err(_) => -i16::try_from(val % 128).unwrap(),
}
}
impl TristarState {
fn from_ram(ram: &[u16]) -> Self {
let scaling = Scaling::from(ram);
Self {
scaling: Some(scaling),
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: signed(ram[TristarRamAddress::Tbatt]),
heatsink_temp: signed(ram[TristarRamAddress::THs]),
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]),
daily_charge_amp_hours: f64::from(ram[TristarRamAddress::AhcDaily]) * 0.1,
daily_charge_watt_hours: f64::from(ram[TristarRamAddress::WhcDaily]),
}
}
}
#[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).timeout(std::time::Duration::from_secs(3)),
)?;
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,
scaling: None,
})
}
pub async fn refresh(&mut self) -> eyre::Result<crate::controller::CommonData> {
let new_state = self.get_data().await?;
self.scaling = new_state.scaling;
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());
HEATSINK_TEMP
.with_label_values(&[&self.friendly_name])
.set(new_state.heatsink_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);
TRISTAR_TOTAL_AH_CHARGE_DAILY
.with_label_values(&[&self.friendly_name])
.set(new_state.daily_charge_amp_hours);
TRISTAR_TOTAL_WH_CHARGE_DAILY
.with_label_values(&[&self.friendly_name])
.set(new_state.daily_charge_watt_hours);
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),
})
}
pub async fn set_target_voltage(&mut self, target_voltage: f64) -> eyre::Result<()> {
let scaled_voltage: u16 = self.scale_voltage(target_voltage)?;
self.modbus
.write_single_register(TristarRamAddress::VbRefSlave as u16, scaled_voltage)
.await??;
log::debug!(
"tristar {} being set to voltage {target_voltage} (scaled: {scaled_voltage:#X?})",
self.friendly_name
);
Ok(())
}
fn scale_voltage(&self, voltage: f64) -> eyre::Result<u16> {
let Some(scaling) = &self.scaling else {
return Err(eyre::eyre!("no scaling data present"));
};
Ok(scaling.inverse_voltage(voltage))
}
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))
}
}
pub enum TristarRamAddress {
AdcVbFMed = 0x0018,
AdcVbtermF = 0x0019,
AdcVbsF = 0x001A,
AdcVaF = 0x001B,
AdcIbFShadow = 0x001C,
AdcIaFShadow = 0x001D,
AdcP12F = 0x001E,
AdcP3F = 0x001F,
AdcPmeterF = 0x0020,
AdcP18F = 0x0021,
AdcVRef = 0x0022,
THs = 0x0023,
TRts = 0x0024,
Tbatt = 0x0025,
AdcVbF1m = 0x0026,
AdcIbF1m = 0x0027,
VbMin = 0x0028,
VbMax = 0x0029,
HourmeterHi = 0x002A,
HourmeterLo = 0x002B,
Fault = 0x002C,
AlarmHi = 0x002E,
AlarmLo = 0x002F,
Dip = 0x0030,
Led = 0x0031,
ChargeState = 0x0032,
VbRef = 0x0033,
AhcRHi = 0x0034,
AhcRLo = 0x0035,
AhcTHi = 0x0036,
AhcTLo = 0x0037,
KwhcR = 0x0038,
KwhcT = 0x0039,
PowerOutShadow = 0x003A,
PowerInShadow = 0x003B,
SweepPinMax = 0x003C,
SweepVmp = 0x003D,
SweepVoc = 0x003E,
VbMinDaily = 0x0040,
VbMaxDaily = 0x0041,
VaMaxDaily = 0x0042,
AhcDaily = 0x0043,
WhcDaily = 0x0044,
FlagsDaily = 0x0045,
PoutMaxDaily = 0x0046,
TbMinDaily = 0x0047,
TbMaxDaily = 0x0048,
FaultDaily = 0x0049,
AlarmDailyHi = 0x004B,
AlarmDailyLo = 0x004C,
TimeAbDaily = 0x004D,
TimeEqDaily = 0x004E,
TimeFlDaily = 0x004F,
IbRefSlave = 0x0058,
VbRefSlave = 0x0059,
VaRefFixed = 0x005A,
VaRefFixedPc = 0x005B,
}
impl std::ops::Index<TristarRamAddress> for [u16] {
type Output = u16;
fn index(&self, index: TristarRamAddress) -> &Self::Output {
&self[index as usize]
}
}

View file

@ -1,25 +1,29 @@
use rocket::{get, post, routes, serde::json::Json, State};
use crate::storage::AllControllers;
mod static_handler;
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>>,
>,
tx_to_controllers: crate::controller::MultiTx,
}
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>>,
>,
tx_to_controllers: crate::controller::MultiTx,
) -> Self {
let primary_name = primary_name.to_string();
Self {
primary_name,
data,
map,
tx_to_controllers,
}
}
@ -48,12 +52,8 @@ pub fn rocket(state: ServerState) -> rocket::Rocket<rocket::Build> {
metrics,
interfaces,
all_interfaces,
all_interfaces_full,
all_interfaces_settings,
primary_interface,
interface,
interface_full,
interface_settings,
get_control,
enable_control,
disable_control
@ -63,7 +63,7 @@ pub fn rocket(state: ServerState) -> rocket::Rocket<rocket::Build> {
#[get("/interfaces")]
fn interfaces(state: &State<ServerState>) -> Json<Vec<String>> {
Json(state.data.controller_names().cloned().collect())
Json(state.map.keys().cloned().collect())
}
#[get("/interfaces/primary")]
@ -71,13 +71,14 @@ async fn primary_interface(
state: &State<ServerState>,
) -> Result<Json<crate::controller::CommonData>, ServerError> {
let s = state
.data
.map
.get(&state.primary_name)
.ok_or(ServerError::InvalidPrimaryName)?
.read_state()
.await;
.read()
.await
.clone();
Ok(Json(s.as_ref().ok_or(ServerError::NoData)?.common()))
Ok(Json(s))
}
#[get("/interfaces/data")]
@ -86,43 +87,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 +100,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")]
@ -200,23 +139,19 @@ async fn enable_control() {
}
#[post("/control/disable")]
async fn disable_control(state: &State<ServerState>) -> Result<(), ServerError> {
async fn disable_control(state: &State<ServerState>) {
log::warn!("disabling control");
crate::config::write_to_config()
.await
.enable_secondary_control = false;
state
.tx_to_controllers
.send(crate::controller::VoltageCommand::None)?;
Ok(())
.send_to_all(crate::controller::VoltageCommand::Set(-1.0));
}
enum ServerError {
Prometheus,
NotFound,
InvalidPrimaryName,
NoData,
ControllerTx,
}
impl From<prometheus::Error> for ServerError {
@ -225,20 +160,12 @@ 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::Prometheus => rocket::http::Status::InternalServerError,
Self::NotFound => rocket::http::Status::NotFound,
Self::InvalidPrimaryName => rocket::http::Status::ServiceUnavailable,
Self::ControllerTx | Self::NoData | Self::Prometheus => {
rocket::http::Status::InternalServerError
}
})
}
}

View file

@ -51,12 +51,10 @@ impl Handler for UiStatic {
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,
})
});
.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))
}
}

View file

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

View file

@ -95,7 +95,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,11 +42,11 @@ 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
}
}
@ -153,7 +153,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

@ -60,7 +60,7 @@ impl ConfigWatcher {
async fn overwrite_config(config: Config) -> eyre::Result<()> {
*CONFIG
.get()
.ok_or_else(|| eyre::eyre!("could not get config"))?
.ok_or(eyre::eyre!("could not get config"))?
.write()
.await = config;
Ok(())

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 {
@ -51,10 +50,7 @@ impl VehicleController {
}
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().await {
if state.is_charging() {
self.control_state = ChargeRateControllerState::Charging {
rate_amps: state.charge_amps,
@ -62,14 +58,10 @@ 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,

View file

@ -1,5 +1,3 @@
#![allow(clippy::significant_drop_tightening)]
use std::path::PathBuf;
use clap::Parser;

View file

@ -27,7 +27,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 }
}
}

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 {
data: v.contents().to_vec(),
name: plus_index,
})
});
.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))
}
}