From 30285251a73c5531c0dc010224aed7785a59dada Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 17 Jul 2026 12:22:17 +0200 Subject: [PATCH 1/7] Add Hvac and Setpoint client features Groundwork for the HVAC system function and temperature use cases. Co-Authored-By: Claude Fable 5 --- features/client/hvac.go | 93 ++++++++ features/client/hvac_test.go | 111 ++++++++++ features/client/setpoint.go | 77 +++++++ features/client/setpoint_test.go | 97 +++++++++ features/internal/hvac.go | 174 +++++++++++++++ features/internal/hvac_test.go | 336 +++++++++++++++++++++++++++++ features/internal/setpoint.go | 140 ++++++++++++ features/internal/setpoint_test.go | 252 ++++++++++++++++++++++ 8 files changed, 1280 insertions(+) create mode 100644 features/client/hvac.go create mode 100644 features/client/hvac_test.go create mode 100644 features/client/setpoint.go create mode 100644 features/client/setpoint_test.go create mode 100644 features/internal/hvac.go create mode 100644 features/internal/hvac_test.go create mode 100644 features/internal/setpoint.go create mode 100644 features/internal/setpoint_test.go diff --git a/features/client/hvac.go b/features/client/hvac.go new file mode 100644 index 00000000..a6295a7b --- /dev/null +++ b/features/client/hvac.go @@ -0,0 +1,93 @@ +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 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 FunctionTypeHvacSystemFunctionDescriptionListData from a remote device +func (h *Hvac) RequestHvacSystemFunctionDescriptions( + selector *model.HvacSystemFunctionDescriptionListDataSelectorsType, + elements *model.HvacSystemFunctionDescriptionDataElementsType, +) (*model.MsgCounterType, error) { + return h.requestData(model.FunctionTypeHvacSystemFunctionDescriptionListData, selector, elements) +} + +// request FunctionTypeHvacSystemFunctionListData from a remote device +func (h *Hvac) RequestHvacSystemFunctions( + selector *model.HvacSystemFunctionListDataSelectorsType, + elements *model.HvacSystemFunctionDataElementsType, +) (*model.MsgCounterType, error) { + return h.requestData(model.FunctionTypeHvacSystemFunctionListData, 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) +} + +// request FunctionTypeHvacSystemFunctionOperationModeRelationListData from a remote device +func (h *Hvac) RequestHvacSystemFunctionOperationModeRelations( + selector *model.HvacSystemFunctionOperationModeRelationListDataSelectorsType, + elements *model.HvacSystemFunctionOperationModeRelationDataElementsType, +) (*model.MsgCounterType, error) { + return h.requestData(model.FunctionTypeHvacSystemFunctionOperationModeRelationListData, selector, elements) +} + +// 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) +} + +// write the given HVAC system function data to the remote device, +// e.g. to change the current operation mode of a system function +func (h *Hvac) WriteHvacSystemFunctionListData( + data []model.HvacSystemFunctionDataType, +) (*model.MsgCounterType, error) { + if len(data) == 0 { + return nil, api.ErrMissingData + } + + cmd := model.CmdType{ + HvacSystemFunctionListData: &model.HvacSystemFunctionListDataType{ + HvacSystemFunctionData: data, + }, + } + + return h.remoteDevice.Sender().Write(h.featureLocal.Address(), h.featureRemote.Address(), cmd) +} diff --git a/features/client/hvac_test.go b/features/client/hvac_test.go new file mode 100644 index 00000000..677cdf37 --- /dev/null +++ b/features/client/hvac_test.go @@ -0,0 +1,111 @@ +package client + +import ( + "testing" + + shipapi "github.com/enbility/ship-go/api" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +func TestHvacSuite(t *testing.T) { + suite.Run(t, new(HvacSuite)) +} + +type HvacSuite struct { + suite.Suite + + localEntity spineapi.EntityLocalInterface + remoteEntity spineapi.EntityRemoteInterface + + hvac *Hvac + sentMessage []byte +} + +var _ shipapi.ShipConnectionDataWriterInterface = (*HvacSuite)(nil) + +func (s *HvacSuite) WriteShipMessageWithPayload(message []byte) { + s.sentMessage = message +} + +func (s *HvacSuite) BeforeTest(suiteName, testName string) { + s.localEntity, s.remoteEntity = setupFeatures( + s.T(), + s, + []featureFunctions{ + { + featureType: model.FeatureTypeTypeHvac, + functions: []model.FunctionType{ + model.FunctionTypeHvacSystemFunctionDescriptionListData, + model.FunctionTypeHvacSystemFunctionListData, + model.FunctionTypeHvacOperationModeDescriptionListData, + model.FunctionTypeHvacSystemFunctionOperationModeRelationListData, + model.FunctionTypeHvacSystemFunctionSetPointRelationListData, + }, + }, + }, + ) + + var err error + s.hvac, err = NewHvac(s.localEntity, nil) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), s.hvac) + + s.hvac, err = NewHvac(s.localEntity, s.remoteEntity) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), s.hvac) +} + +func (s *HvacSuite) Test_RequestHvacSystemFunctionDescriptions() { + counter, err := s.hvac.RequestHvacSystemFunctionDescriptions(nil, nil) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), counter) +} + +func (s *HvacSuite) Test_RequestHvacSystemFunctions() { + counter, err := s.hvac.RequestHvacSystemFunctions(nil, nil) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), counter) +} + +func (s *HvacSuite) Test_RequestHvacOperationModeDescriptions() { + counter, err := s.hvac.RequestHvacOperationModeDescriptions(nil, nil) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), counter) +} + +func (s *HvacSuite) Test_RequestHvacSystemFunctionOperationModeRelations() { + counter, err := s.hvac.RequestHvacSystemFunctionOperationModeRelations(nil, nil) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), counter) +} + +func (s *HvacSuite) Test_RequestHvacSystemFunctionSetpointRelations() { + counter, err := s.hvac.RequestHvacSystemFunctionSetpointRelations(nil, nil) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), counter) +} + +func (s *HvacSuite) Test_WriteHvacSystemFunctionListData() { + counter, err := s.hvac.WriteHvacSystemFunctionListData(nil) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), counter) + + data := []model.HvacSystemFunctionDataType{} + counter, err = s.hvac.WriteHvacSystemFunctionListData(data) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), counter) + + data = []model.HvacSystemFunctionDataType{ + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(1)), + CurrentOperationModeId: util.Ptr(model.HvacOperationModeIdType(2)), + }, + } + counter, err = s.hvac.WriteHvacSystemFunctionListData(data) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), counter) +} diff --git a/features/client/setpoint.go b/features/client/setpoint.go new file mode 100644 index 00000000..b3b98534 --- /dev/null +++ b/features/client/setpoint.go @@ -0,0 +1,77 @@ +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) +} + +// write the given setpoint data to the remote device, +// returns the message counter of the sent message +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/client/setpoint_test.go b/features/client/setpoint_test.go new file mode 100644 index 00000000..1c9784a3 --- /dev/null +++ b/features/client/setpoint_test.go @@ -0,0 +1,97 @@ +package client + +import ( + "testing" + + shipapi "github.com/enbility/ship-go/api" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +func TestSetpointSuite(t *testing.T) { + suite.Run(t, new(SetpointSuite)) +} + +type SetpointSuite struct { + suite.Suite + + localEntity spineapi.EntityLocalInterface + remoteEntity spineapi.EntityRemoteInterface + + setpoint *Setpoint + sentMessage []byte +} + +var _ shipapi.ShipConnectionDataWriterInterface = (*SetpointSuite)(nil) + +func (s *SetpointSuite) WriteShipMessageWithPayload(message []byte) { + s.sentMessage = message +} + +func (s *SetpointSuite) BeforeTest(suiteName, testName string) { + s.localEntity, s.remoteEntity = setupFeatures( + s.T(), + s, + []featureFunctions{ + { + featureType: model.FeatureTypeTypeSetpoint, + functions: []model.FunctionType{ + model.FunctionTypeSetpointDescriptionListData, + model.FunctionTypeSetpointConstraintsListData, + model.FunctionTypeSetpointListData, + }, + }, + }, + ) + + var err error + s.setpoint, err = NewSetpoint(s.localEntity, nil) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), s.setpoint) + + s.setpoint, err = NewSetpoint(s.localEntity, s.remoteEntity) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), s.setpoint) +} + +func (s *SetpointSuite) Test_RequestSetpointDescriptions() { + counter, err := s.setpoint.RequestSetpointDescriptions(nil, nil) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), counter) +} + +func (s *SetpointSuite) Test_RequestSetpointConstraints() { + counter, err := s.setpoint.RequestSetpointConstraints(nil, nil) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), counter) +} + +func (s *SetpointSuite) Test_RequestSetpoints() { + counter, err := s.setpoint.RequestSetpoints(nil, nil) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), counter) +} + +func (s *SetpointSuite) Test_WriteSetpointListData() { + counter, err := s.setpoint.WriteSetpointListData(nil) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), counter) + + data := []model.SetpointDataType{} + counter, err = s.setpoint.WriteSetpointListData(data) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), counter) + + data = []model.SetpointDataType{ + { + SetpointId: util.Ptr(model.SetpointIdType(1)), + Value: model.NewScaledNumberType(21), + }, + } + counter, err = s.setpoint.WriteSetpointListData(data) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), counter) +} diff --git a/features/internal/hvac.go b/features/internal/hvac.go new file mode 100644 index 00000000..82c56235 --- /dev/null +++ b/features/internal/hvac.go @@ -0,0 +1,174 @@ +package internal + +import ( + "github.com/enbility/eebus-go/api" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" +) + +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, + } +} + +// GetHvacSystemFunctionDescriptionsForFilter 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 +} + +// GetHvacSystemFunctionDescriptionForId returns the system function description for a given system function ID +func (h *HvacCommon) GetHvacSystemFunctionDescriptionForId( + id model.HvacSystemFunctionIdType, +) (*model.HvacSystemFunctionDescriptionDataType, error) { + filter := model.HvacSystemFunctionDescriptionDataType{ + SystemFunctionId: &id, + } + + result, err := h.GetHvacSystemFunctionDescriptionsForFilter(filter) + if err != nil || len(result) == 0 { + return nil, api.ErrDataNotAvailable + } + + return util.Ptr(result[0]), nil +} + +// GetHvacSystemFunctionsForFilter returns the system function data for a given filter +func (h *HvacCommon) GetHvacSystemFunctionsForFilter( + filter model.HvacSystemFunctionDataType, +) ([]model.HvacSystemFunctionDataType, error) { + function := model.FunctionTypeHvacSystemFunctionListData + + data, err := featureDataCopyOfType[model.HvacSystemFunctionListDataType](h.featureLocal, h.featureRemote, function) + if err != nil || data == nil || data.HvacSystemFunctionData == nil { + return nil, api.ErrDataNotAvailable + } + + result := searchFilterInList[model.HvacSystemFunctionDataType](data.HvacSystemFunctionData, filter) + + return result, nil +} + +// GetHvacSystemFunctionForId returns the system function data for a given system function ID +func (h *HvacCommon) GetHvacSystemFunctionForId( + id model.HvacSystemFunctionIdType, +) (*model.HvacSystemFunctionDataType, error) { + filter := model.HvacSystemFunctionDataType{ + SystemFunctionId: &id, + } + + result, err := h.GetHvacSystemFunctionsForFilter(filter) + if err != nil || len(result) == 0 { + return nil, api.ErrDataNotAvailable + } + + return util.Ptr(result[0]), nil +} + +// GetHvacOperationModeDescriptionsForFilter returns the operation mode descriptions for a given filter +func (h *HvacCommon) GetHvacOperationModeDescriptionsForFilter( + filter model.HvacOperationModeDescriptionDataType, +) ([]model.HvacOperationModeDescriptionDataType, error) { + function := model.FunctionTypeHvacOperationModeDescriptionListData + + data, err := featureDataCopyOfType[model.HvacOperationModeDescriptionListDataType](h.featureLocal, h.featureRemote, function) + if err != nil || data == nil || data.HvacOperationModeDescriptionData == nil { + return nil, api.ErrDataNotAvailable + } + + result := searchFilterInList[model.HvacOperationModeDescriptionDataType](data.HvacOperationModeDescriptionData, filter) + + return result, nil +} + +// GetHvacOperationModeDescriptionForId returns the operation mode description for a given operation mode ID +func (h *HvacCommon) GetHvacOperationModeDescriptionForId( + id model.HvacOperationModeIdType, +) (*model.HvacOperationModeDescriptionDataType, error) { + filter := model.HvacOperationModeDescriptionDataType{ + OperationModeId: &id, + } + + result, err := h.GetHvacOperationModeDescriptionsForFilter(filter) + if err != nil || len(result) == 0 { + return nil, api.ErrDataNotAvailable + } + + return util.Ptr(result[0]), nil +} + +// GetHvacSystemFunctionOperationModeRelationsForFilter returns the system function +// operation mode relations for a given filter +func (h *HvacCommon) GetHvacSystemFunctionOperationModeRelationsForFilter( + filter model.HvacSystemFunctionOperationModeRelationDataType, +) ([]model.HvacSystemFunctionOperationModeRelationDataType, error) { + function := model.FunctionTypeHvacSystemFunctionOperationModeRelationListData + + data, err := featureDataCopyOfType[model.HvacSystemFunctionOperationModeRelationListDataType](h.featureLocal, h.featureRemote, function) + if err != nil || data == nil || data.HvacSystemFunctionOperationModeRelationData == nil { + return nil, api.ErrDataNotAvailable + } + + result := searchFilterInList[model.HvacSystemFunctionOperationModeRelationDataType](data.HvacSystemFunctionOperationModeRelationData, filter) + + return result, nil +} + +// GetHvacSystemFunctionSetpointRelationsForFilter returns the system function +// setpoint relations for a given filter +func (h *HvacCommon) GetHvacSystemFunctionSetpointRelationsForFilter( + filter model.HvacSystemFunctionSetpointRelationDataType, +) ([]model.HvacSystemFunctionSetpointRelationDataType, error) { + function := model.FunctionTypeHvacSystemFunctionSetPointRelationListData + + 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) + + return result, nil +} + +// CheckEventPayloadDataForFilter checks if the given event payload data contains +// system function data matching the given filter +func (h *HvacCommon) CheckEventPayloadDataForFilter(payloadData any, filter model.HvacSystemFunctionDataType) bool { + if payloadData == nil { + return false + } + + data, ok := payloadData.(*model.HvacSystemFunctionListDataType) + if !ok || data.HvacSystemFunctionData == nil { + return false + } + + result := searchFilterInList[model.HvacSystemFunctionDataType](data.HvacSystemFunctionData, filter) + + return len(result) > 0 +} diff --git a/features/internal/hvac_test.go b/features/internal/hvac_test.go new file mode 100644 index 00000000..7cc62bfe --- /dev/null +++ b/features/internal/hvac_test.go @@ -0,0 +1,336 @@ +package internal_test + +import ( + "testing" + + "github.com/enbility/eebus-go/features/internal" + shipmocks "github.com/enbility/ship-go/mocks" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +func TestHvacSuite(t *testing.T) { + suite.Run(t, new(HvacSuite)) +} + +type HvacSuite struct { + suite.Suite + + localEntity spineapi.EntityLocalInterface + remoteEntity spineapi.EntityRemoteInterface + + localFeature spineapi.FeatureLocalInterface + remoteFeature spineapi.FeatureRemoteInterface + + localSut, + remoteSut *internal.HvacCommon +} + +func (s *HvacSuite) BeforeTest(suiteName, testName string) { + mockWriter := shipmocks.NewShipConnectionDataWriterInterface(s.T()) + mockWriter.EXPECT().WriteShipMessageWithPayload(mock.Anything).Return().Maybe() + + s.localEntity, s.remoteEntity = setupFeatures( + s.T(), + mockWriter, + []featureFunctions{ + { + featureType: model.FeatureTypeTypeHvac, + functions: []model.FunctionType{ + model.FunctionTypeHvacSystemFunctionDescriptionListData, + model.FunctionTypeHvacSystemFunctionListData, + model.FunctionTypeHvacOperationModeDescriptionListData, + model.FunctionTypeHvacSystemFunctionOperationModeRelationListData, + model.FunctionTypeHvacSystemFunctionSetPointRelationListData, + }, + }, + }, + ) + + s.localFeature = s.localEntity.FeatureOfTypeAndRole(model.FeatureTypeTypeHvac, model.RoleTypeServer) + assert.NotNil(s.T(), s.localFeature) + s.localSut = internal.NewLocalHvac(s.localFeature) + assert.NotNil(s.T(), s.localSut) + + s.remoteFeature = s.remoteEntity.FeatureOfTypeAndRole(model.FeatureTypeTypeHvac, model.RoleTypeServer) + assert.NotNil(s.T(), s.remoteFeature) + s.remoteSut = internal.NewRemoteHvac(s.remoteFeature) + assert.NotNil(s.T(), s.remoteSut) +} + +func (s *HvacSuite) Test_GetHvacSystemFunctionDescriptions() { + filter := model.HvacSystemFunctionDescriptionDataType{} + data, err := s.localSut.GetHvacSystemFunctionDescriptionsForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + data, err = s.remoteSut.GetHvacSystemFunctionDescriptionsForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + desc, err := s.localSut.GetHvacSystemFunctionDescriptionForId(model.HvacSystemFunctionIdType(1)) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), desc) + + s.addDescriptions() + + data, err = s.localSut.GetHvacSystemFunctionDescriptionsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 2, len(data)) + data, err = s.remoteSut.GetHvacSystemFunctionDescriptionsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 2, len(data)) + + filter.SystemFunctionType = util.Ptr(model.HvacSystemFunctionTypeTypeHeating) + data, err = s.localSut.GetHvacSystemFunctionDescriptionsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 1, len(data)) + + desc, err = s.localSut.GetHvacSystemFunctionDescriptionForId(model.HvacSystemFunctionIdType(1)) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), desc) + desc, err = s.remoteSut.GetHvacSystemFunctionDescriptionForId(model.HvacSystemFunctionIdType(10)) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), desc) +} + +func (s *HvacSuite) Test_GetHvacSystemFunctions() { + filter := model.HvacSystemFunctionDataType{} + data, err := s.localSut.GetHvacSystemFunctionsForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + data, err = s.remoteSut.GetHvacSystemFunctionsForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + function, err := s.localSut.GetHvacSystemFunctionForId(model.HvacSystemFunctionIdType(1)) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), function) + + s.addData() + + data, err = s.localSut.GetHvacSystemFunctionsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 1, len(data)) + data, err = s.remoteSut.GetHvacSystemFunctionsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 1, len(data)) + + function, err = s.localSut.GetHvacSystemFunctionForId(model.HvacSystemFunctionIdType(1)) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), function) + assert.Equal(s.T(), model.HvacOperationModeIdType(2), *function.CurrentOperationModeId) + + function, err = s.remoteSut.GetHvacSystemFunctionForId(model.HvacSystemFunctionIdType(10)) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), function) +} + +func (s *HvacSuite) Test_GetHvacOperationModeDescriptions() { + filter := model.HvacOperationModeDescriptionDataType{} + data, err := s.localSut.GetHvacOperationModeDescriptionsForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + data, err = s.remoteSut.GetHvacOperationModeDescriptionsForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + desc, err := s.localSut.GetHvacOperationModeDescriptionForId(model.HvacOperationModeIdType(1)) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), desc) + + s.addOperationModeDescriptions() + + data, err = s.localSut.GetHvacOperationModeDescriptionsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 2, len(data)) + data, err = s.remoteSut.GetHvacOperationModeDescriptionsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 2, len(data)) + + filter.OperationModeType = util.Ptr(model.HvacOperationModeTypeTypeAuto) + data, err = s.localSut.GetHvacOperationModeDescriptionsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 1, len(data)) + + desc, err = s.localSut.GetHvacOperationModeDescriptionForId(model.HvacOperationModeIdType(1)) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), desc) + desc, err = s.remoteSut.GetHvacOperationModeDescriptionForId(model.HvacOperationModeIdType(10)) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), desc) +} + +func (s *HvacSuite) Test_GetHvacSystemFunctionOperationModeRelations() { + filter := model.HvacSystemFunctionOperationModeRelationDataType{} + data, err := s.localSut.GetHvacSystemFunctionOperationModeRelationsForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + data, err = s.remoteSut.GetHvacSystemFunctionOperationModeRelationsForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + s.addOperationModeRelations() + + data, err = s.localSut.GetHvacSystemFunctionOperationModeRelationsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 2, len(data)) + data, err = s.remoteSut.GetHvacSystemFunctionOperationModeRelationsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 2, len(data)) + + filter.SystemFunctionId = util.Ptr(model.HvacSystemFunctionIdType(1)) + data, err = s.localSut.GetHvacSystemFunctionOperationModeRelationsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 1, len(data)) +} + +func (s *HvacSuite) Test_GetHvacSystemFunctionSetpointRelations() { + filter := model.HvacSystemFunctionSetpointRelationDataType{} + data, err := s.localSut.GetHvacSystemFunctionSetpointRelationsForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + data, err = s.remoteSut.GetHvacSystemFunctionSetpointRelationsForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + s.addSetpointRelations() + + data, err = s.localSut.GetHvacSystemFunctionSetpointRelationsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 2, len(data)) + data, err = s.remoteSut.GetHvacSystemFunctionSetpointRelationsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 2, len(data)) + + filter.OperationModeId = util.Ptr(model.HvacOperationModeIdType(1)) + data, err = s.localSut.GetHvacSystemFunctionSetpointRelationsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 1, len(data)) +} + +func (s *HvacSuite) Test_CheckEventPayloadDataForFilter() { + filter := model.HvacSystemFunctionDataType{ + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(1)), + } + + exists := s.localSut.CheckEventPayloadDataForFilter(nil, filter) + assert.False(s.T(), exists) + exists = s.remoteSut.CheckEventPayloadDataForFilter(nil, filter) + assert.False(s.T(), exists) + + temp := true + exists = s.localSut.CheckEventPayloadDataForFilter(temp, filter) + assert.False(s.T(), exists) + + data := &model.HvacSystemFunctionListDataType{} + exists = s.localSut.CheckEventPayloadDataForFilter(data, filter) + assert.False(s.T(), exists) + + data = &model.HvacSystemFunctionListDataType{ + HvacSystemFunctionData: []model.HvacSystemFunctionDataType{ + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(1)), + CurrentOperationModeId: util.Ptr(model.HvacOperationModeIdType(2)), + }, + }, + } + exists = s.localSut.CheckEventPayloadDataForFilter(data, filter) + assert.True(s.T(), exists) + + filter.SystemFunctionId = util.Ptr(model.HvacSystemFunctionIdType(10)) + exists = s.localSut.CheckEventPayloadDataForFilter(data, filter) + assert.False(s.T(), exists) +} + +// helpers + +func (s *HvacSuite) addDescriptions() { + fData := &model.HvacSystemFunctionDescriptionListDataType{ + HvacSystemFunctionDescriptionData: []model.HvacSystemFunctionDescriptionDataType{ + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(1)), + SystemFunctionType: util.Ptr(model.HvacSystemFunctionTypeTypeHeating), + }, + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(2)), + SystemFunctionType: util.Ptr(model.HvacSystemFunctionTypeTypeDhw), + }, + }, + } + _ = s.localFeature.UpdateData(model.FunctionTypeHvacSystemFunctionDescriptionListData, fData, nil, nil) + _, _ = s.remoteFeature.UpdateData(true, model.FunctionTypeHvacSystemFunctionDescriptionListData, fData, nil, nil) +} + +func (s *HvacSuite) addData() { + fData := &model.HvacSystemFunctionListDataType{ + HvacSystemFunctionData: []model.HvacSystemFunctionDataType{ + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(1)), + CurrentOperationModeId: util.Ptr(model.HvacOperationModeIdType(2)), + IsOperationModeIdChangeable: util.Ptr(true), + CurrentSetpointId: util.Ptr(model.SetpointIdType(1)), + IsSetpointIdChangeable: util.Ptr(true), + IsOverrunActive: util.Ptr(false), + }, + }, + } + _ = s.localFeature.UpdateData(model.FunctionTypeHvacSystemFunctionListData, fData, nil, nil) + _, _ = s.remoteFeature.UpdateData(true, model.FunctionTypeHvacSystemFunctionListData, fData, nil, nil) +} + +func (s *HvacSuite) addOperationModeDescriptions() { + fData := &model.HvacOperationModeDescriptionListDataType{ + HvacOperationModeDescriptionData: []model.HvacOperationModeDescriptionDataType{ + { + OperationModeId: util.Ptr(model.HvacOperationModeIdType(1)), + OperationModeType: util.Ptr(model.HvacOperationModeTypeTypeAuto), + }, + { + OperationModeId: util.Ptr(model.HvacOperationModeIdType(2)), + OperationModeType: util.Ptr(model.HvacOperationModeTypeTypeOff), + }, + }, + } + _ = s.localFeature.UpdateData(model.FunctionTypeHvacOperationModeDescriptionListData, fData, nil, nil) + _, _ = s.remoteFeature.UpdateData(true, model.FunctionTypeHvacOperationModeDescriptionListData, fData, nil, nil) +} + +func (s *HvacSuite) addOperationModeRelations() { + fData := &model.HvacSystemFunctionOperationModeRelationListDataType{ + HvacSystemFunctionOperationModeRelationData: []model.HvacSystemFunctionOperationModeRelationDataType{ + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(1)), + OperationModeId: []model.HvacOperationModeIdType{1}, + }, + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(2)), + OperationModeId: []model.HvacOperationModeIdType{2}, + }, + }, + } + _ = s.localFeature.UpdateData(model.FunctionTypeHvacSystemFunctionOperationModeRelationListData, fData, nil, nil) + _, _ = s.remoteFeature.UpdateData(true, model.FunctionTypeHvacSystemFunctionOperationModeRelationListData, fData, nil, nil) +} + +func (s *HvacSuite) addSetpointRelations() { + fData := &model.HvacSystemFunctionSetpointRelationListDataType{ + HvacSystemFunctionSetpointRelationData: []model.HvacSystemFunctionSetpointRelationDataType{ + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(1)), + OperationModeId: util.Ptr(model.HvacOperationModeIdType(1)), + SetpointId: []model.SetpointIdType{1}, + }, + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(1)), + OperationModeId: util.Ptr(model.HvacOperationModeIdType(2)), + SetpointId: []model.SetpointIdType{2}, + }, + }, + } + _ = s.localFeature.UpdateData(model.FunctionTypeHvacSystemFunctionSetPointRelationListData, fData, nil, nil) + _, _ = s.remoteFeature.UpdateData(true, model.FunctionTypeHvacSystemFunctionSetPointRelationListData, fData, nil, nil) +} diff --git a/features/internal/setpoint.go b/features/internal/setpoint.go new file mode 100644 index 00000000..85a81110 --- /dev/null +++ b/features/internal/setpoint.go @@ -0,0 +1,140 @@ +package internal + +import ( + "github.com/enbility/eebus-go/api" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" +) + +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, + } +} + +// GetSetpointDescriptionsForFilter returns the setpoint descriptions for a given filter +func (s *SetpointCommon) GetSetpointDescriptionsForFilter( + filter model.SetpointDescriptionDataType, +) ([]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 + } + + result := searchFilterInList[model.SetpointDescriptionDataType](data.SetpointDescriptionData, filter) + + return result, nil +} + +// GetSetpointDescriptionForId returns the setpoint description for a given setpoint ID +func (s *SetpointCommon) GetSetpointDescriptionForId( + id model.SetpointIdType, +) (*model.SetpointDescriptionDataType, error) { + filter := model.SetpointDescriptionDataType{ + SetpointId: &id, + } + + result, err := s.GetSetpointDescriptionsForFilter(filter) + if err != nil || len(result) == 0 { + return nil, api.ErrDataNotAvailable + } + + return util.Ptr(result[0]), nil +} + +// 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 +} + +// 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 +} + +// 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 +} + +// 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 +} + +// CheckEventPayloadDataForFilter checks if the given event payload data contains +// setpoint data for the given setpoint ID +func (s *SetpointCommon) CheckEventPayloadDataForFilter(payloadData any, filter model.SetpointDataType) bool { + if payloadData == nil { + return false + } + + data, ok := payloadData.(*model.SetpointListDataType) + if !ok || data.SetpointData == nil { + return false + } + + result := searchFilterInList[model.SetpointDataType](data.SetpointData, filter) + + return len(result) > 0 +} diff --git a/features/internal/setpoint_test.go b/features/internal/setpoint_test.go new file mode 100644 index 00000000..b5c12dd7 --- /dev/null +++ b/features/internal/setpoint_test.go @@ -0,0 +1,252 @@ +package internal_test + +import ( + "testing" + + "github.com/enbility/eebus-go/features/internal" + shipmocks "github.com/enbility/ship-go/mocks" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +func TestSetpointSuite(t *testing.T) { + suite.Run(t, new(SetpointSuite)) +} + +type SetpointSuite struct { + suite.Suite + + localEntity spineapi.EntityLocalInterface + remoteEntity spineapi.EntityRemoteInterface + + localFeature spineapi.FeatureLocalInterface + remoteFeature spineapi.FeatureRemoteInterface + + localSut, + remoteSut *internal.SetpointCommon +} + +func (s *SetpointSuite) BeforeTest(suiteName, testName string) { + mockWriter := shipmocks.NewShipConnectionDataWriterInterface(s.T()) + mockWriter.EXPECT().WriteShipMessageWithPayload(mock.Anything).Return().Maybe() + + s.localEntity, s.remoteEntity = setupFeatures( + s.T(), + mockWriter, + []featureFunctions{ + { + featureType: model.FeatureTypeTypeSetpoint, + functions: []model.FunctionType{ + model.FunctionTypeSetpointDescriptionListData, + model.FunctionTypeSetpointConstraintsListData, + model.FunctionTypeSetpointListData, + }, + }, + }, + ) + + s.localFeature = s.localEntity.FeatureOfTypeAndRole(model.FeatureTypeTypeSetpoint, model.RoleTypeServer) + assert.NotNil(s.T(), s.localFeature) + s.localSut = internal.NewLocalSetpoint(s.localFeature) + assert.NotNil(s.T(), s.localSut) + + s.remoteFeature = s.remoteEntity.FeatureOfTypeAndRole(model.FeatureTypeTypeSetpoint, model.RoleTypeServer) + assert.NotNil(s.T(), s.remoteFeature) + s.remoteSut = internal.NewRemoteSetpoint(s.remoteFeature) + assert.NotNil(s.T(), s.remoteSut) +} + +func (s *SetpointSuite) Test_GetSetpointDescriptions() { + filter := model.SetpointDescriptionDataType{} + data, err := s.localSut.GetSetpointDescriptionsForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + data, err = s.remoteSut.GetSetpointDescriptionsForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + desc, err := s.localSut.GetSetpointDescriptionForId(model.SetpointIdType(0)) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), desc) + + s.addDescriptions() + + data, err = s.localSut.GetSetpointDescriptionsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 2, len(data)) + data, err = s.remoteSut.GetSetpointDescriptionsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 2, len(data)) + + filter.ScopeType = util.Ptr(model.ScopeTypeTypeDhwTemperature) + data, err = s.localSut.GetSetpointDescriptionsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 1, len(data)) + + desc, err = s.localSut.GetSetpointDescriptionForId(model.SetpointIdType(0)) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), desc) + desc, err = s.remoteSut.GetSetpointDescriptionForId(model.SetpointIdType(10)) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), desc) +} + +func (s *SetpointSuite) Test_GetSetpointData() { + filter := model.SetpointDataType{} + data, err := s.localSut.GetSetpointDataForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + data, err = s.remoteSut.GetSetpointDataForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + sp, err := s.localSut.GetSetpointForId(model.SetpointIdType(0)) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), sp) + + s.addData() + + data, err = s.localSut.GetSetpointDataForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 2, len(data)) + data, err = s.remoteSut.GetSetpointDataForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 2, len(data)) + + sp, err = s.localSut.GetSetpointForId(model.SetpointIdType(0)) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), sp) + assert.Equal(s.T(), 21.0, sp.Value.GetValue()) + + sp, err = s.remoteSut.GetSetpointForId(model.SetpointIdType(10)) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), sp) +} + +func (s *SetpointSuite) Test_GetSetpointConstraints() { + filter := model.SetpointConstraintsDataType{} + data, err := s.localSut.GetSetpointConstraintsForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + data, err = s.remoteSut.GetSetpointConstraintsForFilter(filter) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + constraints, err := s.localSut.GetSetpointConstraintsForId(model.SetpointIdType(0)) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), constraints) + + s.addConstraints() + + data, err = s.localSut.GetSetpointConstraintsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 1, len(data)) + data, err = s.remoteSut.GetSetpointConstraintsForFilter(filter) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 1, len(data)) + + constraints, err = s.localSut.GetSetpointConstraintsForId(model.SetpointIdType(0)) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), constraints) + assert.Equal(s.T(), 16.0, constraints.SetpointRangeMin.GetValue()) + assert.Equal(s.T(), 25.0, constraints.SetpointRangeMax.GetValue()) + + constraints, err = s.remoteSut.GetSetpointConstraintsForId(model.SetpointIdType(10)) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), constraints) +} + +func (s *SetpointSuite) Test_CheckEventPayloadDataForFilter() { + filter := model.SetpointDataType{ + SetpointId: util.Ptr(model.SetpointIdType(0)), + } + + exists := s.localSut.CheckEventPayloadDataForFilter(nil, filter) + assert.False(s.T(), exists) + exists = s.remoteSut.CheckEventPayloadDataForFilter(nil, filter) + assert.False(s.T(), exists) + + temp := true + exists = s.localSut.CheckEventPayloadDataForFilter(temp, filter) + assert.False(s.T(), exists) + + data := &model.SetpointListDataType{} + exists = s.localSut.CheckEventPayloadDataForFilter(data, filter) + assert.False(s.T(), exists) + + data = &model.SetpointListDataType{ + SetpointData: []model.SetpointDataType{ + { + SetpointId: util.Ptr(model.SetpointIdType(0)), + Value: model.NewScaledNumberType(21), + }, + }, + } + exists = s.localSut.CheckEventPayloadDataForFilter(data, filter) + assert.True(s.T(), exists) + + filter.SetpointId = util.Ptr(model.SetpointIdType(10)) + exists = s.localSut.CheckEventPayloadDataForFilter(data, filter) + assert.False(s.T(), exists) +} + +// helpers + +func (s *SetpointSuite) addDescriptions() { + fData := &model.SetpointDescriptionListDataType{ + SetpointDescriptionData: []model.SetpointDescriptionDataType{ + { + SetpointId: util.Ptr(model.SetpointIdType(0)), + SetpointType: util.Ptr(model.SetpointTypeTypeValueAbsolute), + ScopeType: util.Ptr(model.ScopeTypeTypeDhwTemperature), + Unit: util.Ptr(model.UnitOfMeasurementTypedegC), + }, + { + SetpointId: util.Ptr(model.SetpointIdType(1)), + SetpointType: util.Ptr(model.SetpointTypeTypeValueAbsolute), + ScopeType: util.Ptr(model.ScopeTypeTypeRoomAirTemperature), + Unit: util.Ptr(model.UnitOfMeasurementTypedegC), + }, + }, + } + _ = s.localFeature.UpdateData(model.FunctionTypeSetpointDescriptionListData, fData, nil, nil) + _, _ = s.remoteFeature.UpdateData(true, model.FunctionTypeSetpointDescriptionListData, fData, nil, nil) +} + +func (s *SetpointSuite) addData() { + fData := &model.SetpointListDataType{ + SetpointData: []model.SetpointDataType{ + { + SetpointId: util.Ptr(model.SetpointIdType(0)), + Value: model.NewScaledNumberType(21), + IsSetpointChangeable: util.Ptr(true), + }, + { + SetpointId: util.Ptr(model.SetpointIdType(1)), + Value: model.NewScaledNumberType(45), + IsSetpointActive: util.Ptr(true), + }, + }, + } + _ = s.localFeature.UpdateData(model.FunctionTypeSetpointListData, fData, nil, nil) + _, _ = s.remoteFeature.UpdateData(true, model.FunctionTypeSetpointListData, fData, nil, nil) +} + +func (s *SetpointSuite) addConstraints() { + fData := &model.SetpointConstraintsListDataType{ + SetpointConstraintsData: []model.SetpointConstraintsDataType{ + { + SetpointId: util.Ptr(model.SetpointIdType(0)), + SetpointRangeMin: model.NewScaledNumberType(16), + SetpointRangeMax: model.NewScaledNumberType(25), + SetpointStepSize: model.NewScaledNumberType(0.5), + }, + }, + } + _ = s.localFeature.UpdateData(model.FunctionTypeSetpointConstraintsListData, fData, nil, nil) + _, _ = s.remoteFeature.UpdateData(true, model.FunctionTypeSetpointConstraintsListData, fData, nil, nil) +} From 8f1ca7a05efe094accca8c0eda93f83130c7b66b Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 17 Jul 2026 12:24:10 +0200 Subject: [PATCH 2/7] Add shared HVAC use case API types Co-Authored-By: Claude Fable 5 --- usecases/api/types.go | 46 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/usecases/api/types.go b/usecases/api/types.go index dc125fad..1a287a76 100644 --- a/usecases/api/types.go +++ b/usecases/api/types.go @@ -194,3 +194,49 @@ type PendingDeviceConfiguration struct { Value *model.DeviceConfigurationKeyValueValueType `json:"value,omitempty"` IsValueChangeable *bool `json:"isValueChangeable,omitempty"` } + +// operation mode of an HVAC system function +type HvacOperationModeType string + +const ( + HvacOperationModeTypeAuto HvacOperationModeType = "auto" + HvacOperationModeTypeOn HvacOperationModeType = "on" + HvacOperationModeTypeOff HvacOperationModeType = "off" + HvacOperationModeTypeEco HvacOperationModeType = "eco" +) + +// HVAC temperature setpoint, e.g. for a room or domestic hot water +type Setpoint struct { + // the setpoint identifier + Id uint + + // the setpoint temperature value + Value float64 + + // the minimum allowed temperature value + MinValue float64 + + // the maximum allowed temperature value + MaxValue float64 + + // whether the setpoint is currently active + IsActive bool + + // whether the setpoint may be changed by a client + IsChangeable bool +} + +// constraints for an HVAC temperature setpoint +type SetpointConstraints struct { + // the setpoint identifier + Id uint + + // the minimum allowed temperature value + MinValue float64 + + // the maximum allowed temperature value + MaxValue float64 + + // the step size for temperature value changes + StepSize float64 +} From 6ba4e02f783dce159e6a95667f051e0d8e94ab7d Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 17 Jul 2026 12:36:06 +0200 Subject: [PATCH 3/7] Add Configuration of Room Cooling Temperature (CRCT) use case Co-Authored-By: Claude Fable 5 --- usecases/api/ca_crct.go | 47 +++ usecases/ca/crct/events.go | 117 ++++++ usecases/ca/crct/events_test.go | 102 +++++ usecases/ca/crct/public.go | 229 +++++++++++ usecases/ca/crct/public_test.go | 175 +++++++++ usecases/ca/crct/testhelper_test.go | 179 +++++++++ usecases/ca/crct/types.go | 24 ++ usecases/ca/crct/usecase.go | 74 ++++ usecases/ca/crct/usecase_test.go | 5 + usecases/mocks/CaCRCTInterface.go | 583 ++++++++++++++++++++++++++++ 10 files changed, 1535 insertions(+) create mode 100644 usecases/api/ca_crct.go create mode 100644 usecases/ca/crct/events.go create mode 100644 usecases/ca/crct/events_test.go create mode 100644 usecases/ca/crct/public.go create mode 100644 usecases/ca/crct/public_test.go create mode 100644 usecases/ca/crct/testhelper_test.go create mode 100644 usecases/ca/crct/types.go create mode 100644 usecases/ca/crct/usecase.go create mode 100644 usecases/ca/crct/usecase_test.go create mode 100644 usecases/mocks/CaCRCTInterface.go diff --git a/usecases/api/ca_crct.go b/usecases/api/ca_crct.go new file mode 100644 index 00000000..31f92551 --- /dev/null +++ b/usecases/api/ca_crct.go @@ -0,0 +1,47 @@ +package api + +import ( + "github.com/enbility/eebus-go/api" + spineapi "github.com/enbility/spine-go/api" +) + +// Actor: Configuration Appliance +// UseCase: Configuration of Room Cooling Temperature +type CaCRCTInterface interface { + api.UseCaseInterface + + // Scenario 1 + + // return the current room cooling temperature setpoints + // + // parameters: + // - entity: the entity of the HVAC room + // + // possible errors: + // - ErrDataNotAvailable if no such data is (yet) available + // - and others + Setpoints(entity spineapi.EntityRemoteInterface) ([]Setpoint, error) + + // return the constraints for the room cooling temperature setpoints + // + // parameters: + // - entity: the entity of the HVAC room + // + // possible errors: + // - ErrDataNotAvailable if no such data is (yet) available + // - and others + SetpointConstraints(entity spineapi.EntityRemoteInterface) ([]SetpointConstraints, error) + + // write the room cooling temperature setpoint for a cooling operation mode + // + // parameters: + // - entity: the entity of the HVAC room + // - mode: the cooling operation mode the setpoint is used for (on, off or eco) + // - degC: the temperature setpoint in degree Celsius + // + // possible errors: + // - ErrNotSupported if the setpoint is not changeable or the mode is auto + // - ErrDataNotAvailable if the required data is not (yet) available + // - and others + WriteSetpoint(entity spineapi.EntityRemoteInterface, mode HvacOperationModeType, degC float64) error +} diff --git a/usecases/ca/crct/events.go b/usecases/ca/crct/events.go new file mode 100644 index 00000000..cde1cb46 --- /dev/null +++ b/usecases/ca/crct/events.go @@ -0,0 +1,117 @@ +package crct + +import ( + "github.com/enbility/eebus-go/features/client" + internal "github.com/enbility/eebus-go/usecases/internal" + "github.com/enbility/ship-go/logging" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" +) + +// handle SPINE events +func (e *CRCT) HandleEvent(payload spineapi.EventPayload) { + if !e.IsCompatibleEntityType(payload.Entity) { + return + } + + if internal.IsEntityAdded(payload) { + e.hvacRoomConnected(payload.Entity) + return + } + + if payload.EventType != spineapi.EventTypeDataChange || + payload.ChangeType != spineapi.ElementChangeUpdate { + return + } + + switch payload.Data.(type) { + case *model.HvacSystemFunctionDescriptionListDataType: + e.hvacSystemFunctionDescriptionDataUpdate(payload.Entity) + + case *model.SetpointDescriptionListDataType: + e.setpointDescriptionDataUpdate(payload.Entity) + + case *model.SetpointListDataType: + e.setpointDataUpdate(payload) + + case *model.SetpointConstraintsListDataType: + e.setpointConstraintsDataUpdate(payload) + } +} + +// process required steps when an HVAC room device entity is connected +func (e *CRCT) hvacRoomConnected(entity spineapi.EntityRemoteInterface) { + if hvac, err := client.NewHvac(e.LocalEntity, entity); err == nil { + if !hvac.HasSubscription() { + if _, err := hvac.Subscribe(); err != nil { + logging.Log().Error(err) + } + } + + if _, err := hvac.RequestHvacSystemFunctionDescriptions(nil, nil); err != nil { + logging.Log().Error(err) + } + + if _, err := hvac.RequestHvacOperationModeDescriptions(nil, nil); err != nil { + logging.Log().Error(err) + } + } + + if setpoint, err := client.NewSetpoint(e.LocalEntity, entity); err == nil { + if !setpoint.HasSubscription() { + if _, err := setpoint.Subscribe(); err != nil { + logging.Log().Error(err) + } + } + + selector := &model.SetpointDescriptionListDataSelectorsType{ + ScopeType: util.Ptr(model.ScopeTypeTypeRoomAirTemperature), + } + if _, err := setpoint.RequestSetpointDescriptions(selector, nil); err != nil { + logging.Log().Error(err) + } + } +} + +// the HVAC system function description data of a device was updated +func (e *CRCT) hvacSystemFunctionDescriptionDataUpdate(entity spineapi.EntityRemoteInterface) { + if hvac, err := client.NewHvac(e.LocalEntity, entity); err == nil { + // system function descriptions received, now get the setpoint relations + if _, err := hvac.RequestHvacSystemFunctionSetpointRelations(nil, nil); err != nil { + logging.Log().Error(err) + } + } +} + +// the setpoint description data of a device was updated +func (e *CRCT) setpointDescriptionDataUpdate(entity spineapi.EntityRemoteInterface) { + if setpoint, err := client.NewSetpoint(e.LocalEntity, entity); err == nil { + // setpoint descriptions received, now get the constraints and data + if _, err := setpoint.RequestSetpointConstraints(nil, nil); err != nil { + logging.Log().Error(err) + } + + if _, err := setpoint.RequestSetpoints(nil, nil); err != nil { + logging.Log().Error(err) + } + } +} + +// the setpoint data of a device was updated +func (e *CRCT) setpointDataUpdate(payload spineapi.EventPayload) { + if setpoint, err := client.NewSetpoint(e.LocalEntity, payload.Entity); err == nil { + filter := model.SetpointDataType{} + if setpoint.CheckEventPayloadDataForFilter(payload.Data, filter) && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateSetpoints) + } + } +} + +// the setpoint constraints data of a device was updated +func (e *CRCT) setpointConstraintsDataUpdate(payload spineapi.EventPayload) { + if data, ok := payload.Data.(*model.SetpointConstraintsListDataType); ok && + len(data.SetpointConstraintsData) > 0 && e.EventCB != nil { + e.EventCB(payload.Ski, payload.Device, payload.Entity, DataUpdateSetpointConstraints) + } +} diff --git a/usecases/ca/crct/events_test.go b/usecases/ca/crct/events_test.go new file mode 100644 index 00000000..f013fb00 --- /dev/null +++ b/usecases/ca/crct/events_test.go @@ -0,0 +1,102 @@ +package crct + +import ( + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" + "github.com/stretchr/testify/assert" +) + +func (s *CaCRCTSuite) Test_Events() { + payload := spineapi.EventPayload{ + Entity: s.mockRemoteEntity, + } + s.sut.HandleEvent(payload) + + payload.Entity = s.hvacRoomEntity + s.sut.HandleEvent(payload) + + payload.EventType = spineapi.EventTypeEntityChange + payload.ChangeType = spineapi.ElementChangeAdd + s.sut.HandleEvent(payload) + + payload.ChangeType = spineapi.ElementChangeRemove + s.sut.HandleEvent(payload) + + payload.EventType = spineapi.EventTypeDataChange + payload.ChangeType = spineapi.ElementChangeAdd + s.sut.HandleEvent(payload) + + payload.EventType = spineapi.EventTypeDataChange + payload.ChangeType = spineapi.ElementChangeUpdate + payload.Data = util.Ptr(model.HvacSystemFunctionDescriptionListDataType{}) + s.sut.HandleEvent(payload) + + payload.Data = util.Ptr(model.SetpointDescriptionListDataType{}) + s.sut.HandleEvent(payload) + + payload.Data = util.Ptr(model.SetpointListDataType{}) + s.sut.HandleEvent(payload) + + payload.Data = util.Ptr(model.SetpointConstraintsListDataType{}) + s.sut.HandleEvent(payload) + + payload.Data = util.Ptr(model.NodeManagementUseCaseDataType{}) + s.sut.HandleEvent(payload) +} + +func (s *CaCRCTSuite) Test_Failures() { + s.sut.hvacRoomConnected(s.mockRemoteEntity) + + s.sut.hvacSystemFunctionDescriptionDataUpdate(s.mockRemoteEntity) + + s.sut.setpointDescriptionDataUpdate(s.mockRemoteEntity) +} + +func (s *CaCRCTSuite) Test_setpointDataUpdate() { + payload := spineapi.EventPayload{ + Ski: remoteSki, + Entity: s.hvacRoomEntity, + } + s.sut.setpointDataUpdate(payload) + assert.False(s.T(), s.eventCalled) + + payload.Data = util.Ptr(model.SetpointListDataType{}) + s.sut.setpointDataUpdate(payload) + assert.False(s.T(), s.eventCalled) + + payload.Data = util.Ptr(model.SetpointListDataType{ + SetpointData: []model.SetpointDataType{ + { + SetpointId: util.Ptr(model.SetpointIdType(1)), + Value: model.NewScaledNumberType(21), + }, + }, + }) + s.sut.setpointDataUpdate(payload) + assert.True(s.T(), s.eventCalled) +} + +func (s *CaCRCTSuite) Test_setpointConstraintsDataUpdate() { + payload := spineapi.EventPayload{ + Ski: remoteSki, + Entity: s.hvacRoomEntity, + } + s.sut.setpointConstraintsDataUpdate(payload) + assert.False(s.T(), s.eventCalled) + + payload.Data = util.Ptr(model.SetpointConstraintsListDataType{}) + s.sut.setpointConstraintsDataUpdate(payload) + assert.False(s.T(), s.eventCalled) + + payload.Data = util.Ptr(model.SetpointConstraintsListDataType{ + SetpointConstraintsData: []model.SetpointConstraintsDataType{ + { + SetpointId: util.Ptr(model.SetpointIdType(1)), + SetpointRangeMin: model.NewScaledNumberType(16), + }, + }, + }) + s.sut.setpointConstraintsDataUpdate(payload) + assert.True(s.T(), s.eventCalled) +} diff --git a/usecases/ca/crct/public.go b/usecases/ca/crct/public.go new file mode 100644 index 00000000..9d3b1d2d --- /dev/null +++ b/usecases/ca/crct/public.go @@ -0,0 +1,229 @@ +package crct + +import ( + "github.com/enbility/eebus-go/api" + "github.com/enbility/eebus-go/features/client" + ucapi "github.com/enbility/eebus-go/usecases/api" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" +) + +// Scenario 1 + +// return the current room cooling temperature setpoints of the HVAC room entity, +// returns ErrDataNotAvailable if no such data is (yet) available +func (e *CRCT) Setpoints(entity spineapi.EntityRemoteInterface) ([]ucapi.Setpoint, error) { + if !e.IsCompatibleEntityType(entity) { + return nil, api.ErrNoCompatibleEntity + } + + setpointIds, err := e.setpointIds(entity) + if err != nil { + return nil, err + } + + sp, err := client.NewSetpoint(e.LocalEntity, entity) + if err != nil { + return nil, err + } + + setpoints := make([]ucapi.Setpoint, 0) + for _, id := range setpointIds { + data, err := sp.GetSetpointForId(id) + if err != nil { + continue + } + + setpoint := ucapi.Setpoint{ + Id: uint(id), + // if absent, the setpoint is active and changeable + IsActive: data.IsSetpointActive == nil || *data.IsSetpointActive, + IsChangeable: data.IsSetpointChangeable == nil || *data.IsSetpointChangeable, + } + if data.Value != nil { + setpoint.Value = data.Value.GetValue() + } + if data.ValueMin != nil { + setpoint.MinValue = data.ValueMin.GetValue() + } + if data.ValueMax != nil { + setpoint.MaxValue = data.ValueMax.GetValue() + } + + setpoints = append(setpoints, setpoint) + } + + if len(setpoints) == 0 { + return nil, api.ErrDataNotAvailable + } + + return setpoints, nil +} + +// return the constraints for the room cooling temperature setpoints, +// returns ErrDataNotAvailable if no such data is (yet) available +func (e *CRCT) SetpointConstraints(entity spineapi.EntityRemoteInterface) ([]ucapi.SetpointConstraints, error) { + if !e.IsCompatibleEntityType(entity) { + return nil, api.ErrNoCompatibleEntity + } + + setpointIds, err := e.setpointIds(entity) + if err != nil { + return nil, err + } + + sp, err := client.NewSetpoint(e.LocalEntity, entity) + if err != nil { + return nil, err + } + + constraints := make([]ucapi.SetpointConstraints, 0) + for _, id := range setpointIds { + data, err := sp.GetSetpointConstraintsForId(id) + if err != nil { + continue + } + + constraint := ucapi.SetpointConstraints{ + Id: uint(id), + } + if data.SetpointRangeMin != nil { + constraint.MinValue = data.SetpointRangeMin.GetValue() + } + if data.SetpointRangeMax != nil { + constraint.MaxValue = data.SetpointRangeMax.GetValue() + } + if data.SetpointStepSize != nil { + constraint.StepSize = data.SetpointStepSize.GetValue() + } + + constraints = append(constraints, constraint) + } + + if len(constraints) == 0 { + return nil, api.ErrDataNotAvailable + } + + return constraints, nil +} + +// write the room cooling temperature setpoint in degree Celsius for a cooling +// operation mode (on, off or eco), returns ErrNotSupported if it is not changeable +func (e *CRCT) WriteSetpoint( + entity spineapi.EntityRemoteInterface, + mode ucapi.HvacOperationModeType, + degC float64, +) error { + if !e.IsCompatibleEntityType(entity) { + return api.ErrNoCompatibleEntity + } + + // the setpoints of the "auto" mode are controlled by a timetable of the device + if mode == ucapi.HvacOperationModeTypeAuto { + return api.ErrNotSupported + } + + setpointId, err := e.setpointIdForMode(entity, mode) + if err != nil { + return err + } + + sp, err := client.NewSetpoint(e.LocalEntity, entity) + if err != nil { + return err + } + + if data, err := sp.GetSetpointForId(setpointId); err == nil && + data.IsSetpointChangeable != nil && !*data.IsSetpointChangeable { + return api.ErrNotSupported + } + + data := []model.SetpointDataType{ + { + SetpointId: util.Ptr(setpointId), + Value: model.NewScaledNumberType(degC), + }, + } + + _, err = sp.WriteSetpointListData(data) + + return err +} + +// return the ids of the setpoints related to the cooling system function +func (e *CRCT) setpointIds(entity spineapi.EntityRemoteInterface) ([]model.SetpointIdType, error) { + relations, err := e.setpointRelations(entity) + if err != nil { + return nil, err + } + + var ids []model.SetpointIdType + for _, relation := range relations { + ids = append(ids, relation.SetpointId...) + } + + if len(ids) == 0 { + return nil, api.ErrDataNotAvailable + } + + return ids, nil +} + +// return the id of the setpoint related to the given cooling operation mode +func (e *CRCT) setpointIdForMode( + entity spineapi.EntityRemoteInterface, + mode ucapi.HvacOperationModeType, +) (model.SetpointIdType, error) { + hvac, err := client.NewHvac(e.LocalEntity, entity) + if err != nil { + return 0, err + } + + modeFilter := model.HvacOperationModeDescriptionDataType{ + OperationModeType: util.Ptr(model.HvacOperationModeTypeType(mode)), + } + modeDescriptions, err := hvac.GetHvacOperationModeDescriptionsForFilter(modeFilter) + if err != nil || len(modeDescriptions) == 0 || modeDescriptions[0].OperationModeId == nil { + return 0, api.ErrDataNotAvailable + } + + relations, err := e.setpointRelations(entity) + if err != nil { + return 0, err + } + + for _, relation := range relations { + if relation.OperationModeId != nil && + *relation.OperationModeId == *modeDescriptions[0].OperationModeId && + len(relation.SetpointId) == 1 { + return relation.SetpointId[0], nil + } + } + + return 0, api.ErrDataNotAvailable +} + +// return the setpoint relations of the cooling system function +func (e *CRCT) setpointRelations( + entity spineapi.EntityRemoteInterface, +) ([]model.HvacSystemFunctionSetpointRelationDataType, error) { + hvac, err := client.NewHvac(e.LocalEntity, entity) + if err != nil { + return nil, err + } + + descFilter := model.HvacSystemFunctionDescriptionDataType{ + SystemFunctionType: util.Ptr(model.HvacSystemFunctionTypeTypeCooling), + } + descriptions, err := hvac.GetHvacSystemFunctionDescriptionsForFilter(descFilter) + if err != nil || len(descriptions) == 0 || descriptions[0].SystemFunctionId == nil { + return nil, api.ErrDataNotAvailable + } + + relationFilter := model.HvacSystemFunctionSetpointRelationDataType{ + SystemFunctionId: descriptions[0].SystemFunctionId, + } + + return hvac.GetHvacSystemFunctionSetpointRelationsForFilter(relationFilter) +} diff --git a/usecases/ca/crct/public_test.go b/usecases/ca/crct/public_test.go new file mode 100644 index 00000000..d49a8035 --- /dev/null +++ b/usecases/ca/crct/public_test.go @@ -0,0 +1,175 @@ +package crct + +import ( + ucapi "github.com/enbility/eebus-go/usecases/api" + "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" + "github.com/stretchr/testify/assert" +) + +func (s *CaCRCTSuite) Test_Setpoints() { + data, err := s.sut.Setpoints(s.mockRemoteEntity) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + data, err = s.sut.Setpoints(s.hvacRoomEntity) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + s.addHvacData() + + data, err = s.sut.Setpoints(s.hvacRoomEntity) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + s.addSetpointData() + + data, err = s.sut.Setpoints(s.hvacRoomEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 2, len(data)) + assert.Equal(s.T(), uint(1), data[0].Id) + assert.Equal(s.T(), 21.0, data[0].Value) + assert.True(s.T(), data[0].IsChangeable) + assert.True(s.T(), data[1].IsActive) +} + +func (s *CaCRCTSuite) Test_SetpointConstraints() { + data, err := s.sut.SetpointConstraints(s.mockRemoteEntity) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + data, err = s.sut.SetpointConstraints(s.hvacRoomEntity) + assert.NotNil(s.T(), err) + assert.Nil(s.T(), data) + + s.addHvacData() + s.addSetpointData() + + data, err = s.sut.SetpointConstraints(s.hvacRoomEntity) + assert.Nil(s.T(), err) + assert.Equal(s.T(), 1, len(data)) + assert.Equal(s.T(), uint(1), data[0].Id) + assert.Equal(s.T(), 16.0, data[0].MinValue) + assert.Equal(s.T(), 25.0, data[0].MaxValue) + assert.Equal(s.T(), 0.5, data[0].StepSize) +} + +func (s *CaCRCTSuite) Test_WriteSetpoint() { + err := s.sut.WriteSetpoint(s.mockRemoteEntity, ucapi.HvacOperationModeTypeEco, 19) + assert.NotNil(s.T(), err) + + // the setpoints of the auto mode cannot be written + err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeAuto, 19) + assert.NotNil(s.T(), err) + + err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeEco, 19) + assert.NotNil(s.T(), err) + + s.addHvacData() + + err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeEco, 19) + assert.Nil(s.T(), err) + + // the off mode has no setpoint in the test data + err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeOff, 19) + assert.NotNil(s.T(), err) + + // a setpoint marked not changeable cannot be written + s.addSetpointData() + + err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeOn, 22) + assert.NotNil(s.T(), err) + + err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeEco, 19) + assert.Nil(s.T(), err) +} + +// helpers + +func (s *CaCRCTSuite) addHvacData() { + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.hvacRoomEntity, model.FeatureTypeTypeHvac, model.RoleTypeServer) + + descData := &model.HvacSystemFunctionDescriptionListDataType{ + HvacSystemFunctionDescriptionData: []model.HvacSystemFunctionDescriptionDataType{ + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(1)), + SystemFunctionType: util.Ptr(model.HvacSystemFunctionTypeTypeCooling), + }, + }, + } + _, fErr := rFeature.UpdateData(true, model.FunctionTypeHvacSystemFunctionDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + modeData := &model.HvacOperationModeDescriptionListDataType{ + HvacOperationModeDescriptionData: []model.HvacOperationModeDescriptionDataType{ + { + OperationModeId: util.Ptr(model.HvacOperationModeIdType(1)), + OperationModeType: util.Ptr(model.HvacOperationModeTypeTypeEco), + }, + { + OperationModeId: util.Ptr(model.HvacOperationModeIdType(2)), + OperationModeType: util.Ptr(model.HvacOperationModeTypeTypeOn), + }, + { + OperationModeId: util.Ptr(model.HvacOperationModeIdType(3)), + OperationModeType: util.Ptr(model.HvacOperationModeTypeTypeOff), + }, + }, + } + _, fErr = rFeature.UpdateData(true, model.FunctionTypeHvacOperationModeDescriptionListData, modeData, nil, nil) + assert.Nil(s.T(), fErr) + + relationData := &model.HvacSystemFunctionSetpointRelationListDataType{ + HvacSystemFunctionSetpointRelationData: []model.HvacSystemFunctionSetpointRelationDataType{ + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(1)), + OperationModeId: util.Ptr(model.HvacOperationModeIdType(1)), + SetpointId: []model.SetpointIdType{1}, + }, + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(1)), + OperationModeId: util.Ptr(model.HvacOperationModeIdType(2)), + SetpointId: []model.SetpointIdType{2}, + }, + }, + } + _, fErr = rFeature.UpdateData(true, model.FunctionTypeHvacSystemFunctionSetPointRelationListData, relationData, nil, nil) + assert.Nil(s.T(), fErr) +} + +func (s *CaCRCTSuite) addSetpointData() { + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.hvacRoomEntity, model.FeatureTypeTypeSetpoint, model.RoleTypeServer) + + spData := &model.SetpointListDataType{ + SetpointData: []model.SetpointDataType{ + { + SetpointId: util.Ptr(model.SetpointIdType(1)), + Value: model.NewScaledNumberType(21), + ValueMin: model.NewScaledNumberType(16), + ValueMax: model.NewScaledNumberType(25), + IsSetpointChangeable: util.Ptr(true), + }, + { + SetpointId: util.Ptr(model.SetpointIdType(2)), + Value: model.NewScaledNumberType(23), + IsSetpointActive: util.Ptr(true), + IsSetpointChangeable: util.Ptr(false), + }, + }, + } + _, fErr := rFeature.UpdateData(true, model.FunctionTypeSetpointListData, spData, nil, nil) + assert.Nil(s.T(), fErr) + + constraintsData := &model.SetpointConstraintsListDataType{ + SetpointConstraintsData: []model.SetpointConstraintsDataType{ + { + SetpointId: util.Ptr(model.SetpointIdType(1)), + SetpointRangeMin: model.NewScaledNumberType(16), + SetpointRangeMax: model.NewScaledNumberType(25), + SetpointStepSize: model.NewScaledNumberType(0.5), + }, + }, + } + _, fErr = rFeature.UpdateData(true, model.FunctionTypeSetpointConstraintsListData, constraintsData, nil, nil) + assert.Nil(s.T(), fErr) +} diff --git a/usecases/ca/crct/testhelper_test.go b/usecases/ca/crct/testhelper_test.go new file mode 100644 index 00000000..bd0885f9 --- /dev/null +++ b/usecases/ca/crct/testhelper_test.go @@ -0,0 +1,179 @@ +package crct + +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" + 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/enbility/spine-go/util" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +func TestCaCRCTSuite(t *testing.T) { + suite.Run(t, new(CaCRCTSuite)) +} + +type CaCRCTSuite struct { + suite.Suite + + sut *CRCT + + service api.ServiceInterface + + remoteDevice spineapi.DeviceRemoteInterface + mockRemoteEntity *spinemocks.EntityRemoteInterface + hvacRoomEntity spineapi.EntityRemoteInterface + + eventCalled bool +} + +func (s *CaCRCTSuite) Event(ski string, device spineapi.DeviceRemoteInterface, entity spineapi.EntityRemoteInterface, event api.EventType) { + s.eventCalled = true +} + +func (s *CaCRCTSuite) 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, nil, nil) + + 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() + mockRemoteDevice.EXPECT().Ski().Return(remoteSki).Maybe() + s.mockRemoteEntity.EXPECT().Device().Return(mockRemoteDevice).Maybe() + s.mockRemoteEntity.EXPECT().EntityType().Return(mock.Anything).Maybe() + entityAddress := &model.EntityAddressType{} + s.mockRemoteEntity.EXPECT().Address().Return(entityAddress).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 = NewCRCT(localEntity, s.Event) + _ = s.sut.AddFeatures() + s.sut.AddUseCase() + + s.remoteDevice, s.hvacRoomEntity = setupDevices(s.service, s.T()) +} + +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.FunctionTypeHvacSystemFunctionDescriptionListData, + 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}, + 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.EntityTypeTypeHvacRoom), + }, + }, + }, + FeatureInformation: featureInformations, + } + + entities, err := remoteDevice.AddEntityAndFeatures(true, detailedData, nil) + 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[0] +} diff --git a/usecases/ca/crct/types.go b/usecases/ca/crct/types.go new file mode 100644 index 00000000..44136303 --- /dev/null +++ b/usecases/ca/crct/types.go @@ -0,0 +1,24 @@ +package crct + +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-crct-UseCaseSupportUpdate" + + // Room cooling temperature setpoint data updated + // + // Use `Setpoints` to get the current data + // + // Use Case CRCT, Scenario 1 + DataUpdateSetpoints api.EventType = "ca-crct-DataUpdateSetpoints" + + // Room cooling temperature setpoint constraints updated + // + // Use `SetpointConstraints` to get the current data + // + // Use Case CRCT, Scenario 1 + DataUpdateSetpointConstraints api.EventType = "ca-crct-DataUpdateSetpointConstraints" +) diff --git a/usecases/ca/crct/usecase.go b/usecases/ca/crct/usecase.go new file mode 100644 index 00000000..21978d8b --- /dev/null +++ b/usecases/ca/crct/usecase.go @@ -0,0 +1,74 @@ +package crct + +import ( + "errors" + + "github.com/enbility/eebus-go/api" + ucapi "github.com/enbility/eebus-go/usecases/api" + usecase "github.com/enbility/eebus-go/usecases/usecase" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" +) + +type CRCT struct { + *usecase.UseCaseBase +} + +var _ ucapi.CaCRCTInterface = (*CRCT)(nil) + +// Add support for the Configuration of Room Cooling Temperature (CRCT) +// use case as a Configuration Appliance actor +// +// Parameters: +// - localEntity: The local entity which should support the use case +// - eventCB: The callback to be called when an event is triggered (optional, can be nil) +func NewCRCT(localEntity spineapi.EntityLocalInterface, eventCB api.EntityEventCallback) *CRCT { + validActorTypes := []model.UseCaseActorType{model.UseCaseActorTypeHVACRoom} + validEntityTypes := []model.EntityTypeType{model.EntityTypeTypeHvacRoom} + useCaseScenarios := []api.UseCaseScenario{ + { + Scenario: model.UseCaseScenarioSupportType(1), + Mandatory: true, + ServerFeatures: []model.FeatureTypeType{ + model.FeatureTypeTypeSetpoint, + model.FeatureTypeTypeHvac, + }, + }, + } + + usecase := usecase.NewUseCaseBase( + localEntity, + model.UseCaseActorTypeConfigurationAppliance, + model.UseCaseNameTypeConfigurationOfRoomCoolingTemperature, + "1.0.0", + "release", + useCaseScenarios, + eventCB, + UseCaseSupportUpdate, + validActorTypes, + validEntityTypes, + false) + + uc := &CRCT{ + UseCaseBase: usecase, + } + + _ = localEntity.Device().Events().Subscribe(uc) + + return uc +} + +func (e *CRCT) AddFeatures() error { + // client features + var clientFeatures = []model.FeatureTypeType{ + model.FeatureTypeTypeSetpoint, + model.FeatureTypeTypeHvac, + } + for _, feature := range clientFeatures { + if f := e.LocalEntity.GetOrAddFeature(feature, model.RoleTypeClient); f == nil { + return errors.New("could not add feature: " + string(feature)) + } + } + + return nil +} diff --git a/usecases/ca/crct/usecase_test.go b/usecases/ca/crct/usecase_test.go new file mode 100644 index 00000000..10fe7ae0 --- /dev/null +++ b/usecases/ca/crct/usecase_test.go @@ -0,0 +1,5 @@ +package crct + +func (s *CaCRCTSuite) Test_UpdateUseCaseAvailability() { + s.sut.UpdateUseCaseAvailability(true) +} diff --git a/usecases/mocks/CaCRCTInterface.go b/usecases/mocks/CaCRCTInterface.go new file mode 100644 index 00000000..de2c8216 --- /dev/null +++ b/usecases/mocks/CaCRCTInterface.go @@ -0,0 +1,583 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + api0 "github.com/enbility/eebus-go/api" + api1 "github.com/enbility/eebus-go/usecases/api" + "github.com/enbility/spine-go/api" + mock "github.com/stretchr/testify/mock" +) + +// NewCaCRCTInterface creates a new instance of CaCRCTInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewCaCRCTInterface(t interface { + mock.TestingT + Cleanup(func()) +}) *CaCRCTInterface { + mock := &CaCRCTInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// CaCRCTInterface is an autogenerated mock type for the CaCRCTInterface type +type CaCRCTInterface struct { + mock.Mock +} + +type CaCRCTInterface_Expecter struct { + mock *mock.Mock +} + +func (_m *CaCRCTInterface) EXPECT() *CaCRCTInterface_Expecter { + return &CaCRCTInterface_Expecter{mock: &_m.Mock} +} + +// AddFeatures provides a mock function for the type CaCRCTInterface +func (_mock *CaCRCTInterface) AddFeatures() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for AddFeatures") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// CaCRCTInterface_AddFeatures_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddFeatures' +type CaCRCTInterface_AddFeatures_Call struct { + *mock.Call +} + +// AddFeatures is a helper method to define mock.On call +func (_e *CaCRCTInterface_Expecter) AddFeatures() *CaCRCTInterface_AddFeatures_Call { + return &CaCRCTInterface_AddFeatures_Call{Call: _e.mock.On("AddFeatures")} +} + +func (_c *CaCRCTInterface_AddFeatures_Call) Run(run func()) *CaCRCTInterface_AddFeatures_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *CaCRCTInterface_AddFeatures_Call) Return(err error) *CaCRCTInterface_AddFeatures_Call { + _c.Call.Return(err) + return _c +} + +func (_c *CaCRCTInterface_AddFeatures_Call) RunAndReturn(run func() error) *CaCRCTInterface_AddFeatures_Call { + _c.Call.Return(run) + return _c +} + +// AddUseCase provides a mock function for the type CaCRCTInterface +func (_mock *CaCRCTInterface) AddUseCase() { + _mock.Called() + return +} + +// CaCRCTInterface_AddUseCase_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddUseCase' +type CaCRCTInterface_AddUseCase_Call struct { + *mock.Call +} + +// AddUseCase is a helper method to define mock.On call +func (_e *CaCRCTInterface_Expecter) AddUseCase() *CaCRCTInterface_AddUseCase_Call { + return &CaCRCTInterface_AddUseCase_Call{Call: _e.mock.On("AddUseCase")} +} + +func (_c *CaCRCTInterface_AddUseCase_Call) Run(run func()) *CaCRCTInterface_AddUseCase_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *CaCRCTInterface_AddUseCase_Call) Return() *CaCRCTInterface_AddUseCase_Call { + _c.Call.Return() + return _c +} + +func (_c *CaCRCTInterface_AddUseCase_Call) RunAndReturn(run func()) *CaCRCTInterface_AddUseCase_Call { + _c.Run(run) + return _c +} + +// AvailableScenariosForEntity provides a mock function for the type CaCRCTInterface +func (_mock *CaCRCTInterface) AvailableScenariosForEntity(entity api.EntityRemoteInterface) []uint { + ret := _mock.Called(entity) + + if len(ret) == 0 { + panic("no return value specified for AvailableScenariosForEntity") + } + + var r0 []uint + if returnFunc, ok := ret.Get(0).(func(api.EntityRemoteInterface) []uint); ok { + r0 = returnFunc(entity) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]uint) + } + } + return r0 +} + +// CaCRCTInterface_AvailableScenariosForEntity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AvailableScenariosForEntity' +type CaCRCTInterface_AvailableScenariosForEntity_Call struct { + *mock.Call +} + +// AvailableScenariosForEntity is a helper method to define mock.On call +// - entity api.EntityRemoteInterface +func (_e *CaCRCTInterface_Expecter) AvailableScenariosForEntity(entity interface{}) *CaCRCTInterface_AvailableScenariosForEntity_Call { + return &CaCRCTInterface_AvailableScenariosForEntity_Call{Call: _e.mock.On("AvailableScenariosForEntity", entity)} +} + +func (_c *CaCRCTInterface_AvailableScenariosForEntity_Call) Run(run func(entity api.EntityRemoteInterface)) *CaCRCTInterface_AvailableScenariosForEntity_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 api.EntityRemoteInterface + if args[0] != nil { + arg0 = args[0].(api.EntityRemoteInterface) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *CaCRCTInterface_AvailableScenariosForEntity_Call) Return(uints []uint) *CaCRCTInterface_AvailableScenariosForEntity_Call { + _c.Call.Return(uints) + return _c +} + +func (_c *CaCRCTInterface_AvailableScenariosForEntity_Call) RunAndReturn(run func(entity api.EntityRemoteInterface) []uint) *CaCRCTInterface_AvailableScenariosForEntity_Call { + _c.Call.Return(run) + return _c +} + +// IsCompatibleEntityType provides a mock function for the type CaCRCTInterface +func (_mock *CaCRCTInterface) IsCompatibleEntityType(entity api.EntityRemoteInterface) bool { + ret := _mock.Called(entity) + + if len(ret) == 0 { + panic("no return value specified for IsCompatibleEntityType") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func(api.EntityRemoteInterface) bool); ok { + r0 = returnFunc(entity) + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// CaCRCTInterface_IsCompatibleEntityType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsCompatibleEntityType' +type CaCRCTInterface_IsCompatibleEntityType_Call struct { + *mock.Call +} + +// IsCompatibleEntityType is a helper method to define mock.On call +// - entity api.EntityRemoteInterface +func (_e *CaCRCTInterface_Expecter) IsCompatibleEntityType(entity interface{}) *CaCRCTInterface_IsCompatibleEntityType_Call { + return &CaCRCTInterface_IsCompatibleEntityType_Call{Call: _e.mock.On("IsCompatibleEntityType", entity)} +} + +func (_c *CaCRCTInterface_IsCompatibleEntityType_Call) Run(run func(entity api.EntityRemoteInterface)) *CaCRCTInterface_IsCompatibleEntityType_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 api.EntityRemoteInterface + if args[0] != nil { + arg0 = args[0].(api.EntityRemoteInterface) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *CaCRCTInterface_IsCompatibleEntityType_Call) Return(b bool) *CaCRCTInterface_IsCompatibleEntityType_Call { + _c.Call.Return(b) + return _c +} + +func (_c *CaCRCTInterface_IsCompatibleEntityType_Call) RunAndReturn(run func(entity api.EntityRemoteInterface) bool) *CaCRCTInterface_IsCompatibleEntityType_Call { + _c.Call.Return(run) + return _c +} + +// IsScenarioAvailableAtEntity provides a mock function for the type CaCRCTInterface +func (_mock *CaCRCTInterface) IsScenarioAvailableAtEntity(entity api.EntityRemoteInterface, scenario uint) bool { + ret := _mock.Called(entity, scenario) + + if len(ret) == 0 { + panic("no return value specified for IsScenarioAvailableAtEntity") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func(api.EntityRemoteInterface, uint) bool); ok { + r0 = returnFunc(entity, scenario) + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// CaCRCTInterface_IsScenarioAvailableAtEntity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsScenarioAvailableAtEntity' +type CaCRCTInterface_IsScenarioAvailableAtEntity_Call struct { + *mock.Call +} + +// IsScenarioAvailableAtEntity is a helper method to define mock.On call +// - entity api.EntityRemoteInterface +// - scenario uint +func (_e *CaCRCTInterface_Expecter) IsScenarioAvailableAtEntity(entity interface{}, scenario interface{}) *CaCRCTInterface_IsScenarioAvailableAtEntity_Call { + return &CaCRCTInterface_IsScenarioAvailableAtEntity_Call{Call: _e.mock.On("IsScenarioAvailableAtEntity", entity, scenario)} +} + +func (_c *CaCRCTInterface_IsScenarioAvailableAtEntity_Call) Run(run func(entity api.EntityRemoteInterface, scenario uint)) *CaCRCTInterface_IsScenarioAvailableAtEntity_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 api.EntityRemoteInterface + if args[0] != nil { + arg0 = args[0].(api.EntityRemoteInterface) + } + var arg1 uint + if args[1] != nil { + arg1 = args[1].(uint) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *CaCRCTInterface_IsScenarioAvailableAtEntity_Call) Return(b bool) *CaCRCTInterface_IsScenarioAvailableAtEntity_Call { + _c.Call.Return(b) + return _c +} + +func (_c *CaCRCTInterface_IsScenarioAvailableAtEntity_Call) RunAndReturn(run func(entity api.EntityRemoteInterface, scenario uint) bool) *CaCRCTInterface_IsScenarioAvailableAtEntity_Call { + _c.Call.Return(run) + return _c +} + +// RemoteEntitiesScenarios provides a mock function for the type CaCRCTInterface +func (_mock *CaCRCTInterface) RemoteEntitiesScenarios() []api0.RemoteEntityScenarios { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for RemoteEntitiesScenarios") + } + + var r0 []api0.RemoteEntityScenarios + if returnFunc, ok := ret.Get(0).(func() []api0.RemoteEntityScenarios); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]api0.RemoteEntityScenarios) + } + } + return r0 +} + +// CaCRCTInterface_RemoteEntitiesScenarios_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoteEntitiesScenarios' +type CaCRCTInterface_RemoteEntitiesScenarios_Call struct { + *mock.Call +} + +// RemoteEntitiesScenarios is a helper method to define mock.On call +func (_e *CaCRCTInterface_Expecter) RemoteEntitiesScenarios() *CaCRCTInterface_RemoteEntitiesScenarios_Call { + return &CaCRCTInterface_RemoteEntitiesScenarios_Call{Call: _e.mock.On("RemoteEntitiesScenarios")} +} + +func (_c *CaCRCTInterface_RemoteEntitiesScenarios_Call) Run(run func()) *CaCRCTInterface_RemoteEntitiesScenarios_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *CaCRCTInterface_RemoteEntitiesScenarios_Call) Return(remoteEntityScenarioss []api0.RemoteEntityScenarios) *CaCRCTInterface_RemoteEntitiesScenarios_Call { + _c.Call.Return(remoteEntityScenarioss) + return _c +} + +func (_c *CaCRCTInterface_RemoteEntitiesScenarios_Call) RunAndReturn(run func() []api0.RemoteEntityScenarios) *CaCRCTInterface_RemoteEntitiesScenarios_Call { + _c.Call.Return(run) + return _c +} + +// RemoveUseCase provides a mock function for the type CaCRCTInterface +func (_mock *CaCRCTInterface) RemoveUseCase() { + _mock.Called() + return +} + +// CaCRCTInterface_RemoveUseCase_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveUseCase' +type CaCRCTInterface_RemoveUseCase_Call struct { + *mock.Call +} + +// RemoveUseCase is a helper method to define mock.On call +func (_e *CaCRCTInterface_Expecter) RemoveUseCase() *CaCRCTInterface_RemoveUseCase_Call { + return &CaCRCTInterface_RemoveUseCase_Call{Call: _e.mock.On("RemoveUseCase")} +} + +func (_c *CaCRCTInterface_RemoveUseCase_Call) Run(run func()) *CaCRCTInterface_RemoveUseCase_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *CaCRCTInterface_RemoveUseCase_Call) Return() *CaCRCTInterface_RemoveUseCase_Call { + _c.Call.Return() + return _c +} + +func (_c *CaCRCTInterface_RemoveUseCase_Call) RunAndReturn(run func()) *CaCRCTInterface_RemoveUseCase_Call { + _c.Run(run) + return _c +} + +// SetpointConstraints provides a mock function for the type CaCRCTInterface +func (_mock *CaCRCTInterface) SetpointConstraints(entity api.EntityRemoteInterface) ([]api1.SetpointConstraints, error) { + ret := _mock.Called(entity) + + if len(ret) == 0 { + panic("no return value specified for SetpointConstraints") + } + + var r0 []api1.SetpointConstraints + var r1 error + if returnFunc, ok := ret.Get(0).(func(api.EntityRemoteInterface) ([]api1.SetpointConstraints, error)); ok { + return returnFunc(entity) + } + if returnFunc, ok := ret.Get(0).(func(api.EntityRemoteInterface) []api1.SetpointConstraints); ok { + r0 = returnFunc(entity) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]api1.SetpointConstraints) + } + } + if returnFunc, ok := ret.Get(1).(func(api.EntityRemoteInterface) error); ok { + r1 = returnFunc(entity) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// CaCRCTInterface_SetpointConstraints_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetpointConstraints' +type CaCRCTInterface_SetpointConstraints_Call struct { + *mock.Call +} + +// SetpointConstraints is a helper method to define mock.On call +// - entity api.EntityRemoteInterface +func (_e *CaCRCTInterface_Expecter) SetpointConstraints(entity interface{}) *CaCRCTInterface_SetpointConstraints_Call { + return &CaCRCTInterface_SetpointConstraints_Call{Call: _e.mock.On("SetpointConstraints", entity)} +} + +func (_c *CaCRCTInterface_SetpointConstraints_Call) Run(run func(entity api.EntityRemoteInterface)) *CaCRCTInterface_SetpointConstraints_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 api.EntityRemoteInterface + if args[0] != nil { + arg0 = args[0].(api.EntityRemoteInterface) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *CaCRCTInterface_SetpointConstraints_Call) Return(setpointConstraintss []api1.SetpointConstraints, err error) *CaCRCTInterface_SetpointConstraints_Call { + _c.Call.Return(setpointConstraintss, err) + return _c +} + +func (_c *CaCRCTInterface_SetpointConstraints_Call) RunAndReturn(run func(entity api.EntityRemoteInterface) ([]api1.SetpointConstraints, error)) *CaCRCTInterface_SetpointConstraints_Call { + _c.Call.Return(run) + return _c +} + +// Setpoints provides a mock function for the type CaCRCTInterface +func (_mock *CaCRCTInterface) Setpoints(entity api.EntityRemoteInterface) ([]api1.Setpoint, error) { + ret := _mock.Called(entity) + + if len(ret) == 0 { + panic("no return value specified for Setpoints") + } + + var r0 []api1.Setpoint + var r1 error + if returnFunc, ok := ret.Get(0).(func(api.EntityRemoteInterface) ([]api1.Setpoint, error)); ok { + return returnFunc(entity) + } + if returnFunc, ok := ret.Get(0).(func(api.EntityRemoteInterface) []api1.Setpoint); ok { + r0 = returnFunc(entity) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]api1.Setpoint) + } + } + if returnFunc, ok := ret.Get(1).(func(api.EntityRemoteInterface) error); ok { + r1 = returnFunc(entity) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// CaCRCTInterface_Setpoints_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Setpoints' +type CaCRCTInterface_Setpoints_Call struct { + *mock.Call +} + +// Setpoints is a helper method to define mock.On call +// - entity api.EntityRemoteInterface +func (_e *CaCRCTInterface_Expecter) Setpoints(entity interface{}) *CaCRCTInterface_Setpoints_Call { + return &CaCRCTInterface_Setpoints_Call{Call: _e.mock.On("Setpoints", entity)} +} + +func (_c *CaCRCTInterface_Setpoints_Call) Run(run func(entity api.EntityRemoteInterface)) *CaCRCTInterface_Setpoints_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 api.EntityRemoteInterface + if args[0] != nil { + arg0 = args[0].(api.EntityRemoteInterface) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *CaCRCTInterface_Setpoints_Call) Return(setpoints []api1.Setpoint, err error) *CaCRCTInterface_Setpoints_Call { + _c.Call.Return(setpoints, err) + return _c +} + +func (_c *CaCRCTInterface_Setpoints_Call) RunAndReturn(run func(entity api.EntityRemoteInterface) ([]api1.Setpoint, error)) *CaCRCTInterface_Setpoints_Call { + _c.Call.Return(run) + return _c +} + +// UpdateUseCaseAvailability provides a mock function for the type CaCRCTInterface +func (_mock *CaCRCTInterface) UpdateUseCaseAvailability(available bool) { + _mock.Called(available) + return +} + +// CaCRCTInterface_UpdateUseCaseAvailability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateUseCaseAvailability' +type CaCRCTInterface_UpdateUseCaseAvailability_Call struct { + *mock.Call +} + +// UpdateUseCaseAvailability is a helper method to define mock.On call +// - available bool +func (_e *CaCRCTInterface_Expecter) UpdateUseCaseAvailability(available interface{}) *CaCRCTInterface_UpdateUseCaseAvailability_Call { + return &CaCRCTInterface_UpdateUseCaseAvailability_Call{Call: _e.mock.On("UpdateUseCaseAvailability", available)} +} + +func (_c *CaCRCTInterface_UpdateUseCaseAvailability_Call) Run(run func(available bool)) *CaCRCTInterface_UpdateUseCaseAvailability_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 bool + if args[0] != nil { + arg0 = args[0].(bool) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *CaCRCTInterface_UpdateUseCaseAvailability_Call) Return() *CaCRCTInterface_UpdateUseCaseAvailability_Call { + _c.Call.Return() + return _c +} + +func (_c *CaCRCTInterface_UpdateUseCaseAvailability_Call) RunAndReturn(run func(available bool)) *CaCRCTInterface_UpdateUseCaseAvailability_Call { + _c.Run(run) + return _c +} + +// WriteSetpoint provides a mock function for the type CaCRCTInterface +func (_mock *CaCRCTInterface) WriteSetpoint(entity api.EntityRemoteInterface, mode api1.HvacOperationModeType, degC float64) error { + ret := _mock.Called(entity, mode, degC) + + if len(ret) == 0 { + panic("no return value specified for WriteSetpoint") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(api.EntityRemoteInterface, api1.HvacOperationModeType, float64) error); ok { + r0 = returnFunc(entity, mode, degC) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// CaCRCTInterface_WriteSetpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteSetpoint' +type CaCRCTInterface_WriteSetpoint_Call struct { + *mock.Call +} + +// WriteSetpoint is a helper method to define mock.On call +// - entity api.EntityRemoteInterface +// - mode api1.HvacOperationModeType +// - degC float64 +func (_e *CaCRCTInterface_Expecter) WriteSetpoint(entity interface{}, mode interface{}, degC interface{}) *CaCRCTInterface_WriteSetpoint_Call { + return &CaCRCTInterface_WriteSetpoint_Call{Call: _e.mock.On("WriteSetpoint", entity, mode, degC)} +} + +func (_c *CaCRCTInterface_WriteSetpoint_Call) Run(run func(entity api.EntityRemoteInterface, mode api1.HvacOperationModeType, degC float64)) *CaCRCTInterface_WriteSetpoint_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 api.EntityRemoteInterface + if args[0] != nil { + arg0 = args[0].(api.EntityRemoteInterface) + } + var arg1 api1.HvacOperationModeType + if args[1] != nil { + arg1 = args[1].(api1.HvacOperationModeType) + } + var arg2 float64 + if args[2] != nil { + arg2 = args[2].(float64) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *CaCRCTInterface_WriteSetpoint_Call) Return(err error) *CaCRCTInterface_WriteSetpoint_Call { + _c.Call.Return(err) + return _c +} + +func (_c *CaCRCTInterface_WriteSetpoint_Call) RunAndReturn(run func(entity api.EntityRemoteInterface, mode api1.HvacOperationModeType, degC float64) error) *CaCRCTInterface_WriteSetpoint_Call { + _c.Call.Return(run) + return _c +} From d948705a7e67c963fed333c056282809b22399b1 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 20 Jul 2026 17:41:53 +0200 Subject: [PATCH 4/7] Surface the device write result and fail closed on ambiguous ids Point 5: WriteSetpoint discarded the message counter and reported success as soon as the request was sent. Return the message counter and accept a resultCB so a non-zero ResultData.ErrorNumber surfaces as a device rejection, matching the OPEV/LPC/OHPCF write pattern. Point 6: the setpoint relation resolved the cooling system function via the first matching description. Return ErrDataNotAvailable unless exactly one cooling system function matches, so the wrong setpoint is never controlled. Co-Authored-By: Claude Opus 4.8 (1M context) --- usecases/api/ca_crct.go | 8 +++++- usecases/ca/crct/public.go | 48 ++++++++++++++++++++++++------- usecases/ca/crct/public_test.go | 38 +++++++++++++++++++----- usecases/mocks/CaCRCTInterface.go | 44 +++++++++++++++++++--------- 4 files changed, 107 insertions(+), 31 deletions(-) diff --git a/usecases/api/ca_crct.go b/usecases/api/ca_crct.go index 31f92551..4291a375 100644 --- a/usecases/api/ca_crct.go +++ b/usecases/api/ca_crct.go @@ -3,6 +3,7 @@ package api import ( "github.com/enbility/eebus-go/api" spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" ) // Actor: Configuration Appliance @@ -43,5 +44,10 @@ type CaCRCTInterface interface { // - ErrNotSupported if the setpoint is not changeable or the mode is auto // - ErrDataNotAvailable if the required data is not (yet) available // - and others - WriteSetpoint(entity spineapi.EntityRemoteInterface, mode HvacOperationModeType, degC float64) error + WriteSetpoint( + entity spineapi.EntityRemoteInterface, + mode HvacOperationModeType, + degC float64, + resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType), + ) (*model.MsgCounterType, error) } diff --git a/usecases/ca/crct/public.go b/usecases/ca/crct/public.go index 9d3b1d2d..2bc69550 100644 --- a/usecases/ca/crct/public.go +++ b/usecases/ca/crct/public.go @@ -4,6 +4,7 @@ import ( "github.com/enbility/eebus-go/api" "github.com/enbility/eebus-go/features/client" ucapi "github.com/enbility/eebus-go/usecases/api" + "github.com/enbility/ship-go/logging" spineapi "github.com/enbility/spine-go/api" "github.com/enbility/spine-go/model" "github.com/enbility/spine-go/util" @@ -109,34 +110,38 @@ func (e *CRCT) SetpointConstraints(entity spineapi.EntityRemoteInterface) ([]uca } // write the room cooling temperature setpoint in degree Celsius for a cooling -// operation mode (on, off or eco), returns ErrNotSupported if it is not changeable +// operation mode (on, off or eco), returns ErrNotSupported if it is not changeable. +// +// The returned message counter and the resultCB let the caller observe the +// device result: a non-zero ResultData.ErrorNumber signals a rejected write. func (e *CRCT) WriteSetpoint( entity spineapi.EntityRemoteInterface, mode ucapi.HvacOperationModeType, degC float64, -) error { + resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType), +) (*model.MsgCounterType, error) { if !e.IsCompatibleEntityType(entity) { - return api.ErrNoCompatibleEntity + return nil, api.ErrNoCompatibleEntity } // the setpoints of the "auto" mode are controlled by a timetable of the device if mode == ucapi.HvacOperationModeTypeAuto { - return api.ErrNotSupported + return nil, api.ErrNotSupported } setpointId, err := e.setpointIdForMode(entity, mode) if err != nil { - return err + return nil, err } sp, err := client.NewSetpoint(e.LocalEntity, entity) if err != nil { - return err + return nil, err } if data, err := sp.GetSetpointForId(setpointId); err == nil && data.IsSetpointChangeable != nil && !*data.IsSetpointChangeable { - return api.ErrNotSupported + return nil, api.ErrNotSupported } data := []model.SetpointDataType{ @@ -146,9 +151,31 @@ func (e *CRCT) WriteSetpoint( }, } - _, err = sp.WriteSetpointListData(data) + msgCounter, err := sp.WriteSetpointListData(data) + registerResultCallback(sp, msgCounter, resultCB) + + return msgCounter, err +} + +// register a response callback that surfaces the device result of a write to +// the caller, so a non-zero ResultData.ErrorNumber can be treated as a rejection +func registerResultCallback( + sp *client.Setpoint, + msgCounter *model.MsgCounterType, + resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType), +) { + if resultCB == nil || msgCounter == nil { + return + } - return err + cb := func(msg spineapi.ResponseMessage) { + if response, ok := msg.Data.(*model.ResultDataType); ok { + resultCB(*response, *msgCounter) + } + } + if err := sp.AddResponseCallback(*msgCounter, cb); err != nil { + logging.Log().Debug("failed to add response callback for msgCounter %v: %v", msgCounter, err) + } } // return the ids of the setpoints related to the cooling system function @@ -217,7 +244,8 @@ func (e *CRCT) setpointRelations( SystemFunctionType: util.Ptr(model.HvacSystemFunctionTypeTypeCooling), } descriptions, err := hvac.GetHvacSystemFunctionDescriptionsForFilter(descFilter) - if err != nil || len(descriptions) == 0 || descriptions[0].SystemFunctionId == nil { + // fail closed on an ambiguous result so the wrong system function is never controlled + if err != nil || len(descriptions) != 1 || descriptions[0].SystemFunctionId == nil { return nil, api.ErrDataNotAvailable } diff --git a/usecases/ca/crct/public_test.go b/usecases/ca/crct/public_test.go index d49a8035..d2bd8d84 100644 --- a/usecases/ca/crct/public_test.go +++ b/usecases/ca/crct/public_test.go @@ -55,35 +55,59 @@ func (s *CaCRCTSuite) Test_SetpointConstraints() { } func (s *CaCRCTSuite) Test_WriteSetpoint() { - err := s.sut.WriteSetpoint(s.mockRemoteEntity, ucapi.HvacOperationModeTypeEco, 19) + _, err := s.sut.WriteSetpoint(s.mockRemoteEntity, ucapi.HvacOperationModeTypeEco, 19, nil) assert.NotNil(s.T(), err) // the setpoints of the auto mode cannot be written - err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeAuto, 19) + _, err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeAuto, 19, nil) assert.NotNil(s.T(), err) - err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeEco, 19) + _, err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeEco, 19, nil) assert.NotNil(s.T(), err) s.addHvacData() - err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeEco, 19) + _, err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeEco, 19, nil) assert.Nil(s.T(), err) // the off mode has no setpoint in the test data - err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeOff, 19) + _, err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeOff, 19, nil) assert.NotNil(s.T(), err) // a setpoint marked not changeable cannot be written s.addSetpointData() - err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeOn, 22) + _, err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeOn, 22, nil) assert.NotNil(s.T(), err) - err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeEco, 19) + _, err = s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeEco, 19, nil) assert.Nil(s.T(), err) } +func (s *CaCRCTSuite) Test_WriteSetpoint_AmbiguousSystemFunction() { + s.addHvacData() + + // two cooling system functions make the id ambiguous, so the write must fail + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.hvacRoomEntity, model.FeatureTypeTypeHvac, model.RoleTypeServer) + descData := &model.HvacSystemFunctionDescriptionListDataType{ + HvacSystemFunctionDescriptionData: []model.HvacSystemFunctionDescriptionDataType{ + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(1)), + SystemFunctionType: util.Ptr(model.HvacSystemFunctionTypeTypeCooling), + }, + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(2)), + SystemFunctionType: util.Ptr(model.HvacSystemFunctionTypeTypeCooling), + }, + }, + } + _, fErr := rFeature.UpdateData(true, model.FunctionTypeHvacSystemFunctionDescriptionListData, descData, nil, nil) + assert.Nil(s.T(), fErr) + + _, err := s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeEco, 19, nil) + assert.NotNil(s.T(), err) +} + // helpers func (s *CaCRCTSuite) addHvacData() { diff --git a/usecases/mocks/CaCRCTInterface.go b/usecases/mocks/CaCRCTInterface.go index de2c8216..61501b8a 100644 --- a/usecases/mocks/CaCRCTInterface.go +++ b/usecases/mocks/CaCRCTInterface.go @@ -8,6 +8,7 @@ import ( api0 "github.com/enbility/eebus-go/api" api1 "github.com/enbility/eebus-go/usecases/api" "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" mock "github.com/stretchr/testify/mock" ) @@ -520,20 +521,31 @@ func (_c *CaCRCTInterface_UpdateUseCaseAvailability_Call) RunAndReturn(run func( } // WriteSetpoint provides a mock function for the type CaCRCTInterface -func (_mock *CaCRCTInterface) WriteSetpoint(entity api.EntityRemoteInterface, mode api1.HvacOperationModeType, degC float64) error { - ret := _mock.Called(entity, mode, degC) +func (_mock *CaCRCTInterface) WriteSetpoint(entity api.EntityRemoteInterface, mode api1.HvacOperationModeType, degC float64, resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType)) (*model.MsgCounterType, error) { + ret := _mock.Called(entity, mode, degC, resultCB) if len(ret) == 0 { panic("no return value specified for WriteSetpoint") } - var r0 error - if returnFunc, ok := ret.Get(0).(func(api.EntityRemoteInterface, api1.HvacOperationModeType, float64) error); ok { - r0 = returnFunc(entity, mode, degC) + var r0 *model.MsgCounterType + var r1 error + if returnFunc, ok := ret.Get(0).(func(api.EntityRemoteInterface, api1.HvacOperationModeType, float64, func(result model.ResultDataType, msgCounter model.MsgCounterType)) (*model.MsgCounterType, error)); ok { + return returnFunc(entity, mode, degC, resultCB) + } + if returnFunc, ok := ret.Get(0).(func(api.EntityRemoteInterface, api1.HvacOperationModeType, float64, func(result model.ResultDataType, msgCounter model.MsgCounterType)) *model.MsgCounterType); ok { + r0 = returnFunc(entity, mode, degC, resultCB) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.MsgCounterType) + } } - return r0 + if returnFunc, ok := ret.Get(1).(func(api.EntityRemoteInterface, api1.HvacOperationModeType, float64, func(result model.ResultDataType, msgCounter model.MsgCounterType)) error); ok { + r1 = returnFunc(entity, mode, degC, resultCB) + } else { + r1 = ret.Error(1) + } + return r0, r1 } // CaCRCTInterface_WriteSetpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteSetpoint' @@ -545,11 +557,12 @@ type CaCRCTInterface_WriteSetpoint_Call struct { // - entity api.EntityRemoteInterface // - mode api1.HvacOperationModeType // - degC float64 -func (_e *CaCRCTInterface_Expecter) WriteSetpoint(entity interface{}, mode interface{}, degC interface{}) *CaCRCTInterface_WriteSetpoint_Call { - return &CaCRCTInterface_WriteSetpoint_Call{Call: _e.mock.On("WriteSetpoint", entity, mode, degC)} +// - resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType) +func (_e *CaCRCTInterface_Expecter) WriteSetpoint(entity interface{}, mode interface{}, degC interface{}, resultCB interface{}) *CaCRCTInterface_WriteSetpoint_Call { + return &CaCRCTInterface_WriteSetpoint_Call{Call: _e.mock.On("WriteSetpoint", entity, mode, degC, resultCB)} } -func (_c *CaCRCTInterface_WriteSetpoint_Call) Run(run func(entity api.EntityRemoteInterface, mode api1.HvacOperationModeType, degC float64)) *CaCRCTInterface_WriteSetpoint_Call { +func (_c *CaCRCTInterface_WriteSetpoint_Call) Run(run func(entity api.EntityRemoteInterface, mode api1.HvacOperationModeType, degC float64, resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType))) *CaCRCTInterface_WriteSetpoint_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 api.EntityRemoteInterface if args[0] != nil { @@ -563,21 +576,26 @@ func (_c *CaCRCTInterface_WriteSetpoint_Call) Run(run func(entity api.EntityRemo if args[2] != nil { arg2 = args[2].(float64) } + var arg3 func(result model.ResultDataType, msgCounter model.MsgCounterType) + if args[3] != nil { + arg3 = args[3].(func(result model.ResultDataType, msgCounter model.MsgCounterType)) + } run( arg0, arg1, arg2, + arg3, ) }) return _c } -func (_c *CaCRCTInterface_WriteSetpoint_Call) Return(err error) *CaCRCTInterface_WriteSetpoint_Call { - _c.Call.Return(err) +func (_c *CaCRCTInterface_WriteSetpoint_Call) Return(msgCounterType *model.MsgCounterType, err error) *CaCRCTInterface_WriteSetpoint_Call { + _c.Call.Return(msgCounterType, err) return _c } -func (_c *CaCRCTInterface_WriteSetpoint_Call) RunAndReturn(run func(entity api.EntityRemoteInterface, mode api1.HvacOperationModeType, degC float64) error) *CaCRCTInterface_WriteSetpoint_Call { +func (_c *CaCRCTInterface_WriteSetpoint_Call) RunAndReturn(run func(entity api.EntityRemoteInterface, mode api1.HvacOperationModeType, degC float64, resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType)) (*model.MsgCounterType, error)) *CaCRCTInterface_WriteSetpoint_Call { _c.Call.Return(run) return _c } From fe838099fea8131296815a0034d565e964201376 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 20 Jul 2026 17:08:29 +0200 Subject: [PATCH 5/7] Return ErrNotSupported when the remote does not advertise Write() The Hvac and Setpoint write helpers issued a write command without checking whether the remote server feature advertises the Write() operation for the corresponding function. Guard each write helper so it returns api.ErrNotSupported unless the remote advertises Write(), matching the existing LoadControl/DeviceConfiguration behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) --- features/client/hvac.go | 6 ++++++ features/client/setpoint.go | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/features/client/hvac.go b/features/client/hvac.go index a6295a7b..93c04880 100644 --- a/features/client/hvac.go +++ b/features/client/hvac.go @@ -83,6 +83,12 @@ func (h *Hvac) WriteHvacSystemFunctionListData( return nil, api.ErrMissingData } + // the remote server has to advertise the write operation for this function + operation := h.featureRemote.Operations()[model.FunctionTypeHvacSystemFunctionListData] + if operation == nil || !operation.Write() { + return nil, api.ErrNotSupported + } + cmd := model.CmdType{ HvacSystemFunctionListData: &model.HvacSystemFunctionListDataType{ HvacSystemFunctionData: data, diff --git a/features/client/setpoint.go b/features/client/setpoint.go index b3b98534..26bda1a4 100644 --- a/features/client/setpoint.go +++ b/features/client/setpoint.go @@ -67,6 +67,12 @@ func (s *Setpoint) WriteSetpointListData( return nil, api.ErrMissingData } + // the remote server has to advertise the write operation for this function + operation := s.featureRemote.Operations()[model.FunctionTypeSetpointListData] + if operation == nil || !operation.Write() { + return nil, api.ErrNotSupported + } + cmd := model.CmdType{ SetpointListData: &model.SetpointListDataType{ SetpointData: data, From a0ee04d1cad498b55543299c1f25afa26ec2afb3 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 20 Jul 2026 17:22:18 +0200 Subject: [PATCH 6/7] Write a partial list when supported, otherwise the complete cached list The Hvac and Setpoint write helpers sent a list containing only the modified entry. Servers that interpret the payload as a full replacement would then drop unrelated system functions, overruns or setpoints. Send a proper partial write when the remote advertises WritePartial(). Otherwise copy the cached list, merge the modified entries and write the complete list, matching the LoadControl/DeviceConfiguration behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) --- features/client/hvac.go | 19 +++++++++++++++++++ features/client/hvac_test.go | 29 +++++++++++++++++++++++++++++ features/client/setpoint.go | 19 +++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/features/client/hvac.go b/features/client/hvac.go index 93c04880..71704c57 100644 --- a/features/client/hvac.go +++ b/features/client/hvac.go @@ -5,6 +5,7 @@ import ( "github.com/enbility/eebus-go/features/internal" spineapi "github.com/enbility/spine-go/api" "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" ) type Hvac struct { @@ -89,11 +90,29 @@ func (h *Hvac) WriteHvacSystemFunctionListData( return nil, api.ErrNotSupported } + // use a partial write when the server supports it, otherwise merge the + // modified entries into the cached list and write the complete list, so + // unrelated system functions are not dropped by a full replacement + filters := []model.FilterType{*model.NewFilterTypePartial()} + if !operation.WritePartial() { + filters = nil + updateData := &model.HvacSystemFunctionListDataType{ + HvacSystemFunctionData: data, + } + if mergedData, err := h.featureRemote.UpdateData(false, model.FunctionTypeHvacSystemFunctionListData, updateData, nil, nil); err == nil { + data = mergedData.([]model.HvacSystemFunctionDataType) + } + } + cmd := model.CmdType{ HvacSystemFunctionListData: &model.HvacSystemFunctionListDataType{ HvacSystemFunctionData: data, }, } + if filters != nil { + cmd.Filter = filters + cmd.Function = util.Ptr(model.FunctionTypeHvacSystemFunctionListData) + } return h.remoteDevice.Sender().Write(h.featureLocal.Address(), h.featureRemote.Address(), cmd) } diff --git a/features/client/hvac_test.go b/features/client/hvac_test.go index 677cdf37..f3e125a3 100644 --- a/features/client/hvac_test.go +++ b/features/client/hvac_test.go @@ -109,3 +109,32 @@ func (s *HvacSuite) Test_WriteHvacSystemFunctionListData() { assert.Nil(s.T(), err) assert.NotNil(s.T(), counter) } + +func (s *HvacSuite) Test_WriteHvacSystemFunctionListData_Partial() { + localEntity, remoteEntity := setupFeatures( + s.T(), + s, + []featureFunctions{ + { + featureType: model.FeatureTypeTypeHvac, + functions: []model.FunctionType{ + model.FunctionTypeHvacSystemFunctionListData, + }, + partial: true, + }, + }, + ) + + hvac, err := NewHvac(localEntity, remoteEntity) + assert.Nil(s.T(), err) + + data := []model.HvacSystemFunctionDataType{ + { + SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(1)), + CurrentOperationModeId: util.Ptr(model.HvacOperationModeIdType(2)), + }, + } + counter, err := hvac.WriteHvacSystemFunctionListData(data) + assert.Nil(s.T(), err) + assert.NotNil(s.T(), counter) +} diff --git a/features/client/setpoint.go b/features/client/setpoint.go index 26bda1a4..80143bcb 100644 --- a/features/client/setpoint.go +++ b/features/client/setpoint.go @@ -5,6 +5,7 @@ import ( "github.com/enbility/eebus-go/features/internal" spineapi "github.com/enbility/spine-go/api" "github.com/enbility/spine-go/model" + "github.com/enbility/spine-go/util" ) type Setpoint struct { @@ -73,11 +74,29 @@ func (s *Setpoint) WriteSetpointListData( return nil, api.ErrNotSupported } + // use a partial write when the server supports it, otherwise merge the + // modified entries into the cached list and write the complete list, so + // unrelated setpoints are not dropped by a full replacement + filters := []model.FilterType{*model.NewFilterTypePartial()} + if !operation.WritePartial() { + filters = nil + updateData := &model.SetpointListDataType{ + SetpointData: data, + } + if mergedData, err := s.featureRemote.UpdateData(false, model.FunctionTypeSetpointListData, updateData, nil, nil); err == nil { + data = mergedData.([]model.SetpointDataType) + } + } + cmd := model.CmdType{ SetpointListData: &model.SetpointListDataType{ SetpointData: data, }, } + if filters != nil { + cmd.Filter = filters + cmd.Function = util.Ptr(model.FunctionTypeSetpointListData) + } return s.remoteDevice.Sender().Write(s.featureLocal.Address(), s.featureRemote.Address(), cmd) } From 57ec3acf1dc1176b130168b4a3bc15ddccddd140 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 20 Jul 2026 20:13:29 +0200 Subject: [PATCH 7/7] Advertise Write in tests and cover the write-not-advertised path Now that the Write() advertisement gate is present in the Setpoint feature helper, the test fixture must advertise the Write operation for existing write tests to pass, and a dedicated test verifies WriteSetpoint returns ErrNotSupported when the remote advertises the setpoint list as read-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- usecases/ca/crct/public_test.go | 18 ++++++++++++++++++ usecases/ca/crct/testhelper_test.go | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/usecases/ca/crct/public_test.go b/usecases/ca/crct/public_test.go index d2bd8d84..daf5b8ca 100644 --- a/usecases/ca/crct/public_test.go +++ b/usecases/ca/crct/public_test.go @@ -1,6 +1,7 @@ package crct import ( + "github.com/enbility/eebus-go/api" ucapi "github.com/enbility/eebus-go/usecases/api" "github.com/enbility/spine-go/model" "github.com/enbility/spine-go/util" @@ -108,6 +109,23 @@ func (s *CaCRCTSuite) Test_WriteSetpoint_AmbiguousSystemFunction() { assert.NotNil(s.T(), err) } +func (s *CaCRCTSuite) Test_WriteSetpoint_WriteNotAdvertised() { + s.addHvacData() + + // re-advertise the setpoint list as read-only + rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.hvacRoomEntity, model.FeatureTypeTypeSetpoint, model.RoleTypeServer) + rFeature.SetOperations([]model.FunctionPropertyType{ + { + Function: util.Ptr(model.FunctionTypeSetpointListData), + PossibleOperations: &model.PossibleOperationsType{Read: &model.PossibleOperationsReadType{}}, + }, + }) + + // the write must be rejected when the remote does not advertise Write() + _, err := s.sut.WriteSetpoint(s.hvacRoomEntity, ucapi.HvacOperationModeTypeEco, 19, nil) + assert.ErrorIs(s.T(), err, api.ErrNotSupported) +} + // helpers func (s *CaCRCTSuite) addHvacData() { diff --git a/usecases/ca/crct/testhelper_test.go b/usecases/ca/crct/testhelper_test.go index bd0885f9..0d84ff1c 100644 --- a/usecases/ca/crct/testhelper_test.go +++ b/usecases/ca/crct/testhelper_test.go @@ -120,7 +120,8 @@ func setupDevices( supportedFct := model.FunctionPropertyType{ Function: util.Ptr(fct), PossibleOperations: &model.PossibleOperationsType{ - Read: &model.PossibleOperationsReadType{}, + Read: &model.PossibleOperationsReadType{}, + Write: &model.PossibleOperationsWriteType{}, }, } supportedFcts = append(supportedFcts, supportedFct)