evcc-io/charger/sigenergy-evdc_test.go
Stefan 13215c74c2
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
Remove sponsor token gating
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.
2026-08-30 10:41:18 +02:00

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)
}