]> git.giacomolli.uk - onehouse/commitdiff
Exchange bounded Alamo gateway requests
authorAnderson A. Giacomolli <redacted>
Tue, 10 Nov 2026 23:40:19 +0000 (20:40 -0300)
committerAnderson A. Giacomolli <redacted>
Tue, 10 Nov 2026 23:40:19 +0000 (20:40 -0300)
The Alamo client reads a complete gateway snapshot and sends one setting
per action. Each response supplies validated thermostat feedback. HTTP
exchanges have a caller-derived timeout and a size limit, reject
redirects, and omit addresses and response data from errors.

Issue: 5947f38

doc/alamo.rst [new file with mode: 0644]
driver/alamo/action.go [new file with mode: 0644]
driver/alamo/client.go [new file with mode: 0644]
driver/alamo/gateway_record.go [new file with mode: 0644]
driver/alamo/http_client.go [new file with mode: 0644]
driver/alamo/http_client_test.go [new file with mode: 0644]
driver/alamo/state.go [new file with mode: 0644]

diff --git a/doc/alamo.rst b/doc/alamo.rst
new file mode 100644 (file)
index 0000000..365f713
--- /dev/null
@@ -0,0 +1,21 @@
+#########################
+ Alamo Slim HTTP gateway
+#########################
+
+``driver/alamo`` defines a raw ``Client`` contract and an ``HTTPClient``
+for the Slim gateway. The client uses ``GET /status`` for all
+thermostats and ``GET /acao?mac=...`` for one thermostat. An action
+changes power or target temperature, not both at once. Its response is
+the complete readback for the requested MAC. The caller owns the HTTP
+transport.
+
+The address is an HTTP origin without a path, credentials, query, or
+fragment. Redirects are rejected. Each exchange has a five-second
+caller-derived timeout and a 64 KiB response limit. Records require all
+seven documented properties. Extra gateway properties are permitted.
+Errors omit request addresses, queries, and response bodies.
+
+Configure thermostats through the manufacturer's application before use.
+The gateway exposes local heating activity, not independent load-current
+or contactor feedback. Fake HTTP tests do not verify a physical gateway,
+thermostat firmware, or electrical safety.
diff --git a/driver/alamo/action.go b/driver/alamo/action.go
new file mode 100644 (file)
index 0000000..d90aeee
--- /dev/null
@@ -0,0 +1,9 @@
+package alamo
+
+// Action selects at most one change. Nil fields read the current state. The
+// driver sends power and target changes separately to preserve their order.
+// TargetTemperature is finite degrees Celsius. The caller retains the pointers.
+type Action struct {
+       Power             *bool
+       TargetTemperature *float64
+}
diff --git a/driver/alamo/client.go b/driver/alamo/client.go
new file mode 100644 (file)
index 0000000..de70846
--- /dev/null
@@ -0,0 +1,12 @@
+package alamo
+
+import "context"
+
+// Client performs one raw gateway exchange. The driver serializes exchanges and
+// applies the shared request interval. Implementations honor ctx, return full
+// validated records, and do not log. The caller owns the client and its
+// cleanup.
+type Client interface {
+       Status(ctx context.Context) ([]State, error)
+       Action(ctx context.Context, mac string, action Action) (State, error)
+}
diff --git a/driver/alamo/gateway_record.go b/driver/alamo/gateway_record.go
new file mode 100644 (file)
index 0000000..1020a50
--- /dev/null
@@ -0,0 +1,68 @@
+package alamo
+
+import (
+       "encoding/hex"
+       "fmt"
+       "math"
+       "strings"
+)
+
+type gatewayRecord struct {
+       MAC    *string  `json:"mac"`
+       Power  *int     `json:"power"`
+       Target *float64 `json:"stemp"`
+       Room   *float64 `json:"temp"`
+       Floor  *float64 `json:"sensor"`
+       Active *int     `json:"ativo"`
+       Locked *int     `json:"bloqueio"`
+}
+
+func (r gatewayRecord) state() (State, error) {
+       if r.MAC == nil || r.Power == nil || r.Target == nil || r.Room == nil || r.Floor == nil || r.Active == nil || r.Locked == nil {
+               return State{}, fmt.Errorf("gateway record is incomplete")
+       }
+
+       mac, err := normalizeMAC(*r.MAC)
+       if err != nil {
+               return State{}, fmt.Errorf("validate record MAC: %w", err)
+       }
+
+       if !binary(*r.Power) || !binary(*r.Active) || !binary(*r.Locked) {
+               return State{}, fmt.Errorf("gateway flags must be zero or one")
+       }
+
+       if !finite(*r.Target) || !finite(*r.Room) || !finite(*r.Floor) {
+               return State{}, fmt.Errorf("gateway temperatures must be finite")
+       }
+
+       return State{
+               MAC:                mac,
+               Power:              *r.Power == 1,
+               TargetTemperature:  *r.Target,
+               CurrentTemperature: *r.Room,
+               FloorTemperature:   *r.Floor,
+               Active:             *r.Active == 1,
+               Locked:             *r.Locked == 1,
+       }, nil
+}
+
+func normalizeMAC(value string) (string, error) {
+       if len(value) != 12 {
+               return "", fmt.Errorf("MAC must contain twelve hexadecimal digits")
+       }
+
+       _, err := hex.DecodeString(value)
+       if err != nil {
+               return "", fmt.Errorf("MAC must contain twelve hexadecimal digits")
+       }
+
+       return strings.ToLower(value), nil
+}
+
+func binary(value int) bool {
+       return value == 0 || value == 1
+}
+
+func finite(value float64) bool {
+       return !math.IsNaN(value) && !math.IsInf(value, 0)
+}
diff --git a/driver/alamo/http_client.go b/driver/alamo/http_client.go
new file mode 100644 (file)
index 0000000..ac00dfc
--- /dev/null
@@ -0,0 +1,169 @@
+package alamo
+
+import (
+       "bytes"
+       "context"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io"
+       "net/http"
+       "net/url"
+       "strconv"
+       "time"
+)
+
+const (
+       gatewayRequestTimeout = 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, gatewayRequestTimeout)
+       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
+}
diff --git a/driver/alamo/http_client_test.go b/driver/alamo/http_client_test.go
new file mode 100644 (file)
index 0000000..7ee0e36
--- /dev/null
@@ -0,0 +1,283 @@
+package alamo
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "io"
+       "math"
+       "net/http"
+       "strings"
+       "testing"
+       "testing/synctest"
+       "time"
+
+       "github.com/stretchr/testify/require"
+)
+
+const validRecord = `{"mac":"FC610E7F98AA","power":0,"stemp":20,"temp":19.5,"sensor":23,"ativo":0,"bloqueio":1}`
+
+func TestHTTPClientReadsAllRecordsAndOrderedActionFields(t *testing.T) {
+       var requests []*http.Request
+
+       transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
+               requests = append(requests, request)
+
+               return responseWith("[" + validRecord + "]"), nil
+       })
+
+       client, err := NewHTTPClient("http://example.com", &http.Client{Transport: transport})
+       require.NoError(t, err)
+
+       states, err := client.Status(t.Context())
+       require.NoError(t, err)
+       require.Len(t, states, 1)
+       require.Equal(t, "fc610e7f98aa", states[0].MAC)
+       require.InDelta(t, 19.5, states[0].CurrentTemperature, 0.001)
+       require.True(t, states[0].Locked)
+
+       target := 21.5
+       state, err := client.Action(t.Context(), "FC610E7F98AA", Action{TargetTemperature: &target})
+       require.NoError(t, err)
+       require.Equal(t, states[0], state)
+
+       power := true
+       _, err = client.Action(t.Context(), "fc610e7f98aa", Action{Power: &power})
+       require.NoError(t, err)
+
+       _, err = client.Action(t.Context(), "fc610e7f98aa", Action{})
+       require.NoError(t, err)
+       require.Equal(t, "/status", requests[0].URL.Path)
+       require.Equal(t, "/acao", requests[1].URL.Path)
+       require.Equal(t, "fc610e7f98aa", requests[1].URL.Query().Get("mac"))
+       require.Equal(t, "21.5", requests[1].URL.Query().Get("stemp"))
+       require.Equal(t, "1", requests[2].URL.Query().Get("power"))
+       require.Len(t, requests[3].URL.Query(), 1)
+       require.Equal(t, http.MethodGet, requests[0].Method)
+}
+
+func TestHTTPClientRejectsInvalidAndIncompleteResponses(t *testing.T) {
+       testCases := []struct {
+               name string
+               body string
+       }{
+               {
+                       name: "missing flags do not become false observations",
+                       body: `[{"mac":"fc610e7f98aa"}]`,
+               },
+               {
+                       name: "invalid binary flag is rejected",
+                       body: "[" + strings.Replace(validRecord, `"ativo":0`, `"ativo":2`, 1) + "]",
+               },
+               {
+                       name: "invalid MAC is rejected",
+                       body: "[" + strings.Replace(validRecord, "FC610E7F98AA", "no", 1) + "]",
+               },
+               {
+                       name: "same MAC cannot identify two records",
+                       body: "[" + validRecord + "," + validRecord + "]",
+               },
+               {
+                       name: "trailing document is rejected",
+                       body: "[] []",
+               },
+               {
+                       name: "null does not imply an empty gateway",
+                       body: "null",
+               },
+               {
+                       name: "response size is bounded",
+                       body: strings.Repeat(" ", gatewayResponseLimit+1),
+               },
+               {
+                       name: "non array is rejected",
+                       body: "{}",
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
+                               return responseWith(tc.body), nil
+                       })})
+                       require.NoError(t, err)
+
+                       _, err = client.Status(t.Context())
+                       require.Error(t, err)
+                       require.NotContains(t, err.Error(), tc.body)
+               })
+       }
+}
+
+func TestHTTPClientAcceptsEmptyArraysAndExtraProperties(t *testing.T) {
+       for _, body := range []string{"[]", "[" + strings.Replace(validRecord, `"power":0`, `"extra":1,"power":0`, 1) + "]"} {
+               client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
+                       return responseWith(body), nil
+               })})
+               require.NoError(t, err)
+
+               _, err = client.Status(t.Context())
+               require.NoError(t, err)
+       }
+}
+
+func TestHTTPClientRejectsUnsafeOriginsAndActions(t *testing.T) {
+       for _, origin := range []string{"https://example.com", "http://user@example.com", "http://example.com/", "http://example.com?token=secret", "http://example.com#x", "http://example.com?", "not a URL", "http://"} {
+               _, err := NewHTTPClient(origin, http.DefaultClient)
+               require.Error(t, err)
+               require.NotContains(t, err.Error(), origin)
+       }
+
+       client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
+               t.Fatal("invalid action must not send a request")
+
+               return nil, errors.New("unexpected request")
+       })})
+       require.NoError(t, err)
+
+       power := true
+       target := 20.0
+       _, err = client.Action(t.Context(), "bad", Action{})
+       require.Error(t, err)
+
+       _, err = client.Action(t.Context(), "fc610e7f98aa", Action{
+               Power:             &power,
+               TargetTemperature: &target,
+       })
+       require.Error(t, err)
+
+       target = math.Inf(1)
+       _, err = client.Action(t.Context(), "fc610e7f98aa", Action{TargetTemperature: &target})
+       require.Error(t, err)
+}
+
+func TestHTTPClientRejectsWrongActionReadback(t *testing.T) {
+       for _, body := range []string{"[]", "[" + validRecord + "," + strings.ReplaceAll(validRecord, "FC610E7F98AA", "fc610e7f98ab") + "]", "[" + strings.ReplaceAll(validRecord, "FC610E7F98AA", "fc610e7f98ab") + "]"} {
+               client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
+                       return responseWith(body), nil
+               })})
+               require.NoError(t, err)
+
+               _, err = client.Action(t.Context(), "fc610e7f98aa", Action{})
+               require.ErrorContains(t, err, "requested thermostat")
+       }
+}
+
+func TestHTTPClientBoundsTimeoutAndPreservesCancellation(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
+                       <-request.Context().Done()
+
+                       return nil, request.Context().Err()
+               })})
+               require.NoError(t, err)
+
+               start := time.Now()
+               _, err = client.Status(t.Context())
+               require.ErrorIs(t, err, context.DeadlineExceeded)
+               require.Equal(t, 5*time.Second, time.Since(start))
+
+               ctx, cancel := context.WithCancel(t.Context())
+               cancel()
+
+               _, err = client.Status(ctx)
+               require.ErrorIs(t, err, context.Canceled)
+       })
+}
+
+func TestHTTPClientOmitsSensitiveTransportAndBodyErrors(t *testing.T) {
+       testCases := []struct {
+               name     string
+               response *http.Response
+               err      error
+       }{
+               {
+                       name: "transport URL stays private",
+                       err:  errors.New("http://example.com/acao?token=secret"),
+               },
+               {
+                       name: "status body stays private",
+                       response: &http.Response{
+                               StatusCode: http.StatusForbidden,
+                               Body:       io.NopCloser(strings.NewReader("secret")),
+                       },
+               },
+               {
+                       name: "read error stays private",
+                       response: &http.Response{
+                               StatusCode: http.StatusOK,
+                               Body:       &brokenBody{readErr: errors.New("secret")},
+                       },
+               },
+               {
+                       name: "close error stays private",
+                       response: &http.Response{
+                               StatusCode: http.StatusOK,
+                               Body:       &brokenBody{closeErr: errors.New("secret")},
+                       },
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
+                               return tc.response, tc.err
+                       })})
+                       require.NoError(t, err)
+
+                       _, err = client.Status(t.Context())
+                       require.Error(t, err)
+                       require.NotContains(t, err.Error(), "secret")
+                       require.NotContains(t, err.Error(), "example.com")
+               })
+       }
+}
+
+func TestHTTPClientDoesNotFollowRedirects(t *testing.T) {
+       calls := 0
+       client, err := NewHTTPClient("http://example.com", &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
+               calls++
+
+               return &http.Response{
+                       StatusCode: http.StatusFound,
+                       Header:     http.Header{"Location": {"http://other.example.com"}},
+                       Body:       io.NopCloser(strings.NewReader("")),
+               }, nil
+       })})
+       require.NoError(t, err)
+
+       _, err = client.Status(t.Context())
+       require.ErrorContains(t, err, "302")
+       require.Equal(t, 1, calls)
+}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
+       return f(request)
+}
+
+type brokenBody struct {
+       readErr  error
+       closeErr error
+}
+
+func (b *brokenBody) Read(_ []byte) (int, error) {
+       if b.readErr != nil {
+               return 0, b.readErr
+       }
+
+       return 0, io.EOF
+}
+
+func (b *brokenBody) Close() error {
+       return b.closeErr
+}
+
+func responseWith(body string) *http.Response {
+       return &http.Response{
+               StatusCode: http.StatusOK,
+               Body:       io.NopCloser(strings.NewReader(body)),
+               Header:     http.Header{"Content-Length": {fmt.Sprint(len(body))}},
+       }
+}
diff --git a/driver/alamo/state.go b/driver/alamo/state.go
new file mode 100644 (file)
index 0000000..0242b17
--- /dev/null
@@ -0,0 +1,14 @@
+package alamo
+
+// State is one complete gateway observation. MAC contains twelve lowercase
+// hexadecimal digits. Temperatures are finite degrees Celsius. Active is local
+// heating activity, not independent load or contactor feedback.
+type State struct {
+       MAC                string
+       Power              bool
+       TargetTemperature  float64
+       CurrentTemperature float64
+       FloorTemperature   float64
+       Active             bool
+       Locked             bool
+}