]> git.giacomolli.uk - onehouse/blobdiff - driver/alamo/http_client.go
Poll and command shared Alamo thermostats
[onehouse] / driver / alamo / http_client.go
index ac00dfc67fad978effb5b41cb08dea674c0a3e46..4c645f405be125b452e20688069b920f17582118 100644 (file)
@@ -14,8 +14,8 @@ import (
 )
 
 const (
-       gatewayRequestTimeout = 5 * time.Second
-       gatewayResponseLimit  = 64 * 1024
+       httpRequestTimeout   = 5 * time.Second
+       gatewayResponseLimit = 64 * 1024
 )
 
 // HTTPClient exchanges bounded GET requests with one HTTP gateway origin. Each
@@ -54,49 +54,8 @@ 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, gatewayRequestTimeout)
+       ctx, cancel := context.WithTimeout(ctx, httpRequestTimeout)
        defer cancel()
 
        address := c.origin + path
@@ -111,11 +70,7 @@ func (c *HTTPClient) exchange(ctx context.Context, path string, query url.Values
 
        response, err := c.client.Do(request)
        if err != nil {
-               if ctx.Err() != nil {
-                       return nil, ctx.Err()
-               }
-
-               return nil, fmt.Errorf("send gateway request")
+               return nil, fmt.Errorf("send gateway request: %w", safeRequestError(err))
        }
 
        defer func() {
@@ -131,11 +86,7 @@ func (c *HTTPClient) exchange(ctx context.Context, path string, query url.Values
 
        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")
+               return nil, fmt.Errorf("read gateway response: %w", safeRequestError(err))
        }
 
        if len(body) > gatewayResponseLimit {
@@ -152,9 +103,11 @@ func (c *HTTPClient) exchange(ctx context.Context, path string, query url.Values
        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)
+               var state State
+
+               state, err = record.state()
+               if err != nil {
+                       return nil, fmt.Errorf("validate gateway record: %w", err)
                }
 
                if seen[state.MAC] {
@@ -167,3 +120,70 @@ func (c *HTTPClient) exchange(ctx context.Context, path string, query url.Values
 
        return states, 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 safeRequestError(err error) error {
+       var joined interface{ Unwrap() []error }
+
+       if errors.As(err, &joined) {
+               var causes []error
+
+               for _, cause := range joined.Unwrap() {
+                       if cause != nil {
+                               causes = append(causes, safeRequestError(cause))
+                       }
+               }
+
+               return errors.Join(causes...)
+       }
+
+       if errors.Is(err, context.Canceled) {
+               return context.Canceled
+       }
+
+       if errors.Is(err, context.DeadlineExceeded) {
+               return context.DeadlineExceeded
+       }
+
+       return fmt.Errorf("gateway transport failed")
+}