]> git.giacomolli.uk - onehouse/blob - driver/alamo/command_handler.go
Poll and command shared Alamo thermostats
[onehouse] / driver / alamo / command_handler.go
1 package alamo
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "time"
8
9 "onehouse/driver"
10 "onehouse/protocol"
11 )
12
13 type commandHandler struct {
14 gateway *gateway
15 thermostat *thermostatState
16 }
17
18 func (h *commandHandler) handle(ctx context.Context, commands []driver.Command) error {
19 for _, command := range commands {
20 err := h.one(ctx, command)
21 if err != nil {
22 return fmt.Errorf("process thermostat command: %w", err)
23 }
24 }
25
26 return nil
27 }
28
29 func (h *commandHandler) one(ctx context.Context, command driver.Command) error {
30 s := h.thermostat
31 if command.DeviceID != s.settings.DeviceID {
32 return fmt.Errorf("command device is not owned by this thermostat")
33 }
34
35 record, exists := s.ledger.Lookup(command.Metadata.MessageID)
36 if exists {
37 err := s.publisher.Result(ctx, s.settings.DeviceID, command.Metadata.MessageID, record.Metadata, record.Payload)
38 if err != nil {
39 return fmt.Errorf("replay thermostat result: %w", err)
40 }
41
42 return nil
43 }
44
45 reason := ""
46 settings, err := decodeControl(command.Payload.Value)
47
48 switch {
49 case command.Expired(time.Now()):
50 reason = "Command expired"
51 case command.Capability != "control":
52 reason = "Capability is not the thermostat control"
53 case err != nil:
54 reason = "Thermostat settings are invalid"
55 }
56
57 if reason != "" {
58 err = s.result(ctx, command, protocol.ResultPayload{
59 Status: "rejected",
60 Reason: reason,
61 })
62 if err != nil {
63 return fmt.Errorf("reject thermostat command: %w", err)
64 }
65
66 return nil
67 }
68
69 state, observedAt, applyErr := h.apply(ctx, command, settings)
70 payload := protocol.ResultPayload{Status: "completed"}
71 if applyErr != nil {
72 payload.Status = "failed"
73 payload.Reason = "Thermostat request or readback failed"
74 if command.Expired(time.Now()) {
75 payload.Reason = "Command expired"
76 }
77 }
78
79 record, err = s.ledger.Remember(command.Metadata.MessageID, payload)
80 if err != nil {
81 return fmt.Errorf("remember applied thermostat result: %w", err)
82 }
83
84 if ctx.Err() != nil {
85 if applyErr != nil {
86 return fmt.Errorf("stop thermostat command: %w", errors.Join(ctx.Err(), applyErr))
87 }
88
89 return ctx.Err()
90 }
91
92 if !observedAt.IsZero() {
93 err = s.record(ctx, state, observedAt, command.Metadata.MessageID)
94 if err != nil {
95 return fmt.Errorf("record command feedback: %w", err)
96 }
97 }
98
99 err = s.publisher.Result(ctx, s.settings.DeviceID, command.Metadata.MessageID, record.Metadata, record.Payload)
100 if err != nil {
101 return fmt.Errorf("publish applied thermostat result: %w", err)
102 }
103
104 return nil
105 }
106
107 func (h *commandHandler) apply(ctx context.Context, command driver.Command, settings controlSettings) (State, time.Time, error) {
108 ctx, cancel := context.WithDeadline(ctx, *command.Metadata.ExpiresAt)
109 defer cancel()
110
111 mac := h.thermostat.settings.MAC
112 state, err := h.gateway.action(ctx, mac, Action{})
113 if err != nil {
114 return State{}, time.Time{}, fmt.Errorf("read command baseline: %w", err)
115 }
116
117 observedAt := time.Now().UTC()
118
119 var next State
120
121 wantPower := settings.Mode == "heat"
122 if state.Power && (!wantPower || state.TargetTemperature != settings.TargetTemperature) {
123 power := false
124 next, err = h.gateway.action(ctx, mac, Action{Power: &power})
125 if err != nil {
126 return state, observedAt, fmt.Errorf("turn thermostat off: %w", err)
127 }
128
129 state = next
130 observedAt = time.Now().UTC()
131 if state.Power {
132 return state, observedAt, fmt.Errorf("thermostat remains on before target change")
133 }
134 }
135
136 if state.TargetTemperature != settings.TargetTemperature {
137 next, err = h.gateway.action(ctx, mac, Action{TargetTemperature: &settings.TargetTemperature})
138 if err != nil {
139 return state, observedAt, fmt.Errorf("set thermostat target: %w", err)
140 }
141
142 state = next
143 observedAt = time.Now().UTC()
144 if state.TargetTemperature != settings.TargetTemperature {
145 return state, observedAt, fmt.Errorf("thermostat target readback differs")
146 }
147 }
148
149 if wantPower && !state.Power {
150 power := true
151 next, err = h.gateway.action(ctx, mac, Action{Power: &power})
152 if err != nil {
153 return state, observedAt, fmt.Errorf("turn thermostat on: %w", err)
154 }
155
156 state = next
157 observedAt = time.Now().UTC()
158 }
159
160 if state.Power != wantPower || state.TargetTemperature != settings.TargetTemperature {
161 return state, observedAt, fmt.Errorf("thermostat settings readback differs")
162 }
163
164 return state, observedAt, nil
165 }