diff --git a/charger/foxess-evc.go b/charger/foxess-evc.go new file mode 100644 index 000000000..cb9dd8d44 --- /dev/null +++ b/charger/foxess-evc.go @@ -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 + } +} diff --git a/charger/foxess-evc_test.go b/charger/foxess-evc_test.go new file mode 100644 index 000000000..68c836b24 --- /dev/null +++ b/charger/foxess-evc_test.go @@ -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) +} diff --git a/templates/definition/charger/foxess-evc.yaml b/templates/definition/charger/foxess-evc.yaml new file mode 100644 index 000000000..030d330af --- /dev/null +++ b/templates/definition/charger/foxess-evc.yaml @@ -0,0 +1,18 @@ +template: foxess-evc +products: + - brand: FoxESS + description: + generic: AC EV Charger (Modbus) +capabilities: ["mA", "rfid", "meter", "1p3p"] +requirements: + description: + de: In der Fox Switch App muss der Arbeitsmodus auf "Modbus TCP" gestellt werden. Wie eine Ladesession beginnt, bestimmt der in der Wallbox eingestellte Betriebsmodus - entweder per App bzw. RFID-Karte oder automatisch beim Anstecken starten. + en: The work mode must be set to "Modbus TCP" in the Fox Switch app. How a session starts depends on the charger's own operating mode - start either via app or RFID card, or automatically on plug-in. + evcc: ["sponsorship"] +params: + - name: modbus + choice: ["tcpip"] + id: 1 +render: | + type: foxess-evc + {{- include "modbus" . }} diff --git a/templates/definition/charger/ocpp-foxess.yaml b/templates/definition/charger/ocpp-foxess.yaml index 7d3d4e5c7..4634d7b94 100644 --- a/templates/definition/charger/ocpp-foxess.yaml +++ b/templates/definition/charger/ocpp-foxess.yaml @@ -2,7 +2,7 @@ template: ocpp-foxess products: - brand: FoxESS description: - generic: AC EV Charger + generic: AC EV Charger (OCPP) capabilities: ["rfid", "dim"] requirements: evcc: ["sponsorship", "skiptest"]