Compare commits

...

17 commits

Author SHA1 Message Date
7ffdfd1fc3 ccs: log message when server spawned 2025-01-18 12:00:57 +11:00
9d12bce452 tcc: fix clippy lints 2025-01-18 11:58:22 +11:00
b549ceebab update toolchain 2025-01-18 11:52:32 +11:00
b28026820c rename .gitea -> .forgejo 2025-01-18 11:18:11 +11:00
ab3cd28f83 v1.9.9-pre-30: revert always send voltages
All checks were successful
Build and release .deb / Release (push) Successful in 54s
2025-01-16 11:30:22 +11:00
cec26d8cfb v1.9.9-pre-29: always send but don't follow target
All checks were successful
Build and release .deb / Release (push) Successful in 56s
for debugging
2025-01-16 11:21:36 +11:00
186d8fc71a ccs: use tokio::sync::watch for tx to controllers 2025-01-16 11:15:31 +11:00
05edfe6b84 release v1.9.9-pre-28
All checks were successful
Build and release .deb / Release (push) Successful in 56s
2025-01-15 12:11:34 +11:00
c4c99eff1d ccs: set refresh missedtickbehavior 2025-01-15 12:09:57 +11:00
76c33534be ccs: modbus wrapper: consolidate max errors 2025-01-15 12:06:23 +11:00
9bd1243129 ccs: tristar modbus wrapper: actually retry 2025-01-15 12:05:41 +11:00
667f51d375 ccs: reinstate ControllerInner large_enum_variant 2025-01-15 12:04:36 +11:00
9a87b4f820 release v1.9.9-pre-27
All checks were successful
Build and release .deb / Release (push) Successful in 59s
2025-01-14 19:47:25 +11:00
37c7412df1 ccs: tristar: wrap modbus 2025-01-14 19:46:37 +11:00
03d6278ba9 release v1.9.9-pre-26
All checks were successful
Build and release .deb / Release (push) Successful in 57s
2025-01-13 14:48:25 +11:00
7508459414 ccs: pl: get settings 2025-01-13 14:48:04 +11:00
b28f39667d ccs: pull settings timer out of tristar 2025-01-13 13:28:52 +11:00
17 changed files with 546 additions and 197 deletions

4
Cargo.lock generated
View file

@ -239,7 +239,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "charge-controller-supervisor"
version = "1.9.9-pre-25"
version = "1.9.9-pre-30"
dependencies = [
"bitflags 2.7.0",
"chrono",
@ -2205,7 +2205,7 @@ dependencies = [
[[package]]
name = "tesla-charge-controller"
version = "1.9.9-pre-25"
version = "1.9.9-pre-30"
dependencies = [
"chrono",
"clap",

View file

@ -4,7 +4,7 @@ default-members = ["charge-controller-supervisor"]
resolver = "2"
[workspace.package]
version = "1.9.9-pre-25"
version = "1.9.9-pre-30"
[workspace.lints.clippy]
pedantic = "warn"

View file

@ -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 {
Ok(Self {
name: config.name,
interval: std::time::Duration::from_secs(config.watch_interval_seconds),
inner,
data,
voltage_rx,
voltage_tx: None,
},
voltage_tx,
))
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) => {
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))
}
}
}

View file

@ -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?;
@ -449,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]
}

View file

@ -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>,
@ -781,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)
};
@ -818,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;
@ -883,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 {
@ -893,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);
@ -906,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?})",
@ -931,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)
}
@ -943,7 +860,7 @@ impl Tristar {
let data = self
.modbus
.read_holding_registers(0x0000, RAM_DATA_SIZE + 1)
.await?;
.await??;
Ok(TristarState::from_ram(&data))
}
}

View file

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

View file

@ -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,23 +178,21 @@ 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() {
let rx = controller.get_rx();
tokio::select! {
_ = timeout.tick() => {
do_refresh(&mut controller).await;
}
Some(command) = rx.recv() => {
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;
}
}
}

View file

@ -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
}
})
}
}

View file

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

View file

@ -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!(

View file

@ -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,

View file

@ -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(())

View file

@ -4,6 +4,7 @@ pub struct VehicleController {
control_state: ChargeRateControllerState,
}
#[expect(dead_code, reason = "not all states are currently in use")]
pub enum ChargeRateControllerState {
Inactive,
Charging { rate_amps: i64 },
@ -14,7 +15,7 @@ pub enum InterfaceRequest {
}
impl VehicleController {
pub fn new(
pub const fn new(
car: std::sync::Arc<crate::api::Car>,
requests: tokio::sync::mpsc::UnboundedReceiver<InterfaceRequest>,
) -> Self {
@ -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,

View file

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

View file

@ -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 }
}
}

View file

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