Add FoxESS EV charger (Modbus TCP) (#31412)
Some checks failed
Some checks failed
Co-authored-by: premultiply <4681172+premultiply@users.noreply.github.com>
This commit is contained in:
parent
05676e7c68
commit
c727537702
4 changed files with 1094 additions and 1 deletions
674
charger/foxess-evc.go
Normal file
674
charger/foxess-evc.go
Normal 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
401
charger/foxess-evc_test.go
Normal 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)
|
||||
}
|
||||
18
templates/definition/charger/foxess-evc.yaml
Normal file
18
templates/definition/charger/foxess-evc.yaml
Normal file
|
|
@ -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" . }}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue