EEBus: always send OPEV and OSCEV limits together (#28761)

This commit is contained in:
Andreas Linde 2026-04-03 16:23:27 +02:00 • committed by GitHub
parent 8dd9333003
commit 764aa45d8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 204 additions and 157 deletions

View file

@ -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

View file

@ -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

View file

@ -57,4 +57,3 @@ params:
render: |
{{ include "eebus" . }}
meter: true
vasvw: true

View file

@ -11,4 +11,3 @@ params:
render: |
{{ include "eebus" . }}
meter: true
vasvw: true

View file

@ -24,4 +24,3 @@ params:
render: |
{{ include "eebus" . }}
meter: true
vasvw: true