Skip to content

Commit 9306bd3

Browse files
andigclaude
andcommitted
Surface the device write result and fail closed on ambiguous ids
Point 5: the write methods discarded the message counter and reported success as soon as the request was sent, even if the device later rejected it. 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: systemFunctionId and overrunId returned the first matching entry. Return ErrDataNotAvailable unless exactly one DHW system function / one-time-DHW overrun matches, so the wrong entry is never controlled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dac0164 commit 9306bd3

4 files changed

Lines changed: 228 additions & 84 deletions

File tree

usecases/api/ca_cdsf.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
import (
44
"github.com/enbility/eebus-go/api"
55
spineapi "github.com/enbility/spine-go/api"
6+
"github.com/enbility/spine-go/model"
67
)
78

89
// Actor: Configuration Appliance
@@ -37,36 +38,49 @@ type CaCDSFInterface interface {
3738
// parameters:
3839
// - entity: the entity of the DHW circuit
3940
// - mode: the DHW operation mode to set
41+
// - resultCB: callback for the device result; a non-zero ResultData.ErrorNumber signals a rejected write
4042
//
4143
// possible errors:
4244
// - ErrNotSupported if the operation mode is not changeable or not supported
4345
// - ErrDataNotAvailable if the required data is not (yet) available
4446
// - and others
45-
WriteOperationMode(entity spineapi.EntityRemoteInterface, mode HvacOperationModeType) error
47+
WriteOperationMode(
48+
entity spineapi.EntityRemoteInterface,
49+
mode HvacOperationModeType,
50+
resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType),
51+
) (*model.MsgCounterType, error)
4652

4753
// Scenario 2
4854

4955
// start the one-time DHW loading overrun of the DHW circuit
5056
//
5157
// parameters:
5258
// - entity: the entity of the DHW circuit
59+
// - resultCB: callback for the device result; a non-zero ResultData.ErrorNumber signals a rejected write
5360
//
5461
// possible errors:
5562
// - ErrNotSupported if the overrun status is not changeable
5663
// - ErrDataNotAvailable if the required data is not (yet) available
5764
// - and others
58-
StartOneTimeDhw(entity spineapi.EntityRemoteInterface) error
65+
StartOneTimeDhw(
66+
entity spineapi.EntityRemoteInterface,
67+
resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType),
68+
) (*model.MsgCounterType, error)
5969

6070
// Scenario 3
6171

6272
// stop the one-time DHW loading overrun of the DHW circuit
6373
//
6474
// parameters:
6575
// - entity: the entity of the DHW circuit
76+
// - resultCB: callback for the device result; a non-zero ResultData.ErrorNumber signals a rejected write
6677
//
6778
// possible errors:
6879
// - ErrNotSupported if the overrun status is not changeable
6980
// - ErrDataNotAvailable if the required data is not (yet) available
7081
// - and others
71-
StopOneTimeDhw(entity spineapi.EntityRemoteInterface) error
82+
StopOneTimeDhw(
83+
entity spineapi.EntityRemoteInterface,
84+
resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType),
85+
) (*model.MsgCounterType, error)
7286
}

usecases/ca/cdsf/public.go

Lines changed: 83 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"github.com/enbility/eebus-go/api"
55
"github.com/enbility/eebus-go/features/client"
66
ucapi "github.com/enbility/eebus-go/usecases/api"
7+
"github.com/enbility/ship-go/logging"
78
spineapi "github.com/enbility/spine-go/api"
89
"github.com/enbility/spine-go/model"
910
"github.com/enbility/spine-go/util"
@@ -86,31 +87,38 @@ func (e *CDSF) CurrentOperationMode(entity spineapi.EntityRemoteInterface) (ucap
8687
}
8788

8889
// set the DHW operation mode of the DHW circuit,
89-
// returns ErrNotSupported if the operation mode is not changeable or not supported
90-
func (e *CDSF) WriteOperationMode(entity spineapi.EntityRemoteInterface, mode ucapi.HvacOperationModeType) error {
90+
// returns ErrNotSupported if the operation mode is not changeable or not supported.
91+
//
92+
// The returned message counter and the resultCB let the caller observe the
93+
// device result: a non-zero ResultData.ErrorNumber signals a rejected write.
94+
func (e *CDSF) WriteOperationMode(
95+
entity spineapi.EntityRemoteInterface,
96+
mode ucapi.HvacOperationModeType,
97+
resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType),
98+
) (*model.MsgCounterType, error) {
9199
if !e.IsCompatibleEntityType(entity) {
92-
return api.ErrNoCompatibleEntity
100+
return nil, api.ErrNoCompatibleEntity
93101
}
94102

95103
hvac, err := client.NewHvac(e.LocalEntity, entity)
96104
if err != nil {
97-
return err
105+
return nil, err
98106
}
99107

100108
systemFunctionId, err := e.systemFunctionId(entity)
101109
if err != nil {
102-
return err
110+
return nil, err
103111
}
104112

105113
data, err := hvac.GetHvacSystemFunctionForId(systemFunctionId)
106114
if err != nil {
107-
return api.ErrDataNotAvailable
115+
return nil, api.ErrDataNotAvailable
108116
}
109117

110118
// only an explicit false blocks the write; an omitted flag is tolerated, as
111119
// some devices accept the write without advertising the changeability flag
112120
if data.IsOperationModeIdChangeable != nil && !*data.IsOperationModeIdChangeable {
113-
return api.ErrNotSupported
121+
return nil, api.ErrNotSupported
114122
}
115123

116124
// resolve the requested mode through the DHW system-function relation, so a
@@ -120,7 +128,7 @@ func (e *CDSF) WriteOperationMode(entity spineapi.EntityRemoteInterface, mode uc
120128
}
121129
relations, err := hvac.GetHvacSystemFunctionOperationModeRelationsForFilter(relationFilter)
122130
if err != nil || len(relations) == 0 {
123-
return api.ErrDataNotAvailable
131+
return nil, api.ErrDataNotAvailable
124132
}
125133

126134
var modeId *model.HvacOperationModeIdType
@@ -140,7 +148,7 @@ func (e *CDSF) WriteOperationMode(entity spineapi.EntityRemoteInterface, mode uc
140148
}
141149
}
142150
if modeId == nil {
143-
return api.ErrNotSupported
151+
return nil, api.ErrNotSupported
144152
}
145153

146154
writeData := []model.HvacSystemFunctionDataType{
@@ -150,49 +158,63 @@ func (e *CDSF) WriteOperationMode(entity spineapi.EntityRemoteInterface, mode uc
150158
},
151159
}
152160

153-
_, err = hvac.WriteHvacSystemFunctionListData(writeData)
161+
msgCounter, err := hvac.WriteHvacSystemFunctionListData(writeData)
162+
e.registerResultCallback(hvac, msgCounter, resultCB)
154163

155-
return err
164+
return msgCounter, err
156165
}
157166

158167
// Scenario 2
159168

160169
// start the one-time DHW loading overrun of the DHW circuit,
161-
// returns ErrNotSupported if the overrun status is not changeable
162-
func (e *CDSF) StartOneTimeDhw(entity spineapi.EntityRemoteInterface) error {
163-
return e.writeOverrunStatus(entity, model.HvacOverrunStatusTypeActive)
170+
// returns ErrNotSupported if the overrun status is not changeable.
171+
//
172+
// The returned message counter and the resultCB let the caller observe the
173+
// device result: a non-zero ResultData.ErrorNumber signals a rejected write.
174+
func (e *CDSF) StartOneTimeDhw(
175+
entity spineapi.EntityRemoteInterface,
176+
resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType),
177+
) (*model.MsgCounterType, error) {
178+
return e.writeOverrunStatus(entity, model.HvacOverrunStatusTypeActive, resultCB)
164179
}
165180

166181
// Scenario 3
167182

168183
// stop the one-time DHW loading overrun of the DHW circuit,
169-
// returns ErrNotSupported if the overrun status is not changeable
170-
func (e *CDSF) StopOneTimeDhw(entity spineapi.EntityRemoteInterface) error {
171-
return e.writeOverrunStatus(entity, model.HvacOverrunStatusTypeInactive)
184+
// returns ErrNotSupported if the overrun status is not changeable.
185+
//
186+
// The returned message counter and the resultCB let the caller observe the
187+
// device result: a non-zero ResultData.ErrorNumber signals a rejected write.
188+
func (e *CDSF) StopOneTimeDhw(
189+
entity spineapi.EntityRemoteInterface,
190+
resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType),
191+
) (*model.MsgCounterType, error) {
192+
return e.writeOverrunStatus(entity, model.HvacOverrunStatusTypeInactive, resultCB)
172193
}
173194

174195
// write the status of the one-time DHW overrun
175196
func (e *CDSF) writeOverrunStatus(
176197
entity spineapi.EntityRemoteInterface,
177198
status model.HvacOverrunStatusType,
178-
) error {
199+
resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType),
200+
) (*model.MsgCounterType, error) {
179201
if !e.IsCompatibleEntityType(entity) {
180-
return api.ErrNoCompatibleEntity
202+
return nil, api.ErrNoCompatibleEntity
181203
}
182204

183205
hvac, err := client.NewHvac(e.LocalEntity, entity)
184206
if err != nil {
185-
return err
207+
return nil, err
186208
}
187209

188210
overrunId, err := e.overrunId(entity)
189211
if err != nil {
190-
return err
212+
return nil, err
191213
}
192214

193215
if data, err := hvac.GetHvacOverrunForId(overrunId); err == nil &&
194216
data.IsOverrunStatusChangeable != nil && !*data.IsOverrunStatusChangeable {
195-
return api.ErrNotSupported
217+
return nil, api.ErrNotSupported
196218
}
197219

198220
writeData := []model.HvacOverrunDataType{
@@ -202,12 +224,35 @@ func (e *CDSF) writeOverrunStatus(
202224
},
203225
}
204226

205-
_, err = hvac.WriteHvacOverrunListData(writeData)
227+
msgCounter, err := hvac.WriteHvacOverrunListData(writeData)
228+
e.registerResultCallback(hvac, msgCounter, resultCB)
206229

207-
return err
230+
return msgCounter, err
208231
}
209232

210-
// return the id of the one-time DHW overrun affecting the DHW system function
233+
// register a response callback that surfaces the device result of a write to
234+
// the caller, so a non-zero ResultData.ErrorNumber can be treated as a rejection
235+
func (e *CDSF) registerResultCallback(
236+
hvac *client.Hvac,
237+
msgCounter *model.MsgCounterType,
238+
resultCB func(result model.ResultDataType, msgCounter model.MsgCounterType),
239+
) {
240+
if resultCB == nil || msgCounter == nil {
241+
return
242+
}
243+
244+
cb := func(msg spineapi.ResponseMessage) {
245+
if response, ok := msg.Data.(*model.ResultDataType); ok {
246+
resultCB(*response, *msgCounter)
247+
}
248+
}
249+
if err := hvac.AddResponseCallback(*msgCounter, cb); err != nil {
250+
logging.Log().Debug("failed to add response callback for msgCounter %v: %v", msgCounter, err)
251+
}
252+
}
253+
254+
// return the id of the one-time DHW overrun affecting the DHW system function,
255+
// returns ErrDataNotAvailable unless exactly one matching overrun exists
211256
func (e *CDSF) overrunId(entity spineapi.EntityRemoteInterface) (model.HvacOverrunIdType, error) {
212257
hvac, err := client.NewHvac(e.LocalEntity, entity)
213258
if err != nil {
@@ -227,22 +272,30 @@ func (e *CDSF) overrunId(entity spineapi.EntityRemoteInterface) (model.HvacOverr
227272
return 0, api.ErrDataNotAvailable
228273
}
229274

275+
var overrunIds []model.HvacOverrunIdType
230276
for _, description := range descriptions {
231277
if description.OverrunId == nil {
232278
continue
233279
}
234280

235281
for _, affectedId := range description.AffectedSystemFunctionId {
236282
if affectedId == systemFunctionId {
237-
return *description.OverrunId, nil
283+
overrunIds = append(overrunIds, *description.OverrunId)
284+
break
238285
}
239286
}
240287
}
241288

242-
return 0, api.ErrDataNotAvailable
289+
// fail closed on an ambiguous result so the wrong overrun is never controlled
290+
if len(overrunIds) != 1 {
291+
return 0, api.ErrDataNotAvailable
292+
}
293+
294+
return overrunIds[0], nil
243295
}
244296

245-
// return the id of the DHW system function of the DHW circuit
297+
// return the id of the DHW system function of the DHW circuit,
298+
// returns ErrDataNotAvailable unless exactly one matching system function exists
246299
func (e *CDSF) systemFunctionId(entity spineapi.EntityRemoteInterface) (model.HvacSystemFunctionIdType, error) {
247300
hvac, err := client.NewHvac(e.LocalEntity, entity)
248301
if err != nil {
@@ -253,7 +306,8 @@ func (e *CDSF) systemFunctionId(entity spineapi.EntityRemoteInterface) (model.Hv
253306
SystemFunctionType: util.Ptr(model.HvacSystemFunctionTypeTypeDhw),
254307
}
255308
descriptions, err := hvac.GetHvacSystemFunctionDescriptionsForFilter(descFilter)
256-
if err != nil || len(descriptions) == 0 || descriptions[0].SystemFunctionId == nil {
309+
// fail closed on an ambiguous result so the wrong system function is never controlled
310+
if err != nil || len(descriptions) != 1 || descriptions[0].SystemFunctionId == nil {
257311
return 0, api.ErrDataNotAvailable
258312
}
259313

usecases/ca/cdsf/public_test.go

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,34 +41,34 @@ func (s *CaCDSFSuite) Test_CurrentOperationMode() {
4141
}
4242

4343
func (s *CaCDSFSuite) Test_WriteOperationMode() {
44-
err := s.sut.WriteOperationMode(s.mockRemoteEntity, ucapi.HvacOperationModeTypeOn)
44+
_, err := s.sut.WriteOperationMode(s.mockRemoteEntity, ucapi.HvacOperationModeTypeOn, nil)
4545
assert.NotNil(s.T(), err)
4646

47-
err = s.sut.WriteOperationMode(s.dhwCircuitEntity, ucapi.HvacOperationModeTypeOn)
47+
_, err = s.sut.WriteOperationMode(s.dhwCircuitEntity, ucapi.HvacOperationModeTypeOn, nil)
4848
assert.NotNil(s.T(), err)
4949

5050
s.addHvacData(util.Ptr(true))
5151

52-
err = s.sut.WriteOperationMode(s.dhwCircuitEntity, ucapi.HvacOperationModeTypeOn)
52+
_, err = s.sut.WriteOperationMode(s.dhwCircuitEntity, ucapi.HvacOperationModeTypeOn, nil)
5353
assert.Nil(s.T(), err)
5454

5555
// an unsupported mode cannot be written
56-
err = s.sut.WriteOperationMode(s.dhwCircuitEntity, ucapi.HvacOperationModeType("invalid"))
56+
_, err = s.sut.WriteOperationMode(s.dhwCircuitEntity, ucapi.HvacOperationModeType("invalid"), nil)
5757
assert.NotNil(s.T(), err)
5858
}
5959

6060
func (s *CaCDSFSuite) Test_WriteOperationMode_NotChangeable() {
6161
s.addHvacData(util.Ptr(false))
6262

63-
err := s.sut.WriteOperationMode(s.dhwCircuitEntity, ucapi.HvacOperationModeTypeOn)
63+
_, err := s.sut.WriteOperationMode(s.dhwCircuitEntity, ucapi.HvacOperationModeTypeOn, nil)
6464
assert.NotNil(s.T(), err)
6565
}
6666

6767
func (s *CaCDSFSuite) Test_WriteOperationMode_ChangeabilityOmitted() {
6868
// a device may omit the changeability flag but still accept the write
6969
s.addHvacData(nil)
7070

71-
err := s.sut.WriteOperationMode(s.dhwCircuitEntity, ucapi.HvacOperationModeTypeOn)
71+
_, err := s.sut.WriteOperationMode(s.dhwCircuitEntity, ucapi.HvacOperationModeTypeOn, nil)
7272
assert.Nil(s.T(), err)
7373
}
7474

@@ -101,34 +101,58 @@ func (s *CaCDSFSuite) Test_WriteOperationMode_UnrelatedMode() {
101101
assert.Nil(s.T(), fErr)
102102

103103
// the relation only lists modes 1, 2, 3, so the unrelated mode must not be written
104-
err := s.sut.WriteOperationMode(s.dhwCircuitEntity, ucapi.HvacOperationModeTypeOff)
104+
_, err := s.sut.WriteOperationMode(s.dhwCircuitEntity, ucapi.HvacOperationModeTypeOff, nil)
105105
assert.NotNil(s.T(), err)
106106
}
107107

108108
func (s *CaCDSFSuite) Test_StartStopOneTimeDhw() {
109-
err := s.sut.StartOneTimeDhw(s.mockRemoteEntity)
109+
_, err := s.sut.StartOneTimeDhw(s.mockRemoteEntity, nil)
110110
assert.NotNil(s.T(), err)
111111

112-
err = s.sut.StartOneTimeDhw(s.dhwCircuitEntity)
112+
_, err = s.sut.StartOneTimeDhw(s.dhwCircuitEntity, nil)
113113
assert.NotNil(s.T(), err)
114114

115115
s.addHvacData(util.Ptr(true))
116116

117-
err = s.sut.StartOneTimeDhw(s.dhwCircuitEntity)
117+
_, err = s.sut.StartOneTimeDhw(s.dhwCircuitEntity, nil)
118118
assert.NotNil(s.T(), err)
119119

120120
s.addOverrunData(true)
121121

122-
err = s.sut.StartOneTimeDhw(s.dhwCircuitEntity)
122+
_, err = s.sut.StartOneTimeDhw(s.dhwCircuitEntity, nil)
123123
assert.Nil(s.T(), err)
124124

125-
err = s.sut.StopOneTimeDhw(s.dhwCircuitEntity)
125+
_, err = s.sut.StopOneTimeDhw(s.dhwCircuitEntity, nil)
126126
assert.Nil(s.T(), err)
127127

128128
// the overrun status marked not changeable cannot be written
129129
s.addOverrunData(false)
130130

131-
err = s.sut.StartOneTimeDhw(s.dhwCircuitEntity)
131+
_, err = s.sut.StartOneTimeDhw(s.dhwCircuitEntity, nil)
132+
assert.NotNil(s.T(), err)
133+
}
134+
135+
func (s *CaCDSFSuite) Test_WriteOperationMode_AmbiguousSystemFunction() {
136+
s.addHvacData(util.Ptr(true))
137+
138+
// two DHW system functions make the id ambiguous, so the write must fail
139+
rFeature := s.remoteDevice.FeatureByEntityTypeAndRole(s.dhwCircuitEntity, model.FeatureTypeTypeHvac, model.RoleTypeServer)
140+
descData := &model.HvacSystemFunctionDescriptionListDataType{
141+
HvacSystemFunctionDescriptionData: []model.HvacSystemFunctionDescriptionDataType{
142+
{
143+
SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(1)),
144+
SystemFunctionType: util.Ptr(model.HvacSystemFunctionTypeTypeDhw),
145+
},
146+
{
147+
SystemFunctionId: util.Ptr(model.HvacSystemFunctionIdType(2)),
148+
SystemFunctionType: util.Ptr(model.HvacSystemFunctionTypeTypeDhw),
149+
},
150+
},
151+
}
152+
_, fErr := rFeature.UpdateData(true, model.FunctionTypeHvacSystemFunctionDescriptionListData, descData, nil, nil)
153+
assert.Nil(s.T(), fErr)
154+
155+
_, err := s.sut.WriteOperationMode(s.dhwCircuitEntity, ucapi.HvacOperationModeTypeOn, nil)
132156
assert.NotNil(s.T(), err)
133157
}
134158

0 commit comments

Comments
 (0)