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
5 changes: 5 additions & 0 deletions .changeset/charger-connection-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Warn about missing saved charger connections in Settings and setup drafts. Include exact charger-binding evidence in technical diagnostics so assistants can verify connections without guessing from redacted names.
12 changes: 11 additions & 1 deletion go/internal/api/api_agent_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func (s *Server) agentDebugContext() map[string]any {
ref, kind = "v"+strings.TrimPrefix(s.deps.Version, "v"), "declared release tag"
}
tree := "https://github.com/srcfl/ftw/tree/" + url.PathEscape(ref)
return map[string]any{
out := map[string]any{
"core": map[string]any{"version": s.deps.Version, "revision": revision, "modified": modified, "source_ref_kind": kind,
"repository": "https://github.com/srcfl/ftw", "source": tree,
"api": tree + "/go/internal/api", "energy_history": tree + "/go/internal/state", "planner": tree + "/go/internal/mpc",
Expand All @@ -151,8 +151,18 @@ func (s *Server) agentDebugContext() map[string]any {
"Read /api/health, /api/support/report and /api/components for versions, clock, freshness and failures.",
"Use /api/energy/history for Wh and provenance, /api/series for original measurements, and /api/mpc/diagnose/history then /api/mpc/diagnose/at for the plan active at the incident.",
"Inspect configuration, driver details, driver source and logs as needed. Compare with source for the running version; default-branch code may differ.",
"For charger faults, read charger_connections below and use /api/config, /api/drivers and /api/loadpoints to compare loadpoints[].driver_name with drivers[].name. If OCPP is enabled, also check /api/ocpp/chargers. A configured name does not prove online equipment or successful control.",
"Basic config_get and drivers_status hide technical names and cannot prove these bindings. Use these full diagnostic API reads. If a read is refused, record the path, refusal and missing grant or version; do not infer a driver fault from unavailable or redacted evidence.",
"For a bug report, include the Core version and source reference, equipment and driver versions, reproduction steps, expected and observed results, incident time, exact API queries and relevant logs or snapshots. Mark data gaps and whether evidence came from real equipment, a simulator or a test.",
"Use the existing control and goal tools when an action is needed. Reading a diagnostic view does not issue a command. Prepare a bug report for the user; do not publish it without their request.",
},
}
if s.deps.Cfg != nil {
if s.deps.CfgMu != nil {
s.deps.CfgMu.RLock()
defer s.deps.CfgMu.RUnlock()
}
out["charger_connections"] = s.deps.Cfg.LoadpointConnections()
}
return out
}
30 changes: 30 additions & 0 deletions go/internal/api/api_agent_charger_connections_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package api

import (
"encoding/json"
"sync"
"testing"

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/protocol"
)

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"}}}
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 {
Troubleshooting struct {
Connections []config.LoadpointConnection `json:"charger_connections"`
} `json:"troubleshooting"`
}
if err := json.Unmarshal(answer.Body.Bytes(), &page); err != nil {
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" {
t.Fatal("diagnostic evidence lost the saved name or confused a name binding with device health")
}
}
4 changes: 3 additions & 1 deletion go/internal/api/api_charger_steps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ func TestPostConfigSavesLimitedStepsAndReturnsWarning(t *testing.T) {
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Warnings) != 1 || len(response.Loadpoints) != 1 {
// The missing connection is separate from the corrected power steps.
if len(response.Warnings) != 2 || len(response.Loadpoints) != 1 ||
!strings.Contains(strings.Join(response.Warnings, " "), "Missing charger connection") {
t.Fatalf("missing save feedback: %s", rr.Body.String())
}
if response.Loadpoints[0].AllowedStepsW[3] != 11000 {
Expand Down
55 changes: 55 additions & 0 deletions go/internal/config/loadpoint_connections.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package config

import (
"fmt"
"strings"
)

// LoadpointConnection describes a saved name binding, not device health.
type LoadpointConnection struct {
ID string `json:"id"`
DriverName string `json:"driver_name"`
Status string `json:"status"`
}

func (c *Config) LoadpointConnections() []LoadpointConnection {
drivers := make(map[string]bool, len(c.Drivers))
for _, driver := range c.Drivers {
drivers[driver.Name] = true
}
connections := make([]LoadpointConnection, 0, len(c.Loadpoints))
for _, lp := range c.Loadpoints {
status := "missing"
if lp.DriverName != "" && drivers[lp.DriverName] {
status = "configured"
} 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"
}
connections = append(connections, LoadpointConnection{ID: lp.ID, DriverName: lp.DriverName, Status: status})
}
return connections
}

const missingChargerConnection = "Missing 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) {
warnings = append(warnings, warning)
}
}
for _, connection := range c.LoadpointConnections() {
if connection.Status != "missing" {
continue
}
detail := fmt.Sprintf("charger %q has no charger selected.", connection.ID)
if connection.DriverName != "" {
detail = fmt.Sprintf("charger %q refers to %q, which is not a configured driver.", connection.ID, connection.DriverName)
}
warnings = append(warnings, missingChargerConnection+detail+" Select a configured charger or remove this charger in Settings > Chargers.")
}
c.LoadpointWarnings = warnings
}
68 changes: 68 additions & 0 deletions go/internal/config/loadpoint_connections_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package config

import (
"slices"
"strings"
"testing"
)

func TestMissingChargerConnectionWarnsWithoutRebinding(t *testing.T) {
cfg := &Config{Loadpoints: []Loadpoint{{ID: "garage", DriverName: "old-charger", MinChargeW: 1400, MaxChargeW: 7000}}}
applyDefaults(cfg)
cfg.Fuse.MaxAmps, cfg.Fuse.Phases, cfg.Fuse.Voltage = 20, 3, 230
if err := cfg.Validate(); err != nil {
t.Fatal("valid settings should remain repairable")
}
if len(cfg.LoadpointWarnings) != 1 || !strings.Contains(cfg.LoadpointWarnings[0], "old-charger") {
t.Fatal("missing charger connection was silent")
}
if cfg.Loadpoints[0].DriverName != "old-charger" {
t.Fatal("validation chose a replacement")
}
cfg.LoadpointWarnings = append(cfg.LoadpointWarnings, "Existing charging-step correction")
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
if len(cfg.LoadpointWarnings) != 2 {
t.Fatal("validation duplicated its warning")
}
cfg.Loadpoints = nil
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
if !slices.Equal(cfg.LoadpointWarnings, []string{"Existing charging-step correction"}) {
t.Fatal("repair kept a stale connection warning or lost a charging-step correction")
}
}

func TestChargerConnectionEvidenceDistinguishesConfiguredMissingAndOCPP(t *testing.T) {
for _, tc := range []struct {
name, bound, driver string
ocpp bool
status string
warnings int
}{
{"configured name", "charger-current", "charger-current", false, "configured", 0},
{"old name", "charger-old", "charger-current", false, "missing", 1},
{"no selection", "", "charger-current", false, "missing", 1},
{"OCPP may be offline", "ocpp-garage", "", true, "unverified_ocpp", 0},
{"OCPP does not replace an empty selection", "", "", true, "missing", 1},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := &Config{Loadpoints: []Loadpoint{{ID: "garage", DriverName: tc.bound}}, OCPP: &OCPP{Enabled: tc.ocpp}}
if tc.driver != "" {
cfg.Drivers = []Driver{{Name: tc.driver}}
}
if err := cfg.normalizeLoadpointSteps(); err != nil {
t.Fatal(err)
}
if len(cfg.LoadpointWarnings) != tc.warnings {
t.Fatal("unexpected connection warnings")
}
evidence := cfg.LoadpointConnections()
if len(evidence) != 1 || evidence[0].Status != tc.status || evidence[0].DriverName != tc.bound {
t.Fatal("connection evidence lost the exact saved name or its status")
}
})
}
}
1 change: 1 addition & 0 deletions go/internal/config/loadpoint_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ func (c *Config) normalizeLoadpointSteps() error {
}
}
c.Loadpoints = loadpoints
c.warnMissingLoadpointConnections()
return nil
}
9 changes: 5 additions & 4 deletions go/internal/config/loadpoint_steps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ loadpoints:
if !reflect.DeepEqual(cfg.Loadpoints[0].AllowedStepsW, want) {
t.Fatal(cfg.Loadpoints[0].AllowedStepsW)
}
if len(cfg.LoadpointWarnings) != 1 || !strings.Contains(cfg.LoadpointWarnings[0], `"Easee"`) {
// This fixture also has no configured charger for its saved connection.
if len(cfg.LoadpointWarnings) != 2 || !strings.Contains(cfg.LoadpointWarnings[0], `"Easee"`) {
t.Fatal(cfg.LoadpointWarnings)
}
old := cfg.Loadpoints[0].AllowedStepsW
Expand All @@ -48,8 +49,8 @@ loadpoints:
t.Fatalf("persisted step %v exceeds max", step)
}
}
if len(reloaded.LoadpointWarnings) != 0 {
t.Fatal("corrected persisted config still warns", reloaded.LoadpointWarnings)
if len(reloaded.LoadpointWarnings) != 1 || !strings.HasPrefix(reloaded.LoadpointWarnings[0], missingChargerConnection) {
t.Fatal("step correction should leave only the unresolved connection warning")
}
}

Expand Down Expand Up @@ -79,7 +80,7 @@ func TestLoadpointStepsAreLimitedInStoredSettings(t *testing.T) {
if err := json.Unmarshal(doc.Document, &saved); err != nil {
t.Fatal(err)
}
if saved.Config.Loadpoints[0].AllowedStepsW[1] != 11000 || len(cfg.LoadpointWarnings) != 1 {
if saved.Config.Loadpoints[0].AllowedStepsW[1] != 11000 || len(cfg.LoadpointWarnings) != 2 {
t.Fatalf("stored steps=%v warnings=%v", saved.Config.Loadpoints[0].AllowedStepsW, cfg.LoadpointWarnings)
}
}
Expand Down
57 changes: 57 additions & 0 deletions go/internal/onboarding/connection_warnings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package onboarding

import (
"bytes"
"encoding/json"
"errors"
"testing"

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

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")
}
}
20 changes: 17 additions & 3 deletions go/internal/onboarding/draft.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"slices"
"strings"
"sync"

Expand Down Expand Up @@ -258,12 +259,20 @@ func (s *Service) ValidateWithCheck(live *config.Config, revision int64, sha str
if err != nil {
return Draft{}, err
}
if bytes.Equal(normalized, d.Config) {
warnings := slices.Clone(d.ValidationWarnings)
for _, warning := range cfg.LoadpointWarnings {
if !slices.Contains(warnings, warning) {
warnings = append(warnings, warning)
}
}
if bytes.Equal(normalized, d.Config) && slices.Equal(warnings, d.ValidationWarnings) {
return d, nil
}
if !bytes.Equal(normalized, d.Config) {
d.NormalizedFromRevision = d.Revision
}
d.Config = normalized
d.NormalizedFromRevision = d.Revision
d.ValidationWarnings = append([]string(nil), cfg.LoadpointWarnings...)
d.ValidationWarnings = warnings
d.Revision++
d.SHA256 = digest(d.Config, d.BaseRevision)
raw, err := json.Marshal(d)
Expand Down Expand Up @@ -318,6 +327,11 @@ func (s *Service) checkedLocked(liveRevision, revision int64, sha string) (*conf
if !bytes.Equal(normalized, d.Config) {
return nil, fmt.Errorf("%w: run setup_validate and review its returned draft before saving", ErrConflict)
}
for _, warning := range cfg.LoadpointWarnings {
if !slices.Contains(d.ValidationWarnings, warning) {
return nil, fmt.Errorf("%w: run setup_validate and review its returned warnings before saving", ErrConflict)
}
}
return cfg, nil
}

Expand Down
1 change: 1 addition & 0 deletions web/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -2190,6 +2190,7 @@ body.ftw-app .settings-status {
font-family: var(--mono);
}
body.ftw-app .settings-status.success { color: var(--green-e); }
body.ftw-app .settings-status.warning { color: var(--amber); }
body.ftw-app .settings-status.error { color: var(--red-e); }
body.ftw-app .creds-badge {
border-radius: 999px;
Expand Down
25 changes: 23 additions & 2 deletions web/settings-shell.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function loadShell(saveResponse, ok = true) {
const requests = [];
const responses = {};
const alerts = [];
const timers = [];
const sandbox = {
window: { FTWSettings: { tabs: {} } },
document,
Expand All @@ -73,13 +74,13 @@ function loadShell(saveResponse, ok = true) {
},
// The shell only uses timers to clear the "Saved" status and to poll after
// a restart; neither is what these tests are about.
setTimeout: () => 0,
setTimeout: fn => { timers.push(fn); return timers.length; },
Date, JSON, Array, Object, String, console,
};
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(source, sandbox);
return { elements, document, requests, responses, alerts, tabs: sandbox.window.FTWSettings.tabs,
return { elements, document, requests, responses, alerts, timers, tabs: sandbox.window.FTWSettings.tabs,
loadTab: file => vm.runInContext(readFileSync(new URL(file, import.meta.url), "utf8"), sandbox) };
}

Expand Down Expand Up @@ -440,6 +441,26 @@ describe("late driver profile fields", () => {
});

describe("the settings shell after a save", () => {
it("keeps charger warnings visible after a successful save", async () => {
const rig = loadShell({warnings: ["Missing charger connection: select a configured charger."]});
rig.elements["settings-save"].handlers.click();
await settled();
const status = rig.elements["settings-status"];
assert.match(status.textContent, /Saved.*Missing charger connection/);
assert.equal(status.className, "settings-status warning");
assert.equal(status.attributes.role, "alert");
assert.equal(rig.timers.length, 0, "a warning must not disappear on a timer");
});
it("does not let an earlier success timer clear a later warning", async () => {
const rig = loadShell({});
rig.elements["settings-save"].handlers.click();
await settled();
rig.responses["/api/config"] = {warnings: ["Missing charger connection."]};
rig.elements["settings-save"].handlers.click();
await settled();
rig.timers.forEach(fn => fn());
assert.match(rig.elements["settings-status"].textContent, /Missing charger connection/);
});
it("calls the tab back so it can ask the box again", async () => {
const { elements, tabs } = loadShell({ restart_required: false, restart_reasons: [] });
let called = 0;
Expand Down
Loading
Loading