EEBus: add MGCP, MPC, LPC and LPP conformance tests (#31384)

This commit is contained in:
andig 2026-07-14 21:53:12 +02:00 • committed by GitHub
parent 3a2711cc4f
commit 36c422f7a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 794 additions and 0 deletions

231
meter/eebus_lpc_lpp_test.go Normal file
View file

@ -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")
})
}
}

245
meter/eebus_mgcp_test.go Normal file
View file

@ -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")
})
}
}

166
meter/eebus_mpc_test.go Normal file
View file

@ -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")
})
}
}