From d8118ce57e18920bce6c467b5b02f61d04f40ef0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 31 Jul 2026 13:49:12 +0200 Subject: [PATCH] feat(api): send a declared driver command, and hold it for a bounded time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/drivers/{name}/control sends one command the driver declared and holds it; DELETE ends the hold early; /api/drivers/{name} shows what is set and until when. Deliberately outside control v2. Synthesising a RuntimePolicy for an unsigned driver is worse than doing nothing: HostEnv.permissionAllowed grants everything only while the policy is nil, so a policy without permissions silently blocks the driver's own MQTT, and LuaDriver.Command refuses a control v2 driver on the legacy path — v2 wants driver_command_v2 entrypoints no community driver has. Signed packages keep CommandV2 unchanged. What that costs: no host-enforced write scope, no host-verified evidence. What it keeps is the part that protects hardware. Core clamps to the declared bounds rather than trusting the Lua, and the declaration is the whole allowlist — an undeclared control is a 400, not a 200 for a command the Lua ignored, which is what the registry cannot otherwise tell apart. Every hold ends by itself, into the driver's own driver_default_mode rather than a value Core invented: only the driver knows what neutral is. Default 4 h, maximum 24 h, nothing survives a restart. Because the policy is nil the registry's lease machinery never arms, so the 300 s Lease.MaxDuration ceiling does not apply and the hold does not need to fight it. Tests drive a real registry and a real Lua driver, so they distinguish "Core sent it" from "Core said it sent it", and cover the case that bites: replacing a hold must stop the old timer from defaulting the device out from under the new setting. Co-Authored-By: Claude Opus 5 --- .changeset/driver-control-path.md | 31 +++ go/internal/api/api.go | 8 + go/internal/api/api_driver_control.go | 239 ++++++++++++++++++++ go/internal/api/api_driver_control_test.go | 242 +++++++++++++++++++++ go/internal/api/api_drivers_debug.go | 6 +- 5 files changed, 524 insertions(+), 2 deletions(-) create mode 100644 .changeset/driver-control-path.md create mode 100644 go/internal/api/api_driver_control.go create mode 100644 go/internal/api/api_driver_control_test.go diff --git a/.changeset/driver-control-path.md b/.changeset/driver-control-path.md new file mode 100644 index 00000000..627b64bb --- /dev/null +++ b/.changeset/driver-control-path.md @@ -0,0 +1,31 @@ +--- +"ftw": minor +--- + +An operator can now send a driver's declared command and hold it for a bounded +time. `POST /api/drivers/{name}/control` takes `{control, value, duration_s}`; +`DELETE` on the same path ends the hold early. The active hold appears on +`/api/drivers/{name}` so a UI can show what is set and until when. + +Deliberately outside control v2. A signed package binds a RuntimePolicy and +goes through `CommandV2` with its write scope, lease and evidence, unchanged. +A bundled or local driver has no policy, and synthesising one would be worse +than doing nothing: `HostEnv.permissionAllowed` grants everything only while +the policy is nil, so a policy without permissions silently blocks the driver's +own MQTT, and `LuaDriver.Command` refuses a control v2 driver on the legacy +path — v2 wants `driver_command_v2` entrypoints no community driver has. This +path leaves the policy layer untouched and validates against the catalog +declaration instead. + +What that costs, stated plainly: no host-enforced write scope, no host-verified +evidence. What it keeps is the part that protects hardware. Core clamps every +value to the declared bounds rather than trusting the Lua to do it — a driver +that forgets to clamp is exactly the driver this protects — and the driver's +own declaration is the whole allowlist, so an undeclared control is a 400 +rather than a 200 for a command the Lua silently ignored. + +Every hold ends by itself, and ending means calling the driver's own +`driver_default_mode` rather than writing a value Core invented: only the +driver knows what neutral is. Default 4 h, maximum 24 h, and nothing survives a +restart. An offset left behind by a browser tab that closed is a house heated +wrong for weeks. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index fa5ed888..806546e3 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -197,6 +197,12 @@ type Server struct { savingsCacheMu sync.Mutex savingsCache map[string]daySavings + // controlHolds is the one operator setting in force per driver, with the + // timer that ends it. Process-lifetime only, deliberately: a restart + // should leave no device held by a setting nobody remembers making. + controlHoldMu sync.Mutex + controlHolds map[string]*controlHold + versionUpdateMu sync.Mutex driverUpdateMu sync.Mutex backupMu sync.Mutex @@ -268,6 +274,8 @@ func (s *Server) routes() { s.handle("GET /api/drivers/{name}/logs", s.handleDriverLogs) s.handle("GET /api/logs", s.handleGlobalLogs) s.handle("GET /api/support/dump", s.handleSupportDump) + s.handle("POST /api/drivers/{name}/control", s.handleDriverControl) + s.handle("DELETE /api/drivers/{name}/control", s.handleDriverControlRelease) s.handle("POST /api/drivers/{name}/restart", s.handleDriverRestart) s.handle("POST /api/drivers/{name}/disable", s.handleDriverDisable) s.handle("POST /api/drivers/{name}/enable", s.handleDriverEnable) diff --git a/go/internal/api/api_driver_control.go b/go/internal/api/api_driver_control.go new file mode 100644 index 00000000..190080ce --- /dev/null +++ b/go/internal/api/api_driver_control.go @@ -0,0 +1,239 @@ +// Operator-facing driver controls: send one declared command, hold it for a +// bounded time, and hand the device back to itself when the hold ends. +// +// Deliberately outside control v2. A signed package binds a RuntimePolicy and +// goes through CommandV2 with its write scope, lease and evidence. A bundled +// or local driver has no policy, and giving it a synthesised one would be +// worse than doing nothing: HostEnv.permissionAllowed grants everything only +// while the policy is nil, so a policy without permissions silently blocks +// the driver's own MQTT, and LuaDriver.Command refuses a control v2 driver on +// the legacy path — v2 needs driver_command_v2 entrypoints that no community +// driver has. So this path leaves the policy layer untouched and validates +// against the driver's catalog declaration instead. +// +// What that costs is honest and worth stating: no host-enforced write scope +// and no host-verified evidence. What it keeps is the part that protects +// hardware — Core clamps every value to the declared bounds rather than +// trusting the Lua to do it, and every hold ends by itself. +package api + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/srcfl/ftw/go/internal/drivers" +) + +// A hold has to end on its own. An offset left behind by a browser tab that +// closed, or by an FTW that stopped answering, is a house heated wrong for +// weeks — the failure nobody notices until the bill. 24 h is long enough for +// "warm through the cold snap" and short enough that forgetting is cheap. +const ( + maxControlHoldSeconds = 24 * 60 * 60 + defaultControlHoldSeconds = 4 * 60 * 60 +) + +// controlHold is one active operator setting. The timer is what releases it; +// the fields are what the UI shows meanwhile. +type controlHold struct { + Control string `json:"control"` + Value *float64 `json:"value,omitempty"` + ExpiresAt int64 `json:"expires_at_ms"` + + timer *time.Timer +} + +type controlRequest struct { + Control string `json:"control"` + Value *float64 `json:"value"` + DurationS int `json:"duration_s"` +} + +// POST /api/drivers/{name}/control — send one declared command and hold it. +// +// The command must be declared by the driver's catalog entry. That is the +// whole allowlist: a driver that declares nothing can be commanded by nobody, +// and a typo'd control name is a 400 rather than a silent success. The Lua +// command hook returns no value for an action it does not know, which the +// registry cannot tell apart from a command that worked. +func (s *Server) handleDriverControl(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + writeJSON(w, 400, map[string]string{"error": "missing driver name"}) + return + } + var req controlRequest + if err := readJSON(r, &req); err != nil { + writeJSON(w, 400, map[string]string{"error": "invalid request"}) + return + } + + declared := s.driverControls(name) + if len(declared) == 0 { + writeJSON(w, 404, map[string]string{"error": "driver declares no controls"}) + return + } + var control *drivers.CatalogControl + for i := range declared { + if declared[i].ID == req.Control { + control = &declared[i] + break + } + } + if control == nil { + writeJSON(w, 400, map[string]string{"error": "unknown control"}) + return + } + if s.deps.Registry == nil { + writeJSON(w, 503, map[string]string{"error": "driver registry not available"}) + return + } + + payload := map[string]any{"action": control.ID} + var applied *float64 + if control.Input.Type == "number" { + if req.Value == nil { + writeJSON(w, 400, map[string]string{"error": "control requires a value"}) + return + } + // Clamp here rather than reject: the bounds came from the driver, and + // a UI that rounds differently should not fail an operator's click. + // Clamping in Core is the point — a driver that forgets to clamp is + // exactly the driver this protects. + value := clampToDeclared(*req.Value, control.Input) + applied = &value + payload["value"] = value + // Drivers written before this endpoint read their own key names. + // Sending both costs one JSON field and saves every such driver a + // rewrite; heishamon reads cmd.offset or cmd.value. + payload["offset"] = value + } + + body, err := json.Marshal(payload) + if err != nil { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + if err := s.deps.Registry.Send(r.Context(), name, body); err != nil { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + + seconds := req.DurationS + if seconds <= 0 { + seconds = defaultControlHoldSeconds + } + if seconds > maxControlHoldSeconds { + seconds = maxControlHoldSeconds + } + hold := s.armControlHold(name, control.ID, applied, time.Duration(seconds)*time.Second) + + writeJSON(w, 200, map[string]any{ + "control": control.ID, + "applied": applied, + "evidence": control.Evidence, + "expires_at_ms": hold.ExpiresAt, + }) +} + +// DELETE /api/drivers/{name}/control — end the hold now. +// +// Releasing means calling the driver's own default mode, not writing a value +// this package invented. Only the driver knows what neutral is: heishamon's +// is its configured safe_offset, which an operator may have moved. +func (s *Server) handleDriverControlRelease(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + writeJSON(w, 400, map[string]string{"error": "missing driver name"}) + return + } + if s.deps.Registry == nil { + writeJSON(w, 503, map[string]string{"error": "driver registry not available"}) + return + } + s.clearControlHold(name) + if err := s.deps.Registry.SendDefault(r.Context(), name); err != nil { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, 200, map[string]string{"status": "released"}) +} + +func clampToDeclared(value float64, in drivers.CatalogControlInput) float64 { + if in.Min != nil && value < *in.Min { + value = *in.Min + } + if in.Max != nil && value > *in.Max { + value = *in.Max + } + return value +} + +// armControlHold replaces any existing hold for the driver. One driver holds +// one control at a time: two overlapping holds on the same device would each +// expire into a default that undoes the other. +func (s *Server) armControlHold(name, control string, value *float64, d time.Duration) *controlHold { + s.controlHoldMu.Lock() + defer s.controlHoldMu.Unlock() + if s.controlHolds == nil { + s.controlHolds = make(map[string]*controlHold) + } + if existing, ok := s.controlHolds[name]; ok && existing.timer != nil { + existing.timer.Stop() + } + hold := &controlHold{ + Control: control, + Value: value, + ExpiresAt: time.Now().Add(d).UnixMilli(), + } + hold.timer = time.AfterFunc(d, func() { s.expireControlHold(name, hold) }) + s.controlHolds[name] = hold + return hold +} + +// expireControlHold hands the device back to itself. It checks identity +// first: a hold that was replaced or released already had its timer stopped, +// but a timer that had begun firing cannot be stopped, and defaulting a +// driver that an operator has just set again is the one wrong answer here. +func (s *Server) expireControlHold(name string, fired *controlHold) { + s.controlHoldMu.Lock() + current, ok := s.controlHolds[name] + if !ok || current != fired { + s.controlHoldMu.Unlock() + return + } + delete(s.controlHolds, name) + s.controlHoldMu.Unlock() + + if s.deps.Registry == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := s.deps.Registry.SendDefault(ctx, name); err != nil { + // Nothing to retry into: the driver is gone, wedged, or already in + // its default. Log rather than reschedule, so a dead driver does not + // leave a timer firing every ten seconds for the process lifetime. + slog.Warn("control hold expiry failed", "driver", name, "err", err) + } +} + +func (s *Server) clearControlHold(name string) { + s.controlHoldMu.Lock() + defer s.controlHoldMu.Unlock() + if hold, ok := s.controlHolds[name]; ok { + if hold.timer != nil { + hold.timer.Stop() + } + delete(s.controlHolds, name) + } +} + +func (s *Server) activeControlHold(name string) *controlHold { + s.controlHoldMu.Lock() + defer s.controlHoldMu.Unlock() + return s.controlHolds[name] +} diff --git a/go/internal/api/api_driver_control_test.go b/go/internal/api/api_driver_control_test.go new file mode 100644 index 00000000..8e4a644c --- /dev/null +++ b/go/internal/api/api_driver_control_test.go @@ -0,0 +1,242 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/drivers" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// A driver that declares one control, records what it was commanded and +// counts its own default-mode calls, so a test can tell "Core sent it" from +// "Core said it sent it". +const controlProbeLua = `DRIVER = { + id = "probe", + name = "Probe", + version = "1.0.0", + controls = { + { + id = "set_offset", + label = "Offset", + evidence = "readback", + input = { type = "number", min = -3, max = 3, step = 1, unit = "C" }, + }, + }, +} + +local applied = nil +local defaulted = 0 + +function driver_init(config) + host.set_make("Probe") + -- The default first poll is 5 s away, which would make every assertion + -- here a five-second wait for a value that was already set. + host.set_poll_interval(100) +end + +function driver_poll() + if applied ~= nil then host.emit_metric("applied", applied, "C") end + host.emit_metric("defaulted", defaulted, "n") + return 100 +end + +function driver_command(action, power_w, cmd) + if action == "set_offset" then + applied = tonumber(cmd and (cmd.offset or cmd.value)) + return true + end + return false +end + +function driver_default_mode() + defaulted = defaulted + 1 + applied = 0 +end +` + +func controlServer(t *testing.T) (*Server, *telemetry.Store) { + t.Helper() + dir := t.TempDir() + lua := filepath.Join(dir, "probe.lua") + if err := os.WriteFile(lua, []byte(controlProbeLua), 0o600); err != nil { + t.Fatal(err) + } + tel := telemetry.NewStore() + reg := drivers.NewRegistry(tel) + cfg := config.Driver{Name: "heat", Lua: lua} + if err := reg.Add(context.Background(), cfg); err != nil { + t.Fatalf("add driver: %v", err) + } + t.Cleanup(reg.ShutdownAll) + srv := New(&Deps{ + Tel: tel, + Registry: reg, + Cfg: &config.Config{Drivers: []config.Driver{cfg}}, + CfgMu: &sync.RWMutex{}, + DriverDir: dir, + ConfigPath: filepath.Join(dir, "config.yaml"), + }) + return srv, tel +} + +func post(t *testing.T, srv *Server, path, body string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + srv.Handler().ServeHTTP(rec, req) + return rec +} + +// waitMetric polls for a metric to reach want, so the test follows the +// driver's own poll loop rather than a sleep chosen by guess. +func waitMetric(t *testing.T, tel *telemetry.Store, driver, metric string, want float64) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + var last float64 + for time.Now().Before(deadline) { + if got, _, ok := tel.LatestMetric(driver, metric); ok { + last = got + if got == want { + return + } + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("%s/%s = %v, want %v", driver, metric, last, want) +} + +// The value reaches the driver, and Core clamps it to the declared bound +// rather than trusting the Lua to do it. +func TestDriverControlClampsAndReachesDriver(t *testing.T) { + srv, tel := controlServer(t) + + rec := post(t, srv, "/api/drivers/heat/control", + `{"control":"set_offset","value":99,"duration_s":600}`) + if rec.Code != http.StatusOK { + t.Fatalf("POST = %d, body %s", rec.Code, rec.Body.String()) + } + var resp struct { + Applied *float64 `json:"applied"` + Evidence string `json:"evidence"` + Expires int64 `json:"expires_at_ms"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Applied == nil || *resp.Applied != 3 { + t.Errorf("applied = %v, want 3 (clamped from 99)", resp.Applied) + } + if resp.Evidence != "readback" { + t.Errorf("evidence = %q", resp.Evidence) + } + if resp.Expires <= time.Now().UnixMilli() { + t.Errorf("expires_at_ms = %d, want in the future", resp.Expires) + } + waitMetric(t, tel, "heat", "applied", 3) +} + +// The declaration is the allowlist. A control the driver never declared is a +// 400, not a 200 for a command the Lua silently ignored. +func TestDriverControlRejectsUndeclared(t *testing.T) { + srv, _ := controlServer(t) + + rec := post(t, srv, "/api/drivers/heat/control", `{"control":"set_fan","value":1}`) + if rec.Code != http.StatusBadRequest { + t.Errorf("unknown control = %d, want 400 (body %s)", rec.Code, rec.Body.String()) + } + rec = post(t, srv, "/api/drivers/heat/control", `{"control":"set_offset"}`) + if rec.Code != http.StatusBadRequest { + t.Errorf("missing value = %d, want 400", rec.Code) + } + rec = post(t, srv, "/api/drivers/nosuch/control", `{"control":"set_offset","value":1}`) + if rec.Code != http.StatusNotFound { + t.Errorf("unknown driver = %d, want 404", rec.Code) + } +} + +func TestDriverControlHoldIsVisibleAndReleasable(t *testing.T) { + srv, tel := controlServer(t) + + if rec := post(t, srv, "/api/drivers/heat/control", + `{"control":"set_offset","value":2,"duration_s":600}`); rec.Code != http.StatusOK { + t.Fatalf("POST = %d", rec.Code) + } + + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/drivers/heat", nil)) + var detail driverDetailResp + if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil { + t.Fatal(err) + } + if detail.Hold == nil || detail.Hold.Control != "set_offset" { + t.Fatalf("hold = %+v, want set_offset", detail.Hold) + } + if detail.Hold.Value == nil || *detail.Hold.Value != 2 { + t.Errorf("hold value = %v, want 2", detail.Hold.Value) + } + + rec = httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, + httptest.NewRequest(http.MethodDelete, "/api/drivers/heat/control", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("DELETE = %d, body %s", rec.Code, rec.Body.String()) + } + // Releasing calls the driver's own default mode, not a value this + // package invented. + waitMetric(t, tel, "heat", "defaulted", 1) + if hold := srv.activeControlHold("heat"); hold != nil { + t.Errorf("hold survived release: %+v", hold) + } +} + +// The whole reason a hold is bounded: it has to end by itself. An offset that +// outlives the browser tab that set it heats a house wrong for weeks. +func TestDriverControlHoldExpiresIntoDefault(t *testing.T) { + srv, tel := controlServer(t) + + if rec := post(t, srv, "/api/drivers/heat/control", + `{"control":"set_offset","value":2,"duration_s":1}`); rec.Code != http.StatusOK { + t.Fatalf("POST = %d", rec.Code) + } + waitMetric(t, tel, "heat", "applied", 2) + waitMetric(t, tel, "heat", "defaulted", 1) + if hold := srv.activeControlHold("heat"); hold != nil { + t.Errorf("hold survived expiry: %+v", hold) + } +} + +// Replacing a hold must not leave the old timer able to default the device +// out from under the new setting. +func TestDriverControlReplacingHoldCancelsTheOldTimer(t *testing.T) { + srv, tel := controlServer(t) + + if rec := post(t, srv, "/api/drivers/heat/control", + `{"control":"set_offset","value":1,"duration_s":1}`); rec.Code != http.StatusOK { + t.Fatalf("first POST = %d", rec.Code) + } + if rec := post(t, srv, "/api/drivers/heat/control", + `{"control":"set_offset","value":-2,"duration_s":600}`); rec.Code != http.StatusOK { + t.Fatalf("second POST = %d", rec.Code) + } + waitMetric(t, tel, "heat", "applied", -2) + + // Past when the first hold would have fired. + time.Sleep(1500 * time.Millisecond) + if got, _, ok := tel.LatestMetric("heat", "defaulted"); ok && got != 0 { + t.Errorf("defaulted = %v, want 0 — the replaced timer still fired", got) + } + if got, _, ok := tel.LatestMetric("heat", "applied"); !ok || got != -2 { + t.Errorf("applied = %v, want -2 to survive", got) + } +} diff --git a/go/internal/api/api_drivers_debug.go b/go/internal/api/api_drivers_debug.go index 429d5359..1159fb96 100644 --- a/go/internal/api/api_drivers_debug.go +++ b/go/internal/api/api_drivers_debug.go @@ -36,9 +36,10 @@ type driverDetailResp struct { Metrics []telemetry.MetricSnapshot `json:"metrics"` Identity driverIdentityDTO `json:"identity"` // Controls are what this driver says an operator may command. Absent - // for every driver that only reports. Nothing sends them yet — this is - // the description, not the path. + // for every driver that only reports. Controls []drivers.CatalogControl `json:"controls,omitempty"` + // Hold is the operator setting in force, if any, and when it ends. + Hold *controlHold `json:"hold,omitempty"` } type readingDTO struct { @@ -107,6 +108,7 @@ func (s *Server) handleDriverDetail(w http.ResponseWriter, r *http.Request) { } } resp.Controls = s.driverControls(name) + resp.Hold = s.activeControlHold(name) writeJSON(w, 200, resp) }