]> git.giacomolli.uk - onehouse/blob - driver/alamo/http_client.go
Poll and command shared Alamo thermostats
[onehouse] / driver / alamo / http_client.go
1 package alamo
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "net/url"
12 "strconv"
13 "time"
14 )
15
16 const (
17 httpRequestTimeout = 5 * time.Second
18 gatewayResponseLimit = 64 * 1024
19 )
20
21 // HTTPClient exchanges bounded GET requests with one HTTP gateway origin. Each
22 // exchange has a five-second caller-derived timeout. It rejects redirects and
23 // incomplete records, but permits extra device properties. It has no rate
24 // limiter. Use it through Driver to share the gateway request budget. Errors
25 // omit URLs, MAC values, query parameters, and response bodies.
26 type HTTPClient struct {
27 origin string
28 client http.Client
29 }
30
31 // NewHTTPClient validates an HTTP origin without credentials, path, query, or
32 // fragment. It copies client settings and keeps its caller-owned transport. The
33 // caller supplies a valid HTTP client and owns transport cleanup.
34 func NewHTTPClient(origin string, client *http.Client) (*HTTPClient, error) {
35 address, err := url.Parse(origin)
36 if err != nil || address.Scheme != "http" || address.Hostname() == "" || address.User != nil || address.Path != "" || address.RawQuery != "" || address.ForceQuery || address.Fragment != "" || address.Opaque != "" {
37 return nil, fmt.Errorf("gateway address must be an HTTP origin")
38 }
39
40 copied := *client
41 copied.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
42 return http.ErrUseLastResponse
43 }
44
45 return &HTTPClient{
46 origin: origin,
47 client: copied,
48 }, nil
49 }
50
51 // Status reads all thermostats in one gateway exchange. An empty array is
52 // valid.
53 func (c *HTTPClient) Status(ctx context.Context) ([]State, error) {
54 return c.exchange(ctx, "/status", nil)
55 }
56
57 func (c *HTTPClient) exchange(ctx context.Context, path string, query url.Values) (states []State, err error) {
58 ctx, cancel := context.WithTimeout(ctx, httpRequestTimeout)
59 defer cancel()
60
61 address := c.origin + path
62 if len(query) > 0 {
63 address += "?" + query.Encode()
64 }
65
66 request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
67 if err != nil {
68 return nil, fmt.Errorf("create gateway request")
69 }
70
71 response, err := c.client.Do(request)
72 if err != nil {
73 return nil, fmt.Errorf("send gateway request: %w", safeRequestError(err))
74 }
75
76 defer func() {
77 closeErr := response.Body.Close()
78 if closeErr != nil {
79 err = errors.Join(err, fmt.Errorf("close gateway response"))
80 }
81 }()
82
83 if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
84 return nil, fmt.Errorf("gateway returns HTTP %d", response.StatusCode)
85 }
86
87 body, err := io.ReadAll(io.LimitReader(response.Body, gatewayResponseLimit+1))
88 if err != nil {
89 return nil, fmt.Errorf("read gateway response: %w", safeRequestError(err))
90 }
91
92 if len(body) > gatewayResponseLimit {
93 return nil, fmt.Errorf("gateway response exceeds size limit")
94 }
95
96 var records []gatewayRecord
97
98 err = json.Unmarshal(body, &records)
99 if err != nil || bytes.Equal(bytes.TrimSpace(body), []byte("null")) {
100 return nil, fmt.Errorf("gateway response must be one record array")
101 }
102
103 seen := make(map[string]bool, len(records))
104
105 for _, record := range records {
106 var state State
107
108 state, err = record.state()
109 if err != nil {
110 return nil, fmt.Errorf("validate gateway record: %w", err)
111 }
112
113 if seen[state.MAC] {
114 return nil, fmt.Errorf("gateway response repeats a MAC")
115 }
116
117 seen[state.MAC] = true
118 states = append(states, state)
119 }
120
121 return states, nil
122 }
123
124 // Action changes one field or reads a thermostat, and returns its complete
125 // readback. It rejects a response for another MAC or more than one thermostat.
126 func (c *HTTPClient) Action(ctx context.Context, mac string, action Action) (State, error) {
127 mac, err := normalizeMAC(mac)
128 if err != nil {
129 return State{}, fmt.Errorf("validate action MAC: %w", err)
130 }
131
132 if action.Power != nil && action.TargetTemperature != nil {
133 return State{}, fmt.Errorf("action must change at most one field")
134 }
135
136 query := url.Values{"mac": {mac}}
137 if action.Power != nil {
138 query.Set("power", "0")
139
140 if *action.Power {
141 query.Set("power", "1")
142 }
143 }
144
145 if action.TargetTemperature != nil {
146 if !finite(*action.TargetTemperature) {
147 return State{}, fmt.Errorf("target temperature must be finite")
148 }
149
150 query.Set("stemp", strconv.FormatFloat(*action.TargetTemperature, 'f', -1, 64))
151 }
152
153 states, err := c.exchange(ctx, "/acao", query)
154 if err != nil {
155 return State{}, fmt.Errorf("exchange action: %w", err)
156 }
157
158 if len(states) != 1 || states[0].MAC != mac {
159 return State{}, fmt.Errorf("action response must contain the requested thermostat only")
160 }
161
162 return states[0], nil
163 }
164
165 func safeRequestError(err error) error {
166 var joined interface{ Unwrap() []error }
167
168 if errors.As(err, &joined) {
169 var causes []error
170
171 for _, cause := range joined.Unwrap() {
172 if cause != nil {
173 causes = append(causes, safeRequestError(cause))
174 }
175 }
176
177 return errors.Join(causes...)
178 }
179
180 if errors.Is(err, context.Canceled) {
181 return context.Canceled
182 }
183
184 if errors.Is(err, context.DeadlineExceeded) {
185 return context.DeadlineExceeded
186 }
187
188 return fmt.Errorf("gateway transport failed")
189 }