--- /dev/null
+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
+}
--- /dev/null
+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))}},
+ }
+}