Compare commits
21 commits
v1.9.9-pre
...
main
Author | SHA1 | Date | |
---|---|---|---|
7ffdfd1fc3 | |||
9d12bce452 | |||
b549ceebab | |||
b28026820c | |||
ab3cd28f83 | |||
cec26d8cfb | |||
186d8fc71a | |||
05edfe6b84 | |||
c4c99eff1d | |||
76c33534be | |||
9bd1243129 | |||
667f51d375 | |||
9a87b4f820 | |||
37c7412df1 | |||
03d6278ba9 | |||
7508459414 | |||
b28f39667d | |||
d0018b7953 | |||
b5121ce7f4 | |||
8b2ef6513f | |||
12bc89ede6 |
18 changed files with 806 additions and 225 deletions
42
Cargo.lock
generated
42
Cargo.lock
generated
|
@ -185,9 +185,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
|||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.6.0"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
|
||||
checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
@ -239,8 +239,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||
|
||||
[[package]]
|
||||
name = "charge-controller-supervisor"
|
||||
version = "1.9.9-pre-24"
|
||||
version = "1.9.9-pre-30"
|
||||
dependencies = [
|
||||
"bitflags 2.7.0",
|
||||
"chrono",
|
||||
"clap",
|
||||
"env_logger",
|
||||
|
@ -254,6 +255,7 @@ dependencies = [
|
|||
"rocket",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.11",
|
||||
"tokio",
|
||||
"tokio-modbus",
|
||||
"tokio-serial",
|
||||
|
@ -381,7 +383,7 @@ version = "0.4.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7"
|
||||
dependencies = [
|
||||
"bitflags 2.6.0",
|
||||
"bitflags 2.7.0",
|
||||
"proc-macro2",
|
||||
"proc-macro2-diagnostics",
|
||||
"quote",
|
||||
|
@ -1169,7 +1171,7 @@ version = "0.1.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
|
||||
dependencies = [
|
||||
"bitflags 2.6.0",
|
||||
"bitflags 2.7.0",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
]
|
||||
|
@ -1360,7 +1362,7 @@ version = "7.0.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
|
||||
dependencies = [
|
||||
"bitflags 2.6.0",
|
||||
"bitflags 2.7.0",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
|
@ -1591,7 +1593,7 @@ dependencies = [
|
|||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.9",
|
||||
"thiserror 2.0.11",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
@ -1610,7 +1612,7 @@ dependencies = [
|
|||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.9",
|
||||
"thiserror 2.0.11",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
|
@ -1675,7 +1677,7 @@ version = "0.5.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834"
|
||||
dependencies = [
|
||||
"bitflags 2.6.0",
|
||||
"bitflags 2.7.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1888,7 +1890,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"bitflags 2.6.0",
|
||||
"bitflags 2.7.0",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
@ -1911,7 +1913,7 @@ version = "0.38.42"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85"
|
||||
dependencies = [
|
||||
"bitflags 2.6.0",
|
||||
"bitflags 2.7.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
|
@ -2053,7 +2055,7 @@ version = "4.6.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "779e2977f0cc2ff39708fef48f96f3768ac8ddd8c6caaaab82e83bd240ef99b2"
|
||||
dependencies = [
|
||||
"bitflags 2.6.0",
|
||||
"bitflags 2.7.0",
|
||||
"cfg-if",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
|
@ -2203,7 +2205,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tesla-charge-controller"
|
||||
version = "1.9.9-pre-24"
|
||||
version = "1.9.9-pre-30"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
@ -2222,7 +2224,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"serialport",
|
||||
"thiserror 2.0.9",
|
||||
"thiserror 2.0.11",
|
||||
"tokio",
|
||||
"tokio-modbus",
|
||||
"tokio-serial",
|
||||
|
@ -2239,11 +2241,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.9"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc"
|
||||
checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.9",
|
||||
"thiserror-impl 2.0.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2259,9 +2261,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.9"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4"
|
||||
checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
@ -2376,7 +2378,7 @@ dependencies = [
|
|||
"futures-util",
|
||||
"log",
|
||||
"smallvec",
|
||||
"thiserror 2.0.9",
|
||||
"thiserror 2.0.11",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
|
|
@ -4,7 +4,7 @@ default-members = ["charge-controller-supervisor"]
|
|||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.9.9-pre-24"
|
||||
version = "1.9.9-pre-30"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
pedantic = "warn"
|
||||
|
|
|
@ -12,6 +12,7 @@ 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"
|
||||
|
@ -25,6 +26,7 @@ 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"
|
||||
|
|
|
@ -8,8 +8,10 @@ pub struct Controller {
|
|||
interval: std::time::Duration,
|
||||
inner: ControllerInner,
|
||||
data: std::sync::Arc<ControllerData>,
|
||||
voltage_rx: Option<tokio::sync::mpsc::UnboundedReceiver<VoltageCommand>>,
|
||||
voltage_tx: Option<MultiTx>,
|
||||
follow_voltage: bool,
|
||||
voltage_rx: tokio::sync::watch::Receiver<VoltageCommand>,
|
||||
voltage_tx: Option<tokio::sync::watch::Sender<VoltageCommand>>,
|
||||
settings_last_read: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Serialize, Clone)]
|
||||
|
@ -46,21 +48,21 @@ impl ControllerState {
|
|||
#[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,
|
||||
) -> eyre::Result<(
|
||||
Self,
|
||||
Option<tokio::sync::mpsc::UnboundedSender<VoltageCommand>>,
|
||||
)> {
|
||||
voltage_rx: tokio::sync::watch::Receiver<VoltageCommand>,
|
||||
) -> eyre::Result<Self> {
|
||||
let inner = match config.variant {
|
||||
crate::config::ChargeControllerVariant::Tristar => ControllerInner::Tristar(
|
||||
tristar::Tristar::new(&config.name, &config.transport).await?,
|
||||
|
@ -79,24 +81,16 @@ impl Controller {
|
|||
|
||||
let data = std::sync::Arc::new(ControllerData::new());
|
||||
|
||||
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,
|
||||
))
|
||||
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> {
|
||||
|
@ -104,7 +98,7 @@ impl Controller {
|
|||
}
|
||||
|
||||
pub async fn refresh(&mut self) -> eyre::Result<()> {
|
||||
let (data, settings) = self.inner.refresh().await?;
|
||||
let data = self.inner.refresh().await?;
|
||||
|
||||
if let Some(tx) = self.voltage_tx.as_mut() {
|
||||
if crate::config::access_config()
|
||||
|
@ -118,13 +112,21 @@ impl Controller {
|
|||
target
|
||||
);
|
||||
|
||||
tx.send_to_all(VoltageCommand::Set(target));
|
||||
tx.send(VoltageCommand::Set(target))?;
|
||||
}
|
||||
}
|
||||
|
||||
*self.data.write_state().await = Some(data);
|
||||
if let Some(settings) = settings {
|
||||
*self.data.write_settings().await = Some(settings);
|
||||
if self.needs_new_settings() {
|
||||
match self.inner.get_settings().await {
|
||||
Ok(s) => {
|
||||
*self.data.write_settings().await = Some(s);
|
||||
self.settings_last_read = Some(std::time::Instant::now());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("couldn't read config from {}: {e:?}", self.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
@ -135,12 +137,12 @@ impl Controller {
|
|||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
self.name.as_str()
|
||||
}
|
||||
|
||||
pub fn set_tx_to_secondary(&mut self, tx: MultiTx) {
|
||||
pub fn set_tx_to_secondary(&mut self, tx: tokio::sync::watch::Sender<VoltageCommand>) {
|
||||
assert!(
|
||||
self.voltage_rx.is_none(),
|
||||
!self.follow_voltage,
|
||||
"trying to set {} as primary when it is also a secondary!",
|
||||
self.name
|
||||
);
|
||||
|
@ -148,29 +150,30 @@ impl Controller {
|
|||
self.voltage_tx = Some(tx);
|
||||
}
|
||||
|
||||
pub fn get_rx(&mut self) -> Option<&mut tokio::sync::mpsc::UnboundedReceiver<VoltageCommand>> {
|
||||
self.voltage_rx.as_mut()
|
||||
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) => {
|
||||
self.inner.set_target_voltage(target_voltage).await
|
||||
if self.follow_voltage {
|
||||
self.inner.set_target_voltage(target_voltage).await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
VoltageCommand::None => {
|
||||
// todo: disable voltage control
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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:?}");
|
||||
}
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -181,31 +184,30 @@ pub enum ControllerInner {
|
|||
}
|
||||
|
||||
impl ControllerInner {
|
||||
pub async fn refresh(&mut self) -> eyre::Result<(ControllerState, Option<ControllerSettings>)> {
|
||||
pub async fn refresh(&mut self) -> eyre::Result<ControllerState> {
|
||||
match self {
|
||||
ControllerInner::Pl(pli) => {
|
||||
let pl_data = pli.refresh().await?;
|
||||
|
||||
Ok((ControllerState::Pl(pl_data), None))
|
||||
Ok(ControllerState::Pl(pl_data))
|
||||
}
|
||||
ControllerInner::Tristar(tristar) => {
|
||||
let settings = if tristar.needs_new_settings() {
|
||||
match tristar.read_settings().await {
|
||||
Ok(v) => Some(ControllerSettings::Tristar(v)),
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"couldn't read config from tristar {}: {e:?}",
|
||||
tristar.name()
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let tristar_data = tristar.refresh().await?;
|
||||
|
||||
Ok((ControllerState::Tristar(tristar_data), settings))
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -103,6 +103,48 @@ 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 {
|
||||
|
@ -186,6 +228,75 @@ 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?;
|
||||
|
@ -298,11 +409,10 @@ impl Pli {
|
|||
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]))
|
||||
let response = self.get_response().await?;
|
||||
match response {
|
||||
Ok(()) => Ok(self.receive::<1>().await?[0]),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -320,11 +430,61 @@ impl Pli {
|
|||
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]))
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -400,6 +560,112 @@ 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] {
|
||||
[operation, address, data, !operation]
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use modbus_wrapper::ModbusTimeout;
|
||||
use prometheus::core::{AtomicI64, GenericGauge};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio_modbus::client::{Reader, Writer};
|
||||
|
||||
use crate::gauges::{
|
||||
BATTERY_TEMP, BATTERY_VOLTAGE, CHARGE_STATE, HEATSINK_TEMP, INPUT_CURRENT, TARGET_VOLTAGE,
|
||||
|
@ -54,58 +54,10 @@ pub struct Tristar {
|
|||
charge_state_gauges: ChargeStateGauges,
|
||||
consecutive_errors: usize,
|
||||
scaling: Scaling,
|
||||
settings_last_read: Option<std::time::Instant>,
|
||||
transport_settings: crate::config::Transport,
|
||||
}
|
||||
|
||||
struct ModbusTimeout {
|
||||
context: tokio_modbus::client::Context,
|
||||
reconnect_required: bool,
|
||||
}
|
||||
mod modbus_wrapper;
|
||||
|
||||
const MODBUS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
impl ModbusTimeout {
|
||||
const fn new(context: tokio_modbus::client::Context) -> Self {
|
||||
Self {
|
||||
context,
|
||||
reconnect_required: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write_single_register(
|
||||
&mut self,
|
||||
addr: tokio_modbus::Address,
|
||||
word: u16,
|
||||
) -> eyre::Result<()> {
|
||||
let r = tokio::time::timeout(
|
||||
MODBUS_TIMEOUT,
|
||||
self.context.write_single_register(addr, word),
|
||||
)
|
||||
.await?;
|
||||
if let Err(tokio_modbus::Error::Transport(_)) = &r {
|
||||
self.reconnect_required = true;
|
||||
}
|
||||
r??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn read_holding_registers(
|
||||
&mut self,
|
||||
addr: tokio_modbus::Address,
|
||||
cnt: tokio_modbus::Quantity,
|
||||
) -> eyre::Result<Vec<u16>> {
|
||||
let r = tokio::time::timeout(
|
||||
MODBUS_TIMEOUT,
|
||||
self.context.read_holding_registers(addr, cnt),
|
||||
)
|
||||
.await?;
|
||||
if let Err(tokio_modbus::Error::Transport(_)) = &r {
|
||||
self.reconnect_required = true;
|
||||
}
|
||||
Ok(r??)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TristarSettings {
|
||||
network: Option<NetworkSettings>,
|
||||
|
@ -421,6 +373,12 @@ pub struct TristarState {
|
|||
pub tristar_open_circuit_voltage: f64,
|
||||
pub daily_charge_amp_hours: f64,
|
||||
pub daily_charge_watt_hours: f64,
|
||||
pub alarms: Alarms,
|
||||
pub faults: Faults,
|
||||
pub alarms_daily: Alarms,
|
||||
pub faults_daily: Faults,
|
||||
pub flags_daily: DailyFlags,
|
||||
pub dip_switches: DipSwitch,
|
||||
}
|
||||
|
||||
fn signed(val: u16) -> i16 {
|
||||
|
@ -450,10 +408,183 @@ impl TristarState {
|
|||
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]),
|
||||
alarms: Alarms::from_words(
|
||||
ram[TristarRamAddress::AlarmLo],
|
||||
ram[TristarRamAddress::AlarmHi],
|
||||
),
|
||||
faults: Faults::from_bits_retain(ram[TristarRamAddress::Fault]),
|
||||
alarms_daily: Alarms::from_words(
|
||||
ram[TristarRamAddress::AlarmDailyLo],
|
||||
ram[TristarRamAddress::AlarmDailyHi],
|
||||
),
|
||||
faults_daily: Faults::from_bits_retain(ram[TristarRamAddress::FaultDaily]),
|
||||
flags_daily: DailyFlags::from_bits_retain(ram[TristarRamAddress::FlagsDaily]),
|
||||
dip_switches: DipSwitch::parse_byte(
|
||||
(ram[TristarRamAddress::Dip] & 0xFF).try_into().unwrap_or(0),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bitflags::bitflags! {
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct Faults: u16 {
|
||||
const Overcurrent = 1;
|
||||
const FETsShorted = 1 << 1;
|
||||
const SoftwareBug = 1 << 2;
|
||||
const BatteryHVD = 1 << 3;
|
||||
const ArrayHVD = 1 << 4;
|
||||
const SettingsSwitchChanged = 1 << 5;
|
||||
const CustomSettingsEdit = 1 << 6;
|
||||
const RTSShorted = 1 << 7;
|
||||
const RTSDisconnected = 1 << 8;
|
||||
const EEPROMRetryLimit = 1 << 9;
|
||||
const Reserved = 1 << 10;
|
||||
const SlaveControlTimeout = 1 << 11;
|
||||
const Fault13 = 1 << 12;
|
||||
const Fault14 = 1 << 13;
|
||||
const Fault15 = 1 << 14;
|
||||
const Fault16 = 1 << 15;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct Alarms: u32 {
|
||||
const RTSOpen = 1;
|
||||
const RTSShorted = 1 << 1;
|
||||
const RTSDisconnected = 1 << 2;
|
||||
const HeatsinkTempSensorOpen = 1 << 3;
|
||||
const HeatsinkTempSensorShorted = 1 << 4;
|
||||
const HighTemperatureCurrentLimit = 1 << 5;
|
||||
const CurrentLimit = 1 << 6;
|
||||
const CurrentOffset = 1 << 7;
|
||||
const BatterySenseOutOfRange = 1 << 8;
|
||||
const BatterySenseDisconnected = 1 << 9;
|
||||
const Uncalibrated = 1 << 10;
|
||||
const RTSMiswire = 1 << 11;
|
||||
const HighVoltageDisconnect = 1 << 12;
|
||||
const Undefined = 1 << 13;
|
||||
const SystemMiswire = 1 << 14;
|
||||
const MOSFETOpen = 1 << 15;
|
||||
const P12VoltageOff = 1 << 16;
|
||||
const HighInputVoltageCurrentLimit = 1 << 17;
|
||||
const ADCInputMax = 1 << 18;
|
||||
const ControllerWasReset = 1 << 19;
|
||||
const Alarm21 = 1 << 20;
|
||||
const Alarm22 = 1 << 21;
|
||||
const Alarm23 = 1 << 22;
|
||||
const Alarm24 = 1 << 23;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct DailyFlags: u16 {
|
||||
const ResetDetected = 1;
|
||||
const EqualizeTriggered = 1 << 1;
|
||||
const EnteredFloat = 1 << 2;
|
||||
const AlarmOccurred = 1 << 3;
|
||||
const FaultOccurred = 1 << 4;
|
||||
}
|
||||
}
|
||||
|
||||
impl Alarms {
|
||||
const fn from_words(low: u16, high: u16) -> Self {
|
||||
let val = (low as u32) | ((high as u32) << 16);
|
||||
Self::from_bits_retain(val)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct DipSwitch {
|
||||
sw1_reserved: bool,
|
||||
system_voltage: SystemVoltage,
|
||||
battery_config: BatteryConfig,
|
||||
battery_equalization: BatteryEqualization,
|
||||
ethernet_security: bool,
|
||||
}
|
||||
|
||||
trait Bit {
|
||||
fn bit(&self, num: u8) -> bool;
|
||||
}
|
||||
|
||||
impl Bit for u8 {
|
||||
fn bit(&self, num: u8) -> bool {
|
||||
((*self >> num) & 0b1) != 0
|
||||
}
|
||||
}
|
||||
|
||||
impl DipSwitch {
|
||||
fn parse_byte(value: u8) -> Self {
|
||||
let sw1_reserved = value.bit(0);
|
||||
let system_voltage = SystemVoltage::from_dip(value.bit(1), value.bit(2));
|
||||
let battery_config = BatteryConfig::from_dip(value.bit(3), value.bit(4), value.bit(5));
|
||||
let battery_equalization = if value.bit(6) {
|
||||
BatteryEqualization::Automatic
|
||||
} else {
|
||||
BatteryEqualization::Manual
|
||||
};
|
||||
let ethernet_security = value.bit(7);
|
||||
|
||||
Self {
|
||||
sw1_reserved,
|
||||
system_voltage,
|
||||
battery_config,
|
||||
battery_equalization,
|
||||
ethernet_security,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
enum SystemVoltage {
|
||||
Auto,
|
||||
V12,
|
||||
V24,
|
||||
V48,
|
||||
}
|
||||
|
||||
impl SystemVoltage {
|
||||
const fn from_dip(switch2: bool, switch3: bool) -> Self {
|
||||
match (switch2, switch3) {
|
||||
(false, false) => Self::Auto,
|
||||
(false, true) => Self::V12,
|
||||
(true, false) => Self::V24,
|
||||
(true, true) => Self::V48,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
enum BatteryConfig {
|
||||
Gel,
|
||||
Sealed2,
|
||||
Sealed3,
|
||||
AgmFlooded,
|
||||
Flooded5,
|
||||
Flooded6,
|
||||
Flooded7,
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl BatteryConfig {
|
||||
const fn from_dip(switch4: bool, switch5: bool, switch6: bool) -> Self {
|
||||
match (switch4, switch5, switch6) {
|
||||
(false, false, false) => Self::Gel,
|
||||
(false, false, true) => Self::Sealed2,
|
||||
(false, true, false) => Self::Sealed3,
|
||||
(false, true, true) => Self::AgmFlooded,
|
||||
(true, false, false) => Self::Flooded5,
|
||||
(true, false, true) => Self::Flooded6,
|
||||
(true, true, false) => Self::Flooded7,
|
||||
(true, true, true) => Self::Custom,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
enum BatteryEqualization {
|
||||
Manual,
|
||||
Automatic,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum ChargeState {
|
||||
Start,
|
||||
|
@ -602,32 +733,14 @@ impl ChargeStateGauges {
|
|||
}
|
||||
}
|
||||
|
||||
async fn connect_modbus(transport: &crate::config::Transport) -> eyre::Result<ModbusTimeout> {
|
||||
let slave = tokio_modbus::Slave(DEVICE_ID);
|
||||
|
||||
let modbus = match transport {
|
||||
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(ModbusTimeout::new(modbus))
|
||||
}
|
||||
|
||||
impl Tristar {
|
||||
pub async fn new(
|
||||
friendly_name: &str,
|
||||
transport: &crate::config::Transport,
|
||||
) -> eyre::Result<Self> {
|
||||
let mut modbus = connect_modbus(transport).await?;
|
||||
let mut modbus = ModbusTimeout::new(transport.clone()).await?;
|
||||
let scaling = {
|
||||
let data = modbus.read_holding_registers(0x0000, 4).await?;
|
||||
let data = modbus.read_holding_registers(0x0000, 4).await??;
|
||||
Scaling::from(&data)
|
||||
};
|
||||
|
||||
|
@ -639,15 +752,10 @@ impl Tristar {
|
|||
charge_state_gauges,
|
||||
consecutive_errors: 0,
|
||||
scaling,
|
||||
settings_last_read: None,
|
||||
transport_settings: transport.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn refresh(&mut self) -> eyre::Result<TristarState> {
|
||||
if self.modbus.reconnect_required {
|
||||
self.modbus = connect_modbus(&self.transport_settings).await?;
|
||||
}
|
||||
let new_state = self.get_data().await?;
|
||||
|
||||
self.scaling = new_state.scaling;
|
||||
|
@ -704,7 +812,7 @@ impl Tristar {
|
|||
let network = if let Ok(network_data) = self
|
||||
.modbus
|
||||
.read_holding_registers(NETWORK_DATA_ADDR_START, NETWORK_DATA_LENGTH)
|
||||
.await
|
||||
.await?
|
||||
{
|
||||
Some(NetworkSettings::from_buf(&network_data)?)
|
||||
} else {
|
||||
|
@ -714,12 +822,12 @@ impl Tristar {
|
|||
let charge_data_1 = self
|
||||
.modbus
|
||||
.read_holding_registers(CHARGE_DATA_ADDR_START, 0x22)
|
||||
.await?;
|
||||
.await??;
|
||||
|
||||
let charge_data_2 = self
|
||||
.modbus
|
||||
.read_holding_registers(CHARGE_DATA_ADDR_START + 0x80, 0x4e)
|
||||
.await?;
|
||||
.await??;
|
||||
|
||||
let mut charge_data = vec![0; 0xCE];
|
||||
charge_data[..0x22].copy_from_slice(&charge_data_1);
|
||||
|
@ -727,22 +835,14 @@ impl Tristar {
|
|||
|
||||
let charge = ChargeSettings::from_buf(&charge_data, &self.scaling)?;
|
||||
|
||||
self.settings_last_read = Some(std::time::Instant::now());
|
||||
|
||||
Ok(TristarSettings { network, charge })
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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?;
|
||||
.await??;
|
||||
|
||||
log::debug!(
|
||||
"tristar {} being set to voltage {target_voltage} (scaled: {scaled_voltage:#X?})",
|
||||
|
@ -752,10 +852,6 @@ impl Tristar {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.friendly_name
|
||||
}
|
||||
|
||||
fn scale_voltage(&self, voltage: f64) -> u16 {
|
||||
self.scaling.inverse_voltage(voltage)
|
||||
}
|
||||
|
@ -764,7 +860,7 @@ impl Tristar {
|
|||
let data = self
|
||||
.modbus
|
||||
.read_holding_registers(0x0000, RAM_DATA_SIZE + 1)
|
||||
.await?;
|
||||
.await??;
|
||||
Ok(TristarState::from_ram(&data))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,192 @@
|
|||
pub struct ModbusTimeout {
|
||||
context: Option<tokio_modbus::client::Context>,
|
||||
transport_settings: crate::config::Transport,
|
||||
counters: Counters,
|
||||
}
|
||||
|
||||
const MODBUS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
async fn connect(
|
||||
transport_settings: &crate::config::Transport,
|
||||
) -> eyre::Result<tokio_modbus::client::Context> {
|
||||
let slave = tokio_modbus::Slave(super::DEVICE_ID);
|
||||
|
||||
let modbus = match transport_settings {
|
||||
crate::config::Transport::Serial { port, baud_rate } => {
|
||||
let modbus_serial =
|
||||
tokio_serial::SerialStream::open(&tokio_serial::new(port, *baud_rate))?;
|
||||
tokio_modbus::client::rtu::attach_slave(modbus_serial, slave)
|
||||
}
|
||||
crate::config::Transport::Tcp { ip, port } => {
|
||||
let modbus_tcp = tokio::net::TcpStream::connect((*ip, *port)).await?;
|
||||
tokio_modbus::client::tcp::attach_slave(modbus_tcp, slave)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(modbus)
|
||||
}
|
||||
|
||||
type ModbusDeviceResult<T> = Result<T, tokio_modbus::ExceptionCode>;
|
||||
type ModbusResult<T> = Result<ModbusDeviceResult<T>, tokio_modbus::Error>;
|
||||
|
||||
type ContextFuture<'a, R> = dyn std::future::Future<Output = ModbusResult<R>> + Send + 'a;
|
||||
type ContextFn<R, D> =
|
||||
fn(&mut tokio_modbus::client::Context, D) -> std::pin::Pin<Box<ContextFuture<'_, R>>>;
|
||||
|
||||
trait TryInsert {
|
||||
type T;
|
||||
async fn get_or_try_insert_with<F, R, Fut>(&mut self, f: F) -> Result<&mut Self::T, R>
|
||||
where
|
||||
Fut: std::future::Future<Output = Result<Self::T, R>>,
|
||||
F: FnOnce() -> Fut;
|
||||
}
|
||||
|
||||
impl<T> TryInsert for Option<T> {
|
||||
type T = T;
|
||||
|
||||
async fn get_or_try_insert_with<F, R, Fut>(&mut self, f: F) -> Result<&mut Self::T, R>
|
||||
where
|
||||
Fut: std::future::Future<Output = Result<Self::T, R>>,
|
||||
F: FnOnce() -> Fut,
|
||||
{
|
||||
if self.is_none() {
|
||||
let got = f().await?;
|
||||
*self = Some(got);
|
||||
}
|
||||
|
||||
// a `None` variant for `self` would have been replaced by a `Some` variant
|
||||
// in the code above, or the ? would have caused an early return
|
||||
Ok(self.as_mut().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
struct Counters {
|
||||
gateway: usize,
|
||||
timeout: usize,
|
||||
protocol: usize,
|
||||
}
|
||||
|
||||
const MAX_ERRORS: usize = 2;
|
||||
|
||||
impl Counters {
|
||||
fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
const fn any_above_max(&self) -> bool {
|
||||
self.gateway > MAX_ERRORS || self.timeout > MAX_ERRORS || self.protocol > MAX_ERRORS
|
||||
}
|
||||
}
|
||||
|
||||
const NUM_TRIES: usize = 3;
|
||||
|
||||
impl ModbusTimeout {
|
||||
pub async fn new(transport_settings: crate::config::Transport) -> eyre::Result<Self> {
|
||||
let context = Some(connect(&transport_settings).await?);
|
||||
Ok(Self {
|
||||
context,
|
||||
transport_settings,
|
||||
counters: Counters::default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn with_context<R, D: Copy>(
|
||||
&mut self,
|
||||
f: ContextFn<R, D>,
|
||||
data: D,
|
||||
) -> eyre::Result<Result<R, tokio_modbus::ExceptionCode>> {
|
||||
let mut last_err = None;
|
||||
for _ in 0..NUM_TRIES {
|
||||
if let Ok(context) = self
|
||||
.context
|
||||
.get_or_try_insert_with(async || connect(&self.transport_settings).await)
|
||||
.await
|
||||
{
|
||||
let res = tokio::time::timeout(MODBUS_TIMEOUT, f(context, data)).await;
|
||||
match res {
|
||||
Ok(Ok(Err(e)))
|
||||
if e == tokio_modbus::ExceptionCode::GatewayTargetDevice
|
||||
|| e == tokio_modbus::ExceptionCode::GatewayPathUnavailable =>
|
||||
{
|
||||
log::warn!("gateway error: {e:?}");
|
||||
last_err = Some(e.into());
|
||||
self.counters.gateway += 1;
|
||||
}
|
||||
Ok(Ok(v)) => {
|
||||
self.counters.reset();
|
||||
return Ok(v);
|
||||
}
|
||||
Ok(Err(tokio_modbus::Error::Protocol(e))) => {
|
||||
// protocol error
|
||||
log::warn!("protocol error: {e:?}");
|
||||
last_err = Some(e.into());
|
||||
self.counters.protocol += 1;
|
||||
}
|
||||
Ok(Err(tokio_modbus::Error::Transport(e))) => {
|
||||
// transport error
|
||||
log::warn!("reconnecting due to transport error: {e:?}");
|
||||
last_err = Some(e.into());
|
||||
self.context = None;
|
||||
}
|
||||
Err(_) => {
|
||||
// timeout
|
||||
last_err = Some(eyre::eyre!("timeout"));
|
||||
self.counters.timeout += 1;
|
||||
}
|
||||
}
|
||||
if self.counters.any_above_max() {
|
||||
self.context = None;
|
||||
log::warn!(
|
||||
"reconnecting due to multiple errors without a successful operation: {:?}",
|
||||
self.counters
|
||||
);
|
||||
self.counters.reset();
|
||||
}
|
||||
} else {
|
||||
// failed to reconnect
|
||||
return Err(eyre::eyre!("failed to reconnect to controller"));
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.unwrap_or_else(|| eyre::eyre!("unknown last error????")))
|
||||
}
|
||||
|
||||
pub async fn write_single_register(
|
||||
&mut self,
|
||||
addr: tokio_modbus::Address,
|
||||
word: u16,
|
||||
) -> eyre::Result<ModbusDeviceResult<()>> {
|
||||
async fn write(
|
||||
context: &mut tokio_modbus::client::Context,
|
||||
addr: tokio_modbus::Address,
|
||||
word: u16,
|
||||
) -> ModbusResult<()> {
|
||||
use tokio_modbus::client::Writer;
|
||||
context.write_single_register(addr, word).await
|
||||
}
|
||||
|
||||
let fut: ContextFn<(), _> = |context, (addr, word)| Box::pin(write(context, addr, word));
|
||||
let r = self.with_context(fut, (addr, word)).await?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub async fn read_holding_registers(
|
||||
&mut self,
|
||||
addr: tokio_modbus::Address,
|
||||
cnt: tokio_modbus::Quantity,
|
||||
) -> eyre::Result<ModbusDeviceResult<Vec<u16>>> {
|
||||
async fn read(
|
||||
context: &mut tokio_modbus::client::Context,
|
||||
addr: tokio_modbus::Address,
|
||||
cnt: tokio_modbus::Quantity,
|
||||
) -> ModbusResult<Vec<u16>> {
|
||||
use tokio_modbus::client::Reader;
|
||||
context.read_holding_registers(addr, cnt).await
|
||||
}
|
||||
|
||||
let fut: ContextFn<_, _> = |context, (addr, cnt)| Box::pin(read(context, addr, cnt));
|
||||
let res = self.with_context(fut, (addr, cnt)).await?;
|
||||
Ok(res)
|
||||
}
|
||||
}
|
|
@ -108,29 +108,26 @@ async fn watch(args: Args) -> eyre::Result<()> {
|
|||
|
||||
let mut controllers = Vec::new();
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let mut follow_voltage_tx = Vec::new();
|
||||
|
||||
let (voltage_tx, voltage_rx) =
|
||||
tokio::sync::watch::channel(controller::VoltageCommand::None);
|
||||
|
||||
for config in &config.charge_controllers {
|
||||
let n = config.name.clone();
|
||||
match controller::Controller::new(config.clone()).await {
|
||||
Ok((v, voltage_tx)) => {
|
||||
match controller::Controller::new(config.clone(), voltage_rx.clone()).await {
|
||||
Ok(v) => {
|
||||
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(follow_voltage_tx.clone());
|
||||
primary.set_tx_to_secondary(voltage_tx.clone());
|
||||
}
|
||||
|
||||
drop(config);
|
||||
|
@ -142,7 +139,7 @@ async fn watch(args: Args) -> eyre::Result<()> {
|
|||
|
||||
(
|
||||
storage::AllControllers::new(map),
|
||||
follow_voltage_tx,
|
||||
voltage_tx,
|
||||
controller_tasks,
|
||||
)
|
||||
};
|
||||
|
@ -153,6 +150,7 @@ async fn watch(args: Args) -> eyre::Result<()> {
|
|||
follow_voltage_tx,
|
||||
));
|
||||
let server_task = tokio::task::spawn(server.launch());
|
||||
log::warn!("...started!");
|
||||
|
||||
tokio::select! {
|
||||
v = controller_tasks.next() => {
|
||||
|
@ -180,22 +178,20 @@ 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 {
|
||||
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());
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
timeout.tick().await;
|
||||
do_refresh(&mut controller).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,14 +7,14 @@ mod static_handler;
|
|||
pub struct ServerState {
|
||||
primary_name: String,
|
||||
data: AllControllers,
|
||||
tx_to_controllers: crate::controller::MultiTx,
|
||||
tx_to_controllers: tokio::sync::watch::Sender<crate::controller::VoltageCommand>,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
pub fn new(
|
||||
primary_name: &impl ToString,
|
||||
data: AllControllers,
|
||||
tx_to_controllers: crate::controller::MultiTx,
|
||||
tx_to_controllers: tokio::sync::watch::Sender<crate::controller::VoltageCommand>,
|
||||
) -> Self {
|
||||
let primary_name = primary_name.to_string();
|
||||
Self {
|
||||
|
@ -200,20 +200,23 @@ async fn enable_control() {
|
|||
}
|
||||
|
||||
#[post("/control/disable")]
|
||||
async fn disable_control(state: &State<ServerState>) {
|
||||
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_to_all(crate::controller::VoltageCommand::Set(-1.0));
|
||||
.send(crate::controller::VoltageCommand::None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
enum ServerError {
|
||||
Prometheus,
|
||||
NotFound,
|
||||
InvalidPrimaryName,
|
||||
NoData,
|
||||
ControllerTx,
|
||||
}
|
||||
|
||||
impl From<prometheus::Error> for ServerError {
|
||||
|
@ -222,12 +225,20 @@ 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::NoData | Self::Prometheus => rocket::http::Status::InternalServerError,
|
||||
Self::ControllerTx | Self::NoData | Self::Prometheus => {
|
||||
rocket::http::Status::InternalServerError
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2025-01-16"
|
||||
targets = ["aarch64-unknown-linux-musl"]
|
||||
|
|
|
@ -95,6 +95,7 @@ impl Vehicle {
|
|||
Ok(state.charge_state)
|
||||
}
|
||||
|
||||
#[expect(dead_code, reason = "active charge control not yet implemented")]
|
||||
pub async fn set_charging_amps(&self, charging_amps: i64) -> eyre::Result<()> {
|
||||
self.client
|
||||
.post(format!(
|
||||
|
|
|
@ -42,11 +42,11 @@ impl Car {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn vehicle(&self) -> &http::Vehicle {
|
||||
pub const fn vehicle(&self) -> &http::Vehicle {
|
||||
&self.vehicle
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &tokio::sync::RwLock<CarState> {
|
||||
pub const fn state(&self) -> &tokio::sync::RwLock<CarState> {
|
||||
&self.state
|
||||
}
|
||||
}
|
||||
|
@ -153,7 +153,7 @@ impl ChargeState {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ChargingState {
|
||||
Charging,
|
||||
Stopped,
|
||||
|
|
|
@ -60,7 +60,7 @@ impl ConfigWatcher {
|
|||
async fn overwrite_config(config: Config) -> eyre::Result<()> {
|
||||
*CONFIG
|
||||
.get()
|
||||
.ok_or(eyre::eyre!("could not get config"))?
|
||||
.ok_or_else(|| eyre::eyre!("could not get config"))?
|
||||
.write()
|
||||
.await = config;
|
||||
Ok(())
|
||||
|
|
|
@ -4,6 +4,7 @@ pub struct VehicleController {
|
|||
control_state: ChargeRateControllerState,
|
||||
}
|
||||
|
||||
#[expect(dead_code, reason = "not all states are currently in use")]
|
||||
pub enum ChargeRateControllerState {
|
||||
Inactive,
|
||||
Charging { rate_amps: i64 },
|
||||
|
@ -14,7 +15,7 @@ pub enum InterfaceRequest {
|
|||
}
|
||||
|
||||
impl VehicleController {
|
||||
pub fn new(
|
||||
pub const fn new(
|
||||
car: std::sync::Arc<crate::api::Car>,
|
||||
requests: tokio::sync::mpsc::UnboundedReceiver<InterfaceRequest>,
|
||||
) -> Self {
|
||||
|
@ -50,7 +51,10 @@ impl VehicleController {
|
|||
}
|
||||
match self.control_state {
|
||||
ChargeRateControllerState::Inactive => {
|
||||
if let Some(state) = self.car.state().read().await.charge_state().await {
|
||||
let car_state = self.car.state().read().await;
|
||||
let state = car_state.charge_state().await;
|
||||
|
||||
if let Some(state) = state {
|
||||
if state.is_charging() {
|
||||
self.control_state = ChargeRateControllerState::Charging {
|
||||
rate_amps: state.charge_amps,
|
||||
|
@ -58,10 +62,14 @@ 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,
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
#![allow(clippy::significant_drop_tightening)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
|
|
|
@ -27,7 +27,7 @@ pub struct ServerState {
|
|||
}
|
||||
|
||||
impl ServerState {
|
||||
pub fn new(car: Arc<Car>, api_requests: UnboundedSender<InterfaceRequest>) -> Self {
|
||||
pub const fn new(car: Arc<Car>, api_requests: UnboundedSender<InterfaceRequest>) -> Self {
|
||||
Self { car, api_requests }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,10 +52,12 @@ impl Handler for UiStatic {
|
|||
data: v.contents().to_vec(),
|
||||
name: p,
|
||||
})
|
||||
.or(UI_DIR_FILES.get_file(&plus_index).map(|v| RawHtml {
|
||||
data: v.contents().to_vec(),
|
||||
name: plus_index,
|
||||
}));
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue