From: Anderson A. Giacomolli Date: Tue, 10 Nov 2026 23:43:18 +0000 (-0300) Subject: Share the Alamo gateway request budget X-Git-Url: https://git.giacomolli.uk/onehouse/commitdiff_plain/b9a4f3bdd2609a8b178dd42f313c383ccb59049b?hp=db3091ee257788c92421a5b8b1af49d429ce8f8e Share the Alamo gateway request budget 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 --- diff --git a/doc/alamo.rst b/doc/alamo.rst index 365f713..c9e6fde 100644 --- a/doc/alamo.rst +++ b/doc/alamo.rst @@ -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 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 index 0000000..6d87e34 --- /dev/null +++ b/driver/alamo/control_settings.go @@ -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 index 0000000..944cecc --- /dev/null +++ b/driver/alamo/control_settings_test.go @@ -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 index 0000000..03d36f2 --- /dev/null +++ b/driver/alamo/fake_helper_test.go @@ -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 index 0000000..1bf0074 --- /dev/null +++ b/driver/alamo/gateway.go @@ -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 index 0000000..715e860 --- /dev/null +++ b/driver/alamo/gateway_test.go @@ -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) + }) +} diff --git a/driver/alamo/http_client.go b/driver/alamo/http_client.go index ac00dfc..7179124 100644 --- a/driver/alamo/http_client.go +++ b/driver/alamo/http_client.go @@ -14,8 +14,8 @@ import ( ) 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 @@ -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) { - ctx, cancel := context.WithTimeout(ctx, gatewayRequestTimeout) + ctx, cancel := context.WithTimeout(ctx, httpRequestTimeout) defer cancel() address := c.origin + path diff --git a/driver/alamo/reported_control.go b/driver/alamo/reported_control.go new file mode 100644 index 0000000..f9ec769 --- /dev/null +++ b/driver/alamo/reported_control.go @@ -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 index 0000000..8077721 --- /dev/null +++ b/driver/alamo/reported_control_test.go @@ -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`) + }) + } +}