evcc-io/charger/eebus_test.go

437 lines
13 KiB
Go

package charger
import (
"errors"
"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"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
// Test measurements updated after writing limits detction works
func TestEEBusNoCurrents(t *testing.T) {
evcc := mocks.NewCemEVCCInterface(t)
evcem := mocks.NewCemEVCEMInterface(t)
evEntity := spinemocks.NewEntityRemoteInterface(t)
eebus := &EEBus{
cem: &eebus.CustomerEnergyManagement{
EvCC: evcc,
EvCem: evcem,
},
ev: evEntity,
log: util.NewLogger("test"),
}
evcc.EXPECT().EVConnected(evEntity).Return(true)
evcem.EXPECT().IsScenarioAvailableAtEntity(evEntity, mock.Anything).Return(true)
// limit set 15:04:45, measurement receviced afterwards before calling currents
eebus.limitUpdated = time.Date(2024, 9, 16, 15, 4, 45, 0, time.UTC)
eebus.UseCaseEvent(nil, evEntity, evcemuc.DataUpdateCurrentPerPhase)
evcem.EXPECT().CurrentPerPhase(evEntity).Return([]float64{10.5, 10.5, 10.5}, nil).Once()
l1, l2, l3, err := eebus.currents()
require.NoError(t, err)
assert.Equal(t, 10.5, l1)
assert.Equal(t, 10.5, l2)
assert.Equal(t, 10.5, l3)
// limit set 15:05:09, measurement receviced afterwards before calling currents
eebus.limitUpdated = time.Date(2024, 9, 16, 15, 5, 9, 0, time.UTC)
eebus.UseCaseEvent(nil, evEntity, evcemuc.DataUpdateCurrentPerPhase)
evcem.EXPECT().CurrentPerPhase(evEntity).Return([]float64{6.6, 6.6, 6.6}, nil).Once()
l1, l2, l3, err = eebus.currents()
require.NoError(t, err)
assert.Equal(t, 6.6, l1)
assert.Equal(t, 6.6, l2)
assert.Equal(t, 6.6, l3)
// limit set 15:05:39, measurement received afterwards before calling currents
eebus.limitUpdated = time.Date(2024, 9, 16, 15, 5, 39, 0, time.UTC)
eebus.UseCaseEvent(nil, evEntity, evcemuc.DataUpdateCurrentPerPhase)
evcem.EXPECT().CurrentPerPhase(evEntity).Return([]float64{10.4, 10.5, 10.4}, nil).Once()
l1, l2, l3, err = eebus.currents()
require.NoError(t, err)
assert.Equal(t, 10.4, l1)
assert.Equal(t, 10.5, l2)
assert.Equal(t, 10.4, l3)
// limit set 15:06:09, measurement received afterwards before calling currents
eebus.limitUpdated = time.Date(2024, 9, 16, 15, 6, 9, 0, time.UTC)
eebus.UseCaseEvent(nil, evEntity, evcemuc.DataUpdateCurrentPerPhase)
evcem.EXPECT().CurrentPerPhase(evEntity).Return([]float64{10.4, 10.4, 10.4}, nil).Once()
l1, l2, l3, err = eebus.currents()
require.NoError(t, err)
assert.Equal(t, 10.4, l1)
assert.Equal(t, 10.4, l2)
assert.Equal(t, 10.4, l3)
// limit set 20 seconds ago, no measurement received yet
eebus.limitUpdated = time.Now().Add(-20 * time.Second)
l1, l2, l3, err = eebus.currents()
require.Error(t, err)
assert.Equal(t, 0.0, l1)
assert.Equal(t, 0.0, l2)
assert.Equal(t, 0.0, l3)
// now we got a measurement again
eebus.UseCaseEvent(nil, evEntity, evcemuc.DataUpdateCurrentPerPhase)
evcem.EXPECT().CurrentPerPhase(evEntity).Return([]float64{10.4, 10.4, 10.4}, nil).Once()
l1, l2, l3, err = eebus.currents()
require.NoError(t, err)
assert.Equal(t, 10.4, l1)
assert.Equal(t, 10.4, l2)
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
}
type testMeasurementStruct struct {
charging bool
currents []float64
powers []float64
}
tests := []struct {
name string
limits []limitStruct
measurements []testMeasurementStruct
}{
{
"3 phase IEC",
[]limitStruct{
{6, 16, 0},
{6, 16, 0},
{6, 16, 0},
},
[]testMeasurementStruct{
{
false,
[]float64{0, 3, 0},
[]float64{0, 690, 0},
},
{
true,
[]float64{6, 0, 1},
[]float64{1380, 0, 230},
},
},
},
{
"1 phase IEC",
[]limitStruct{
{6, 16, 0},
},
[]testMeasurementStruct{
{
false,
[]float64{2},
[]float64{460},
},
{
true,
[]float64{6},
[]float64{1380},
},
},
},
{
"3 phase ISO",
[]limitStruct{
{2.2, 16, 0.1},
{2.2, 16, 0.1},
{2.2, 16, 0.1},
},
[]testMeasurementStruct{
{
false,
[]float64{1, 0, 0},
[]float64{230, 0, 0},
},
{
true,
[]float64{1.8, 1, 3},
[]float64{414, 230, 690},
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var limitsMin, limitsMax, limitsDefault []float64
for _, limit := range tc.limits {
limitsMin = append(limitsMin, limit.min)
limitsMax = append(limitsMax, limit.max)
limitsDefault = append(limitsDefault, limit.pause)
}
for _, m := range tc.measurements {
ctrl := gomock.NewController(t)
evcc := mocks.NewCemEVCCInterface(t)
evcem := mocks.NewCemEVCEMInterface(t)
opev := mocks.NewCemOPEVInterface(t)
evEntity := spinemocks.NewEntityRemoteInterface(t)
eebus := &EEBus{
cem: &eebus.CustomerEnergyManagement{
EvCC: evcc,
EvCem: evcem,
OpEV: opev,
},
ev: evEntity,
}
evcc.EXPECT().EVConnected(evEntity).Return(true)
evcem.EXPECT().IsScenarioAvailableAtEntity(evEntity, mock.Anything).Return(true)
evcem.EXPECT().PowerPerPhase(evEntity).Return(m.powers, nil)
opev.EXPECT().CurrentLimits(evEntity).Return(limitsMin, limitsMax, limitsDefault, nil)
require.Equal(t, m.charging, eebus.isCharging(evEntity))
ctrl.Finish()
}
})
}
}
func TestEEBusCurrentPower(t *testing.T) {
evcc := mocks.NewCemEVCCInterface(t)
evcem := mocks.NewCemEVCEMInterface(t)
evEntity := spinemocks.NewEntityRemoteInterface(t)
eebus := &EEBus{
cem: &eebus.CustomerEnergyManagement{
EvCC: evcc,
EvCem: evcem,
},
ev: evEntity,
log: util.NewLogger("test"),
}
evcc.EXPECT().EVConnected(evEntity).Return(true)
evcem.EXPECT().IsScenarioAvailableAtEntity(evEntity, mock.Anything).Return(true)
evcem.EXPECT().PowerPerPhase(evEntity).Return([]float64{600, 600, 600}, nil)
power, err := eebus.currentPower()
require.NoError(t, err)
assert.Equal(t, 1800.0, power)
}
func TestEEBusCurrentPower_Elli(t *testing.T) {
evcc := mocks.NewCemEVCCInterface(t)
evcem := mocks.NewCemEVCEMInterface(t)
evEntity := spinemocks.NewEntityRemoteInterface(t)
eebus := &EEBus{
cem: &eebus.CustomerEnergyManagement{
EvCC: evcc,
EvCem: evcem,
},
ev: evEntity,
log: util.NewLogger("test"),
}
evcc.EXPECT().EVConnected(evEntity).Return(true)
evcem.EXPECT().IsScenarioAvailableAtEntity(evEntity, mock.Anything).Return(true)
evcem.EXPECT().PowerPerPhase(evEntity).Return(nil, errors.New("error"))
evcem.EXPECT().CurrentPerPhase(evEntity).Return([]float64{5.8, 5.8, 5.8}, nil)
power, err := eebus.currentPower()
require.NoError(t, err)
assert.Equal(t, 4002.0, power)
}