From bf80760fb4d11698ca09e76f22ce44cf347b78df Mon Sep 17 00:00:00 2001
From: Fredrik Ahlgren
Date: Mon, 19 Oct 2026 07:40:55 +0200
Subject: [PATCH] fix(chargers): warn when a saved driver is disabled
---
.changeset/disabled-charger-binding.md | 5 +
go/cmd/ftw/forecast_charger_binding_test.go | 36 +++++++
.../api/api_agent_charger_connections_test.go | 8 +-
go/internal/config/loadpoint_connections.go | 16 +++-
go/internal/config/loadpoint_disabled_test.go | 44 +++++++++
.../onboarding/connection_warnings_test.go | 94 ++++++++++---------
go/test/fixtures/measurement-api/main.go | 83 ++++++++++++++++
.../tabs/loadpoint-connections.test.mjs | 16 ++++
web/settings/tabs/loadpoints.js | 11 ++-
9 files changed, 259 insertions(+), 54 deletions(-)
create mode 100644 .changeset/disabled-charger-binding.md
create mode 100644 go/cmd/ftw/forecast_charger_binding_test.go
create mode 100644 go/internal/config/loadpoint_disabled_test.go
create mode 100644 go/test/fixtures/measurement-api/main.go
diff --git a/.changeset/disabled-charger-binding.md b/.changeset/disabled-charger-binding.md
new file mode 100644
index 000000000..ce7086149
--- /dev/null
+++ b/.changeset/disabled-charger-binding.md
@@ -0,0 +1,5 @@
+---
+"ftw": patch
+---
+
+Warn when a saved charger uses a disabled driver, without changing its binding or treating missing power as zero.
diff --git a/go/cmd/ftw/forecast_charger_binding_test.go b/go/cmd/ftw/forecast_charger_binding_test.go
new file mode 100644
index 000000000..c17c9768a
--- /dev/null
+++ b/go/cmd/ftw/forecast_charger_binding_test.go
@@ -0,0 +1,36 @@
+package main
+
+import (
+ "github.com/srcfl/ftw/go/internal/config"
+ "github.com/srcfl/ftw/go/internal/telemetry"
+ "testing"
+)
+
+func TestRetainedChargerBindingNeverInventsZeroEVPower(t *testing.T) {
+ for _, disabled := range []bool{false, true} {
+ cfg := &config.Config{Drivers: []config.Driver{{Name: "site", Lua: "drivers/meter.lua", IsSiteMeter: true}, {Name: "charger-current", Lua: "drivers/charger.lua", Disabled: disabled}},
+ Loadpoints: []config.Loadpoint{{ID: "garage", DriverName: "charger-old"}}}
+ if disabled {
+ cfg.Loadpoints[0].DriverName = "charger-current"
+ }
+ tel := telemetry.NewStore()
+ tel.Update("site", telemetry.DerMeter, 4000, nil, nil)
+ tel.RecordDriverSuccess("site")
+ if !disabled {
+ tel.Update("charger-current", telemetry.DerEV, 1000, nil, nil)
+ tel.RecordDriverSuccess("charger-current")
+ }
+ r := tel.LiveMeasurementNow("site", historyMeasurementOptions(cfg, measurementCatalog()))
+ if r.Valid || r.Known[telemetry.DerEV] || r.Reason != "missing:"+cfg.Loadpoints[0].DriverName+":ev" {
+ t.Fatal("an unresolved EV flow became a valid household balance")
+ }
+ cfg.Loadpoints[0].DriverName = "charger-current"
+ cfg.Drivers[1].Disabled = false
+ tel.Update("charger-current", telemetry.DerEV, 1000, nil, nil)
+ tel.RecordDriverSuccess("charger-current")
+ r = tel.LiveMeasurementNow("site", historyMeasurementOptions(cfg, measurementCatalog()))
+ if !r.Valid || r.HouseholdW != 3000 || r.EVW != 1000 {
+ t.Fatal("an explicit repair with fresh EV evidence did not restore the balance")
+ }
+ }
+}
diff --git a/go/internal/api/api_agent_charger_connections_test.go b/go/internal/api/api_agent_charger_connections_test.go
index 613ae79e9..d4bd9282d 100644
--- a/go/internal/api/api_agent_charger_connections_test.go
+++ b/go/internal/api/api_agent_charger_connections_test.go
@@ -10,8 +10,8 @@ import (
)
func TestDiagnosticCatalogueIncludesExactSavedChargerConnections(t *testing.T) {
- cfg := &config.Config{Drivers: []config.Driver{{Name: "charger-current"}},
- Loadpoints: []config.Loadpoint{{ID: "garage", DriverName: "charger-old"}, {ID: "driveway", DriverName: "charger-current"}}}
+ cfg := &config.Config{Drivers: []config.Driver{{Name: "charger-current"}, {Name: "charger-disabled", Disabled: true}},
+ Loadpoints: []config.Loadpoint{{ID: "garage", DriverName: "charger-old"}, {ID: "driveway", DriverName: "charger-current"}, {ID: "street", DriverName: "charger-disabled"}}}
srv := New(&Deps{Cfg: cfg, CfgMu: &sync.RWMutex{}, Version: "0.153.0-beta.1"})
answer, _ := diagnosticRead(t, srv, protocol.AgentAPICatalogPath, protocol.APIRead{})
var page struct {
@@ -23,8 +23,8 @@ func TestDiagnosticCatalogueIncludesExactSavedChargerConnections(t *testing.T) {
t.Fatal(err)
}
connections := page.Troubleshooting.Connections
- if len(connections) != 2 || connections[0].DriverName != "charger-old" || connections[0].Status != "missing" ||
- connections[1].DriverName != "charger-current" || connections[1].Status != "configured" {
+ if len(connections) != 3 || connections[0].DriverName != "charger-old" || connections[0].Status != "missing" ||
+ connections[1].DriverName != "charger-current" || connections[1].Status != "configured" || connections[2].Status != "disabled" {
t.Fatal("diagnostic evidence lost the saved name or confused a name binding with device health")
}
}
diff --git a/go/internal/config/loadpoint_connections.go b/go/internal/config/loadpoint_connections.go
index 3652a44c5..0228ebc6c 100644
--- a/go/internal/config/loadpoint_connections.go
+++ b/go/internal/config/loadpoint_connections.go
@@ -13,15 +13,18 @@ type LoadpointConnection struct {
}
func (c *Config) LoadpointConnections() []LoadpointConnection {
- drivers := make(map[string]bool, len(c.Drivers))
+ drivers := make(map[string]Driver, len(c.Drivers))
for _, driver := range c.Drivers {
- drivers[driver.Name] = true
+ drivers[driver.Name] = driver
}
connections := make([]LoadpointConnection, 0, len(c.Loadpoints))
for _, lp := range c.Loadpoints {
status := "missing"
- if lp.DriverName != "" && drivers[lp.DriverName] {
+ if driver, found := drivers[lp.DriverName]; lp.DriverName != "" && found {
status = "configured"
+ if driver.Disabled {
+ status = "disabled"
+ }
} else if lp.DriverName != "" && c.OCPP != nil && c.OCPP.Enabled {
// OCPP identities have no driver entry and may not have connected yet.
status = "unverified_ocpp"
@@ -32,16 +35,21 @@ func (c *Config) LoadpointConnections() []LoadpointConnection {
}
const missingChargerConnection = "Missing charger connection: "
+const disabledChargerConnection = "Disabled charger connection: "
func (c *Config) warnMissingLoadpointConnections() {
// Recompute these warnings after a repair while retaining step corrections.
warnings := make([]string, 0, len(c.LoadpointWarnings))
for _, warning := range c.LoadpointWarnings {
- if !strings.HasPrefix(warning, missingChargerConnection) {
+ if !strings.HasPrefix(warning, missingChargerConnection) && !strings.HasPrefix(warning, disabledChargerConnection) {
warnings = append(warnings, warning)
}
}
for _, connection := range c.LoadpointConnections() {
+ if connection.Status == "disabled" {
+ warnings = append(warnings, disabledChargerConnection+fmt.Sprintf("charger %q refers to disabled driver %q. Enable that driver in Settings > Devices, select an enabled charger, or remove this charger in Settings > Chargers.", connection.ID, connection.DriverName))
+ continue
+ }
if connection.Status != "missing" {
continue
}
diff --git a/go/internal/config/loadpoint_disabled_test.go b/go/internal/config/loadpoint_disabled_test.go
new file mode 100644
index 000000000..78545d0b8
--- /dev/null
+++ b/go/internal/config/loadpoint_disabled_test.go
@@ -0,0 +1,44 @@
+package config
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestDisabledChargerBindingWarnsAndRecovers(t *testing.T) {
+ cfg := &Config{Drivers: []Driver{{Name: "charger", Disabled: true}},
+ Loadpoints: []Loadpoint{{ID: "garage", DriverName: "charger"}}, OCPP: &OCPP{Enabled: true}}
+ for range 2 {
+ if err := cfg.normalizeLoadpointSteps(); err != nil {
+ t.Fatal(err)
+ }
+ if cfg.LoadpointConnections()[0].Status != "disabled" || len(cfg.LoadpointWarnings) != 1 || !strings.Contains(cfg.LoadpointWarnings[0], "disabled") {
+ t.Fatal("a disabled saved charger binding did not keep one actionable warning")
+ }
+ if cfg.Loadpoints[0].DriverName != "charger" || !cfg.Drivers[0].Disabled {
+ t.Fatal("validation changed equipment selection or enabled a driver")
+ }
+ }
+ cfg.Drivers[0].Disabled = false
+ if err := cfg.normalizeLoadpointSteps(); err != nil {
+ t.Fatal(err)
+ }
+ if cfg.LoadpointConnections()[0].Status != "configured" || len(cfg.LoadpointWarnings) != 0 {
+ t.Fatal("re-enabling left an obsolete warning")
+ }
+ cfg.Drivers = nil
+ cfg.OCPP.Enabled = false
+ if err := cfg.normalizeLoadpointSteps(); err != nil {
+ t.Fatal(err)
+ }
+ if cfg.LoadpointConnections()[0].Status != "missing" || len(cfg.LoadpointWarnings) != 1 {
+ t.Fatal("removal did not replace the warning")
+ }
+ cfg.Loadpoints = nil
+ if err := cfg.normalizeLoadpointSteps(); err != nil {
+ t.Fatal(err)
+ }
+ if len(cfg.LoadpointWarnings) != 0 {
+ t.Fatal("removing the charger kept an obsolete warning")
+ }
+}
diff --git a/go/internal/onboarding/connection_warnings_test.go b/go/internal/onboarding/connection_warnings_test.go
index 1ddb89824..a648da9b2 100644
--- a/go/internal/onboarding/connection_warnings_test.go
+++ b/go/internal/onboarding/connection_warnings_test.go
@@ -10,48 +10,56 @@ import (
)
func TestSetupValidationPersistsConnectionWarningWithoutChangingSettings(t *testing.T) {
- live, err := config.Parse([]byte("fuse:\n max_amps: 20\n phases: 3\n voltage: 230\n"), t.TempDir())
- if err != nil {
- t.Fatal(err)
- }
- live.Revision = 1
- live.Loadpoints = []config.Loadpoint{{ID: "garage", DriverName: "removed-charger", MinChargeW: 1400, MaxChargeW: 7000}}
- store := draftStore(t)
- service := New(store)
- original, err := service.Edit(live, 0, json.RawMessage("{}"))
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Checked(live.Revision, original.Revision, original.SHA256); !errors.Is(err, ErrConflict) {
- t.Fatal("unchecked charger warning was not bound to review")
- }
- checked, err := service.Validate(live, original.Revision, original.SHA256)
- if err != nil {
- t.Fatal(err)
- }
- if len(checked.ValidationWarnings) != 1 || checked.Revision != original.Revision+1 {
- t.Fatal("warning-only validation was not saved for review")
- }
- if !bytes.Equal(checked.Config, original.Config) {
- t.Fatal("warning changed the saved charger choice")
- }
- resumed, err := New(store).Read(live)
- if err != nil || len(resumed.ValidationWarnings) != 1 || resumed.Revision != checked.Revision {
- t.Fatal("warning disappeared after reopening the draft")
- }
- again, err := service.Validate(live, checked.Revision, checked.SHA256)
- if err != nil || again.Revision != checked.Revision {
- t.Fatal("revalidation changed a reviewed warning")
- }
- if _, err := service.Checked(live.Revision, checked.Revision, checked.SHA256); err != nil {
- t.Fatal(err)
- }
- repaired, err := service.Edit(live, checked.Revision, json.RawMessage(`{"loadpoints":[]}`))
- if err != nil {
- t.Fatal(err)
- }
- repaired, err = service.Validate(live, repaired.Revision, repaired.SHA256)
- if err != nil || len(repaired.ValidationWarnings) != 0 {
- t.Fatal("repair left an old warning")
+ for _, disabled := range []bool{false, true} {
+ t.Run(map[bool]string{false: "removed", true: "disabled"}[disabled], func(t *testing.T) {
+ live, err := config.Parse([]byte("fuse:\n max_amps: 20\n phases: 3\n voltage: 230\n"), t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ live.Revision = 1
+ if disabled {
+ live.Drivers = []config.Driver{{Name: "site", Lua: "drivers/meter.lua", IsSiteMeter: true, Capabilities: config.Capabilities{Standalone: true}}, {Name: "removed-charger", Lua: "drivers/charger.lua", Disabled: true, Capabilities: config.Capabilities{Standalone: true}}}
+ }
+ live.Loadpoints = []config.Loadpoint{{ID: "garage", DriverName: "removed-charger", MinChargeW: 1400, MaxChargeW: 7000}}
+ store := draftStore(t)
+ service := New(store)
+ original, err := service.Edit(live, 0, json.RawMessage("{}"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := service.Checked(live.Revision, original.Revision, original.SHA256); !errors.Is(err, ErrConflict) {
+ t.Fatal("unchecked charger warning was not bound to review")
+ }
+ checked, err := service.Validate(live, original.Revision, original.SHA256)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(checked.ValidationWarnings) != 1 || checked.Revision != original.Revision+1 {
+ t.Fatal("warning-only validation was not saved for review")
+ }
+ if !bytes.Equal(checked.Config, original.Config) {
+ t.Fatal("warning changed the saved charger choice")
+ }
+ resumed, err := New(store).Read(live)
+ if err != nil || len(resumed.ValidationWarnings) != 1 || resumed.Revision != checked.Revision {
+ t.Fatal("warning disappeared after reopening the draft")
+ }
+ again, err := service.Validate(live, checked.Revision, checked.SHA256)
+ if err != nil || again.Revision != checked.Revision {
+ t.Fatal("revalidation changed a reviewed warning")
+ }
+ if _, err := service.Checked(live.Revision, checked.Revision, checked.SHA256); err != nil {
+ t.Fatal(err)
+ }
+ repaired, err := service.Edit(live, checked.Revision, json.RawMessage(`{"loadpoints":[]}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ repaired, err = service.Validate(live, repaired.Revision, repaired.SHA256)
+ if err != nil || len(repaired.ValidationWarnings) != 0 {
+ t.Fatal("repair left an old warning")
+ }
+
+ })
}
}
diff --git a/go/test/fixtures/measurement-api/main.go b/go/test/fixtures/measurement-api/main.go
new file mode 100644
index 000000000..800060015
--- /dev/null
+++ b/go/test/fixtures/measurement-api/main.go
@@ -0,0 +1,83 @@
+// measurement-api serves the real Core read handlers with synthetic telemetry
+// and temporary SQLite data for the paired Cloud MCP integration tests. It
+// opens no driver, controls no device and reads no installed configuration.
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/srcfl/ftw/go/internal/api"
+ "github.com/srcfl/ftw/go/internal/apiauth"
+ "github.com/srcfl/ftw/go/internal/config"
+ "github.com/srcfl/ftw/go/internal/control"
+ "github.com/srcfl/ftw/go/internal/state"
+ "github.com/srcfl/ftw/go/internal/telemetry"
+ "github.com/srcfl/ftw/go/protocol"
+)
+
+func main() {
+ if len(os.Args) != 2 {
+ panic("choose missing, disabled or repaired fixture")
+ }
+ mode := os.Args[1]
+ if mode != "missing" && mode != "disabled" && mode != "repaired" {
+ panic("unknown measurement fixture")
+ }
+ dir, err := os.MkdirTemp("", "ftw-measurement-api-")
+ if err != nil {
+ panic("temporary state unavailable")
+ }
+ defer os.RemoveAll(dir)
+ st, err := state.Open(filepath.Join(dir, "state.db"))
+ if err != nil {
+ panic("temporary state unavailable")
+ }
+ defer st.Close()
+ if st.EnableHistoryAggregation() != nil {
+ panic("history aggregation unavailable")
+ }
+ base := time.Now().UTC().Truncate(5 * time.Minute).Add(-20 * time.Minute).UnixMilli()
+ for i := range 2 {
+ counter := 100 + float64(i)*12
+ if err := st.EnqueueTelemetryTick(nil, []state.Sample{{Driver: "charger-current", Metric: "ev_w", TsMs: base + int64(i)*60000, Value: 1000 + float64(i)*1000, Unit: "W"}}, []state.EnergyObservation{{AssetID: "fixture-asset", DeviceID: "fixture-device", AssetKind: state.AssetGridMeter, Label: "fixture-site", Flow: state.FlowGridImport, AtMs: base + int64(i)*60000, CounterWh: &counter}}); err != nil {
+ panic("could not seed measurement history")
+ }
+ }
+ if st.FlushHistory(context.Background()) != nil {
+ panic("could not flush measurement history")
+ }
+ cfg := &config.Config{Drivers: []config.Driver{{Name: "site", IsSiteMeter: true}, {Name: "charger-current", Disabled: mode == "disabled"}}, Loadpoints: []config.Loadpoint{{ID: "garage", DriverName: "charger-current"}}}
+ if mode == "missing" {
+ cfg.Loadpoints[0].DriverName = "charger-old"
+ }
+ tel := telemetry.NewStore()
+ tel.Update("site", telemetry.DerMeter, 4000, nil, nil)
+ tel.RecordDriverSuccess("site")
+ if mode != "disabled" {
+ data, _ := json.Marshal(map[string]any{"power_observed_at": time.Now().UTC().Format(time.RFC3339Nano)})
+ tel.Update("charger-current", telemetry.DerEV, 1000, nil, data)
+ tel.RecordDriverSuccess("charger-current")
+ }
+ srv := api.New(&api.Deps{State: st, Tel: tel, Cfg: cfg, CfgMu: &sync.RWMutex{}, Ctrl: &control.State{SiteMeterDriver: "site"}, CtrlMu: &sync.Mutex{}, CapMu: &sync.RWMutex{}, Version: "0.153.0-test",
+ HistoryMeasurementSources: func() telemetry.ForecastOptions {
+ return telemetry.ForecastOptions{ExpectedFlows: []telemetry.ForecastFlow{{Driver: cfg.Loadpoints[0].DriverName, DerType: telemetry.DerEV}}}
+ }})
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ caller := apiauth.Caller{Kind: apiauth.KindAgent, Role: apiauth.RoleAgent, Subject: "agent:fixture", Scopes: apiauth.NewScopeSet(protocol.ScopeDiagRun)}
+ r = r.WithContext(apiauth.WithCaller(r.Context(), caller))
+ srv.ServeAgentRead(w, r, protocol.APIRead{MaxBytes: protocol.AgentAPIPageBytes})
+ }))
+ defer server.Close()
+ // The parent test consumes this on a private pipe; it never logs the URL.
+ fmt.Println(server.URL)
+ // Closing stdin ends the fixture and removes all temporary state.
+ _, _ = os.Stdin.Read(make([]byte, 1))
+}
diff --git a/web/settings/tabs/loadpoint-connections.test.mjs b/web/settings/tabs/loadpoint-connections.test.mjs
index 0a16a8d4b..89bedc185 100644
--- a/web/settings/tabs/loadpoint-connections.test.mjs
+++ b/web/settings/tabs/loadpoint-connections.test.mjs
@@ -11,6 +11,22 @@ const config = () => ({
});
describe('saved charger connections', () => {
+ it('warns for a disabled driver, even with OCPP enabled, until an explicit repair', () => {
+ S.catalogByLua = {'drivers/charger.lua': {capabilities: ['ev']}};
+ S.ocppStatus = {enabled: true, chargers: []};
+ const cfg = config();
+ cfg.ocpp = {enabled: true};
+ cfg.drivers[0].disabled = true;
+ cfg.loadpoints[0].driver_name = 'charger-current';
+ const html = render(cfg);
+ assert.match(html, /data-charger-connection-status="0" role="alert"/);
+ assert.match(html, /disabled/);
+ assert.match(html, /Settings > Devices/);
+ assert.equal(cfg.drivers[0].disabled, true);
+ assert.equal(cfg.loadpoints[0].driver_name, 'charger-current');
+ cfg.drivers[0].disabled = false;
+ assert.doesNotMatch(render(cfg), /data-charger-connection-status/);
+ });
it('shows the missing saved connection without selecting a replacement', () => {
S.catalogByLua = {'drivers/charger.lua': {capabilities: ['ev']}};
S.ocppStatus = {enabled: false, chargers: []};
diff --git a/web/settings/tabs/loadpoints.js b/web/settings/tabs/loadpoints.js
index d40edf380..7fd8a571d 100644
--- a/web/settings/tabs/loadpoints.js
+++ b/web/settings/tabs/loadpoints.js
@@ -55,7 +55,12 @@
function chargerConnection(config, lp, ocppStatus) {
var name = lp.driver_name || "";
- if (name && (config.drivers || []).some(function (driver) { return driver.name === name; })) return null;
+ var driver = name && (config.drivers || []).find(function (driver) { return driver.name === name; });
+ if (driver) {
+ if (!driver.disabled) return null;
+ return { kind: "disabled", label: "disabled connection",
+ message: 'Saved connection "' + name + '" uses a disabled driver. Enable it in Settings > Devices, select an enabled charger, or remove this charger.' };
+ }
if (name && ocppChargerIds(ocppStatus).indexOf(name) >= 0) return null;
if (name && config.ocpp && config.ocpp.enabled) {
return { kind: "unverified_ocpp", label: "OCPP not seen",
@@ -504,8 +509,8 @@
'
' : '') +
'