diff --git a/.changeset/lua-driver-contract.md b/.changeset/lua-driver-contract.md new file mode 100644 index 00000000..8fcbdc02 --- /dev/null +++ b/.changeset/lua-driver-contract.md @@ -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. diff --git a/docs/site-convention.md b/docs/site-convention.md index 7b48260d..e85182fd 100644 --- a/docs/site-convention.md +++ b/docs/site-convention.md @@ -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. diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md index 24a45ef3..0fef8ff6 100644 --- a/docs/writing-a-driver.md +++ b/docs/writing-a-driver.md @@ -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 diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index c3566b62..86d385d9 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -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", @@ -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" ] } diff --git a/go/internal/drivers/bundled_startup_contract_test.go b/go/internal/drivers/bundled_startup_contract_test.go new file mode 100644 index 00000000..b0c69002 --- /dev/null +++ b/go/internal/drivers/bundled_startup_contract_test.go @@ -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") + } + }) + } +} diff --git a/go/internal/drivers/catalog.go b/go/internal/drivers/catalog.go index 0a450438..ee21a8a0 100644 --- a/go/internal/drivers/catalog.go +++ b/go/internal/drivers/catalog.go @@ -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 } diff --git a/go/internal/drivers/catalog_test.go b/go/internal/drivers/catalog_test.go index 890cf178..4f1dd47d 100644 --- a/go/internal/drivers/catalog_test.go +++ b/go/internal/drivers/catalog_test.go @@ -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") +} diff --git a/go/internal/drivers/ferroamp_modbus_test.go b/go/internal/drivers/ferroamp_modbus_test.go index 1aae85f6..e97d19b2 100644 --- a/go/internal/drivers/ferroamp_modbus_test.go +++ b/go/internal/drivers/ferroamp_modbus_test.go @@ -3,7 +3,7 @@ package drivers import ( "context" "encoding/json" - "strings" + "errors" "testing" "time" @@ -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) } } diff --git a/go/internal/drivers/fingerprint.go b/go/internal/drivers/fingerprint.go index 7de363c7..4c8abe1d 100644 --- a/go/internal/drivers/fingerprint.go +++ b/go/internal/drivers/fingerprint.go @@ -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 diff --git a/go/internal/drivers/fingerprint_test.go b/go/internal/drivers/fingerprint_test.go index 1b9e5d89..e58e6624 100644 --- a/go/internal/drivers/fingerprint_test.go +++ b/go/internal/drivers/fingerprint_test.go @@ -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()) diff --git a/go/internal/drivers/host.go b/go/internal/drivers/host.go index 4b4d99b8..db60b2e9 100644 --- a/go/internal/drivers/host.go +++ b/go/internal/drivers/host.go @@ -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 @@ -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 } diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go index 560e2ed1..4c6ee3df 100644 --- a/go/internal/drivers/lua.go +++ b/go/internal/drivers/lua.go @@ -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. @@ -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 @@ -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() } @@ -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 { diff --git a/go/internal/drivers/lua_test.go b/go/internal/drivers/lua_test.go index e50d9780..76166ab7 100644 --- a/go/internal/drivers/lua_test.go +++ b/go/internal/drivers/lua_test.go @@ -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") diff --git a/go/internal/drivers/pv_generation_limit.go b/go/internal/drivers/pv_generation_limit.go index 3d1dd169..349cf717 100644 --- a/go/internal/drivers/pv_generation_limit.go +++ b/go/internal/drivers/pv_generation_limit.go @@ -25,8 +25,15 @@ func (d *LuaDriver) clearPVGenerationLimit() { func (d *LuaDriver) refreshPVGenerationLimit() { d.pvProofMu.Lock() defer d.pvProofMu.Unlock() - const reviewedFerroamp = "c04d137d595ba50b8c6178c82d917b115dbe9a7cbd2cf671ef2660e871f96de3" - if d.loadedSourceSHA256 != reviewedFerroamp || d.Env.MQTT == nil || d.initConfig["_supports_pv_curtail"] != true { + // The second reviewed version adds configured serial identity and a version + // bump; its generation-limit and release commands are unchanged. + switch d.loadedSourceSHA256 { + case "c04d137d595ba50b8c6178c82d917b115dbe9a7cbd2cf671ef2660e871f96de3", + "81de3c2f78618f4a30994b0397f4b6b4b56273a33946ae2d23f75a27698bc399": + default: + return + } + if d.Env.MQTT == nil || d.initConfig["_supports_pv_curtail"] != true { return } w, err := strconv.ParseFloat(fmt.Sprint(d.initConfig["pplim_release_w"]), 64) @@ -34,7 +41,7 @@ func (d *LuaDriver) refreshPVGenerationLimit() { return } w = math.Floor(w) - d.pvProof = PVGenerationLimit{Token: fmt.Sprintf("%s/%d/%.0f", reviewedFerroamp, d.pvProofEpoch, w), MinW: 2, MaxW: w} + d.pvProof = PVGenerationLimit{Token: fmt.Sprintf("%s/%d/%.0f", d.loadedSourceSHA256, d.pvProofEpoch, w), MinW: 2, MaxW: w} } func (d *LuaDriver) PVGenerationLimit() PVGenerationLimit { @@ -47,7 +54,7 @@ func (r *Registry) PVGenerationLimit(name string) PVGenerationLimit { r.mu.Lock() defer r.mu.Unlock() rd := r.rec[name] - if rd == nil || rd.cfg.Disabled || rd.cfg.ObserveOnly || rd.cfg.BatteryTelemetryOnly || !rd.cfg.SupportsPVCurtail { + if rd == nil || rd.readOnly || rd.cfg.Disabled || rd.cfg.ObserveOnly || rd.cfg.BatteryTelemetryOnly || !rd.cfg.SupportsPVCurtail { return PVGenerationLimit{} } s := rd.controlStatus() diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index 36840488..776f872a 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -30,6 +30,9 @@ var ( // ErrObserveOnly is returned when a configured telemetry-only driver is // reached through a generic command path instead of the API guard. ErrObserveOnly = errors.New("driver is observe_only and cannot be controlled") + // ErrReadOnlyDriver rejects dispatch before the declared read-only Lua + // command hook can run, even when that hook exists and would accept it. + ErrReadOnlyDriver = errors.New("driver is read_only and cannot be controlled") // ErrCommandSuperseded is returned when an EV command no longer belongs // to the current per-driver control sequence. In particular, ev_resume is // valid only immediately after the ev_pause that opened its cycle; any @@ -242,6 +245,7 @@ type runningDriver struct { env *HostEnv cfg config.Driver policy *RuntimePolicy + readOnly bool leaseExpiresAt time.Time generation uint64 statusMu sync.RWMutex @@ -453,12 +457,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", "pv", "ev", "v2x", "v2x_charger", "vehicle", "heatpump": + 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 + } + 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 @@ -595,14 +621,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} @@ -662,6 +688,7 @@ func (r *Registry) add(ctx context.Context, cfg config.Driver, startupDefault bo env: env, cfg: cfg, policy: policy, + readOnly: driverDeclaresReadOnly(luaDrv.L), lifecycleCtx: lifecycleCtx, lifecycleCancel: lifecycleCancel, cmdCh: make(chan driverCmd, 8), @@ -1306,6 +1333,9 @@ func (r *Registry) sendWithGeneration(ctx context.Context, name string, payload if rd.cfg.ObserveOnly { return generation, ErrObserveOnly } + if rd.readOnly { + return generation, ErrReadOnlyDriver + } if err := ctx.Err(); err != nil { return generation, err } diff --git a/go/internal/drivers/registry_restart_test.go b/go/internal/drivers/registry_restart_test.go index bd8b870d..b6730281 100644 --- a/go/internal/drivers/registry_restart_test.go +++ b/go/internal/drivers/registry_restart_test.go @@ -754,6 +754,90 @@ 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) + host.emit_metric("unexpected_command", 1) + return true +end +` + path := writeTestDriver(t, src) + tel := telemetry.NewStore() + r := NewRegistry(tel) + 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") }) + if err := r.Send(context.Background(), "d1", []byte(`{"action":"battery","power_w":1000}`)); !errors.Is(err, ErrReadOnlyDriver) { + t.Fatalf("read-only command = %v, want ErrReadOnlyDriver", err) + } + if _, _, ok := tel.LatestMetric("d1", "unexpected_command"); ok { + t.Fatal("read-only driver_command ran") + } +} + +func TestLegacyPVCurtailDriverRequiresDefaultMode(t *testing.T) { + path := writeTestDriver(t, ` +DRIVER = { + id = "pv_without_default", + capabilities = { "pv" }, +} +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 +`) + r := NewRegistry(telemetry.NewStore()) + err := r.Add(context.Background(), config.Driver{Name: "pv", Lua: path}) + if err == nil || !strings.Contains(err.Error(), "driver_default_mode") { + t.Fatalf("PV actuator without safe default = %v", err) + } +} + +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 = { diff --git a/go/internal/drivers/zero_release_read_only_test.go b/go/internal/drivers/zero_release_read_only_test.go index 49b45be7..540a646b 100644 --- a/go/internal/drivers/zero_release_read_only_test.go +++ b/go/internal/drivers/zero_release_read_only_test.go @@ -3,7 +3,7 @@ package drivers import ( "context" "encoding/json" - "strings" + "errors" "sync/atomic" "testing" @@ -76,7 +76,7 @@ func TestReleaseOnZeroHybridsAreWriteInert(t *testing.T) { t.Fatalf("%s must declare read_only=true", name) } for i, err := range commandErrors { - if err == nil || !strings.Contains(err.Error(), "returned false") { + if !errors.Is(err, ErrReadOnlyDriver) { t.Fatalf("command %v: got %v, want read-only refusal", commands[i], err) } } diff --git a/go/internal/evcloud/tesla_wc.go b/go/internal/evcloud/tesla_wc.go index ef752821..637302a5 100644 --- a/go/internal/evcloud/tesla_wc.go +++ b/go/internal/evcloud/tesla_wc.go @@ -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", } } diff --git a/go/internal/evcloud/zaptec.go b/go/internal/evcloud/zaptec.go index 1dc1fc79..c7c8caac 100644 --- a/go/internal/evcloud/zaptec.go +++ b/go/internal/evcloud/zaptec.go @@ -69,7 +69,9 @@ func (z *Zaptec) Describe() Descriptor { Transport: TransportHTTP, NeedsAuth: true, UsernameLabel: "Email", - LuaDriver: "drivers/zaptec_cloud.lua", + // Installed from the device-drivers pin as drivers/zaptec_cloud.lua. + // Tests load the in-tree copy at go/internal/drivers/testdata/. + LuaDriver: "drivers/zaptec_cloud.lua", } } diff --git a/go/internal/fleetping/shipped_gen.go b/go/internal/fleetping/shipped_gen.go index f7779881..1975ba0c 100644 --- a/go/internal/fleetping/shipped_gen.go +++ b/go/internal/fleetping/shipped_gen.go @@ -6,7 +6,7 @@ package fleetping // // An array rather than a slice, so it is fixed in every sense: the length // is part of the type and nothing can append to it at run time. -var shippedDrivers = [38]string{ +var shippedDrivers = [40]string{ "ambibox_v2x", "ctek", "ctek_hybrid", @@ -41,8 +41,10 @@ var shippedDrivers = [38]string{ "sonnen", "sungrow", "tesla_vehicle", + "tesla_wall_connector", "tibber", "victron", "zap", + "zaptec_cloud", "zuidwijk_p1", }