Some checks failed
CodeQL / Analyze (go) (push) Failing after 1m16s
CodeQL / Analyze (actions) (push) Failing after 4s
CodeQL / Analyze (javascript-typescript) (push) Failing after 7s
Default / Clean (push) Has been cancelled
Default / Build (push) Has been cancelled
Default / Test (push) Has been cancelled
Default / Lint (push) Has been cancelled
Default / UI (push) Has been cancelled
Default / Integration (push) Has been cancelled
Default / Warm fork cache (Go) (push) Has been cancelled
Default / Warm fork cache (UI) (push) Has been cancelled
Unlock all locally gated features without a sponsor token. The change sits in util/sponsor/auth.go instead of the ~68 device constructors: - Subject defaults to a non-empty value, so RedactedStatus reports an active sponsorship and the frontend unlocks isSponsor - IsAuthorized always returns true, opening every caller including the modbus proxy and the optimizer gate - ConfigureSponsorship still validates a configured token but never fails, so an expired token no longer aborts startup Drops TestAlpitronicSponsorGate and TestSigenergyEVDCSponsorGate, which asserted exactly the gate that is removed here. Cloud-backed services (optimizer API, remote access, cloud vehicles, telemetry) still require a real token - those are checked server side. See FORK.md.
321 lines
8.6 KiB
Go
321 lines
8.6 KiB
Go
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/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 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)
|
|
}
|