package alamo import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" ) const ( httpRequestTimeout = 5 * time.Second gatewayResponseLimit = 64 * 1024 ) // HTTPClient exchanges bounded GET requests with one HTTP gateway origin. Each // exchange has a five-second caller-derived timeout. It rejects redirects and // incomplete records, but permits extra device properties. It has no rate // limiter. Use it through Driver to share the gateway request budget. Errors // omit URLs, MAC values, query parameters, and response bodies. type HTTPClient struct { origin string client http.Client } // NewHTTPClient validates an HTTP origin without credentials, path, query, or // fragment. It copies client settings and keeps its caller-owned transport. The // caller supplies a valid HTTP client and owns transport cleanup. func NewHTTPClient(origin string, client *http.Client) (*HTTPClient, error) { address, err := url.Parse(origin) if err != nil || address.Scheme != "http" || address.Hostname() == "" || address.User != nil || address.Path != "" || address.RawQuery != "" || address.ForceQuery || address.Fragment != "" || address.Opaque != "" { return nil, fmt.Errorf("gateway address must be an HTTP origin") } copied := *client copied.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } return &HTTPClient{ origin: origin, client: copied, }, nil } // Status reads all thermostats in one gateway exchange. An empty array is // valid. func (c *HTTPClient) Status(ctx context.Context) ([]State, error) { return c.exchange(ctx, "/status", nil) } // Action changes one field or reads a thermostat, and returns its complete // readback. It rejects a response for another MAC or more than one thermostat. func (c *HTTPClient) Action(ctx context.Context, mac string, action Action) (State, error) { mac, err := normalizeMAC(mac) if err != nil { return State{}, fmt.Errorf("validate action MAC: %w", err) } if action.Power != nil && action.TargetTemperature != nil { return State{}, fmt.Errorf("action must change at most one field") } query := url.Values{"mac": {mac}} if action.Power != nil { query.Set("power", "0") if *action.Power { query.Set("power", "1") } } if action.TargetTemperature != nil { if !finite(*action.TargetTemperature) { return State{}, fmt.Errorf("target temperature must be finite") } query.Set("stemp", strconv.FormatFloat(*action.TargetTemperature, 'f', -1, 64)) } states, err := c.exchange(ctx, "/acao", query) if err != nil { return State{}, fmt.Errorf("exchange action: %w", err) } if len(states) != 1 || states[0].MAC != mac { return State{}, fmt.Errorf("action response must contain the requested thermostat only") } return states[0], nil } func (c *HTTPClient) exchange(ctx context.Context, path string, query url.Values) (states []State, err error) { ctx, cancel := context.WithTimeout(ctx, httpRequestTimeout) defer cancel() address := c.origin + path if len(query) > 0 { address += "?" + query.Encode() } request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil) if err != nil { return nil, fmt.Errorf("create gateway request") } response, err := c.client.Do(request) if err != nil { if ctx.Err() != nil { return nil, ctx.Err() } return nil, fmt.Errorf("send gateway request") } defer func() { closeErr := response.Body.Close() if closeErr != nil { err = errors.Join(err, fmt.Errorf("close gateway response")) } }() if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { return nil, fmt.Errorf("gateway returns HTTP %d", response.StatusCode) } body, err := io.ReadAll(io.LimitReader(response.Body, gatewayResponseLimit+1)) if err != nil { if ctx.Err() != nil { return nil, ctx.Err() } return nil, fmt.Errorf("read gateway response") } if len(body) > gatewayResponseLimit { return nil, fmt.Errorf("gateway response exceeds size limit") } var records []gatewayRecord err = json.Unmarshal(body, &records) if err != nil || bytes.Equal(bytes.TrimSpace(body), []byte("null")) { return nil, fmt.Errorf("gateway response must be one record array") } seen := make(map[string]bool, len(records)) for _, record := range records { state, recordErr := record.state() if recordErr != nil { return nil, fmt.Errorf("validate gateway record: %w", recordErr) } if seen[state.MAC] { return nil, fmt.Errorf("gateway response repeats a MAC") } seen[state.MAC] = true states = append(states, state) } return states, nil }