use crate::modbus::{read_words, with_retries, ModbusBus};
use crate::register::{decode, RegisterDef};
use crate::{Applied, Capabilities, Command, Error, Inverter, Mode, Telemetry};
use std::time::{Instant, SystemTime};
const LOG_TARGET: &str = "inverter.foxess";
const WRITE_BLOCKED: &str = "FoxESS register map is unverified on hardware; \
reads are trusted, writes are not implemented";
pub struct RegisterMap {
pub model: &'static str,
pub battery_soc: RegisterDef,
pub battery_power: RegisterDef,
pub grid_power: RegisterDef,
pub load_power: RegisterDef,
pub pv_powers: &'static [RegisterDef],
}
pub mod registers {
use super::RegisterMap;
use crate::register::RegisterDef as R;
pub const H1_G1: RegisterMap = RegisterMap {
model: "FoxESS H1 G1 (RS485, community map, unverified)",
battery_soc: R::input("battery_soc", 11036),
battery_power: R::input("battery_power", 11008).signed().scale(-0.001),
grid_power: R::input("grid_ct", 11021).signed().scale(-0.001),
load_power: R::input("load_power", 11023).signed().scale(0.001),
pv_powers: &[
R::input("pv1_power", 11002).scale(0.001),
R::input("pv2_power", 11005).scale(0.001),
],
};
pub const H1_G2: RegisterMap = RegisterMap {
model: "FoxESS H1 G2 (RS485, community map, unverified)",
battery_soc: R::holding("battery_soc", 31024),
battery_power: R::holding("battery_power", 31022).signed().scale(-0.001),
grid_power: R::holding("grid_ct", 31014).signed().scale(-0.001),
load_power: R::holding("load_power", 31016).signed().scale(0.001),
pv_powers: &[
R::holding("pv1_power", 39280).scale(0.001),
R::holding("pv2_power", 39282).scale(0.001),
],
};
pub mod remote_control {
use crate::register::RegisterDef as R;
pub const REMOTE_ENABLE: R = R::holding("remote_enable", 44000);
pub const TIMEOUT_SET: R = R::holding("timeout_set", 44001);
pub const ACTIVE_POWER: R = R::holding("active_power", 44002).signed().scale(0.001);
pub const WORK_MODE: R = R::holding("work_mode", 41000);
pub const MIN_SOC: R = R::holding("min_soc", 41009);
pub const MAX_SOC: R = R::holding("max_soc", 41010);
}
}
pub struct FoxEss<B: ModbusBus> {
bus: B,
map: &'static RegisterMap,
}
impl<B: ModbusBus> FoxEss<B> {
pub fn new(bus: B, map: &'static RegisterMap) -> Self {
log::warn!(
target: LOG_TARGET,
"FoxESS driver started with an UNVERIFIED register map ({}) - reads only",
map.model
);
Self { bus, map }
}
fn read(&mut self, reg: &RegisterDef) -> Result<f64, Error> {
with_retries(
&mut self.bus,
LOG_TARGET,
&format!("read {}", reg.name),
|bus| read_words(bus, reg).map(|words| decode(reg, &words)),
)
}
}
#[cfg(feature = "serial")]
impl FoxEss<crate::modbus::SerialBus> {
pub fn open_serial(
port: &str,
baud_rate: u32,
unit_id: u8,
map: &'static RegisterMap,
) -> Result<Self, Error> {
Ok(Self::new(
crate::modbus::SerialBus::open(port, baud_rate, unit_id)?,
map,
))
}
}
#[cfg(feature = "tcp")]
impl FoxEss<crate::modbus::TcpBus> {
pub fn open_tcp(addr: &str, unit_id: u8, map: &'static RegisterMap) -> Result<Self, Error> {
Ok(Self::new(
crate::modbus::TcpBus::connect(addr, unit_id)?,
map,
))
}
}
impl<B: ModbusBus> Inverter for FoxEss<B> {
fn capabilities(&self) -> Capabilities {
let mut caps = Capabilities::read_only(self.map.model, WRITE_BLOCKED);
caps.reports_solar = !self.map.pv_powers.is_empty();
caps
}
fn read_telemetry(&mut self) -> Result<Telemetry, Error> {
let map = self.map;
let soc_pct = self.read(&map.battery_soc)?;
let battery_kw = self.read(&map.battery_power)?;
let grid_kw = self.read(&map.grid_power)?;
let load_kw = self.read(&map.load_power)?.max(0.0);
let mut solar_kw = 0.0;
for pv in map.pv_powers {
solar_kw += self.read(pv)?;
}
Ok(Telemetry {
soc_pct,
battery_kw,
grid_kw,
load_kw,
solar_kw,
at: SystemTime::now(),
read_at: Instant::now(),
})
}
fn apply(&mut self, command: Command) -> Result<Applied, Error> {
Err(Error::Unsupported(format!(
"{WRITE_BLOCKED} (refused: {command})"
)))
}
fn mode(&mut self) -> Result<Mode, Error> {
Err(Error::Unsupported(
"FoxESS mode read-back is not implemented: \
the remote-control registers are unverified"
.into(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
const INTENDED_MODES: &[Mode] = &[Mode::Passive, Mode::ForceCharge, Mode::ForceDischarge];
struct FakeBus {
input: HashMap<u16, Vec<u16>>,
holding: HashMap<u16, Vec<u16>>,
}
impl FakeBus {
fn with_input(pairs: &[(u16, u16)]) -> Self {
Self {
input: pairs.iter().map(|&(a, v)| (a, vec![v])).collect(),
holding: HashMap::new(),
}
}
fn with_holding(pairs: &[(u16, u16)]) -> Self {
Self {
input: HashMap::new(),
holding: pairs.iter().map(|&(a, v)| (a, vec![v])).collect(),
}
}
}
impl ModbusBus for FakeBus {
fn read_input(&mut self, address: u16, _words: u8) -> Result<Vec<u16>, Error> {
self.input
.get(&address)
.cloned()
.ok_or_else(|| Error::Comm(format!("no fixture for input {address}")))
}
fn read_holding(&mut self, address: u16, _words: u8) -> Result<Vec<u16>, Error> {
self.holding
.get(&address)
.cloned()
.ok_or_else(|| Error::Comm(format!("no fixture for holding {address}")))
}
fn write_holding(&mut self, _address: u16, _value: u16) -> Result<(), Error> {
Err(Error::Comm("fake bus is read-only".into()))
}
}
fn g1_fixture() -> FakeBus {
FakeBus::with_input(&[
(registers::H1_G1.battery_soc.address, 64),
(registers::H1_G1.battery_power.address, (-1500i16) as u16),
(registers::H1_G1.grid_power.address, (-2000i16) as u16),
(registers::H1_G1.load_power.address, 500),
(registers::H1_G1.pv_powers[0].address, 0),
(registers::H1_G1.pv_powers[1].address, 0),
])
}
fn g2_fixture() -> FakeBus {
FakeBus::with_holding(&[
(registers::H1_G2.battery_soc.address, 55),
(registers::H1_G2.battery_power.address, 400),
(registers::H1_G2.grid_power.address, 250),
(registers::H1_G2.load_power.address, 750),
(registers::H1_G2.pv_powers[0].address, 300),
(registers::H1_G2.pv_powers[1].address, 300),
])
}
#[test]
fn g1_normalises_foxess_signs_and_watts_to_crate_conventions() {
let mut inv = FoxEss::new(g1_fixture(), ®isters::H1_G1);
let t = inv.read_telemetry().unwrap();
assert_eq!(t.soc_pct, 64.0);
assert_eq!(t.battery_kw, 1.5, "raw negative watts mean charging");
assert_eq!(t.grid_kw, 2.0, "raw negative watts mean importing");
assert_eq!(t.load_kw, 0.5);
assert_eq!(t.solar_kw, 0.0);
assert_eq!(t.export_kw(), 0.0, "importing, so nothing is exported");
}
#[test]
fn g2_reads_holding_registers_and_sums_pv_strings() {
let mut inv = FoxEss::new(g2_fixture(), ®isters::H1_G2);
let t = inv.read_telemetry().unwrap();
assert_eq!(t.soc_pct, 55.0);
assert_eq!(t.battery_kw, -0.4, "raw positive means discharging");
assert_eq!(t.grid_kw, -0.25, "raw positive means exporting");
assert_eq!(t.export_kw(), 0.25);
assert_eq!(t.load_kw, 0.75);
assert_eq!(t.solar_kw, 0.6, "both PV strings summed");
}
#[test]
fn a_slightly_negative_load_reading_clamps_to_zero() {
let mut bus = g2_fixture();
bus.holding
.insert(registers::H1_G2.load_power.address, vec![(-5i16) as u16]);
let mut inv = FoxEss::new(bus, ®isters::H1_G2);
assert_eq!(inv.read_telemetry().unwrap().load_kw, 0.0);
}
#[test]
fn reports_that_it_cannot_write_and_says_why() {
for map in [®isters::H1_G1, ®isters::H1_G2] {
let inv = FoxEss::new(FakeBus::with_input(&[]), map);
let caps = inv.capabilities();
assert!(!caps.can_write);
assert!(caps.write_blocked_reason.is_some());
assert!(caps.reports_solar);
assert!(!caps.reports_mode, "mode() cannot answer until verified");
for mode in INTENDED_MODES {
assert!(!caps.supports(*mode), "{mode:?} must not be advertised");
}
}
}
#[test]
fn mode_read_back_is_honestly_unsupported() {
let mut inv = FoxEss::new(g2_fixture(), ®isters::H1_G2);
assert!(matches!(inv.mode(), Err(Error::Unsupported(_))));
}
#[test]
fn refuses_every_command_while_the_map_is_unverified() {
let mut inv = FoxEss::new(g2_fixture(), ®isters::H1_G2);
for command in [
Command::passive(),
Command::charge(2_000.0),
Command::export(3_000.0),
] {
assert!(matches!(inv.apply(command), Err(Error::Unsupported(_)),));
}
}
#[test]
fn a_missing_register_is_an_error_not_a_plausible_zero() {
let mut inv = FoxEss::new(
FakeBus::with_input(&[(registers::H1_G1.battery_soc.address, 50)]),
®isters::H1_G1,
);
assert!(inv.read_telemetry().is_err());
}
#[test]
fn a_g2_wired_up_with_the_g1_map_fails_rather_than_lying() {
let mut inv = FoxEss::new(g2_fixture(), ®isters::H1_G1);
assert!(inv.read_telemetry().is_err());
}
#[test]
fn the_maps_pin_the_community_documented_addresses() {
use crate::register::RegKind;
let g1 = ®isters::H1_G1;
assert_eq!(g1.battery_soc.address, 11036);
assert_eq!(g1.battery_power.address, 11008);
assert_eq!(g1.grid_power.address, 11021);
assert_eq!(g1.load_power.address, 11023);
assert!(g1.pv_powers.iter().all(|reg| reg.kind == RegKind::Input));
let g2 = ®isters::H1_G2;
assert_eq!(g2.battery_soc.address, 31024);
assert_eq!(g2.battery_power.address, 31022);
assert_eq!(g2.grid_power.address, 31014);
assert_eq!(g2.load_power.address, 31016);
assert!(g2.pv_powers.iter().all(|reg| reg.kind == RegKind::Holding));
for map in [g1, g2] {
assert_eq!(
map.battery_power.scale, -0.001,
"raw battery watts are discharge-positive; scaled to charge-positive kW"
);
assert_eq!(
map.grid_power.scale, -0.001,
"raw grid watts are export-positive; scaled to import-positive kW"
);
assert_eq!(map.load_power.scale, 0.001);
for pv in map.pv_powers {
assert_eq!(pv.scale, 0.001);
}
assert!(map.battery_power.signed && map.grid_power.signed && map.load_power.signed);
}
}
#[test]
fn a_missing_pv_register_is_an_error_like_any_other() {
let mut bus = g2_fixture();
bus.holding.remove(®isters::H1_G2.pv_powers[1].address);
let mut inv = FoxEss::new(bus, ®isters::H1_G2);
assert!(inv.read_telemetry().is_err());
}
#[test]
fn remote_control_registers_are_recorded_but_unused() {
assert_eq!(registers::remote_control::TIMEOUT_SET.address, 44001);
assert_eq!(registers::remote_control::REMOTE_ENABLE.address, 44000);
assert_eq!(registers::remote_control::ACTIVE_POWER.address, 44002);
}
}