From 36c422f7a8c791301e2f9ccba2867bdd15283637 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 14 Jul 2026 21:53:12 +0200 Subject: [PATCH] EEBus: add MGCP, MPC, LPC and LPP conformance tests (#31384) --- charger/eebus-ohpcf_lpc_test.go | 152 ++++++++++++++++++++ meter/eebus_lpc_lpp_test.go | 231 ++++++++++++++++++++++++++++++ meter/eebus_mgcp_test.go | 245 ++++++++++++++++++++++++++++++++ meter/eebus_mpc_test.go | 166 ++++++++++++++++++++++ 4 files changed, 794 insertions(+) create mode 100644 charger/eebus-ohpcf_lpc_test.go create mode 100644 meter/eebus_lpc_lpp_test.go create mode 100644 meter/eebus_mgcp_test.go create mode 100644 meter/eebus_mpc_test.go diff --git a/charger/eebus-ohpcf_lpc_test.go b/charger/eebus-ohpcf_lpc_test.go new file mode 100644 index 000000000..7e6eb3ab4 --- /dev/null +++ b/charger/eebus-ohpcf_lpc_test.go @@ -0,0 +1,152 @@ +package charger + +// Conformance suite for EEBus LPC TestSpec V1.0.1 — Energy Guard (EG) role. +// The OHPCF charger is the EG via Dim/Dimmed; it has no LPP (a heat pump only consumes). + +import ( + "testing" + + eebusapi "github.com/enbility/eebus-go/api" + ucapi "github.com/enbility/eebus-go/usecases/api" + egmocks "github.com/enbility/eebus-go/usecases/mocks" + spineapi "github.com/enbility/spine-go/api" + 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" +) + +func newOHPCFEGCharger(t *testing.T) (*EEBusOHPCF, *egmocks.EgLPCInterface, spineapi.EntityRemoteInterface) { + t.Helper() + + lpc := egmocks.NewEgLPCInterface(t) + entity := spinemocks.NewEntityRemoteInterface(t) + + c := &EEBusOHPCF{ + log: util.NewLogger("eebus-ohpcf-test"), + eg: &eebus.EnergyGuard{EgLPCInterface: lpc}, + egLpcEntity: entity, + } + + return c, lpc, entity +} + +// ATC_COM_PT_EGMessages_001/003 (LPC-TS-001/001-2): the EG sends an activated, +// then deactivated, consumption-limit write command. Dim writes a 0 W limit. +func TestOHPCF_LPC_EGMessages_ConsumptionLimit(t *testing.T) { + for _, tc := range []struct { + name string + dim bool + active bool + }{ + {"activate", true, true}, + {"deactivate", false, false}, + } { + t.Run("ATC_COM_PT_EGMessages_001_"+tc.name, func(t *testing.T) { + c, lpc, entity := newOHPCFEGCharger(t) + lpc.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPCLimit).Return(true) + lpc.EXPECT(). + WriteConsumptionLimit(entity, ucapi.LoadLimit{Value: 0, IsActive: tc.active}, mock.Anything). + Run(func(_ spineapi.EntityRemoteInterface, _ ucapi.LoadLimit, cb func(model.ResultDataType, model.MsgCounterType)) { + cb(model.ResultDataType{}, 0) + }). + Return(new(model.MsgCounterType), nil) + + assert.NoError(t, c.Dim(tc.dim)) + }) + } +} + +// A rejected write (NACK) must surface as an error, not silent success. +func TestOHPCF_LPC_Dim_WriteRejected(t *testing.T) { + c, lpc, entity := newOHPCFEGCharger(t) + lpc.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPCLimit).Return(true) + lpc.EXPECT(). + WriteConsumptionLimit(entity, mock.Anything, mock.Anything). + Run(func(_ spineapi.EntityRemoteInterface, _ ucapi.LoadLimit, cb func(model.ResultDataType, model.MsgCounterType)) { + n := model.ErrorNumberType(7) + cb(model.ResultDataType{ErrorNumber: &n}, 0) + }). + Return(new(model.MsgCounterType), nil) + + assert.Error(t, c.Dim(true)) +} + +// Dim is gated: no announced LPC scenario, or no connected entity → ErrNotAvailable. +func TestOHPCF_LPC_Dim_Gating(t *testing.T) { + t.Run("scenario_not_announced", func(t *testing.T) { + c, lpc, entity := newOHPCFEGCharger(t) + lpc.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPCLimit).Return(false) + + assert.ErrorIs(t, c.Dim(true), api.ErrNotAvailable) + }) + + t.Run("entity_not_connected", func(t *testing.T) { + c, _, _ := newOHPCFEGCharger(t) + c.egLpcEntity = nil + + assert.ErrorIs(t, c.Dim(true), api.ErrNotAvailable) + }) +} + +// Dimmed reports an active consumption limit. Dim always writes a fixed 0W +// limit, so only IsActive determines the dimmed state (a value-based check +// would never report dimmed or release it). +func TestOHPCF_LPC_Dimmed(t *testing.T) { + for _, tc := range []struct { + name string + limit ucapi.LoadLimit + want bool + }{ + {"active_positive", ucapi.LoadLimit{IsActive: true, Value: 4000}, true}, + {"active_zero", ucapi.LoadLimit{IsActive: true, Value: 0}, true}, + {"inactive", ucapi.LoadLimit{IsActive: false, Value: 4000}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + c, lpc, entity := newOHPCFEGCharger(t) + lpc.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPCLimit).Return(true) + lpc.EXPECT().ConsumptionLimit(entity).Return(tc.limit, nil) + + got, err := c.Dimmed() + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// Dimmed is gated like Dim: no announced LPC scenario, or no connected entity → ErrNotAvailable. +func TestOHPCF_LPC_Dimmed_Gating(t *testing.T) { + t.Run("scenario_not_announced", func(t *testing.T) { + c, lpc, entity := newOHPCFEGCharger(t) + lpc.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPCLimit).Return(false) + + _, err := c.Dimmed() + assert.ErrorIs(t, err, api.ErrNotAvailable) + }) + + t.Run("entity_not_connected", func(t *testing.T) { + c, _, _ := newOHPCFEGCharger(t) + c.egLpcEntity = nil + + _, err := c.Dimmed() + assert.ErrorIs(t, err, api.ErrNotAvailable) + }) +} + +// Dimmed discards a non-normal limit value (LPC-TS-008) as ErrNotAvailable. +func TestOHPCF_LPC_Dimmed_Discard(t *testing.T) { + for _, badErr := range []error{eebusapi.ErrDataInvalid, eebusapi.ErrDataNotAvailable, eebusapi.ErrMetadataNotAvailable} { + t.Run(badErr.Error(), func(t *testing.T) { + c, lpc, entity := newOHPCFEGCharger(t) + lpc.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPCLimit).Return(true) + lpc.EXPECT().ConsumptionLimit(entity).Return(ucapi.LoadLimit{}, badErr) + + _, err := c.Dimmed() + assert.ErrorIs(t, err, api.ErrNotAvailable) + }) + } +} diff --git a/meter/eebus_lpc_lpp_test.go b/meter/eebus_lpc_lpp_test.go new file mode 100644 index 000000000..57844cfff --- /dev/null +++ b/meter/eebus_lpc_lpp_test.go @@ -0,0 +1,231 @@ +package meter + +// Conformance suite for EEBus LPC/LPP TestSpec V1.0.1 — Energy Guard (EG) role only. +// Grid meter = EG (Dim/SetCurtailPercent); Controllable System is the HEMS/charger, not the meter. + +import ( + "testing" + + ucapi "github.com/enbility/eebus-go/usecases/api" + egmocks "github.com/enbility/eebus-go/usecases/mocks" + spineapi "github.com/enbility/spine-go/api" + 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" +) + +func newEGMeter(t *testing.T) (*EEBus, *egmocks.EgLPCInterface, *egmocks.EgLPPInterface, spineapi.EntityRemoteInterface) { + t.Helper() + + lpc := egmocks.NewEgLPCInterface(t) + lpp := egmocks.NewEgLPPInterface(t) + entity := spinemocks.NewEntityRemoteInterface(t) + + c := &EEBus{ + log: util.NewLogger("eebus-eg-test"), + eg: &eebus.EnergyGuard{EgLPCInterface: lpc, EgLPPInterface: lpp}, + egLpcEntity: entity, + egLppEntity: entity, + } + + return c, lpc, lpp, entity +} + +// ackWrite makes a mocked write invoke its result callback with a success result, +// so eebus.Await completes. +func ackWrite(_ spineapi.EntityRemoteInterface, _ ucapi.LoadLimit, cb func(model.ResultDataType, model.MsgCounterType)) { + cb(model.ResultDataType{}, 0) +} + +// --- LPC: Dim/Dimmed (Active Power Consumption Limit) ------------------------- + +// ATC_COM_PT_EGMessages_001/003 (LPC-TS-001/001-2): the EG sends an activated, +// then deactivated, consumption-limit write command. evcc's Dim writes a 0 W limit. +func TestLPC_EGMessages_ConsumptionLimit(t *testing.T) { + for _, tc := range []struct { + name string + dim bool + active bool + }{ + {"activate", true, true}, + {"deactivate", false, false}, + } { + t.Run("ATC_COM_PT_EGMessages_001_"+tc.name, func(t *testing.T) { + c, lpc, _, entity := newEGMeter(t) + lpc.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPCLimit).Return(true) + lpc.EXPECT(). + WriteConsumptionLimit(entity, ucapi.LoadLimit{Value: 0, IsActive: tc.active}, mock.Anything). + Run(ackWrite). + Return(new(model.MsgCounterType), nil) + + assert.NoError(t, c.Dim(tc.dim)) + }) + } +} + +// A rejected write (NACK) must surface as an error, not silent success. +func TestLPC_Dim_WriteRejected(t *testing.T) { + c, lpc, _, entity := newEGMeter(t) + lpc.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPCLimit).Return(true) + lpc.EXPECT(). + WriteConsumptionLimit(entity, mock.Anything, mock.Anything). + Run(func(_ spineapi.EntityRemoteInterface, _ ucapi.LoadLimit, cb func(model.ResultDataType, model.MsgCounterType)) { + n := model.ErrorNumberType(7) + cb(model.ResultDataType{ErrorNumber: &n}, 0) + }). + Return(new(model.MsgCounterType), nil) + + assert.Error(t, c.Dim(true)) +} + +// Dim is gated: no announced LPC scenario, or no connected entity → ErrNotAvailable. +func TestLPC_Dim_Gating(t *testing.T) { + t.Run("scenario_not_announced", func(t *testing.T) { + c, lpc, _, entity := newEGMeter(t) + lpc.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPCLimit).Return(false) + + assert.ErrorIs(t, c.Dim(true), api.ErrNotAvailable) + }) + + t.Run("entity_not_connected", func(t *testing.T) { + c, _, _, _ := newEGMeter(t) + c.egLpcEntity = nil + + assert.ErrorIs(t, c.Dim(true), api.ErrNotAvailable) + }) +} + +// Dimmed reports an active consumption limit. Dim always writes a fixed 0W +// limit, so only IsActive determines the dimmed state (a value-based check +// would never report dimmed or release it). +func TestLPC_Dimmed(t *testing.T) { + for _, tc := range []struct { + name string + limit ucapi.LoadLimit + want bool + }{ + {"active_positive", ucapi.LoadLimit{IsActive: true, Value: 4000}, true}, + {"active_zero", ucapi.LoadLimit{IsActive: true, Value: 0}, true}, + {"inactive", ucapi.LoadLimit{IsActive: false, Value: 4000}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + c, lpc, _, entity := newEGMeter(t) + lpc.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPCLimit).Return(true) + lpc.EXPECT().ConsumptionLimit(entity).Return(tc.limit, nil) + + got, err := c.Dimmed() + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// --- LPP: Curtail/Curtailed (Active Power Production Limit) ------------------- + +// ATC_COM_PT_EGMessages_001 (LPP-TS-001): the EG sends an activated/deactivated +// production-limit write command. LPP-TS-001 requires the value ≤ 0; evcc writes 0 W. +func TestLPP_EGMessages_ProductionLimit(t *testing.T) { + for _, tc := range []struct { + name string + percent int + active bool + }{ + {"activate", 0, true}, + {"deactivate", 100, false}, + } { + t.Run("ATC_COM_PT_EGMessages_001_"+tc.name, func(t *testing.T) { + c, _, lpp, entity := newEGMeter(t) + lpp.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPPLimit).Return(true) + if tc.active { + lpp.EXPECT().ProductionNominalMax(entity).Return(0.0, api.ErrNotAvailable) + } + lpp.EXPECT(). + WriteProductionLimit(entity, ucapi.LoadLimit{Value: 0, IsActive: tc.active}, mock.Anything). + Run(func(_ spineapi.EntityRemoteInterface, _ ucapi.LoadLimit, cb func(model.ResultDataType, model.MsgCounterType)) { + cb(model.ResultDataType{}, 0) + }). + Return(new(model.MsgCounterType), nil) + + assert.NoError(t, c.SetCurtailPercent(tc.percent)) + }) + } +} + +// A rejected write (NACK) must surface as an error, not silent success. +func TestLPP_Curtail_WriteRejected(t *testing.T) { + c, _, lpp, entity := newEGMeter(t) + lpp.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPPLimit).Return(true) + lpp.EXPECT().ProductionNominalMax(entity).Return(0.0, api.ErrNotAvailable) + lpp.EXPECT(). + WriteProductionLimit(entity, mock.Anything, mock.Anything). + Run(func(_ spineapi.EntityRemoteInterface, _ ucapi.LoadLimit, cb func(model.ResultDataType, model.MsgCounterType)) { + n := model.ErrorNumberType(7) + cb(model.ResultDataType{ErrorNumber: &n}, 0) + }). + Return(new(model.MsgCounterType), nil) + + assert.Error(t, c.SetCurtailPercent(0)) +} + +// SetCurtailPercent is gated the same way as Dim. +func TestLPP_SetCurtailPercent_Gating(t *testing.T) { + t.Run("scenario_not_announced", func(t *testing.T) { + c, _, lpp, entity := newEGMeter(t) + lpp.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPPLimit).Return(false) + + assert.ErrorIs(t, c.SetCurtailPercent(0), api.ErrNotAvailable) + }) + + t.Run("entity_not_connected", func(t *testing.T) { + c, _, _, _ := newEGMeter(t) + c.egLppEntity = nil + + assert.ErrorIs(t, c.SetCurtailPercent(0), api.ErrNotAvailable) + }) +} + +// Curtailed reports an active production limit. Per LPP-TS-001 valid values are ≤ 0, +// so a positive value is not treated as curtailed. +func TestLPP_Curtailed(t *testing.T) { + for _, tc := range []struct { + name string + limit ucapi.LoadLimit + want bool + }{ + {"active_negative", ucapi.LoadLimit{IsActive: true, Value: -2000}, true}, + {"active_zero", ucapi.LoadLimit{IsActive: true, Value: 0}, true}, + {"active_positive_invalid", ucapi.LoadLimit{IsActive: true, Value: 100}, false}, + {"inactive", ucapi.LoadLimit{IsActive: false, Value: -2000}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + c, _, lpp, entity := newEGMeter(t) + lpp.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPPLimit).Return(true) + lpp.EXPECT().ProductionLimit(entity).Return(tc.limit, nil) + + got, err := c.Curtailed() + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestLPC_LPP_NonCoverage records the Controllable-System and connection/heartbeat +// abstract test cases that belong to eebus-go and the evcc HEMS/charger, not the meter. +func TestLPC_LPP_NonCoverage(t *testing.T) { + for _, atc := range []string{ + "ATC_COM_PT_CSLimited_002", // Controllable System role → charger/HEMS + "ATC_COM_PT_CSFS_001", // failsafe values → hems/eebus + eebus-go + "ATC_COM_PT_EGHeartbeat_001", // heartbeat cadence → eebus-go + "ATC_COM_PT_EGConnection_001", // connection setup → eebus-go + "ATC_COM_PT_EGMessages_002", // resend-after-reboot/NACK → eebus-go + } { + t.Run(atc, func(t *testing.T) { + t.Skip("not applicable: covered by eebus-go or the evcc HEMS/charger, not the grid meter") + }) + } +} diff --git a/meter/eebus_mgcp_test.go b/meter/eebus_mgcp_test.go new file mode 100644 index 000000000..91a19c282 --- /dev/null +++ b/meter/eebus_mgcp_test.go @@ -0,0 +1,245 @@ +package meter + +// Conformance suite for EEBus MGCP TestSpec V1.0.1 ch.8 (Monitoring Appliance as DUT). +// evcc's grid meter is the MA; each ATC below maps to a subtest named by its ATC id. + +import ( + "testing" + + eebusapi "github.com/enbility/eebus-go/api" + mgcpmocks "github.com/enbility/eebus-go/usecases/mocks" + spineapi "github.com/enbility/spine-go/api" + spinemocks "github.com/enbility/spine-go/mocks" + "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/require" +) + +// newMGCPMeter wires an EEBus grid meter to a mocked MaMGCPInterface, as if a +// grid connection point had connected and announced its scenarios. +func newMGCPMeter(t *testing.T) (*EEBus, *mgcpmocks.MaMGCPInterface, spineapi.EntityRemoteInterface) { + t.Helper() + + mm := mgcpmocks.NewMaMGCPInterface(t) + entity := spinemocks.NewEntityRemoteInterface(t) + + c := &EEBus{ + log: util.NewLogger("eebus-mgcp-test"), + mm: mm, + maEntity: entity, + scenarios: mgcpScenarios, + } + + return c, mm, entity +} + +// nonNormalErrors model MGCP-TS-008: values in state "error"/"out of range" (or +// otherwise unusable) SHALL be ignored by the MA — evcc maps them to ErrNotAvailable. +var nonNormalErrors = []error{ + eebusapi.ErrDataInvalid, + eebusapi.ErrDataNotAvailable, + eebusapi.ErrMetadataNotAvailable, +} + +// SCE2: total active power (ATC_SCE2_*_MATotalActivePower_*) +func TestMGCP_SCE2_TotalActivePower(t *testing.T) { + // PT_001: state "normal"; MGCP-TS-010 consumption positive, production negative. + t.Run("ATC_SCE2_PT_MATotalActivePower_001", func(t *testing.T) { + for _, tc := range []struct { + dir string + value float64 + }{ + {"consume", 4200}, + {"produce", -3100}, + } { + t.Run(tc.dir, func(t *testing.T) { + c, mm, entity := newMGCPMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MGCPPower).Return(true) + mm.EXPECT().Power(entity).Return(tc.value, nil) + + got, err := c.CurrentPower() + require.NoError(t, err) + assert.Equal(t, tc.value, got) + }) + } + }) + + // NT_002: error/out-of-range value is discarded, never surfaced as a reading. + t.Run("ATC_SCE2_NT_MATotalActivePower_002", func(t *testing.T) { + for _, badErr := range nonNormalErrors { + t.Run(badErr.Error(), func(t *testing.T) { + c, mm, entity := newMGCPMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MGCPPower).Return(true) + mm.EXPECT().Power(entity).Return(0, badErr) + + _, err := c.CurrentPower() + assert.ErrorIs(t, err, api.ErrNotAvailable) + }) + } + }) +} + +// SCE4: total consumed energy (ATC_SCE4_*_MATotalConsumedEnergy_*) +func TestMGCP_SCE4_TotalConsumedEnergy(t *testing.T) { + // PT_001: state "normal" while consuming; positive value per MGCP-TS-010. + t.Run("ATC_SCE4_PT_MATotalConsumedEnergy_001", func(t *testing.T) { + c, mm, entity := newMGCPMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MGCPEnergyConsumed).Return(true) + mm.EXPECT().EnergyConsumed(entity).Return(12345.6, nil) + + got, err := c.TotalEnergy() + require.NoError(t, err) + assert.Equal(t, 12345.6, got) + }) + + // NT_002: error/out-of-range → discarded. + t.Run("ATC_SCE4_NT_MATotalConsumedEnergy_002", func(t *testing.T) { + for _, badErr := range nonNormalErrors { + t.Run(badErr.Error(), func(t *testing.T) { + c, mm, entity := newMGCPMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MGCPEnergyConsumed).Return(true) + mm.EXPECT().EnergyConsumed(entity).Return(0, badErr) + + _, err := c.TotalEnergy() + assert.ErrorIs(t, err, api.ErrNotAvailable) + }) + } + }) +} + +// SCE5: phase-specific AC current (ATC_SCE5_*_MAActiveACCurrent_*) +func TestMGCP_SCE5_ActiveACCurrent(t *testing.T) { + // PT_001/003/005 (phase A/B/C, "normal"): evcc reads all phases in one call, + // so a single Currents() covers the three per-phase positive cases. + t.Run("ATC_SCE5_PT_MAActiveACCurrent_001_003_005", func(t *testing.T) { + for _, tc := range []struct { + dir string + a, b, cc float64 + }{ + {"consume", 6.1, 6.2, 6.3}, + {"produce", -6.1, -6.2, -6.3}, + } { + t.Run(tc.dir, func(t *testing.T) { + c, mm, entity := newMGCPMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MGCPCurrentPerPhase).Return(true) + mm.EXPECT().CurrentPerPhase(entity).Return([]float64{tc.a, tc.b, tc.cc}, nil) + + l1, l2, l3, err := c.Currents() + require.NoError(t, err) + assert.Equal(t, []float64{tc.a, tc.b, tc.cc}, []float64{l1, l2, l3}) + }) + } + }) + + // NT_002/004/006: error/out-of-range → discarded. + t.Run("ATC_SCE5_NT_MAActiveACCurrent_002_004_006", func(t *testing.T) { + for _, badErr := range nonNormalErrors { + t.Run(badErr.Error(), func(t *testing.T) { + c, mm, entity := newMGCPMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MGCPCurrentPerPhase).Return(true) + mm.EXPECT().CurrentPerPhase(entity).Return(nil, badErr) + + _, _, _, err := c.Currents() + assert.ErrorIs(t, err, api.ErrNotAvailable) + }) + } + }) + + // MGCP-TS-006/7: only connected phases delivered; evcc pads to three phases. + t.Run("partial_phases_padded", func(t *testing.T) { + c, mm, entity := newMGCPMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MGCPCurrentPerPhase).Return(true) + mm.EXPECT().CurrentPerPhase(entity).Return([]float64{7.5}, nil) + + l1, l2, l3, err := c.Currents() + require.NoError(t, err) + assert.Equal(t, []float64{7.5, 0, 0}, []float64{l1, l2, l3}) + }) + + // Malformed data (>3 phases) must not be surfaced as a reading. + t.Run("too_many_phases_rejected", func(t *testing.T) { + c, mm, entity := newMGCPMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MGCPCurrentPerPhase).Return(true) + mm.EXPECT().CurrentPerPhase(entity).Return([]float64{1, 2, 3, 4}, nil) + + _, _, _, err := c.Currents() + assert.Error(t, err) + }) +} + +// SCE6: phase-specific AC voltage (ATC_SCE6_*_MAACVoltage_*) +func TestMGCP_SCE6_ACVoltage(t *testing.T) { + // PT_*: state "normal"; MGCP-TS-011 voltages independent of energy direction. + t.Run("ATC_SCE6_PT_MAACVoltage", func(t *testing.T) { + c, mm, entity := newMGCPMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MGCPVoltagePerPhase).Return(true) + mm.EXPECT().VoltagePerPhase(entity).Return([]float64{230.1, 229.8, 231.0}, nil) + + u1, u2, u3, err := c.Voltages() + require.NoError(t, err) + assert.Equal(t, []float64{230.1, 229.8, 231.0}, []float64{u1, u2, u3}) + }) + + // NT_*: error/out-of-range → discarded. + t.Run("ATC_SCE6_NT_MAACVoltage", func(t *testing.T) { + for _, badErr := range nonNormalErrors { + t.Run(badErr.Error(), func(t *testing.T) { + c, mm, entity := newMGCPMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MGCPVoltagePerPhase).Return(true) + mm.EXPECT().VoltagePerPhase(entity).Return(nil, badErr) + + _, _, _, err := c.Voltages() + assert.ErrorIs(t, err, api.ErrNotAvailable) + }) + } + }) +} + +// Availability gating: an unannounced scenario or unconnected entity yields +// ErrNotAvailable — the MA must not invent a value for an unsupported data point. +func TestMGCP_ScenarioGating(t *testing.T) { + t.Run("scenario_not_announced", func(t *testing.T) { + c, mm, entity := newMGCPMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MGCPPower).Return(false) + + _, err := c.CurrentPower() + assert.ErrorIs(t, err, api.ErrNotAvailable) + }) + + t.Run("entity_not_connected", func(t *testing.T) { + c, _, _ := newMGCPMeter(t) + c.maEntity = nil // GCP not (yet) connected + + _, err := c.CurrentPower() + assert.ErrorIs(t, err, api.ErrNotAvailable) + }) +} + +// MGCP-TS-009: the MA supports at least one of SCE2/3/4. evcc wires SCE2, SCE4 +// plus SCE5/SCE6; these compile-time assertions guard the capabilities. +var ( + _ api.Meter = (*EEBus)(nil) + _ api.MeterEnergy = (*EEBus)(nil) + _ api.PhaseCurrents = (*EEBus)(nil) + _ api.PhaseVoltages = (*EEBus)(nil) +) + +// TestMGCPNonCoverage records the MA abstract test cases intentionally out of +// scope for evcc's grid meter, keeping the coverage map visible in test output. +func TestMGCPNonCoverage(t *testing.T) { + for _, atc := range []string{ + "ATC_SCE1_PT_MAPowerLimitFactor_001", // power-limit factor not exposed by api.Meter + "ATC_SCE3_PT_MATotalFeedInEnergy_001", // feed-in energy: evcc reads consumed energy (SCE4) only + "ATC_SCE3_NT_MATotalFeedInEnergy_002", + "ATC_SCE7_PT_MAFrequency_001", // grid frequency not exposed by api.Meter + "ATC_SCE7_NT_MAFrequency_002", + "ATC_COM_PT_MAPolling_001", // polling cadence owned by eebus-go + "ATC_COM_PT_MANotification_001", // notification timing owned by eebus-go + } { + t.Run(atc, func(t *testing.T) { + t.Skip("not applicable: evcc grid meter does not expose this MGCP data point") + }) + } +} diff --git a/meter/eebus_mpc_test.go b/meter/eebus_mpc_test.go new file mode 100644 index 000000000..c5d1fdf13 --- /dev/null +++ b/meter/eebus_mpc_test.go @@ -0,0 +1,166 @@ +package meter + +// Conformance suite for EEBus MPC TestSpec V1.0.1 ch.8 (Monitoring Appliance as DUT). +// evcc's meter (non-grid usage) is the MA; it reads via MaMPCInterface. + +import ( + "testing" + + mpcmocks "github.com/enbility/eebus-go/usecases/mocks" + spineapi "github.com/enbility/spine-go/api" + spinemocks "github.com/enbility/spine-go/mocks" + "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/require" +) + +func newMPCMeter(t *testing.T) (*EEBus, *mpcmocks.MaMPCInterface, spineapi.EntityRemoteInterface) { + t.Helper() + + mm := mpcmocks.NewMaMPCInterface(t) + entity := spinemocks.NewEntityRemoteInterface(t) + + c := &EEBus{ + log: util.NewLogger("eebus-mpc-test"), + mm: mm, + maEntity: entity, + scenarios: mpcScenarios, + } + + return c, mm, entity +} + +// SCE1: total active power (ATC_SCE1_*_MATotalActivePower_*) +func TestMPC_SCE1_TotalActivePower(t *testing.T) { + // PT_001: state "normal"; MPC-TS-010 consumption positive, production negative. + t.Run("ATC_SCE1_PT_MATotalActivePower_001", func(t *testing.T) { + for _, tc := range []struct { + dir string + value float64 + }{ + {"consume", 3300}, + {"produce", -1800}, + } { + t.Run(tc.dir, func(t *testing.T) { + c, mm, entity := newMPCMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MPCPower).Return(true) + mm.EXPECT().Power(entity).Return(tc.value, nil) + + got, err := c.CurrentPower() + require.NoError(t, err) + assert.Equal(t, tc.value, got) + }) + } + }) + + // NT_002: error/out-of-range → discarded (MPC-TS-008). + t.Run("ATC_SCE1_NT_MATotalActivePower_002", func(t *testing.T) { + for _, badErr := range nonNormalErrors { + t.Run(badErr.Error(), func(t *testing.T) { + c, mm, entity := newMPCMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MPCPower).Return(true) + mm.EXPECT().Power(entity).Return(0, badErr) + + _, err := c.CurrentPower() + assert.ErrorIs(t, err, api.ErrNotAvailable) + }) + } + }) +} + +// SCE2: total consumed energy (ATC_SCE2_*_MATotalConsumedEnergy_*) +func TestMPC_SCE2_TotalConsumedEnergy(t *testing.T) { + t.Run("ATC_SCE2_PT_MATotalConsumedEnergy_001", func(t *testing.T) { + c, mm, entity := newMPCMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MPCEnergyConsumed).Return(true) + mm.EXPECT().EnergyConsumed(entity).Return(9876.5, nil) + + got, err := c.TotalEnergy() + require.NoError(t, err) + assert.Equal(t, 9876.5, got) + }) + + t.Run("ATC_SCE2_NT_MATotalConsumedEnergy_002", func(t *testing.T) { + for _, badErr := range nonNormalErrors { + t.Run(badErr.Error(), func(t *testing.T) { + c, mm, entity := newMPCMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MPCEnergyConsumed).Return(true) + mm.EXPECT().EnergyConsumed(entity).Return(0, badErr) + + _, err := c.TotalEnergy() + assert.ErrorIs(t, err, api.ErrNotAvailable) + }) + } + }) +} + +// SCE3: phase-specific AC current (ATC_SCE3_*_MAActiveACCurrent_*) +func TestMPC_SCE3_ActiveACCurrent(t *testing.T) { + // PT_001/003/005 (phase A/B/C, "normal") in one Currents() call. + t.Run("ATC_SCE3_PT_MAActiveACCurrent_001_003_005", func(t *testing.T) { + c, mm, entity := newMPCMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MPCCurrentPerPhase).Return(true) + mm.EXPECT().CurrentPerPhase(entity).Return([]float64{5.1, 5.2, 5.3}, nil) + + l1, l2, l3, err := c.Currents() + require.NoError(t, err) + assert.Equal(t, []float64{5.1, 5.2, 5.3}, []float64{l1, l2, l3}) + }) + + // NT_002/004/006: error/out-of-range → discarded. + t.Run("ATC_SCE3_NT_MAActiveACCurrent_002_004_006", func(t *testing.T) { + for _, badErr := range nonNormalErrors { + t.Run(badErr.Error(), func(t *testing.T) { + c, mm, entity := newMPCMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MPCCurrentPerPhase).Return(true) + mm.EXPECT().CurrentPerPhase(entity).Return(nil, badErr) + + _, _, _, err := c.Currents() + assert.ErrorIs(t, err, api.ErrNotAvailable) + }) + } + }) +} + +// SCE4: phase-specific AC voltage (ATC_SCE4_*_MAACVoltage_*) +func TestMPC_SCE4_ACVoltage(t *testing.T) { + t.Run("ATC_SCE4_PT_MAACVoltage", func(t *testing.T) { + c, mm, entity := newMPCMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MPCVoltagePerPhase).Return(true) + mm.EXPECT().VoltagePerPhase(entity).Return([]float64{230.0, 230.5, 229.5}, nil) + + u1, u2, u3, err := c.Voltages() + require.NoError(t, err) + assert.Equal(t, []float64{230.0, 230.5, 229.5}, []float64{u1, u2, u3}) + }) + + t.Run("ATC_SCE4_NT_MAACVoltage", func(t *testing.T) { + for _, badErr := range nonNormalErrors { + t.Run(badErr.Error(), func(t *testing.T) { + c, mm, entity := newMPCMeter(t) + mm.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.MPCVoltagePerPhase).Return(true) + mm.EXPECT().VoltagePerPhase(entity).Return(nil, badErr) + + _, _, _, err := c.Voltages() + assert.ErrorIs(t, err, api.ErrNotAvailable) + }) + } + }) +} + +// TestMPCNonCoverage records MPC MA abstract test cases out of scope for evcc. +func TestMPCNonCoverage(t *testing.T) { + for _, atc := range []string{ + "ATC_SCE1_PT_MAPhaseActivePower_001", // per-phase active power not exposed by api.Meter + "ATC_SCE2_PT_MATotalProducedEnergy_001", // produced energy not exposed (consumed only) + "ATC_SCE5_PT_MAFrequency_001", // grid frequency not exposed by api.Meter + "ATC_COM_PT_MAPolling_001", // polling cadence owned by eebus-go + "ATC_COM_PT_MANotification_001", // notification timing owned by eebus-go + } { + t.Run(atc, func(t *testing.T) { + t.Skip("not applicable: evcc meter does not expose this MPC data point") + }) + } +}