From: Anderson A. Giacomolli Date: Tue, 10 Nov 2026 23:40:19 +0000 (-0300) Subject: Exchange bounded Alamo gateway requests X-Git-Url: https://git.giacomolli.uk/onehouse/commitdiff_plain/db3091ee257788c92421a5b8b1af49d429ce8f8e?ds=inline Exchange bounded Alamo gateway requests 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 --- diff --git a/doc/alamo.rst b/doc/alamo.rst new file mode 100644 index 0000000..365f713 --- /dev/null +++ b/doc/alamo.rst @@ -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 index 0000000..d90aeee --- /dev/null +++ b/driver/alamo/action.go @@ -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 index 0000000..de70846 --- /dev/null +++ b/driver/alamo/client.go @@ -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 index 0000000..1020a50 --- /dev/null +++ b/driver/alamo/gateway_record.go @@ -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 index 0000000..ac00dfc --- /dev/null +++ b/driver/alamo/http_client.go @@ -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 index 0000000..7ee0e36 --- /dev/null +++ b/driver/alamo/http_client_test.go @@ -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 index 0000000..0242b17 --- /dev/null +++ b/driver/alamo/state.go @@ -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 +}