diff --git a/examples/hems/main.go b/examples/hems/main.go index 14038cc0..7e5a8cfa 100644 --- a/examples/hems/main.go +++ b/examples/hems/main.go @@ -152,12 +152,21 @@ func (h *hems) OnLPCEvent(ski string, device spineapi.DeviceRemoteInterface, ent case cslpc.WriteApprovalRequired: // get pending writes pendingWrites := h.uccslpc.PendingConsumptionLimits() + pendingDeviceConfigWrites := h.uccslpc.PendingDeviceConfigurations() // 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, "") } + for msgCounter, configs := range pendingDeviceConfigWrites { + fmt.Printf("Approving LPC device config write with msgCounter %d for features: ", msgCounter) + for _, config := range configs { + fmt.Printf("%s ", *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") @@ -172,12 +181,21 @@ func (h *hems) OnLPPEvent(ski string, device spineapi.DeviceRemoteInterface, ent case cslpp.WriteApprovalRequired: // get pending writes pendingWrites := h.uccslpp.PendingProductionLimits() + pendingDeviceConfigWrites := h.uccslpp.PendingDeviceConfigurations() // 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, "") } + for msgCounter, configs := range pendingDeviceConfigWrites { + fmt.Printf("Approving LPP device config write with msgCounter %d for features: ", msgCounter) + for _, config := range configs { + fmt.Printf("%s ", *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..cdde8255 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" eebus:"writecheck"` +} diff --git a/usecases/cs/lpc/public.go b/usecases/cs/lpc/public.go index 038d1434..0104cc32 100644 --- a/usecases/cs/lpc/public.go +++ b/usecases/cs/lpc/public.go @@ -266,6 +266,59 @@ 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 { + result := make(map[model.MsgCounterType][]ucapi.PendingDeviceConfiguration) + + e.pendingDeviceConfigMux.Lock() + defer e.pendingDeviceConfigMux.Unlock() + + dc, err := server.NewDeviceConfiguration(e.LocalEntity) + if err != nil { + return result + } + + for msgCounter, msg := range e.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 +} + +// 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/types.go b/usecases/cs/lpc/types.go index 0f828a73..5159c4b3 100644 --- a/usecases/cs/lpc/types.go +++ b/usecases/cs/lpc/types.go @@ -17,8 +17,10 @@ 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` and `PendingDeviceConfigurations` to get + // the currently pending write approval requests and invoke + // `ApproveOrDenyConsumptionLimit` or `ApproveOrDenyDeviceConfiguration` + // for each // // Use Case LPC, Scenario 1 WriteApprovalRequired api.EventType = "cs-lpc-WriteApprovalRequired" diff --git a/usecases/cs/lpc/usecase.go b/usecases/cs/lpc/usecase.go index 5b6d3608..4a91a66e 100644 --- a/usecases/cs/lpc/usecase.go +++ b/usecases/cs/lpc/usecase.go @@ -1,6 +1,7 @@ package lpc import ( + "slices" "sync" "github.com/enbility/eebus-go/api" @@ -21,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 @@ -74,8 +78,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) @@ -172,6 +177,80 @@ 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) { + if msg.RequestHeader == nil || msg.RequestHeader.MsgCounter == nil || + msg.Cmd.DeviceConfigurationKeyValueListData == nil { + logging.Log().Debug("LPC deviceConfigurationWriteCB: invalid message") + return + } + + data := msg.Cmd.DeviceConfigurationKeyValueListData + + if data == nil || data.DeviceConfigurationKeyValueData == nil || len(data.DeviceConfigurationKeyValueData) == 0 { + logging.Log().Debug("LPC deviceConfigurationWriteCB: no data") + return + } + + // all DeviceConfigurationKeyValueData must have keyId set as primary identifier + if slices.ContainsFunc(data.DeviceConfigurationKeyValueData, func(i model.DeviceConfigurationKeyValueDataType) bool { + return i.KeyId == nil + }) { + logging.Log().Debug("LPC deviceConfigurationWriteCB: invalid message") + return + } + + dc, err := server.NewDeviceConfiguration(e.LocalEntity) + if err != nil { + return + } + + configsToApprove := map[model.DeviceConfigurationKeyNameType]struct{}{ + model.DeviceConfigurationKeyNameTypeFailsafeConsumptionActivePowerLimit: {}, + model.DeviceConfigurationKeyNameTypeFailsafeDurationMinimum: {}, + } + for _, deviceKeyValueData := range data.DeviceConfigurationKeyValueData { + description, err := dc.GetKeyValueDescriptionFoKeyId(*deviceKeyValueData.KeyId) + if description == nil || err != nil { + logging.Log().Debug("LPC deviceConfigurationWriteCB: no device configuration for KeyID found: ", *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 { + 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, WriteApprovalRequired) + return + } + e.pendingDeviceConfigMux.Unlock() + } + } + + // If neither a failsafe duration nor a failsafe limit were set this message does not pertain to this callback so we accept + go e.approveOrDenyDeviceConfiguration(msg, true, "") +} + func (e *LPC) AddFeatures() { // client features _ = e.LocalEntity.GetOrAddFeature(model.FeatureTypeTypeDeviceDiagnosis, model.RoleTypeClient) @@ -209,6 +288,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/lpp/public.go b/usecases/cs/lpp/public.go index ca2bf1c9..24e677eb 100644 --- a/usecases/cs/lpp/public.go +++ b/usecases/cs/lpp/public.go @@ -267,6 +267,60 @@ func (e *LPP) SetFailsafeDurationMinimum(duration time.Duration, changeable bool return dc.UpdateKeyValueDataForFilter(data, nil, filter) } +// return the currently pending incoming failsafe consumption limit writes +func (e *LPP) PendingDeviceConfigurations() map[model.MsgCounterType][]ucapi.PendingDeviceConfiguration { + result := make(map[model.MsgCounterType][]ucapi.PendingDeviceConfiguration) + + e.pendingDeviceConfigMux.Lock() + defer e.pendingDeviceConfigMux.Unlock() + + dc, err := server.NewDeviceConfiguration(e.LocalEntity) + if err != nil { + return result + } + + for msgCounter, msg := range e.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 +} + +// 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/types.go b/usecases/cs/lpp/types.go index 0776d631..fb463e22 100644 --- a/usecases/cs/lpp/types.go +++ b/usecases/cs/lpp/types.go @@ -12,15 +12,17 @@ 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` and `PendingDeviceConfigurations` to get + // the currently pending write approval requests and invoke + // `ApproveOrDenyProductionLimit` or `ApproveOrDenyDeviceConfiguration` for + // each // - // Use Case LPC, Scenario 1 + // Use Case LPP, Scenario 1 WriteApprovalRequired api.EventType = "cs-lpp-WriteApprovalRequired" // Failsafe limit for the produced active (real) power of the @@ -28,7 +30,7 @@ const ( // // 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 +38,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 8b859f98..7726c33f 100644 --- a/usecases/cs/lpp/usecase.go +++ b/usecases/cs/lpp/usecase.go @@ -1,6 +1,7 @@ package lpp import ( + "slices" "sync" "github.com/enbility/eebus-go/api" @@ -21,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 @@ -28,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 // // Parameters: @@ -73,8 +77,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) @@ -172,6 +177,80 @@ 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) { + if msg.RequestHeader == nil || msg.RequestHeader.MsgCounter == nil || + msg.Cmd.DeviceConfigurationKeyValueListData == nil { + logging.Log().Debug("LPP deviceConfigurationWriteCB: invalid message") + return + } + + data := msg.Cmd.DeviceConfigurationKeyValueListData + + if data == nil || data.DeviceConfigurationKeyValueData == nil || len(data.DeviceConfigurationKeyValueData) == 0 { + logging.Log().Debug("LPP deviceConfigurationWriteCB: no data") + return + } + + // all DeviceConfigurationKeyValueData must have keyId set as primary identifier + if slices.ContainsFunc(data.DeviceConfigurationKeyValueData, func(i model.DeviceConfigurationKeyValueDataType) bool { + return i.KeyId == nil + }) { + logging.Log().Debug("LPP deviceConfigurationWriteCB: invalid message") + return + } + + dc, err := server.NewDeviceConfiguration(e.LocalEntity) + if err != nil { + return + } + + configsToApprove := map[model.DeviceConfigurationKeyNameType]struct{}{ + model.DeviceConfigurationKeyNameTypeFailsafeProductionActivePowerLimit: {}, + model.DeviceConfigurationKeyNameTypeFailsafeDurationMinimum: {}, + } + for _, deviceKeyValueData := range data.DeviceConfigurationKeyValueData { + description, err := dc.GetKeyValueDescriptionFoKeyId(*deviceKeyValueData.KeyId) + if description == nil || err != nil { + logging.Log().Debug("LPP deviceConfigurationWriteCB: no device configuration for KeyID found: ", *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 { + 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, WriteApprovalRequired) + return + } + e.pendingDeviceConfigMux.Unlock() + } + } + + // If neither a failsafe duration nor a failsafe limit were set this message does not pertain to this callback so we accept + go e.approveOrDenyDeviceConfiguration(msg, true, "") +} + func (e *LPP) AddFeatures() { // client features _ = e.LocalEntity.GetOrAddFeature(model.FeatureTypeTypeDeviceDiagnosis, model.RoleTypeClient) @@ -209,6 +288,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(