Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/driver-declared-controls.md
Original file line number Diff line number Diff line change
@@ -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".
139 changes: 139 additions & 0 deletions go/internal/api/api_driver_controls_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
44 changes: 44 additions & 0 deletions go/internal/api/api_drivers_debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions go/internal/drivers/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>. Used for things like Auth-Tokens that the
Expand Down Expand Up @@ -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
}

Expand Down
Loading