diff --git a/features/internal/deviceconfiguration.go b/features/internal/deviceconfiguration.go index a8ea257d..13850e97 100644 --- a/features/internal/deviceconfiguration.go +++ b/features/internal/deviceconfiguration.go @@ -51,7 +51,7 @@ func (d *DeviceConfigurationCommon) CheckEventPayloadDataForFilter(payloadData a for _, item := range data.DeviceConfigurationKeyValueData { if item.KeyId != nil && - *item.KeyId == *desc.KeyId || + *item.KeyId == *desc.KeyId && item.Value != nil { return true } diff --git a/usecases/eg/lpc/events.go b/usecases/eg/lpc/events.go index ef2a89bb..3e13824b 100644 --- a/usecases/eg/lpc/events.go +++ b/usecases/eg/lpc/events.go @@ -133,8 +133,11 @@ func (e *LPC) loadControlLimitDataUpdate(payload spineapi.EventPayload) { LimitDirection: util.Ptr(model.EnergyDirectionTypeConsume), ScopeType: util.Ptr(model.ScopeTypeTypeActivePowerLimit), } - if lc.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateLimit) + if lc.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.ConsumptionLimit(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateLimit) + } } } } @@ -155,12 +158,18 @@ func (e *LPC) configurationDataUpdate(payload spineapi.EventPayload) { filter := model.DeviceConfigurationKeyValueDescriptionDataType{ KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeConsumptionActivePowerLimit), } - if dc.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateFailsafeConsumptionActivePowerLimit) + if dc.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.FailsafeConsumptionActivePowerLimit(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateFailsafeConsumptionActivePowerLimit) + } } filter.KeyName = util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeDurationMinimum) - if dc.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateFailsafeDurationMinimum) + if dc.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.FailsafeDurationMinimum(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateFailsafeDurationMinimum) + } } } } diff --git a/usecases/eg/lpc/events_test.go b/usecases/eg/lpc/events_test.go index 023d5c6f..c8681299 100644 --- a/usecases/eg/lpc/events_test.go +++ b/usecases/eg/lpc/events_test.go @@ -1,6 +1,8 @@ package lpc import ( + "time" + spineapi "github.com/enbility/spine-go/api" "github.com/enbility/spine-go/model" "github.com/enbility/spine-go/util" @@ -124,14 +126,23 @@ func (s *EgLPCSuite) Test_loadControlLimitDataUpdate() { data = &model.LoadControlLimitListDataType{ LoadControlLimitData: []model.LoadControlLimitDataType{ { - LimitId: util.Ptr(model.LoadControlLimitIdType(0)), - Value: model.NewScaledNumberType(16), + LimitId: util.Ptr(model.LoadControlLimitIdType(0)), + IsLimitChangeable: util.Ptr(true), + IsLimitActive: util.Ptr(false), + Value: model.NewScaledNumberType(6000), + TimePeriod: &model.TimePeriodType{ + EndTime: model.NewAbsoluteOrRelativeTimeType("PT2H"), + }, }, }, } payload.Data = data + // Update the feature with the data so it's actually stored + _, fErr = rFeature.UpdateData(true, model.FunctionTypeLoadControlLimitListData, data, nil, nil) + assert.Nil(s.T(), fErr) + s.sut.loadControlLimitDataUpdate(payload) assert.True(s.T(), s.eventCalled) } @@ -148,12 +159,14 @@ func (s *EgLPCSuite) Test_configurationDataUpdate() { descData := &model.DeviceConfigurationKeyValueDescriptionListDataType{ DeviceConfigurationKeyValueDescriptionData: []model.DeviceConfigurationKeyValueDescriptionDataType{ { - KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(1)), - KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeConsumptionActivePowerLimit), + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(1)), + KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeConsumptionActivePowerLimit), + ValueType: util.Ptr(model.DeviceConfigurationKeyValueTypeTypeScaledNumber), }, { - KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(2)), - KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeDurationMinimum), + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(2)), + KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeDurationMinimum), + ValueType: util.Ptr(model.DeviceConfigurationKeyValueTypeTypeDuration), }, }, } @@ -178,17 +191,25 @@ func (s *EgLPCSuite) Test_configurationDataUpdate() { DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ { KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(1)), - Value: &model.DeviceConfigurationKeyValueValueType{}, + Value: &model.DeviceConfigurationKeyValueValueType{ + ScaledNumber: model.NewScaledNumberType(6000), + }, }, { KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(2)), - Value: &model.DeviceConfigurationKeyValueValueType{}, + Value: &model.DeviceConfigurationKeyValueValueType{ + Duration: model.NewDurationType(time.Hour * 10), + }, }, }, } payload.Data = data + // Update the feature with the data so it's actually stored + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, data, nil, nil) + assert.Nil(s.T(), fErr) + s.sut.configurationDataUpdate(payload) assert.True(s.T(), s.eventCalled) } diff --git a/usecases/eg/lpc/public.go b/usecases/eg/lpc/public.go index 8b939cfd..ff6367c0 100644 --- a/usecases/eg/lpc/public.go +++ b/usecases/eg/lpc/public.go @@ -285,7 +285,7 @@ func (e *LPC) ConsumptionNominalMax(entity spineapi.EntityRemoteInterface) (floa data, err := electricalConnection.GetCharacteristicsForFilter(filter) if err != nil { return 0, err - } else if len(data) == 0 || data[0].Value == nil { + } else if len(data) != 1 || data[0].CharacteristicId == nil || data[0].Value == nil { return 0, api.ErrDataNotAvailable } diff --git a/usecases/eg/lpc/public_test.go b/usecases/eg/lpc/public_test.go index fff9c640..5d3bd676 100644 --- a/usecases/eg/lpc/public_test.go +++ b/usecases/eg/lpc/public_test.go @@ -3,8 +3,10 @@ package lpc import ( "time" + "github.com/enbility/eebus-go/api" "github.com/enbility/eebus-go/features/client" ucapi "github.com/enbility/eebus-go/usecases/api" + spinemocks "github.com/enbility/spine-go/mocks" "github.com/enbility/spine-go/model" "github.com/enbility/spine-go/util" "github.com/stretchr/testify/assert" @@ -417,3 +419,651 @@ func (s *EgLPCSuite) Test_PowerConsumptionNominalMax() { assert.Nil(s.T(), err) assert.Equal(s.T(), 8000.0, data) } + +// Integration tests for EG LPC validation with malformed data from CS + +func (s *EgLPCSuite) Test_ConsumptionLimit_ValidationErrors() { + // Setup LoadControl description first + descData := &model.LoadControlLimitDescriptionListDataType{ + LoadControlLimitDescriptionData: []model.LoadControlLimitDescriptionDataType{ + { + LimitId: util.Ptr(model.LoadControlLimitIdType(0)), + LimitCategory: util.Ptr(model.LoadControlCategoryTypeObligation), + LimitType: util.Ptr(model.LoadControlLimitTypeTypeSignDependentAbsValueLimit), + LimitDirection: util.Ptr(model.EnergyDirectionTypeConsume), + ScopeType: util.Ptr(model.ScopeTypeTypeActivePowerLimit), + }, + }, + } + + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.monitoredEntity, model.FeatureTypeTypeLoadControl, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeLoadControlLimitDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test 1: Missing LimitId - should return ErrDataInvalid when validator runs + // Note: Data must include Value to pass the `value.Value == nil` check in public.go:59 + invalidLimitData := &model.LoadControlLimitListDataType{ + LoadControlLimitData: []model.LoadControlLimitDataType{ + { + // LimitId missing - this should trigger validation error + IsLimitChangeable: util.Ptr(true), + IsLimitActive: util.Ptr(false), + Value: model.NewScaledNumberType(6000), // Include value to pass initial check + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeLoadControlLimitListData, invalidLimitData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err := s.sut.ConsumptionLimit(s.monitoredEntity) + // This will actually return ErrDataNotAvailable because GetLimitDataForId won't find matching ID + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data.Value) + assert.Equal(s.T(), false, data.IsChangeable) + assert.Equal(s.T(), false, data.IsActive) + + // Test 2: Valid ID but missing Value - should return ErrDataNotAvailable from public.go:59 + invalidLimitData = &model.LoadControlLimitListDataType{ + LoadControlLimitData: []model.LoadControlLimitDataType{ + { + LimitId: util.Ptr(model.LoadControlLimitIdType(0)), + IsLimitChangeable: util.Ptr(true), + IsLimitActive: util.Ptr(false), + // Value missing - will cause ErrDataNotAvailable from public.go:59 + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeLoadControlLimitListData, invalidLimitData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.ConsumptionLimit(s.monitoredEntity) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data.Value) + + // Test 3: Negative Value - per spec, EG should accept values from CS without range validation + invalidLimitData = &model.LoadControlLimitListDataType{ + LoadControlLimitData: []model.LoadControlLimitDataType{ + { + LimitId: util.Ptr(model.LoadControlLimitIdType(0)), + IsLimitChangeable: util.Ptr(true), + IsLimitActive: util.Ptr(false), + Value: model.NewScaledNumberType(-5000), // Negative value + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeLoadControlLimitListData, invalidLimitData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.ConsumptionLimit(s.monitoredEntity) + assert.Nil(s.T(), err) // Should accept negative value per spec + assert.Equal(s.T(), -5000.0, data.Value) + + // Test 4: Excessive Value - per spec, EG should accept values from CS without range validation + invalidLimitData = &model.LoadControlLimitListDataType{ + LoadControlLimitData: []model.LoadControlLimitDataType{ + { + LimitId: util.Ptr(model.LoadControlLimitIdType(0)), + IsLimitChangeable: util.Ptr(true), + IsLimitActive: util.Ptr(false), + Value: model.NewScaledNumberType(2000000), // Excessive value (2MW > 1MW limit) + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeLoadControlLimitListData, invalidLimitData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.ConsumptionLimit(s.monitoredEntity) + assert.Nil(s.T(), err) // Should accept excessive value per spec + assert.Equal(s.T(), 2000000.0, data.Value) + + // Test 5: Valid data after validation errors - should work correctly + validLimitData := &model.LoadControlLimitListDataType{ + LoadControlLimitData: []model.LoadControlLimitDataType{ + { + LimitId: util.Ptr(model.LoadControlLimitIdType(0)), + IsLimitChangeable: util.Ptr(true), + IsLimitActive: util.Ptr(false), + Value: model.NewScaledNumberType(6000), + TimePeriod: &model.TimePeriodType{ + EndTime: model.NewAbsoluteOrRelativeTimeType("PT2H"), + }, + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeLoadControlLimitListData, validLimitData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.ConsumptionLimit(s.monitoredEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 6000.0, data.Value) + assert.Equal(s.T(), true, data.IsChangeable) + assert.Equal(s.T(), false, data.IsActive) +} + +func (s *EgLPCSuite) Test_FailsafeConsumptionActivePowerLimit_ValidationErrors() { + // Setup DeviceConfiguration description first + descData := &model.DeviceConfigurationKeyValueDescriptionListDataType{ + DeviceConfigurationKeyValueDescriptionData: []model.DeviceConfigurationKeyValueDescriptionDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeConsumptionActivePowerLimit), + ValueType: util.Ptr(model.DeviceConfigurationKeyValueTypeTypeScaledNumber), + }, + }, + } + + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.monitoredEntity, model.FeatureTypeTypeDeviceConfiguration, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test 1: Missing KeyId - should return ErrDataNotAvailable (can't match filter) + invalidKeyData := &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + // KeyId missing - GetKeyValueDataForFilter won't find matching data + Value: &model.DeviceConfigurationKeyValueValueType{ + ScaledNumber: model.NewScaledNumberType(4000), + }, + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, invalidKeyData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err := s.sut.FailsafeConsumptionActivePowerLimit(s.monitoredEntity) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data) + + // Test 2: Missing Value - should return ErrDataNotAvailable (public.go:129 check) + invalidKeyData = &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + // Value missing - public.go:129 will return ErrDataNotAvailable + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, invalidKeyData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.FailsafeConsumptionActivePowerLimit(s.monitoredEntity) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data) + + // Test 3: Empty Value object - should return ErrDataNotAvailable (public.go:129 check) + invalidKeyData = &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + Value: &model.DeviceConfigurationKeyValueValueType{ + // No ScaledNumber - public.go:129 will return ErrDataNotAvailable + }, + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, invalidKeyData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.FailsafeConsumptionActivePowerLimit(s.monitoredEntity) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data) + + // Test 4: Negative ScaledNumber value - per spec, EG should accept values from CS without range validation + invalidKeyData = &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + Value: &model.DeviceConfigurationKeyValueValueType{ + ScaledNumber: model.NewScaledNumberType(-1000), // Negative value + }, + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, invalidKeyData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.FailsafeConsumptionActivePowerLimit(s.monitoredEntity) + assert.Nil(s.T(), err) // Should accept negative value per spec + assert.Equal(s.T(), -1000.0, data) + + // Test 5: Excessive ScaledNumber value - per spec, EG should accept values from CS without range validation + invalidKeyData = &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + Value: &model.DeviceConfigurationKeyValueValueType{ + ScaledNumber: model.NewScaledNumberType(150000), // Excessive value (>100kW) + }, + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, invalidKeyData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.FailsafeConsumptionActivePowerLimit(s.monitoredEntity) + assert.Nil(s.T(), err) // Should accept excessive value per spec + assert.Equal(s.T(), 150000.0, data) + + // Test 6: Valid data after validation errors - should work correctly + validKeyData := &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + Value: &model.DeviceConfigurationKeyValueValueType{ + ScaledNumber: model.NewScaledNumberType(4000), + }, + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, validKeyData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.FailsafeConsumptionActivePowerLimit(s.monitoredEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 4000.0, data) +} + +func (s *EgLPCSuite) Test_FailsafeDurationMinimum_ValidationErrors() { + // Setup DeviceConfiguration description first + descData := &model.DeviceConfigurationKeyValueDescriptionListDataType{ + DeviceConfigurationKeyValueDescriptionData: []model.DeviceConfigurationKeyValueDescriptionDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeDurationMinimum), + ValueType: util.Ptr(model.DeviceConfigurationKeyValueTypeTypeDuration), + }, + }, + } + + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.monitoredEntity, model.FeatureTypeTypeDeviceConfiguration, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test 1: Missing KeyId - should return ErrDataNotAvailable (can't match filter) + invalidKeyData := &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + // KeyId missing - GetKeyValueDataForFilter won't find matching data + Value: &model.DeviceConfigurationKeyValueValueType{ + Duration: model.NewDurationType(time.Hour * 2), + }, + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, invalidKeyData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err := s.sut.FailsafeDurationMinimum(s.monitoredEntity) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), time.Duration(0), data) + + // Test 2: Missing Value - should return ErrDataNotAvailable (public.go:200 check) + invalidKeyData = &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + // Value missing - public.go:200 will return ErrDataNotAvailable + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, invalidKeyData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.FailsafeDurationMinimum(s.monitoredEntity) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), time.Duration(0), data) + + // Test 3: Empty Value object - should return ErrDataNotAvailable (public.go:200 check) + invalidKeyData = &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + Value: &model.DeviceConfigurationKeyValueValueType{ + // No Duration - public.go:200 will return ErrDataNotAvailable + }, + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, invalidKeyData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.FailsafeDurationMinimum(s.monitoredEntity) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), time.Duration(0), data) + + // Test 4: Duration below 2h - should be accepted when reading (EG trusts CS data) + shortDurationData := &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + Value: &model.DeviceConfigurationKeyValueValueType{ + Duration: model.NewDurationType(time.Hour * 1), // 1 hour + }, + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, shortDurationData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.FailsafeDurationMinimum(s.monitoredEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), time.Hour*1, data) + + // Test 5: Duration above 24h - should be accepted when reading (EG trusts CS data) + longDurationData := &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + Value: &model.DeviceConfigurationKeyValueValueType{ + Duration: model.NewDurationType(time.Hour * 25), // 25 hours + }, + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, longDurationData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.FailsafeDurationMinimum(s.monitoredEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), time.Hour*25, data) + + // Test 6: Valid data after validation errors - should work correctly + validKeyData := &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + Value: &model.DeviceConfigurationKeyValueValueType{ + Duration: model.NewDurationType(time.Hour * 2), + }, + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, validKeyData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.FailsafeDurationMinimum(s.monitoredEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), time.Duration(time.Hour*2), data) +} + +func (s *EgLPCSuite) Test_ConsumptionNominalMax_ValidationErrors() { + // Test 1: Missing CharacteristicId - should return ErrDataInvalid + invalidCharData := &model.ElectricalConnectionCharacteristicListDataType{ + ElectricalConnectionCharacteristicData: []model.ElectricalConnectionCharacteristicDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + // CharacteristicId missing + CharacteristicContext: util.Ptr(model.ElectricalConnectionCharacteristicContextTypeEntity), + CharacteristicType: util.Ptr(model.ElectricalConnectionCharacteristicTypeTypePowerConsumptionNominalMax), // EVSE expects Power + Value: model.NewScaledNumberType(8000), + }, + }, + } + + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.monitoredEntity, model.FeatureTypeTypeElectricalConnection, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeElectricalConnectionCharacteristicListData, invalidCharData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err := s.sut.ConsumptionNominalMax(s.monitoredEntity) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) // no CharacteristicId -> treated as not available + assert.Equal(s.T(), 0.0, data) + + // Test 2: Wrong CharacteristicType - should return ErrDataNotAvailable (filtered out) + invalidCharData = &model.ElectricalConnectionCharacteristicListDataType{ + ElectricalConnectionCharacteristicData: []model.ElectricalConnectionCharacteristicDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + CharacteristicId: util.Ptr(model.ElectricalConnectionCharacteristicIdType(0)), + CharacteristicContext: util.Ptr(model.ElectricalConnectionCharacteristicContextTypeEntity), + CharacteristicType: util.Ptr(model.ElectricalConnectionCharacteristicTypeTypePowerProductionNominalMax), // Wrong type - won't match filter + Value: model.NewScaledNumberType(8000), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeElectricalConnectionCharacteristicListData, invalidCharData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.ConsumptionNominalMax(s.monitoredEntity) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) // Filtered out, so no data found + assert.Equal(s.T(), 0.0, data) + + // Test 3: Wrong CharacteristicContext - should return ErrDataNotAvailable (filtered out) + invalidCharData = &model.ElectricalConnectionCharacteristicListDataType{ + ElectricalConnectionCharacteristicData: []model.ElectricalConnectionCharacteristicDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + CharacteristicId: util.Ptr(model.ElectricalConnectionCharacteristicIdType(0)), + CharacteristicContext: util.Ptr(model.ElectricalConnectionCharacteristicContextTypeDevice), // Wrong context - won't match filter + CharacteristicType: util.Ptr(model.ElectricalConnectionCharacteristicTypeTypeContractualConsumptionNominalMax), + Value: model.NewScaledNumberType(8000), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeElectricalConnectionCharacteristicListData, invalidCharData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.ConsumptionNominalMax(s.monitoredEntity) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) // Filtered out, so no data found + assert.Equal(s.T(), 0.0, data) + + // Test 4: Negative Value - per spec, EG should accept values from CS without range validation + invalidCharData = &model.ElectricalConnectionCharacteristicListDataType{ + ElectricalConnectionCharacteristicData: []model.ElectricalConnectionCharacteristicDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + CharacteristicId: util.Ptr(model.ElectricalConnectionCharacteristicIdType(0)), + CharacteristicContext: util.Ptr(model.ElectricalConnectionCharacteristicContextTypeEntity), + CharacteristicType: util.Ptr(model.ElectricalConnectionCharacteristicTypeTypePowerConsumptionNominalMax), // EVSE expects Power + Value: model.NewScaledNumberType(-5000), // Negative value + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeElectricalConnectionCharacteristicListData, invalidCharData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.ConsumptionNominalMax(s.monitoredEntity) + assert.Nil(s.T(), err) // Should accept negative value per spec + assert.Equal(s.T(), -5000.0, data) + + // Test 5: Excessive Value - per spec, EG should accept values from CS without range validation + invalidCharData = &model.ElectricalConnectionCharacteristicListDataType{ + ElectricalConnectionCharacteristicData: []model.ElectricalConnectionCharacteristicDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + CharacteristicId: util.Ptr(model.ElectricalConnectionCharacteristicIdType(0)), + CharacteristicContext: util.Ptr(model.ElectricalConnectionCharacteristicContextTypeEntity), + CharacteristicType: util.Ptr(model.ElectricalConnectionCharacteristicTypeTypePowerConsumptionNominalMax), // EVSE expects Power + Value: model.NewScaledNumberType(2000000), // Excessive value (2MW > 1MW limit) + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeElectricalConnectionCharacteristicListData, invalidCharData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.ConsumptionNominalMax(s.monitoredEntity) + assert.Nil(s.T(), err) // Should accept excessive value per spec + assert.Equal(s.T(), 2000000.0, data) + + // Test 6: Valid PowerConsumptionNominalMax data - should work correctly + // monitoredEntity is an EVSE, so characteristicType resolves to PowerConsumptionNominalMax + validCharData := &model.ElectricalConnectionCharacteristicListDataType{ + ElectricalConnectionCharacteristicData: []model.ElectricalConnectionCharacteristicDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + CharacteristicId: util.Ptr(model.ElectricalConnectionCharacteristicIdType(0)), + CharacteristicContext: util.Ptr(model.ElectricalConnectionCharacteristicContextTypeEntity), + CharacteristicType: util.Ptr(model.ElectricalConnectionCharacteristicTypeTypePowerConsumptionNominalMax), + Value: model.NewScaledNumberType(8000), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeElectricalConnectionCharacteristicListData, validCharData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.ConsumptionNominalMax(s.monitoredEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 8000.0, data) + + // Test 7: Valid PowerConsumptionNominalMax data - should work correctly + // monitoredEntity is an EVSE, so characteristicType resolves to PowerConsumptionNominalMax + validCharData = &model.ElectricalConnectionCharacteristicListDataType{ + ElectricalConnectionCharacteristicData: []model.ElectricalConnectionCharacteristicDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + CharacteristicId: util.Ptr(model.ElectricalConnectionCharacteristicIdType(0)), + CharacteristicContext: util.Ptr(model.ElectricalConnectionCharacteristicContextTypeEntity), + CharacteristicType: util.Ptr(model.ElectricalConnectionCharacteristicTypeTypePowerConsumptionNominalMax), + Value: model.NewScaledNumberType(12000), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeElectricalConnectionCharacteristicListData, validCharData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.ConsumptionNominalMax(s.monitoredEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 12000.0, data) +} + +// Additional tests to improve coverage for low-coverage functions + +func (s *EgLPCSuite) Test_IsHeartbeatWithinDuration_ErrorCase() { + // Test with an incompatible entity that can't create DeviceDiagnosis + result := s.sut.IsHeartbeatWithinDuration(s.mockRemoteEntity) + assert.False(s.T(), result) +} + +func (s *EgLPCSuite) Test_FailsafeConsumptionActivePowerLimit_ValidationError() { + // Setup device configuration description first + descData := &model.DeviceConfigurationKeyValueDescriptionListDataType{ + DeviceConfigurationKeyValueDescriptionData: []model.DeviceConfigurationKeyValueDescriptionDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeConsumptionActivePowerLimit), + ValueType: util.Ptr(model.DeviceConfigurationKeyValueTypeTypeScaledNumber), + }, + }, + } + + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.monitoredEntity, model.FeatureTypeTypeDeviceConfiguration, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test with invalid data that has missing KeyId (won't match filter so ErrDataNotAvailable) + keyData := &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + // KeyId missing - GetKeyValueDataForFilter won't find matching data + Value: &model.DeviceConfigurationKeyValueValueType{ + ScaledNumber: model.NewScaledNumberType(4000), + }, + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, keyData, nil, nil) + assert.Nil(s.T(), fErr) + + // Since the filter won't match, this should return ErrDataNotAvailable + data, err := s.sut.FailsafeConsumptionActivePowerLimit(s.monitoredEntity) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data) +} + +func (s *EgLPCSuite) Test_characteristicType_EdgeCases() { + // nil entity -> default to power consumption nominal max + result := s.sut.characteristicType(nil) + assert.Equal(s.T(), model.ElectricalConnectionCharacteristicTypeTypePowerConsumptionNominalMax, result) + + // non-CEM entity -> power consumption nominal max + mockEntity1 := &spinemocks.EntityRemoteInterface{} + mockEntity1.EXPECT().EntityType().Return(model.EntityTypeTypeEVSE).Once() + + result = s.sut.characteristicType(mockEntity1) + assert.Equal(s.T(), model.ElectricalConnectionCharacteristicTypeTypePowerConsumptionNominalMax, result) + + // CEM entity -> contractual consumption nominal max + mockEntity2 := &spinemocks.EntityRemoteInterface{} + mockEntity2.EXPECT().EntityType().Return(model.EntityTypeTypeCEM).Once() + + result = s.sut.characteristicType(mockEntity2) + assert.Equal(s.T(), model.ElectricalConnectionCharacteristicTypeTypeContractualConsumptionNominalMax, result) +} + +func (s *EgLPCSuite) Test_ConsumptionNominalMax_ErrorCases() { + // Test with empty characteristics array + charData := &model.ElectricalConnectionCharacteristicListDataType{ + ElectricalConnectionCharacteristicData: []model.ElectricalConnectionCharacteristicDataType{}, + } + + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.monitoredEntity, model.FeatureTypeTypeElectricalConnection, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeElectricalConnectionCharacteristicListData, charData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err := s.sut.ConsumptionNominalMax(s.monitoredEntity) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data) + + // Test with characteristic that has nil Value + charData = &model.ElectricalConnectionCharacteristicListDataType{ + ElectricalConnectionCharacteristicData: []model.ElectricalConnectionCharacteristicDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + CharacteristicId: util.Ptr(model.ElectricalConnectionCharacteristicIdType(0)), + CharacteristicContext: util.Ptr(model.ElectricalConnectionCharacteristicContextTypeEntity), + CharacteristicType: util.Ptr(model.ElectricalConnectionCharacteristicTypeTypePowerConsumptionNominalMax), + Value: nil, // Nil value should trigger ErrDataNotAvailable + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeElectricalConnectionCharacteristicListData, charData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.ConsumptionNominalMax(s.monitoredEntity) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data) + + // Test with characteristic that fails validation (missing CharacteristicId) + charData = &model.ElectricalConnectionCharacteristicListDataType{ + ElectricalConnectionCharacteristicData: []model.ElectricalConnectionCharacteristicDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + // CharacteristicId missing - should trigger validation error + CharacteristicContext: util.Ptr(model.ElectricalConnectionCharacteristicContextTypeEntity), + CharacteristicType: util.Ptr(model.ElectricalConnectionCharacteristicTypeTypePowerConsumptionNominalMax), + Value: model.NewScaledNumberType(8000), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeElectricalConnectionCharacteristicListData, charData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.ConsumptionNominalMax(s.monitoredEntity) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) // missing CharacteristicId -> not available + assert.Equal(s.T(), 0.0, data) +} diff --git a/usecases/internal/measurement.go b/usecases/internal/measurement.go index 60de626f..ed136c96 100644 --- a/usecases/internal/measurement.go +++ b/usecases/internal/measurement.go @@ -1,6 +1,7 @@ package internal import ( + "fmt" "slices" "github.com/enbility/eebus-go/api" @@ -21,6 +22,7 @@ func MeasurementPhaseSpecificDataForFilter( measurementFilter model.MeasurementDescriptionDataType, energyDirection model.EnergyDirectionType, validPhaseNameTypes []model.ElectricalConnectionPhaseNameType, + validator MeasurementValidator, ) ([]float64, error) { measurement, err := client.NewMeasurement(localEntity, remoteEntity) electricalConnection, err1 := client.NewElectricalConnection(localEntity, remoteEntity) @@ -36,7 +38,10 @@ func MeasurementPhaseSpecificDataForFilter( var result []float64 for _, item := range data { - if item.Value == nil || item.MeasurementId == nil { + // Skip measurements that fail validation. This also covers + // MGCP-003 / MPC-003 (values with state error or outOfRange + // SHALL be ignored) via the SkipValueState rule in scenario validators. + if err := validator.Validate(&item); err != nil { continue } @@ -67,15 +72,11 @@ func MeasurementPhaseSpecificDataForFilter( } } - // if the value state is set and not normal, the value is not valid and should be ignored - // therefore we return an error - if item.ValueState != nil && *item.ValueState != model.MeasurementValueStateTypeNormal { - return nil, api.ErrDataInvalid - } - - value := item.Value.GetValue() + result = append(result, item.Value.GetValue()) + } - result = append(result, value) + if len(result) == 0 { + return nil, api.ErrDataNotAvailable } return result, nil @@ -106,3 +107,143 @@ func GetPowerTotalMeasurementId(localEntity spineapi.EntityLocalInterface) model return *MeasurementDescriptionData[0].MeasurementId } + +// ======================================== +// Measurement-Specific Validation +// ======================================== + +// MeasurementValidator is a specialized validator for MeasurementDataType. +type MeasurementValidator struct { + BaseValidator[*model.MeasurementDataType] +} + +// NewMeasurementValidator creates a new measurement validator. +func NewMeasurementValidator() MeasurementValidator { + return MeasurementValidator{} +} + +// WithRule adds a validation rule. +func (v MeasurementValidator) WithRule(rule ValidationRule[*model.MeasurementDataType]) MeasurementValidator { + v.BaseValidator.rules = append(v.BaseValidator.rules, rule) + return v +} + +// WithName sets the validator name for better error messages. +func (v MeasurementValidator) WithName(name string) MeasurementValidator { + v.BaseValidator.name = name + return v +} + +// Validate applies all rules to a single measurement. +func (v MeasurementValidator) Validate(m *model.MeasurementDataType) error { + return v.BaseValidator.Validate(m) +} + +// FindFirstValidItem returns the first measurement in items that passes all rules. +func (v MeasurementValidator) FindFirstValidItem(items []*model.MeasurementDataType) (*model.MeasurementDataType, error) { + return v.BaseValidator.FindFirstValidItem(items) +} + +// GetMeasurementValue returns the first valid measurement's numeric value. +// Returns api.ErrDataNotAvailable if no valid measurements are found. +func GetMeasurementValue(measurements []model.MeasurementDataType, validator MeasurementValidator) (float64, error) { + ptrMeasurements := make([]*model.MeasurementDataType, len(measurements)) + for i := range measurements { + ptrMeasurements[i] = &measurements[i] + } + + valid, err := validator.FindFirstValidItem(ptrMeasurements) + if err != nil || valid == nil || valid.Value == nil { + return 0, api.ErrDataNotAvailable + } + + return valid.Value.GetValue(), nil +} + +// Measurement-specific validation rules + +// RequireMeasurementId ensures MeasurementId is present. +func RequireMeasurementId() ValidationRule[*model.MeasurementDataType] { + return RequireField( + func(m *model.MeasurementDataType) *model.MeasurementIdType { return m.MeasurementId }, + "MeasurementId", + ) +} + +// RequireMeasurementValue ensures Value is present. +func RequireMeasurementValue() ValidationRule[*model.MeasurementDataType] { + return RequireScaledNumber( + func(m *model.MeasurementDataType) *model.ScaledNumberType { return m.Value }, + "Value", + ) +} + +// RequireValueType ensures ValueType matches expected type. +func RequireValueType(expected model.MeasurementValueTypeType) ValidationRule[*model.MeasurementDataType] { + return func(m *model.MeasurementDataType) error { + if m.ValueType == nil { + return fmt.Errorf("ValueType is required to be %s", expected) + } + if *m.ValueType != expected { + return fmt.Errorf("ValueType must be %s, got %s", expected, *m.ValueType) + } + return nil + } +} + +// RequireValueSource ensures ValueSource is present and one of the allowed types. +// Callers must pass at least one allowed value. +func RequireValueSource(allowed ...model.MeasurementValueSourceType) ValidationRule[*model.MeasurementDataType] { + if len(allowed) == 0 { + panic("RequireValueSource requires at least one allowed value") + } + return func(m *model.MeasurementDataType) error { + if m.ValueSource == nil { + return fmt.Errorf("ValueSource is required") + } + if slices.Contains(allowed, *m.ValueSource) { + return nil + } + return fmt.Errorf("ValueSource must be one of %v, got %s", allowed, *m.ValueSource) + } +} + +// ValidateValueState validates the value state, optionally requiring presence. +func ValidateValueState(expected model.MeasurementValueStateType, required bool) ValidationRule[*model.MeasurementDataType] { + return func(m *model.MeasurementDataType) error { + if m.ValueState == nil { + if required { + return fmt.Errorf("ValueState is required") + } + return nil + } + if expected != "" && *m.ValueState != expected { + return fmt.Errorf("ValueState must be %s, got %s", expected, *m.ValueState) + } + return nil + } +} + +// ValidateMeasurementRange ensures the measurement value is within range. +func ValidateMeasurementRange(minVal, maxVal float64) ValidationRule[*model.MeasurementDataType] { + return ValidateRange( + func(m *model.MeasurementDataType) *model.ScaledNumberType { return m.Value }, + minVal, maxVal, + "Measurement value", + ) +} + +// SkipValueState implements MGCP-003 / MPC-003: measurements with ValueState +// "outOfRange" or "error" SHALL be ignored by the Monitoring Appliance. +func SkipValueState() ValidationRule[*model.MeasurementDataType] { + return func(m *model.MeasurementDataType) error { + if m.ValueState == nil { + return nil + } + if *m.ValueState == model.MeasurementValueStateTypeError || + *m.ValueState == model.MeasurementValueStateTypeOutofrange { + return ErrSkipMeasurement + } + return nil + } +} diff --git a/usecases/internal/measurement_test.go b/usecases/internal/measurement_test.go index 7cbae1e6..132a9173 100644 --- a/usecases/internal/measurement_test.go +++ b/usecases/internal/measurement_test.go @@ -20,7 +20,14 @@ func (s *InternalSuite) Test_MeasurementPhaseSpecificDataForFilter() { ScopeType: &scopeType, } - data, err := MeasurementPhaseSpecificDataForFilter(nil, nil, filter, energyDirection, ucapi.PhaseNameMapping) + // Create a simple test validator that includes ValueState validation + testValidator := NewMeasurementValidator(). + WithName("Test"). + WithRule(RequireMeasurementId()). + WithRule(RequireMeasurementValue()). + WithRule(SkipValueState()) // Skip measurements with error or out-of-range states + + data, err := MeasurementPhaseSpecificDataForFilter(nil, nil, filter, energyDirection, ucapi.PhaseNameMapping, testValidator) assert.NotNil(s.T(), err) assert.Nil(s.T(), data) @@ -30,6 +37,7 @@ func (s *InternalSuite) Test_MeasurementPhaseSpecificDataForFilter() { filter, energyDirection, ucapi.PhaseNameMapping, + testValidator, ) assert.NotNil(s.T(), err) assert.Nil(s.T(), data) @@ -40,6 +48,7 @@ func (s *InternalSuite) Test_MeasurementPhaseSpecificDataForFilter() { filter, energyDirection, ucapi.PhaseNameMapping, + testValidator, ) assert.NotNil(s.T(), err) assert.Nil(s.T(), data) @@ -80,6 +89,7 @@ func (s *InternalSuite) Test_MeasurementPhaseSpecificDataForFilter() { filter, energyDirection, ucapi.PhaseNameMapping, + testValidator, ) assert.NotNil(s.T(), err) assert.Nil(s.T(), data) @@ -113,9 +123,10 @@ func (s *InternalSuite) Test_MeasurementPhaseSpecificDataForFilter() { filter, energyDirection, ucapi.PhaseNameMapping, + testValidator, ) - assert.Nil(s.T(), err) - assert.Equal(s.T(), 0, len(data)) + assert.NotNil(s.T(), err) // Should get "data not available" because no electrical connection parameters are set up yet + assert.Nil(s.T(), data) elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ @@ -159,9 +170,10 @@ func (s *InternalSuite) Test_MeasurementPhaseSpecificDataForFilter() { filter, energyDirection, ucapi.PhaseNameMapping, + testValidator, ) assert.Nil(s.T(), err) - assert.Equal(s.T(), []float64{10, 10, 10}, data) + assert.Equal(s.T(), []float64{10, 10, 10}, data) // 3 values: id=0, id=1, id=2 (id=10 has no value) measData = &model.MeasurementListDataType{ MeasurementData: []model.MeasurementDataType{ @@ -193,9 +205,10 @@ func (s *InternalSuite) Test_MeasurementPhaseSpecificDataForFilter() { filter, energyDirection, ucapi.PhaseNameMapping, + testValidator, ) - assert.NotNil(s.T(), err) - assert.Nil(s.T(), data) + assert.Nil(s.T(), err) + assert.Equal(s.T(), []float64{10, 10}, data) // 2 values: id=1 and id=2 (id=10 has no value, id=0 has error state) } func (s *InternalSuite) Test_GetPowerTotalMeasurementId() { diff --git a/usecases/internal/measurement_validation_test.go b/usecases/internal/measurement_validation_test.go new file mode 100644 index 00000000..bfeb26e5 --- /dev/null +++ b/usecases/internal/measurement_validation_test.go @@ -0,0 +1,195 @@ +package internal + +import ( + "errors" + "testing" + + "github.com/enbility/spine-go/model" + "github.com/stretchr/testify/assert" +) + +func ptrTest[T any](v T) *T { return &v } + +func validMeasurementData() model.MeasurementDataType { + return model.MeasurementDataType{ + MeasurementId: ptrTest(model.MeasurementIdType(1)), + Value: model.NewScaledNumberType(100), + ValueType: ptrTest(model.MeasurementValueTypeTypeValue), + ValueSource: ptrTest(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: ptrTest(model.MeasurementValueStateTypeNormal), + } +} + +func testValidator() MeasurementValidator { + return NewMeasurementValidator(). + WithName("Test Validator"). + WithRule(RequireMeasurementId()). + WithRule(RequireMeasurementValue()). + WithRule(RequireValueType(model.MeasurementValueTypeTypeValue)) +} + +func TestMeasurementValidator_AcceptsValid(t *testing.T) { + m := validMeasurementData() + assert.NoError(t, testValidator().Validate(&m)) +} + +func TestMeasurementValidator_RejectsMissingMeasurementId(t *testing.T) { + m := validMeasurementData() + m.MeasurementId = nil + err := testValidator().Validate(&m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "MeasurementId is required") +} + +func TestRequireValueSource(t *testing.T) { + rule := RequireValueSource( + model.MeasurementValueSourceTypeMeasuredValue, + model.MeasurementValueSourceTypeCalculatedValue, + ) + + t.Run("accepts allowed", func(t *testing.T) { + m := validMeasurementData() + m.ValueSource = ptrTest(model.MeasurementValueSourceTypeCalculatedValue) + assert.NoError(t, rule(&m)) + }) + + t.Run("rejects disallowed", func(t *testing.T) { + m := validMeasurementData() + m.ValueSource = ptrTest(model.MeasurementValueSourceTypeEmpiricalValue) + assert.Error(t, rule(&m)) + }) + + t.Run("rejects nil (mandatory)", func(t *testing.T) { + m := validMeasurementData() + m.ValueSource = nil + err := rule(&m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ValueSource is required") + }) + + t.Run("panics when no allowed values", func(t *testing.T) { + assert.Panics(t, func() { RequireValueSource() }) + }) +} + +func TestRequireValueType(t *testing.T) { + rule := RequireValueType(model.MeasurementValueTypeTypeValue) + + t.Run("accepts matching", func(t *testing.T) { + m := validMeasurementData() + assert.NoError(t, rule(&m)) + }) + + t.Run("rejects wrong type", func(t *testing.T) { + m := validMeasurementData() + m.ValueType = ptrTest(model.MeasurementValueTypeTypeAverageValue) + err := rule(&m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ValueType must be") + }) + + t.Run("rejects nil", func(t *testing.T) { + m := validMeasurementData() + m.ValueType = nil + err := rule(&m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ValueType is required") + }) +} + +func TestValidateMeasurementRange(t *testing.T) { + rule := ValidateMeasurementRange(0, 100) + + t.Run("accepts in range", func(t *testing.T) { + m := validMeasurementData() + m.Value = model.NewScaledNumberType(50) + assert.NoError(t, rule(&m)) + }) + + t.Run("rejects below", func(t *testing.T) { + m := validMeasurementData() + m.Value = model.NewScaledNumberType(-1) + err := rule(&m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "must be between") + }) + + t.Run("rejects above", func(t *testing.T) { + m := validMeasurementData() + m.Value = model.NewScaledNumberType(101) + err := rule(&m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "must be between") + }) +} + +func TestValidateValueState(t *testing.T) { + t.Run("required, matching", func(t *testing.T) { + rule := ValidateValueState(model.MeasurementValueStateTypeNormal, true) + m := validMeasurementData() + assert.NoError(t, rule(&m)) + }) + + t.Run("required, missing", func(t *testing.T) { + rule := ValidateValueState(model.MeasurementValueStateTypeNormal, true) + m := validMeasurementData() + m.ValueState = nil + err := rule(&m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ValueState is required") + }) + + t.Run("optional, missing", func(t *testing.T) { + rule := ValidateValueState(model.MeasurementValueStateTypeNormal, false) + m := validMeasurementData() + m.ValueState = nil + assert.NoError(t, rule(&m)) + }) +} + +func TestSkipValueState(t *testing.T) { + rule := SkipValueState() + for _, state := range []model.MeasurementValueStateType{ + model.MeasurementValueStateTypeError, + model.MeasurementValueStateTypeOutofrange, + } { + t.Run(string(state), func(t *testing.T) { + m := validMeasurementData() + m.ValueState = ptrTest(state) + err := rule(&m) + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrSkipMeasurement)) + }) + } + + t.Run("nil passes", func(t *testing.T) { + m := validMeasurementData() + m.ValueState = nil + assert.NoError(t, rule(&m)) + }) + + t.Run("normal passes", func(t *testing.T) { + m := validMeasurementData() + assert.NoError(t, rule(&m)) + }) +} + +func TestGetMeasurementValue(t *testing.T) { + t.Run("returns first valid value, skipping invalid ones", func(t *testing.T) { + invalid := validMeasurementData() + invalid.MeasurementId = nil + valid := validMeasurementData() + valid.Value = model.NewScaledNumberType(42) + + value, err := GetMeasurementValue([]model.MeasurementDataType{invalid, valid}, testValidator()) + assert.NoError(t, err) + assert.Equal(t, 42.0, value) + }) + + t.Run("errors when no valid measurements", func(t *testing.T) { + invalid := validMeasurementData() + invalid.MeasurementId = nil + _, err := GetMeasurementValue([]model.MeasurementDataType{invalid}, testValidator()) + assert.Error(t, err) + }) +} diff --git a/usecases/internal/validation.go b/usecases/internal/validation.go new file mode 100644 index 00000000..3e361ad8 --- /dev/null +++ b/usecases/internal/validation.go @@ -0,0 +1,343 @@ +// Package internal provides validation utilities for EEBUS data types. +// +// The validation system uses Go generics to provide type-safe validation +// for any SPINE data type while maintaining high performance with no reflection. +// +// Basic Usage: +// +// validator := internal.NewValidator[*model.MeasurementDataType](). +// WithName("Power Validator"). +// WithRule(internal.RequireMeasurementId()). +// WithRule(internal.RequireMeasurementValue()) +// +// err := validator.Validate(measurement) +// +// For custom data types, use generic rules: +// +// validator := internal.NewValidator[*model.SetpointDataType](). +// WithRule(internal.RequireField(func(s *model.SetpointDataType) *model.SetpointIdType { +// return s.SetpointId +// }, "SetpointId")). +// WithRule(internal.ValidateRange(func(s *model.SetpointDataType) *model.ScaledNumberType { +// return s.Value +// }, 0, 100, "Percentage")) +package internal + +import ( + "errors" + "fmt" + "github.com/enbility/spine-go/model" +) + +// ErrSkipMeasurement indicates that a measurement should be skipped during validation +// This is used for MGCP-003 compliance where measurements with "error" or "outOfRange" +// states should be ignored by the Monitoring Appliance +var ErrSkipMeasurement = errors.New("measurement should be skipped") + +// ======================================== +// Generic Validation System +// ======================================== + +// Validator is the base interface for all validators. +// +// It provides methods to validate single items, find the first valid item +// from a slice, or validate all items with detailed error reporting. +type Validator[T any] interface { + Validate(item T) error + FindFirstValidItem(items []T) (T, error) + ValidateAll(items []T) ([]T, []error) + WithName(name string) Validator[T] +} + +// ValidationRule is a function that validates a specific type. +// +// Rules are composable and can be combined using CombineRules or +// applied conditionally using ConditionalRule. +type ValidationRule[T any] func(T) error + +// BaseValidator provides common validation functionality for any type. +// +// It implements the Validator interface and supports method chaining +// for building complex validators with multiple rules. +type BaseValidator[T any] struct { + rules []ValidationRule[T] + name string +} + +// NewValidator creates a new validator for type T. +// +// Example: +// +// validator := NewValidator[*model.MeasurementDataType](). +// WithName("Power Validator"). +// WithRule(RequireMeasurementId()). +// WithRule(RequireMeasurementValue()) +// +// err := validator.Validate(measurement) +func NewValidator[T any]() *BaseValidator[T] { + return &BaseValidator[T]{} +} + +// WithName sets the validator name for better error messages. +// +// The name appears in error messages to help identify which validator failed. +// +// Example: +// +// validator.WithName("Power Measurement") +// // Error: "Power Measurement validation failed at rule 2: Value is required" +func (v *BaseValidator[T]) WithName(name string) *BaseValidator[T] { + v.name = name + return v +} + +// WithRule adds a validation rule to the validator. +// +// Rules are executed in the order they are added. The first rule that fails +// stops validation and returns an error. +// +// Example: +// +// validator.WithRule(RequireField(getter, "FieldName")). +// WithRule(ValidateRange(getter, 0, 100, "Value")) +func (v *BaseValidator[T]) WithRule(rule ValidationRule[T]) *BaseValidator[T] { + v.rules = append(v.rules, rule) + return v +} + +// Validate applies all rules to a single item. +// +// Returns nil if all rules pass, or the first error encountered. +// Error messages include the validator name (if set) and rule position. +func (v *BaseValidator[T]) Validate(item T) error { + for i, rule := range v.rules { + if err := rule(item); err != nil { + if v.name != "" { + return fmt.Errorf("%s validation failed at rule %d: %w", v.name, i+1, err) + } + return fmt.Errorf("validation failed at rule %d: %w", i+1, err) + } + } + return nil +} + +// FindFirstValidItem returns the first item in the slice that passes all validation rules. +// +// This is useful when you have multiple items but only need one valid one. +// +// Example: +// +// measurements := []model.MeasurementDataType{...} +// valid, err := validator.FindFirstValidItem(measurements) +// if err != nil { +// return api.ErrDataNotAvailable +// } +func (v *BaseValidator[T]) FindFirstValidItem(items []T) (T, error) { + var zero T + for _, item := range items { + if err := v.Validate(item); err == nil { + return item, nil + } + } + return zero, fmt.Errorf("no valid item found") +} + +// ValidateAll validates all items and returns valid ones with errors for invalid ones. +// +// Unlike FindFirstValidItem, this validates every item and returns both +// the valid items and detailed errors for each invalid item. +// +// Example: +// +// validItems, errors := validator.ValidateAll(allItems) +// if len(errors) > 0 { +// for _, err := range errors { +// log.Printf("Validation error: %v", err) +// } +// } +// // Process validItems... +func (v *BaseValidator[T]) ValidateAll(items []T) ([]T, []error) { + valid := make([]T, 0, len(items)) + errors := make([]error, 0) + + for i, item := range items { + if err := v.Validate(item); err != nil { + errors = append(errors, fmt.Errorf("item %d: %w", i, err)) + } else { + valid = append(valid, item) + } + } + + return valid, errors +} + +// ======================================== +// Generic Validation Rules +// ======================================== +// These rules work with any type using getter functions + +// RequireField creates a rule that checks if a field is not nil. +// +// This is the most commonly used validation rule for ensuring required fields are present. +// +// Example: +// +// rule := RequireField(func(item *model.MeasurementDataType) *model.MeasurementIdType { +// return item.MeasurementId +// }, "MeasurementId") +// +// // Use in validator: +// validator.WithRule(RequireField(func(s *model.SetpointDataType) *model.SetpointIdType { +// return s.SetpointId +// }, "SetpointId")) +func RequireField[T any, F any](getter func(T) *F, fieldName string) ValidationRule[T] { + return func(item T) error { + if getter(item) == nil { + return fmt.Errorf("%s is required", fieldName) + } + return nil + } +} + +// RequireScaledNumber creates a rule that checks if a ScaledNumberType field is not nil. +// +// This is a specialized version of RequireField for SPINE ScaledNumberType fields. +// +// Example: +// +// validator.WithRule(RequireScaledNumber(func(m *model.MeasurementDataType) *model.ScaledNumberType { +// return m.Value +// }, "Value")) +func RequireScaledNumber[T any](getter func(T) *model.ScaledNumberType, fieldName string) ValidationRule[T] { + return func(item T) error { + if getter(item) == nil { + return fmt.Errorf("%s is required", fieldName) + } + return nil + } +} + +// ValidateRange creates a rule that validates a numeric value is within range. +// +// If the value is nil, validation passes (use RequireScaledNumber to make it required). +// Range validation is inclusive on both ends. +// +// Example: +// +// // Validate power is between 0 and 50kW +// validator.WithRule(ValidateRange(func(m *model.MeasurementDataType) *model.ScaledNumberType { +// return m.Value +// }, 0, 50000, "Power")) +func ValidateRange[T any]( + getter func(T) *model.ScaledNumberType, + minVal, maxVal float64, + fieldName string, +) ValidationRule[T] { + return func(item T) error { + value := getter(item) + if value == nil { + return nil // Skip if nil, use RequireScaledNumber to make it required + } + val := value.GetValue() + if val < minVal || val > maxVal { + return fmt.Errorf("%s must be between %.2f and %.2f, got %.2f", fieldName, minVal, maxVal, val) + } + return nil + } +} + +// ValidateMinMax creates a rule that validates min <= value <= max relationship +func ValidateMinMax[T any]( + valueGetter func(T) *model.ScaledNumberType, + minGetter func(T) *model.ScaledNumberType, + maxGetter func(T) *model.ScaledNumberType, + fieldName string, +) ValidationRule[T] { + return func(item T) error { + value := valueGetter(item) + if value == nil { + return nil // Skip if no value + } + + val := value.GetValue() + + if minVal := minGetter(item); minVal != nil && val < minVal.GetValue() { + return fmt.Errorf("%s %.2f is below minimum %.2f", fieldName, val, minVal.GetValue()) + } + + if maxVal := maxGetter(item); maxVal != nil && val > maxVal.GetValue() { + return fmt.Errorf("%s %.2f is above maximum %.2f", fieldName, val, maxVal.GetValue()) + } + + return nil + } +} + +// ValidateEnum creates a rule that validates a value is one of allowed values. +// If the value pointer is nil the rule passes; combine with RequireField to reject nil. +// Passing an empty allowed list panics: that is always a programming error. +func ValidateEnum[T any, E comparable]( + getter func(T) *E, + allowed []E, + fieldName string, +) ValidationRule[T] { + if len(allowed) == 0 { + panic(fmt.Sprintf("ValidateEnum for %s requires at least one allowed value", fieldName)) + } + return func(item T) error { + value := getter(item) + if value == nil { + return nil // combine with RequireField to reject nil + } + for _, a := range allowed { + if *value == a { + return nil + } + } + return fmt.Errorf("%s must be one of allowed values", fieldName) + } +} + +// ValidateSliceNotEmpty creates a rule that validates a slice is not empty +func ValidateSliceNotEmpty[T any, S any]( + getter func(T) []S, + fieldName string, +) ValidationRule[T] { + return func(item T) error { + slice := getter(item) + if len(slice) == 0 { + return fmt.Errorf("%s cannot be empty", fieldName) + } + return nil + } +} + +// ValidateCustom creates a rule with custom validation logic +func ValidateCustom[T any](validate func(T) error) ValidationRule[T] { + return validate +} + +// CombineRules combines multiple validation rules into one +func CombineRules[T any](rules ...ValidationRule[T]) ValidationRule[T] { + return func(item T) error { + for _, rule := range rules { + if err := rule(item); err != nil { + return err + } + } + return nil + } +} + +// ConditionalRule applies a rule only if a condition is met +func ConditionalRule[T any]( + condition func(T) bool, + rule ValidationRule[T], +) ValidationRule[T] { + return func(item T) error { + if condition(item) { + return rule(item) + } + return nil + } +} diff --git a/usecases/internal/validation_test.go b/usecases/internal/validation_test.go new file mode 100644 index 00000000..e8951def --- /dev/null +++ b/usecases/internal/validation_test.go @@ -0,0 +1,566 @@ +package internal + +import ( + "testing" + "time" + + "github.com/enbility/spine-go/model" + "github.com/stretchr/testify/assert" +) + +// Helper function to create pointers +func ptr[T any](v T) *T { + return &v +} + +// Test structs for generic validation testing +type TestStruct struct { + ID *int + Value *model.ScaledNumberType + Name *string + Status *string + Items []string + Min *model.ScaledNumberType + Max *model.ScaledNumberType + IsActive *bool + LastUpdated *time.Time +} + +func TestBaseValidator(t *testing.T) { + t.Run("single rule validation", func(t *testing.T) { + validator := NewValidator[*TestStruct](). + WithName("TestValidator"). + WithRule(func(ts *TestStruct) error { + if ts.ID == nil { + return assert.AnError + } + return nil + }) + + // Should fail - nil ID + err := validator.Validate(&TestStruct{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "TestValidator validation failed") + + // Should pass + err = validator.Validate(&TestStruct{ID: ptr(1)}) + assert.NoError(t, err) + }) + + t.Run("multiple rules", func(t *testing.T) { + validator := NewValidator[*TestStruct](). + WithRule(RequireField(func(ts *TestStruct) *int { return ts.ID }, "ID")). + WithRule(RequireField(func(ts *TestStruct) *string { return ts.Name }, "Name")) + + // Should fail on first rule + err := validator.Validate(&TestStruct{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ID is required") + + // Should fail on second rule + err = validator.Validate(&TestStruct{ID: ptr(1)}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Name is required") + + // Should pass + err = validator.Validate(&TestStruct{ID: ptr(1), Name: ptr("test")}) + assert.NoError(t, err) + }) + + t.Run("WithName sets validator name", func(t *testing.T) { + validator := NewValidator[*TestStruct](). + WithName("Custom Validator"). + WithRule(RequireField(func(ts *TestStruct) *int { return ts.ID }, "ID")) + + err := validator.Validate(&TestStruct{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Custom Validator validation failed") + }) + + t.Run("FindFirstValidItem", func(t *testing.T) { + validator := NewValidator[*TestStruct](). + WithRule(RequireField(func(ts *TestStruct) *int { return ts.ID }, "ID")) + + items := []*TestStruct{ + {}, // Invalid + {ID: ptr(1)}, // Valid + {ID: ptr(2)}, // Also valid but shouldn't be returned + } + + result, err := validator.FindFirstValidItem(items) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, 1, *result.ID) + + // No valid items + invalidItems := []*TestStruct{{}, {}} + _, err = validator.FindFirstValidItem(invalidItems) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no valid item found") + }) + + t.Run("FindFirstValidItem with empty slice", func(t *testing.T) { + validator := NewValidator[*TestStruct](). + WithRule(RequireField(func(ts *TestStruct) *int { return ts.ID }, "ID")) + + items := []*TestStruct{} + _, err := validator.FindFirstValidItem(items) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no valid item found") + }) + + t.Run("ValidateAll", func(t *testing.T) { + validator := NewValidator[*TestStruct](). + WithRule(RequireField(func(ts *TestStruct) *int { return ts.ID }, "ID")) + + items := []*TestStruct{ + {}, // Invalid + {ID: ptr(1)}, // Valid + {}, // Invalid + {ID: ptr(2)}, // Valid + } + + valid, errors := validator.ValidateAll(items) + assert.Len(t, valid, 2) + assert.Len(t, errors, 2) + assert.Equal(t, 1, *valid[0].ID) + assert.Equal(t, 2, *valid[1].ID) + assert.Contains(t, errors[0].Error(), "item 0") + assert.Contains(t, errors[1].Error(), "item 2") + }) + + t.Run("ValidateAll with all valid items", func(t *testing.T) { + validator := NewValidator[*TestStruct](). + WithRule(RequireField(func(ts *TestStruct) *int { return ts.ID }, "ID")) + + items := []*TestStruct{ + {ID: ptr(1)}, + {ID: ptr(2)}, + } + + valid, errors := validator.ValidateAll(items) + assert.Len(t, valid, 2) + assert.Len(t, errors, 0) + }) + + t.Run("ValidateAll with all invalid items", func(t *testing.T) { + validator := NewValidator[*TestStruct](). + WithRule(RequireField(func(ts *TestStruct) *int { return ts.ID }, "ID")) + + items := []*TestStruct{{}, {}} + + valid, errors := validator.ValidateAll(items) + assert.Len(t, valid, 0) + assert.Len(t, errors, 2) + }) +} + +func TestRequireField(t *testing.T) { + t.Run("nil field fails", func(t *testing.T) { + rule := RequireField(func(ts *TestStruct) *int { return ts.ID }, "ID") + + err := rule(&TestStruct{}) + assert.Error(t, err) + assert.Equal(t, "ID is required", err.Error()) + }) + + t.Run("non-nil field passes", func(t *testing.T) { + rule := RequireField(func(ts *TestStruct) *int { return ts.ID }, "ID") + + err := rule(&TestStruct{ID: ptr(42)}) + assert.NoError(t, err) + }) + + t.Run("different field types", func(t *testing.T) { + // String field + stringRule := RequireField(func(ts *TestStruct) *string { return ts.Name }, "Name") + err := stringRule(&TestStruct{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Name is required") + + err = stringRule(&TestStruct{Name: ptr("test")}) + assert.NoError(t, err) + + // Bool field + boolRule := RequireField(func(ts *TestStruct) *bool { return ts.IsActive }, "IsActive") + err = boolRule(&TestStruct{}) + assert.Error(t, err) + + err = boolRule(&TestStruct{IsActive: ptr(true)}) + assert.NoError(t, err) + }) +} + +func TestRequireScaledNumber(t *testing.T) { + t.Run("nil ScaledNumber fails", func(t *testing.T) { + rule := RequireScaledNumber(func(ts *TestStruct) *model.ScaledNumberType { return ts.Value }, "Value") + + err := rule(&TestStruct{}) + assert.Error(t, err) + assert.Equal(t, "Value is required", err.Error()) + }) + + t.Run("non-nil ScaledNumber passes", func(t *testing.T) { + rule := RequireScaledNumber(func(ts *TestStruct) *model.ScaledNumberType { return ts.Value }, "Value") + + err := rule(&TestStruct{Value: model.NewScaledNumberType(42)}) + assert.NoError(t, err) + }) + + t.Run("custom field name in error", func(t *testing.T) { + rule := RequireScaledNumber(func(ts *TestStruct) *model.ScaledNumberType { return ts.Min }, "Minimum") + + err := rule(&TestStruct{}) + assert.Error(t, err) + assert.Equal(t, "Minimum is required", err.Error()) + }) +} + +func TestValidateRange(t *testing.T) { + rule := ValidateRange( + func(ts *TestStruct) *model.ScaledNumberType { return ts.Value }, + 10, 100, + "Value", + ) + + t.Run("nil value passes (skipped)", func(t *testing.T) { + err := rule(&TestStruct{}) + assert.NoError(t, err) + }) + + t.Run("value below min fails", func(t *testing.T) { + err := rule(&TestStruct{Value: model.NewScaledNumberType(5)}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Value must be between 10.00 and 100.00, got 5.00") + }) + + t.Run("value above max fails", func(t *testing.T) { + err := rule(&TestStruct{Value: model.NewScaledNumberType(150)}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Value must be between 10.00 and 100.00, got 150.00") + }) + + t.Run("value at min boundary passes", func(t *testing.T) { + err := rule(&TestStruct{Value: model.NewScaledNumberType(10)}) + assert.NoError(t, err) + }) + + t.Run("value at max boundary passes", func(t *testing.T) { + err := rule(&TestStruct{Value: model.NewScaledNumberType(100)}) + assert.NoError(t, err) + }) + + t.Run("value in range passes", func(t *testing.T) { + err := rule(&TestStruct{Value: model.NewScaledNumberType(50)}) + assert.NoError(t, err) + }) +} + +func TestValidateMinMax(t *testing.T) { + rule := ValidateMinMax( + func(ts *TestStruct) *model.ScaledNumberType { return ts.Value }, + func(ts *TestStruct) *model.ScaledNumberType { return ts.Min }, + func(ts *TestStruct) *model.ScaledNumberType { return ts.Max }, + "Value", + ) + + t.Run("nil value passes (skipped)", func(t *testing.T) { + err := rule(&TestStruct{}) + assert.NoError(t, err) + }) + + t.Run("value below min fails", func(t *testing.T) { + err := rule(&TestStruct{ + Value: model.NewScaledNumberType(5), + Min: model.NewScaledNumberType(10), + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Value 5.00 is below minimum 10.00") + }) + + t.Run("value above max fails", func(t *testing.T) { + err := rule(&TestStruct{ + Value: model.NewScaledNumberType(100), + Max: model.NewScaledNumberType(50), + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Value 100.00 is above maximum 50.00") + }) + + t.Run("value within range passes", func(t *testing.T) { + err := rule(&TestStruct{ + Value: model.NewScaledNumberType(50), + Min: model.NewScaledNumberType(10), + Max: model.NewScaledNumberType(100), + }) + assert.NoError(t, err) + }) + + t.Run("no min/max constraints passes", func(t *testing.T) { + err := rule(&TestStruct{ + Value: model.NewScaledNumberType(50), + }) + assert.NoError(t, err) + }) + + t.Run("only min constraint", func(t *testing.T) { + err := rule(&TestStruct{ + Value: model.NewScaledNumberType(50), + Min: model.NewScaledNumberType(10), + }) + assert.NoError(t, err) + + err = rule(&TestStruct{ + Value: model.NewScaledNumberType(5), + Min: model.NewScaledNumberType(10), + }) + assert.Error(t, err) + }) + + t.Run("only max constraint", func(t *testing.T) { + err := rule(&TestStruct{ + Value: model.NewScaledNumberType(50), + Max: model.NewScaledNumberType(100), + }) + assert.NoError(t, err) + + err = rule(&TestStruct{ + Value: model.NewScaledNumberType(150), + Max: model.NewScaledNumberType(100), + }) + assert.Error(t, err) + }) +} + +func TestValidateEnum(t *testing.T) { + allowed := []string{"active", "inactive", "pending"} + rule := ValidateEnum( + func(ts *TestStruct) *string { return ts.Status }, + allowed, + "Status", + ) + + t.Run("nil value passes (skipped)", func(t *testing.T) { + err := rule(&TestStruct{}) + assert.NoError(t, err) + }) + + t.Run("allowed value passes", func(t *testing.T) { + err := rule(&TestStruct{Status: ptr("active")}) + assert.NoError(t, err) + + err = rule(&TestStruct{Status: ptr("inactive")}) + assert.NoError(t, err) + + err = rule(&TestStruct{Status: ptr("pending")}) + assert.NoError(t, err) + }) + + t.Run("disallowed value fails", func(t *testing.T) { + err := rule(&TestStruct{Status: ptr("unknown")}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Status must be one of allowed values") + }) + + t.Run("panics on empty allowed list", func(t *testing.T) { + assert.Panics(t, func() { + ValidateEnum( + func(ts *TestStruct) *string { return ts.Status }, + []string{}, + "Status", + ) + }) + }) +} + +func TestValidateSliceNotEmpty(t *testing.T) { + rule := ValidateSliceNotEmpty( + func(ts *TestStruct) []string { return ts.Items }, + "Items", + ) + + t.Run("empty slice fails", func(t *testing.T) { + err := rule(&TestStruct{Items: []string{}}) + assert.Error(t, err) + assert.Equal(t, "Items cannot be empty", err.Error()) + }) + + t.Run("nil slice fails", func(t *testing.T) { + err := rule(&TestStruct{}) + assert.Error(t, err) + assert.Equal(t, "Items cannot be empty", err.Error()) + }) + + t.Run("non-empty slice passes", func(t *testing.T) { + err := rule(&TestStruct{Items: []string{"item1"}}) + assert.NoError(t, err) + + err = rule(&TestStruct{Items: []string{"item1", "item2"}}) + assert.NoError(t, err) + }) +} + +func TestValidateCustom(t *testing.T) { + rule := ValidateCustom(func(ts *TestStruct) error { + if ts.ID != nil && *ts.ID < 0 { + return assert.AnError + } + return nil + }) + + t.Run("custom validation passes", func(t *testing.T) { + err := rule(&TestStruct{ID: ptr(42)}) + assert.NoError(t, err) + + err = rule(&TestStruct{}) + assert.NoError(t, err) + }) + + t.Run("custom validation fails", func(t *testing.T) { + err := rule(&TestStruct{ID: ptr(-1)}) + assert.Error(t, err) + }) +} + +func TestCombineRules(t *testing.T) { + combinedRule := CombineRules( + RequireField(func(ts *TestStruct) *int { return ts.ID }, "ID"), + RequireField(func(ts *TestStruct) *string { return ts.Name }, "Name"), + ValidateCustom(func(ts *TestStruct) error { + if ts.ID != nil && *ts.ID < 0 { + return assert.AnError + } + return nil + }), + ) + + t.Run("all rules pass", func(t *testing.T) { + err := combinedRule(&TestStruct{ID: ptr(1), Name: ptr("test")}) + assert.NoError(t, err) + }) + + t.Run("first rule fails", func(t *testing.T) { + err := combinedRule(&TestStruct{Name: ptr("test")}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ID is required") + }) + + t.Run("second rule fails", func(t *testing.T) { + err := combinedRule(&TestStruct{ID: ptr(1)}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Name is required") + }) + + t.Run("third rule fails", func(t *testing.T) { + err := combinedRule(&TestStruct{ID: ptr(-1), Name: ptr("test")}) + assert.Error(t, err) + }) +} + +func TestConditionalRule(t *testing.T) { + rule := ConditionalRule( + func(ts *TestStruct) bool { return ts.IsActive != nil && *ts.IsActive }, + RequireField(func(ts *TestStruct) *string { return ts.Name }, "Name"), + ) + + t.Run("condition not met, rule skipped", func(t *testing.T) { + // IsActive is false, so rule should be skipped + err := rule(&TestStruct{IsActive: ptr(false)}) + assert.NoError(t, err) + + // IsActive is nil, so rule should be skipped + err = rule(&TestStruct{}) + assert.NoError(t, err) + }) + + t.Run("condition met, rule applied and passes", func(t *testing.T) { + err := rule(&TestStruct{IsActive: ptr(true), Name: ptr("test")}) + assert.NoError(t, err) + }) + + t.Run("condition met, rule applied and fails", func(t *testing.T) { + err := rule(&TestStruct{IsActive: ptr(true)}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Name is required") + }) +} + +func TestComplexValidationScenarios(t *testing.T) { + t.Run("complex validator with multiple rule types", func(t *testing.T) { + validator := NewValidator[*TestStruct](). + WithName("Complex Validator"). + WithRule(RequireField(func(ts *TestStruct) *int { return ts.ID }, "ID")). + WithRule(RequireScaledNumber(func(ts *TestStruct) *model.ScaledNumberType { return ts.Value }, "Value")). + WithRule(ValidateRange( + func(ts *TestStruct) *model.ScaledNumberType { return ts.Value }, + 0, 1000, + "Value", + )). + WithRule(ValidateEnum( + func(ts *TestStruct) *string { return ts.Status }, + []string{"active", "inactive"}, + "Status", + )). + WithRule(ConditionalRule( + func(ts *TestStruct) bool { return ts.IsActive != nil && *ts.IsActive }, + RequireField(func(ts *TestStruct) *string { return ts.Name }, "Name"), + )) + + // Should pass all validations + testData := &TestStruct{ + ID: ptr(1), + Value: model.NewScaledNumberType(500), + Status: ptr("active"), + IsActive: ptr(true), + Name: ptr("test"), + } + err := validator.Validate(testData) + assert.NoError(t, err) + + // Should fail on value range + testData.Value = model.NewScaledNumberType(2000) + err = validator.Validate(testData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "must be between 0.00 and 1000.00") + + // Should fail on status enum + testData.Value = model.NewScaledNumberType(500) + testData.Status = ptr("unknown") + err = validator.Validate(testData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Status must be one of allowed values") + + // Should fail on conditional rule (IsActive=true but no Name) + testData.Status = ptr("active") + testData.Name = nil + err = validator.Validate(testData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Name is required") + + // Should pass when IsActive=false (conditional rule skipped) + testData.IsActive = ptr(false) + err = validator.Validate(testData) + assert.NoError(t, err) + }) + + t.Run("validator reuse with different types", func(t *testing.T) { + // Define a simple struct for testing + type SimpleStruct struct { + ID *int + Name *string + } + + // Create validator for SimpleStruct + simpleValidator := NewValidator[*SimpleStruct](). + WithRule(RequireField(func(s *SimpleStruct) *int { return s.ID }, "ID")). + WithRule(RequireField(func(s *SimpleStruct) *string { return s.Name }, "Name")) + + // Should work with SimpleStruct + err := simpleValidator.Validate(&SimpleStruct{ID: ptr(1), Name: ptr("test")}) + assert.NoError(t, err) + + err = simpleValidator.Validate(&SimpleStruct{ID: ptr(1)}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Name is required") + }) +} diff --git a/usecases/ma/mgcp/events.go b/usecases/ma/mgcp/events.go index 7f88ac64..9a33949e 100644 --- a/usecases/ma/mgcp/events.go +++ b/usecases/ma/mgcp/events.go @@ -108,8 +108,11 @@ func (e *MGCP) gridConfigurationDataUpdate(payload spineapi.EventPayload) { filter := model.DeviceConfigurationKeyValueDescriptionDataType{ KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypePvCurtailmentLimitFactor), } - if dc.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdatePowerLimitationFactor) + if dc.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.PowerLimitationFactor(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdatePowerLimitationFactor) + } } } } @@ -131,38 +134,56 @@ func (e *MGCP) gridMeasurementDataUpdate(payload spineapi.EventPayload) { filter := model.MeasurementDescriptionDataType{ ScopeType: util.Ptr(model.ScopeTypeTypeACPowerTotal), } - if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdatePower) + if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.Power(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdatePower) + } } // Scenario 3 filter.ScopeType = util.Ptr(model.ScopeTypeTypeGridFeedIn) - if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateEnergyFeedIn) + if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.EnergyFeedIn(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateEnergyFeedIn) + } } // Scenario 4 filter.ScopeType = util.Ptr(model.ScopeTypeTypeGridConsumption) - if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateEnergyConsumed) + if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.EnergyConsumed(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateEnergyConsumed) + } } // Scenario 5 filter.ScopeType = util.Ptr(model.ScopeTypeTypeACCurrent) - if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateCurrentPerPhase) + if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.CurrentPerPhase(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateCurrentPerPhase) + } } // Scenario 6 filter.ScopeType = util.Ptr(model.ScopeTypeTypeACVoltage) - if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateVoltagePerPhase) + if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.VoltagePerPhase(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateVoltagePerPhase) + } } // Scenario 7 filter.ScopeType = util.Ptr(model.ScopeTypeTypeACFrequency) - if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateFrequency) + if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.Frequency(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateFrequency) + } } } } diff --git a/usecases/ma/mgcp/events_test.go b/usecases/ma/mgcp/events_test.go index 63a5d8e7..3a0f236e 100644 --- a/usecases/ma/mgcp/events_test.go +++ b/usecases/ma/mgcp/events_test.go @@ -32,6 +32,9 @@ func (s *GcpMGCPSuite) Test_Events() { payload.Data = util.Ptr(model.DeviceConfigurationKeyValueDescriptionListDataType{}) s.sut.HandleEvent(payload) + payload.Data = util.Ptr(model.DeviceConfigurationKeyValueListDataType{}) + s.sut.HandleEvent(payload) + payload.Data = util.Ptr(model.MeasurementDescriptionListDataType{}) s.sut.HandleEvent(payload) @@ -89,6 +92,10 @@ func (s *GcpMGCPSuite) Test_gridConfigurationDataUpdate() { payload.Data = keyData + // Update the feature with the data so it's actually stored + _, fErr = rFeature.UpdateData(true, model.FunctionTypeDeviceConfigurationKeyValueListData, keyData, nil, nil) + assert.Nil(s.T(), fErr) + s.sut.gridConfigurationDataUpdate(payload) assert.True(s.T(), s.eventCalled) } @@ -105,28 +112,40 @@ func (s *GcpMGCPSuite) Test_gridMeasurementDataUpdate() { descData := &model.MeasurementDescriptionListDataType{ MeasurementDescriptionData: []model.MeasurementDescriptionDataType{ { - MeasurementId: util.Ptr(model.MeasurementIdType(0)), - ScopeType: util.Ptr(model.ScopeTypeTypeACPowerTotal), + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + MeasurementType: util.Ptr(model.MeasurementTypeTypePower), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACPowerTotal), }, { - MeasurementId: util.Ptr(model.MeasurementIdType(1)), - ScopeType: util.Ptr(model.ScopeTypeTypeGridFeedIn), + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeEnergy), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeGridFeedIn), }, { - MeasurementId: util.Ptr(model.MeasurementIdType(2)), - ScopeType: util.Ptr(model.ScopeTypeTypeGridConsumption), + MeasurementId: util.Ptr(model.MeasurementIdType(2)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeEnergy), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeGridConsumption), }, { - MeasurementId: util.Ptr(model.MeasurementIdType(3)), - ScopeType: util.Ptr(model.ScopeTypeTypeACCurrent), + MeasurementId: util.Ptr(model.MeasurementIdType(3)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeCurrent), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACCurrent), }, { - MeasurementId: util.Ptr(model.MeasurementIdType(4)), - ScopeType: util.Ptr(model.ScopeTypeTypeACVoltage), + MeasurementId: util.Ptr(model.MeasurementIdType(4)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeVoltage), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACVoltage), }, { - MeasurementId: util.Ptr(model.MeasurementIdType(5)), - ScopeType: util.Ptr(model.ScopeTypeTypeACFrequency), + MeasurementId: util.Ptr(model.MeasurementIdType(5)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeFrequency), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACFrequency), }, }, } @@ -135,6 +154,52 @@ func (s *GcpMGCPSuite) Test_gridMeasurementDataUpdate() { _, fErr := rFeature.UpdateData(true, model.FunctionTypeMeasurementDescriptionListData, descData, nil, nil) assert.Nil(s.T(), fErr) + // Add electrical connection setup for complete validation + elDescData := &model.ElectricalConnectionDescriptionListDataType{ + ElectricalConnectionDescriptionData: []model.ElectricalConnectionDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + PositiveEnergyDirection: util.Ptr(model.EnergyDirectionTypeConsume), + }, + }, + } + + rElFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.smgwEntity, model.FeatureTypeTypeElectricalConnection, model.RoleTypeServer) + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionDescriptionListData, elDescData, nil, nil) + assert.Nil(s.T(), fErr) + + elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ + ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(2)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(3)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(4)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(5)), + }, + }, + } + + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionParameterDescriptionListData, elParamData, nil, nil) + assert.Nil(s.T(), fErr) + s.sut.gridMeasurementDataUpdate(payload) assert.False(s.T(), s.eventCalled) @@ -142,33 +207,155 @@ func (s *GcpMGCPSuite) Test_gridMeasurementDataUpdate() { MeasurementData: []model.MeasurementDataType{ { MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, { MeasurementId: util.Ptr(model.MeasurementIdType(1)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, { MeasurementId: util.Ptr(model.MeasurementIdType(2)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, { MeasurementId: util.Ptr(model.MeasurementIdType(3)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, { MeasurementId: util.Ptr(model.MeasurementIdType(4)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, { MeasurementId: util.Ptr(model.MeasurementIdType(5)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, }, } payload.Data = data + // Update the feature with the data so it's actually stored + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, data, nil, nil) + assert.Nil(s.T(), fErr) + + s.sut.gridMeasurementDataUpdate(payload) + assert.True(s.T(), s.eventCalled) +} + +func (s *GcpMGCPSuite) Test_gridMeasurementDataUpdate_PhaseEventCallbacks() { + // Test missing event callback coverage for CurrentPerPhase and VoltagePerPhase + + // Setup measurement descriptions for current and voltage + descData := &model.MeasurementDescriptionListDataType{ + MeasurementDescriptionData: []model.MeasurementDescriptionDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeCurrent), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACCurrent), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeVoltage), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACVoltage), + }, + }, + } + + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.smgwEntity, model.FeatureTypeTypeMeasurement, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeMeasurementDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + // Setup electrical connection + elDescData := &model.ElectricalConnectionDescriptionListDataType{ + ElectricalConnectionDescriptionData: []model.ElectricalConnectionDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + PositiveEnergyDirection: util.Ptr(model.EnergyDirectionTypeConsume), + }, + }, + } + + rElFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.smgwEntity, model.FeatureTypeTypeElectricalConnection, model.RoleTypeServer) + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionDescriptionListData, elDescData, nil, nil) + assert.Nil(s.T(), fErr) + + elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ + ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + AcMeasuredPhases: util.Ptr(model.ElectricalConnectionPhaseNameTypeA), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + AcMeasuredPhases: util.Ptr(model.ElectricalConnectionPhaseNameTypeA), + }, + }, + } + + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionParameterDescriptionListData, elParamData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test CurrentPerPhase event callback + currentData := &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(15), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + }, + }, + } + + payload := spineapi.EventPayload{ + Ski: remoteSki, + Device: s.remoteDevice, + Entity: s.smgwEntity, + Data: currentData, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, currentData, nil, nil) + assert.Nil(s.T(), fErr) + + s.eventCalled = false + s.sut.gridMeasurementDataUpdate(payload) + assert.True(s.T(), s.eventCalled) + + // Test VoltagePerPhase event callback + voltageData := &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(230), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + }, + }, + } + + payload.Data = voltageData + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, voltageData, nil, nil) + assert.Nil(s.T(), fErr) + + s.eventCalled = false s.sut.gridMeasurementDataUpdate(payload) assert.True(s.T(), s.eventCalled) } diff --git a/usecases/ma/mgcp/public.go b/usecases/ma/mgcp/public.go index 357ad52e..8dcc06dc 100644 --- a/usecases/ma/mgcp/public.go +++ b/usecases/ma/mgcp/public.go @@ -69,7 +69,7 @@ func (e *MGCP) Power(entity spineapi.EntityRemoteInterface) (float64, error) { CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), ScopeType: util.Ptr(model.ScopeTypeTypeACPowerTotal), } - data, err := internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, model.EnergyDirectionTypeConsume, nil) + data, err := internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, model.EnergyDirectionTypeConsume, nil, MGCPPowerValidator) if err != nil { return 0, err } @@ -107,17 +107,14 @@ func (e *MGCP) EnergyFeedIn(entity spineapi.EntityRemoteInterface) (float64, err ScopeType: util.Ptr(model.ScopeTypeTypeGridFeedIn), } result, err := measurement.GetDataForFilter(filter) - if err != nil || len(result) == 0 || result[0].Value == nil { + if err != nil || len(result) == 0 { return 0, api.ErrDataNotAvailable } - // if the value state is set and not normal, the value is not valid and should be ignored - // therefore we return an error - if result[0].ValueState != nil && *result[0].ValueState != model.MeasurementValueStateTypeNormal { - return 0, api.ErrDataInvalid - } - - return result[0].Value.GetValue(), nil + // Use MGCP-compliant validation per specification requirements + // This replaces the previous non-compliant behavior that returned ErrDataInvalid + // for non-normal states. Per MGCP-003, such values should be ignored (skipped). + return internal.GetMeasurementValue(result, MGCPEnergyValidator) } // Scenario 4 @@ -146,17 +143,14 @@ func (e *MGCP) EnergyConsumed(entity spineapi.EntityRemoteInterface) (float64, e ScopeType: util.Ptr(model.ScopeTypeTypeGridConsumption), } result, err := measurement.GetDataForFilter(filter) - if err != nil || len(result) == 0 || result[0].Value == nil { + if err != nil || len(result) == 0 { return 0, api.ErrDataNotAvailable } - // if the value state is set and not normal, the value is not valid and should be ignored - // therefore we return an error - if result[0].ValueState != nil && *result[0].ValueState != model.MeasurementValueStateTypeNormal { - return 0, api.ErrDataInvalid - } - - return result[0].Value.GetValue(), nil + // Use MGCP-compliant validation per specification requirements + // This replaces the previous non-compliant behavior that returned ErrDataInvalid + // for non-normal states. Per MGCP-003, such values should be ignored (skipped). + return internal.GetMeasurementValue(result, MGCPEnergyValidator) } // Scenario 5 @@ -180,7 +174,7 @@ func (e *MGCP) CurrentPerPhase(entity spineapi.EntityRemoteInterface) ([]float64 CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), ScopeType: util.Ptr(model.ScopeTypeTypeACCurrent), } - return internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, model.EnergyDirectionTypeConsume, ucapi.PhaseNameMapping) + return internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, model.EnergyDirectionTypeConsume, ucapi.PhaseNameMapping, MGCPCurrentValidator) } // Scenario 6 @@ -201,7 +195,7 @@ func (e *MGCP) VoltagePerPhase(entity spineapi.EntityRemoteInterface) ([]float64 CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), ScopeType: util.Ptr(model.ScopeTypeTypeACVoltage), } - return internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, "", ucapi.PhaseNameMapping) + return internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, "", ucapi.PhaseNameMapping, MGCPVoltageValidator) } // Scenario 7 @@ -228,15 +222,12 @@ func (e *MGCP) Frequency(entity spineapi.EntityRemoteInterface) (float64, error) ScopeType: util.Ptr(model.ScopeTypeTypeACFrequency), } result, err := measurement.GetDataForFilter(filter) - if err != nil || len(result) == 0 || result[0].Value == nil { + if err != nil || len(result) == 0 { return 0, api.ErrDataNotAvailable } - // if the value state is set and not normal, the value is not valid and should be ignored - // therefore we return an error - if result[0].ValueState != nil && *result[0].ValueState != model.MeasurementValueStateTypeNormal { - return 0, api.ErrDataInvalid - } - - return result[0].Value.GetValue(), nil + // Use MGCP-compliant validation per specification requirements + // This replaces the previous non-compliant behavior that returned ErrDataInvalid + // for non-normal states. Per MGCP-003, such values should be ignored (skipped). + return internal.GetMeasurementValue(result, MGCPFrequencyValidator) } diff --git a/usecases/ma/mgcp/public_test.go b/usecases/ma/mgcp/public_test.go index 436af816..6c205a79 100644 --- a/usecases/ma/mgcp/public_test.go +++ b/usecases/ma/mgcp/public_test.go @@ -1,6 +1,7 @@ package mgcp import ( + "github.com/enbility/eebus-go/api" "github.com/enbility/spine-go/model" "github.com/enbility/spine-go/util" "github.com/stretchr/testify/assert" @@ -85,6 +86,9 @@ func (s *GcpMGCPSuite) Test_Power() { { MeasurementId: util.Ptr(model.MeasurementIdType(0)), Value: model.NewScaledNumberType(10), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), // MGCP required + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), // MGCP recommended + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), // MGCP recommended }, }, } @@ -130,6 +134,91 @@ func (s *GcpMGCPSuite) Test_Power() { assert.Equal(s.T(), 10.0, data) } +func (s *GcpMGCPSuite) Test_Power_ErrorCases() { + // Test case where multiple measurement data entries are returned (len(data) != 1) + // This should trigger the missing coverage on line 77-79 in Power() + + descData := &model.MeasurementDescriptionListDataType{ + MeasurementDescriptionData: []model.MeasurementDescriptionDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + MeasurementType: util.Ptr(model.MeasurementTypeTypePower), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACPowerTotal), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + MeasurementType: util.Ptr(model.MeasurementTypeTypePower), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACPowerTotal), + }, + }, + } + + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.smgwEntity, model.FeatureTypeTypeMeasurement, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeMeasurementDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + // Add measurement data for both descriptions + measData := &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + Value: model.NewScaledNumberType(10), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + Value: model.NewScaledNumberType(20), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + // Add electrical connection data to enable both measurements + elDescData := &model.ElectricalConnectionDescriptionListDataType{ + ElectricalConnectionDescriptionData: []model.ElectricalConnectionDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + PositiveEnergyDirection: util.Ptr(model.EnergyDirectionTypeConsume), + }, + }, + } + + rElFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.smgwEntity, model.FeatureTypeTypeElectricalConnection, model.RoleTypeServer) + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionDescriptionListData, elDescData, nil, nil) + assert.Nil(s.T(), fErr) + + elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ + ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + }, + }, + } + + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionParameterDescriptionListData, elParamData, nil, nil) + assert.Nil(s.T(), fErr) + + // This should now return multiple data points and trigger the len(data) != 1 condition + data, err := s.sut.Power(s.smgwEntity) + assert.NotNil(s.T(), err) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data) +} + func (s *GcpMGCPSuite) Test_EnergyFeedIn() { data, err := s.sut.EnergyFeedIn(s.mockRemoteEntity) assert.NotNil(s.T(), err) @@ -163,6 +252,9 @@ func (s *GcpMGCPSuite) Test_EnergyFeedIn() { { MeasurementId: util.Ptr(model.MeasurementIdType(0)), Value: model.NewScaledNumberType(10), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), // MGCP required + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), // MGCP mandatory for energy + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), // MGCP recommended }, }, } @@ -225,6 +317,9 @@ func (s *GcpMGCPSuite) Test_EnergyConsumed() { { MeasurementId: util.Ptr(model.MeasurementIdType(0)), Value: model.NewScaledNumberType(10), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), // MGCP required + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), // MGCP mandatory for energy + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), // MGCP recommended }, }, } @@ -299,14 +394,23 @@ func (s *GcpMGCPSuite) Test_CurrentPerPhase() { { MeasurementId: util.Ptr(model.MeasurementIdType(0)), Value: model.NewScaledNumberType(10), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), // MGCP required + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), // MGCP recommended + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), // MGCP recommended }, { MeasurementId: util.Ptr(model.MeasurementIdType(1)), Value: model.NewScaledNumberType(10), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), // MGCP required + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), // MGCP recommended + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), // MGCP recommended }, { MeasurementId: util.Ptr(model.MeasurementIdType(2)), Value: model.NewScaledNumberType(10), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), // MGCP required + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), // MGCP recommended + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), // MGCP recommended }, }, } @@ -315,8 +419,8 @@ func (s *GcpMGCPSuite) Test_CurrentPerPhase() { assert.Nil(s.T(), fErr) data, err = s.sut.CurrentPerPhase(s.smgwEntity) - assert.Nil(s.T(), err) - assert.Equal(s.T(), 0, len(data)) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ @@ -404,14 +508,23 @@ func (s *GcpMGCPSuite) Test_VoltagePerPhase() { { MeasurementId: util.Ptr(model.MeasurementIdType(0)), Value: model.NewScaledNumberType(230), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), // MGCP required + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), // MGCP recommended + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), // MGCP recommended }, { MeasurementId: util.Ptr(model.MeasurementIdType(1)), Value: model.NewScaledNumberType(230), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), // MGCP required + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), // MGCP recommended + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), // MGCP recommended }, { MeasurementId: util.Ptr(model.MeasurementIdType(2)), Value: model.NewScaledNumberType(230), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), // MGCP required + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), // MGCP recommended + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), // MGCP recommended }, }, } @@ -420,8 +533,8 @@ func (s *GcpMGCPSuite) Test_VoltagePerPhase() { assert.Nil(s.T(), fErr) data, err = s.sut.VoltagePerPhase(s.smgwEntity) - assert.Nil(s.T(), err) - assert.Equal(s.T(), 0, len(data)) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ @@ -485,6 +598,9 @@ func (s *GcpMGCPSuite) Test_Frequency() { { MeasurementId: util.Ptr(model.MeasurementIdType(0)), Value: model.NewScaledNumberType(50), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), // MGCP required + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), // MGCP recommended + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), // MGCP recommended }, }, } @@ -513,3 +629,270 @@ func (s *GcpMGCPSuite) Test_Frequency() { assert.NotNil(s.T(), err) assert.Equal(s.T(), 0.0, data) } + +// Additional comprehensive tests for MGCP specification compliance + +func (s *GcpMGCPSuite) Test_PowerWithInvalidValueType() { + // Setup measurement description first + descData := &model.MeasurementDescriptionListDataType{ + MeasurementDescriptionData: []model.MeasurementDescriptionDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + MeasurementType: util.Ptr(model.MeasurementTypeTypePower), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACPowerTotal), + }, + }, + } + + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.smgwEntity, model.FeatureTypeTypeMeasurement, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeMeasurementDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test with invalid ValueType (averageValue instead of value) + measData := &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + Value: model.NewScaledNumberType(2500), + ValueType: util.Ptr(model.MeasurementValueTypeTypeAverageValue), // Invalid + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err := s.sut.Power(s.smgwEntity) + assert.NotNil(s.T(), err) + assert.Equal(s.T(), 0.0, data) +} + +func (s *GcpMGCPSuite) Test_EnergyWithMissingValueSource() { + // Setup measurement description for energy + descData := &model.MeasurementDescriptionListDataType{ + MeasurementDescriptionData: []model.MeasurementDescriptionDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeEnergy), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeGridFeedIn), + }, + }, + } + + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.smgwEntity, model.FeatureTypeTypeMeasurement, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeMeasurementDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test energy measurement without ValueSource (mandatory for energy per MGCP spec) + measData := &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + Value: model.NewScaledNumberType(5000), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + // ValueSource missing - should fail for energy + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err := s.sut.EnergyFeedIn(s.smgwEntity) + assert.NotNil(s.T(), err) + assert.Equal(s.T(), 0.0, data) +} + +func (s *GcpMGCPSuite) Test_FrequencyWithOutOfRangeState() { + // Setup measurement description + descData := &model.MeasurementDescriptionListDataType{ + MeasurementDescriptionData: []model.MeasurementDescriptionDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeFrequency), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACFrequency), + }, + }, + } + + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.smgwEntity, model.FeatureTypeTypeMeasurement, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeMeasurementDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test with outOfRange state (should be skipped per MGCP-003) + measData := &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + Value: model.NewScaledNumberType(50), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeOutofrange), // Should be skipped + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err := s.sut.Frequency(s.smgwEntity) + assert.NotNil(s.T(), err) // Should fail due to no valid measurements + assert.Equal(s.T(), 0.0, data) +} + +func (s *GcpMGCPSuite) Test_CurrentWithMixedStates() { + // Setup measurement description for current + descData := &model.MeasurementDescriptionListDataType{ + MeasurementDescriptionData: []model.MeasurementDescriptionDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeCurrent), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACCurrent), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeCurrent), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACCurrent), + }, + }, + } + + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.smgwEntity, model.FeatureTypeTypeMeasurement, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeMeasurementDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + // Setup electrical connection description + elDescData := &model.ElectricalConnectionDescriptionListDataType{ + ElectricalConnectionDescriptionData: []model.ElectricalConnectionDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + PositiveEnergyDirection: util.Ptr(model.EnergyDirectionTypeConsume), + }, + }, + } + + rElFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.smgwEntity, model.FeatureTypeTypeElectricalConnection, model.RoleTypeServer) + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionDescriptionListData, elDescData, nil, nil) + assert.Nil(s.T(), fErr) + + // Setup electrical connection parameter description with phase mapping + elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ + ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + ParameterId: util.Ptr(model.ElectricalConnectionParameterIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + AcMeasuredPhases: util.Ptr(model.ElectricalConnectionPhaseNameTypeA), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + ParameterId: util.Ptr(model.ElectricalConnectionParameterIdType(1)), + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + AcMeasuredPhases: util.Ptr(model.ElectricalConnectionPhaseNameTypeB), + }, + }, + } + + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionParameterDescriptionListData, elParamData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test with mixed states - one error, one normal + measData := &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + Value: model.NewScaledNumberType(15), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeError), // Should be skipped + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + Value: model.NewScaledNumberType(12), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), // Should be used + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err := s.sut.CurrentPerPhase(s.smgwEntity) + assert.Nil(s.T(), err) // Should succeed with one valid measurement + assert.Equal(s.T(), 1, len(data)) + assert.Equal(s.T(), 12.0, data[0]) +} + +func (s *GcpMGCPSuite) Test_VoltageValidationCompliance() { + // Setup measurement description for voltage + descData := &model.MeasurementDescriptionListDataType{ + MeasurementDescriptionData: []model.MeasurementDescriptionDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeVoltage), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACVoltage), + }, + }, + } + + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.smgwEntity, model.FeatureTypeTypeMeasurement, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeMeasurementDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + // Setup electrical connection description + elDescData := &model.ElectricalConnectionDescriptionListDataType{ + ElectricalConnectionDescriptionData: []model.ElectricalConnectionDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + }, + }, + } + + rElFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.smgwEntity, model.FeatureTypeTypeElectricalConnection, model.RoleTypeServer) + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionDescriptionListData, elDescData, nil, nil) + assert.Nil(s.T(), fErr) + + // Setup electrical connection parameter description + elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ + ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + ParameterId: util.Ptr(model.ElectricalConnectionParameterIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + AcMeasuredPhases: util.Ptr(model.ElectricalConnectionPhaseNameTypeA), + }, + }, + } + + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionParameterDescriptionListData, elParamData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test voltage measurement with valid ValueSource (mandatory for MGCP) + measData := &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + Value: model.NewScaledNumberType(230), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err := s.sut.VoltagePerPhase(s.smgwEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 1, len(data)) + assert.Equal(s.T(), 230.0, data[0]) +} diff --git a/usecases/ma/mgcp/validators.go b/usecases/ma/mgcp/validators.go new file mode 100644 index 00000000..93e94462 --- /dev/null +++ b/usecases/ma/mgcp/validators.go @@ -0,0 +1,38 @@ +package mgcp + +import ( + internal "github.com/enbility/eebus-go/usecases/internal" + "github.com/enbility/spine-go/model" +) + +// Validators for the MGCP (Monitoring of Grid Connection Point) use case. +// Per the MGCP spec: +// - measurementId, value, valueType=value are Mandatory +// - valueSource is Mandatory and must be measuredValue/calculatedValue/empiricalValue +// - MGCP-003: measurements with valueState=error or outOfRange SHALL be ignored + +// mgcpValueSources are the allowed value sources for all MGCP measurements. +var mgcpValueSources = []model.MeasurementValueSourceType{ + model.MeasurementValueSourceTypeMeasuredValue, + model.MeasurementValueSourceTypeCalculatedValue, + model.MeasurementValueSourceTypeEmpiricalValue, +} + +// scenarioValidator builds the common validator shape used by every MGCP scenario. +func scenarioValidator(name string) internal.MeasurementValidator { + return internal.NewMeasurementValidator(). + WithName(name). + WithRule(internal.RequireMeasurementId()). + WithRule(internal.RequireMeasurementValue()). + WithRule(internal.RequireValueType(model.MeasurementValueTypeTypeValue)). + WithRule(internal.RequireValueSource(mgcpValueSources...)). + WithRule(internal.SkipValueState()) +} + +var ( + MGCPPowerValidator = scenarioValidator("MGCP Power") + MGCPEnergyValidator = scenarioValidator("MGCP Energy") + MGCPCurrentValidator = scenarioValidator("MGCP Current") + MGCPVoltageValidator = scenarioValidator("MGCP Voltage") + MGCPFrequencyValidator = scenarioValidator("MGCP Frequency") +) diff --git a/usecases/ma/mgcp/validators_test.go b/usecases/ma/mgcp/validators_test.go new file mode 100644 index 00000000..de53a33f --- /dev/null +++ b/usecases/ma/mgcp/validators_test.go @@ -0,0 +1,181 @@ +package mgcp + +import ( + "errors" + "testing" + + internal "github.com/enbility/eebus-go/usecases/internal" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" + "github.com/stretchr/testify/assert" +) + +func validMeasurement() *model.MeasurementDataType { + return &model.MeasurementDataType{ + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + Value: model.NewScaledNumberType(2500), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + } +} + +func mgcpValidators() map[string]internal.MeasurementValidator { + return map[string]internal.MeasurementValidator{ + "power": MGCPPowerValidator, + "energy": MGCPEnergyValidator, + "current": MGCPCurrentValidator, + "voltage": MGCPVoltageValidator, + "frequency": MGCPFrequencyValidator, + } +} + +func TestMGCPValidators_AcceptValidMeasurements(t *testing.T) { + for name, v := range mgcpValidators() { + t.Run(name, func(t *testing.T) { + assert.NoError(t, v.Validate(validMeasurement())) + }) + } +} + +func TestMGCPValidators_AcceptAllAllowedValueSources(t *testing.T) { + for _, src := range mgcpValueSources { + for name, v := range mgcpValidators() { + t.Run(name+"/"+string(src), func(t *testing.T) { + m := validMeasurement() + m.ValueSource = util.Ptr(src) + assert.NoError(t, v.Validate(m)) + }) + } + } +} + +func TestMGCPValidators_RejectMissingRequiredFields(t *testing.T) { + for name, v := range mgcpValidators() { + t.Run(name+"/MeasurementId", func(t *testing.T) { + m := validMeasurement() + m.MeasurementId = nil + err := v.Validate(m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "MeasurementId") + }) + t.Run(name+"/Value", func(t *testing.T) { + m := validMeasurement() + m.Value = nil + err := v.Validate(m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Value") + }) + t.Run(name+"/ValueType", func(t *testing.T) { + m := validMeasurement() + m.ValueType = nil + err := v.Validate(m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ValueType") + }) + t.Run(name+"/ValueSource", func(t *testing.T) { + m := validMeasurement() + m.ValueSource = nil + err := v.Validate(m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ValueSource is required") + }) + } +} + +func TestMGCPValidators_RejectWrongValueType(t *testing.T) { + for name, v := range mgcpValidators() { + for _, badType := range []model.MeasurementValueTypeType{ + model.MeasurementValueTypeTypeAverageValue, + model.MeasurementValueTypeTypeMaxValue, + model.MeasurementValueTypeTypeMinValue, + } { + t.Run(name+"/"+string(badType), func(t *testing.T) { + m := validMeasurement() + m.ValueType = util.Ptr(badType) + err := v.Validate(m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ValueType must be") + }) + } + } +} + +func TestMGCPValidators_RejectUnknownValueSource(t *testing.T) { + for name, v := range mgcpValidators() { + for _, bad := range []model.MeasurementValueSourceType{"simulatedValue", "approximatedValue"} { + t.Run(name+"/"+string(bad), func(t *testing.T) { + m := validMeasurement() + m.ValueSource = util.Ptr(bad) + err := v.Validate(m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ValueSource") + }) + } + } +} + +func TestMGCPValidators_SkipValueStateErrorOrOutOfRange(t *testing.T) { + // MGCP-003: measurements with state error or outOfRange SHALL be ignored. + for _, state := range []model.MeasurementValueStateType{ + model.MeasurementValueStateTypeError, + model.MeasurementValueStateTypeOutofrange, + } { + for name, v := range mgcpValidators() { + t.Run(name+"/"+string(state), func(t *testing.T) { + m := validMeasurement() + m.ValueState = util.Ptr(state) + err := v.Validate(m) + assert.Error(t, err) + assert.True(t, errors.Is(err, internal.ErrSkipMeasurement), "expected ErrSkipMeasurement, got %v", err) + }) + } + } +} + +func TestMGCPValidators_AcceptMissingValueState(t *testing.T) { + for name, v := range mgcpValidators() { + t.Run(name, func(t *testing.T) { + m := validMeasurement() + m.ValueState = nil + assert.NoError(t, v.Validate(m)) + }) + } +} + +func TestGetMeasurementValue_SkipsErrorState(t *testing.T) { + measurements := []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + Value: model.NewScaledNumberType(2500), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeError), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(2)), + Value: model.NewScaledNumberType(3000), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + }, + } + + value, err := internal.GetMeasurementValue(measurements, MGCPPowerValidator) + assert.NoError(t, err) + assert.Equal(t, 3000.0, value) +} + +func TestGetMeasurementValue_AllSkipped(t *testing.T) { + measurements := []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + Value: model.NewScaledNumberType(2500), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeError), + }, + } + _, err := internal.GetMeasurementValue(measurements, MGCPEnergyValidator) + assert.Error(t, err) +} diff --git a/usecases/ma/mpc/events.go b/usecases/ma/mpc/events.go index 05a2da62..a8df0bc8 100644 --- a/usecases/ma/mpc/events.go +++ b/usecases/ma/mpc/events.go @@ -90,42 +90,63 @@ func (e *MPC) deviceMeasurementDataUpdate(payload spineapi.EventPayload) { filter := model.MeasurementDescriptionDataType{ ScopeType: util.Ptr(model.ScopeTypeTypeACPowerTotal), } - if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdatePower) + if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.Power(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdatePower) + } } filter.ScopeType = util.Ptr(model.ScopeTypeTypeACPower) - if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdatePowerPerPhase) + if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.PowerPerPhase(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdatePowerPerPhase) + } } // Scenario 2 filter.ScopeType = util.Ptr(model.ScopeTypeTypeACEnergyConsumed) - if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateEnergyConsumed) + if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.EnergyConsumed(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateEnergyConsumed) + } } filter.ScopeType = util.Ptr(model.ScopeTypeTypeACEnergyProduced) - if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateEnergyProduced) + if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.EnergyProduced(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateEnergyProduced) + } } // Scenario 3 filter.ScopeType = util.Ptr(model.ScopeTypeTypeACCurrent) - if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateCurrentsPerPhase) + if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.CurrentPerPhase(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateCurrentsPerPhase) + } } // Scenario 4 filter.ScopeType = util.Ptr(model.ScopeTypeTypeACVoltage) - if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateVoltagePerPhase) + if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.VoltagePerPhase(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateVoltagePerPhase) + } } // Scenario 5 filter.ScopeType = util.Ptr(model.ScopeTypeTypeACFrequency) - if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { - e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateFrequency) + if measurement.CheckEventPayloadDataForFilter(payload.Data, filter) { + // Only fire event if public method succeeds (data is valid and retrievable) + if _, err := e.Frequency(payload.Entity); err == nil && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateFrequency) + } } } } diff --git a/usecases/ma/mpc/events_test.go b/usecases/ma/mpc/events_test.go index cca469f7..e9031ae6 100644 --- a/usecases/ma/mpc/events_test.go +++ b/usecases/ma/mpc/events_test.go @@ -57,32 +57,46 @@ func (s *MaMPCSuite) Test_deviceMeasurementDataUpdate() { descData := &model.MeasurementDescriptionListDataType{ MeasurementDescriptionData: []model.MeasurementDescriptionDataType{ { - MeasurementId: util.Ptr(model.MeasurementIdType(0)), - ScopeType: util.Ptr(model.ScopeTypeTypeACPowerTotal), + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + MeasurementType: util.Ptr(model.MeasurementTypeTypePower), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACPowerTotal), }, { - MeasurementId: util.Ptr(model.MeasurementIdType(1)), - ScopeType: util.Ptr(model.ScopeTypeTypeACPower), + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + MeasurementType: util.Ptr(model.MeasurementTypeTypePower), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACPower), }, { - MeasurementId: util.Ptr(model.MeasurementIdType(2)), - ScopeType: util.Ptr(model.ScopeTypeTypeACEnergyConsumed), + MeasurementId: util.Ptr(model.MeasurementIdType(2)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeEnergy), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACEnergyConsumed), }, { - MeasurementId: util.Ptr(model.MeasurementIdType(3)), - ScopeType: util.Ptr(model.ScopeTypeTypeACEnergyProduced), + MeasurementId: util.Ptr(model.MeasurementIdType(3)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeEnergy), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACEnergyProduced), }, { - MeasurementId: util.Ptr(model.MeasurementIdType(4)), - ScopeType: util.Ptr(model.ScopeTypeTypeACCurrent), + MeasurementId: util.Ptr(model.MeasurementIdType(4)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeCurrent), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACCurrent), }, { - MeasurementId: util.Ptr(model.MeasurementIdType(5)), - ScopeType: util.Ptr(model.ScopeTypeTypeACVoltage), + MeasurementId: util.Ptr(model.MeasurementIdType(5)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeVoltage), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACVoltage), }, { - MeasurementId: util.Ptr(model.MeasurementIdType(6)), - ScopeType: util.Ptr(model.ScopeTypeTypeACFrequency), + MeasurementId: util.Ptr(model.MeasurementIdType(6)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeFrequency), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACFrequency), }, }, } @@ -91,6 +105,56 @@ func (s *MaMPCSuite) Test_deviceMeasurementDataUpdate() { _, fErr := rFeature.UpdateData(true, model.FunctionTypeMeasurementDescriptionListData, descData, nil, nil) assert.Nil(s.T(), fErr) + // Add electrical connection setup for complete validation + elDescData := &model.ElectricalConnectionDescriptionListDataType{ + ElectricalConnectionDescriptionData: []model.ElectricalConnectionDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + PositiveEnergyDirection: util.Ptr(model.EnergyDirectionTypeConsume), + }, + }, + } + + rElFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.monitoredEntity, model.FeatureTypeTypeElectricalConnection, model.RoleTypeServer) + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionDescriptionListData, elDescData, nil, nil) + assert.Nil(s.T(), fErr) + + elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ + ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(2)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(3)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(4)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(5)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(6)), + }, + }, + } + + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionParameterDescriptionListData, elParamData, nil, nil) + assert.Nil(s.T(), fErr) + s.sut.deviceMeasurementDataUpdate(payload) assert.False(s.T(), s.eventCalled) @@ -98,37 +162,202 @@ func (s *MaMPCSuite) Test_deviceMeasurementDataUpdate() { MeasurementData: []model.MeasurementDataType{ { MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, { MeasurementId: util.Ptr(model.MeasurementIdType(1)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, { MeasurementId: util.Ptr(model.MeasurementIdType(2)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, { MeasurementId: util.Ptr(model.MeasurementIdType(3)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, { MeasurementId: util.Ptr(model.MeasurementIdType(4)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, { MeasurementId: util.Ptr(model.MeasurementIdType(5)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, { MeasurementId: util.Ptr(model.MeasurementIdType(6)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, }, } payload.Data = data + // Update the feature with the data so it's actually stored + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, data, nil, nil) + assert.Nil(s.T(), fErr) + + s.sut.deviceMeasurementDataUpdate(payload) + assert.True(s.T(), s.eventCalled) +} + +func (s *MaMPCSuite) Test_deviceMeasurementDataUpdate_EventCallbacks() { + // Test missing event callback coverage lines 103,105 (PowerPerPhase), 129,131 (CurrentPerPhase), 138,140 (VoltagePerPhase) + + // First setup measurement descriptions to enable the public methods to succeed + descData := &model.MeasurementDescriptionListDataType{ + MeasurementDescriptionData: []model.MeasurementDescriptionDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + MeasurementType: util.Ptr(model.MeasurementTypeTypePower), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACPower), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeCurrent), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACCurrent), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(2)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeVoltage), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACVoltage), + }, + }, + } + + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.monitoredEntity, model.FeatureTypeTypeMeasurement, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeMeasurementDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + // Add valid measurement data to make public methods succeed + measData := &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + Value: model.NewScaledNumberType(1000), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + Value: model.NewScaledNumberType(10), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(2)), + Value: model.NewScaledNumberType(230), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + // Setup electrical connection for phase-specific data + elDescData := &model.ElectricalConnectionDescriptionListDataType{ + ElectricalConnectionDescriptionData: []model.ElectricalConnectionDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + PositiveEnergyDirection: util.Ptr(model.EnergyDirectionTypeConsume), + }, + }, + } + + rElFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.monitoredEntity, model.FeatureTypeTypeElectricalConnection, model.RoleTypeServer) + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionDescriptionListData, elDescData, nil, nil) + assert.Nil(s.T(), fErr) + + elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ + ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + AcMeasuredPhases: util.Ptr(model.ElectricalConnectionPhaseNameTypeA), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + AcMeasuredPhases: util.Ptr(model.ElectricalConnectionPhaseNameTypeA), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(2)), + AcMeasuredPhases: util.Ptr(model.ElectricalConnectionPhaseNameTypeA), + }, + }, + } + + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionParameterDescriptionListData, elParamData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test PowerPerPhase event callback (line 103,105) + payload := spineapi.EventPayload{ + Ski: remoteSki, + Device: s.remoteDevice, + Entity: s.monitoredEntity, + Data: measData, + } + + s.eventCalled = false + s.sut.deviceMeasurementDataUpdate(payload) + assert.True(s.T(), s.eventCalled) + + // Test CurrentPerPhase event callback (line 129,131) + currentData := &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + Value: model.NewScaledNumberType(15), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + }, + }, + } + + payload.Data = currentData + s.eventCalled = false + s.sut.deviceMeasurementDataUpdate(payload) + assert.True(s.T(), s.eventCalled) + + // Test VoltagePerPhase event callback (line 138,140) + voltageData := &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(2)), + Value: model.NewScaledNumberType(240), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + }, + }, + } + + payload.Data = voltageData + s.eventCalled = false s.sut.deviceMeasurementDataUpdate(payload) assert.True(s.T(), s.eventCalled) } diff --git a/usecases/ma/mpc/public.go b/usecases/ma/mpc/public.go index 6df7fbfc..420847de 100644 --- a/usecases/ma/mpc/public.go +++ b/usecases/ma/mpc/public.go @@ -2,7 +2,6 @@ package mpc import ( "github.com/enbility/eebus-go/api" - "github.com/enbility/eebus-go/features/client" ucapi "github.com/enbility/eebus-go/usecases/api" internal "github.com/enbility/eebus-go/usecases/internal" spineapi "github.com/enbility/spine-go/api" @@ -28,7 +27,7 @@ func (e *MPC) Power(entity spineapi.EntityRemoteInterface) (float64, error) { CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), ScopeType: util.Ptr(model.ScopeTypeTypeACPowerTotal), } - values, err := internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, model.EnergyDirectionTypeConsume, nil) + values, err := internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, model.EnergyDirectionTypeConsume, nil, powerValidator) if err != nil { return 0, err } @@ -55,7 +54,7 @@ func (e *MPC) PowerPerPhase(entity spineapi.EntityRemoteInterface) ([]float64, e CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), ScopeType: util.Ptr(model.ScopeTypeTypeACPower), } - return internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, model.EnergyDirectionTypeConsume, ucapi.PhaseNameMapping) + return internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, model.EnergyDirectionTypeConsume, ucapi.PhaseNameMapping, powerValidator) } // Scenario 2 @@ -73,34 +72,20 @@ func (e *MPC) EnergyConsumed(entity spineapi.EntityRemoteInterface) (float64, er return 0, api.ErrNoCompatibleEntity } - measurement, err := client.NewMeasurement(e.LocalEntity, entity) - if err != nil { - return 0, err - } - filter := model.MeasurementDescriptionDataType{ MeasurementType: util.Ptr(model.MeasurementTypeTypeEnergy), CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), ScopeType: util.Ptr(model.ScopeTypeTypeACEnergyConsumed), } - values, err := measurement.GetDataForFilter(filter) - if err != nil || len(values) == 0 { - return 0, api.ErrDataNotAvailable + values, err := internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, model.EnergyDirectionTypeConsume, nil, energyValidator) + if err != nil { + return 0, err } - - // we assume thre is only one result - value := values[0].Value - if value == nil { + if len(values) != 1 { return 0, api.ErrDataNotAvailable } - // if the value state is set and not normal, the value is not valid and should be ignored - // therefore we return an error - if values[0].ValueState != nil && *values[0].ValueState != model.MeasurementValueStateTypeNormal { - return 0, api.ErrDataInvalid - } - - return value.GetValue(), nil + return values[0], nil } // return the total feed in energy @@ -116,34 +101,20 @@ func (e *MPC) EnergyProduced(entity spineapi.EntityRemoteInterface) (float64, er return 0, api.ErrNoCompatibleEntity } - measurement, err := client.NewMeasurement(e.LocalEntity, entity) - if err != nil { - return 0, err - } - filter := model.MeasurementDescriptionDataType{ MeasurementType: util.Ptr(model.MeasurementTypeTypeEnergy), CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), ScopeType: util.Ptr(model.ScopeTypeTypeACEnergyProduced), } - values, err := measurement.GetDataForFilter(filter) - if err != nil || len(values) == 0 { - return 0, api.ErrDataNotAvailable + values, err := internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, model.EnergyDirectionTypeConsume, nil, energyValidator) + if err != nil { + return 0, err } - - // we assume thre is only one result - value := values[0].Value - if value == nil { + if len(values) != 1 { return 0, api.ErrDataNotAvailable } - // if the value state is set and not normal, the value is not valid and should be ignored - // therefore we return an error - if values[0].ValueState != nil && *values[0].ValueState != model.MeasurementValueStateTypeNormal { - return 0, api.ErrDataInvalid - } - - return value.GetValue(), nil + return values[0], nil } // Scenario 3 @@ -167,7 +138,7 @@ func (e *MPC) CurrentPerPhase(entity spineapi.EntityRemoteInterface) ([]float64, CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), ScopeType: util.Ptr(model.ScopeTypeTypeACCurrent), } - return internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, model.EnergyDirectionTypeConsume, ucapi.PhaseNameMapping) + return internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, model.EnergyDirectionTypeConsume, ucapi.PhaseNameMapping, currentValidator) } // Scenario 4 @@ -188,7 +159,7 @@ func (e *MPC) VoltagePerPhase(entity spineapi.EntityRemoteInterface) ([]float64, CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), ScopeType: util.Ptr(model.ScopeTypeTypeACVoltage), } - return internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, "", ucapi.PhaseNameMapping) + return internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, "", ucapi.PhaseNameMapping, voltageValidator) } // Scenario 5 @@ -204,29 +175,18 @@ func (e *MPC) Frequency(entity spineapi.EntityRemoteInterface) (float64, error) return 0, api.ErrNoCompatibleEntity } - measurement, err := client.NewMeasurement(e.LocalEntity, entity) - if err != nil { - return 0, err - } - filter := model.MeasurementDescriptionDataType{ MeasurementType: util.Ptr(model.MeasurementTypeTypeFrequency), CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), ScopeType: util.Ptr(model.ScopeTypeTypeACFrequency), } - data, err := measurement.GetDataForFilter(filter) - if err != nil || len(data) == 0 || data[0].Value == nil { - return 0, api.ErrDataNotAvailable + values, err := internal.MeasurementPhaseSpecificDataForFilter(e.LocalEntity, entity, filter, "", nil, frequencyValidator) + if err != nil { + return 0, err } - - // if the value state is set and not normal, the value is not valid and should be ignored - // therefore we return an error - if data[0].ValueState != nil && *data[0].ValueState != model.MeasurementValueStateTypeNormal { - return 0, api.ErrDataInvalid + if len(values) != 1 { + return 0, api.ErrDataNotAvailable } - // take the first item - value := data[0].Value - - return value.GetValue(), nil + return values[0], nil } diff --git a/usecases/ma/mpc/public_test.go b/usecases/ma/mpc/public_test.go index 45622fe2..08822e85 100644 --- a/usecases/ma/mpc/public_test.go +++ b/usecases/ma/mpc/public_test.go @@ -1,6 +1,7 @@ package mpc import ( + "github.com/enbility/eebus-go/api" "github.com/enbility/spine-go/model" "github.com/enbility/spine-go/util" "github.com/stretchr/testify/assert" @@ -34,6 +35,7 @@ func (s *MaMPCSuite) Test_Power() { assert.NotNil(s.T(), err) assert.Equal(s.T(), 0.0, data) + // Test with incomplete measurement data (missing ValueType and ValueSource) measData := &model.MeasurementListDataType{ MeasurementData: []model.MeasurementDataType{ { @@ -50,6 +52,22 @@ func (s *MaMPCSuite) Test_Power() { assert.NotNil(s.T(), err) assert.Equal(s.T(), 0.0, data) + // Test with measurement missing value + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.Power(s.monitoredEntity) + assert.NotNil(s.T(), err) + assert.Equal(s.T(), 0.0, data) + elDescData := &model.ElectricalConnectionDescriptionListDataType{ ElectricalConnectionDescriptionData: []model.ElectricalConnectionDescriptionDataType{ { @@ -79,9 +97,129 @@ func (s *MaMPCSuite) Test_Power() { _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionParameterDescriptionListData, elParamData, nil, nil) assert.Nil(s.T(), fErr) + data, err = s.sut.Power(s.monitoredEntity) + assert.NotNil(s.T(), err) + assert.Equal(s.T(), 0.0, data) + + // Test with complete, valid measurement data + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + data, err = s.sut.Power(s.monitoredEntity) assert.Nil(s.T(), err) assert.Equal(s.T(), 10.0, data) + + // Test with valid data but error state - should be rejected + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeError), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.Power(s.monitoredEntity) + assert.NotNil(s.T(), err) + assert.Equal(s.T(), 0.0, data) + + // Test with multiple measurements matching the same filter (len(values) != 1 case) + descData = &model.MeasurementDescriptionListDataType{ + MeasurementDescriptionData: []model.MeasurementDescriptionDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + MeasurementType: util.Ptr(model.MeasurementTypeTypePower), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACPowerTotal), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + MeasurementType: util.Ptr(model.MeasurementTypeTypePower), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACPowerTotal), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(20), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + elParamData = &model.ElectricalConnectionParameterDescriptionListDataType{ + ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + }, + }, + } + + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionParameterDescriptionListData, elParamData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.Power(s.monitoredEntity) + assert.NotNil(s.T(), err) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data) + // Test with all measurements failing validation (empty result after validation) + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeAverageValue), // Wrong ValueType + Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.Power(s.monitoredEntity) + assert.NotNil(s.T(), err) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data) } func (s *MaMPCSuite) Test_PowerPerPhase() { @@ -145,8 +283,8 @@ func (s *MaMPCSuite) Test_PowerPerPhase() { assert.Nil(s.T(), fErr) data, err = s.sut.PowerPerPhase(s.monitoredEntity) - assert.Nil(s.T(), err) - assert.Equal(s.T(), 0, len(data)) + assert.NotNil(s.T(), err) // Should fail validation due to missing ValueType/ValueSource + assert.Nil(s.T(), data) elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ @@ -184,6 +322,37 @@ func (s *MaMPCSuite) Test_PowerPerPhase() { _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionDescriptionListData, elDescData, nil, nil) assert.Nil(s.T(), fErr) + data, err = s.sut.PowerPerPhase(s.monitoredEntity) + assert.NotNil(s.T(), err) // Still invalid - measurements need ValueType/ValueSource + assert.Nil(s.T(), data) + + // Add complete, valid measurement data + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(2)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + data, err = s.sut.PowerPerPhase(s.monitoredEntity) assert.Nil(s.T(), err) assert.Equal(s.T(), []float64{10, 10, 10}, data) @@ -236,7 +405,9 @@ func (s *MaMPCSuite) Test_EnergyConsumed() { MeasurementData: []model.MeasurementDataType{ { MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, }, } @@ -244,6 +415,36 @@ func (s *MaMPCSuite) Test_EnergyConsumed() { _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) assert.Nil(s.T(), fErr) + data, err = s.sut.EnergyConsumed(s.monitoredEntity) + assert.NotNil(s.T(), err) // Need electrical connection setup + assert.Equal(s.T(), 0.0, data) + + // Add electrical connection setup for energy measurements + elDescData := &model.ElectricalConnectionDescriptionListDataType{ + ElectricalConnectionDescriptionData: []model.ElectricalConnectionDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + PositiveEnergyDirection: util.Ptr(model.EnergyDirectionTypeConsume), + }, + }, + } + + rElFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.monitoredEntity, model.FeatureTypeTypeElectricalConnection, model.RoleTypeServer) + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionDescriptionListData, elDescData, nil, nil) + assert.Nil(s.T(), fErr) + + elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ + ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + }, + }, + } + + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionParameterDescriptionListData, elParamData, nil, nil) + assert.Nil(s.T(), fErr) + data, err = s.sut.EnergyConsumed(s.monitoredEntity) assert.Nil(s.T(), err) assert.Equal(s.T(), 10.0, data) @@ -252,7 +453,9 @@ func (s *MaMPCSuite) Test_EnergyConsumed() { MeasurementData: []model.MeasurementDataType{ { MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), ValueState: util.Ptr(model.MeasurementValueStateTypeError), }, }, @@ -264,6 +467,88 @@ func (s *MaMPCSuite) Test_EnergyConsumed() { data, err = s.sut.EnergyConsumed(s.monitoredEntity) assert.NotNil(s.T(), err) assert.Equal(s.T(), 0.0, data) + + // Test with multiple measurements matching the same filter (len(values) != 1 case) + descData = &model.MeasurementDescriptionListDataType{ + MeasurementDescriptionData: []model.MeasurementDescriptionDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeEnergy), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACEnergyConsumed), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeEnergy), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACEnergyConsumed), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(100), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(200), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + elParamData = &model.ElectricalConnectionParameterDescriptionListDataType{ + ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + }, + }, + } + + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionParameterDescriptionListData, elParamData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.EnergyConsumed(s.monitoredEntity) + assert.NotNil(s.T(), err) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data) + + // Test with a disallowed ValueSource (should fail validation) + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceType("simulatedValue")), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.EnergyConsumed(s.monitoredEntity) + assert.NotNil(s.T(), err) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data) } func (s *MaMPCSuite) Test_EnergyProduced() { @@ -313,7 +598,9 @@ func (s *MaMPCSuite) Test_EnergyProduced() { MeasurementData: []model.MeasurementDataType{ { MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, }, } @@ -321,6 +608,36 @@ func (s *MaMPCSuite) Test_EnergyProduced() { _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) assert.Nil(s.T(), fErr) + data, err = s.sut.EnergyProduced(s.monitoredEntity) + assert.NotNil(s.T(), err) // Need electrical connection setup + assert.Equal(s.T(), 0.0, data) + + // Add electrical connection setup for energy measurements + elDescData := &model.ElectricalConnectionDescriptionListDataType{ + ElectricalConnectionDescriptionData: []model.ElectricalConnectionDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + PositiveEnergyDirection: util.Ptr(model.EnergyDirectionTypeConsume), + }, + }, + } + + rElFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.monitoredEntity, model.FeatureTypeTypeElectricalConnection, model.RoleTypeServer) + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionDescriptionListData, elDescData, nil, nil) + assert.Nil(s.T(), fErr) + + elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ + ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + }, + }, + } + + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionParameterDescriptionListData, elParamData, nil, nil) + assert.Nil(s.T(), fErr) + data, err = s.sut.EnergyProduced(s.monitoredEntity) assert.Nil(s.T(), err) assert.Equal(s.T(), 10.0, data) @@ -329,7 +646,9 @@ func (s *MaMPCSuite) Test_EnergyProduced() { MeasurementData: []model.MeasurementDataType{ { MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), ValueState: util.Ptr(model.MeasurementValueStateTypeError), }, }, @@ -341,6 +660,68 @@ func (s *MaMPCSuite) Test_EnergyProduced() { data, err = s.sut.EnergyProduced(s.monitoredEntity) assert.NotNil(s.T(), err) assert.Equal(s.T(), 0.0, data) + + // Test with multiple measurements matching the same filter (len(values) != 1 case) + descData = &model.MeasurementDescriptionListDataType{ + MeasurementDescriptionData: []model.MeasurementDescriptionDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeEnergy), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACEnergyProduced), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeEnergy), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACEnergyProduced), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(150), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(250), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + elParamData = &model.ElectricalConnectionParameterDescriptionListDataType{ + ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + }, + { + ElectricalConnectionId: util.Ptr(model.ElectricalConnectionIdType(0)), + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + }, + }, + } + + _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionParameterDescriptionListData, elParamData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.EnergyProduced(s.monitoredEntity) + assert.NotNil(s.T(), err) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data) } func (s *MaMPCSuite) Test_CurrentPerPhase() { @@ -388,14 +769,17 @@ func (s *MaMPCSuite) Test_CurrentPerPhase() { { MeasurementId: util.Ptr(model.MeasurementIdType(0)), Value: model.NewScaledNumberType(10), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), }, { MeasurementId: util.Ptr(model.MeasurementIdType(1)), Value: model.NewScaledNumberType(10), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), }, { MeasurementId: util.Ptr(model.MeasurementIdType(2)), Value: model.NewScaledNumberType(10), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), }, }, } @@ -404,8 +788,8 @@ func (s *MaMPCSuite) Test_CurrentPerPhase() { assert.Nil(s.T(), fErr) data, err = s.sut.CurrentPerPhase(s.monitoredEntity) - assert.Nil(s.T(), err) - assert.Equal(s.T(), 0, len(data)) + assert.NotNil(s.T(), err) // Should fail - missing ValueState (required for current) + assert.Nil(s.T(), data) elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ @@ -443,9 +827,78 @@ func (s *MaMPCSuite) Test_CurrentPerPhase() { _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionDescriptionListData, elDescData, nil, nil) assert.Nil(s.T(), fErr) + data, err = s.sut.CurrentPerPhase(s.monitoredEntity) + assert.NotNil(s.T(), err) // Still missing ValueState + assert.Nil(s.T(), data) + + // Add complete, valid current measurement data + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(2)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(10), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + data, err = s.sut.CurrentPerPhase(s.monitoredEntity) assert.Nil(s.T(), err) assert.Equal(s.T(), []float64{10, 10, 10}, data) + + // Per MPC-003, measurements with state error/outOfRange SHALL be ignored. + // Only the one with valueState=normal should make it through. + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(15), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeError), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(20), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeOutofrange), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(2)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(25), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.CurrentPerPhase(s.monitoredEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), []float64{25}, data) } func (s *MaMPCSuite) Test_VoltagePerPhase() { @@ -509,8 +962,8 @@ func (s *MaMPCSuite) Test_VoltagePerPhase() { assert.Nil(s.T(), fErr) data, err = s.sut.VoltagePerPhase(s.monitoredEntity) - assert.Nil(s.T(), err) - assert.Equal(s.T(), 0, len(data)) + assert.NotNil(s.T(), err) // Should fail - missing ValueType, no range validation + assert.Nil(s.T(), data) elParamData := &model.ElectricalConnectionParameterDescriptionListDataType{ ElectricalConnectionParameterDescriptionData: []model.ElectricalConnectionParameterDescriptionDataType{ @@ -536,9 +989,71 @@ func (s *MaMPCSuite) Test_VoltagePerPhase() { _, fErr = rElFeature.UpdateData(true, model.FunctionTypeElectricalConnectionParameterDescriptionListData, elParamData, nil, nil) assert.Nil(s.T(), fErr) + data, err = s.sut.VoltagePerPhase(s.monitoredEntity) + assert.NotNil(s.T(), err) // Still invalid - missing ValueType + assert.Nil(s.T(), data) + + // Add complete, valid voltage measurement data + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(230), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(230), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(2)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(230), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + data, err = s.sut.VoltagePerPhase(s.monitoredEntity) assert.Nil(s.T(), err) assert.Equal(s.T(), []float64{230, 230, 230}, data) + + // MPC spec places no upper bound on voltage, so 1001 is valid just like 230. + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(1001), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(230), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(2)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(230), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.VoltagePerPhase(s.monitoredEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), []float64{1001, 230, 230}, data) } func (s *MaMPCSuite) Test_Frequency() { @@ -588,7 +1103,9 @@ func (s *MaMPCSuite) Test_Frequency() { MeasurementData: []model.MeasurementDataType{ { MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(50), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), }, }, } @@ -604,7 +1121,9 @@ func (s *MaMPCSuite) Test_Frequency() { MeasurementData: []model.MeasurementDataType{ { MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), Value: model.NewScaledNumberType(50), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), ValueState: util.Ptr(model.MeasurementValueStateTypeError), }, }, @@ -616,4 +1135,126 @@ func (s *MaMPCSuite) Test_Frequency() { data, err = s.sut.Frequency(s.monitoredEntity) assert.NotNil(s.T(), err) assert.Equal(s.T(), 0.0, data) + + // Test with multiple measurements matching the same filter (len(values) != 1 case) + descData = &model.MeasurementDescriptionListDataType{ + MeasurementDescriptionData: []model.MeasurementDescriptionDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeFrequency), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACFrequency), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + MeasurementType: util.Ptr(model.MeasurementTypeTypeFrequency), + CommodityType: util.Ptr(model.CommodityTypeTypeElectricity), + ScopeType: util.Ptr(model.ScopeTypeTypeACFrequency), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(50), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + { + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(50.1), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.Frequency(s.monitoredEntity) + assert.NotNil(s.T(), err) + assert.Equal(s.T(), api.ErrDataNotAvailable, err) + assert.Equal(s.T(), 0.0, data) + + // Test frequency values that would have been out of range if validation existed + // These should now succeed since we removed the non-spec range validation + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(44), // 44Hz + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.Frequency(s.monitoredEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 44.0, data) // Should succeed now + + // Test high frequency value + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(66), // 66Hz + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.Frequency(s.monitoredEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 66.0, data) // Should succeed now + + // Test frequency at boundaries (45Hz and 65Hz - should be valid) + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(45), // At lower boundary + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.Frequency(s.monitoredEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 45.0, data) // Should be valid at boundary + + measData = &model.MeasurementListDataType{ + MeasurementData: []model.MeasurementDataType{ + { + MeasurementId: util.Ptr(model.MeasurementIdType(0)), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + Value: model.NewScaledNumberType(65), // At upper boundary + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + }, + }, + } + + _, fErr = rFeature.UpdateData(true, model.FunctionTypeMeasurementListData, measData, nil, nil) + assert.Nil(s.T(), fErr) + + data, err = s.sut.Frequency(s.monitoredEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 65.0, data) // Should be valid at boundary } diff --git a/usecases/ma/mpc/validators.go b/usecases/ma/mpc/validators.go new file mode 100644 index 00000000..bd7bc0df --- /dev/null +++ b/usecases/ma/mpc/validators.go @@ -0,0 +1,38 @@ +package mpc + +import ( + internal "github.com/enbility/eebus-go/usecases/internal" + "github.com/enbility/spine-go/model" +) + +// Validators for the MPC (Monitoring of Power Consumption) use case. +// Per the MPC spec (MPC-001/002/003): +// - measurementId, value, valueType=value are Mandatory +// - valueSource is Mandatory and must be measuredValue/calculatedValue/empiricalValue +// - measurements with valueState=error or outOfRange SHALL be ignored (MPC-003) + +// mpcValueSources are the allowed value sources for MPC measurements. +var mpcValueSources = []model.MeasurementValueSourceType{ + model.MeasurementValueSourceTypeMeasuredValue, + model.MeasurementValueSourceTypeCalculatedValue, + model.MeasurementValueSourceTypeEmpiricalValue, +} + +// scenarioValidator builds the common validator shape used by every MPC scenario. +func scenarioValidator(name string) internal.MeasurementValidator { + return internal.NewMeasurementValidator(). + WithName(name). + WithRule(internal.RequireMeasurementId()). + WithRule(internal.RequireMeasurementValue()). + WithRule(internal.RequireValueType(model.MeasurementValueTypeTypeValue)). + WithRule(internal.RequireValueSource(mpcValueSources...)). + WithRule(internal.SkipValueState()) +} + +var ( + powerValidator = scenarioValidator("MPC Power") + energyValidator = scenarioValidator("MPC Energy") + currentValidator = scenarioValidator("MPC Current") + voltageValidator = scenarioValidator("MPC Voltage") + frequencyValidator = scenarioValidator("MPC Frequency") +) diff --git a/usecases/ma/mpc/validators_test.go b/usecases/ma/mpc/validators_test.go new file mode 100644 index 00000000..ef7b3449 --- /dev/null +++ b/usecases/ma/mpc/validators_test.go @@ -0,0 +1,130 @@ +package mpc + +import ( + "errors" + "testing" + + internal "github.com/enbility/eebus-go/usecases/internal" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" + "github.com/stretchr/testify/assert" +) + +func validMeasurement() *model.MeasurementDataType { + return &model.MeasurementDataType{ + MeasurementId: util.Ptr(model.MeasurementIdType(1)), + Value: model.NewScaledNumberType(100), + ValueType: util.Ptr(model.MeasurementValueTypeTypeValue), + ValueSource: util.Ptr(model.MeasurementValueSourceTypeMeasuredValue), + ValueState: util.Ptr(model.MeasurementValueStateTypeNormal), + } +} + +func mpcValidators() map[string]internal.MeasurementValidator { + return map[string]internal.MeasurementValidator{ + "power": powerValidator, + "energy": energyValidator, + "current": currentValidator, + "voltage": voltageValidator, + "frequency": frequencyValidator, + } +} + +func TestMPCValidators_AcceptValidMeasurements(t *testing.T) { + for name, v := range mpcValidators() { + t.Run(name, func(t *testing.T) { + assert.NoError(t, v.Validate(validMeasurement())) + }) + } +} + +func TestMPCValidators_AcceptAllAllowedValueSources(t *testing.T) { + sources := []model.MeasurementValueSourceType{ + model.MeasurementValueSourceTypeMeasuredValue, + model.MeasurementValueSourceTypeCalculatedValue, + model.MeasurementValueSourceTypeEmpiricalValue, + } + for name, v := range mpcValidators() { + for _, src := range sources { + t.Run(name+"/"+string(src), func(t *testing.T) { + m := validMeasurement() + m.ValueSource = util.Ptr(src) + assert.NoError(t, v.Validate(m)) + }) + } + } +} + +func TestMPCValidators_RejectMissingRequiredFields(t *testing.T) { + for name, v := range mpcValidators() { + t.Run(name+"/MeasurementId", func(t *testing.T) { + m := validMeasurement() + m.MeasurementId = nil + err := v.Validate(m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "MeasurementId") + }) + t.Run(name+"/Value", func(t *testing.T) { + m := validMeasurement() + m.Value = nil + err := v.Validate(m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Value") + }) + t.Run(name+"/ValueType", func(t *testing.T) { + m := validMeasurement() + m.ValueType = nil + err := v.Validate(m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ValueType") + }) + t.Run(name+"/ValueSource", func(t *testing.T) { + m := validMeasurement() + m.ValueSource = nil + err := v.Validate(m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ValueSource is required") + }) + } +} + +func TestMPCValidators_RejectWrongValueType(t *testing.T) { + for name, v := range mpcValidators() { + t.Run(name, func(t *testing.T) { + m := validMeasurement() + m.ValueType = util.Ptr(model.MeasurementValueTypeTypeAverageValue) + err := v.Validate(m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ValueType must be") + }) + } +} + +func TestMPCValidators_SkipValueStateErrorOrOutOfRange(t *testing.T) { + // MPC-003: measurements with state error or outOfRange SHALL be ignored. + for _, state := range []model.MeasurementValueStateType{ + model.MeasurementValueStateTypeError, + model.MeasurementValueStateTypeOutofrange, + } { + for name, v := range mpcValidators() { + t.Run(name+"/"+string(state), func(t *testing.T) { + m := validMeasurement() + m.ValueState = util.Ptr(state) + err := v.Validate(m) + assert.Error(t, err) + assert.True(t, errors.Is(err, internal.ErrSkipMeasurement), "expected ErrSkipMeasurement, got %v", err) + }) + } + } +} + +func TestMPCValidators_AcceptMissingValueState(t *testing.T) { + // ValueState is optional; its absence must not fail validation. + for name, v := range mpcValidators() { + t.Run(name, func(t *testing.T) { + m := validMeasurement() + m.ValueState = nil + assert.NoError(t, v.Validate(m)) + }) + } +}