diff --git a/charger/eebus.go b/charger/eebus.go index e3a4bb146..41bc3f81e 100644 --- a/charger/eebus.go +++ b/charger/eebus.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "slices" "sync" "time" @@ -13,7 +12,6 @@ import ( "github.com/enbility/eebus-go/usecases/cem/evcc" "github.com/enbility/eebus-go/usecases/cem/evcem" spineapi "github.com/enbility/spine-go/api" - "github.com/enbility/spine-go/model" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/server/eebus" @@ -41,7 +39,6 @@ type EEBus struct { limitUpdated time.Time // time of last limit change - vasVW bool // wether the EVSE supports VW VAS with ISO15118-2 enabled bool reconnect bool current float64 @@ -60,7 +57,6 @@ func NewEEBusFromConfig(ctx context.Context, other map[string]any) (api.Charger, Ip string Meter bool ChargedEnergy *bool - VasVW bool } if err := util.DecodeOther(other, &cc); err != nil { @@ -70,13 +66,13 @@ func NewEEBusFromConfig(ctx context.Context, other map[string]any) (api.Charger, // default true hasChargedEnergy := cc.ChargedEnergy != nil && *cc.ChargedEnergy - return NewEEBus(ctx, cc.Ski, cc.Ip, cc.Meter, hasChargedEnergy, cc.VasVW) + return NewEEBus(ctx, cc.Ski, cc.Ip, cc.Meter, hasChargedEnergy) } //go:generate go tool decorate -f decorateEEBus -b *EEBus -r api.Charger -t api.Meter,api.PhaseCurrents,api.ChargeRater // NewEEBus creates EEBus charger -func NewEEBus(ctx context.Context, ski, ip string, hasMeter, hasChargedEnergy, vasVW bool) (api.Charger, error) { +func NewEEBus(ctx context.Context, ski, ip string, hasMeter, hasChargedEnergy bool) (api.Charger, error) { if eebus.Instance == nil { return nil, errors.New("eebus not configured") } @@ -84,7 +80,6 @@ func NewEEBus(ctx context.Context, ski, ip string, hasMeter, hasChargedEnergy, v c := &EEBus{ log: util.NewLogger("eebus"), current: 6, - vasVW: vasVW, cem: eebus.Instance.CustomerEnergyManagement(), } @@ -239,24 +234,6 @@ func (c *EEBus) Enabled() (bool, error) { return c.enabled, nil } - // if the VW VAS PV mode is active, use PV limits - if c.hasActiveVASVW(evEntity) { - limits, err := c.cem.OscEV.LoadControlLimits(evEntity) - if err != nil { - // there are no limits available, e.g. because the data was not received yet - return c.enabled, nil - } - - for _, limit := range limits { - // check if there is an active limit set - if limit.IsActive && limit.Value >= 1 { - return true, nil - } - } - - return false, nil - } - limits, err := c.cem.OpEV.LoadControlLimits(evEntity) if err != nil { // there are no limits available, e.g. because the data was not received yet @@ -319,7 +296,7 @@ func (c *EEBus) writeCurrentLimitData(evEntity spineapi.EntityRemoteInterface, c c.log.DEBUG.Println("no limits from the EVSE are provided:", err) } - // setup the limit data structure + // setup the obligation limit data structure var limits []ucapi.LoadLimitsPhase for phase := range len(ucapi.PhaseNameMapping) { limit := ucapi.LoadLimitsPhase{ @@ -336,157 +313,60 @@ func (c *EEBus) writeCurrentLimitData(evEntity spineapi.EntityRemoteInterface, c limits = append(limits, limit) } - // if VAS VW is available, limits are completely covered by it - // this way evcc can fully control the charging behavior - if c.writeLoadControlLimitsVASVW(evEntity, limits) { - c.mux.Lock() - defer c.mux.Unlock() - - c.limitUpdated = time.Now() - return nil + // always set overload protection limits (obligation) + if _, err := c.cem.OpEV.WriteLoadControlLimits(evEntity, limits, nil); err != nil { + return err } - // make sure the recommendations are inactive, otherwise the EV won't go to sleep - // but only if it supports OSCEV and has required data! - if c.cem.OscEV.IsScenarioAvailableAtEntity(evEntity, 1) { - if _, err := c.cem.OscEV.LoadControlLimits(evEntity); err == nil { - if err := c.disableLimits(evEntity, c.cem.OscEV); err != nil { - return err - } - } - } + // additionally set self-consumption recommendation limits if available + c.writeOscevLimits(evEntity, current) - // set overload protection limits - _, err = c.cem.OpEV.WriteLoadControlLimits(evEntity, limits, nil) - if err == nil { - c.mux.Lock() - defer c.mux.Unlock() + c.mux.Lock() + defer c.mux.Unlock() - c.limitUpdated = time.Now() - } + c.limitUpdated = time.Now() - return err + return nil } -// returns if the connected EV has an active VW PV mode -// in this mode, the EV does not have an active charging demand -func (c *EEBus) hasActiveVASVW(evEntity spineapi.EntityRemoteInterface) bool { - // EVSE has to support VW VAS - if !c.vasVW { - return false - } - - // ISO15118-2 has to be used between EVSE and EV - if comStandard, err := c.cem.EvCC.CommunicationStandard(evEntity); err != nil || comStandard != model.DeviceConfigurationKeyValueStringTypeISO151182ED2 { - return false - } - - // SoC has to be available, otherwise it is plain ISO15118-2 - // SoC has to be >= 25%, because the Taycan can't be setup with a Min SoC below 25%, oherwise obligations have to be used - if soc, err := c.Soc(); err != nil || soc < 25 { - return false - } - - // Optimization of self consumption use case support has to be available - if !c.cem.EvSoc.IsScenarioAvailableAtEntity(evEntity, 1) { - return false - } - - // the use case has to be reported as active - // only then the EV has no active charging demand and will charge based on OSCEV recommendations - // this is a workaround for EVSE changing isActive to false, even though they should - // not announce the use case at all in that case - for _, uci := range evEntity.Device().UseCases() { - // check if the referenced entity address is identical to the ev entity address - // the address may not exist, as it only available since SPINE 1.3 - if uci.Address != nil && - evEntity.Address() != nil && - slices.Compare(uci.Address.Entity, evEntity.Address().Entity) != 0 { - continue - } - - for _, uc := range uci.UseCaseSupport { - if uc.UseCaseName != nil && *uc.UseCaseName == model.UseCaseNameTypeOptimizationOfSelfConsumptionDuringEVCharging && - uc.UseCaseAvailable != nil && *uc.UseCaseAvailable { - return true - } - } - } - - return false -} - -// provides support for the special VW VAS ISO15118-2 charging behavior if supported -// will return false if it isn't supported or successful -// -// this functionality allows to fully control charging without the EV actually having a -// charging demand by itself -func (c *EEBus) writeLoadControlLimitsVASVW(evEntity spineapi.EntityRemoteInterface, limits []ucapi.LoadLimitsPhase) bool { - if !c.hasActiveVASVW(evEntity) { - return false - } - - // check if the EVSE supports optimization of self consumption limits +// writeOscevLimits writes OSCEV recommendation limits if the use case is available. +// An active recommendation triggers the EV to charge with surplus energy. +// An inactive recommendation is equivalent to no recommendation existing. +func (c *EEBus) writeOscevLimits(evEntity spineapi.EntityRemoteInterface, current float64) { if !c.cem.OscEV.IsScenarioAvailableAtEntity(evEntity, 1) { - return false + return } // OSCEV requires recommendation limits to be available if _, err := c.cem.OscEV.LoadControlLimits(evEntity); err != nil { - return false + return } - // on OSCEV all limits have to be active except they are set to the default value minLimits, _, _, err := c.cem.OscEV.CurrentLimits(evEntity) if err != nil { - return false + return } - for index, item := range limits { - // if the limit is equal or bigger than the min allowed, then the recommendation limit is active, otherwise it is not - limits[index].IsActive = false - if index < len(minLimits) { - limits[index].IsActive = item.Value >= minLimits[index] + var limits []ucapi.LoadLimitsPhase + for phase := range len(ucapi.PhaseNameMapping) { + limit := ucapi.LoadLimitsPhase{ + Phase: ucapi.PhaseNameMapping[phase], + IsActive: false, + Value: current, } + + // below min charging current there is nothing to recommend + // in contrast to OPEV the max value has to be active to trigger the recommendation to have any effect + if phase < len(minLimits) { + limit.IsActive = current >= minLimits[phase] + } + + limits = append(limits, limit) } - // set recommendation limits if _, err := c.cem.OscEV.WriteLoadControlLimits(evEntity, limits, nil); err != nil { - return false + c.log.DEBUG.Println("failed to write OSCEV limits:", err) } - - if err := c.disableLimits(evEntity, c.cem.OpEV); err != nil { - return false - } - - return true -} - -type eebusLimitController interface { - LoadControlLimits(spineapi.EntityRemoteInterface) ([]ucapi.LoadLimitsPhase, error) - WriteLoadControlLimits(spineapi.EntityRemoteInterface, []ucapi.LoadLimitsPhase, func(result model.ResultDataType)) (*model.MsgCounterType, error) -} - -// make sure the limits are inactive, otherwise the EV won't go to sleep -func (c *EEBus) disableLimits(evEntity spineapi.EntityRemoteInterface, uc eebusLimitController) error { - limits, err := uc.LoadControlLimits(evEntity) - if err != nil { - return err - } - - var writeNeeded bool - for index, item := range limits { - if item.IsActive { - limits[index].IsActive = false - writeNeeded = true - } - } - - if writeNeeded { - _, err = uc.WriteLoadControlLimits(evEntity, limits, nil) - } - - return err } // MaxCurrent implements the api.Charger interface diff --git a/charger/eebus_test.go b/charger/eebus_test.go index 631c92350..434df0375 100644 --- a/charger/eebus_test.go +++ b/charger/eebus_test.go @@ -5,9 +5,12 @@ import ( "testing" "time" + ucapi "github.com/enbility/eebus-go/usecases/api" evcemuc "github.com/enbility/eebus-go/usecases/cem/evcem" "github.com/enbility/eebus-go/usecases/mocks" spinemocks "github.com/enbility/spine-go/mocks" + "github.com/enbility/spine-go/model" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/server/eebus" "github.com/evcc-io/evcc/util" "github.com/stretchr/testify/assert" @@ -103,6 +106,173 @@ func TestEEBusNoCurrents(t *testing.T) { assert.Equal(t, 10.4, l3) } +// newTestEEBus creates an EEBus instance with all mocks wired up for limit writing tests. +func newTestEEBus(t *testing.T) (*EEBus, *mocks.CemOPEVInterface, *mocks.CemOSCEVInterface, *spinemocks.EntityRemoteInterface) { + t.Helper() + + opev := mocks.NewCemOPEVInterface(t) + oscev := mocks.NewCemOSCEVInterface(t) + evEntity := spinemocks.NewEntityRemoteInterface(t) + + eebus := &EEBus{ + cem: &eebus.CustomerEnergyManagement{ + OpEV: opev, + OscEV: oscev, + }, + ev: evEntity, + log: util.NewLogger("test"), + } + + return eebus, opev, oscev, evEntity +} + +// 3-phase limits helper +func opevLimits3p(min, max, def float64) ([]float64, []float64, []float64, error) { + return []float64{min, min, min}, []float64{max, max, max}, []float64{def, def, def}, nil +} + +func TestWriteCurrentLimitData_OpevOnly(t *testing.T) { + eebus, opev, oscev, evEntity := newTestEEBus(t) + _ = eebus + + // OPEV available, OSCEV not available + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + opev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(6, 16, 0)) + opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 10 + }), mock.Anything).Return(nil, nil) + + oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(false) + + err := eebus.writeCurrentLimitData(evEntity, 10) + require.NoError(t, err) +} + +func TestWriteCurrentLimitData_OpevAndOscev(t *testing.T) { + eebus, opev, oscev, evEntity := newTestEEBus(t) + _ = eebus + + // Both available, current = 10A (between min and max) + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + opev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(6, 16, 0)) + opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + // OPEV: active at 10A (below max of 16) + return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 10 + }), mock.Anything).Return(nil, nil) + + oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + oscev.EXPECT().LoadControlLimits(evEntity).Return([]ucapi.LoadLimitsPhase{}, nil) + oscev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(2, 16, 0)) + oscev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + // OSCEV: active at 10A (>= min of 2, recommendation to charge) + return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 10 + }), mock.Anything).Return(nil, nil) + + err := eebus.writeCurrentLimitData(evEntity, 10) + require.NoError(t, err) +} + +func TestWriteCurrentLimitData_AtMax(t *testing.T) { + eebus, opev, oscev, evEntity := newTestEEBus(t) + _ = eebus + + // Current equals max limit + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + opev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(6, 16, 0)) + opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + // OPEV: inactive at max (no restriction needed) + return len(limits) == 3 && !limits[0].IsActive && limits[0].Value == 16 + }), mock.Anything).Return(nil, nil) + + oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + oscev.EXPECT().LoadControlLimits(evEntity).Return([]ucapi.LoadLimitsPhase{}, nil) + oscev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(2, 16, 0)) + oscev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + // OSCEV: active at 16A (>= min, recommend charging) + return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 16 + }), mock.Anything).Return(nil, nil) + + err := eebus.writeCurrentLimitData(evEntity, 16) + require.NoError(t, err) +} + +func TestWriteCurrentLimitData_Disable(t *testing.T) { + eebus, opev, oscev, evEntity := newTestEEBus(t) + _ = eebus + + // Current = 0 (disable charging) + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + opev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(6, 16, 0)) + opev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + // OPEV: active at 0A (hard stop) + return len(limits) == 3 && limits[0].IsActive && limits[0].Value == 0 + }), mock.Anything).Return(nil, nil) + + oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + oscev.EXPECT().LoadControlLimits(evEntity).Return([]ucapi.LoadLimitsPhase{}, nil) + oscev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(2, 16, 0)) + oscev.EXPECT().WriteLoadControlLimits(evEntity, mock.MatchedBy(func(limits []ucapi.LoadLimitsPhase) bool { + // OSCEV: inactive at 0A (no recommendation, < min) + return len(limits) == 3 && !limits[0].IsActive && limits[0].Value == 0 + }), mock.Anything).Return(nil, nil) + + err := eebus.writeCurrentLimitData(evEntity, 0) + require.NoError(t, err) +} + +func TestWriteCurrentLimitData_OscevNoLimitData(t *testing.T) { + eebus, opev, oscev, evEntity := newTestEEBus(t) + _ = eebus + + // OSCEV scenario available but no limit data (e.g. PCMP wallbox) + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + opev.EXPECT().CurrentLimits(evEntity).Return(opevLimits3p(6, 16, 0)) + opev.EXPECT().WriteLoadControlLimits(evEntity, mock.Anything, mock.Anything).Return(nil, nil) + + oscev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(true) + oscev.EXPECT().LoadControlLimits(evEntity).Return(nil, errors.New("data not available")) + // no WriteLoadControlLimits call expected for OSCEV + + err := eebus.writeCurrentLimitData(evEntity, 10) + require.NoError(t, err) +} + +func TestWriteCurrentLimitData_OpevNotAvailable(t *testing.T) { + eebus, opev, _, evEntity := newTestEEBus(t) + _ = eebus + + opev.EXPECT().IsScenarioAvailableAtEntity(evEntity, uint(1)).Return(false) + + err := eebus.writeCurrentLimitData(evEntity, 10) + require.ErrorIs(t, err, api.ErrNotAvailable) +} + +func TestEnabledAlwaysReadsOpev(t *testing.T) { + evcc := mocks.NewCemEVCCInterface(t) + opev := mocks.NewCemOPEVInterface(t) + + evEntity := spinemocks.NewEntityRemoteInterface(t) + eebus := &EEBus{ + cem: &eebus.CustomerEnergyManagement{ + EvCC: evcc, + OpEV: opev, + }, + ev: evEntity, + log: util.NewLogger("test"), + } + + evcc.EXPECT().EVConnected(evEntity).Return(true) + opev.EXPECT().LoadControlLimits(evEntity).Return([]ucapi.LoadLimitsPhase{ + {Phase: model.ElectricalConnectionPhaseNameTypeA, IsActive: true, Value: 10}, + {Phase: model.ElectricalConnectionPhaseNameTypeB, IsActive: true, Value: 10}, + {Phase: model.ElectricalConnectionPhaseNameTypeC, IsActive: true, Value: 10}, + }, nil) + + enabled, err := eebus.Enabled() + require.NoError(t, err) + assert.True(t, enabled) +} + func TestEEBusIsCharging(t *testing.T) { type limitStruct struct { min, max, pause float64 diff --git a/templates/definition/charger/elli-2.yaml b/templates/definition/charger/elli-2.yaml index 5c1149ba1..d70a53ccf 100644 --- a/templates/definition/charger/elli-2.yaml +++ b/templates/definition/charger/elli-2.yaml @@ -57,4 +57,3 @@ params: render: | {{ include "eebus" . }} meter: true - vasvw: true diff --git a/templates/definition/charger/porsche-pmcc.yaml b/templates/definition/charger/porsche-pmcc.yaml index 05a766b82..9d1c84b3e 100644 --- a/templates/definition/charger/porsche-pmcc.yaml +++ b/templates/definition/charger/porsche-pmcc.yaml @@ -11,4 +11,3 @@ params: render: | {{ include "eebus" . }} meter: true - vasvw: true diff --git a/templates/definition/charger/porsche-wallbox.yaml b/templates/definition/charger/porsche-wallbox.yaml index 1c24d16e0..ebe7c4d9b 100644 --- a/templates/definition/charger/porsche-wallbox.yaml +++ b/templates/definition/charger/porsche-wallbox.yaml @@ -24,4 +24,3 @@ params: render: | {{ include "eebus" . }} meter: true - vasvw: true