diff --git a/examples/evse/main.go b/examples/evse/main.go index 26256724..e7527db8 100644 --- a/examples/evse/main.go +++ b/examples/evse/main.go @@ -151,7 +151,7 @@ func (h *evse) OnLPCEvent(ski string, device spineapi.DeviceRemoteInterface, ent } switch event { - case lpc.WriteApprovalRequired: + case lpc.LimitWriteApprovalRequired: // get pending writes pendingWrites := h.uclpc.PendingConsumptionLimits() diff --git a/examples/hems/main.go b/examples/hems/main.go index 73ec2451..70356ea2 100644 --- a/examples/hems/main.go +++ b/examples/hems/main.go @@ -149,15 +149,28 @@ func (h *hems) run() { func (h *hems) OnLPCEvent(ski string, device spineapi.DeviceRemoteInterface, entity spineapi.EntityRemoteInterface, event api.EventType) { switch event { - case cslpc.WriteApprovalRequired: + case cslpc.LimitWriteApprovalRequired: // get pending writes pendingWrites := h.uccslpc.PendingConsumptionLimits() // approve any write for msgCounter, write := range pendingWrites { - fmt.Println("Approving LPC write with msgCounter", msgCounter, "and limit", write.Value, "W") + fmt.Println("Approving LPC limit write with msgCounter", msgCounter, "and limit", write.Value, "W") h.uccslpc.ApproveOrDenyConsumptionLimit(msgCounter, true, "") } + case cslpc.ConfigurationWriteApprovalRequired: + pendingDeviceConfigWrites := h.uccslpc.PendingDeviceConfigurations() + + for msgCounter, configs := range pendingDeviceConfigWrites { + fmt.Printf("Approving LPC device config write with msgCounter %d for features: ", msgCounter) + for _, config := range configs { + if config.Description.KeyName != nil { + fmt.Printf("%s ", string(*config.Description.KeyName)) + } + } + fmt.Print("\n") + h.uccslpc.ApproveOrDenyDeviceConfiguration(msgCounter, true, "") + } case cslpc.DataUpdateLimit: if currentLimit, err := h.uccslpc.ConsumptionLimit(); err == nil { fmt.Println("New LPC Limit set to", currentLimit.Value, "W") @@ -169,15 +182,30 @@ func (h *hems) OnLPCEvent(ski string, device spineapi.DeviceRemoteInterface, ent func (h *hems) OnLPPEvent(ski string, device spineapi.DeviceRemoteInterface, entity spineapi.EntityRemoteInterface, event api.EventType) { switch event { - case cslpp.WriteApprovalRequired: - // get pending writes + case cslpp.LimitWriteApprovalRequired: + // get pending limit writes pendingWrites := h.uccslpp.PendingProductionLimits() // approve any write for msgCounter, write := range pendingWrites { - fmt.Println("Approving LPP write with msgCounter", msgCounter, "and limit", write.Value, "W") + fmt.Println("Approving LPP limit write with msgCounter", msgCounter, "and limit", write.Value, "W") h.uccslpp.ApproveOrDenyProductionLimit(msgCounter, true, "") } + case cslpp.ConfigurationWriteApprovalRequired: + // get pending device config writes + pendingDeviceConfigWrites := h.uccslpp.PendingDeviceConfigurations() + + // approve any write + for msgCounter, configs := range pendingDeviceConfigWrites { + fmt.Printf("Approving LPP device config write with msgCounter %d for features: ", msgCounter) + for _, config := range configs { + if config.Description.KeyName != nil { + fmt.Printf("%s ", string(*config.Description.KeyName)) + } + } + fmt.Print("\n") + h.uccslpp.ApproveOrDenyDeviceConfiguration(msgCounter, true, "") + } case cslpp.DataUpdateLimit: if currentLimit, err := h.uccslpp.ProductionLimit(); err == nil { fmt.Println("New LPP Limit set to", currentLimit.Value, "W") diff --git a/usecases/api/cs_lpc.go b/usecases/api/cs_lpc.go index 4b1d4eaa..fc1b7914 100644 --- a/usecases/api/cs_lpc.go +++ b/usecases/api/cs_lpc.go @@ -72,6 +72,17 @@ type CsLPCInterface interface { // - changeable: boolean if the client service can change this value SetFailsafeDurationMinimum(duration time.Duration, changeable bool) (resultErr error) + // return the currently pending incoming device configuration writes + PendingDeviceConfigurations() map[model.MsgCounterType][]PendingDeviceConfiguration + + // accept or deny an incoming device configuration writes + // + // parameters: + // - msg: the incoming write message + // - approve: if the write limit for msg should be approved or not + // - reason: the reason why the approval is denied, otherwise an empty string + ApproveOrDenyDeviceConfiguration(msgCounter model.MsgCounterType, approve bool, reason string) + // Scenario 3 // start sending heartbeat from the local entity supporting this usecase diff --git a/usecases/api/cs_lpp.go b/usecases/api/cs_lpp.go index 752c5c1e..b436bb02 100644 --- a/usecases/api/cs_lpp.go +++ b/usecases/api/cs_lpp.go @@ -72,6 +72,17 @@ type CsLPPInterface interface { // - changeable: boolean if the client service can change this value SetFailsafeDurationMinimum(duration time.Duration, changeable bool) (resultErr error) + // return the currently pending incoming device configuration writes + PendingDeviceConfigurations() map[model.MsgCounterType][]PendingDeviceConfiguration + + // accept or deny an incoming device configuration writes + // + // parameters: + // - msg: the incoming write message + // - approve: if the write limit for msg should be approved or not + // - reason: the reason why the approval is denied, otherwise an empty string + ApproveOrDenyDeviceConfiguration(msgCounter model.MsgCounterType, approve bool, reason string) + // Scenario 3 // start sending heartbeat from the local entity supporting this usecase diff --git a/usecases/api/types.go b/usecases/api/types.go index 52a7f54e..e26a7991 100644 --- a/usecases/api/types.go +++ b/usecases/api/types.go @@ -166,3 +166,9 @@ type DurationSlotValue struct { Duration time.Duration // Duration of this slot Value float64 // Energy Cost or Power Limit } + +type PendingDeviceConfiguration struct { + Description *model.DeviceConfigurationKeyValueDescriptionDataType `json:"description,omitempty"` + Value *model.DeviceConfigurationKeyValueValueType `json:"value,omitempty"` + IsValueChangeable *bool `json:"isValueChangeable,omitempty"` +} diff --git a/usecases/cs/lpc/public.go b/usecases/cs/lpc/public.go index a2c5e000..566108de 100644 --- a/usecases/cs/lpc/public.go +++ b/usecases/cs/lpc/public.go @@ -7,6 +7,7 @@ import ( "github.com/enbility/eebus-go/api" "github.com/enbility/eebus-go/features/server" ucapi "github.com/enbility/eebus-go/usecases/api" + "github.com/enbility/eebus-go/usecases/internal" "github.com/enbility/spine-go/model" "github.com/enbility/spine-go/util" ) @@ -270,6 +271,31 @@ func (e *LPC) SetFailsafeDurationMinimum(duration time.Duration, changeable bool return dc.UpdateKeyValueDataForFilter(data, nil, filter) } +// return the currently pending incoming failsafe consumption limit writes +func (e *LPC) PendingDeviceConfigurations() map[model.MsgCounterType][]ucapi.PendingDeviceConfiguration { + e.pendingDeviceConfigMux.Lock() + defer e.pendingDeviceConfigMux.Unlock() + + return internal.GroupPendingDeviceConfigurations(e.pendingDeviceConfigs, e.LocalEntity) +} + +// accept or deny an incoming device configuration write +// +// use PendingDeviceConfigurations to get the list of currently pending requests +func (e *LPC) ApproveOrDenyDeviceConfiguration(msgCounter model.MsgCounterType, approve bool, reason string) { + e.pendingDeviceConfigMux.Lock() + defer e.pendingDeviceConfigMux.Unlock() + + msg, ok := e.pendingDeviceConfigs[msgCounter] + if !ok { + // no pending limit for this msgCounter, this is a caller error + return + } + + e.approveOrDenyDeviceConfiguration(msg, approve, reason) + delete(e.pendingDeviceConfigs, msgCounter) +} + // Scenario 3 // start sending heartbeat from the local entity supporting this usecase diff --git a/usecases/cs/lpc/public_test.go b/usecases/cs/lpc/public_test.go index 152f4890..f095c9ee 100644 --- a/usecases/cs/lpc/public_test.go +++ b/usecases/cs/lpc/public_test.go @@ -170,3 +170,46 @@ func (s *CsLPCSuite) Test_ConsumptionNominalMax() { assert.Equal(s.T(), 10.0, value) assert.Nil(s.T(), err) } + +func (s *CsLPCSuite) Test_PendingDeviceConfigurations() { + data := s.sut.PendingDeviceConfigurations() + assert.Equal(s.T(), 0, len(data)) + + msgCounter := model.MsgCounterType(500) + + msg := &spineapi.Message{ + RequestHeader: &model.HeaderType{ + MsgCounter: util.Ptr(msgCounter), + }, + Cmd: model.CmdType{ + DeviceConfigurationKeyValueListData: &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + Value: &model.DeviceConfigurationKeyValueValueType{ + ScaledNumber: model.NewScaledNumberType(1000), + }, + IsValueChangeable: util.Ptr(true), + }, + }, + }, + }, + DeviceRemote: s.remoteDevice, + EntityRemote: s.monitoredEntity, + } + + s.sut.deviceConfigurationWriteCB(msg) + + data = s.sut.PendingDeviceConfigurations() + assert.Equal(s.T(), 1, len(data)) + + s.sut.ApproveOrDenyDeviceConfiguration(model.MsgCounterType(499), true, "") + + data = s.sut.PendingDeviceConfigurations() + assert.Equal(s.T(), 1, len(data)) + + s.sut.ApproveOrDenyDeviceConfiguration(msgCounter, false, "leave me alone") + + data = s.sut.PendingDeviceConfigurations() + assert.Equal(s.T(), 0, len(data)) +} diff --git a/usecases/cs/lpc/types.go b/usecases/cs/lpc/types.go index 0f828a73..03974a7d 100644 --- a/usecases/cs/lpc/types.go +++ b/usecases/cs/lpc/types.go @@ -17,11 +17,19 @@ const ( // An incoming load control obligation limit needs to be approved or denied // - // Use `PendingConsumptionLimits` to get the currently pending write approval requests - // and invoke `ApproveOrDenyConsumptionLimit` for each + // Use `PendingConsumptionLimits` to get the currently pending consumption limits + // awaiting approval and invoke `ApproveOrDenyConsumptionLimit` for each // // Use Case LPC, Scenario 1 - WriteApprovalRequired api.EventType = "cs-lpc-WriteApprovalRequired" + LimitWriteApprovalRequired api.EventType = "cs-lpc-LimitWriteApprovalRequired" + + // An incoming device configuration write needs to be approved or denied + // + // Use `PendingDeviceConfigurations` to get the currently pending device configurations + // awaiting approval and invoke `ApproveOrDenyDeviceConfiguration` for each + // + // Use Case LPC, Scenario 1 + ConfigurationWriteApprovalRequired api.EventType = "cs-lpc-ConfigurationWriteApprovalRequired" // Failsafe limit for the consumed active (real) power of the // Controllable System data update received diff --git a/usecases/cs/lpc/usecase.go b/usecases/cs/lpc/usecase.go index 197d27eb..e36d3aa3 100644 --- a/usecases/cs/lpc/usecase.go +++ b/usecases/cs/lpc/usecase.go @@ -22,6 +22,9 @@ type LPC struct { pendingMux sync.Mutex pendingLimits map[model.MsgCounterType]*spineapi.Message + pendingDeviceConfigMux sync.Mutex + pendingDeviceConfigs map[model.MsgCounterType]*spineapi.Message + heartbeatDiag *features.DeviceDiagnosis heartbeatKeoWorkaround bool // required because KEO Stack uses multiple identical entities for the same functionality, and it is not clear which to use @@ -77,8 +80,9 @@ func NewLPC(localEntity spineapi.EntityLocalInterface, eventCB api.EntityEventCa ) uc := &LPC{ - UseCaseBase: usecase, - pendingLimits: make(map[model.MsgCounterType]*spineapi.Message), + UseCaseBase: usecase, + pendingLimits: make(map[model.MsgCounterType]*spineapi.Message), + pendingDeviceConfigs: make(map[model.MsgCounterType]*spineapi.Message), } _ = spine.Events.Subscribe(uc) @@ -165,7 +169,7 @@ func (e *LPC) loadControlWriteCB(msg *spineapi.Message) { if _, ok := e.pendingLimits[*msg.RequestHeader.MsgCounter]; !ok { e.pendingLimits[*msg.RequestHeader.MsgCounter] = msg e.pendingMux.Unlock() - e.EventCB(msg.DeviceRemote.Ski(), msg.DeviceRemote, msg.EntityRemote, WriteApprovalRequired) + e.EventCB(msg.DeviceRemote.Ski(), msg.DeviceRemote, msg.EntityRemote, LimitWriteApprovalRequired) return } } @@ -175,6 +179,51 @@ func (e *LPC) loadControlWriteCB(msg *spineapi.Message) { go e.approveOrDenyConsumptionLimit(msg, true, "") } +func (e *LPC) approveOrDenyDeviceConfiguration(msg *spineapi.Message, approve bool, reason string) { + f := e.LocalEntity.FeatureOfTypeAndRole(model.FeatureTypeTypeDeviceConfiguration, model.RoleTypeServer) + + result := model.ErrorType{ + ErrorNumber: model.ErrorNumberType(0), + } + + if !approve { + result.ErrorNumber = model.ErrorNumberType(7) + result.Description = util.Ptr(model.DescriptionType(reason)) + } + + f.ApproveOrDenyWrite(msg, result) +} + +// callback invoked on incoming write messages to this +// DeviceConfiguration server feature. +// the implementation only considers write messages for this use case and +// approves all others +func (e *LPC) deviceConfigurationWriteCB(msg *spineapi.Message) { + configsToApprove := map[model.DeviceConfigurationKeyNameType]struct{}{ + model.DeviceConfigurationKeyNameTypeFailsafeConsumptionActivePowerLimit: {}, + model.DeviceConfigurationKeyNameTypeFailsafeDurationMinimum: {}, + } + approvalRequired, err := internal.ConfigurationWriteRequiresApproval(msg, e.LocalEntity, configsToApprove) + if err != nil { + logging.Log().Errorf("LPC deviceConfigurationWriteCB: %s", err.Error()) + return + } + + if approvalRequired { + e.pendingDeviceConfigMux.Lock() + if _, exists := e.pendingDeviceConfigs[*msg.RequestHeader.MsgCounter]; !exists { + e.pendingDeviceConfigs[*msg.RequestHeader.MsgCounter] = msg + e.pendingDeviceConfigMux.Unlock() + e.EventCB(msg.DeviceRemote.Ski(), msg.DeviceRemote, msg.EntityRemote, ConfigurationWriteApprovalRequired) + return + } + e.pendingDeviceConfigMux.Unlock() + } + + // If neither a failsafe duration nor a failsafe limit were set this message does not pertain to this use case so we accept + go e.approveOrDenyDeviceConfiguration(msg, true, "") +} + func (e *LPC) AddFeatures() { // client features _ = e.LocalEntity.GetOrAddFeature(model.FeatureTypeTypeDeviceDiagnosis, model.RoleTypeClient) @@ -213,6 +262,7 @@ func (e *LPC) AddFeatures() { f = e.LocalEntity.GetOrAddFeature(model.FeatureTypeTypeDeviceConfiguration, model.RoleTypeServer) f.AddFunctionType(model.FunctionTypeDeviceConfigurationKeyValueDescriptionListData, true, false) f.AddFunctionType(model.FunctionTypeDeviceConfigurationKeyValueListData, true, true) + _ = f.AddWriteApprovalCallback(e.deviceConfigurationWriteCB) if dcs, err := server.NewDeviceConfiguration(e.LocalEntity); err == nil { dcs.AddKeyValueDescription( diff --git a/usecases/cs/lpc/usecase_test.go b/usecases/cs/lpc/usecase_test.go index 406280f9..1bd6ac3c 100644 --- a/usecases/cs/lpc/usecase_test.go +++ b/usecases/cs/lpc/usecase_test.go @@ -135,6 +135,94 @@ func (s *CsLPCSuite) Test_loadControlWriteCB() { s.eventCalled = false } +func (s *CsLPCSuite) Test_deviceConfigurationWriteCB() { + // Header missing + msg := &spineapi.Message{} + s.sut.deviceConfigurationWriteCB(msg) + assert.False(s.T(), s.eventCalled) + + // MsgCounter missing + msg = &spineapi.Message{RequestHeader: &model.HeaderType{}} + s.sut.deviceConfigurationWriteCB(msg) + assert.False(s.T(), s.eventCalled) + + // Message is invalid (DeviceConfigurationKeyValueData missing) + msg = &spineapi.Message{ + RequestHeader: &model.HeaderType{ + MsgCounter: util.Ptr(model.MsgCounterType(600)), + }, + Cmd: model.CmdType{ + DeviceConfigurationKeyValueListData: &model.DeviceConfigurationKeyValueListDataType{}, + }, + DeviceRemote: s.remoteDevice, + EntityRemote: s.monitoredEntity, + } + s.sut.deviceConfigurationWriteCB(msg) + assert.False(s.T(), s.eventCalled) + + // Unrelated KeyId + msg = &spineapi.Message{ + RequestHeader: &model.HeaderType{ + MsgCounter: util.Ptr(model.MsgCounterType(603)), + }, + Cmd: model.CmdType{ + DeviceConfigurationKeyValueListData: &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(2)), + }, + }, + }, + }, + DeviceRemote: s.remoteDevice, + EntityRemote: s.monitoredEntity, + } + s.sut.deviceConfigurationWriteCB(msg) + assert.False(s.T(), s.eventCalled) + + // Failsafe consumption active power limit write -> approval required. + msg = &spineapi.Message{ + RequestHeader: &model.HeaderType{ + MsgCounter: util.Ptr(model.MsgCounterType(603)), + }, + Cmd: model.CmdType{ + DeviceConfigurationKeyValueListData: &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + }, + }, + }, + }, + DeviceRemote: s.remoteDevice, + EntityRemote: s.monitoredEntity, + } + s.sut.deviceConfigurationWriteCB(msg) + assert.True(s.T(), s.eventCalled) + s.eventCalled = false + + // Failsafe duration minimum write -> approval required. + msg = &spineapi.Message{ + RequestHeader: &model.HeaderType{ + MsgCounter: util.Ptr(model.MsgCounterType(604)), + }, + Cmd: model.CmdType{ + DeviceConfigurationKeyValueListData: &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(1)), + }, + }, + }, + }, + DeviceRemote: s.remoteDevice, + EntityRemote: s.monitoredEntity, + } + s.sut.deviceConfigurationWriteCB(msg) + assert.True(s.T(), s.eventCalled) + s.eventCalled = false +} + func (s *CsLPCSuite) Test_UpdateUseCaseAvailability() { s.sut.UpdateUseCaseAvailability(true) } diff --git a/usecases/cs/lpp/public.go b/usecases/cs/lpp/public.go index 89642395..72ff19e8 100644 --- a/usecases/cs/lpp/public.go +++ b/usecases/cs/lpp/public.go @@ -7,6 +7,7 @@ import ( "github.com/enbility/eebus-go/api" "github.com/enbility/eebus-go/features/server" ucapi "github.com/enbility/eebus-go/usecases/api" + "github.com/enbility/eebus-go/usecases/internal" "github.com/enbility/spine-go/model" "github.com/enbility/spine-go/util" ) @@ -272,6 +273,32 @@ func (e *LPP) SetFailsafeDurationMinimum(duration time.Duration, changeable bool return dc.UpdateKeyValueDataForFilter(data, nil, filter) } +// return the currently pending incoming failsafe production limit writes +func (e *LPP) PendingDeviceConfigurations() map[model.MsgCounterType][]ucapi.PendingDeviceConfiguration { + e.pendingDeviceConfigMux.Lock() + defer e.pendingDeviceConfigMux.Unlock() + + return internal.GroupPendingDeviceConfigurations(e.pendingDeviceConfigs, e.LocalEntity) +} + +// accept or deny an incoming device configuration write +// +// use PendingDeviceConfigurations to get the list of currently pending requests +func (e *LPP) ApproveOrDenyDeviceConfiguration(msgCounter model.MsgCounterType, approve bool, reason string) { + e.pendingDeviceConfigMux.Lock() + defer e.pendingDeviceConfigMux.Unlock() + + msg, ok := e.pendingDeviceConfigs[msgCounter] + if !ok { + // no pending limit for this msgCounter, this is a caller error + return + } + + e.approveOrDenyDeviceConfiguration(msg, approve, reason) + + delete(e.pendingDeviceConfigs, msgCounter) +} + // Scenario 3 // start sending heartbeat from the local entity supporting this usecase diff --git a/usecases/cs/lpp/public_test.go b/usecases/cs/lpp/public_test.go index e768228e..5cad26de 100644 --- a/usecases/cs/lpp/public_test.go +++ b/usecases/cs/lpp/public_test.go @@ -169,3 +169,46 @@ func (s *CsLPPSuite) Test_ContractualProductionNominalMax() { assert.Equal(s.T(), 10.0, value) assert.Nil(s.T(), err) } + +func (s *CsLPPSuite) Test_PendingDeviceConfigurations() { + data := s.sut.PendingDeviceConfigurations() + assert.Equal(s.T(), 0, len(data)) + + msgCounter := model.MsgCounterType(500) + + msg := &spineapi.Message{ + RequestHeader: &model.HeaderType{ + MsgCounter: util.Ptr(msgCounter), + }, + Cmd: model.CmdType{ + DeviceConfigurationKeyValueListData: &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + Value: &model.DeviceConfigurationKeyValueValueType{ + ScaledNumber: model.NewScaledNumberType(1000), + }, + IsValueChangeable: util.Ptr(true), + }, + }, + }, + }, + DeviceRemote: s.remoteDevice, + EntityRemote: s.monitoredEntity, + } + + s.sut.deviceConfigurationWriteCB(msg) + + data = s.sut.PendingDeviceConfigurations() + assert.Equal(s.T(), 1, len(data)) + + s.sut.ApproveOrDenyDeviceConfiguration(model.MsgCounterType(499), true, "") + + data = s.sut.PendingDeviceConfigurations() + assert.Equal(s.T(), 1, len(data)) + + s.sut.ApproveOrDenyDeviceConfiguration(msgCounter, false, "leave me alone") + + data = s.sut.PendingDeviceConfigurations() + assert.Equal(s.T(), 0, len(data)) +} diff --git a/usecases/cs/lpp/types.go b/usecases/cs/lpp/types.go index 0776d631..0f5f36e8 100644 --- a/usecases/cs/lpp/types.go +++ b/usecases/cs/lpp/types.go @@ -12,23 +12,31 @@ const ( // // Use `ProductionLimit` to get the current data // - // Use Case LPC, Scenario 1 + // Use Case LPP, Scenario 1 DataUpdateLimit api.EventType = "cs-lpp-DataUpdateLimit" // An incoming load control obligation limit needs to be approved or denied // - // Use `PendingProductionLimits` to get the currently pending write approval requests - // and invoke `ApproveOrDenyProductionLimit` for each + // Use `PendingProductionLimits` to get the currently pending produciton limits + // awaiting approval and invoke `ApproveOrDenyProductionLimit` for each // - // Use Case LPC, Scenario 1 - WriteApprovalRequired api.EventType = "cs-lpp-WriteApprovalRequired" + // Use Case LPP, Scenario 1 + LimitWriteApprovalRequired api.EventType = "cs-lpp-LimitWriteApprovalRequired" + + // An incoming device configuration write needs to be approved or denied + // + // Use `PendingDeviceConfigurations` to get the currently pending device configurations + // awaiting approval and invoke `ApproveOrDenyDeviceConfiguration` for each + // + // Use Case LPP, Scenario 1 + ConfigurationWriteApprovalRequired api.EventType = "cs-lpp-ConfigurationWriteApprovalRequired" // Failsafe limit for the produced active (real) power of the // Controllable System data update received // // Use `FailsafeProductionActivePowerLimit` to get the current data // - // Use Case LPC, Scenario 2 + // Use Case LPP, Scenario 2 DataUpdateFailsafeProductionActivePowerLimit api.EventType = "cs-lpp-DataUpdateFailsafeProductionActivePowerLimit" // Minimum time the Controllable System remains in "failsafe state" unless conditions @@ -36,7 +44,7 @@ const ( // // Use `FailsafeDurationMinimum` to get the current data // - // Use Case LPC, Scenario 2 + // Use Case LPP, Scenario 2 DataUpdateFailsafeDurationMinimum api.EventType = "cs-lpp-DataUpdateFailsafeDurationMinimum" // Indicates a notify heartbeat event the application should care of. diff --git a/usecases/cs/lpp/usecase.go b/usecases/cs/lpp/usecase.go index 30cd65c8..4224ccc4 100644 --- a/usecases/cs/lpp/usecase.go +++ b/usecases/cs/lpp/usecase.go @@ -22,6 +22,9 @@ type LPP struct { pendingMux sync.Mutex pendingLimits map[model.MsgCounterType]*spineapi.Message + pendingDeviceConfigMux sync.Mutex + pendingDeviceConfigs map[model.MsgCounterType]*spineapi.Message + heartbeatDiag *features.DeviceDiagnosis heartbeatKeoWorkaround bool // required because KEO Stack uses multiple identical entities for the same functionality, and it is not clear which to use @@ -29,7 +32,7 @@ type LPP struct { var _ ucapi.CsLPPInterface = (*LPP)(nil) -// Add support for the Limitation of Power Production (LPC) use case +// Add support for the Limitation of Power Production (LPP) use case // as a Controllable System actor // // Note: if the Monitoring of Power Consumption (MPC) or Monitoring of Grid Connection Point (MGCP) will be supported, add them first @@ -76,8 +79,9 @@ func NewLPP(localEntity spineapi.EntityLocalInterface, eventCB api.EntityEventCa ) uc := &LPP{ - UseCaseBase: usecase, - pendingLimits: make(map[model.MsgCounterType]*spineapi.Message), + UseCaseBase: usecase, + pendingLimits: make(map[model.MsgCounterType]*spineapi.Message), + pendingDeviceConfigs: make(map[model.MsgCounterType]*spineapi.Message), } _ = spine.Events.Subscribe(uc) @@ -164,7 +168,7 @@ func (e *LPP) loadControlWriteCB(msg *spineapi.Message) { if _, ok := e.pendingLimits[*msg.RequestHeader.MsgCounter]; !ok { e.pendingLimits[*msg.RequestHeader.MsgCounter] = msg e.pendingMux.Unlock() - e.EventCB(msg.DeviceRemote.Ski(), msg.DeviceRemote, msg.EntityRemote, WriteApprovalRequired) + e.EventCB(msg.DeviceRemote.Ski(), msg.DeviceRemote, msg.EntityRemote, LimitWriteApprovalRequired) return } } @@ -175,6 +179,51 @@ func (e *LPP) loadControlWriteCB(msg *spineapi.Message) { go e.approveOrDenyProductionLimit(msg, true, "") } +func (e *LPP) approveOrDenyDeviceConfiguration(msg *spineapi.Message, approve bool, reason string) { + f := e.LocalEntity.FeatureOfTypeAndRole(model.FeatureTypeTypeDeviceConfiguration, model.RoleTypeServer) + + result := model.ErrorType{ + ErrorNumber: model.ErrorNumberType(0), + } + + if !approve { + result.ErrorNumber = model.ErrorNumberType(7) + result.Description = util.Ptr(model.DescriptionType(reason)) + } + + f.ApproveOrDenyWrite(msg, result) +} + +// callback invoked on incoming write messages to this +// DeviceConfiguration server feature. +// the implementation only considers write messages for this use case and +// approves all others +func (e *LPP) deviceConfigurationWriteCB(msg *spineapi.Message) { + configsToApprove := map[model.DeviceConfigurationKeyNameType]struct{}{ + model.DeviceConfigurationKeyNameTypeFailsafeProductionActivePowerLimit: {}, + model.DeviceConfigurationKeyNameTypeFailsafeDurationMinimum: {}, + } + approvalRequired, err := internal.ConfigurationWriteRequiresApproval(msg, e.LocalEntity, configsToApprove) + if err != nil { + logging.Log().Errorf("LPP deviceConfigurationWriteCB: %s", err.Error()) + return + } + + if approvalRequired { + e.pendingDeviceConfigMux.Lock() + if _, exists := e.pendingDeviceConfigs[*msg.RequestHeader.MsgCounter]; !exists { + e.pendingDeviceConfigs[*msg.RequestHeader.MsgCounter] = msg + e.pendingDeviceConfigMux.Unlock() + e.EventCB(msg.DeviceRemote.Ski(), msg.DeviceRemote, msg.EntityRemote, ConfigurationWriteApprovalRequired) + return + } + e.pendingDeviceConfigMux.Unlock() + } + + // If neither a failsafe duration nor a failsafe limit were set this message does not pertain to this use case so we accept + go e.approveOrDenyDeviceConfiguration(msg, true, "") +} + func (e *LPP) AddFeatures() { // client features _ = e.LocalEntity.GetOrAddFeature(model.FeatureTypeTypeDeviceDiagnosis, model.RoleTypeClient) @@ -213,6 +262,7 @@ func (e *LPP) AddFeatures() { f = e.LocalEntity.GetOrAddFeature(model.FeatureTypeTypeDeviceConfiguration, model.RoleTypeServer) f.AddFunctionType(model.FunctionTypeDeviceConfigurationKeyValueDescriptionListData, true, false) f.AddFunctionType(model.FunctionTypeDeviceConfigurationKeyValueListData, true, true) + _ = f.AddWriteApprovalCallback(e.deviceConfigurationWriteCB) if dcs, err := server.NewDeviceConfiguration(e.LocalEntity); err == nil { dcs.AddKeyValueDescription( diff --git a/usecases/cs/lpp/usecase_test.go b/usecases/cs/lpp/usecase_test.go index baaaf1c1..35bfcb55 100644 --- a/usecases/cs/lpp/usecase_test.go +++ b/usecases/cs/lpp/usecase_test.go @@ -78,6 +78,94 @@ func (s *CsLPPSuite) Test_loadControlWriteCB() { s.sut.loadControlWriteCB(msg) } +func (s *CsLPPSuite) Test_deviceConfigurationWriteCB() { + // Header missing + msg := &spineapi.Message{} + s.sut.deviceConfigurationWriteCB(msg) + assert.False(s.T(), s.eventCalled) + + // MsgCounter missing + msg = &spineapi.Message{RequestHeader: &model.HeaderType{}} + s.sut.deviceConfigurationWriteCB(msg) + assert.False(s.T(), s.eventCalled) + + // Message is invalid (DeviceConfigurationKeyValueData missing) + msg = &spineapi.Message{ + RequestHeader: &model.HeaderType{ + MsgCounter: util.Ptr(model.MsgCounterType(600)), + }, + Cmd: model.CmdType{ + DeviceConfigurationKeyValueListData: &model.DeviceConfigurationKeyValueListDataType{}, + }, + DeviceRemote: s.remoteDevice, + EntityRemote: s.monitoredEntity, + } + s.sut.deviceConfigurationWriteCB(msg) + assert.False(s.T(), s.eventCalled) + + // Unrelated KeyId + msg = &spineapi.Message{ + RequestHeader: &model.HeaderType{ + MsgCounter: util.Ptr(model.MsgCounterType(603)), + }, + Cmd: model.CmdType{ + DeviceConfigurationKeyValueListData: &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(2)), + }, + }, + }, + }, + DeviceRemote: s.remoteDevice, + EntityRemote: s.monitoredEntity, + } + s.sut.deviceConfigurationWriteCB(msg) + assert.False(s.T(), s.eventCalled) + + // Failsafe production active power limit write -> approval required. + msg = &spineapi.Message{ + RequestHeader: &model.HeaderType{ + MsgCounter: util.Ptr(model.MsgCounterType(603)), + }, + Cmd: model.CmdType{ + DeviceConfigurationKeyValueListData: &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + }, + }, + }, + }, + DeviceRemote: s.remoteDevice, + EntityRemote: s.monitoredEntity, + } + s.sut.deviceConfigurationWriteCB(msg) + assert.True(s.T(), s.eventCalled) + s.eventCalled = false + + // Failsafe duration minimum write -> approval required. + msg = &spineapi.Message{ + RequestHeader: &model.HeaderType{ + MsgCounter: util.Ptr(model.MsgCounterType(604)), + }, + Cmd: model.CmdType{ + DeviceConfigurationKeyValueListData: &model.DeviceConfigurationKeyValueListDataType{ + DeviceConfigurationKeyValueData: []model.DeviceConfigurationKeyValueDataType{ + { + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(1)), + }, + }, + }, + }, + DeviceRemote: s.remoteDevice, + EntityRemote: s.monitoredEntity, + } + s.sut.deviceConfigurationWriteCB(msg) + assert.True(s.T(), s.eventCalled) + s.eventCalled = false +} + func (s *CsLPPSuite) Test_UpdateUseCaseAvailability() { s.sut.UpdateUseCaseAvailability(true) } diff --git a/usecases/internal/deviceconfiguration.go b/usecases/internal/deviceconfiguration.go new file mode 100644 index 00000000..a2267029 --- /dev/null +++ b/usecases/internal/deviceconfiguration.go @@ -0,0 +1,91 @@ +package internal + +import ( + "fmt" + "slices" + + "github.com/enbility/eebus-go/features/server" + ucapi "github.com/enbility/eebus-go/usecases/api" + "github.com/enbility/ship-go/logging" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" +) + +// Check if an incoming device configuration write requires write approval, if yes return `true` otherwise `false` +func ConfigurationWriteRequiresApproval(msg *spineapi.Message, localEntity spineapi.EntityLocalInterface, configsToApprove map[model.DeviceConfigurationKeyNameType]struct{}) (bool, error) { + if msg.RequestHeader == nil || msg.RequestHeader.MsgCounter == nil || + msg.Cmd.DeviceConfigurationKeyValueListData == nil { + return false, fmt.Errorf("invalid message") + } + + data := msg.Cmd.DeviceConfigurationKeyValueListData + + if len(data.DeviceConfigurationKeyValueData) == 0 { + return false, fmt.Errorf("no data") + } + + // all DeviceConfigurationKeyValueData must have keyId set as primary identifier + if slices.ContainsFunc(data.DeviceConfigurationKeyValueData, func(i model.DeviceConfigurationKeyValueDataType) bool { + return i.KeyId == nil + }) { + return false, fmt.Errorf("invalid message") + } + + dc, err := server.NewDeviceConfiguration(localEntity) + if err != nil { + return false, err + } + + for _, deviceKeyValueData := range data.DeviceConfigurationKeyValueData { + description, err := dc.GetKeyValueDescriptionFoKeyId(*deviceKeyValueData.KeyId) + if description == nil || err != nil { + logging.Log().Debug("ConfigurationWriteRequiresApproval: no device configuration for KeyID found: ", *deviceKeyValueData.KeyId) + continue + } + + if description.KeyName == nil { + logging.Log().Debugf("ConfigurationWriteRequiresApproval: invalid internal data (KeyName not set on DeviceConfigurationKeyValueDescriptionDataType with key %s)", *deviceKeyValueData.KeyId) + continue + } + + // Only ask for write approval if at least one of the configurations we care about is trying to be set + if _, exists := configsToApprove[*description.KeyName]; exists { + return true, nil + } + } + return false, nil +} + +// Extract the device configuration writes from each pending message and return them grouped in a map by msgCounter +func GroupPendingDeviceConfigurations(pendingDeviceConfigs map[model.MsgCounterType]*spineapi.Message, localEntity spineapi.EntityLocalInterface) map[model.MsgCounterType][]ucapi.PendingDeviceConfiguration { + result := make(map[model.MsgCounterType][]ucapi.PendingDeviceConfiguration) + + dc, err := server.NewDeviceConfiguration(localEntity) + if err != nil { + logging.Log().Debugf("GroupPendingDeviceConfigurations: Error occurred when getting device configuration: %s", err.Error()) + return result + } + + for msgCounter, msg := range pendingDeviceConfigs { + data := msg.Cmd.DeviceConfigurationKeyValueListData + for _, configKeyValueData := range data.DeviceConfigurationKeyValueData { + description, err := dc.GetKeyValueDescriptionFoKeyId(*configKeyValueData.KeyId) + if err != nil { + continue + } + + pendingConfigData := ucapi.PendingDeviceConfiguration{ + Description: description, + Value: configKeyValueData.Value, + IsValueChangeable: configKeyValueData.IsValueChangeable, + } + + if _, exists := result[msgCounter]; !exists { + result[msgCounter] = []ucapi.PendingDeviceConfiguration{pendingConfigData} + } else { + result[msgCounter] = append(result[msgCounter], pendingConfigData) + } + } + } + return result +} diff --git a/usecases/internal/deviceconfiguration_test.go b/usecases/internal/deviceconfiguration_test.go new file mode 100644 index 00000000..9ae55309 --- /dev/null +++ b/usecases/internal/deviceconfiguration_test.go @@ -0,0 +1,151 @@ +package internal + +import ( + "github.com/enbility/eebus-go/features/server" + ucapi "github.com/enbility/eebus-go/usecases/api" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" + + "github.com/stretchr/testify/assert" +) + +func (s *InternalSuite) Test_ConfigurationWriteRequiresApproval() { + msg := spineapi.Message{} + configsToApprove := map[model.DeviceConfigurationKeyNameType]struct{}{ + model.DeviceConfigurationKeyNameTypeFailsafeConsumptionActivePowerLimit: {}, + model.DeviceConfigurationKeyNameTypeFailsafeDurationMinimum: {}, + } + // Header missing + _, err := ConfigurationWriteRequiresApproval(&msg, s.localEntity, configsToApprove) + assert.NotNil(s.T(), err) + + // MsgCounter missing + header := model.HeaderType{} + msg = spineapi.Message{RequestHeader: &header} + _, err = ConfigurationWriteRequiresApproval(&msg, s.localEntity, configsToApprove) + assert.NotNil(s.T(), err) + + // DeviceConfigurationKeyValueListData missing + header = model.HeaderType{MsgCounter: util.Ptr(model.MsgCounterType(1))} + msg = spineapi.Message{RequestHeader: &header} + _, err = ConfigurationWriteRequiresApproval(&msg, s.localEntity, configsToApprove) + assert.NotNil(s.T(), err) + + // DeviceConfigurationKeyValueListData.DeviceConfigurationKeyValueData is nil/length of 0 + cmd := model.CmdType{DeviceConfigurationKeyValueListData: util.Ptr(model.DeviceConfigurationKeyValueListDataType{})} + msg = spineapi.Message{RequestHeader: &header, Cmd: cmd} + _, err = ConfigurationWriteRequiresApproval(&msg, s.localEntity, configsToApprove) + assert.NotNil(s.T(), err) + + // Not all elements in slice of DeviceConfigurationKeyValueDataType have KeyId set + deviceConfigList := []model.DeviceConfigurationKeyValueDataType{{KeyId: nil}} + cmd = model.CmdType{DeviceConfigurationKeyValueListData: util.Ptr(model.DeviceConfigurationKeyValueListDataType{DeviceConfigurationKeyValueData: deviceConfigList})} + msg = spineapi.Message{RequestHeader: &header, Cmd: cmd} + _, err = ConfigurationWriteRequiresApproval(&msg, s.localEntity, configsToApprove) + assert.NotNil(s.T(), err) + + // Valid message but not a KeyId we care about => no approval required + deviceConfigList = []model.DeviceConfigurationKeyValueDataType{{KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0))}} + cmd = model.CmdType{DeviceConfigurationKeyValueListData: util.Ptr(model.DeviceConfigurationKeyValueListDataType{DeviceConfigurationKeyValueData: deviceConfigList})} + msg = spineapi.Message{RequestHeader: &header, Cmd: cmd} + approvalRequired, err := ConfigurationWriteRequiresApproval(&msg, s.localEntity, configsToApprove) + assert.Nil(s.T(), err) + assert.False(s.T(), approvalRequired) + + // Valid message with KeyId we care about => approval required + if dcs, err := server.NewDeviceConfiguration(s.localEntity); err == nil { + dcs.AddKeyValueDescription( + model.DeviceConfigurationKeyValueDescriptionDataType{ + KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeConsumptionActivePowerLimit), + ValueType: util.Ptr(model.DeviceConfigurationKeyValueTypeTypeScaledNumber), + Unit: util.Ptr(model.UnitOfMeasurementTypeW), + }, + ) + + value := &model.DeviceConfigurationKeyValueValueType{ + ScaledNumber: model.NewScaledNumberType(0), + } + _ = dcs.UpdateKeyValueDataForFilter( + model.DeviceConfigurationKeyValueDataType{ + Value: value, + IsValueChangeable: util.Ptr(true), + }, + nil, + model.DeviceConfigurationKeyValueDescriptionDataType{ + KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeConsumptionActivePowerLimit), + }, + ) + } + approvalRequired, err = ConfigurationWriteRequiresApproval(&msg, s.localEntity, configsToApprove) + assert.Nil(s.T(), err) + assert.True(s.T(), approvalRequired) +} + +func (s *InternalSuite) Test_GroupPendingDeviceConfigurations() { + failsafeLimitDesc := model.DeviceConfigurationKeyValueDescriptionDataType{ + KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeConsumptionActivePowerLimit), + ValueType: util.Ptr(model.DeviceConfigurationKeyValueTypeTypeScaledNumber), + Unit: util.Ptr(model.UnitOfMeasurementTypeW), + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0)), + } + failsafeDurationMinDesc := model.DeviceConfigurationKeyValueDescriptionDataType{ + KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeDurationMinimum), + ValueType: util.Ptr(model.DeviceConfigurationKeyValueTypeTypeDuration), + KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(1)), + } + if dcs, err := server.NewDeviceConfiguration(s.localEntity); err == nil { + dcs.AddKeyValueDescription(failsafeLimitDesc) + + // only add if it doesn't exist yet + filter := model.DeviceConfigurationKeyValueDescriptionDataType{ + KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeDurationMinimum), + } + if data, err := dcs.GetKeyValueDescriptionsForFilter(filter); err == nil && len(data) == 0 { + dcs.AddKeyValueDescription(failsafeDurationMinDesc) + } + + value := &model.DeviceConfigurationKeyValueValueType{ + ScaledNumber: model.NewScaledNumberType(0), + } + _ = dcs.UpdateKeyValueDataForFilter( + model.DeviceConfigurationKeyValueDataType{ + Value: value, + IsValueChangeable: util.Ptr(true), + }, + nil, + model.DeviceConfigurationKeyValueDescriptionDataType{ + KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeConsumptionActivePowerLimit), + }, + ) + + value = &model.DeviceConfigurationKeyValueValueType{ + Duration: model.NewDurationType(0), + } + _ = dcs.UpdateKeyValueDataForFilter( + model.DeviceConfigurationKeyValueDataType{ + Value: value, + IsValueChangeable: util.Ptr(true), + }, + nil, + model.DeviceConfigurationKeyValueDescriptionDataType{ + KeyName: util.Ptr(model.DeviceConfigurationKeyNameTypeFailsafeDurationMinimum), + }, + ) + } + + deviceConfigList := []model.DeviceConfigurationKeyValueDataType{{KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(0))}, {KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(1))}, {KeyId: util.Ptr(model.DeviceConfigurationKeyIdType(2))}} + cmd := model.CmdType{DeviceConfigurationKeyValueListData: util.Ptr(model.DeviceConfigurationKeyValueListDataType{DeviceConfigurationKeyValueData: deviceConfigList})} + msg := spineapi.Message{Cmd: cmd} + pendingDeviceConfigs := map[model.MsgCounterType]*spineapi.Message{model.MsgCounterType(1): &msg} + groupedConfigurations := GroupPendingDeviceConfigurations(pendingDeviceConfigs, s.localEntity) + // For one of the KeyIds no corresponding device configuration exists, that element should thus be skipped + expected := map[model.MsgCounterType][]ucapi.PendingDeviceConfiguration{ + model.MsgCounterType(1): { + {Description: &failsafeLimitDesc}, + {Description: &failsafeDurationMinDesc}, + }, + } + assert.Equal(s.T(), groupedConfigurations, expected) + +}