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 } // // removed since broken, see https://github.com/evcc-io/evcc/pull/32371 // var _ api.ChargeRater = (*FoxESSEVC)(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 nil, err } if id == 0 { return nil, nil } return []string{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 } }