Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/lua-driver-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Lua driver host: a missing `driver_command` is an error, a battery/EV/V2X/heat-pump driver that can be commanded must implement `driver_default_mode`, and fingerprint probes cannot write hardware. The catalog now reads `auth_post_path`.
19 changes: 9 additions & 10 deletions docs/site-convention.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,12 @@ So we pick: **grid-meter-positive, view the site from the boundary**.

## Verification

- Each driver's telemetry emission is covered by tests that assert the sign
(e.g., `emit_pv` always produces `w <= 0`)
- Integration tests between Lua drivers and simulators verify
that a `+N` charge command produces an actual reading with `bat_w > 0`
- The control loop's own tests assert both sides of the contract:
self-consumption discharges on import to hold grid near zero, while planner
idle/charge slots do not keep individual batteries discharging

Any driver that violates the convention breaks a test. The convention is
enforced, not just documented.
The host rejects a structured emit that breaks the door rules:
PV with `w > 0`, EV with `w < 0`, non-finite power, or an SoC outside 0..1
(`telemetry.ValidateReading`). It does not clamp a bad sign into range.

Individual drivers have sign tests (Ferroamp, Zap, ESPHome DSMR, and the
control-loop cases that a `+N` charge must read back as charge). The catalog
as a whole is not yet under one emit-contract suite — a new driver can still
ship a sign bug until someone writes that test. The convention is enforced at
the door for the cases above, and by driver tests where they exist.
4 changes: 3 additions & 1 deletion docs/writing-a-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ fresh telemetry: age vendor data and stop emitting when it is stale, or core's
watchdog cannot see the fault.

`driver_fingerprint(target)` is an optional passive setup probe. It must never
reconfigure the device.
reconfigure the device. The host denies mutating verbs (`modbus_write`,
`mqtt_pub`, `http_post`, `http_patch`) for that VM, including bundled drivers
that otherwise have no signed write scope.

Call `host.set_make` and `host.set_sn` as soon as stable identity is known.
Core then keys durable device state by hardware identity rather than the YAML
Expand Down
1 change: 1 addition & 0 deletions go/internal/drivers/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ func parseCatalogEntry(path string) (CatalogEntry, error) {
e.TestedModels = pickList(block, "tested_models")
e.ConfigSecrets = pickList(block, "config_secrets")
e.WriteCapabilities = pickList(block, "write_capabilities")
e.AuthPostPath = pickString(block, "auth_post_path")
e.Controls = pickControls(block)
return e, nil
}
Expand Down
44 changes: 44 additions & 0 deletions go/internal/drivers/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,47 @@ func TestLoadCatalogReadsWriteCapabilities(t *testing.T) {
t.Errorf("reader declared no write path but got %v", byID["reader"].WriteCapabilities)
}
}

func TestLoadCatalogReadsAuthPostPath(t *testing.T) {
dir := t.TempDir()
oauth := "DRIVER = {\n id = \"myuplink\",\n name = \"MyUplink\",\n" +
" read_only = true,\n auth_post_path = \"/oauth/token\",\n}\n"
if err := os.WriteFile(filepath.Join(dir, "myuplink.lua"), []byte(oauth), 0644); err != nil {
t.Fatal(err)
}
plain := "DRIVER = {\n id = \"meter\",\n name = \"Meter\",\n}\n"
if err := os.WriteFile(filepath.Join(dir, "meter.lua"), []byte(plain), 0644); err != nil {
t.Fatal(err)
}

entries, err := LoadCatalog(dir)
if err != nil {
t.Fatalf("LoadCatalog: %v", err)
}
byID := make(map[string]CatalogEntry, len(entries))
for _, e := range entries {
byID[e.ID] = e
}
if byID["myuplink"].AuthPostPath != "/oauth/token" {
t.Errorf("myuplink AuthPostPath = %q, want /oauth/token", byID["myuplink"].AuthPostPath)
}
if byID["meter"].AuthPostPath != "" {
t.Errorf("meter AuthPostPath = %q, want empty", byID["meter"].AuthPostPath)
}
}

func TestCatalogMyUplinkDeclaresAuthPostPath(t *testing.T) {
entries, err := LoadCatalog("../../../drivers")
if err != nil {
t.Fatalf("LoadCatalog: %v", err)
}
for _, e := range entries {
if e.ID == "myuplink" {
if e.AuthPostPath != "/oauth/token" {
t.Fatalf("myuplink AuthPostPath = %q, want /oauth/token", e.AuthPostPath)
}
return
}
}
t.Fatal("myuplink missing from catalog")
}
3 changes: 3 additions & 0 deletions go/internal/drivers/fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ func (d *LuaDriver) Discard() {
// invoked — fingerprinting is a passive probe and must not reconfigure the
// device. A driver that fails to load yields MatchUnknown + error.
func RunFingerprint(luaPath string, env *HostEnv, target FingerprintTarget) (Fingerprint, error) {
if env != nil {
env.ProbeReadOnly = true
}
d, err := NewLuaDriver(luaPath, env)
if err != nil {
return Fingerprint{Match: MatchUnknown, Err: err.Error()}, err
Expand Down
27 changes: 27 additions & 0 deletions go/internal/drivers/fingerprint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,33 @@ func TestFingerprintConfidenceIsBounded(t *testing.T) {
}
}

func TestFingerprintProbeCannotWrite(t *testing.T) {
body := `
function driver_fingerprint()
local err = host.modbus_write(1, 99)
if err ~= nil and err ~= "" then
return false
end
return true
end
`
m := newRecordingModbus()
env := NewHostEnv("probe", telemetry.NewStore()).WithModbus(m)
fp, err := RunFingerprint(writeTempDriver(t, body), env, FingerprintTarget{Protocol: "modbus"})
if err != nil {
t.Fatalf("RunFingerprint: %v", err)
}
if len(m.writes) != 0 {
t.Fatalf("fingerprint wrote %v, want none", m.writes)
}
if fp.Match != MatchNo {
t.Fatalf("Match = %q, want no_match when the write is denied", fp.Match)
}
if !env.ProbeReadOnly {
t.Fatal("fingerprint env should stay probe-read-only")
}
}

func TestFingerprintErrorIsUnknown(t *testing.T) {
body := `function driver_fingerprint() error("boom") end`
env := NewHostEnv("probe", telemetry.NewStore())
Expand Down
7 changes: 7 additions & 0 deletions go/internal/drivers/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ type HostEnv struct {
// signed read-only policy denies writes in every phase. A signed v2 control
// policy also limits writes to a bounded command/default-mode call.
RuntimePolicy *RuntimePolicy
// ProbeReadOnly denies every mutating host verb. Fingerprint probes set
// this so a buggy driver_fingerprint cannot reconfigure hardware: bundled
// drivers otherwise have allowWrite as a no-op.
ProbeReadOnly bool

// BatteryCapacityWh mirrors the operator's `battery_capacity_wh`
// declaration for this driver. Zero means "no physical battery
Expand Down Expand Up @@ -302,6 +306,9 @@ func (h *HostEnv) allowAuthPost(rawURL string) bool {
}

func (h *HostEnv) allowWrite(permission string) error {
if h.ProbeReadOnly {
return fmt.Errorf("%s: fingerprint probe cannot write", permission)
}
if h.RuntimePolicy == nil {
return nil
}
Expand Down
57 changes: 20 additions & 37 deletions go/internal/drivers/lua.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,44 +6,27 @@
// driver_poll() — called every N seconds; emit telemetry
// driver_command(c) — receive a control command (JSON table)
// driver_cleanup() — optional, called on shutdown
// driver_default_mode() — optional, called when driver goes offline
// driver_default_mode() — required for a non-read-only driver that
// declares controls or a battery/EV/V2X/heatpump
// command path; optional for reporting-only
//
// The host exposes a capability-gated API surfaced as a `host` global in
// the Lua VM:
// registerHost is the complete host API. writing-a-driver.md summarises it.
// Canonical names plus Blixt L1 aliases (write, write_registers, now_ms):
//
// host.log(level, msg) -- level: "debug"|"info"|"warn"|"error"
// host.emit(type, table) -- type: "meter"|"pv"|"battery"|"ev"|"v2x_charger"
// host.millis() -- ms since driver start
// host.sleep(ms) -- block driver goroutine for ms (inter-write pacing)
// host.set_poll_interval(ms)
// host.set_sn(s) -- device serial (metadata)
// host.set_make(s) -- manufacturer name
// host.set_model(s) -- device model name (metadata)
// host.set_rated_w(w) -- rated AC power, watts (nameplate)
// host.set_warmup_s(s) -- hold off the first poll for s seconds
// host.decode_string(regs, start, count) -- ASCII, 2 chars/register, hi byte first
// host.mqtt_sub(topic) -- subscribe
// host.mqtt_pub(topic, payload) -- publish
// host.mqtt_messages() -- array of {topic, payload} since last call
// host.modbus_read(addr, count, kind) -- kind: "coil"|"discrete"|"holding"|"input"
// host.modbus_write(addr, value)
// host.modbus_write_multi(addr, values)
// host.serial_read(max_bytes, timeout_ms) -- raw read-only serial bytes
// host.aes_gcm_decrypt(key, iv, ciphertext, aad, tag)
// host.json_decode(s) -- convenience JSON → Lua table
// host.json_encode(t) -- Lua table → JSON string
// host.http_get(url, headers) -- HTTP GET, returns (body, nil) or (nil, err)
// host.http_post(url, body, headers) -- HTTP POST, returns (body, nil) or (nil, err)
// host.http_patch(url, body, headers) -- HTTP PATCH (write); needs capabilities.http.allow_write
// host.ws_open(url, headers) -- open WebSocket; (true, nil) or (nil, err)
// host.ws_send(text) -- send one text frame; (true, nil) or (nil, err)
// host.ws_messages() -- drain inbound frames; "" entry = EOF
// host.ws_is_open() -- boolean
// host.ws_close() -- close + free
// host.tcp_open(addr) -- open raw TCP socket "host:port"; (true, nil) or (nil, err)
// host.tcp_recv() -- drain inbound bytes as a Lua string ("" if nothing)
// host.tcp_is_open() -- boolean
// host.tcp_close() -- close + free
// host.log, host.emit, host.emit_metric
// host.millis / host.now_ms, host.sleep, host.set_poll_interval
// host.set_watchdog_timeout_s, host.set_device_fault
// host.set_sn, host.set_make, host.set_model, host.set_rated_w, host.set_warmup_s
// host.persist_secret
// host.decode_string, host.decode_i16, host.decode_{u,i}32_{be,le}
// host.mqtt_sub / mqtt_subscribe, host.mqtt_pub / mqtt_publish, host.mqtt_messages
// host.modbus_read, host.modbus_write / write, host.modbus_write_multi / write_registers
// host.serial_read, host.aes_gcm_decrypt, host.json_decode, host.json_encode
// host.http_get, host.http_post, host.http_patch
// host.ws_open, host.ws_send, host.ws_messages, host.ws_is_open, host.ws_close
// host.tcp_open, host.tcp_recv, host.tcp_is_open, host.tcp_close
//
// emit types: meter | pv | battery | ev | v2x_charger | vehicle
//
// Lua 5.1 via yuin/gopher-lua — pure Go, zero CGo, one allocation-aware
// interpreter per driver.
Expand Down Expand Up @@ -427,7 +410,7 @@ func (d *LuaDriver) Command(ctx context.Context, cmdJSON []byte) error {
defer d.L.RemoveContext()
fn := d.L.GetGlobal("driver_command")
if fn == lua.LNil {
return nil
return errors.New("driver_command is not defined")
}
var cmd map[string]any
if err := json.Unmarshal(cmdJSON, &cmd); err != nil {
Expand Down
21 changes: 21 additions & 0 deletions go/internal/drivers/lua_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,27 @@ func TestLuaDriverLifecycle(t *testing.T) {
}
}

func TestLuaDriverMissingCommandIsError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nocommand.lua")
src := `
function driver_init(config) end
function driver_poll() return 1000 end
`
if err := os.WriteFile(path, []byte(src), 0644); err != nil {
t.Fatal(err)
}
d, err := NewLuaDriver(path, NewHostEnv("nocommand", telemetry.NewStore()))
if err != nil {
t.Fatalf("load: %v", err)
}
defer d.Cleanup()
err = d.Command(context.Background(), []byte(`{"action":"battery","power_w":1000}`))
if err == nil || !strings.Contains(err.Error(), "driver_command is not defined") {
t.Fatalf("Command error = %v, want driver_command is not defined", err)
}
}

func TestLuaDriverCommandAndDefaultModeReturnErrors(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "failing.lua")
Expand Down
32 changes: 27 additions & 5 deletions go/internal/drivers/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,12 +453,34 @@ func (r *Registry) AddProbe(ctx context.Context, cfg config.Driver) error {
return r.add(ctx, cfg, false)
}

func legacyDriverDeclaresControls(path string) (bool, error) {
func actuationCapability(caps []string) bool {
for _, c := range caps {
switch strings.ToLower(strings.TrimSpace(c)) {
case "battery", "ev", "v2x", "v2x_charger", "vehicle", "heatpump":
Comment thread
frahlg marked this conversation as resolved.
Outdated
return true
}
}
return false
}

// legacyDriverRequiresDefaultMode is the start-time safety gate for bundled
// and local drivers. A driver that can receive commands and claims an
// actuation capability (or declares operator controls) must implement
// driver_default_mode so watchdog/shutdown have somewhere to hand the
// hardware back. read_only drivers are reporting-only even if they stub
// driver_command to refuse.
func legacyDriverRequiresDefaultMode(path string, hasCommand bool) (bool, error) {
entry, err := ParseCatalogFile(path)
if err != nil {
return false, err
}
return len(entry.Controls) > 0, nil
if entry.ReadOnly {
return false, nil
Comment thread
frahlg marked this conversation as resolved.
}
if len(entry.Controls) > 0 {
return true, nil
}
return hasCommand && actuationCapability(entry.Capabilities), nil
}

// add is the shared driver construction path. The caller must hold the
Expand Down Expand Up @@ -595,14 +617,14 @@ func (r *Registry) add(ctx context.Context, cfg config.Driver, startupDefault bo
return fmt.Errorf("load lua: %w", err)
}
if !cfg.ObserveOnly && (policy == nil || !policy.IsControlV2()) {
declaresControls, catalogErr := legacyDriverDeclaresControls(cfg.Lua)
requiresDefault, catalogErr := legacyDriverRequiresDefaultMode(cfg.Lua, luaDrv.hasEntrypoint("driver_command"))
if catalogErr != nil {
luaDrv.CleanupContext(ctx)
return fmt.Errorf("validate legacy driver controls: %w", catalogErr)
}
if declaresControls && !luaDrv.hasEntrypoint("driver_default_mode") {
if requiresDefault && !luaDrv.hasEntrypoint("driver_default_mode") {
luaDrv.CleanupContext(ctx)
return fmt.Errorf("driver %q declares operator controls but is missing required driver_default_mode", cfg.Name)
return fmt.Errorf("driver %q can be commanded but is missing required driver_default_mode", cfg.Name)
}
}
var drv driverRuntime = &luaRuntime{LuaDriver: luaDrv}
Expand Down
57 changes: 57 additions & 0 deletions go/internal/drivers/registry_restart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,63 @@ function driver_command(action, w, cmd) return true end
t.Cleanup(func() { r.Remove("d1") })
}

func TestLegacyBatteryCommandDriverRequiresDefaultMode(t *testing.T) {
src := `
DRIVER = {
id = "battery_without_default",
capabilities = { "battery" },
}
function driver_init(config) host.set_poll_interval(1000) end
function driver_poll() return 1000 end
function driver_command(action, w, cmd) return true end
`
path := writeTestDriver(t, src)
r := NewRegistry(telemetry.NewStore())
err := r.Add(context.Background(), config.Driver{Name: "d1", Lua: path})
if err == nil {
t.Fatal("battery driver with command and no driver_default_mode was accepted")
}
if !strings.Contains(err.Error(), "driver_default_mode") {
t.Fatalf("missing-default error = %v, want driver_default_mode", err)
}
}

func TestReadOnlyBatteryMayOmitDefaultMode(t *testing.T) {
src := `
DRIVER = {
id = "ro_battery",
capabilities = { "battery" },
read_only = true,
}
function driver_init(config) host.set_poll_interval(1000) end
function driver_poll() return 1000 end
function driver_command(action, w, cmd) return false end
`
path := writeTestDriver(t, src)
r := NewRegistry(telemetry.NewStore())
if err := r.Add(context.Background(), config.Driver{Name: "d1", Lua: path}); err != nil {
t.Fatalf("read-only battery without default = %v", err)
}
t.Cleanup(func() { r.Remove("d1") })
}

func TestBatteryTelemetryDriverWithoutCommandMayOmitDefaultMode(t *testing.T) {
src := `
DRIVER = {
id = "sonnen_like",
capabilities = { "battery" },
}
function driver_init(config) host.set_poll_interval(1000) end
function driver_poll() return 1000 end
`
path := writeTestDriver(t, src)
r := NewRegistry(telemetry.NewStore())
if err := r.Add(context.Background(), config.Driver{Name: "d1", Lua: path}); err != nil {
t.Fatalf("telemetry battery without command = %v", err)
}
t.Cleanup(func() { r.Remove("d1") })
}

func TestObserveOnlyControlDriverMayOmitDefaultMode(t *testing.T) {
src := `
DRIVER = {
Expand Down
2 changes: 2 additions & 0 deletions go/internal/evcloud/tesla_wc.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ func (t *TeslaWC) Describe() Descriptor {
Label: "Tesla Wall Connector",
Transport: TransportHTTP,
NeedsAuth: false,
// Installed from the device-drivers pin as drivers/tesla_wall_connector.lua.
// Tests load the in-tree copy at go/internal/drivers/testdata/.
LuaDriver: "drivers/tesla_wall_connector.lua",
}
}
Expand Down
Loading