17 httpRequestTimeout = 5 * time.Second
18 gatewayResponseLimit = 64 * 1024
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 {
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")
41 copied.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
42 return http.ErrUseLastResponse
51 // Status reads all thermostats in one gateway exchange. An empty array is
53 func (c *HTTPClient) Status(ctx context.Context) ([]State, error) {
54 return c.exchange(ctx, "/status", nil)
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)
62 return State{}, fmt.Errorf("validate action MAC: %w", err)
65 if action.Power != nil && action.TargetTemperature != nil {
66 return State{}, fmt.Errorf("action must change at most one field")
69 query := url.Values{"mac": {mac}}
70 if action.Power != nil {
71 query.Set("power", "0")
74 query.Set("power", "1")
78 if action.TargetTemperature != nil {
79 if !finite(*action.TargetTemperature) {
80 return State{}, fmt.Errorf("target temperature must be finite")
83 query.Set("stemp", strconv.FormatFloat(*action.TargetTemperature, 'f', -1, 64))
86 states, err := c.exchange(ctx, "/acao", query)
88 return State{}, fmt.Errorf("exchange action: %w", err)
91 if len(states) != 1 || states[0].MAC != mac {
92 return State{}, fmt.Errorf("action response must contain the requested thermostat only")
98 func (c *HTTPClient) exchange(ctx context.Context, path string, query url.Values) (states []State, err error) {
99 ctx, cancel := context.WithTimeout(ctx, httpRequestTimeout)
102 address := c.origin + path
104 address += "?" + query.Encode()
107 request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
109 return nil, fmt.Errorf("create gateway request")
112 response, err := c.client.Do(request)
114 if ctx.Err() != nil {
115 return nil, ctx.Err()
118 return nil, fmt.Errorf("send gateway request")
122 closeErr := response.Body.Close()
124 err = errors.Join(err, fmt.Errorf("close gateway response"))
128 if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
129 return nil, fmt.Errorf("gateway returns HTTP %d", response.StatusCode)
132 body, err := io.ReadAll(io.LimitReader(response.Body, gatewayResponseLimit+1))
134 if ctx.Err() != nil {
135 return nil, ctx.Err()
138 return nil, fmt.Errorf("read gateway response")
141 if len(body) > gatewayResponseLimit {
142 return nil, fmt.Errorf("gateway response exceeds size limit")
145 var records []gatewayRecord
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")
152 seen := make(map[string]bool, len(records))
154 for _, record := range records {
155 state, recordErr := record.state()
156 if recordErr != nil {
157 return nil, fmt.Errorf("validate gateway record: %w", recordErr)
161 return nil, fmt.Errorf("gateway response repeats a MAC")
164 seen[state.MAC] = true
165 states = append(states, state)