666 lines
20 KiB
Go
666 lines
20 KiB
Go
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
|
|
}
|
|
}
|