Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/thermal-control-declaration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": patch
---

A driver can now say what a control does to heat. A control may declare `asset` (space_heat, dhw, cooling or buffer), `strategy` (curve_offset, setpoint, on_off or discrete_mode), `zone`, `min_interval_s`, `device_hold_max_s` (how long the device keeps a value before it returns to its own regulation), and for on_off `min_on_s` and `min_off_s`. A number input may declare its `default`, the value the device runs at by itself. A driver may also list `sensors`: the metrics that are a heat store's temperature. `/api/drivers/{name}` shows both. Nothing plans for or sends a heat command because of them yet.

A string control may declare a closed set of `options`. The control API then refuses any other value instead of passing it to the driver. A control that declares these fields wrongly is left out, as an unrenderable control is today. Drivers that declare none of them are unchanged.
7 changes: 7 additions & 0 deletions go/internal/api/api_driver_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"log/slog"
"math"
"net/http"
"slices"
"sync"
"time"

Expand Down Expand Up @@ -241,6 +242,12 @@ func decodeControlValue(raw json.RawMessage, in drivers.CatalogControlInput) (an
if err := json.Unmarshal(raw, &value); err != nil {
return nil, errors.New("control requires a string value")
}
// A declared closed set is the whole allowlist, as the declared
// bounds are for a number. Unlike a number there is no nearest
// value to clamp to, so anything else is refused.
if len(in.Options) > 0 && !slices.Contains(in.Options, value) {
return nil, errors.New("control value is not one of the declared options")
}
return value, nil
default:
return nil, fmt.Errorf("control has unsupported input type %q", in.Type)
Expand Down
128 changes: 128 additions & 0 deletions go/internal/api/api_driver_thermal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package api

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/config"
)

const controlOptionsProbeLua = `DRIVER = {
id = "probe_options",
name = "Probe options",
version = "1.0.0",
controls = {
{
id = "set_mode", asset = "dhw", strategy = "discrete_mode",
input = { type = "string", options = { "eco", "boost" } },
},
},
sensors = {
{ metric = "tank_temp_c", asset = "dhw" },
},
}

local applied = 0

function driver_init(config)
host.set_make("Probe options")
host.set_poll_interval(100)
end

function driver_poll()
host.emit_metric("applied", applied, "n")
return 100
end

function driver_command(action, power_w, cmd)
if action == "set_mode" then
if cmd.value == "eco" then applied = 1 elseif cmd.value == "boost" then applied = 2 else applied = 9 end
return true
end
return false
end

function driver_default_mode()
applied = 0
end
`

// A declared closed set is the allowlist. A value outside it is a 400 and
// never reaches the driver, which would otherwise have to guess.
func TestDriverControlRefusesValueOutsideDeclaredOptions(t *testing.T) {
srv, tel := controlServerWithLua(t, controlOptionsProbeLua)

rec := post(t, srv, "/api/drivers/heat/control", `{"control":"set_mode","value":"turbo","duration_s":600}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("undeclared option = %d, want 400 (body %s)", rec.Code, rec.Body.String())
}
if hold := srv.activeControlHold("heat"); hold != nil {
t.Fatalf("hold = %+v after a refused value, want none", hold)
}
// Give the driver a few polls; a refused value must not have been sent.
time.Sleep(300 * time.Millisecond)
if v, _, ok := tel.LatestMetric("heat", "applied"); ok && v == 9 {
t.Fatalf("the refused value reached the driver")
}

rec = post(t, srv, "/api/drivers/heat/control", `{"control":"set_mode","value":"boost","duration_s":600}`)
if rec.Code != http.StatusOK {
t.Fatalf("declared option = %d, body %s", rec.Code, rec.Body.String())
}
waitMetric(t, tel, "heat", "applied", 2)
}

func TestDriverDetailSurfacesThermalDeclaration(t *testing.T) {
srv := serverWithDrivers(t,
map[string]string{"probe.lua": controlOptionsProbeLua},
&config.Config{Drivers: []config.Driver{{Name: "tank", Lua: "probe.lua"}}})

got := driverDetail(t, srv, "tank")
if len(got.Controls) != 1 || got.Controls[0].Thermal == nil {
t.Fatalf("controls = %+v, want one thermal control", got.Controls)
}
th := got.Controls[0].Thermal
if th.Asset != "dhw" || th.Strategy != "discrete_mode" {
t.Errorf("thermal = %+v", th)
}
if opts := got.Controls[0].Input.Options; len(opts) != 2 || opts[0] != "eco" || opts[1] != "boost" {
t.Errorf("options = %v", opts)
}
if len(got.Sensors) != 1 || got.Sensors[0].Metric != "tank_temp_c" || got.Sensors[0].Asset != "dhw" {
t.Errorf("sensors = %+v", got.Sensors)
}
}

// A driver that declares no heat role keeps its JSON exactly as before: no
// sensors key, and no thermal key on its controls.
func TestDriverDetailOmitsThermalKeysWhenNoneDeclared(t *testing.T) {
srv := serverWithDrivers(t,
map[string]string{"probe.lua": controlDriverLua},
&config.Config{Drivers: []config.Driver{{Name: "heat", Lua: "probe.lua"}}})

rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/drivers/heat", nil))
var raw struct {
Sensors json.RawMessage `json:"sensors"`
Controls []json.RawMessage `json:"controls"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
t.Fatalf("decode: %v", err)
}
if raw.Sensors != nil {
t.Errorf("sensors key present: %s", rec.Body.String())
}
if len(raw.Controls) != 1 {
t.Fatalf("controls = %d, want 1", len(raw.Controls))
}
var control map[string]any
if err := json.Unmarshal(raw.Controls[0], &control); err != nil {
t.Fatal(err)
}
if _, present := control["thermal"]; present {
t.Errorf("thermal key present on a control that declares none: %s", raw.Controls[0])
}
}
21 changes: 18 additions & 3 deletions go/internal/api/api_drivers_debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ type driverDetailResp struct {
// Controls are what this driver says an operator may command. Absent
// for every driver that only reports.
Controls []drivers.CatalogControl `json:"controls,omitempty"`
// Sensors are the metrics this driver says are a heat store's
// temperature. Absent for every driver that declares none.
Sensors []drivers.CatalogSensor `json:"sensors,omitempty"`
// Hold is the operator setting in force, if any, and when it ends.
Hold *controlHold `json:"hold,omitempty"`
// ControlState says whether Core has confirmed autonomous default mode.
Expand Down Expand Up @@ -121,7 +124,10 @@ 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)
if entry := s.driverCatalogEntry(name); entry != nil {
resp.Controls = entry.Controls
resp.Sensors = entry.Sensors
}
resp.Hold = s.activeControlHold(name)
resp.ControlState = s.driverControlState(name, resp.Hold)
writeJSON(w, 200, resp)
Expand Down Expand Up @@ -160,6 +166,15 @@ func (s *Server) driverControlState(name string, hold *controlHold) driverContro
// 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 entry := s.driverCatalogEntry(name); entry != nil {
return entry.Controls
}
return nil
}

// driverCatalogEntry is the catalog entry for the configured driver `name`,
// or nil. See driverControls for why a parse failure is nil too.
func (s *Server) driverCatalogEntry(name string) *drivers.CatalogEntry {
cfg, ok := s.configuredDriver(name)
if !ok || cfg.Lua == "" {
return nil
Expand All @@ -173,7 +188,7 @@ func (s *Server) driverControls(name string) []drivers.CatalogControl {
if err != nil {
return nil
}
return entry.Controls
return &entry
}

dir := s.deps.DriverDir
Expand All @@ -184,7 +199,7 @@ func (s *Server) driverControls(name string) []drivers.CatalogControl {
if err != nil {
return nil
}
return drivers.ControlsForDriver(entries, lua)
return drivers.EntryForDriver(entries, lua)
}

func (s *Server) configuredDriver(name string) (config.Driver, bool) {
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 @@ -77,6 +77,10 @@ type CatalogEntry struct {
// 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"`
// Sensors are the metrics the driver says describe a heat store's
// temperature. Empty for every driver that declares none. See
// catalog_thermal.go.
Sensors []CatalogSensor `json:"sensors,omitempty"`

// ConfigSecrets lists driver-specific config keys that the Settings
// UI / setup wizard should render as password inputs and store
Expand Down Expand Up @@ -240,6 +244,7 @@ func parseCatalogEntry(path string) (CatalogEntry, error) {
e.AuthPostPath = pickString(block, "auth_post_path")
e.AuthPostPaths = pickList(block, "auth_post_paths")
e.Controls = pickControls(block)
e.Sensors = pickSensors(block)
return e, nil
}

Expand Down
70 changes: 61 additions & 9 deletions go/internal/drivers/catalog_controls.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ type CatalogControl struct {
Label string `json:"label,omitempty"`
Evidence string `json:"evidence,omitempty"`
Input CatalogControlInput `json:"input"`
// Thermal says what this control does to heat, when the driver declares
// it. Nil for every other control. See catalog_thermal.go.
Thermal *CatalogThermalControl `json:"thermal,omitempty"`
}

// CatalogControlInput describes the single value a control carries. Min, max
Expand All @@ -34,6 +37,12 @@ type CatalogControlInput struct {
Max *float64 `json:"max,omitempty"`
Step *float64 `json:"step,omitempty"`
Unit string `json:"unit,omitempty"`
// Options is the closed set a string input accepts. Empty means any
// string, as before.
Options []string `json:"options,omitempty"`
// Default is the value a number control's device runs at by itself,
// inside min..max. Nil means the driver does not say.
Default *float64 `json:"default,omitempty"`
}

var controlEvidence = map[string]bool{"readback": true, "write_ack": true}
Expand All @@ -51,21 +60,30 @@ func validControlToken(value string) bool {
// — 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 e := EntryForDriver(catalog, luaPath); e != nil {
return e.Controls
}
return nil
}

// EntryForDriver is the catalog entry for luaPath, found as ControlsForDriver
// describes, or nil.
func EntryForDriver(catalog []CatalogEntry, luaPath string) *CatalogEntry {
if luaPath == "" {
return nil
}
wantPath := filepath.ToSlash(filepath.Clean(luaPath))
wantFilename := filepath.Base(wantPath)
// A filename is only a fallback. When two sources contain the same
// filename, the configured path is the only safe identity we have.
for _, e := range catalog {
if strings.EqualFold(filepath.ToSlash(filepath.Clean(e.Path)), wantPath) {
return e.Controls
for i := range catalog {
if strings.EqualFold(filepath.ToSlash(filepath.Clean(catalog[i].Path)), wantPath) {
return &catalog[i]
}
}
for _, e := range catalog {
if strings.EqualFold(e.Filename, wantFilename) {
return e.Controls
for i := range catalog {
if strings.EqualFold(catalog[i].Filename, wantFilename) {
return &catalog[i]
}
}
return nil
Expand Down Expand Up @@ -110,16 +128,47 @@ func pickControls(block string) []CatalogControl {
Max: fieldNumber(input, "max"),
Step: fieldNumber(input, "step"),
}
options, ok := pickOptions(input)
if !ok {
continue
}
control.Input.Options = options
hasDefault := fieldPresent(withoutBlock(input, "options"), "default")
control.Input.Default = fieldNumber(input, "default")
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":
if options != nil {
continue
}
// A default outside the range, or not a number, is no
// default: the device cannot run at it, and a planner
// measuring small steps from it would start in the wrong
// place.
if hasDefault && (control.Input.Default == nil ||
*control.Input.Default < *control.Input.Min ||
*control.Input.Default > *control.Input.Max) {
continue
}
case "boolean":
if options != nil || hasDefault {
continue
}
case "string":
if hasDefault {
continue
}
default:
continue
}
thermal, ok := pickThermalControl(entry, control.Input)
if !ok {
continue
}
control.Thermal = thermal
out = append(out, control)
}
return out
Expand All @@ -128,11 +177,14 @@ func pickControls(block string) []CatalogControl {
// 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.
//
// The name must stand alone: `temp_sensors = {` is not `sensors = {`.
func nestedBlock(block, name string) string {
loc := regexp.MustCompile(regexp.QuoteMeta(name) + `\s*=\s*\{`).FindStringIndex(block)
if loc == nil {
m := regexp.MustCompile(`(?:^|[^A-Za-z0-9_])(` + regexp.QuoteMeta(name) + `\s*=\s*\{)`).FindStringSubmatchIndex(block)
if m == nil {
return ""
}
loc := []int{m[2], m[3]}
depth := 0
inString := false
for i := loc[1] - 1; i < len(block); i++ {
Expand Down
Loading
Loading