Add FoxESS EV charger (Modbus TCP) (#31412)
Some checks failed
Release / call-build-workflow (push) Has been cancelled
Release / Publish Docker :release (push) Has been cancelled
Release / Github & APT (push) Has been cancelled
Release / Demo (push) Has been cancelled
Release / Hassio Addon (push) Has been cancelled

Co-authored-by: premultiply <4681172+premultiply@users.noreply.github.com>
This commit is contained in:
GurliGebis 2026-07-30 13:28:58 +02:00 • committed by GitHub
parent 05676e7c68
commit c727537702
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1094 additions and 1 deletions

674
charger/foxess-evc.go Normal file
View file

@ -0,0 +1,674 @@
package charger
// LICENSE
// Copyright (c) evcc.io (andig, naltatis, premultiply)
// This module is NOT covered by the MIT license. All rights reserved.
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"math"
"sync"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/implement"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/modbus"
"github.com/evcc-io/evcc/util/sponsor"
)
// FoxESS EV Charger, Modbus TCP Protocol 1.6
// https://github.com/evcc-io/evcc/discussions/26218
// Section references below refer to that document.
// FoxESSEVC charger implementation
type FoxESSEVC struct {
implement.Caps
log *util.Logger
conn *modbus.Connection
mu sync.Mutex // guards the tracked state below against the heartbeat goroutine
current float64 // tracks phase current, 0 if unset
enabled bool // tracks enabled state
phases int // tracks phase count; the charger does not report it
setpoint uint16 // last known value of foxRegMaxPower
status uint16 // last known value of foxRegStatus
switchable bool // charger switches 1p/3p on its own, derived from the power setpoint
minPower uint16 // min supported power
maxPower uint16 // max supported power
minCurrent float64 // min supported current per phase
maxCurrent float64 // max supported current per phase
}
// Register map per spec §2. Read-only and read/write registers are read with 0x03.
// Per §2 note (2) read/write registers must be written with 0x10, write-only registers with 0x06.
const (
// read-only registers
foxRegDeviceAddress = 0x1000 // device address (§2.1)
foxRegSwVersion = 0x1001 // software version, byte1 major / byte0 minor (§2.2)
foxRegStopReason = 0x1002 // reason the last charging session ended, see spec appendix 1 (§2.3)
foxRegStatus = 0x1003 // EVC status (§2.4)
foxRegCpStatus = 0x1004 // CP status (§2.5)
foxRegCableStatus = 0x1005 // CC status (§2.6)
foxRegPortTemp = 0x1006 // charging port temperature, 0.1°C, offset 50°C (§2.7)
foxRegAmbientTemp = 0x1007 // EVC environment temperature, 0.1°C, offset 50°C (§2.8)
foxRegVoltages = 0x1008 // A/B/C phase voltage, 3 registers, 0.1V (§2.9-§2.11)
foxRegCurrents = 0x100B // A/B/C phase current, 3 registers, 0.1A (§2.12-§2.14)
foxRegPower = 0x100E // active power, 0.1kW (§2.15)
foxRegLockStatus = 0x100F // electronic lock status (§2.16)
foxRegPhaseSequence = 0x1010 // current phase sequence, only meaningful with a phase switch box (§2.17)
foxRegMaxSupPower = 0x1011 // max supported power, 0.1kW (§2.18)
foxRegMinSupPower = 0x1012 // min supported power, 0.1kW (§2.19)
foxRegMaxSupCurrent = 0x1013 // max supported current per phase, 0.1A (§2.20)
foxRegMinSupCurrent = 0x1014 // min supported current per phase, 0.1A (§2.21)
foxRegAlarm = 0x1015 // system alarm, bit-coded, see spec appendix 3 (§2.22)
foxRegTotalEnergy = 0x1016 // internal meter reading, uint32, 0.1kWh; never resets (§2.23)
foxRegSessionEnergy = 0x1018 // energy of the current charge, uint32, 0.1kWh (§2.24)
foxRegFault = 0x101A // system fault, uint32, bit-coded, see spec appendix 2 (§2.25)
foxRegRFID = 0x101C // last RFID card, uint32 (§2.26)
foxRegModel = 0x101E // model code, 4 registers, ASCII (§2.27)
foxRegSerial = 0x1022 // serial number, 16 registers, ASCII (§2.28)
// read/write registers (write with 0x10)
foxRegWorkMode = 0x3000 // work mode (§2.29)
foxRegMaxCurrent = 0x3001 // max charging current, 0.1A (§2.30)
foxRegMaxPower = 0x3002 // max charging power, 0.1kW (§2.31)
foxRegChargeTime = 0x3003 // allowable charge time, minutes (§2.32)
foxRegChargeEnergy = 0x3004 // allowable charge energy, kWh (§2.33)
foxRegTimeValidity = 0x3005 // command validity window, seconds (§2.34)
foxRegDefaultCurrent = 0x3006 // fallback current when the EMS connection is lost, 0.1A (§2.35)
foxRegOtaStatus = 0x3007 // OTA status (§2.36)
foxRegOtaSize = 0x3008 // OTA firmware size, uint32 (§2.37)
foxRegAutoPhaseSwitch = 0x300A // single/three-phase automatic switching (§2.38)
foxRegSwitchInterval = 0x300B // min interval between phase switches, minutes (§2.39)
foxRegLockControl = 0x4000 // electronic lock control, write-only (§2.40)
foxRegSessionControl = 0x4001 // start/stop session, write-only (§2.41)
foxRegPhaseControl = 0x4002 // phase sequence switching, write-only (§2.42)
foxRegRestart = 0x4003 // restart, write-only (§2.43)
)
const (
foxSessionNoAction = 0 // session control values (§2.41)
foxSessionStart = 1
foxSessionStop = 2
foxTimeValidity = 60 // maximum command validity window in seconds (§2.34: 10-60s)
foxDefaultCurrent = 60 // 6.0A fallback current on EMS loss (§2.35: 6-32A)
foxMinSwitchInterval = 5 // minimum phase switching interval in minutes (§2.39: 5-30min)
// Without a phase-cutting box the charger derives the phase count from the power setpoint
// (§2.38): >= 4.2kW three-phase, >= 1.4kW single-phase, below that charging is paused.
// Setpoints are given in 0.1kW.
foxMinPower3p = 42 // 4.2kW, the minimum power setpoint for a 3p charger
foxMinPower1p = 14 // 1.4kW, the minimum power setpoint for a 1p or switchable charger
foxMaxPower1p = 73 // 7.3kW, the maximum power setpoint for a 1p charger
)
// foxStatus values of the EVC status register (§2.4).
const (
foxStatusIdle = 0 // no faults, car not connected
foxStatusConnect = 1 // car connected, waiting for the start command
foxStatusStart = 2 // start command received, waiting for the car
foxStatusCharging = 3 // charging
foxStatusPause = 4 // charging suspended
foxStatusFinish = 5 // charging finished
foxStatusFault = 6 // faulty, cannot charge
foxStatusReserved = 7 // reserved
foxStatusLocked = 8 // locked, no operations possible
foxStatusSwitching = 9 // undocumented: automatic phase switch in progress
)
func init() {
registry.AddCtx("foxess-evc", NewFoxESSEVCFromConfig)
}
// NewFoxESSEVCFromConfig creates a FoxESS EV charger from generic config
func NewFoxESSEVCFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
cc := struct {
modbus.TcpSettings `mapstructure:",squash"`
}{
TcpSettings: modbus.TcpSettings{
ID: 1,
},
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
return NewFoxESSEVC(ctx, cc.TcpSettings)
}
// NewFoxESSEVC creates a FoxESS EV charger
func NewFoxESSEVC(ctx context.Context, settings modbus.TcpSettings) (api.Charger, error) {
conn, err := settings.Connection(ctx)
if err != nil {
return nil, err
}
if !sponsor.IsAuthorized() {
return nil, api.ErrSponsorRequired
}
log := util.NewLogger("foxess-evc")
conn.Logger(log.TRACE)
wb := &FoxESSEVC{
Caps: implement.New(),
log: log,
conn: conn,
}
// device limits are model-specific and constant, so read them once (§2.18-§2.21)
minCurrent, err := wb.readUint16(foxRegMinSupCurrent)
if err != nil {
return nil, err
}
wb.minCurrent = float64(minCurrent) / 10
maxCurrent, err := wb.readUint16(foxRegMaxSupCurrent)
if err != nil {
return nil, err
}
wb.maxCurrent = float64(maxCurrent) / 10
if wb.minPower, err = wb.readUint16(foxRegMinSupPower); err != nil {
return nil, err
}
if wb.maxPower, err = wb.readUint16(foxRegMaxSupPower); err != nil {
return nil, err
}
if wb.minCurrent == 0 || wb.minCurrent > wb.maxCurrent {
return nil, fmt.Errorf("invalid current limits: %.1f/%.1fA", wb.minCurrent, wb.maxCurrent)
}
if wb.minPower == 0 || wb.minPower > wb.maxPower {
return nil, fmt.Errorf("invalid power limits: %d/%d", wb.minPower, wb.maxPower)
}
// derive the hardware phase count from the device limits
wb.phases = 1
if math.Round(float64(wb.maxPower)*100/(230*wb.maxCurrent)) >= 3 {
wb.phases = 3
}
if wb.phases == 3 {
autoSw, err := wb.readUint16(foxRegAutoPhaseSwitch)
if err != nil {
return nil, err
}
wb.switchable = autoSw > 0
}
if wb.switchable {
implement.Has(wb, implement.PhaseSwitcher(wb.phases1p3p))
implement.Has(wb, implement.PhaseGetter(wb.getPhases))
// keep the internal charge pause and switching protection interval as short as possible
if err := wb.writeReg(foxRegSwitchInterval, foxMinSwitchInterval); err != nil {
wb.log.WARN.Printf("switch interval: %v", err)
}
}
// seed the state from the charger
if wb.status, err = wb.readUint16(foxRegStatus); err != nil {
return nil, err
}
setpoint, err := wb.readSetpoint()
if err != nil {
return nil, err
}
if setpoint > 0 && wb.sessionActive(wb.status) {
wb.enabled = true
wb.current, wb.phases = wb.decodeSetpoint(setpoint)
}
// keep the charger from considering evcc offline; see heartbeat (§2.34).
// widening the window to its maximum keeps the heartbeat rate low- firmware ranges differ,
// so a rejected write is not fatal.
if err := wb.writeReg(foxRegTimeValidity, foxTimeValidity); err != nil {
wb.log.WARN.Printf("time validity: %v", err)
}
timeValidity, err := wb.readUint16(foxRegTimeValidity)
if err != nil {
return nil, err
}
if timeValidity == 0 {
return nil, fmt.Errorf("invalid time validity: %d", timeValidity)
}
go wb.heartbeat(ctx, time.Duration(timeValidity)*time.Second/2)
return wb, nil
}
// readUint16 reads a register as a uint16
func (wb *FoxESSEVC) readUint16(reg uint16) (uint16, error) {
b, err := wb.conn.ReadHoldingRegisters(reg, 1)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint16(b), nil
}
// readUint32 reads two consecutive registers as a big-endian uint32
func (wb *FoxESSEVC) readUint32(reg uint16) (uint32, error) {
b, err := wb.conn.ReadHoldingRegisters(reg, 2)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint32(b), nil
}
// readString reads consecutive registers as a zero-padded ASCII string
func (wb *FoxESSEVC) readString(reg, words uint16) (string, error) {
b, err := wb.conn.ReadHoldingRegisters(reg, words)
if err != nil {
return "", err
}
return bytesAsString(bytes.TrimRight(b, "\x00")), nil
}
// getPhaseValues returns 3 sequential register values scaled by divider
func (wb *FoxESSEVC) getPhaseValues(reg uint16, divider float64) (float64, float64, float64, error) {
b, err := wb.conn.ReadHoldingRegisters(reg, 3)
if err != nil {
return 0, 0, 0, err
}
var res [3]float64
for i := range res {
res[i] = float64(binary.BigEndian.Uint16(b[2*i:])) / divider
}
return res[0], res[1], res[2], nil
}
// writeReg writes a single read/write register (0x10)
func (wb *FoxESSEVC) writeReg(reg, val uint16) error {
b := make([]byte, 2)
binary.BigEndian.PutUint16(b, val)
_, err := wb.conn.WriteMultipleRegisters(reg, 1, b)
return err
}
// readSetpoint reads the power setpoint register and updates the cached value.
// Callers must hold mu.
func (wb *FoxESSEVC) readSetpoint() (uint16, error) {
val, err := wb.readUint16(foxRegMaxPower)
if err == nil {
wb.setpoint = val
}
return val, err
}
// sessionActive reports whether the given status belongs to a running charging session.
// Only then is the power setpoint in effect (§2.31) instead of being restored to the device
// maximum, i.e. only then does a non-zero setpoint mean the charger is enabled.
func (wb *FoxESSEVC) sessionActive(status uint16) bool {
switch status {
case foxStatusStart, foxStatusCharging, foxStatusPause, foxStatusSwitching:
return true
default:
return false
}
}
// powerLimits returns the power setpoint bounds for the given phase count.
// A charger doing its own 1p/3p switching picks the phase count from the setpoint alone (§2.38),
// so the setpoint must stay inside the band belonging to the requested phase count. Otherwise the
// charger silently switches phases behind evcc's back- and while its minimum switching interval
// (§2.39) blocks the switch, a three-phase setpoint is delivered on a single phase.
func (wb *FoxESSEVC) powerLimits(phases int) (uint16, uint16) {
lo, hi := wb.minPower, wb.maxPower
if wb.switchable {
if phases == 1 {
lo, hi = foxMinPower1p, foxMinPower3p-1
} else {
lo = foxMinPower3p
}
}
return lo, hi
}
// calcSetpoint converts the enable state and phase current into the power setpoint register value
func (wb *FoxESSEVC) calcSetpoint(enabled bool, current float64, phases int) uint16 {
if !enabled {
return 0
}
lo, hi := wb.powerLimits(phases)
power := 230 * float64(phases) * min(max(current, wb.minCurrent), wb.maxCurrent)
return min(max(uint16(math.Round(power/100)), lo), hi)
}
// decodeSetpoint converts a power setpoint register value back into the phase current and the
// phase count the charger derives from it
func (wb *FoxESSEVC) decodeSetpoint(setpoint uint16) (float64, int) {
phases := wb.phases
if wb.switchable {
switch {
case setpoint >= foxMinPower3p:
phases = 3
case setpoint >= foxMinPower1p:
phases = 1
}
}
return min(float64(setpoint)*100/(230*float64(phases)), wb.maxCurrent), phases
}
// applySetpoint writes the combined enable state and charging limit. Callers must hold mu.
func (wb *FoxESSEVC) applySetpoint(val uint16) error {
if err := wb.writeReg(foxRegMaxPower, val); err != nil {
return err
}
wb.setpoint = val
return nil
}
// heartbeat re-asserts the power setpoint. The charger honours the last EMS command only for the
// duration of the command validity window (foxRegTimeValidity, §2.34) and reverts to its max
// supported power once it expires, so the interval must be shorter than that window.
func (wb *FoxESSEVC) heartbeat(ctx context.Context, interval time.Duration) {
for tick := time.Tick(interval); ; {
select {
case <-tick:
case <-ctx.Done():
return
}
wb.mu.Lock()
err := wb.writeReg(foxRegMaxPower, wb.setpoint)
wb.mu.Unlock()
if err != nil {
wb.log.ERROR.Println("heartbeat:", err)
}
}
}
// Status implements the api.Charger interface
func (wb *FoxESSEVC) Status() (api.ChargeStatus, error) {
wb.mu.Lock()
defer wb.mu.Unlock()
s, err := wb.readUint16(foxRegStatus)
if err != nil {
return api.StatusNone, err
}
wb.status = s
switch s {
case foxStatusIdle:
return api.StatusA, nil
case foxStatusConnect, foxStatusStart, foxStatusPause, foxStatusSwitching, foxStatusFinish:
return api.StatusB, nil
case foxStatusCharging:
return api.StatusC, nil
default:
return api.StatusNone, fmt.Errorf("invalid status: %d", s)
}
}
var _ api.StatusReasoner = (*FoxESSEVC)(nil)
// StatusReason implements the api.StatusReasoner interface
func (wb *FoxESSEVC) StatusReason() (api.Reason, error) {
wb.mu.Lock()
defer wb.mu.Unlock()
// uses the status cached by Status(), which the loadpoint calls immediately before
switch wb.status {
case foxStatusConnect:
return api.ReasonWaitingForAuthorization, nil
case foxStatusFinish:
return api.ReasonDisconnectRequired, nil
default:
return api.ReasonUnknown, nil
}
}
// Enabled implements the api.Charger interface
func (wb *FoxESSEVC) Enabled() (bool, error) {
wb.mu.Lock()
defer wb.mu.Unlock()
val, err := wb.readSetpoint()
if err != nil {
return false, err
}
if val == 0 {
wb.enabled = false
}
return wb.enabled, nil
}
// Enable implements the api.Charger interface
func (wb *FoxESSEVC) Enable(enable bool) error {
wb.mu.Lock()
defer wb.mu.Unlock()
if err := wb.applySetpoint(wb.calcSetpoint(enable, wb.current, wb.phases)); err != nil {
return err
}
wb.enabled = enable
return nil
}
// MaxCurrent implements the api.Charger interface
func (wb *FoxESSEVC) MaxCurrent(current int64) error {
return wb.MaxCurrentMillis(float64(current))
}
var _ api.ChargerEx = (*FoxESSEVC)(nil)
// MaxCurrentMillis implements the api.ChargerEx interface
func (wb *FoxESSEVC) MaxCurrentMillis(current float64) error {
if current < wb.minCurrent {
return fmt.Errorf("invalid current: %.1fA", current)
}
wb.mu.Lock()
defer wb.mu.Unlock()
if err := wb.applySetpoint(wb.calcSetpoint(wb.enabled, current, wb.phases)); err != nil {
return err
}
wb.current = current
return nil
}
var _ api.CurrentLimiter = (*FoxESSEVC)(nil)
// GetMinMaxCurrent implements the api.CurrentLimiter interface
func (wb *FoxESSEVC) GetMinMaxCurrent() (float64, float64, error) {
wb.mu.Lock()
defer wb.mu.Unlock()
lo, hi := wb.powerLimits(wb.phases)
minCurrent, _ := wb.decodeSetpoint(lo)
maxCurrent, _ := wb.decodeSetpoint(hi)
return max(wb.minCurrent, minCurrent), maxCurrent, nil
}
var _ api.CurrentGetter = (*FoxESSEVC)(nil)
// GetMaxCurrent implements the api.CurrentGetter interface
func (wb *FoxESSEVC) GetMaxCurrent() (float64, error) {
wb.mu.Lock()
defer wb.mu.Unlock()
// outside an active session the setpoint may be restored to the max supported power (§2.31),
// which the loadpoint would adopt as the offered current
if !wb.sessionActive(wb.status) {
return 0, api.ErrNotAvailable
}
val, err := wb.readSetpoint()
if err != nil {
return 0, err
}
current, _ := wb.decodeSetpoint(val)
return current, nil
}
var _ api.Meter = (*FoxESSEVC)(nil)
// CurrentPower implements the api.Meter interface
func (wb *FoxESSEVC) CurrentPower() (float64, error) {
val, err := wb.readUint16(foxRegPower)
if err != nil {
return 0, err
}
return float64(val) * 100, nil
}
var _ api.MeterEnergy = (*FoxESSEVC)(nil)
// TotalEnergy implements the api.MeterEnergy interface
func (wb *FoxESSEVC) TotalEnergy() (float64, error) {
energy, err := wb.readUint32(foxRegTotalEnergy)
if err != nil {
return 0, err
}
return float64(energy) / 10, nil
}
var _ api.ChargeRater = (*FoxESSEVC)(nil)
// ChargedEnergy implements the api.ChargeRater interface
func (wb *FoxESSEVC) ChargedEnergy() (float64, error) {
energy, err := wb.readUint32(foxRegSessionEnergy)
if err != nil {
return 0, err
}
return float64(energy) / 10, nil
}
var _ api.PhaseCurrents = (*FoxESSEVC)(nil)
// Currents implements the api.PhaseCurrents interface
func (wb *FoxESSEVC) Currents() (float64, float64, float64, error) {
return wb.getPhaseValues(foxRegCurrents, 10)
}
var _ api.PhaseVoltages = (*FoxESSEVC)(nil)
// Voltages implements the api.PhaseVoltages interface
func (wb *FoxESSEVC) Voltages() (float64, float64, float64, error) {
return wb.getPhaseValues(foxRegVoltages, 10)
}
var _ api.Identifier = (*FoxESSEVC)(nil)
// Identify implements the api.Identifier interface
func (wb *FoxESSEVC) Identify() (string, error) {
id, err := wb.readUint32(foxRegRFID)
if err != nil {
return "", err
}
if id == 0 {
return "", nil
}
return fmt.Sprintf("%08X", id), nil
}
// phases1p3p implements the api.PhaseSwitcher interface
func (wb *FoxESSEVC) phases1p3p(phases int) error {
wb.mu.Lock()
defer wb.mu.Unlock()
// the setpoint band depends on the phase count, so it needs to be rewritten right away-
// the loadpoint does not necessarily re-issue MaxCurrent after a phase switch
if err := wb.applySetpoint(wb.calcSetpoint(wb.enabled, wb.current, phases)); err != nil {
return err
}
wb.phases = phases
return nil
}
// getPhases implements the api.PhaseGetter interface
func (wb *FoxESSEVC) getPhases() (int, error) {
wb.mu.Lock()
defer wb.mu.Unlock()
// Since the setpoint is kept inside the band of the requested phase count, this is the count
// the charger will settle on- its minimum switching interval (§2.39) may delay the actual switch.
_, phases := wb.decodeSetpoint(wb.setpoint)
return phases, nil
}
var _ api.Diagnosis = (*FoxESSEVC)(nil)
// Diagnose implements the api.Diagnosis interface
func (wb *FoxESSEVC) Diagnose() {
if val, err := wb.readUint16(foxRegSwVersion); err == nil {
fmt.Printf("\tSoftware version:\t%d.%d\n", val>>8, val&0xFF)
}
if s, err := wb.readString(foxRegModel, 4); err == nil {
fmt.Printf("\tModel:\t%s\n", s)
}
if s, err := wb.readString(foxRegSerial, 16); err == nil {
fmt.Printf("\tSerial:\t%s\n", s)
}
fmt.Printf("\tMax. Phases:\t%dp\n", wb.phases)
fmt.Printf("\tAuto phase switching:\t%v\n", wb.switchable)
fmt.Printf("\tPower range:\t%.1f-%.1fkW\n", float64(wb.minPower)/10, float64(wb.maxPower)/10)
fmt.Printf("\tCurrent range:\t%.1f-%.1fA\n", wb.minCurrent, wb.maxCurrent)
if val, err := wb.readUint16(foxRegWorkMode); err == nil {
fmt.Printf("\tWork mode:\t%d\n", val)
}
if val, err := wb.readUint16(foxRegStopReason); err == nil {
fmt.Printf("\tStop reason:\t%d\n", val) // see spec appendix 1
}
}

401
charger/foxess-evc_test.go Normal file
View file

@ -0,0 +1,401 @@
package charger
import (
"context"
"net"
"sync"
"testing"
"github.com/andig/mbserver"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/implement"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/modbus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type foxWrite struct {
funcCode uint8
addr uint16
args []uint16
}
// foxHandler mocks the charger's holding register space
type foxHandler struct {
mbserver.RequestHandler
mu sync.Mutex
regs map[uint16]uint16
writes []foxWrite
}
func (h *foxHandler) HandleHoldingRegisters(req *mbserver.HoldingRegistersRequest) ([]uint16, error) {
h.mu.Lock()
defer h.mu.Unlock()
if req.IsWrite {
h.writes = append(h.writes, foxWrite{req.WriteFuncCode, req.Addr, req.Args})
for i, v := range req.Args {
h.regs[req.Addr+uint16(i)] = v
}
return req.Args, nil
}
res := make([]uint16, 0, req.Quantity)
for i := range req.Quantity {
v, ok := h.regs[req.Addr+i]
if !ok {
return nil, mbserver.ErrIllegalDataAddress
}
res = append(res, v)
}
return res, nil
}
// shared mock server: mbserver.Stop() races its accept goroutine, so the server
// is started once and never stopped; handler state is reset per test
var (
foxOnce sync.Once
foxURI string
foxSrvH = &foxHandler{RequestHandler: new(mbserver.DummyHandler)}
)
// foxTestCharger returns a 22kW charger with auto phase switching connected to the mock server
func foxTestCharger(t *testing.T, regs map[uint16]uint16) (*FoxESSEVC, *foxHandler) {
t.Helper()
foxOnce.Do(func() {
l, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
srv, err := mbserver.New(foxSrvH)
require.NoError(t, err)
require.NoError(t, srv.Start(l))
foxURI = l.Addr().String()
})
foxSrvH.regs = regs
foxSrvH.writes = nil
conn, err := modbus.NewConnection(context.Background(), foxURI, "", "", 0, modbus.Tcp, 1)
require.NoError(t, err)
wb := &FoxESSEVC{
Caps: implement.New(),
log: util.NewLogger("foxess-evc"),
conn: conn,
phases: 3,
switchable: true,
minPower: 42,
maxPower: 220,
minCurrent: 6,
maxCurrent: 32,
}
return wb, foxSrvH
}
// fox22kW returns a 22kW charger with auto phase switching
func fox22kW(switchable bool) *FoxESSEVC {
return &FoxESSEVC{
phases: 3, switchable: switchable,
minPower: 42, maxPower: 220, minCurrent: 6, maxCurrent: 32,
}
}
// fox11kW returns an 11kW charger, rated 16A per phase
func fox11kW(switchable bool) *FoxESSEVC {
return &FoxESSEVC{
phases: 3, switchable: switchable,
minPower: foxMinPower3p, maxPower: 110, minCurrent: 6, maxCurrent: 16,
}
}
// fox7kW returns a single-phase 7.3kW charger
func fox7kW() *FoxESSEVC {
return &FoxESSEVC{
phases: 1,
minPower: foxMinPower1p, maxPower: foxMaxPower1p, minCurrent: 6, maxCurrent: 32,
}
}
func TestFoxESSEVCSetpoint(t *testing.T) {
tc := []struct {
name string
wb *FoxESSEVC
enabled bool
current float64
phases int
expected uint16
}{
{"disabled", fox22kW(true), false, 16, 3, 0},
// 3 x 230V x 6A = 4.14kW rounds to 41, below the charger's 4.2kW three-phase
// threshold - it would silently drop to single phase (§2.38)
{"3p min", fox22kW(true), true, 6, 3, foxMinPower3p},
{"3p below min current", fox22kW(true), true, 4, 3, foxMinPower3p},
{"3p nominal", fox22kW(true), true, 16, 3, 110},
{"3p max", fox22kW(true), true, 32, 3, 220},
{"1p min", fox22kW(true), true, 6, 1, foxMinPower1p},
{"1p at threshold", fox22kW(true), true, 18, 1, foxMinPower3p - 1},
// without the cap the charger would deliver 7.4kW on a single phase
{"1p max capped", fox22kW(true), true, 32, 1, foxMinPower3p - 1},
// a charger without auto switching is bound by its device limits only
{"fixed 3p min", fox22kW(false), true, 6, 3, foxMinPower3p},
{"fixed 3p max", fox22kW(false), true, 32, 3, 220},
{"1p charger min", fox7kW(), true, 6, 1, foxMinPower1p},
{"1p charger max", fox7kW(), true, 32, 1, 73},
}
for _, tc := range tc {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, tc.wb.calcSetpoint(tc.enabled, tc.current, tc.phases))
})
}
}
func TestFoxESSEVCMinMaxCurrent(t *testing.T) {
tc := []struct {
name string
wb *FoxESSEVC
phases int
min, max float64
}{
{"3p switchable", fox22kW(true), 3, 6.087, 31.884},
{"1p switchable", fox22kW(true), 1, 6.087, 17.826},
{"3p fixed", fox22kW(false), 3, 6.087, 31.884},
{"1p charger", fox7kW(), 1, 6.087, 31.739},
{"11kW 3p", fox11kW(true), 3, 6.087, 15.942},
// the 4.1kW single-phase ceiling would be 17.8A, beyond the 16A the device is rated for
{"11kW 1p", fox11kW(true), 1, 6.087, 16},
}
for _, tc := range tc {
t.Run(tc.name, func(t *testing.T) {
tc.wb.phases = tc.phases
minCurrent, maxCurrent, err := tc.wb.GetMinMaxCurrent()
require.NoError(t, err)
assert.InDelta(t, tc.min, minCurrent, 0.001)
assert.InDelta(t, tc.max, maxCurrent, 0.001)
// the minimum current must produce a setpoint the charger accepts for that phase count
lo, _ := tc.wb.powerLimits(tc.phases)
assert.GreaterOrEqual(t, tc.wb.calcSetpoint(true, minCurrent, tc.phases), lo)
})
}
}
func TestFoxESSEVCPhases(t *testing.T) {
wb := fox22kW(true)
wb.phases = 1
tc := []struct {
setpoint uint16
phases int
}{
// below the single-phase threshold charging is paused, the tracked count applies
{0, wb.phases},
{foxMinPower1p - 1, wb.phases},
{foxMinPower1p, 1},
{foxMinPower3p - 1, 1},
{foxMinPower3p, 3},
{220, 3},
}
for _, tc := range tc {
wb.setpoint = tc.setpoint
phases, err := wb.getPhases()
require.NoError(t, err)
assert.Equal(t, tc.phases, phases, "setpoint %d", tc.setpoint)
}
}
func TestFoxESSEVCStatus(t *testing.T) {
tc := []struct {
state uint16
status api.ChargeStatus
err bool
}{
{foxStatusIdle, api.StatusA, false},
{foxStatusConnect, api.StatusB, false},
{foxStatusStart, api.StatusB, false},
{foxStatusCharging, api.StatusC, false},
{foxStatusPause, api.StatusB, false},
{foxStatusFinish, api.StatusB, false},
{foxStatusFault, api.StatusNone, true},
{7, api.StatusNone, true}, // reserved
{foxStatusLocked, api.StatusNone, true},
{foxStatusSwitching, api.StatusB, false},
}
for _, tc := range tc {
wb, _ := foxTestCharger(t, map[uint16]uint16{
foxRegStatus: tc.state,
foxRegMaxPower: 110,
})
status, err := wb.Status()
if tc.err {
assert.Error(t, err, "state %d", tc.state)
} else {
assert.NoError(t, err, "state %d", tc.state)
}
assert.Equal(t, tc.status, status, "state %d", tc.state)
// StatusReason and GetMaxCurrent rely on the cached raw status
assert.Equal(t, tc.state, wb.status, "state %d", tc.state)
}
}
func TestFoxESSEVCSessionActive(t *testing.T) {
wb := fox22kW(true)
tc := []struct {
state uint16
active bool
}{
{foxStatusIdle, false},
{foxStatusConnect, false}, // not started, setpoint may still hold the restored maximum
{foxStatusStart, true},
{foxStatusCharging, true},
{foxStatusPause, true}, // suspended by the car or by a zeroed setpoint
{foxStatusFinish, false},
{foxStatusFault, false},
{foxStatusLocked, false},
{foxStatusSwitching, true},
}
for _, tc := range tc {
assert.Equal(t, tc.active, wb.sessionActive(tc.state), "state %d", tc.state)
}
}
func TestFoxESSEVCStatusReason(t *testing.T) {
wb := fox22kW(true)
wb.status = foxStatusConnect
reason, err := wb.StatusReason()
require.NoError(t, err)
assert.Equal(t, api.ReasonWaitingForAuthorization, reason)
wb.status = foxStatusFinish
reason, err = wb.StatusReason()
require.NoError(t, err)
assert.Equal(t, api.ReasonDisconnectRequired, reason)
wb.status = foxStatusCharging
reason, err = wb.StatusReason()
require.NoError(t, err)
assert.Equal(t, api.ReasonUnknown, reason)
}
func TestFoxESSEVCEnable(t *testing.T) {
wb, h := foxTestCharger(t, map[uint16]uint16{
foxRegStatus: foxStatusCharging,
foxRegMaxPower: 0,
})
wb.current = 16
require.NoError(t, wb.Enable(true))
require.Len(t, h.writes, 1)
assert.Equal(t, uint16(foxRegMaxPower), h.writes[0].addr)
assert.Equal(t, []uint16{110}, h.writes[0].args)
enabled, err := wb.Enabled()
require.NoError(t, err)
assert.True(t, enabled)
// an unchanged limit is rewritten as well, it keeps the validity window alive (§2.34)
require.NoError(t, wb.MaxCurrentMillis(16))
require.Len(t, h.writes, 2)
assert.Equal(t, []uint16{110}, h.writes[1].args)
require.NoError(t, wb.Enable(false))
require.Len(t, h.writes, 3)
assert.Equal(t, []uint16{0}, h.writes[2].args)
enabled, err = wb.Enabled()
require.NoError(t, err)
assert.False(t, enabled)
}
func TestFoxESSEVCPhaseSwitch(t *testing.T) {
wb, h := foxTestCharger(t, map[uint16]uint16{
foxRegStatus: foxStatusCharging,
foxRegMaxPower: 110,
})
wb.current = 16
wb.enabled = true
// switching to single phase must rewrite the setpoint right away- 230V x 16A stays below
// the three-phase threshold, so the charger settles on single phase
require.NoError(t, wb.phases1p3p(1))
require.Len(t, h.writes, 1)
assert.Equal(t, []uint16{37}, h.writes[0].args)
assert.Equal(t, 1, wb.phases)
phases, err := wb.getPhases()
require.NoError(t, err)
assert.Equal(t, 1, phases)
}
// TestFoxESSEVCConcurrent exercises the tracked state from several goroutines, as the device
// status API does while the loadpoint and the heartbeat are running. Meaningful under -race.
func TestFoxESSEVCConcurrent(t *testing.T) {
wb, _ := foxTestCharger(t, map[uint16]uint16{
foxRegStatus: foxStatusCharging,
foxRegMaxPower: 110,
})
wb.current = 16
wb.enabled = true
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
for range 20 {
_, _ = wb.Status()
_, _ = wb.StatusReason()
_, _ = wb.Enabled()
_, _ = wb.GetMaxCurrent()
_, _, _ = wb.GetMinMaxCurrent()
_, _ = wb.getPhases()
_ = wb.MaxCurrentMillis(16)
_ = wb.phases1p3p(1)
_ = wb.Enable(true)
// heartbeat
wb.mu.Lock()
_ = wb.writeReg(foxRegMaxPower, wb.setpoint)
wb.mu.Unlock()
}
})
}
wg.Wait()
}
func TestFoxESSEVCGetMaxCurrent(t *testing.T) {
wb, _ := foxTestCharger(t, map[uint16]uint16{
foxRegMaxPower: 110,
})
// outside an active session the charger reports its restored max supported power (§2.31)
wb.status = foxStatusFinish
_, err := wb.GetMaxCurrent()
assert.ErrorIs(t, err, api.ErrNotAvailable)
wb.status = foxStatusCharging
current, err := wb.GetMaxCurrent()
require.NoError(t, err)
assert.InDelta(t, 15.942, current, 0.001)
// the setpoint encodes the phase count, so a three-phase setpoint read back while the tracked
// count is 1p resolves to 3p rather than the impossible 11kW/230V = 47.8A
wb.phases = 1
current, err = wb.GetMaxCurrent()
require.NoError(t, err)
assert.InDelta(t, 15.942, current, 0.001)
}