From 26efd3901b49741fe1ceef1eb88a2ed3982417bd Mon Sep 17 00:00:00 2001 From: David Sapir Date: Tue, 29 Oct 2024 16:35:42 +0200 Subject: [PATCH 1/3] Feat: Add CDT Support as CA (Configuration Appliance) Client Actor This commit introduces CDT support as a CA (Configuration Appliance) client actor. It provides full support for the CDT use case, specifically Scenario 1. This includes the ability to retrieve setpoints, their constraints, and map them to their corresponding operation modes via HvacSetpointRelations. For the use case to fully function, support for the CDSF (Configuration of DHW System Function) use case is necessary. Specifically, we need to request HvacOperationModeDescriptionListDataType, which is used in conjunction with HvacSystemFunctionSetpointRelationListDataType to establish the mapping between operation modes and their setpoints, and to enable the ability to write setpoints. Note: Writing setpoints was tested and confirmed to work with Vaillant's HeatPump by requesting the HvacOperationModeDescriptionListDataType message and performing the mapping without the CDSF use case. Resources used (specifications): - EEBus UC Technical Specification Configuration of DHW Temperature - EEBus SPINE Technical Specification Resource Specification --- features/client/hvac.go | 50 +++++++ features/client/setpoint.go | 83 +++++++++++ features/internal/hvac.go | 77 ++++++++++ features/internal/setpoint.go | 127 ++++++++++++++++ go.mod | 4 +- go.sum | 10 +- usecases/api/ca_cdt.go | 47 ++++++ usecases/api/types.go | 47 ++++++ usecases/ca/cdt/events.go | 99 +++++++++++++ usecases/ca/cdt/public.go | 230 +++++++++++++++++++++++++++++ usecases/ca/cdt/public_test.go | 183 +++++++++++++++++++++++ usecases/ca/cdt/testhelper_test.go | 197 ++++++++++++++++++++++++ usecases/ca/cdt/types.go | 24 +++ usecases/ca/cdt/usecase.go | 75 ++++++++++ usecases/ca/cdt/usecase_test.go | 5 + 15 files changed, 1252 insertions(+), 6 deletions(-) create mode 100644 features/client/hvac.go create mode 100644 features/client/setpoint.go create mode 100644 features/internal/hvac.go create mode 100644 features/internal/setpoint.go create mode 100644 usecases/api/ca_cdt.go create mode 100644 usecases/ca/cdt/events.go create mode 100644 usecases/ca/cdt/public.go create mode 100644 usecases/ca/cdt/public_test.go create mode 100644 usecases/ca/cdt/testhelper_test.go create mode 100644 usecases/ca/cdt/types.go create mode 100644 usecases/ca/cdt/usecase.go create mode 100644 usecases/ca/cdt/usecase_test.go 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..f62a5b28 --- /dev/null +++ b/features/internal/hvac.go @@ -0,0 +1,77 @@ +package internal + +import ( + "github.com/enbility/eebus-go/api" + 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 +} + +// GetHvacSystemFunctionSetpointRelations returns the operation mode relations for a given system function id +func (h *HvacCommon) GetHvacSystemFunctionSetpointRelationsForSystemFunctionId( + id model.HvacSystemFunctionIdType, +) ([]model.HvacSystemFunctionSetpointRelationDataType, error) { + function := model.FunctionTypeHvacSystemFunctionSetPointRelationListData + filter := model.HvacSystemFunctionSetpointRelationDataType{ + SystemFunctionId: &id, + } + + data, err := featureDataCopyOfType[model.HvacSystemFunctionSetpointRelationListDataType](h.featureLocal, h.featureRemote, function) + if err != nil || data == nil || data.HvacSystemFunctionSetpointRelationData == nil { + return nil, api.ErrDataNotAvailable + } + + result := searchFilterInList[model.HvacSystemFunctionSetpointRelationDataType](data.HvacSystemFunctionSetpointRelationData, filter) + if len(result) == 0 { + return nil, api.ErrDataNotAvailable + } + + return result, nil +} + +// GetHvacSystemFunctionDescriptions returns the system function descriptions for a given filter +func (h *HvacCommon) GetHvacSystemFunctionDescriptionsForFilter( + filter model.HvacSystemFunctionDescriptionDataType, +) ([]model.HvacSystemFunctionDescriptionDataType, error) { + function := model.FunctionTypeHvacSystemFunctionDescriptionListData + + data, err := featureDataCopyOfType[model.HvacSystemFunctionDescriptionListDataType](h.featureLocal, h.featureRemote, function) + if err != nil || data == nil || data.HvacSystemFunctionDescriptionData == nil { + return nil, api.ErrDataNotAvailable + } + + result := searchFilterInList[model.HvacSystemFunctionDescriptionDataType](data.HvacSystemFunctionDescriptionData, filter) + + return result, 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..ee53d3a5 --- /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 CaCDTInterface 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..d1e73bdb --- /dev/null +++ b/usecases/ca/cdt/events.go @@ -0,0 +1,99 @@ +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) + } +} + +// 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..24a1b569 --- /dev/null +++ b/usecases/ca/cdt/public.go @@ -0,0 +1,230 @@ +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" + "github.com/enbility/ship-go/util" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" +) + +// Setpoints returns the setpoints. +// +// 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 returns the setpoint constraints. +// +// 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 +} + +// mapSetpointsToOperationModes maps setpoints to their respective operation modes. +func (e *CDT) mapSetpointsToModes(entity spineapi.EntityRemoteInterface) error { + hvac, err := client.NewHvac(e.LocalEntity, entity) + if err != nil { + return err + } + + // Get the DHW system functionId for the DHW system function. + filter := model.HvacSystemFunctionDescriptionDataType{ + SystemFunctionType: util.Ptr(model.HvacSystemFunctionTypeTypeDhw), + } + functions, _ := hvac.GetHvacSystemFunctionDescriptionsForFilter(filter) + if len(functions) == 0 { + return api.ErrDataNotAvailable + } + + functionId := *functions[0].SystemFunctionId + + // Get the relations between operation modes and setpoints for the DHW system function. + relations, _ := hvac.GetHvacSystemFunctionSetpointRelationsForSystemFunctionId(functionId) + if len(relations) == 0 { + return api.ErrDataNotAvailable + } + + // Get the operation mode descriptions for the operation modes in the relations. + descriptions, _ := hvac.GetHvacOperationModeDescriptions() + if len(descriptions) == 0 { + return api.ErrDataNotAvailable + } + + // Create a mapping to get the operation mode descriptions by operation mode ID. + modeDescriptions := make(map[model.HvacOperationModeIdType]model.HvacOperationModeTypeType) + for _, description := range descriptions { + modeDescriptions[*description.OperationModeId] = *description.OperationModeType + } + + // Map the setpoints to their respective operation modes. + for _, relation := range relations { + if mode, found := modeDescriptions[*relation.OperationModeId]; found { + if len(relation.SetpointId) == 0 { + // Only the 'Off' operation mode can have no setpoint associated with it. + if mode != model.HvacOperationModeTypeTypeOff { + logging.Log().Errorf("Operation mode '%s' has no setpoints", mode) + } + } else if len(relation.SetpointId) == 1 { + // Unique 1:1 mapping of operation mode to setpoint. + e.modes[mode] = relation.SetpointId[0] + } else { + if mode != model.HvacOperationModeTypeTypeAuto { + logging.Log().Errorf("Operation mode '%s' has multiple setpoints", mode) + } + } + } + } + + return nil +} + +// WriteSetpoint sets the temperature setpoint for a specific operation mode. +// +// 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, + temperature float64, +) error { + if mode == model.HvacOperationModeTypeTypeAuto { + // 'Auto' mode is controlled by a timetable, meaning the current setpoint + // for the HVAC system function changes according to the timetable. + // Only the 'Off', 'On', and 'Eco' modes can be directly controlled by a setpoint. + return nil + } + + if len(e.modes) == 0 && e.mapSetpointsToModes(entity) != nil { + return api.ErrDataNotAvailable + } + + setpointId, found := e.modes[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(temperature), + }, + } + + 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..bfeb3268 --- /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.modes = map[model.HvacOperationModeTypeType]model.SetpointIdType{ + model.HvacOperationModeTypeTypeOn: 1, + } + + // 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..8ff3fb72 --- /dev/null +++ b/usecases/ca/cdt/usecase.go @@ -0,0 +1,75 @@ +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" +) + +type CDT struct { + *usecase.UseCaseBase + + modes map[model.HvacOperationModeTypeType]model.SetpointIdType +} + +var _ ucapi.CaCDTInterface = (*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, + modes: 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) +} From fe2b7025d4734e1b19c5de252f0b7aca9d82fd3d Mon Sep 17 00:00:00 2001 From: David Sapir Date: Wed, 6 Nov 2024 10:16:57 +0200 Subject: [PATCH 2/3] Add note to isActive and IsChangable flags --- usecases/ca/cdt/public.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/usecases/ca/cdt/public.go b/usecases/ca/cdt/public.go index 24a1b569..bd42b26a 100644 --- a/usecases/ca/cdt/public.go +++ b/usecases/ca/cdt/public.go @@ -50,6 +50,9 @@ func (e *CDT) Setpoints(entity spineapi.EntityRemoteInterface) ([]usecasesapi.Se timePeriod = *setpoint.TimePeriod } + // As per [Resource Specification] 4.3.23.4 setpointListData: + // - isSetpointActive: If false, the setpoint is inactive; if true or omitted, it is active. + // - isSetpointChangeable: If true, the server accepts changes; if false, it declines changes. If absent, changes are accepted. isActive := (setpoint.IsSetpointActive == nil || *setpoint.IsSetpointActive) isChangeable := (setpoint.IsSetpointChangeable == nil || *setpoint.IsSetpointChangeable) From 4afe0717d1055d254298ca494c2f33f84cc55e9e Mon Sep 17 00:00:00 2001 From: David Sapir Date: Wed, 6 Nov 2024 13:35:37 +0200 Subject: [PATCH 3/3] Add HvacOperationModeType to usecase types --- usecases/api/ca_cdt.go | 13 +++---- usecases/api/types.go | 9 +++++ usecases/ca/cdt/events.go | 55 ++++++++++++++++++++++++++++ usecases/ca/cdt/public.go | 67 ++-------------------------------- usecases/ca/cdt/public_test.go | 9 +++-- usecases/ca/cdt/usecase.go | 6 +-- 6 files changed, 82 insertions(+), 77 deletions(-) diff --git a/usecases/api/ca_cdt.go b/usecases/api/ca_cdt.go index ee53d3a5..30a5143b 100644 --- a/usecases/api/ca_cdt.go +++ b/usecases/api/ca_cdt.go @@ -3,7 +3,6 @@ package api import ( "github.com/enbility/eebus-go/api" spineapi "github.com/enbility/spine-go/api" - "github.com/enbility/spine-go/model" ) type CaCDTInterface interface { @@ -11,26 +10,26 @@ type CaCDTInterface interface { // Scenario 1 - // Return the current setpoints data + // Return the setpoints. // // parameters: // - entity: the entity to get the setpoints data from // // return values: - // - setpoints: A map of the setpoints for supported modes + // - setpoints: A list of setpoints // // possible errors: // - ErrDataNotAvailable if no such limit is (yet) available // - and others Setpoints(entity spineapi.EntityRemoteInterface) ([]Setpoint, error) - // Return the constraints for the setpoints + // 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 + // - setpointConstraints: A list of setpoint constraints // // possible errors: // - ErrDataNotAvailable if no such limit is (yet) available @@ -42,6 +41,6 @@ type CaCDTInterface interface { // 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 + // - temperature: the temperature setpoint value to write + WriteSetpoint(entity spineapi.EntityRemoteInterface, mode HvacOperationModeType, temperature float64) error } diff --git a/usecases/api/types.go b/usecases/api/types.go index c2516e0e..76bf9ce3 100644 --- a/usecases/api/types.go +++ b/usecases/api/types.go @@ -17,6 +17,15 @@ const ( EVChargeStateTypeFinished EVChargeStateType = "finished" ) +type HvacOperationModeType string + +const ( + HvacOperationModeTypeAuto HvacOperationModeType = "auto" + HvacOperationModeTypeOn HvacOperationModeType = "on" + HvacOperationModeTypeOff HvacOperationModeType = "off" + HvacOperationModeTypeEco HvacOperationModeType = "eco" +) + // Defines a phase specific limit data set type LoadLimitsPhase struct { Phase model.ElectricalConnectionPhaseNameType // the phase diff --git a/usecases/ca/cdt/events.go b/usecases/ca/cdt/events.go index d1e73bdb..6c4c8148 100644 --- a/usecases/ca/cdt/events.go +++ b/usecases/ca/cdt/events.go @@ -34,6 +34,61 @@ func (e *CDT) HandleEvent(payload spineapi.EventPayload) { case *model.SetpointListDataType: e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateSetpoints) + + case *model.HvacSystemFunctionSetpointRelationListDataType, + *model.HvacOperationModeDescriptionListDataType: + e.mapSetpointsToOperationModes(payload) + } +} + +// mapSetpointsToOperationModes maps setpoints to operation modes. +func (e *CDT) mapSetpointsToOperationModes(payload spineapi.EventPayload) { + hvac, err := client.NewHvac(e.LocalEntity, payload.Entity) + if err != nil { + logging.Log().Debug(err) + return + } + + // Get the DHW system function. + filter := model.HvacSystemFunctionDescriptionDataType{ + SystemFunctionType: util.Ptr(model.HvacSystemFunctionTypeTypeDhw), + } + functions, _ := hvac.GetHvacSystemFunctionDescriptionsForFilter(filter) + if len(functions) != 1 { + logging.Log().Error("Shuold be exactly one DHW system function") + return + } + + dhwFunctionId := *functions[0].SystemFunctionId + + relations, _ := hvac.GetHvacSystemFunctionSetpointRelationsForSystemFunctionId(dhwFunctionId) + descriptions, _ := hvac.GetHvacOperationModeDescriptions() + if len(relations) == 0 || len(descriptions) == 0 { + return + } + + modeForModeId := make(map[model.HvacOperationModeIdType]model.HvacOperationModeTypeType) + for _, description := range descriptions { + modeForModeId[*description.OperationModeId] = *description.OperationModeType + } + + // Map the setpoints to their respective operation modes. + for _, relation := range relations { + if mode, found := modeForModeId[*relation.OperationModeId]; found { + if len(relation.SetpointId) == 0 { + // Only the 'Off' operation mode can have no setpoint associated with it. + if mode != model.HvacOperationModeTypeTypeOff { + logging.Log().Errorf("Operation mode '%s' has no setpoints", mode) + } + } else if len(relation.SetpointId) == 1 { + // Store the unique setpoint for the operation mode. + e.setpointIdForMode[mode] = relation.SetpointId[0] + } else if mode != model.HvacOperationModeTypeTypeAuto { + // Only the 'Auto' operation mode can have multiple setpoints (1 to 4). + // Since 'Auto' mode is not user-controllable, we do not store the setpoints. + logging.Log().Errorf("Operation mode '%s' has multiple setpoints", mode) + } + } } } diff --git a/usecases/ca/cdt/public.go b/usecases/ca/cdt/public.go index bd42b26a..eed37ae5 100644 --- a/usecases/ca/cdt/public.go +++ b/usecases/ca/cdt/public.go @@ -5,7 +5,6 @@ import ( "github.com/enbility/eebus-go/features/client" usecasesapi "github.com/enbility/eebus-go/usecases/api" "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" ) @@ -128,64 +127,6 @@ func (e *CDT) SetpointConstraints(entity spineapi.EntityRemoteInterface) ([]usec return setpointConstraints, nil } -// mapSetpointsToOperationModes maps setpoints to their respective operation modes. -func (e *CDT) mapSetpointsToModes(entity spineapi.EntityRemoteInterface) error { - hvac, err := client.NewHvac(e.LocalEntity, entity) - if err != nil { - return err - } - - // Get the DHW system functionId for the DHW system function. - filter := model.HvacSystemFunctionDescriptionDataType{ - SystemFunctionType: util.Ptr(model.HvacSystemFunctionTypeTypeDhw), - } - functions, _ := hvac.GetHvacSystemFunctionDescriptionsForFilter(filter) - if len(functions) == 0 { - return api.ErrDataNotAvailable - } - - functionId := *functions[0].SystemFunctionId - - // Get the relations between operation modes and setpoints for the DHW system function. - relations, _ := hvac.GetHvacSystemFunctionSetpointRelationsForSystemFunctionId(functionId) - if len(relations) == 0 { - return api.ErrDataNotAvailable - } - - // Get the operation mode descriptions for the operation modes in the relations. - descriptions, _ := hvac.GetHvacOperationModeDescriptions() - if len(descriptions) == 0 { - return api.ErrDataNotAvailable - } - - // Create a mapping to get the operation mode descriptions by operation mode ID. - modeDescriptions := make(map[model.HvacOperationModeIdType]model.HvacOperationModeTypeType) - for _, description := range descriptions { - modeDescriptions[*description.OperationModeId] = *description.OperationModeType - } - - // Map the setpoints to their respective operation modes. - for _, relation := range relations { - if mode, found := modeDescriptions[*relation.OperationModeId]; found { - if len(relation.SetpointId) == 0 { - // Only the 'Off' operation mode can have no setpoint associated with it. - if mode != model.HvacOperationModeTypeTypeOff { - logging.Log().Errorf("Operation mode '%s' has no setpoints", mode) - } - } else if len(relation.SetpointId) == 1 { - // Unique 1:1 mapping of operation mode to setpoint. - e.modes[mode] = relation.SetpointId[0] - } else { - if mode != model.HvacOperationModeTypeTypeAuto { - logging.Log().Errorf("Operation mode '%s' has multiple setpoints", mode) - } - } - } - } - - return nil -} - // WriteSetpoint sets the temperature setpoint for a specific operation mode. // // Possible errors: @@ -194,21 +135,21 @@ func (e *CDT) mapSetpointsToModes(entity spineapi.EntityRemoteInterface) error { // - Other errors: Any other errors encountered during the process. func (e *CDT) WriteSetpoint( entity spineapi.EntityRemoteInterface, - mode model.HvacOperationModeTypeType, + mode usecasesapi.HvacOperationModeType, temperature float64, ) error { - if mode == model.HvacOperationModeTypeTypeAuto { + if model.HvacOperationModeTypeType(mode) == model.HvacOperationModeTypeTypeAuto { // 'Auto' mode is controlled by a timetable, meaning the current setpoint // for the HVAC system function changes according to the timetable. // Only the 'Off', 'On', and 'Eco' modes can be directly controlled by a setpoint. return nil } - if len(e.modes) == 0 && e.mapSetpointsToModes(entity) != nil { + if len(e.setpointIdForMode) == 0 { return api.ErrDataNotAvailable } - setpointId, found := e.modes[mode] + setpointId, found := e.setpointIdForMode[model.HvacOperationModeTypeType(mode)] if !found { return api.ErrDataNotAvailable } diff --git a/usecases/ca/cdt/public_test.go b/usecases/ca/cdt/public_test.go index bfeb3268..17cccd9e 100644 --- a/usecases/ca/cdt/public_test.go +++ b/usecases/ca/cdt/public_test.go @@ -1,6 +1,7 @@ package cdt import ( + "github.com/enbility/eebus-go/usecases/api" "github.com/enbility/ship-go/util" "github.com/enbility/spine-go/model" "github.com/stretchr/testify/assert" @@ -137,7 +138,7 @@ func (s *CaCDTSuite) Test_SetpointConstraints() { // 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) + err := s.sut.WriteSetpoint(s.mockRemoteEntity, api.HvacOperationModeTypeOn, 35.0) assert.NotNil(s.T(), err) // Create a setpoint @@ -169,15 +170,15 @@ func (s *CaCDTSuite) Test_WriteSetpoint() { 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) + err = s.sut.WriteSetpoint(s.cdtEntity, api.HvacOperationModeTypeOn, 35.0) assert.NotNil(s.T(), err) // Create a mapping of operation modes to setpoints - s.sut.modes = map[model.HvacOperationModeTypeType]model.SetpointIdType{ + s.sut.setpointIdForMode = map[model.HvacOperationModeTypeType]model.SetpointIdType{ model.HvacOperationModeTypeTypeOn: 1, } // Test case: Setpoint and operation mode mapping available - the write should succeed - err = s.sut.WriteSetpoint(s.cdtEntity, model.HvacOperationModeTypeTypeOn, 35.0) + err = s.sut.WriteSetpoint(s.cdtEntity, api.HvacOperationModeTypeOn, 35.0) assert.Nil(s.T(), err) } diff --git a/usecases/ca/cdt/usecase.go b/usecases/ca/cdt/usecase.go index 8ff3fb72..7ad93226 100644 --- a/usecases/ca/cdt/usecase.go +++ b/usecases/ca/cdt/usecase.go @@ -12,7 +12,7 @@ import ( type CDT struct { *usecase.UseCaseBase - modes map[model.HvacOperationModeTypeType]model.SetpointIdType + setpointIdForMode map[model.HvacOperationModeTypeType]model.SetpointIdType } var _ ucapi.CaCDTInterface = (*CDT)(nil) @@ -53,8 +53,8 @@ func NewCDT( ) uc := &CDT{ - UseCaseBase: usecase, - modes: make(map[model.HvacOperationModeTypeType]model.SetpointIdType), + UseCaseBase: usecase, + setpointIdForMode: make(map[model.HvacOperationModeTypeType]model.SetpointIdType), } _ = spine.Events.Subscribe(uc)