Skip to content
Merged
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/lua-driver-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": patch
---

Lua driver host: a missing `driver_command` is an error, a battery/PV/EV/V2X/heat-pump driver that can be commanded must implement `driver_default_mode`, and fingerprint probes cannot write hardware. A read-only declaration blocks dispatch before the command hook runs. The catalog now reads `auth_post_path`.

Update the recovery bundle to the companion driver audit, including corrected telemetry freshness, read-only declarations, and the Easee safe default. Include the read-only Zaptec Cloud and Tesla Wall Connector drivers so their setup paths also resolve offline.
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.
9 changes: 8 additions & 1 deletion docs/writing-a-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,15 @@ steer it. Polling must not keep re-emitting an indefinitely cached value as
fresh telemetry: age vendor data and stop emitting when it is stale, or core's
watchdog cannot see the fault.

This includes PV drivers with a command hook: curtailment must also have a safe
default. A `read_only` declaration prevents the Lua command hook from running;
it does not merely hide the control in the catalog. Read-only telemetry drivers
may omit the default hook because Core cannot dispatch commands to them.

`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
5 changes: 3 additions & 2 deletions drivers/BUNDLED_SOURCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"for coverage. Run scripts/sync-bundled-drivers.sh to update."
],
"repository": "srcfl/device-drivers",
"commit": "3890ca922a627fe0eee5df825a4fefe95cefd1b9",
"commit": "d560ca6d7df57a374c9998e1e90329857e3d15c3",
"source_dir": "drivers/lua",
"drivers": [
"ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud",
Expand All @@ -27,6 +27,7 @@
"pixii", "pixii_pv",
"sdm630", "sma", "sma_pv", "sofar", "solaredge", "solaredge_legacy",
"solaredge_pv", "solis", "solis_string", "sonnen", "sungrow",
"tesla_vehicle", "tibber", "victron", "zap", "zuidwijk_p1"
"tesla_vehicle", "tesla_wall_connector", "tibber", "victron", "zap",
"zaptec_cloud", "zuidwijk_p1"
]
}
46 changes: 46 additions & 0 deletions go/internal/drivers/bundled_startup_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package drivers

import (
"encoding/json"
"os"
"path/filepath"
"testing"

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

// Load the actual recovery bundle through the same default-hook gate as Add.
// This proves startup contract compatibility, not physical device recovery.
func TestBundledDriversMeetStartupContract(t *testing.T) {
root := "../../../drivers"
data, err := os.ReadFile(filepath.Join(root, "BUNDLED_SOURCE.json"))
if err != nil {
t.Fatal(err)
}
var pin struct {
Drivers []string `json:"drivers"`
}
if err := json.Unmarshal(data, &pin); err != nil {
t.Fatal(err)
}
if len(pin.Drivers) == 0 {
t.Fatal("recovery bundle has no drivers")
}
for _, name := range pin.Drivers {
t.Run(name, func(t *testing.T) {
path := filepath.Join(root, name+".lua")
d, err := NewLuaDriver(path, NewHostEnv(name, telemetry.NewStore()))
if err != nil {
t.Fatal(err)
}
defer d.L.Close()
required, err := legacyDriverRequiresDefaultMode(path, d.hasEntrypoint("driver_command"))
if err != nil {
t.Fatal(err)
}
if required && !d.hasEntrypoint("driver_default_mode") {
t.Fatal("pinned control driver would fail the startup default-mode gate")
}
})
}
}
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")
}
4 changes: 2 additions & 2 deletions go/internal/drivers/ferroamp_modbus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package drivers
import (
"context"
"encoding/json"
"strings"
"errors"
"testing"
"time"

Expand Down Expand Up @@ -33,7 +33,7 @@ func TestFerroampModbusLoads(t *testing.T) {

for _, action := range []string{"battery", "curtail", "curtail_disable", "deinit"} {
cmd, _ := json.Marshal(map[string]any{"action": action, "power_w": 1000.0})
if err := d.Command(ctx, cmd); err == nil || !strings.Contains(err.Error(), "returned false") {
if err := d.Command(ctx, cmd); !errors.Is(err, ErrReadOnlyDriver) {
t.Fatalf("%s cmd: got %v, want read-only refusal", action, err)
}
}
Expand Down
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
69 changes: 30 additions & 39 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/PV/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 @@ -189,11 +172,16 @@ func openRestrictedLibraries(L *lua.LState) {
L.SetGlobal("coroutine", lua.LNil)
}

func driverDeclaresReadOnlyBattery(L *lua.LState) bool {
func driverDeclaresReadOnly(L *lua.LState) bool {
meta, ok := L.GetGlobal("DRIVER").(*lua.LTable)
if !ok || meta.RawGetString("read_only") != lua.LTrue {
return ok && meta.RawGetString("read_only") == lua.LTrue
}

func driverDeclaresReadOnlyBattery(L *lua.LState) bool {
if !driverDeclaresReadOnly(L) {
return false
}
meta := L.GetGlobal("DRIVER").(*lua.LTable)
caps, ok := meta.RawGetString("capabilities").(*lua.LTable)
if !ok {
return false
Expand Down Expand Up @@ -417,6 +405,9 @@ func (d *LuaDriver) Command(ctx context.Context, cmdJSON []byte) error {
}
d.mu.Lock()
defer d.mu.Unlock()
if driverDeclaresReadOnly(d.L) {
return ErrReadOnlyDriver
}
if ctx == nil {
ctx = context.Background()
}
Expand All @@ -427,7 +418,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
Loading