diff --git a/features/client/hvac.go b/features/client/hvac.go new file mode 100644 index 00000000..77a33246 --- /dev/null +++ b/features/client/hvac.go @@ -0,0 +1,50 @@ +package client + +import ( + "github.com/enbility/eebus-go/features/internal" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" +) + +type Hvac struct { + *Feature + + *internal.HvacCommon +} + +// Get a new HVAC features helper +// +// - The feature on the local entity has to be of role client +// - The feature on the remote entity has to be of role server +func NewHvac( + localEntity spineapi.EntityLocalInterface, + remoteEntity spineapi.EntityRemoteInterface, +) (*Hvac, error) { + feature, err := NewFeature(model.FeatureTypeTypeHvac, localEntity, remoteEntity) + if err != nil { + return nil, err + } + + hvac := &Hvac{ + Feature: feature, + HvacCommon: internal.NewRemoteHvac(feature.featureRemote), + } + + return hvac, nil +} + +// request FunctionTypeHvacSystemFunctionSetPointRelationListData from a remote device +func (h *Hvac) RequestHvacSystemFunctionSetPointRelations( + selector *model.HvacSystemFunctionSetpointRelationListDataSelectorsType, + elements *model.HvacSystemFunctionSetpointRelationDataElementsType, +) (*model.MsgCounterType, error) { + return h.requestData(model.FunctionTypeHvacSystemFunctionSetPointRelationListData, selector, elements) +} + +// request FunctionTypeHvacOperationModeDescriptionListData from a remote device +func (h *Hvac) RequestHvacOperationModeDescriptions( + selector *model.HvacOperationModeDescriptionListDataSelectorsType, + elements *model.HvacOperationModeDescriptionDataElementsType, +) (*model.MsgCounterType, error) { + return h.requestData(model.FunctionTypeHvacOperationModeDescriptionListData, selector, elements) +} diff --git a/features/client/setpoint.go b/features/client/setpoint.go new file mode 100644 index 00000000..075811df --- /dev/null +++ b/features/client/setpoint.go @@ -0,0 +1,83 @@ +package client + +import ( + "github.com/enbility/eebus-go/api" + "github.com/enbility/eebus-go/features/internal" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" +) + +type Setpoint struct { + *Feature + + *internal.SetPointCommon +} + +// Get a new SetPoint features helper +// +// - The feature on the local entity has to be of role client +// - The feature on the remote entity has to be of role server +func NewSetpoint( + localEntity spineapi.EntityLocalInterface, + remoteEntity spineapi.EntityRemoteInterface, +) (*Setpoint, error) { + feature, err := NewFeature(model.FeatureTypeTypeSetpoint, localEntity, remoteEntity) + if err != nil { + return nil, err + } + + sp := &Setpoint{ + Feature: feature, + SetPointCommon: internal.NewRemoteSetPoint(feature.featureRemote), + } + + return sp, nil +} + +// request FunctionTypeSetpointDescriptionListData from a remote device +func (s *Setpoint) RequestSetPointDescriptions( + selector *model.SetpointDescriptionListDataSelectorsType, + elements *model.SetpointDescriptionDataElementsType, +) (*model.MsgCounterType, error) { + return s.requestData(model.FunctionTypeSetpointDescriptionListData, selector, elements) +} + +// request FunctionTypeSetpointConstraintsListData from a remote device +func (s *Setpoint) RequestSetPointConstraints( + selector *model.SetpointConstraintsListDataSelectorsType, + elements *model.SetpointConstraintsDataElementsType, +) (*model.MsgCounterType, error) { + return s.requestData(model.FunctionTypeSetpointConstraintsListData, selector, elements) +} + +// request FunctionTypeSetpointListData from a remote device +func (s *Setpoint) RequestSetPoints( + selector *model.SetpointListDataSelectorsType, + elements *model.SetpointDataElementsType, +) (*model.MsgCounterType, error) { + return s.requestData(model.FunctionTypeSetpointListData, selector, elements) +} + +// WriteSetPointListData writes the given setpoint data +// +// Parameters: +// - data: the setpoint data to write +// +// Returns: +// - the message counter of the sent message +// - an error if the data could not be written +func (s *Setpoint) WriteSetPointListData( + data []model.SetpointDataType, +) (*model.MsgCounterType, error) { + if len(data) == 0 { + return nil, api.ErrMissingData + } + + cmd := model.CmdType{ + SetpointListData: &model.SetpointListDataType{ + SetpointData: data, + }, + } + + return s.remoteDevice.Sender().Write(s.featureLocal.Address(), s.featureRemote.Address(), cmd) +} diff --git a/features/internal/hvac.go b/features/internal/hvac.go new file mode 100644 index 00000000..0d9d194e --- /dev/null +++ b/features/internal/hvac.go @@ -0,0 +1,51 @@ +package internal + +import ( + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" +) + +type HvacCommon struct { + featureLocal spineapi.FeatureLocalInterface + featureRemote spineapi.FeatureRemoteInterface +} + +// NewLocalHvac creates a new HvacCommon helper for local entities +func NewLocalHvac(featureLocal spineapi.FeatureLocalInterface) *HvacCommon { + return &HvacCommon{ + featureLocal: featureLocal, + } +} + +// NewRemoteHvac creates a new HvacCommon helper for remote entities +func NewRemoteHvac(featureRemote spineapi.FeatureRemoteInterface) *HvacCommon { + return &HvacCommon{ + featureRemote: featureRemote, + } +} + +// GetHvacOperationModeDescriptions returns the operation mode descriptions +func (h *HvacCommon) GetHvacOperationModeDescriptions() ([]model.HvacOperationModeDescriptionDataType, error) { + function := model.FunctionTypeHvacOperationModeDescriptionListData + operationModeDescriptions := make([]model.HvacOperationModeDescriptionDataType, 0) + + data, err := featureDataCopyOfType[model.HvacOperationModeDescriptionListDataType](h.featureLocal, h.featureRemote, function) + if err == nil || data != nil { + operationModeDescriptions = append(operationModeDescriptions, data.HvacOperationModeDescriptionData...) + } + + return operationModeDescriptions, nil +} + +// GetHvacSystemFunctionOperationModeRelations returns the operation mode relations (used to map operation modes to setpoints) +func (h *HvacCommon) GetHvacSystemFunctionOperationModeRelations() ([]model.HvacSystemFunctionSetpointRelationDataType, error) { + function := model.FunctionTypeHvacSystemFunctionSetPointRelationListData + relations := make([]model.HvacSystemFunctionSetpointRelationDataType, 0) + + data, err := featureDataCopyOfType[model.HvacSystemFunctionSetpointRelationListDataType](h.featureLocal, h.featureRemote, function) + if err == nil || data != nil { + relations = append(relations, data.HvacSystemFunctionSetpointRelationData...) + } + + return relations, nil +} diff --git a/features/internal/setpoint.go b/features/internal/setpoint.go new file mode 100644 index 00000000..51dcfa5a --- /dev/null +++ b/features/internal/setpoint.go @@ -0,0 +1,127 @@ +package internal + +import ( + "github.com/enbility/eebus-go/api" + "github.com/enbility/ship-go/util" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" +) + +type SetPointCommon struct { + featureLocal spineapi.FeatureLocalInterface + featureRemote spineapi.FeatureRemoteInterface +} + +// NewLocalSetPoint creates a new SetPointCommon helper for local entities +func NewLocalSetPoint(featureLocal spineapi.FeatureLocalInterface) *SetPointCommon { + return &SetPointCommon{ + featureLocal: featureLocal, + } +} + +// NewRemoteSetPoint creates a new SetPointCommon helper for remote entities +func NewRemoteSetPoint(featureRemote spineapi.FeatureRemoteInterface) *SetPointCommon { + return &SetPointCommon{ + featureRemote: featureRemote, + } +} + +// GetSetpointDescriptions returns the setpoint descriptions +func (s *SetPointCommon) GetSetpointDescriptions() ([]model.SetpointDescriptionDataType, error) { + function := model.FunctionTypeSetpointDescriptionListData + + data, err := featureDataCopyOfType[model.SetpointDescriptionListDataType](s.featureLocal, s.featureRemote, function) + if err != nil || data == nil || data.SetpointDescriptionData == nil { + return nil, api.ErrDataNotAvailable + } + + return data.SetpointDescriptionData, nil +} + +// GetSetpointForId returns the setpoint data for a given setpoint ID +func (s *SetPointCommon) GetSetpointForId( + id model.SetpointIdType, +) (*model.SetpointDataType, error) { + filter := model.SetpointDataType{ + SetpointId: &id, + } + + result, err := s.GetSetpointDataForFilter(filter) + if err != nil || len(result) == 0 { + return nil, api.ErrDataNotAvailable + } + + return util.Ptr(result[0]), nil +} + +// GetSetpoints returns the setpoints +func (s *SetPointCommon) GetSetpoints() []model.SetpointDataType { + function := model.FunctionTypeSetpointListData + + data, err := featureDataCopyOfType[model.SetpointListDataType](s.featureLocal, s.featureRemote, function) + if err != nil || data == nil || data.SetpointData == nil { + return []model.SetpointDataType{} + } + + return data.SetpointData +} + +// GetSetpointDataForFilter returns the setpoint data for a given filter +func (s *SetPointCommon) GetSetpointDataForFilter( + filter model.SetpointDataType, +) ([]model.SetpointDataType, error) { + function := model.FunctionTypeSetpointListData + + data, err := featureDataCopyOfType[model.SetpointListDataType](s.featureLocal, s.featureRemote, function) + if err != nil || data == nil || data.SetpointData == nil { + return nil, api.ErrDataNotAvailable + } + + result := searchFilterInList[model.SetpointDataType](data.SetpointData, filter) + + return result, nil +} + +// GetSetpointConstraints returns the setpoints constraints. +func (s *SetPointCommon) GetSetpointConstraints() []model.SetpointConstraintsDataType { + function := model.FunctionTypeSetpointConstraintsListData + + data, err := featureDataCopyOfType[model.SetpointConstraintsListDataType](s.featureLocal, s.featureRemote, function) + if err != nil || data == nil || data.SetpointConstraintsData == nil { + return []model.SetpointConstraintsDataType{} + } + + return data.SetpointConstraintsData +} + +// GetSetpointConstraintsForId returns the setpoint constraints for a given setpoint ID +func (s *SetPointCommon) GetSetpointConstraintsForId( + id model.SetpointIdType, +) (*model.SetpointConstraintsDataType, error) { + filter := model.SetpointConstraintsDataType{ + SetpointId: &id, + } + + result, err := s.GetSetpointConstraintsForFilter(filter) + if err != nil || len(result) == 0 { + return nil, api.ErrDataNotAvailable + } + + return util.Ptr(result[0]), nil +} + +// GetSetpointConstraintsForFilter returns the setpoint constraints for a given filter +func (s *SetPointCommon) GetSetpointConstraintsForFilter( + filter model.SetpointConstraintsDataType, +) ([]model.SetpointConstraintsDataType, error) { + function := model.FunctionTypeSetpointConstraintsListData + + data, err := featureDataCopyOfType[model.SetpointConstraintsListDataType](s.featureLocal, s.featureRemote, function) + if err != nil || data == nil || data.SetpointConstraintsData == nil { + return nil, api.ErrDataNotAvailable + } + + result := searchFilterInList[model.SetpointConstraintsDataType](data.SetpointConstraintsData, filter) + + return result, nil +} diff --git a/go.mod b/go.mod index b960fd80..df235b47 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/enbility/eebus-go go 1.22.0 require ( - github.com/enbility/ship-go v0.0.0-20241006160314-3a4325a1a6d6 - github.com/enbility/spine-go v0.0.0-20241007182100-30ee8bc405a7 + github.com/enbility/ship-go v0.6.1-0.20241023165311-5963bf4d9424 + github.com/enbility/spine-go v0.7.1-0.20241023170915-0b14938a9a37 github.com/stretchr/testify v1.9.0 ) diff --git a/go.sum b/go.sum index 0888b791..adc197e6 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/enbility/go-avahi v0.0.0-20240909195612-d5de6b280d7a h1:foChWb8lhzqa6lWDRs6COYMdp649YlUirFP8GqoT0JQ= github.com/enbility/go-avahi v0.0.0-20240909195612-d5de6b280d7a/go.mod h1:H64mhYcAQUGUUnVqMdZQf93kPecH4M79xwH95Lddt3U= -github.com/enbility/ship-go v0.0.0-20241006160314-3a4325a1a6d6 h1:bjrcJ4wxEsG5rXHlXnedRzqAV9JYglj82S14Nf1oLvs= -github.com/enbility/ship-go v0.0.0-20241006160314-3a4325a1a6d6/go.mod h1:JJp8EQcJhUhTpZ2LSEU4rpdaM3E2n08tswWFWtmm/wU= -github.com/enbility/spine-go v0.0.0-20241007182100-30ee8bc405a7 h1:n6tv+YUMncSR9qxUs6k7d/YsKD9ujHHp5pUspIvM6sc= -github.com/enbility/spine-go v0.0.0-20241007182100-30ee8bc405a7/go.mod h1:ZoI9TaJO/So/677uknrli8sc6iryD7wC5iWhVIre+MI= +github.com/enbility/ship-go v0.6.1-0.20241023165311-5963bf4d9424 h1:yzf1pWKZn+vhxtWE1ZNyspAX8GiX/r4uBXZU+SdidNY= +github.com/enbility/ship-go v0.6.1-0.20241023165311-5963bf4d9424/go.mod h1:JJp8EQcJhUhTpZ2LSEU4rpdaM3E2n08tswWFWtmm/wU= +github.com/enbility/spine-go v0.7.0 h1:UZeghFgnM3VFU0ghc57Htt6gnxwP9jLppfU2GUMJGgY= +github.com/enbility/spine-go v0.7.0/go.mod h1:IF1sBTr7p3wXqlejeBJcJ8BYFlzzRaZcJsGw8XjgEgc= +github.com/enbility/spine-go v0.7.1-0.20241023170915-0b14938a9a37 h1:oZFPU4fHYBbSMVCwP3c9GHov8dFXqqQ2McvEyalsBY8= +github.com/enbility/spine-go v0.7.1-0.20241023170915-0b14938a9a37/go.mod h1:ZoI9TaJO/So/677uknrli8sc6iryD7wC5iWhVIre+MI= github.com/enbility/zeroconf/v2 v2.0.0-20240920094356-be1cae74fda6 h1:XOYvxKtT1oxT37w/5oEiRLuPbm9FuJPt3fiYhX0h8Po= github.com/enbility/zeroconf/v2 v2.0.0-20240920094356-be1cae74fda6/go.mod h1:BszP9qFV14mPXgyIREbgIdQtWxbAj3OKqvK02HihMoM= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= diff --git a/usecases/api/ca_cdt.go b/usecases/api/ca_cdt.go new file mode 100644 index 00000000..0aaa1d05 --- /dev/null +++ b/usecases/api/ca_cdt.go @@ -0,0 +1,47 @@ +package api + +import ( + "github.com/enbility/eebus-go/api" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" +) + +type CemCDTInterface interface { + api.UseCaseInterface + + // Scenario 1 + + // Return the current setpoints data + // + // parameters: + // - entity: the entity to get the setpoints data from + // + // return values: + // - setpoints: A map of the setpoints for supported modes + // + // possible errors: + // - ErrDataNotAvailable if no such limit is (yet) available + // - and others + Setpoints(entity spineapi.EntityRemoteInterface) ([]Setpoint, error) + + // Return the constraints for the setpoints + // + // parameters: + // - entity: the entity to get the setpoints constraints from + // + // return values: + // - setpointConstraints: A map of the constraints for supported modes + // + // possible errors: + // - ErrDataNotAvailable if no such limit is (yet) available + // - and others + SetpointConstraints(entity spineapi.EntityRemoteInterface) ([]SetpointConstraints, error) + + // Write a setpoint + // + // parameters: + // - entity: the entity to write the setpoint to + // - mode: the mode to write the setpoint for + // - degC: the temperature setpoint value to write + WriteSetpoint(entity spineapi.EntityRemoteInterface, mode model.HvacOperationModeTypeType, degC float64) error +} diff --git a/usecases/api/types.go b/usecases/api/types.go index a5661b76..c2516e0e 100644 --- a/usecases/api/types.go +++ b/usecases/api/types.go @@ -150,3 +150,50 @@ type DurationSlotValue struct { Duration time.Duration // Duration of this slot Value float64 // Energy Cost or Power Limit } + +// Contains details about the setpoint. +// Note: At least one of the following elements must be set (combinations may be used): Value, Min, Max +type Setpoint struct { + // The ID of the setpoint. + Id uint + + // The setpoint value, 0 if not set. + Value float64 + + // Lower limit of the value range, 0 if not set. + MinValue float64 + + // Upper limit of the value range, 0 if not set. + MaxValue float64 + + // Maximum absolute variation allowed around the setpoint, 0 if not set. + AbsoluteValueTolerance float64 + + // Maximum percentage variation allowed around the setpoint, 0 if not set. + PercentageValueTolerance float64 + + // Indicates if the setpoint is changeable by a client. + IsChangeable bool + + // Indicates if the setpoint is currently active. + IsActive bool + + // The period during which the setpoint is active, 0 if not set. + TimePeriod model.TimePeriodType +} + +// Contains details about the setpoint constraints. +// Constraints that shall be held when trying to change a setpoint. +type SetpointConstraints struct { + // the ID of the setpoint + Id uint + + // Minimum value, the setpoint can be set to, 0 if not set. + MinValue float64 + + // Maximum value, the setpoint can be set to, 0 if not set. + MaxValue float64 + + // Minimum step size between two different values, 0 if not set. + StepSize float64 +} diff --git a/usecases/ca/cdt/events.go b/usecases/ca/cdt/events.go new file mode 100644 index 00000000..57cd4057 --- /dev/null +++ b/usecases/ca/cdt/events.go @@ -0,0 +1,152 @@ +package cdt + +import ( + "github.com/enbility/eebus-go/features/client" + "github.com/enbility/eebus-go/usecases/internal" + "github.com/enbility/ship-go/logging" + "github.com/enbility/ship-go/util" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" +) + +// HandleEvent handles events for the CDT use case. +func (e *CDT) HandleEvent(payload spineapi.EventPayload) { + if !e.IsCompatibleEntityType(payload.Entity) { + return + } + + if internal.IsEntityConnected(payload) { + e.dhwCircuitconnected(payload.Entity) + return + } + + if payload.EventType != spineapi.EventTypeDataChange || + payload.ChangeType != spineapi.ElementChangeUpdate { + return + } + + switch payload.Data.(type) { + case *model.SetpointDescriptionListDataType: + e.setpointDescriptionsUpdate(payload) + + case *model.SetpointConstraintsListDataType: + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateSetpointConstraints) + + case *model.SetpointListDataType: + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateSetpoints) + + case *model.HvacOperationModeDescriptionListDataType, + *model.HvacSystemFunctionSetpointRelationListDataType: + e.resolveOpModeToSetpointMapping(payload) + } +} + +// resolveOpModeToSetpointMapping resolves the mapping between operation modes and setpoints. +func (e *CDT) resolveOpModeToSetpointMapping(payload spineapi.EventPayload) { + hvac, err := client.NewHvac(e.LocalEntity, payload.Entity) + if err != nil { + logging.Log().Debug(err) + } + + // We need both operation mode descriptions and relations to resolve the mapping + opModeDescriptions, _ := hvac.GetHvacOperationModeDescriptions() + relations, _ := hvac.GetHvacSystemFunctionOperationModeRelations() + if len(opModeDescriptions) == 0 || len(relations) == 0 { + return + } + + clear(e.operationModeToSetpoint) + + // Create a mapping between operation mode IDs and operation modes + operationModeIdToOperationMode := make(map[model.HvacOperationModeIdType]model.HvacOperationModeTypeType) + for _, opModeDescription := range opModeDescriptions { + modeId := opModeDescription.OperationModeId + mode := opModeDescription.OperationModeType + operationModeIdToOperationMode[*modeId] = *mode + } + + // Create a mapping between operation modes and setpoint IDs + operationModeToSetpoint := make(map[model.HvacOperationModeTypeType][]model.SetpointIdType) + for _, relation := range relations { + mode := operationModeIdToOperationMode[*relation.OperationModeId] + operationModeToSetpoint[mode] = append(operationModeToSetpoint[mode], relation.SetpointId...) + } + + for mode, setpointIDs := range operationModeToSetpoint { + if len(setpointIDs) != 1 { + if mode == model.HvacOperationModeTypeTypeAuto { + // For the "auto" operation mode, multiple setpoints (up to four) are allowed as per the specification + logging.Log().Debugf("Operation mode %s cycles between %d setpoints", mode, len(setpointIDs)) + } else { + // For other operation modes, having multiple setpoints is not allowed + // but not explicitly considered an error according to the specification + logging.Log().Errorf("Operation mode %s has %d setpoint IDs", mode, len(setpointIDs)) + } + continue + } + + // Save the unique 1:1 mapping between the operation mode and its corresponding setpoint ID + e.operationModeToSetpoint[mode] = setpointIDs[0] + } +} + +// setpointDescriptionsUpdate processes the necessary steps when setpoint descriptions are updated. +func (e *CDT) setpointDescriptionsUpdate(payload spineapi.EventPayload) { + setPoint, err := client.NewSetpoint(e.LocalEntity, payload.Entity) + if err != nil { + logging.Log().Debug(err) + return + } + + // The setpointConstraintsListData and setpointListData reads should + // be partial, using setpointId from setpointDescriptionListData. + setpointDescriptions := payload.Data.(*model.SetpointDescriptionListDataType).SetpointDescriptionData + for _, setpointDescription := range setpointDescriptions { + constraintsSelector := &model.SetpointConstraintsListDataSelectorsType{ + SetpointId: setpointDescription.SetpointId, + } + if _, err := setPoint.RequestSetPointConstraints(constraintsSelector, nil); err != nil { + logging.Log().Debug(err) + } + + setpointSelector := &model.SetpointListDataSelectorsType{ + SetpointId: setpointDescription.SetpointId, + } + if _, err := setPoint.RequestSetPoints(setpointSelector, nil); err != nil { + logging.Log().Debug(err) + } + } + + // Request setpoint relations to map operation modes to setpoints. + if hvac, err := client.NewHvac(e.LocalEntity, payload.Entity); err == nil { + if _, err := hvac.RequestHvacSystemFunctionSetPointRelations(nil, nil); err != nil { + logging.Log().Debug(err) + } + } +} + +// dhwCircuitconnected processes required steps when a DHW Circuit is connected. +func (e *CDT) dhwCircuitconnected(entity spineapi.EntityRemoteInterface) { + if hvac, err := client.NewHvac(e.LocalEntity, entity); err == nil { + if !hvac.HasSubscription() { + if _, err := hvac.Subscribe(); err != nil { + logging.Log().Debug(err) + } + } + } + + if setPoint, err := client.NewSetpoint(e.LocalEntity, entity); err == nil { + if !setPoint.HasSubscription() { + if _, err := setPoint.Subscribe(); err != nil { + logging.Log().Debug(err) + } + } + + selector := &model.SetpointDescriptionListDataSelectorsType{ + ScopeType: util.Ptr(model.ScopeTypeTypeDhwTemperature), + } + if _, err := setPoint.RequestSetPointDescriptions(selector, nil); err != nil { + logging.Log().Debug(err) + } + } +} diff --git a/usecases/ca/cdt/public.go b/usecases/ca/cdt/public.go new file mode 100644 index 00000000..6063d99c --- /dev/null +++ b/usecases/ca/cdt/public.go @@ -0,0 +1,164 @@ +package cdt + +import ( + "github.com/enbility/eebus-go/api" + "github.com/enbility/eebus-go/features/client" + usecasesapi "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" +) + +// Setpoints retrieves the setpoints for various HVAC operation modes from a remote entity. +// +// Possible errors: +// - ErrDataNotAvailable: If the mapping of operation modes to setpoints or the setpoints themselves are not available. +// - Other errors: Any other errors encountered during the process. +func (e *CDT) Setpoints(entity spineapi.EntityRemoteInterface) ([]usecasesapi.Setpoint, error) { + setpoints := make([]usecasesapi.Setpoint, 0) + + sp, err := client.NewSetpoint(e.LocalEntity, entity) + if err != nil { + return nil, err + } + + for _, setpoint := range sp.GetSetpoints() { + var value float64 = 0 + var minValue float64 = 0 + var maxValue float64 = 0 + var timePeriod model.TimePeriodType = model.TimePeriodType{} + + if setpoint.SetpointId == nil { + logging.Log().Error("Setpoint ID is nil") + continue + } + + if setpoint.Value != nil { + value = setpoint.Value.GetValue() + } + + if setpoint.ValueMax != nil { + maxValue = setpoint.ValueMax.GetValue() + } + + if setpoint.ValueMin != nil { + minValue = setpoint.ValueMin.GetValue() + } + + if setpoint.TimePeriod != nil { + timePeriod = *setpoint.TimePeriod + } + + isActive := (setpoint.IsSetpointActive == nil || *setpoint.IsSetpointActive) + isChangeable := (setpoint.IsSetpointChangeable == nil || *setpoint.IsSetpointChangeable) + + setpoints = append(setpoints, + usecasesapi.Setpoint{ + Id: uint(*setpoint.SetpointId), + Value: value, + MinValue: minValue, + MaxValue: maxValue, + IsActive: isActive, + IsChangeable: isChangeable, + TimePeriod: timePeriod, + }, + ) + } + + if len(setpoints) == 0 { + return nil, api.ErrDataNotAvailable + } + + return setpoints, nil +} + +// SetpointConstraints retrieves the setpoint constraints for various HVAC operation modes from a remote entity. +// +// Possible errors: +// - ErrDataNotAvailable: If the mapping of operation modes to setpoints or the setpoint constraints are not available. +// - Other errors: Any other errors encountered during the process. +func (e *CDT) SetpointConstraints(entity spineapi.EntityRemoteInterface) ([]usecasesapi.SetpointConstraints, error) { + setpointConstraints := make([]usecasesapi.SetpointConstraints, 0) + + sp, err := client.NewSetpoint(e.LocalEntity, entity) + if err != nil { + return nil, err + } + + for _, constraints := range sp.GetSetpointConstraints() { + var minValue float64 = 0 + var maxValue float64 = 0 + var setSize float64 = 0 + + if constraints.SetpointId == nil { + logging.Log().Error("Setpoint ID is nil") + continue + } + + if constraints.SetpointRangeMin != nil { + minValue = constraints.SetpointRangeMin.GetValue() + } + + if constraints.SetpointRangeMax != nil { + maxValue = constraints.SetpointRangeMax.GetValue() + } + + if constraints.SetpointStepSize != nil { + setSize = constraints.SetpointStepSize.GetValue() + } + + setpointConstraints = append(setpointConstraints, + usecasesapi.SetpointConstraints{ + Id: uint(*constraints.SetpointId), + MinValue: minValue, + MaxValue: maxValue, + StepSize: setSize, + }, + ) + } + + if len(setpointConstraints) == 0 { + return nil, api.ErrDataNotAvailable + } + + return setpointConstraints, nil +} + +// WriteSetpoint sets the temperature setpoint for a specified HVAC operation mode on a remote entity. +// +// Possible errors: +// - ErrDataNotAvailable: If the mapping of operation modes to setpoints is not available. +// - ErrNotSupported: If the setpoint is not changeable. +// - Other errors: Any other errors encountered during the process. +func (e *CDT) WriteSetpoint( + entity spineapi.EntityRemoteInterface, + mode model.HvacOperationModeTypeType, + degC float64, +) error { + if mode == model.HvacOperationModeTypeTypeAuto { + return nil + } + + setpointId, found := e.operationModeToSetpoint[mode] + if !found { + return api.ErrDataNotAvailable + } + + setPoint, err := client.NewSetpoint(e.LocalEntity, entity) + if err != nil { + return err + } + + setpointToWrite := []model.SetpointDataType{ + { + SetpointId: &setpointId, + Value: model.NewScaledNumberType(degC), + }, + } + + if _, err = setPoint.WriteSetPointListData(setpointToWrite); err != nil { + return err + } + + return nil +} diff --git a/usecases/ca/cdt/public_test.go b/usecases/ca/cdt/public_test.go new file mode 100644 index 00000000..48cfe2fd --- /dev/null +++ b/usecases/ca/cdt/public_test.go @@ -0,0 +1,183 @@ +package cdt + +import ( + "github.com/enbility/ship-go/util" + "github.com/enbility/spine-go/model" + "github.com/stretchr/testify/assert" +) + +// Test_Setpoints verifies the retrieval of setpoints from a remote entity. +func (s *CaCDTSuite) Test_Setpoints() { + // Test case: No setpoints available for mock remote entity + data, err := s.sut.Setpoints(s.mockRemoteEntity) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + // Test case: No setpoints available for CDT entity + data, err = s.sut.Setpoints(s.cdtEntity) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + // Prepare setpoint data + setpointsData := &model.SetpointListDataType{} + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.cdtEntity, model.FeatureTypeTypeSetpoint, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeSetpointListData, setpointsData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test case: No setpoints available after updating data + data, err = s.sut.Setpoints(s.cdtEntity) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + // Add setpoint data + setpointsData.SetpointData = []model.SetpointDataType{ + { + SetpointId: util.Ptr(model.SetpointIdType(1)), + Value: util.Ptr(model.ScaledNumberType{ + Number: util.Ptr(model.NumberType(34)), + Scale: util.Ptr(model.ScaleType(0)), + }), + ValueMin: util.Ptr(model.ScaledNumberType{ + Number: util.Ptr(model.NumberType(20)), + Scale: util.Ptr(model.ScaleType(0)), + }), + ValueMax: util.Ptr(model.ScaledNumberType{ + Number: util.Ptr(model.NumberType(70)), + Scale: util.Ptr(model.ScaleType(0)), + }), + ValueToleranceAbsolute: util.Ptr(model.ScaledNumberType{ + Number: util.Ptr(model.NumberType(0)), + Scale: util.Ptr(model.ScaleType(0)), + }), + ValueTolerancePercentage: util.Ptr(model.ScaledNumberType{ + Number: util.Ptr(model.NumberType(0)), + Scale: util.Ptr(model.ScaleType(0)), + }), + IsSetpointChangeable: util.Ptr(true), + IsSetpointActive: util.Ptr(true), + TimePeriod: util.Ptr(model.TimePeriodType{}), + }, + } + + // Update data with new setpoint data + _, fErr = rFeature.UpdateData(true, model.FunctionTypeSetpointListData, setpointsData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test case: Setpoints available after updating data + data, err = s.sut.Setpoints(s.cdtEntity) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), data) + assert.Len(s.T(), data, 1) + assert.Equal(s.T(), uint(1), data[0].Id) + assert.Equal(s.T(), 34.0, data[0].Value) + assert.Equal(s.T(), 20.0, data[0].MinValue) + assert.Equal(s.T(), 70.0, data[0].MaxValue) + assert.True(s.T(), data[0].IsActive) + assert.True(s.T(), data[0].IsChangeable) + assert.Equal(s.T(), model.TimePeriodType{}, data[0].TimePeriod) +} + +// Test_SetpointConstraints verifies the retrieval of setpoint constraints from a remote entity. +func (s *CaCDTSuite) Test_SetpointConstraints() { + // Test case: No setpoint constraints available for mock remote entity + data, err := s.sut.SetpointConstraints(s.mockRemoteEntity) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + // Test case: No setpoint constraints available for CDT entity + data, err = s.sut.SetpointConstraints(s.cdtEntity) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + // Prepare setpoint constraints data + constraintsData := &model.SetpointConstraintsListDataType{} + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.cdtEntity, model.FeatureTypeTypeSetpoint, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeSetpointConstraintsListData, constraintsData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test case: No setpoint constraints available after updating data + data, err = s.sut.SetpointConstraints(s.cdtEntity) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + // Add setpoint constraints data + constraintsData.SetpointConstraintsData = []model.SetpointConstraintsDataType{ + { + SetpointId: util.Ptr(model.SetpointIdType(1)), + SetpointRangeMin: util.Ptr(model.ScaledNumberType{ + Number: util.Ptr(model.NumberType(20)), + Scale: util.Ptr(model.ScaleType(0)), + }), + SetpointRangeMax: util.Ptr(model.ScaledNumberType{ + Number: util.Ptr(model.NumberType(70)), + Scale: util.Ptr(model.ScaleType(0)), + }), + SetpointStepSize: util.Ptr(model.ScaledNumberType{ + Number: util.Ptr(model.NumberType(1)), + Scale: util.Ptr(model.ScaleType(0)), + }), + }, + } + + // Update data with new setpoint constraints data + _, fErr = rFeature.UpdateData(true, model.FunctionTypeSetpointConstraintsListData, constraintsData, nil, nil) + assert.Nil(s.T(), fErr) + + // Test case: Setpoint constraints available after updating data + data, err = s.sut.SetpointConstraints(s.cdtEntity) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), data) + assert.Len(s.T(), data, 1) + assert.Equal(s.T(), uint(1), data[0].Id) + assert.Equal(s.T(), 20.0, data[0].MinValue) + assert.Equal(s.T(), 70.0, data[0].MaxValue) + assert.Equal(s.T(), 1.0, data[0].StepSize) +} + +// Test_WriteSetpoint verifies the functionality of writing a setpoint to a remote entity. +func (s *CaCDTSuite) Test_WriteSetpoint() { + // Test case: No setpoints available for mock remote entity + err := s.sut.WriteSetpoint(s.mockRemoteEntity, model.HvacOperationModeTypeTypeOn, 35.0) + assert.NotNil(s.T(), err) + + // Create a setpoint + setpoint := model.SetpointDataType{ + SetpointId: util.Ptr(model.SetpointIdType(1)), + Value: util.Ptr(model.ScaledNumberType{ + Number: util.Ptr(model.NumberType(34)), + Scale: util.Ptr(model.ScaleType(0)), + }), + ValueMin: util.Ptr(model.ScaledNumberType{ + Number: util.Ptr(model.NumberType(20)), + Scale: util.Ptr(model.ScaleType(0)), + }), + ValueMax: util.Ptr(model.ScaledNumberType{ + Number: util.Ptr(model.NumberType(70)), + Scale: util.Ptr(model.ScaleType(0)), + }), + IsSetpointChangeable: util.Ptr(true), + IsSetpointActive: util.Ptr(true), + } + + setpoints := &model.SetpointListDataType{ + SetpointData: []model.SetpointDataType{setpoint}, + } + + // Update the remote feature with the new setpoint data + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.cdtEntity, model.FeatureTypeTypeSetpoint, model.RoleTypeServer) + _, fErr := rFeature.UpdateData(true, model.FunctionTypeSetpointListData, setpoints, nil, nil) + assert.Nil(s.T(), fErr) + + // Test case: No mapping of operation modes to setpoints available + err = s.sut.WriteSetpoint(s.cdtEntity, model.HvacOperationModeTypeTypeOn, 35.0) + assert.NotNil(s.T(), err) + + // Create a mapping of operation modes to setpoints + s.sut.operationModeToSetpoint = map[model.HvacOperationModeTypeType]model.SetpointIdType{ + model.HvacOperationModeTypeTypeOn: *setpoint.SetpointId, + } + + // Test case: Setpoint and operation mode mapping available - the write should succeed + err = s.sut.WriteSetpoint(s.cdtEntity, model.HvacOperationModeTypeTypeOn, 35.0) + assert.Nil(s.T(), err) +} diff --git a/usecases/ca/cdt/testhelper_test.go b/usecases/ca/cdt/testhelper_test.go new file mode 100644 index 00000000..3b14f395 --- /dev/null +++ b/usecases/ca/cdt/testhelper_test.go @@ -0,0 +1,197 @@ +package cdt + +import ( + "fmt" + "testing" + "time" + + "github.com/enbility/eebus-go/api" + "github.com/enbility/eebus-go/mocks" + "github.com/enbility/eebus-go/service" + shipapi "github.com/enbility/ship-go/api" + "github.com/enbility/ship-go/cert" + shipmocks "github.com/enbility/ship-go/mocks" + "github.com/enbility/ship-go/util" + spineapi "github.com/enbility/spine-go/api" + spinemocks "github.com/enbility/spine-go/mocks" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/spine" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +func TestCemCDTSuite(t *testing.T) { + suite.Run(t, new(CaCDTSuite)) +} + +type CaCDTSuite struct { + suite.Suite + + sut *CDT + + service api.ServiceInterface + + remoteDevice spineapi.DeviceRemoteInterface + mockRemoteEntity *spinemocks.EntityRemoteInterface + cdtEntity spineapi.EntityRemoteInterface + + eventCalled bool +} + +func (s *CaCDTSuite) Event( + ski string, + device spineapi.DeviceRemoteInterface, + entity spineapi.EntityRemoteInterface, + event api.EventType, +) { + s.eventCalled = true +} + +func (s *CaCDTSuite) BeforeTest(suiteName, testName string) { + s.eventCalled = false + cert, _ := cert.CreateCertificate("test", "test", "DE", "test") + configuration, _ := api.NewConfiguration( + "test", "test", "test", "test", + []shipapi.DeviceCategoryType{shipapi.DeviceCategoryTypeEnergyManagementSystem}, + model.DeviceTypeTypeEnergyManagementSystem, + []model.EntityTypeType{model.EntityTypeTypeCEM}, + 9999, cert, time.Second*4) + + serviceHandler := mocks.NewServiceReaderInterface(s.T()) + serviceHandler.EXPECT().ServicePairingDetailUpdate(mock.Anything, mock.Anything).Return().Maybe() + + s.service = service.NewService(configuration, serviceHandler) + _ = s.service.Setup() + + mockRemoteDevice := spinemocks.NewDeviceRemoteInterface(s.T()) + s.mockRemoteEntity = spinemocks.NewEntityRemoteInterface(s.T()) + mockRemoteFeature := spinemocks.NewFeatureRemoteInterface(s.T()) + mockRemoteDevice.EXPECT().FeatureByEntityTypeAndRole(mock.Anything, mock.Anything, mock.Anything).Return(mockRemoteFeature).Maybe() + s.mockRemoteEntity.EXPECT().Device().Return(mockRemoteDevice).Maybe() + s.mockRemoteEntity.EXPECT().EntityType().Return(mock.Anything).Maybe() + mockRemoteFeature.EXPECT().DataCopy(mock.Anything).Return(mock.Anything).Maybe() + mockRemoteFeature.EXPECT().Address().Return(&model.FeatureAddressType{}).Maybe() + mockRemoteFeature.EXPECT().Operations().Return(nil).Maybe() + + localEntity := s.service.LocalDevice().EntityForType(model.EntityTypeTypeCEM) + s.sut = NewCDT(localEntity, s.Event) + s.sut.AddFeatures() + s.sut.AddUseCase() + + var entities []spineapi.EntityRemoteInterface + s.remoteDevice, entities = setupDevices(s.service, s.T()) + s.cdtEntity = entities[1] +} + +const remoteSki string = "testremoteski" + +func setupDevices( + eebusService api.ServiceInterface, + t *testing.T, +) ( + spineapi.DeviceRemoteInterface, + []spineapi.EntityRemoteInterface, +) { + localDevice := eebusService.LocalDevice() + + writeHandler := shipmocks.NewShipConnectionDataWriterInterface(t) + writeHandler.EXPECT().WriteShipMessageWithPayload(mock.Anything).Return().Maybe() + sender := spine.NewSender(writeHandler) + remoteDevice := spine.NewDeviceRemote(localDevice, remoteSki, sender) + + remoteDeviceName := "remote" + + var remoteFeatures = []struct { + featureType model.FeatureTypeType + supportedFcts []model.FunctionType + }{ + { + model.FeatureTypeTypeSetpoint, + []model.FunctionType{ + model.FunctionTypeSetpointDescriptionListData, + model.FunctionTypeSetpointConstraintsListData, + model.FunctionTypeSetpointListData, + }, + }, + { + model.FeatureTypeTypeHvac, + []model.FunctionType{ + model.FunctionTypeHvacOperationModeDescriptionListData, + model.FunctionTypeHvacSystemFunctionSetPointRelationListData, + }, + }, + } + + var featureInformations []model.NodeManagementDetailedDiscoveryFeatureInformationType + for index, feature := range remoteFeatures { + supportedFcts := []model.FunctionPropertyType{} + for _, fct := range feature.supportedFcts { + supportedFct := model.FunctionPropertyType{ + Function: util.Ptr(fct), + PossibleOperations: &model.PossibleOperationsType{ + Read: &model.PossibleOperationsReadType{}, + }, + } + supportedFcts = append(supportedFcts, supportedFct) + } + + featureInformation := model.NodeManagementDetailedDiscoveryFeatureInformationType{ + Description: &model.NetworkManagementFeatureDescriptionDataType{ + FeatureAddress: &model.FeatureAddressType{ + Device: util.Ptr(model.AddressDeviceType(remoteDeviceName)), + Entity: []model.AddressEntityType{1, 1}, + Feature: util.Ptr(model.AddressFeatureType(index)), + }, + FeatureType: util.Ptr(feature.featureType), + Role: util.Ptr(model.RoleTypeServer), + SupportedFunction: supportedFcts, + }, + } + featureInformations = append(featureInformations, featureInformation) + } + + detailedData := &model.NodeManagementDetailedDiscoveryDataType{ + DeviceInformation: &model.NodeManagementDetailedDiscoveryDeviceInformationType{ + Description: &model.NetworkManagementDeviceDescriptionDataType{ + DeviceAddress: &model.DeviceAddressType{ + Device: util.Ptr(model.AddressDeviceType(remoteDeviceName)), + }, + }, + }, + EntityInformation: []model.NodeManagementDetailedDiscoveryEntityInformationType{ + { + Description: &model.NetworkManagementEntityDescriptionDataType{ + EntityAddress: &model.EntityAddressType{ + Device: util.Ptr(model.AddressDeviceType(remoteDeviceName)), + Entity: []model.AddressEntityType{1}, + }, + EntityType: util.Ptr(model.EntityTypeTypeEVSE), + }, + }, + { + Description: &model.NetworkManagementEntityDescriptionDataType{ + EntityAddress: &model.EntityAddressType{ + Device: util.Ptr(model.AddressDeviceType(remoteDeviceName)), + Entity: []model.AddressEntityType{1, 1}, + }, + EntityType: util.Ptr(model.EntityTypeTypeEV), + }, + }, + }, + FeatureInformation: featureInformations, + } + + entities, err := remoteDevice.AddEntityAndFeatures(true, detailedData) + if err != nil { + fmt.Println(err) + } + remoteDevice.UpdateDevice(detailedData.DeviceInformation.Description) + + for _, entity := range entities { + entity.UpdateDeviceAddress(*remoteDevice.Address()) + } + + localDevice.AddRemoteDeviceForSki(remoteSki, remoteDevice) + + return remoteDevice, entities +} diff --git a/usecases/ca/cdt/types.go b/usecases/ca/cdt/types.go new file mode 100644 index 00000000..10c63992 --- /dev/null +++ b/usecases/ca/cdt/types.go @@ -0,0 +1,24 @@ +package cdt + +import "github.com/enbility/eebus-go/api" + +const ( + // Update of the list of remote entities supporting the Use Case + // + // Use `RemoteEntities` to get the current data + UseCaseSupportUpdate api.EventType = "ca-cdt-UseCaseSupportUpdate" + + // Setpoints data updated + // + // Use `Setpoints` to get the current data + // + // Use Case CDT, Scenario 1 + DataUpdateSetpoints api.EventType = "ca-cdt-DataUpdateSetpoints" + + // Setpoint constraints data updated + // + // Use `SetpointConstraints` to get the current data + // + // Use Case CDT, Scenario 1 + DataUpdateSetpointConstraints api.EventType = "ca-cdt-DataUpdateSetpointConstraints" +) diff --git a/usecases/ca/cdt/usecase.go b/usecases/ca/cdt/usecase.go new file mode 100644 index 00000000..7315f984 --- /dev/null +++ b/usecases/ca/cdt/usecase.go @@ -0,0 +1,77 @@ +package cdt + +import ( + "github.com/enbility/eebus-go/api" + ucapi "github.com/enbility/eebus-go/usecases/api" + "github.com/enbility/eebus-go/usecases/usecase" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/spine" +) + +// Optimization of Heat Pump Compressor Function +type CDT struct { + *usecase.UseCaseBase + + // Maps operation mode IDs to setpoint IDs + operationModeToSetpoint map[model.HvacOperationModeTypeType]model.SetpointIdType +} + +var _ ucapi.CemCDTInterface = (*CDT)(nil) + +// NewCDT creates a new CDT use case +func NewCDT( + localEntity spineapi.EntityLocalInterface, + eventCB api.EntityEventCallback, +) *CDT { + validActorTypes := []model.UseCaseActorType{ + model.UseCaseActorTypeDHWCircuit, + } + validEntityTypes := []model.EntityTypeType{ + model.EntityTypeTypeDHWCircuit, + } + useCaseScenarios := []api.UseCaseScenario{ + { + Scenario: model.UseCaseScenarioSupportType(1), + Mandatory: true, + ServerFeatures: []model.FeatureTypeType{ + model.FeatureTypeTypeSetpoint, + model.FeatureTypeTypeHvac, + }, + }, + } + + usecase := usecase.NewUseCaseBase( + localEntity, + model.UseCaseActorTypeConfigurationAppliance, + model.UseCaseNameTypeConfigurationOfDhwTemperature, + "1.0.0", + "release", + useCaseScenarios, + eventCB, + UseCaseSupportUpdate, + validActorTypes, + validEntityTypes, + ) + + uc := &CDT{ + UseCaseBase: usecase, + operationModeToSetpoint: make(map[model.HvacOperationModeTypeType]model.SetpointIdType), + } + + _ = spine.Events.Subscribe(uc) + + return uc +} + +// AddFeatures adds the features required for the CDT use case +func (e *CDT) AddFeatures() { + var clientFeatures = []model.FeatureTypeType{ + model.FeatureTypeTypeSetpoint, + model.FeatureTypeTypeHvac, + } + + for _, feature := range clientFeatures { + _ = e.LocalEntity.GetOrAddFeature(feature, model.RoleTypeClient) + } +} diff --git a/usecases/ca/cdt/usecase_test.go b/usecases/ca/cdt/usecase_test.go new file mode 100644 index 00000000..353f6476 --- /dev/null +++ b/usecases/ca/cdt/usecase_test.go @@ -0,0 +1,5 @@ +package cdt + +func (s *CaCDTSuite) Test_UpdateUseCaseAvailability() { + s.sut.UpdateUseCaseAvailability(true) +}