From fa3148ce30d76116eec2cc1602fb83121271465e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 31 Jul 2026 12:23:14 +0200 Subject: [PATCH] feat(drivers): let a driver declare the commands an operator may send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings renders a hand-written branch per driver family, so a driver with a real control surface has no way to describe it and no way to reach a person. The Heishamon heat pump has had a verified curve-offset command since June and its author was told to write a Home Assistant automation instead. A `controls` list in the DRIVER block names the command, labels it, 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. Nothing sends these commands. This is the description; the path is separate work. Signed packages already carry the shape in RuntimeCommand, but only for drivers on the signed channel — a bundled or local driver has no policy, so the DRIVER block is where its declaration has to live. The existing pick helpers stop at the first closing brace and anchor to the start of a line, both right for flat fields and both wrong for a nested list written inline. This reads controls with its own brace matching rather than changing how every other field is parsed for the sake of one that did not exist yet. A declaration the UI could not render is dropped rather than surfaced half formed: no id, a type outside number/boolean/string, or a number missing either bound. Bounds are pointers because zero is a real bound — an undeclared minimum and a minimum of zero must not read the same. Co-Authored-By: Claude Opus 5 --- .changeset/driver-declared-controls.md | 26 +++ go/internal/api/api_driver_controls_test.go | 139 +++++++++++++ go/internal/api/api_drivers_debug.go | 44 ++++ go/internal/drivers/catalog.go | 5 + go/internal/drivers/catalog_controls.go | 207 +++++++++++++++++++ go/internal/drivers/catalog_controls_test.go | 190 +++++++++++++++++ 6 files changed, 611 insertions(+) create mode 100644 .changeset/driver-declared-controls.md create mode 100644 go/internal/api/api_driver_controls_test.go create mode 100644 go/internal/drivers/catalog_controls.go create mode 100644 go/internal/drivers/catalog_controls_test.go 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) + } + } + } +}