]> git.giacomolli.uk - onehouse/commitdiff
Share the Alamo gateway request budget
authorAnderson A. Giacomolli <redacted>
Tue, 10 Nov 2026 23:43:18 +0000 (20:43 -0300)
committerAnderson A. Giacomolli <redacted>
Tue, 10 Nov 2026 23:43:18 +0000 (20:43 -0300)
Status polls and actions use one six-second request-start interval. The
wait honors caller cancellation and command expiry before another
exchange. Each exchange has its own five-second timeout after the wait.
Complete control settings and readback values keep the thermostat's
mode, target, temperatures, local heating action, and screen lock.

Issue: 5947f38

doc/alamo.rst
driver/alamo/control_settings.go [new file with mode: 0644]
driver/alamo/control_settings_test.go [new file with mode: 0644]
driver/alamo/fake_helper_test.go [new file with mode: 0644]
driver/alamo/gateway.go [new file with mode: 0644]
driver/alamo/gateway_test.go [new file with mode: 0644]
driver/alamo/http_client.go
driver/alamo/reported_control.go [new file with mode: 0644]
driver/alamo/reported_control_test.go [new file with mode: 0644]

index 365f713c85ea26ad919c22a339d01da20d0461a6..c9e6fde47ee62174b7eff135f25135036f3641c9 100644 (file)
@@ -19,3 +19,17 @@ 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.
 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.
+
+The driver request scheduler applies one minimum six-second
+request-start interval to status polls and actions together. Its
+operation timeout starts after the rate wait. Caller cancellation or a
+command deadline stops the wait without another exchange. A multi-step
+command can expire between actions. The gateway budget belongs to one
+driver owner.
+
+The private control value uses ``settings.mode`` (``off`` or ``heat``),
+``settings.target_temperature`` in degrees Celsius,
+``current_temperature``, ``floor_temperature``, ``action`` (``off``,
+``idle``, or ``heating``), and ``locked``. Commands contain the complete
+mode and target settings only. House-specific target bounds and steps
+belong to core validation. The driver accepts finite targets.
diff --git a/driver/alamo/control_settings.go b/driver/alamo/control_settings.go
new file mode 100644 (file)
index 0000000..6d87e34
--- /dev/null
@@ -0,0 +1,44 @@
+package alamo
+
+import (
+       "bytes"
+       "encoding/json"
+       "fmt"
+)
+
+type controlSettings struct {
+       Mode              string  `json:"mode"`
+       TargetTemperature float64 `json:"target_temperature"`
+}
+
+func decodeControl(value json.RawMessage) (controlSettings, error) {
+       var fields struct {
+               Mode   *string  `json:"mode"`
+               Target *float64 `json:"target_temperature"`
+       }
+
+       decoder := json.NewDecoder(bytes.NewReader(value))
+       decoder.DisallowUnknownFields()
+
+       err := decoder.Decode(&fields)
+       if err != nil {
+               return controlSettings{}, fmt.Errorf("decode control settings: %w", err)
+       }
+
+       if !json.Valid(value) || fields.Mode == nil || fields.Target == nil {
+               return controlSettings{}, fmt.Errorf("control settings must contain mode and target temperature")
+       }
+
+       if *fields.Mode != "off" && *fields.Mode != "heat" {
+               return controlSettings{}, fmt.Errorf("control mode must be off or heat")
+       }
+
+       if !finite(*fields.Target) {
+               return controlSettings{}, fmt.Errorf("target temperature must be finite")
+       }
+
+       return controlSettings{
+               Mode:              *fields.Mode,
+               TargetTemperature: *fields.Target,
+       }, nil
+}
diff --git a/driver/alamo/control_settings_test.go b/driver/alamo/control_settings_test.go
new file mode 100644 (file)
index 0000000..944cecc
--- /dev/null
@@ -0,0 +1,22 @@
+package alamo
+
+import (
+       "encoding/json"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestControlSettingsRequiresCompleteSupportedValues(t *testing.T) {
+       for _, value := range []string{`null`, `{}`, `{"mode":"heat"}`, `{"mode":"heat","target_temperature":null}`, `{"mode":"cool","target_temperature":20}`, `{"mode":"heat","target_temperature":20,"extra":1}`, `{"mode":"off","target_temperature":20} {}`, `[]`, `{"mode":"heat","target_temperature":"20"}`} {
+               _, err := decodeControl(json.RawMessage(value))
+               require.Error(t, err)
+       }
+
+       for _, mode := range []string{"off", "heat"} {
+               settings, err := decodeControl(json.RawMessage(`{"mode":"` + mode + `","target_temperature":21.5}`))
+               require.NoError(t, err)
+               require.Equal(t, mode, settings.Mode)
+               require.InDelta(t, 21.5, settings.TargetTemperature, 0.001)
+       }
+}
diff --git a/driver/alamo/fake_helper_test.go b/driver/alamo/fake_helper_test.go
new file mode 100644 (file)
index 0000000..03d36f2
--- /dev/null
@@ -0,0 +1,78 @@
+package alamo
+
+import (
+       "context"
+       "time"
+)
+
+const testMAC = "fc610e7f98aa"
+
+type fakeGateway struct {
+       states  []State
+       starts  []time.Time
+       actions []Action
+       err     error
+       delay   time.Duration
+}
+
+func (g *fakeGateway) Status(ctx context.Context) ([]State, error) {
+       err := g.request(ctx)
+       if err != nil {
+               return nil, err
+       }
+
+       return append([]State(nil), g.states...), nil
+}
+
+func (g *fakeGateway) Action(ctx context.Context, mac string, action Action) (State, error) {
+       err := g.request(ctx)
+       if err != nil {
+               return State{}, err
+       }
+
+       g.actions = append(g.actions, action)
+
+       for index := range g.states {
+               state := &g.states[index]
+               if state.MAC != mac {
+                       continue
+               }
+
+               if action.Power != nil {
+                       state.Power = *action.Power
+               }
+
+               if action.TargetTemperature != nil {
+                       state.TargetTemperature = *action.TargetTemperature
+               }
+
+               return *state, nil
+       }
+
+       return State{}, nil
+}
+
+func (g *fakeGateway) request(ctx context.Context) error {
+       g.starts = append(g.starts, time.Now())
+       if g.delay > 0 {
+               timer := time.NewTimer(g.delay)
+               defer timer.Stop()
+
+               select {
+               case <-ctx.Done():
+                       return ctx.Err()
+               case <-timer.C:
+               }
+       }
+
+       return g.err
+}
+
+func testState() State {
+       return State{
+               MAC:                testMAC,
+               TargetTemperature:  20,
+               CurrentTemperature: 19.5,
+               FloorTemperature:   23,
+       }
+}
diff --git a/driver/alamo/gateway.go b/driver/alamo/gateway.go
new file mode 100644 (file)
index 0000000..1bf0074
--- /dev/null
@@ -0,0 +1,79 @@
+package alamo
+
+import (
+       "context"
+       "fmt"
+       "time"
+)
+
+const (
+       gatewayRequestInterval = 6 * time.Second
+       gatewayExchangeTimeout = 5 * time.Second
+)
+
+type gateway struct {
+       client    Client
+       nextStart time.Time
+}
+
+func (g *gateway) status(ctx context.Context) ([]State, error) {
+       err := g.wait(ctx)
+       if err != nil {
+               return nil, fmt.Errorf("wait for status slot: %w", err)
+       }
+
+       ctx, cancel := context.WithTimeout(ctx, gatewayExchangeTimeout)
+       defer cancel()
+
+       states, err := g.client.Status(ctx)
+       if err != nil {
+               return nil, fmt.Errorf("read gateway status: %w", err)
+       }
+
+       return states, nil
+}
+
+func (g *gateway) action(ctx context.Context, mac string, action Action) (State, error) {
+       err := g.wait(ctx)
+       if err != nil {
+               return State{}, fmt.Errorf("wait for action slot: %w", err)
+       }
+
+       ctx, cancel := context.WithTimeout(ctx, gatewayExchangeTimeout)
+       defer cancel()
+
+       state, err := g.client.Action(ctx, mac, action)
+       if err != nil {
+               return State{}, fmt.Errorf("exchange gateway action: %w", err)
+       }
+
+       return state, nil
+}
+
+func (g *gateway) wait(ctx context.Context) error {
+       err := ctx.Err()
+       if err != nil {
+               return err
+       }
+
+       delay := time.Until(g.nextStart)
+       if delay > 0 {
+               timer := time.NewTimer(delay)
+               defer timer.Stop()
+
+               select {
+               case <-ctx.Done():
+                       return ctx.Err()
+               case <-timer.C:
+               }
+       }
+
+       err = ctx.Err()
+       if err != nil {
+               return err
+       }
+
+       g.nextStart = time.Now().Add(gatewayRequestInterval)
+
+       return nil
+}
diff --git a/driver/alamo/gateway_test.go b/driver/alamo/gateway_test.go
new file mode 100644 (file)
index 0000000..715e860
--- /dev/null
@@ -0,0 +1,87 @@
+package alamo
+
+import (
+       "context"
+       "errors"
+       "testing"
+       "testing/synctest"
+       "time"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestGatewaySharesSixSecondRequestStartInterval(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               client := &fakeGateway{states: []State{testState()}}
+               gateway := &gateway{client: client}
+               _, err := gateway.status(t.Context())
+               require.NoError(t, err)
+
+               _, err = gateway.action(t.Context(), testMAC, Action{})
+               require.NoError(t, err)
+
+               _, err = gateway.status(t.Context())
+               require.NoError(t, err)
+               require.Len(t, client.starts, 3)
+               require.Equal(t, 6*time.Second, client.starts[1].Sub(client.starts[0]))
+               require.Equal(t, 6*time.Second, client.starts[2].Sub(client.starts[1]))
+       })
+}
+
+func TestGatewayDoesNotSpendAnotherIntervalAfterSlowRequests(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               client := &fakeGateway{
+                       states: []State{testState()},
+                       delay:  4 * time.Second,
+               }
+               gateway := &gateway{client: client}
+               _, err := gateway.status(t.Context())
+               require.NoError(t, err)
+
+               _, err = gateway.status(t.Context())
+               require.NoError(t, err)
+               require.Equal(t, 6*time.Second, client.starts[1].Sub(client.starts[0]))
+       })
+}
+
+func TestGatewayExpiryAndCancellationStopRateWaitWithoutIO(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               client := &fakeGateway{states: []State{testState()}}
+               gateway := &gateway{client: client}
+               _, err := gateway.status(t.Context())
+               require.NoError(t, err)
+
+               ctx, cancel := context.WithTimeout(t.Context(), time.Second)
+               defer cancel()
+
+               _, err = gateway.action(ctx, testMAC, Action{})
+               require.ErrorIs(t, err, context.DeadlineExceeded)
+
+               ctx, cancel = context.WithCancel(t.Context())
+               cancel()
+
+               _, err = gateway.status(ctx)
+               require.ErrorIs(t, err, context.Canceled)
+               require.Len(t, client.starts, 1)
+       })
+}
+
+func TestGatewayBoundsFakeClientOperationsAndReturnsFailures(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               client := &fakeGateway{delay: time.Minute}
+               gateway := &gateway{client: client}
+               _, err := gateway.status(t.Context())
+               require.ErrorIs(t, err, context.DeadlineExceeded)
+
+               _, err = gateway.action(t.Context(), testMAC, Action{})
+               require.ErrorIs(t, err, context.DeadlineExceeded)
+
+               client.delay = 0
+               client.err = errors.New("gateway unavailable")
+               _, err = gateway.status(t.Context())
+               require.ErrorIs(t, err, client.err)
+
+               _, err = gateway.action(t.Context(), testMAC, Action{})
+               require.ErrorIs(t, err, client.err)
+       })
+}
index ac00dfc67fad978effb5b41cb08dea674c0a3e46..717912402c8f306cde39ac2031f5c66755ae06dd 100644 (file)
@@ -14,8 +14,8 @@ import (
 )
 
 const (
 )
 
 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
 )
 
 // HTTPClient exchanges bounded GET requests with one HTTP gateway origin. Each
@@ -96,7 +96,7 @@ func (c *HTTPClient) Action(ctx context.Context, mac string, action Action) (Sta
 }
 
 func (c *HTTPClient) exchange(ctx context.Context, path string, query url.Values) (states []State, err error) {
 }
 
 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
        defer cancel()
 
        address := c.origin + path
diff --git a/driver/alamo/reported_control.go b/driver/alamo/reported_control.go
new file mode 100644 (file)
index 0000000..f9ec769
--- /dev/null
@@ -0,0 +1,33 @@
+package alamo
+
+type reportedControl struct {
+       Settings           controlSettings `json:"settings"`
+       CurrentTemperature float64         `json:"current_temperature"`
+       FloorTemperature   float64         `json:"floor_temperature"`
+       Action             string          `json:"action"`
+       Locked             bool            `json:"locked"`
+}
+
+func newReportedControl(state State) reportedControl {
+       mode := "off"
+       action := "off"
+
+       if state.Power {
+               mode = "heat"
+               action = "idle"
+               if state.Active {
+                       action = "heating"
+               }
+       }
+
+       return reportedControl{
+               Settings: controlSettings{
+                       Mode:              mode,
+                       TargetTemperature: state.TargetTemperature,
+               },
+               CurrentTemperature: state.CurrentTemperature,
+               FloorTemperature:   state.FloorTemperature,
+               Action:             action,
+               Locked:             state.Locked,
+       }
+}
diff --git a/driver/alamo/reported_control_test.go b/driver/alamo/reported_control_test.go
new file mode 100644 (file)
index 0000000..8077721
--- /dev/null
@@ -0,0 +1,56 @@
+package alamo
+
+import (
+       "encoding/json"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestReportedControlPreservesReadbackAndLocalHeatingMeaning(t *testing.T) {
+       testCases := []struct {
+               name   string
+               power  bool
+               active bool
+               mode   string
+               action string
+       }{
+               {
+                       name:   "powered off has no heating action",
+                       mode:   "off",
+                       action: "off",
+               },
+               {
+                       name:   "powered thermostat can be idle",
+                       power:  true,
+                       mode:   "heat",
+                       action: "idle",
+               },
+               {
+                       name:   "active thermostat reports local heating",
+                       power:  true,
+                       active: true,
+                       mode:   "heat",
+                       action: "heating",
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       state := testState()
+                       state.Power = tc.power
+                       state.Active = tc.active
+                       state.Locked = true
+                       reported := newReportedControl(state)
+                       require.Equal(t, tc.mode, reported.Settings.Mode)
+                       require.Equal(t, tc.action, reported.Action)
+
+                       data, err := json.Marshal(reported)
+                       require.NoError(t, err)
+                       require.Contains(t, string(data), `"current_temperature":19.5`)
+                       require.Contains(t, string(data), `"floor_temperature":23`)
+                       require.Contains(t, string(data), `"target_temperature":20`)
+                       require.Contains(t, string(data), `"locked":true`)
+               })
+       }
+}