#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
use std::time::{Duration, Instant, SystemTime};
pub mod register;
pub mod modbus;
#[cfg(feature = "foxess")]
pub mod foxess;
#[cfg(feature = "mock")]
pub mod mock;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("communication error: {0}")]
Comm(String),
#[error("read-back mismatch: {0}")]
Readback(String),
#[error("value out of range: {0}")]
Range(String),
#[error("unsupported: {0}")]
Unsupported(String),
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum Mode {
Passive,
ForceCharge,
ForceDischarge,
}
impl Mode {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Mode::Passive => "passive",
Mode::ForceCharge => "force_charge",
Mode::ForceDischarge => "force_discharge",
}
}
}
impl std::fmt::Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(r#"unrecognised mode {0:?}: expected "passive", "force_charge" or "force_discharge""#)]
pub struct ParseModeError(String);
impl std::str::FromStr for Mode {
type Err = ParseModeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"passive" => Ok(Mode::Passive),
"force_charge" => Ok(Mode::ForceCharge),
"force_discharge" => Ok(Mode::ForceDischarge),
_ => Err(ParseModeError(s.to_string())),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum DischargeTarget {
HouseOnly,
GridExport,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct Command {
pub mode: Mode,
pub power_kw: f64,
pub target: DischargeTarget,
pub hold: Duration,
}
impl Command {
pub const DEFAULT_HOLD: Duration = Duration::from_secs(300);
#[must_use]
pub fn passive() -> Self {
Command {
mode: Mode::Passive,
power_kw: 0.0,
target: DischargeTarget::HouseOnly,
hold: Self::DEFAULT_HOLD,
}
}
#[must_use]
pub fn charge(power_kw: impl Into<f64>) -> Self {
Command {
mode: Mode::ForceCharge,
power_kw: power_kw.into(),
target: DischargeTarget::HouseOnly,
hold: Self::DEFAULT_HOLD,
}
}
#[must_use]
pub fn discharge(power_kw: impl Into<f64>) -> Self {
Command {
mode: Mode::ForceDischarge,
power_kw: power_kw.into(),
target: DischargeTarget::HouseOnly,
hold: Self::DEFAULT_HOLD,
}
}
#[must_use]
pub fn export(power_kw: impl Into<f64>) -> Self {
Command {
mode: Mode::ForceDischarge,
power_kw: power_kw.into(),
target: DischargeTarget::GridExport,
hold: Self::DEFAULT_HOLD,
}
}
#[must_use]
pub fn holding_for(mut self, hold: Duration) -> Self {
self.hold = hold;
self
}
}
impl std::fmt::Display for Command {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (self.mode, self.target) {
(Mode::Passive, _) => f.write_str("passive"),
(Mode::ForceDischarge, DischargeTarget::GridExport) => {
write!(f, "force_discharge@{}kW(grid-export)", self.power_kw)
}
_ => write!(f, "{}@{}kW", self.mode, self.power_kw),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum Expiry {
InverterTimeout(Duration),
InverterCondition(&'static str),
RecurringWindow,
UntilChanged,
}
impl Expiry {
#[must_use]
pub fn is_dead_controller_safe(&self) -> bool {
matches!(self, Expiry::InverterTimeout(_))
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[non_exhaustive]
pub struct Capabilities {
pub model: &'static str,
pub can_write: bool,
pub modes: &'static [Mode],
pub expiry: Expiry,
pub reports_solar: bool,
pub reports_mode: bool,
pub write_blocked_reason: Option<&'static str>,
}
impl Capabilities {
#[must_use]
pub fn read_only(model: &'static str, reason: &'static str) -> Self {
Capabilities {
model,
can_write: false,
modes: &[],
expiry: Expiry::UntilChanged,
reports_solar: false,
reports_mode: false,
write_blocked_reason: Some(reason),
}
}
#[must_use]
pub fn writable(model: &'static str, modes: &'static [Mode], expiry: Expiry) -> Self {
assert!(
modes.contains(&Mode::Passive),
"a writable driver must support Mode::Passive"
);
Capabilities {
model,
can_write: true,
modes,
expiry,
reports_solar: false,
reports_mode: false,
write_blocked_reason: None,
}
}
#[must_use]
pub fn supports(&self, mode: Mode) -> bool {
self.can_write && self.modes.contains(&mode)
}
}
#[derive(Clone, Copy, Debug)]
pub struct Telemetry {
pub soc_pct: f64,
pub battery_kw: f64,
pub grid_kw: f64,
pub load_kw: f64,
pub solar_kw: f64,
pub at: SystemTime,
pub read_at: Instant,
}
impl Telemetry {
#[must_use]
pub fn export_kw(&self) -> f64 {
(-self.grid_kw).max(0.0)
}
#[must_use]
pub fn age(&self) -> Duration {
self.read_at.elapsed()
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct Applied {
pub expiry: Expiry,
pub power_kw: f64,
}
pub trait Inverter: Send {
fn capabilities(&self) -> Capabilities;
fn read_telemetry(&mut self) -> Result<Telemetry, Error>;
fn apply(&mut self, command: Command) -> Result<Applied, Error>;
fn mode(&mut self) -> Result<Mode, Error>;
fn close(&mut self) {}
}
pub trait InverterExt: Inverter {
fn passive(&mut self) -> Result<Applied, Error> {
self.apply(Command::passive())
}
fn charge(&mut self, power_kw: impl Into<f64>) -> Result<Applied, Error> {
self.apply(Command::charge(power_kw))
}
fn discharge(&mut self, power_kw: impl Into<f64>) -> Result<Applied, Error> {
self.apply(Command::discharge(power_kw))
}
fn export(&mut self, power_kw: impl Into<f64>) -> Result<Applied, Error> {
self.apply(Command::export(power_kw))
}
fn soc_pct(&mut self) -> Result<f64, Error> {
Ok(self.read_telemetry()?.soc_pct)
}
fn battery_kw(&mut self) -> Result<f64, Error> {
Ok(self.read_telemetry()?.battery_kw)
}
fn grid_kw(&mut self) -> Result<f64, Error> {
Ok(self.read_telemetry()?.grid_kw)
}
fn load_kw(&mut self) -> Result<f64, Error> {
Ok(self.read_telemetry()?.load_kw)
}
fn solar_kw(&mut self) -> Result<f64, Error> {
Ok(self.read_telemetry()?.solar_kw)
}
fn export_kw(&mut self) -> Result<f64, Error> {
Ok(self.read_telemetry()?.export_kw())
}
}
impl<I: Inverter + ?Sized> InverterExt for I {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_round_trips_through_its_identifier() {
for mode in [Mode::Passive, Mode::ForceCharge, Mode::ForceDischarge] {
assert_eq!(mode.as_str().parse(), Ok(mode));
assert_eq!(mode.to_string(), mode.as_str(), "Display matches as_str");
}
let err = "nonsense".parse::<Mode>().unwrap_err();
assert!(err.to_string().contains("nonsense"), "{err}");
}
#[test]
fn only_a_one_shot_inverter_timeout_survives_a_dead_controller() {
assert!(Expiry::InverterTimeout(Duration::from_secs(60)).is_dead_controller_safe());
assert!(!Expiry::InverterCondition("target soc").is_dead_controller_safe());
assert!(!Expiry::RecurringWindow.is_dead_controller_safe());
assert!(!Expiry::UntilChanged.is_dead_controller_safe());
}
#[test]
fn export_is_distinguishable_from_house_only_discharge() {
assert_eq!(Command::discharge(1.5).target, DischargeTarget::HouseOnly);
assert_eq!(Command::export(3).target, DischargeTarget::GridExport);
assert!(Command::export(3).to_string().contains("grid-export"));
}
#[test]
fn display_names_the_mode_power_and_export_intent() {
assert_eq!(Command::passive().to_string(), "passive");
assert_eq!(Command::charge(2).to_string(), "force_charge@2kW");
assert_eq!(Command::discharge(1.5).to_string(), "force_discharge@1.5kW");
assert_eq!(
Command::export(3).to_string(),
"force_discharge@3kW(grid-export)"
);
}
#[test]
fn constructors_request_the_default_hold_unless_overridden() {
assert_eq!(Command::charge(1.0).hold, Command::DEFAULT_HOLD);
let short = Command::charge(1.0).holding_for(Duration::from_secs(60));
assert_eq!(short.hold, Duration::from_secs(60));
}
#[test]
fn export_kw_is_the_positive_part_of_negative_grid_flow() {
let mut t = Telemetry {
soc_pct: 50.0,
battery_kw: 0.0,
grid_kw: -0.3,
load_kw: 0.0,
solar_kw: 0.0,
at: SystemTime::now(),
read_at: Instant::now(),
};
assert_eq!(t.export_kw(), 0.3);
t.grid_kw = 0.2;
assert_eq!(t.export_kw(), 0.0);
}
#[test]
fn integer_and_float_powers_build_the_same_command() {
assert_eq!(Command::charge(2), Command::charge(2.0));
assert_eq!(Command::discharge(1), Command::discharge(1.0));
assert_eq!(Command::export(3), Command::export(3.0));
}
#[test]
fn a_writable_driver_supports_only_its_listed_modes() {
let caps = Capabilities::writable(
"test",
&[Mode::Passive, Mode::ForceCharge],
Expiry::UntilChanged,
);
assert!(caps.supports(Mode::Passive));
assert!(caps.supports(Mode::ForceCharge));
assert!(!caps.supports(Mode::ForceDischarge));
assert_eq!(caps.write_blocked_reason, None);
}
#[test]
#[should_panic(expected = "must support Mode::Passive")]
fn a_writable_driver_without_passive_is_rejected_outright() {
let _ = Capabilities::writable("test", &[Mode::ForceCharge], Expiry::UntilChanged);
}
#[test]
fn capabilities_refuse_every_mode_when_the_driver_cannot_write() {
let caps = Capabilities {
model: "test",
can_write: false,
modes: &[Mode::Passive, Mode::ForceCharge],
expiry: Expiry::UntilChanged,
reports_solar: false,
reports_mode: false,
write_blocked_reason: Some("unverified map"),
};
assert!(!caps.supports(Mode::Passive));
assert!(!caps.supports(Mode::ForceCharge));
}
#[test]
fn a_read_only_driver_carries_its_reason_and_reports_nothing_extra() {
let caps = Capabilities::read_only("test", "map unverified");
assert!(!caps.can_write);
assert_eq!(caps.write_blocked_reason, Some("map unverified"));
assert!(!caps.reports_solar && !caps.reports_mode);
assert!(!caps.supports(Mode::Passive));
}
}