Add Sigenergy EVDC charger (#32023)
Co-authored-by: premultiply <4681172+premultiply@users.noreply.github.com>
This commit is contained in:
parent
c8517b0237
commit
cd9812ef14
3 changed files with 726 additions and 0 deletions
375
charger/sigenergy-evdc.go
Normal file
375
charger/sigenergy-evdc.go
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/modbus"
|
||||
"github.com/evcc-io/evcc/util/sponsor"
|
||||
"github.com/volkszaehler/mbmd/encoding"
|
||||
)
|
||||
|
||||
// SigenergyEVDC is the Sigenergy EVDC DC charger module attached to a
|
||||
// SigenStor/Sigen Hybrid inverter. All registers live in the hybrid
|
||||
// inverter's Modbus slave address space; the EVDC has no own slave ID.
|
||||
type SigenergyEVDC struct {
|
||||
conn *modbus.Connection
|
||||
enabled bool
|
||||
ratedPower uint32 // W
|
||||
inputG func() ([]byte, error)
|
||||
}
|
||||
|
||||
const (
|
||||
// input registers (FC04), all on the hybrid inverter's slave ID
|
||||
evdcBase = 31500 // start of the bulk-read block
|
||||
evdcRegVehicleVoltage = 31500 // U16, 0.1 V
|
||||
evdcRegChargingCurrent = 31501 // U16, 0.1 A
|
||||
evdcRegOutputPower = 31502 // S32, W (assumed negative while discharging)
|
||||
evdcRegVehicleSoc = 31504 // U16, 0.1 %
|
||||
evdcRegSessionEnergy = 31505 // U32, 10 Wh, single session
|
||||
evdcRegSessionDuration = 31507 // U32, s, single session
|
||||
evdcRegRunningState = 31513 // U16
|
||||
evdcRegDischargeCurrent = 31514 // U16, 0.1 A
|
||||
evdcRegTotalEnergy = 31519 // U32, 10 Wh
|
||||
evdcRegTotalDischargeEnergy = 31521 // U32, 10 Wh
|
||||
evdcRegRatedPower = 31523 // U32, W
|
||||
evdcRegRatedDischargePower = 31525 // U32, W
|
||||
// 31515/31517 (session discharge energy/duration, U32) exist but are unused
|
||||
// 31509/31511 inside the bulk-read block are inverter PV registers — not EVDC data
|
||||
|
||||
// holding registers
|
||||
evdcRegStartStop = 41000 // U16, WO, single FC06 write only (41001 is reserved): 0 = start, 1 = stop
|
||||
evdcRegPowerLimit = 41002 // U32, W, RW, FC10; clamped to [evdcMinPower, rated] — never write 0!
|
||||
evdcRegDischargeLimit = 41004 // U32, W, RW; discharge control is out of scope
|
||||
|
||||
evdcInputLen = 23 // registers 31500-31522 in a single FC04 read
|
||||
|
||||
// minimum setpoint
|
||||
evdcMinPower = 500 // 1A@500V per DC CCS Power Classes
|
||||
|
||||
evdcMinCurrent = 1.0 // A
|
||||
|
||||
// evcc current setpoints are converted using the 3-phase AC convention
|
||||
evdcPowerPerAmp = 230 * 3 // W/A
|
||||
)
|
||||
|
||||
// running states of evdcRegRunningState
|
||||
const (
|
||||
evdcStateIdle = 0x00
|
||||
evdcStateOccupied = 0x01 // gun plugged in but not detected
|
||||
evdcStatePreparing = 0x02 // establishing communication
|
||||
evdcStateCharging = 0x03
|
||||
evdcStateFault = 0x04
|
||||
evdcStateScheduled = 0x05
|
||||
evdcStateEnded = 0x06
|
||||
evdcStateUnavailable = 0x07 // under maintenance
|
||||
evdcStateDischarging = 0x08
|
||||
evdcStateAlarm = 0x09
|
||||
evdcStateInsulation = 0x0A // insulation detection in progress
|
||||
)
|
||||
|
||||
var evdcStateNames = map[uint16]string{
|
||||
evdcStateIdle: "Idle",
|
||||
evdcStateOccupied: "Occupied",
|
||||
evdcStatePreparing: "Preparing",
|
||||
evdcStateCharging: "Charging",
|
||||
evdcStateFault: "Fault",
|
||||
evdcStateScheduled: "Scheduled",
|
||||
evdcStateEnded: "Ended",
|
||||
evdcStateUnavailable: "Unavailable",
|
||||
evdcStateDischarging: "Discharging",
|
||||
evdcStateAlarm: "Alarm",
|
||||
evdcStateInsulation: "Insulation detection",
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.AddCtx("sigenergy-evdc", NewSigenergyEVDCFromConfig)
|
||||
}
|
||||
|
||||
// NewSigenergyEVDCFromConfig creates a new Sigenergy EVDC ModbusTCP charger
|
||||
func NewSigenergyEVDCFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
|
||||
cc := modbus.TcpSettings{
|
||||
ID: 1,
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewSigenergyEVDC(ctx, cc.URI, cc.ID)
|
||||
}
|
||||
|
||||
// NewSigenergyEVDC creates a new charger
|
||||
func NewSigenergyEVDC(ctx context.Context, uri string, slaveID uint8) (*SigenergyEVDC, error) {
|
||||
conn, err := modbus.NewConnection(ctx, uri, "", "", 0, modbus.Tcp, slaveID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !sponsor.IsAuthorized() {
|
||||
return nil, api.ErrSponsorRequired
|
||||
}
|
||||
|
||||
log := util.NewLogger("sigenergy-evdc")
|
||||
conn.Logger(log.TRACE)
|
||||
|
||||
wb := newSigenergyEVDC(conn)
|
||||
|
||||
// probe rated power: verifies DC charger presence and V2.9 firmware, value clamps setpoints
|
||||
b, err := conn.ReadInputRegisters(evdcRegRatedPower, 2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no DC charger present or firmware too old (requires Modbus protocol V2.9): %w", err)
|
||||
}
|
||||
wb.ratedPower = encoding.Uint32(b)
|
||||
|
||||
// seed enabled state so evcc restarts mid-session report the true state
|
||||
b, err = conn.ReadInputRegisters(evdcRegRunningState, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wb.enabled = encoding.Uint16(b) == evdcStateCharging
|
||||
|
||||
return wb, nil
|
||||
}
|
||||
|
||||
// newSigenergyEVDC wires the struct without sponsor gate and device probe (also used by tests)
|
||||
func newSigenergyEVDC(conn *modbus.Connection) *SigenergyEVDC {
|
||||
wb := &SigenergyEVDC{
|
||||
conn: conn,
|
||||
}
|
||||
|
||||
// all cyclic values come from a single cached bulk read to respect the
|
||||
// device's documented 1000ms minimum request period
|
||||
wb.inputG = util.Cached(func() ([]byte, error) {
|
||||
return wb.conn.ReadInputRegisters(evdcBase, evdcInputLen)
|
||||
}, time.Second)
|
||||
|
||||
return wb
|
||||
}
|
||||
|
||||
// evdcInput returns the bytes of the given register within the cached bulk read
|
||||
func evdcInput(b []byte, reg uint16, n int) []byte {
|
||||
off := 2 * int(reg-evdcBase)
|
||||
return b[off : off+n]
|
||||
}
|
||||
|
||||
// Status implements the api.Charger interface
|
||||
func (wb *SigenergyEVDC) Status() (api.ChargeStatus, error) {
|
||||
b, err := wb.inputG()
|
||||
if err != nil {
|
||||
return api.StatusNone, err
|
||||
}
|
||||
|
||||
switch state := encoding.Uint16(evdcInput(b, evdcRegRunningState, 2)); state {
|
||||
case evdcStateIdle:
|
||||
return api.StatusA, nil
|
||||
case evdcStateOccupied, evdcStatePreparing, evdcStateScheduled, evdcStateEnded, evdcStateInsulation:
|
||||
return api.StatusB, nil
|
||||
case evdcStateCharging:
|
||||
wb.enabled = true
|
||||
return api.StatusC, nil
|
||||
case evdcStateDischarging:
|
||||
return api.StatusC, nil
|
||||
case evdcStateFault, evdcStateUnavailable, evdcStateAlarm:
|
||||
return api.StatusNone, fmt.Errorf("device state: %s", evdcStateNames[state])
|
||||
default:
|
||||
return api.StatusNone, fmt.Errorf("invalid status: %d", state)
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled implements the api.Charger interface
|
||||
func (wb *SigenergyEVDC) Enabled() (bool, error) {
|
||||
return wb.enabled, nil
|
||||
}
|
||||
|
||||
// Enable implements the api.Charger interface
|
||||
func (wb *SigenergyEVDC) Enable(enable bool) error {
|
||||
var v uint16
|
||||
if !enable {
|
||||
v = 1
|
||||
}
|
||||
|
||||
// single FC06 write only — 41001 is a reserved register
|
||||
_, err := wb.conn.WriteSingleRegister(evdcRegStartStop, v)
|
||||
if err == nil {
|
||||
wb.enabled = enable
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// MaxCurrent implements the api.Charger interface
|
||||
func (wb *SigenergyEVDC) MaxCurrent(current int64) error {
|
||||
return wb.MaxCurrentMillis(float64(current))
|
||||
}
|
||||
|
||||
var _ api.ChargerEx = (*SigenergyEVDC)(nil)
|
||||
|
||||
// MaxCurrentMillis implements the api.ChargerEx interface
|
||||
func (wb *SigenergyEVDC) MaxCurrentMillis(current float64) error {
|
||||
if current < evdcMinCurrent {
|
||||
return fmt.Errorf("invalid current %.3g", current)
|
||||
}
|
||||
|
||||
power := min(max(uint32(current*evdcPowerPerAmp), evdcMinPower), wb.ratedPower)
|
||||
|
||||
b := make([]byte, 4)
|
||||
encoding.PutUint32(b, power)
|
||||
|
||||
_, err := wb.conn.WriteMultipleRegisters(evdcRegPowerLimit, 2, b)
|
||||
return err
|
||||
}
|
||||
|
||||
var _ api.CurrentLimiter = (*SigenergyEVDC)(nil)
|
||||
|
||||
// GetMinMaxCurrent implements the api.CurrentLimiter interface
|
||||
func (wb *SigenergyEVDC) GetMinMaxCurrent() (float64, float64, error) {
|
||||
return evdcMinCurrent, float64(wb.ratedPower) / evdcPowerPerAmp, nil
|
||||
}
|
||||
|
||||
var _ api.Meter = (*SigenergyEVDC)(nil)
|
||||
|
||||
// CurrentPower implements the api.Meter interface
|
||||
func (wb *SigenergyEVDC) CurrentPower() (float64, error) {
|
||||
b, err := wb.inputG()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// S32, negative while discharging (to be confirmed on hardware, see spec)
|
||||
return float64(encoding.Int32(evdcInput(b, evdcRegOutputPower, 4))), nil
|
||||
}
|
||||
|
||||
var _ api.MeterEnergy = (*SigenergyEVDC)(nil)
|
||||
|
||||
// TotalEnergy implements the api.MeterEnergy interface
|
||||
func (wb *SigenergyEVDC) TotalEnergy() (float64, error) {
|
||||
b, err := wb.inputG()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return float64(encoding.Uint32(evdcInput(b, evdcRegTotalEnergy, 4))) / 100, nil
|
||||
}
|
||||
|
||||
var _ api.MeterReturnEnergy = (*SigenergyEVDC)(nil)
|
||||
|
||||
// ReturnEnergy implements the api.MeterReturnEnergy interface
|
||||
func (wb *SigenergyEVDC) ReturnEnergy() (float64, error) {
|
||||
b, err := wb.inputG()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return float64(encoding.Uint32(evdcInput(b, evdcRegTotalDischargeEnergy, 4))) / 100, nil
|
||||
}
|
||||
|
||||
var _ api.ChargeRater = (*SigenergyEVDC)(nil)
|
||||
|
||||
// ChargedEnergy implements the api.ChargeRater interface
|
||||
func (wb *SigenergyEVDC) ChargedEnergy() (float64, error) {
|
||||
b, err := wb.inputG()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return float64(encoding.Uint32(evdcInput(b, evdcRegSessionEnergy, 4))) / 100, nil
|
||||
}
|
||||
|
||||
var _ api.ChargeTimer = (*SigenergyEVDC)(nil)
|
||||
|
||||
// ChargeDuration implements the api.ChargeTimer interface
|
||||
func (wb *SigenergyEVDC) ChargeDuration() (time.Duration, error) {
|
||||
b, err := wb.inputG()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return time.Duration(encoding.Uint32(evdcInput(b, evdcRegSessionDuration, 4))) * time.Second, nil
|
||||
}
|
||||
|
||||
var _ api.Battery = (*SigenergyEVDC)(nil)
|
||||
|
||||
// Soc implements the api.Battery interface
|
||||
func (wb *SigenergyEVDC) Soc() (float64, error) {
|
||||
b, err := wb.inputG()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if soc := float64(encoding.Uint16(evdcInput(b, evdcRegVehicleSoc, 2))) / 10; soc > 0 {
|
||||
return soc, nil
|
||||
}
|
||||
|
||||
// no vehicle connected or no BMS data
|
||||
return 0, api.ErrNotAvailable
|
||||
}
|
||||
|
||||
var _ api.Diagnosis = (*SigenergyEVDC)(nil)
|
||||
|
||||
// Diagnose implements the api.Diagnosis interface
|
||||
func (wb *SigenergyEVDC) Diagnose() {
|
||||
if b, err := wb.conn.ReadInputRegisters(evdcRegRunningState, 1); err == nil {
|
||||
state := encoding.Uint16(b)
|
||||
name := evdcStateNames[state]
|
||||
if name == "" {
|
||||
name = "Unknown"
|
||||
}
|
||||
fmt.Printf("\tRunning state:\t%d (%s)\n", state, name)
|
||||
}
|
||||
if b, err := wb.conn.ReadInputRegisters(evdcRegVehicleVoltage, 1); err == nil {
|
||||
fmt.Printf("\tVehicle voltage:\t%.1f V\n", float64(encoding.Uint16(b))/10)
|
||||
}
|
||||
if b, err := wb.conn.ReadInputRegisters(evdcRegChargingCurrent, 1); err == nil {
|
||||
fmt.Printf("\tCharging current:\t%.1f A\n", float64(encoding.Uint16(b))/10)
|
||||
}
|
||||
if b, err := wb.conn.ReadInputRegisters(evdcRegDischargeCurrent, 1); err == nil {
|
||||
fmt.Printf("\tDischarging current:\t%.1f A\n", float64(encoding.Uint16(b))/10)
|
||||
}
|
||||
if b, err := wb.conn.ReadInputRegisters(evdcRegTotalDischargeEnergy, 2); err == nil {
|
||||
fmt.Printf("\tTotal discharged:\t%.2f kWh\n", float64(encoding.Uint32(b))/100)
|
||||
}
|
||||
fmt.Printf("\tRated power:\t%d W\n", wb.ratedPower)
|
||||
if b, err := wb.conn.ReadInputRegisters(evdcRegRatedDischargePower, 2); err == nil {
|
||||
fmt.Printf("\tRated discharge power:\t%d W\n", encoding.Uint32(b))
|
||||
}
|
||||
// power limit readback: FC04 per spec text, FC03 field-proven fallback
|
||||
if b, err := wb.conn.ReadInputRegisters(evdcRegPowerLimit, 2); err == nil {
|
||||
fmt.Printf("\tPower limit:\t%d W\n", encoding.Uint32(b))
|
||||
} else if b, err := wb.conn.ReadHoldingRegisters(evdcRegPowerLimit, 2); err == nil {
|
||||
fmt.Printf("\tPower limit (FC03):\t%d W\n", encoding.Uint32(b))
|
||||
}
|
||||
if b, err := wb.conn.ReadInputRegisters(evdcRegDischargeLimit, 2); err == nil {
|
||||
fmt.Printf("\tDischarge limit:\t%d W\n", encoding.Uint32(b))
|
||||
}
|
||||
}
|
||||
|
||||
var _ api.Resurrector = (*SigenergyEVDC)(nil)
|
||||
|
||||
// WakeUp implements the api.Resurrector interface
|
||||
func (wb *SigenergyEVDC) WakeUp() error {
|
||||
// re-issue Start: recovers sessions stopped on the vehicle/vendor-app side,
|
||||
// which latch until a new start command (device-verified)
|
||||
_, err := wb.conn.WriteSingleRegister(evdcRegStartStop, 0)
|
||||
return err
|
||||
}
|
||||
333
charger/sigenergy-evdc_test.go
Normal file
333
charger/sigenergy-evdc_test.go
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
package charger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andig/mbserver"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util/modbus"
|
||||
"github.com/evcc-io/evcc/util/sponsor"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type evdcWrite struct {
|
||||
funcCode uint8
|
||||
addr uint16
|
||||
args []uint16
|
||||
}
|
||||
|
||||
// evdcHandler mocks the hybrid inverter's EVDC register space
|
||||
type evdcHandler struct {
|
||||
mbserver.RequestHandler
|
||||
regs map[uint16]uint16
|
||||
writes []evdcWrite
|
||||
failWrites bool
|
||||
}
|
||||
|
||||
func (h *evdcHandler) HandleInputRegisters(req *mbserver.InputRegistersRequest) ([]uint16, error) {
|
||||
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
|
||||
}
|
||||
|
||||
func (h *evdcHandler) HandleHoldingRegisters(req *mbserver.HoldingRegistersRequest) ([]uint16, error) {
|
||||
if !req.IsWrite {
|
||||
return nil, mbserver.ErrIllegalDataAddress
|
||||
}
|
||||
if h.failWrites {
|
||||
return nil, mbserver.ErrIllegalDataValue
|
||||
}
|
||||
h.writes = append(h.writes, evdcWrite{req.WriteFuncCode, req.Addr, req.Args})
|
||||
return req.Args, nil
|
||||
}
|
||||
|
||||
// evdcRegs returns a fully populated 31500-31520 block with the given running state
|
||||
func evdcRegs(state uint16) map[uint16]uint16 {
|
||||
regs := make(map[uint16]uint16)
|
||||
for reg := uint16(evdcBase); reg < evdcBase+evdcInputLen; reg++ {
|
||||
regs[reg] = 0
|
||||
}
|
||||
regs[evdcRegRunningState] = state
|
||||
return regs
|
||||
}
|
||||
|
||||
// shared mock server: mbserver.Stop() races its accept goroutine (unlocked
|
||||
// tcpListener read vs. nil assignment), so the server is started once and
|
||||
// never stopped; handler state is reset per test
|
||||
var (
|
||||
evdcOnce sync.Once
|
||||
evdcURI string
|
||||
evdcSrvH = &evdcHandler{RequestHandler: new(mbserver.DummyHandler)}
|
||||
)
|
||||
|
||||
// evdcTestCharger connects a charger to the shared mock Modbus server
|
||||
func evdcTestCharger(t *testing.T, regs map[uint16]uint16) (*SigenergyEVDC, *evdcHandler) {
|
||||
t.Helper()
|
||||
|
||||
evdcOnce.Do(func() {
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
srv, err := mbserver.New(evdcSrvH)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, srv.Start(l))
|
||||
|
||||
evdcURI = l.Addr().String()
|
||||
})
|
||||
|
||||
evdcSrvH.regs = regs
|
||||
evdcSrvH.writes = nil
|
||||
evdcSrvH.failWrites = false
|
||||
|
||||
conn, err := modbus.NewConnection(context.Background(), evdcURI, "", "", 0, modbus.Tcp, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
wb := newSigenergyEVDC(conn)
|
||||
wb.ratedPower = 25000
|
||||
|
||||
return wb, evdcSrvH
|
||||
}
|
||||
|
||||
func TestSigenergyEVDCStatus(t *testing.T) {
|
||||
tc := []struct {
|
||||
state uint16
|
||||
status api.ChargeStatus
|
||||
err bool
|
||||
}{
|
||||
{evdcStateIdle, api.StatusA, false},
|
||||
{evdcStateOccupied, api.StatusB, false},
|
||||
{evdcStatePreparing, api.StatusB, false},
|
||||
{evdcStateCharging, api.StatusC, false},
|
||||
{evdcStateScheduled, api.StatusB, false},
|
||||
{evdcStateEnded, api.StatusB, false},
|
||||
{evdcStateInsulation, api.StatusB, false},
|
||||
{evdcStateDischarging, api.StatusC, false},
|
||||
{evdcStateFault, api.StatusNone, true},
|
||||
{evdcStateUnavailable, api.StatusNone, true},
|
||||
{evdcStateAlarm, api.StatusNone, true},
|
||||
{0x0B, api.StatusNone, true},
|
||||
}
|
||||
|
||||
for _, tc := range tc {
|
||||
wb, _ := evdcTestCharger(t, evdcRegs(tc.state))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigenergyEVDCMeasurements(t *testing.T) {
|
||||
regs := evdcRegs(evdcStateCharging)
|
||||
regs[evdcRegOutputPower] = 0
|
||||
regs[evdcRegOutputPower+1] = 11500 // 11500 W
|
||||
regs[evdcRegVehicleSoc] = 655 // 65.5 %
|
||||
regs[evdcRegSessionEnergy] = 0
|
||||
regs[evdcRegSessionEnergy+1] = 1234 // 12.34 kWh
|
||||
regs[evdcRegSessionDuration] = 0
|
||||
regs[evdcRegSessionDuration+1] = 3600 // 1h
|
||||
regs[evdcRegTotalEnergy] = 1
|
||||
regs[evdcRegTotalEnergy+1] = 34464 // 65536+34464 = 100000 -> 1000.00 kWh
|
||||
regs[evdcRegTotalDischargeEnergy] = 0
|
||||
regs[evdcRegTotalDischargeEnergy+1] = 500 // 5.00 kWh
|
||||
|
||||
wb, _ := evdcTestCharger(t, regs)
|
||||
|
||||
power, err := wb.CurrentPower()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 11500.0, power)
|
||||
|
||||
soc, err := wb.Soc()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 65.5, soc)
|
||||
|
||||
charged, err := wb.ChargedEnergy()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 12.34, charged)
|
||||
|
||||
dur, err := wb.ChargeDuration()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, time.Hour, dur)
|
||||
|
||||
total, err := wb.TotalEnergy()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1000.0, total)
|
||||
|
||||
returned, err := wb.ReturnEnergy()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5.0, returned)
|
||||
}
|
||||
|
||||
func TestSigenergyEVDCNegativePower(t *testing.T) {
|
||||
regs := evdcRegs(evdcStateDischarging)
|
||||
regs[evdcRegOutputPower] = 0xFFFF
|
||||
regs[evdcRegOutputPower+1] = 0xEC78 // -5000 W as S32
|
||||
|
||||
wb, _ := evdcTestCharger(t, regs)
|
||||
|
||||
power, err := wb.CurrentPower()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, -5000.0, power)
|
||||
}
|
||||
|
||||
func TestSigenergyEVDCSocNotAvailable(t *testing.T) {
|
||||
wb, _ := evdcTestCharger(t, evdcRegs(evdcStateIdle)) // all-zero block
|
||||
|
||||
_, err := wb.Soc()
|
||||
assert.ErrorIs(t, err, api.ErrNotAvailable)
|
||||
}
|
||||
|
||||
func TestSigenergyEVDCEnable(t *testing.T) {
|
||||
wb, h := evdcTestCharger(t, evdcRegs(evdcStateOccupied))
|
||||
|
||||
require.NoError(t, wb.Enable(true))
|
||||
enabled, err := wb.Enabled()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, enabled)
|
||||
|
||||
require.NoError(t, wb.Enable(false))
|
||||
enabled, err = wb.Enabled()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, enabled)
|
||||
|
||||
// start/stop is a single-register FC06 write to 41000: 0=start, 1=stop
|
||||
require.Len(t, h.writes, 2)
|
||||
assert.Equal(t, evdcWrite{6, evdcRegStartStop, []uint16{0}}, h.writes[0])
|
||||
assert.Equal(t, evdcWrite{6, evdcRegStartStop, []uint16{1}}, h.writes[1])
|
||||
}
|
||||
|
||||
func TestSigenergyEVDCEnableWriteFailure(t *testing.T) {
|
||||
wb, h := evdcTestCharger(t, evdcRegs(evdcStateOccupied))
|
||||
h.failWrites = true
|
||||
|
||||
assert.Error(t, wb.Enable(true))
|
||||
|
||||
// failed write must not flip the cached state
|
||||
enabled, err := wb.Enabled()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, enabled)
|
||||
}
|
||||
|
||||
func TestSigenergyEVDCEnabledSync(t *testing.T) {
|
||||
// charging syncs the cache to enabled
|
||||
wb, _ := evdcTestCharger(t, evdcRegs(evdcStateCharging))
|
||||
_, err := wb.Status()
|
||||
require.NoError(t, err)
|
||||
enabled, _ := wb.Enabled()
|
||||
assert.True(t, enabled)
|
||||
|
||||
// ambiguous lifecycle state leaves the cache untouched
|
||||
wb, _ = evdcTestCharger(t, evdcRegs(evdcStateEnded))
|
||||
wb.enabled = true
|
||||
_, err = wb.Status()
|
||||
require.NoError(t, err)
|
||||
enabled, _ = wb.Enabled()
|
||||
assert.True(t, enabled)
|
||||
|
||||
// discharging must NOT sync (vendor-initiated V2X, evcc-disabled loadpoint stays consistent)
|
||||
wb, _ = evdcTestCharger(t, evdcRegs(evdcStateDischarging))
|
||||
_, err = wb.Status()
|
||||
require.NoError(t, err)
|
||||
enabled, _ = wb.Enabled()
|
||||
assert.False(t, enabled)
|
||||
}
|
||||
|
||||
func TestSigenergyEVDCMaxCurrent(t *testing.T) {
|
||||
tc := []struct {
|
||||
current float64
|
||||
power uint16 // expected low word; high word is 0 in all cases
|
||||
}{
|
||||
{6, 4140},
|
||||
{16, 11040},
|
||||
{1.45, 1000}, // 1000.5 W truncated to 1000
|
||||
{1.0, 690}, // minimum current -> 690 W
|
||||
{40, 25000}, // 27600 W clamped down to rated power
|
||||
}
|
||||
|
||||
for _, tc := range tc {
|
||||
wb, h := evdcTestCharger(t, evdcRegs(evdcStateCharging))
|
||||
|
||||
require.NoError(t, wb.MaxCurrentMillis(tc.current))
|
||||
require.Len(t, h.writes, 1, "current %v", tc.current)
|
||||
|
||||
w := h.writes[0]
|
||||
assert.Equal(t, uint8(16), w.funcCode, "current %v", tc.current)
|
||||
assert.Equal(t, uint16(evdcRegPowerLimit), w.addr, "current %v", tc.current)
|
||||
assert.Equal(t, []uint16{0, tc.power}, w.args, "current %v", tc.current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigenergyEVDCMaxCurrentInvalid(t *testing.T) {
|
||||
wb, h := evdcTestCharger(t, evdcRegs(evdcStateCharging))
|
||||
|
||||
assert.Error(t, wb.MaxCurrentMillis(0.9))
|
||||
assert.Error(t, wb.MaxCurrentMillis(0))
|
||||
assert.Error(t, wb.MaxCurrentMillis(-1))
|
||||
assert.Empty(t, h.writes)
|
||||
}
|
||||
|
||||
func TestSigenergyEVDCMinMaxCurrent(t *testing.T) {
|
||||
wb, _ := evdcTestCharger(t, evdcRegs(evdcStateIdle))
|
||||
|
||||
minA, maxA, err := wb.GetMinMaxCurrent()
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 1.0, minA, 0.001) // evdcMinCurrent
|
||||
assert.InDelta(t, 36.23, maxA, 0.01) // 25000 W / 690
|
||||
}
|
||||
|
||||
func TestSigenergyEVDCSponsorGate(t *testing.T) {
|
||||
// go-e tests set the global sponsor.Subject and never reset it
|
||||
old := sponsor.Subject
|
||||
sponsor.Subject = ""
|
||||
t.Cleanup(func() { sponsor.Subject = old })
|
||||
|
||||
// tests run without sponsorship: the public constructor must refuse
|
||||
_, err := NewSigenergyEVDC(t.Context(), "localhost:0", 1)
|
||||
assert.ErrorIs(t, err, api.ErrSponsorRequired)
|
||||
}
|
||||
|
||||
func TestSigenergyEVDCReadFailure(t *testing.T) {
|
||||
// missing register in the bulk-read block -> IllegalDataAddress propagates
|
||||
regs := evdcRegs(evdcStateCharging)
|
||||
delete(regs, uint16(evdcRegRunningState))
|
||||
|
||||
wb, _ := evdcTestCharger(t, regs)
|
||||
|
||||
_, err := wb.Status()
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = wb.CurrentPower()
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSigenergyEVDCWakeUp(t *testing.T) {
|
||||
wb, h := evdcTestCharger(t, evdcRegs(evdcStateOccupied))
|
||||
|
||||
// realistic scenario: cache latched true from a prior session, external stop happened
|
||||
wb.enabled = true
|
||||
|
||||
require.NoError(t, wb.WakeUp())
|
||||
|
||||
// single FC06 start write, must not touch the enabled cache
|
||||
require.Len(t, h.writes, 1)
|
||||
assert.Equal(t, evdcWrite{6, evdcRegStartStop, []uint16{0}}, h.writes[0])
|
||||
|
||||
enabled, err := wb.Enabled()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, enabled)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue