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