diff --git a/.changeset/driver-declared-controls.md b/.changeset/driver-declared-controls.md new file mode 100644 index 00000000..ca90e0f7 --- /dev/null +++ b/.changeset/driver-declared-controls.md @@ -0,0 +1,26 @@ +--- +"ftw": minor +--- + +Drivers can declare the commands an operator may send, and Core reads them. +A `controls` list in the `DRIVER` block names the command, gives it a label, +describes its single input — type, bounds, step, unit — and states what counts +as proof the device took it. `/api/drivers/{name}` and `/api/drivers/catalog` +surface it. + +This is the description, not the path: nothing sends these commands yet. It +exists because there is currently no way to describe one at all. Settings → +Devices renders a hand-written branch per driver family, so a driver with a +real control surface — the Heishamon heat pump's curve offset, verified +against hardware since June — has no way to reach a person, and its author is +told to write a Home Assistant automation instead (srcfl/ftw#520). + +Signed packages already carry this shape in `RuntimeCommand`, but only for +drivers shipping through the signed channel. Bundled and local drivers have no +policy, so the `DRIVER` block is where their declaration has to live. + +A declaration the UI could not render is dropped rather than surfaced half +formed: no id, an input type outside number/boolean/string, or a number +missing either bound. A slider with one end is a guess, not a control. Drivers +that declare nothing are unchanged, and the JSON key stays absent for them, so +a client can tell "reports only" from "declares an empty list". diff --git a/go/internal/api/api_driver_controls_test.go b/go/internal/api/api_driver_controls_test.go new file mode 100644 index 00000000..ee101036 --- /dev/null +++ b/go/internal/api/api_driver_controls_test.go @@ -0,0 +1,139 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +const controlDriverLua = `DRIVER = { + id = "probe", + name = "Probe", + version = "1.0.0", + controls = { + { + id = "set_heat_curve_offset", + label = "Heat curve offset", + evidence = "readback", + input = { type = "number", min = -3, max = 3, step = 1, unit = "°C" }, + }, + }, +} +` + +const quietDriverLua = `DRIVER = { + id = "quiet", + name = "Quiet", + version = "1.0.0", +} +` + +// serverWithDrivers wires the smallest Deps that /api/drivers/{name} needs: +// a driver directory to parse and a config naming the file, since the lookup +// goes driver name → configured lua path → catalog entry. +func serverWithDrivers(t *testing.T, files map[string]string, cfg *config.Config) *Server { + t.Helper() + dir := t.TempDir() + for name, body := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + for i := range cfg.Drivers { + cfg.Drivers[i].Lua = filepath.Join(dir, cfg.Drivers[i].Lua) + } + return New(&Deps{ + Tel: telemetry.NewStore(), + Cfg: cfg, + CfgMu: &sync.RWMutex{}, + DriverDir: dir, + ConfigPath: filepath.Join(dir, "config.yaml"), + }) +} + +func driverDetail(t *testing.T, srv *Server, name string) driverDetailResp { + t.Helper() + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/drivers/"+name, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/drivers/%s = %d, body %s", name, rec.Code, rec.Body.String()) + } + var got driverDetailResp + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + return got +} + +func TestDriverDetailSurfacesDeclaredControls(t *testing.T) { + srv := serverWithDrivers(t, + map[string]string{"probe.lua": controlDriverLua}, + &config.Config{Drivers: []config.Driver{{Name: "heat", Lua: "probe.lua"}}}) + + got := driverDetail(t, srv, "heat") + if len(got.Controls) != 1 { + t.Fatalf("controls = %+v, want 1", got.Controls) + } + c := got.Controls[0] + if c.ID != "set_heat_curve_offset" || c.Label != "Heat curve offset" || + c.Evidence != "readback" { + t.Errorf("control = %+v", c) + } + if c.Input.Type != "number" || c.Input.Unit != "°C" { + t.Errorf("input = %+v", c.Input) + } + if c.Input.Min == nil || *c.Input.Min != -3 || c.Input.Max == nil || *c.Input.Max != 3 { + t.Errorf("bounds = %v..%v", c.Input.Min, c.Input.Max) + } +} + +// A reporting-only driver must not grow a controls key. The UI decides +// whether to render anything by its presence, so an empty list and no list +// have to stay distinguishable in the JSON. +func TestDriverDetailOmitsControlsWhenNoneDeclared(t *testing.T) { + srv := serverWithDrivers(t, + map[string]string{"quiet.lua": quietDriverLua}, + &config.Config{Drivers: []config.Driver{{Name: "quiet", Lua: "quiet.lua"}}}) + + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/drivers/quiet", nil)) + var raw map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode: %v", err) + } + if _, present := raw["controls"]; present { + t.Errorf("controls key present for a reporting-only driver: %s", rec.Body.String()) + } +} + +// The declaration belongs to the driver file, not to the name in YAML. Two +// drivers on the same file both get it; a name that matches no configured +// driver gets nothing. +func TestDriverDetailResolvesControlsByFileNotName(t *testing.T) { + srv := serverWithDrivers(t, + map[string]string{"probe.lua": controlDriverLua, "quiet.lua": quietDriverLua}, + &config.Config{Drivers: []config.Driver{ + {Name: "upstairs", Lua: "probe.lua"}, + {Name: "downstairs", Lua: "probe.lua"}, + {Name: "meter", Lua: "quiet.lua"}, + }}) + + for _, name := range []string{"upstairs", "downstairs"} { + if got := driverDetail(t, srv, name); len(got.Controls) != 1 { + t.Errorf("%s: controls = %+v, want 1", name, got.Controls) + } + } + if got := driverDetail(t, srv, "meter"); len(got.Controls) != 0 { + t.Errorf("meter: controls = %+v, want none", got.Controls) + } + if got := driverDetail(t, srv, "not-configured"); len(got.Controls) != 0 { + t.Errorf("unknown driver: controls = %+v, want none", got.Controls) + } +} diff --git a/go/internal/api/api_drivers_debug.go b/go/internal/api/api_drivers_debug.go index aded6677..429d5359 100644 --- a/go/internal/api/api_drivers_debug.go +++ b/go/internal/api/api_drivers_debug.go @@ -35,6 +35,10 @@ type driverDetailResp struct { Readings []readingDTO `json:"readings"` 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. + Controls []drivers.CatalogControl `json:"controls,omitempty"` } type readingDTO struct { @@ -102,9 +106,49 @@ func (s *Server) handleDriverDetail(w http.ResponseWriter, r *http.Request) { resp.Identity = driverIdentityDTO{Make: make, SN: sn, MAC: mac, Endpoint: ep} } } + resp.Controls = s.driverControls(name) writeJSON(w, 200, resp) } +// driverControls returns the controls the configured driver `name` declares. +// +// The lookup goes name → configured lua path → catalog entry. A driver that +// is configured but whose file no longer parses returns nothing, which is the +// same answer as a driver that declares nothing. That is deliberate: a parse +// failure belongs in the catalog endpoint, where an operator is looking at +// driver files, not here. +func (s *Server) driverControls(name string) []drivers.CatalogControl { + if s.deps.Cfg == nil { + return nil + } + lua := "" + if s.deps.CfgMu != nil { + s.deps.CfgMu.RLock() + } + for _, d := range s.deps.Cfg.Drivers { + if d.Name == name { + lua = d.Lua + break + } + } + if s.deps.CfgMu != nil { + s.deps.CfgMu.RUnlock() + } + if lua == "" { + return nil + } + + dir := s.deps.DriverDir + if dir == "" { + dir = filepath.Join(filepath.Dir(s.deps.ConfigPath), "drivers") + } + entries, err := drivers.LoadCatalogMulti(s.deps.UserDriverDir, s.managedDriverDir(), dir) + if err != nil { + return nil + } + return drivers.ControlsForDriver(entries, lua) +} + // POST /api/drivers/test — start one short-lived driver instance from the // posted config, wait briefly for telemetry, and return whatever live values // it emitted. This lets Settings validate an unsaved driver without writing it diff --git a/go/internal/drivers/catalog.go b/go/internal/drivers/catalog.go index 7e7d09bb..8c71ea5e 100644 --- a/go/internal/drivers/catalog.go +++ b/go/internal/drivers/catalog.go @@ -58,6 +58,10 @@ type CatalogEntry struct { VerificationNotes string `json:"verification_notes,omitempty"` TestedModels []string `json:"tested_models,omitempty"` // e.g. ["Home", "Charge"] + // Controls are the operator-facing commands the driver declares. Empty + // for every driver that only reports. See catalog_controls.go. + Controls []CatalogControl `json:"controls,omitempty"` + // ConfigSecrets lists driver-specific config keys that the Settings // UI / setup wizard should render as password inputs and store // under config.. Used for things like Auth-Tokens that the @@ -194,6 +198,7 @@ func parseCatalogEntry(path string) (CatalogEntry, error) { e.VerificationNotes = pickString(block, "verification_notes") e.TestedModels = pickList(block, "tested_models") e.ConfigSecrets = pickList(block, "config_secrets") + e.Controls = pickControls(block) return e, nil } diff --git a/go/internal/drivers/catalog_controls.go b/go/internal/drivers/catalog_controls.go new file mode 100644 index 00000000..02a4c3d5 --- /dev/null +++ b/go/internal/drivers/catalog_controls.go @@ -0,0 +1,207 @@ +package drivers + +import ( + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// A driver that can be commanded says so here, in terms an operator UI can +// render without knowing the driver: what the command is called, what one +// input looks like, and what counts as proof the device took it. +// +// Core's own dispatch verbs (battery, curtail) are not declared. They are +// wired into the control tick and are not the operator's to press. This is +// for the commands that have no other way to reach a person — a heat curve +// offset, a mode selection — which today reach nobody at all. +// +// The signed package manifest carries the same shape in RuntimeCommand, but +// only for drivers that ship through the signed channel. A bundled or local +// driver has no policy, so this is where its declaration lives. +type CatalogControl struct { + ID string `json:"id"` + Label string `json:"label,omitempty"` + Evidence string `json:"evidence,omitempty"` + Input CatalogControlInput `json:"input"` +} + +// CatalogControlInput describes the single value a control carries. Min, max +// and step are pointers because zero is a real bound: a 0..100 percentage and +// an undeclared range must not read the same. +type CatalogControlInput struct { + Type string `json:"type"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` + Step *float64 `json:"step,omitempty"` + Unit string `json:"unit,omitempty"` +} + +var controlEvidence = map[string]bool{"readback": true, "write_ack": true} + +// ControlsForDriver returns the controls declared by the catalog entry for +// luaPath, matching the way IsEVOrVehicleDriver and IsReadOnlyDriver already +// do: portable path first, then filename. Operators write the lua path both +// ways — `drivers/heishamon.lua` and `/data/heishamon.lua` are both ordinary +// — and a control that appears for one spelling and not the other would look +// like the driver, not the lookup. +func ControlsForDriver(catalog []CatalogEntry, luaPath string) []CatalogControl { + if luaPath == "" { + return nil + } + wantPath := filepath.ToSlash(luaPath) + wantFilename := filepath.Base(wantPath) + for _, e := range catalog { + if strings.EqualFold(e.Path, wantPath) || strings.EqualFold(e.Filename, wantFilename) { + return e.Controls + } + } + return nil +} + +// pickControls reads the DRIVER block's `controls` list. +// +// The existing pick helpers stop at the first closing brace, which is why +// this does its own brace matching rather than extending them. Nothing else +// in the block nests, and making pickKVBlock nest would change how every +// field is read for the sake of one that did not exist yet. +// +// A declaration the UI cannot render is dropped rather than surfaced half +// formed: no id, an input type outside the vocabulary, or a number without +// both bounds. A slider with one end is not a control, it is a guess. +func pickControls(block string) []CatalogControl { + body := nestedBlock(block, "controls") + if body == "" { + return nil + } + var out []CatalogControl + for _, entry := range topLevelTables(body) { + control := CatalogControl{ + ID: fieldString(entry, "id"), + Label: fieldString(entry, "label"), + Evidence: fieldString(entry, "evidence"), + } + if control.ID == "" || !validControlToken(control.ID) { + continue + } + if control.Evidence != "" && !controlEvidence[control.Evidence] { + continue + } + input := nestedBlock(entry, "input") + if input == "" { + continue + } + control.Input = CatalogControlInput{ + Type: fieldString(input, "type"), + Unit: fieldString(input, "unit"), + Min: fieldNumber(input, "min"), + Max: fieldNumber(input, "max"), + Step: fieldNumber(input, "step"), + } + switch control.Input.Type { + case "number": + if control.Input.Min == nil || control.Input.Max == nil || + *control.Input.Min >= *control.Input.Max { + continue + } + case "boolean", "string": + default: + continue + } + out = append(out, control) + } + return out +} + +// nestedBlock returns the body of `name = { … }`, matching braces so an inner +// table does not end the outer one. Braces inside string literals are skipped; +// a topic or a label is allowed to contain one. +func nestedBlock(block, name string) string { + loc := regexp.MustCompile(regexp.QuoteMeta(name) + `\s*=\s*\{`).FindStringIndex(block) + if loc == nil { + return "" + } + depth := 0 + inString := false + for i := loc[1] - 1; i < len(block); i++ { + switch block[i] { + case '"': + // Lua has no escaped quote in the metadata this reads, and a + // backslash before one would be a driver bug, not a string. + inString = !inString + case '{': + if !inString { + depth++ + } + case '}': + if inString { + continue + } + depth-- + if depth == 0 { + return block[loc[1]:i] + } + } + } + return "" +} + +// topLevelTables splits a list body into its `{ … }` entries, ignoring braces +// nested inside each entry. +func topLevelTables(body string) []string { + var out []string + depth, start := 0, -1 + inString := false + for i := 0; i < len(body); i++ { + switch body[i] { + case '"': + inString = !inString + case '{': + if inString { + continue + } + if depth == 0 { + start = i + 1 + } + depth++ + case '}': + if inString { + continue + } + depth-- + if depth == 0 && start >= 0 { + out = append(out, body[start:i]) + start = -1 + } + } + } + return out +} + +// The pick helpers in catalog.go anchor to the start of a line, which is +// right for the DRIVER block's own fields and wrong here: a control is +// commonly written inline, `{ id = "x", input = { type = "number" } }`, where +// only the first field of each table begins a line. These match a field +// wherever it sits, while still requiring a full name so `min` does not hit +// inside `minimum`. +func fieldString(block, name string) string { + re := regexp.MustCompile(`(?:^|[\s,{])` + regexp.QuoteMeta(name) + `\s*=\s*"([^"]*)"`) + m := re.FindStringSubmatch(block) + if len(m) < 2 { + return "" + } + return m[1] +} + +func fieldNumber(block, name string) *float64 { + re := regexp.MustCompile(`(?:^|[\s,{])` + regexp.QuoteMeta(name) + `\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)`) + m := re.FindStringSubmatch(block) + if len(m) < 2 { + return nil + } + value, err := strconv.ParseFloat(strings.TrimSpace(m[1]), 64) + if err != nil { + return nil + } + return &value +} diff --git a/go/internal/drivers/catalog_controls_test.go b/go/internal/drivers/catalog_controls_test.go new file mode 100644 index 00000000..b2184831 --- /dev/null +++ b/go/internal/drivers/catalog_controls_test.go @@ -0,0 +1,190 @@ +package drivers + +import ( + "os" + "path/filepath" + "testing" +) + +// writeDriver puts a .lua file in a temp dir and parses it the way the +// catalog does, so these tests exercise the real entry point rather than +// pickControls alone. +func writeDriver(t *testing.T, body string) CatalogEntry { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "probe.lua") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + entry, err := ParseCatalogFile(path) + if err != nil { + t.Fatalf("parse: %v", err) + } + return entry +} + +const offsetControl = `DRIVER = { + id = "probe", + name = "Probe", + version = "1.0.0", + controls = { + { + id = "set_heat_curve_offset", + label = "Heat curve offset", + evidence = "readback", + input = { type = "number", min = -3, max = 3, step = 1, unit = "°C" }, + }, + }, +} +` + +func TestControlsParseFromDriverBlock(t *testing.T) { + entry := writeDriver(t, offsetControl) + + if entry.ID != "probe" || entry.Version != "1.0.0" { + t.Fatalf("nested controls broke the surrounding block: %+v", entry) + } + if len(entry.Controls) != 1 { + t.Fatalf("controls = %d, want 1", len(entry.Controls)) + } + got := entry.Controls[0] + if got.ID != "set_heat_curve_offset" || got.Label != "Heat curve offset" || + got.Evidence != "readback" { + t.Errorf("control = %+v", got) + } + if got.Input.Type != "number" || got.Input.Unit != "°C" { + t.Errorf("input = %+v", got.Input) + } + for name, pair := range map[string]struct { + got *float64 + want float64 + }{ + "min": {got.Input.Min, -3}, + "max": {got.Input.Max, 3}, + "step": {got.Input.Step, 1}, + } { + if pair.got == nil || *pair.got != pair.want { + t.Errorf("%s = %v, want %v", name, pair.got, pair.want) + } + } +} + +// A driver that declares nothing must stay exactly as it parsed before, or +// every existing catalog entry changes shape for a field it never had. +func TestControlsAbsentLeavesEntryUnchanged(t *testing.T) { + entry := writeDriver(t, `DRIVER = { + id = "probe", + capabilities = { "battery" }, +} +`) + if entry.Controls != nil { + t.Errorf("controls = %+v, want nil", entry.Controls) + } + if len(entry.Capabilities) != 1 || entry.Capabilities[0] != "battery" { + t.Errorf("capabilities = %v", entry.Capabilities) + } +} + +// Each of these is a declaration the UI could not render. Surfacing one puts +// a broken control in front of an operator; dropping it leaves the driver +// exactly as visible as it was before it tried. +func TestControlsDropUnrenderableDeclarations(t *testing.T) { + cases := map[string]string{ + "no id": `{ input = { type = "number", min = 0, max = 1 } }`, + "bad id": `{ id = "Set Offset", input = { type = "number", min = 0, max = 1 } }`, + "no input": `{ id = "set_offset" }`, + "unknown type": `{ id = "set_offset", input = { type = "table" } }`, + "number no min": `{ id = "set_offset", input = { type = "number", max = 3 } }`, + "number no max": `{ id = "set_offset", input = { type = "number", min = -3 } }`, + "inverted range": `{ id = "set_offset", input = { type = "number", min = 3, max = -3 } }`, + "bad evidence": `{ id = "set_offset", evidence = "trust_me", input = { type = "number", min = 0, max = 1 } }`, + } + for name, entry := range cases { + t.Run(name, func(t *testing.T) { + parsed := writeDriver(t, "DRIVER = {\n id = \"probe\",\n controls = {\n "+entry+",\n },\n}\n") + if len(parsed.Controls) != 0 { + t.Errorf("controls = %+v, want none", parsed.Controls) + } + }) + } +} + +// Zero is a real bound. If min were an ordinary float, an undeclared minimum +// and a minimum of zero would be the same value and a 0..100 control would +// silently become unbounded below. +func TestControlsKeepZeroBound(t *testing.T) { + entry := writeDriver(t, `DRIVER = { + id = "probe", + controls = { + { id = "set_limit", input = { type = "number", min = 0, max = 100, unit = "%" } }, + }, +} +`) + if len(entry.Controls) != 1 { + t.Fatalf("controls = %d, want 1", len(entry.Controls)) + } + min := entry.Controls[0].Input.Min + if min == nil || *min != 0 { + t.Errorf("min = %v, want 0", min) + } +} + +func TestControlsAcceptSeveralAndNonNumericInputs(t *testing.T) { + entry := writeDriver(t, `DRIVER = { + id = "probe", + controls = { + { id = "set_offset", input = { type = "number", min = -3, max = 3 } }, + { id = "set_boost", label = "Hot water boost", input = { type = "boolean" } }, + }, +} +`) + if len(entry.Controls) != 2 { + t.Fatalf("controls = %d, want 2", len(entry.Controls)) + } + if entry.Controls[1].ID != "set_boost" || entry.Controls[1].Input.Type != "boolean" { + t.Errorf("second control = %+v", entry.Controls[1]) + } +} + +// The brace matcher has to survive a brace inside a quoted string, or a label +// mentioning one truncates the declaration. +func TestControlsSurviveBraceInsideString(t *testing.T) { + entry := writeDriver(t, `DRIVER = { + id = "probe", + controls = { + { id = "set_offset", label = "Offset {zone 1}", input = { type = "number", min = -3, max = 3 } }, + }, +} +`) + if len(entry.Controls) != 1 { + t.Fatalf("controls = %d, want 1", len(entry.Controls)) + } + if entry.Controls[0].Label != "Offset {zone 1}" { + t.Errorf("label = %q", entry.Controls[0].Label) + } +} + +// Every bundled driver must still parse. This is the check that would catch +// a controls reader that quietly ate an unrelated field. +func TestBundledCatalogStillParses(t *testing.T) { + entries, err := LoadCatalog(filepath.Join("..", "..", "..", "drivers")) + if err != nil { + t.Fatalf("load catalog: %v", err) + } + if len(entries) == 0 { + t.Fatal("no bundled drivers parsed") + } + for _, e := range entries { + if e.ID == "" { + continue // files without a DRIVER block are returned deliberately + } + if e.Name == "" { + t.Errorf("%s: name went missing", e.ID) + } + for _, c := range e.Controls { + if c.ID == "" || c.Input.Type == "" { + t.Errorf("%s: surfaced an unrenderable control %+v", e.ID, c) + } + } + } +}