From cd9812ef14e56a8a8c08ab5565d0b12afe20b311 Mon Sep 17 00:00:00 2001 From: lasseb13 <276900773+lasseb13@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:02:19 +0200 Subject: [PATCH] Add Sigenergy EVDC charger (#32023) Co-authored-by: premultiply <4681172+premultiply@users.noreply.github.com> --- charger/sigenergy-evdc.go | 375 ++++++++++++++++++ charger/sigenergy-evdc_test.go | 333 ++++++++++++++++ .../definition/charger/sigenergy-evdc.yaml | 18 + 3 files changed, 726 insertions(+) create mode 100644 charger/sigenergy-evdc.go create mode 100644 charger/sigenergy-evdc_test.go create mode 100644 templates/definition/charger/sigenergy-evdc.yaml diff --git a/charger/sigenergy-evdc.go b/charger/sigenergy-evdc.go new file mode 100644 index 000000000..f821265ba --- /dev/null +++ b/charger/sigenergy-evdc.go @@ -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 +} diff --git a/charger/sigenergy-evdc_test.go b/charger/sigenergy-evdc_test.go new file mode 100644 index 000000000..36f78b18d --- /dev/null +++ b/charger/sigenergy-evdc_test.go @@ -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) +} diff --git a/templates/definition/charger/sigenergy-evdc.yaml b/templates/definition/charger/sigenergy-evdc.yaml new file mode 100644 index 000000000..6829010f2 --- /dev/null +++ b/templates/definition/charger/sigenergy-evdc.yaml @@ -0,0 +1,18 @@ +template: sigenergy-evdc +products: + - brand: Sigenergy + description: + generic: EVDC +capabilities: ["mA", "meter", "dim"] +requirements: + description: + de: Modbus TCP muss in der Konfigurations-App mit Installateursrechten aktiviert werden. Die Modbus-ID ist die des Hybrid-Wechselrichters, an dem der EVDC angeschlossen ist. Benötigt aktuelle Firmware (Modbus-Protokoll V2.9). + en: Modbus TCP must be enabled in the configuration app with installer rights. The Modbus ID is that of the hybrid inverter the EVDC is attached to. Requires current firmware (Modbus protocol V2.9). + evcc: ["sponsorship"] +params: + - name: modbus + choice: ["tcpip"] + id: 1 +render: | + type: sigenergy-evdc + {{- include "modbus" . }}