]> git.giacomolli.uk - onehouse/blob - driver/alamo/http_client.go
Exchange bounded Alamo gateway requests
[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 gatewayRequestTimeout = 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 // Action changes one field or reads a thermostat, and returns its complete
58 // readback. It rejects a response for another MAC or more than one thermostat.
59 func (c *HTTPClient) Action(ctx context.Context, mac string, action Action) (State, error) {
60 mac, err := normalizeMAC(mac)
61 if err != nil {
62 return State{}, fmt.Errorf("validate action MAC: %w", err)
63 }
64
65 if action.Power != nil && action.TargetTemperature != nil {
66 return State{}, fmt.Errorf("action must change at most one field")
67 }
68
69 query := url.Values{"mac": {mac}}
70 if action.Power != nil {
71 query.Set("power", "0")
72
73 if *action.Power {
74 query.Set("power", "1")
75 }
76 }
77
78 if action.TargetTemperature != nil {
79 if !finite(*action.TargetTemperature) {
80 return State{}, fmt.Errorf("target temperature must be finite")
81 }
82
83 query.Set("stemp", strconv.FormatFloat(*action.TargetTemperature, 'f', -1, 64))
84 }
85
86 states, err := c.exchange(ctx, "/acao", query)
87 if err != nil {
88 return State{}, fmt.Errorf("exchange action: %w", err)
89 }
90
91 if len(states) != 1 || states[0].MAC != mac {
92 return State{}, fmt.Errorf("action response must contain the requested thermostat only")
93 }
94
95 return states[0], nil
96 }
97
98 func (c *HTTPClient) exchange(ctx context.Context, path string, query url.Values) (states []State, err error) {
99 ctx, cancel := context.WithTimeout(ctx, gatewayRequestTimeout)
100 defer cancel()
101
102 address := c.origin + path
103 if len(query) > 0 {
104 address += "?" + query.Encode()
105 }
106
107 request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
108 if err != nil {
109 return nil, fmt.Errorf("create gateway request")
110 }
111
112 response, err := c.client.Do(request)
113 if err != nil {
114 if ctx.Err() != nil {
115 return nil, ctx.Err()
116 }
117
118 return nil, fmt.Errorf("send gateway request")
119 }
120
121 defer func() {
122 closeErr := response.Body.Close()
123 if closeErr != nil {
124 err = errors.Join(err, fmt.Errorf("close gateway response"))
125 }
126 }()
127
128 if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
129 return nil, fmt.Errorf("gateway returns HTTP %d", response.StatusCode)
130 }
131
132 body, err := io.ReadAll(io.LimitReader(response.Body, gatewayResponseLimit+1))
133 if err != nil {
134 if ctx.Err() != nil {
135 return nil, ctx.Err()
136 }
137
138 return nil, fmt.Errorf("read gateway response")
139 }
140
141 if len(body) > gatewayResponseLimit {
142 return nil, fmt.Errorf("gateway response exceeds size limit")
143 }
144
145 var records []gatewayRecord
146
147 err = json.Unmarshal(body, &records)
148 if err != nil || bytes.Equal(bytes.TrimSpace(body), []byte("null")) {
149 return nil, fmt.Errorf("gateway response must be one record array")
150 }
151
152 seen := make(map[string]bool, len(records))
153
154 for _, record := range records {
155 state, recordErr := record.state()
156 if recordErr != nil {
157 return nil, fmt.Errorf("validate gateway record: %w", recordErr)
158 }
159
160 if seen[state.MAC] {
161 return nil, fmt.Errorf("gateway response repeats a MAC")
162 }
163
164 seen[state.MAC] = true
165 states = append(states, state)
166 }
167
168 return states, nil
169 }