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 @@ '
' + 'Charger ' + (idx + 1) + ' ยท ' + escHtml(lp.id || "(unnamed)") + '' + (connection ? '

' + escHtml(connection.message) + '

' : '') + + (connection.kind !== 'unverified_ocpp' ? 'alert' : 'status') + '" style="color:var(--' + + (connection.kind !== 'unverified_ocpp' ? 'amber' : 'text-dim') + ')">' + escHtml(connection.message) + '

' : '') + '
' + '
' +