diff --git a/.changeset/simple-electrical-setup.md b/.changeset/simple-electrical-setup.md new file mode 100644 index 000000000..c1093b554 --- /dev/null +++ b/.changeset/simple-electrical-setup.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Make electricity setup clearer: choose three-phase or single-phase, enter the confirmed main fuse rating, and keep voltage under Advanced. Standard connections use 230 V per phase. Show saved custom connections without changing their values. Make the country and price-zone list available during a fresh install. diff --git a/go/cmd/ftw/bootstrap.go b/go/cmd/ftw/bootstrap.go index 42284d376..a769e3920 100644 --- a/go/cmd/ftw/bootstrap.go +++ b/go/cmd/ftw/bootstrap.go @@ -13,6 +13,7 @@ import ( "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/drivers" "github.com/srcfl/ftw/go/internal/evcloud" + "github.com/srcfl/ftw/go/internal/prices" "github.com/srcfl/ftw/go/internal/scanner" ) @@ -33,6 +34,7 @@ func runBootstrap(configPath, webDir, driverDir string) { w.Header().Set("Cache-Control", "no-cache, must-revalidate") http.ServeFile(w, r, path) }) + mux.HandleFunc("GET /api/prices/zones", bootstrapPriceZones) mux.HandleFunc("GET /api/drivers/catalog", func(w http.ResponseWriter, _ *http.Request) { entries, err := drivers.LoadCatalogMulti(config.UserDriversDirOverride, driverDir) if err != nil { @@ -121,6 +123,18 @@ func runBootstrap(configPath, webDir, driverDir string) { } } +// Bootstrap needs the same country/zone choices before any config exists. +func bootstrapPriceZones(w http.ResponseWriter, _ *http.Request) { + zones := prices.Zones() + items := make([]map[string]string, 0, len(zones)) + for _, z := range zones { + items = append(items, map[string]string{ + "code": z.Code, "country": z.Country, "currency": z.Currency, "name": z.Name(), + }) + } + writeBootstrapJSON(w, http.StatusOK, map[string]any{"zones": items}) +} + func secureBootstrapMutations(next http.Handler) http.Handler { return api.WithSecurityHeaders(api.Authenticate(next, apiMutationPolicy())) } diff --git a/go/cmd/ftw/bootstrap_zones_test.go b/go/cmd/ftw/bootstrap_zones_test.go new file mode 100644 index 000000000..c999cfcbd --- /dev/null +++ b/go/cmd/ftw/bootstrap_zones_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/srcfl/ftw/go/internal/prices" +) + +func TestBootstrapPriceZones(t *testing.T) { + rr := httptest.NewRecorder() + secureBootstrapMutations(http.HandlerFunc(bootstrapPriceZones)).ServeHTTP(rr, + httptest.NewRequest(http.MethodGet, "http://localhost:8080/api/prices/zones", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rr.Code, rr.Body.String()) + } + var result struct { + Zones []struct{ Code, Country, Currency, Name string } + } + if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + want := prices.Zones() + if len(result.Zones) != len(want) { + t.Fatalf("got %d zones, want %d", len(result.Zones), len(want)) + } + for i, zone := range result.Zones { + if zone.Code != want[i].Code || zone.Country != want[i].Country || zone.Currency != want[i].Currency || zone.Name != want[i].Name() { + t.Errorf("zone %d = %+v, want %+v", i, zone, want[i]) + } + } +} diff --git a/web/settings-shell.test.mjs b/web/settings-shell.test.mjs index 3951290d8..e989b851b 100644 --- a/web/settings-shell.test.mjs +++ b/web/settings-shell.test.mjs @@ -495,3 +495,72 @@ describe("charger setup navigation and saves", () => { assert.equal(elements['settings-save'].hidden, false); }); }); + +async function electricalShell(fuse) { + const original = { site: { name: 'Home' }, fuse, hidden: { keep: 17 } }; + const rig = loadShell(structuredClone(original)); + rig.loadTab('./settings/tabs/control.js'); + const fields = {}; + for (const [key, fallback] of Object.entries({ phases: 3, voltage: 230, max_amps: 16, safety_margin_a: 0.5 })) { + fields[key] = Object.assign(stubElement(), { + dataset: { path: 'fuse.' + key }, type: 'number', value: String(fuse[key] ?? fallback), + }); + } + const connection = stubElement(), advanced = stubElement(), summary = stubElement(); + const body = rig.elements['settings-body']; + body.querySelectorAll = selector => selector === '[data-path]' ? Object.values(fields) : []; + body.querySelector = selector => ({ '#fuse-connection': connection, '#fuse-advanced': advanced, + '#fuse-summary': summary })[selector] ?? Object.values(fields).find(f => selector === `[data-path="${f.dataset.path}"]`); + rig.elements['settings-btn'].handlers.click(); + await settled(); + return { ...rig, original, fields, connection, advanced, summary, + async save() { + rig.elements['settings-save'].handlers.click(); + await settled(); + return JSON.parse(rig.requests.filter(r => r.opts?.method === 'POST').at(-1).opts.body); + } }; +} + +describe('electricity connection settings', () => { + for (const fuse of [ + { phases: 3, voltage: 230, max_amps: 20, safety_margin_a: 0 }, + { phases: 1, voltage: 230, max_amps: 25, safety_margin_a: 1.2 }, + { phases: 3, voltage: 400, max_amps: 16, safety_margin_a: 0 }, + { phases: 2, voltage: 240, max_amps: 32, safety_margin_a: 0.5 }, + {}, + ]) { + it('preserves the saved config on an untouched form: ' + JSON.stringify(fuse), async () => { + const rig = await electricalShell(fuse); + const custom = fuse.voltage != null && (fuse.voltage !== 230 || fuse.phases === 2); + assert.equal(rig.connection.value, custom ? 'custom' : String(fuse.phases ?? 3)); + if (custom) { + assert.equal(rig.advanced.open, true); + assert.match(rig.summary.textContent, /Custom connection/); + assert.ok(rig.summary.textContent.includes(String(fuse.voltage))); + } + assert.deepEqual(await rig.save(), rig.original); + }); + } + + it('only replaces custom voltage when the user picks a standard connection', async () => { + const rig = await electricalShell({ phases: 2, voltage: 240, max_amps: 20, safety_margin_a: 0 }); + rig.connection.value = '3'; + rig.connection.handlers.change(); + assert.deepEqual((await rig.save()).fuse, { phases: 3, voltage: 230, max_amps: 20, safety_margin_a: 0 }); + rig.connection.value = '1'; + rig.connection.handlers.change(); + assert.equal((await rig.save()).fuse.phases, 1); + rig.fields.voltage.value = '240'; + rig.fields.voltage.handlers.input(); + assert.equal(rig.connection.value, 'custom'); + assert.equal((await rig.save()).fuse.voltage, 240); + }); + + it('opening Custom does not change stored values', async () => { + const rig = await electricalShell({ phases: 3, voltage: 230, max_amps: 20 }); + rig.connection.value = 'custom'; + rig.connection.handlers.change(); + assert.equal(rig.advanced.open, true); + assert.deepEqual(await rig.save(), rig.original); + }); +}); diff --git a/web/settings/tabs/control.js b/web/settings/tabs/control.js index d0db1ab23..5b0e700bd 100644 --- a/web/settings/tabs/control.js +++ b/web/settings/tabs/control.js @@ -68,21 +68,58 @@ "Trigger when projected grid export exceeds this many watts after the plan's target. Defaults to 100 W whenever the operator or planner enables absorption.") + '' + '' + - '
'; + }, + after: function (ctx) { + var root = ctx.bodyEl; + var connection = root.querySelector("#fuse-connection"); + var phases = root.querySelector('[data-path="fuse.phases"]'); + var voltage = root.querySelector('[data-path="fuse.voltage"]'); + var advanced = root.querySelector("#fuse-advanced"); + var summary = root.querySelector("#fuse-summary"); + function sync() { + var standard = Number(voltage.value) === 230 && (phases.value === "1" || phases.value === "3"); + connection.value = standard ? phases.value : "custom"; + summary.textContent = standard + ? (phases.value === "3" ? "Three-phase" : "Single-phase") + " · 230 V per phase" + : "Custom connection · " + phases.value + " phases · " + voltage.value + " V per phase. Check Advanced before changing this."; + if (!standard) advanced.open = true; + } + connection.addEventListener("change", function () { + if (connection.value === "custom") { + advanced.open = true; + phases.focus(); + return; + } + phases.value = connection.value; + voltage.value = "230"; + sync(); + }); + phases.addEventListener("input", sync); + voltage.addEventListener("input", sync); + sync(); }, }; })(); diff --git a/web/setup-connection.test.mjs b/web/setup-connection.test.mjs new file mode 100644 index 000000000..381fae23c --- /dev/null +++ b/web/setup-connection.test.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; +import vm from 'node:vm'; + +const html = readFileSync(new URL('./setup.html', import.meta.url), 'utf8'); +const source = readFileSync(new URL('./setup.js', import.meta.url), 'utf8'); + +// Run the wizard's real navigation and save handlers. Initial field values +// come from the HTML so a changed default also changes the posted config. +function wizard(search = '', zonesFail = false) { + const elements = {}, requests = []; + function element(attrs = '') { + const attr = name => attrs.match(new RegExp('\\b' + name + '="([^"]*)"'))?.[1]; + const handlers = {}, classes = new Set(); + let markup = ''; + return { + value: attr('value') ?? '', checked: /\bchecked\b/.test(attrs), style: {}, handlers, + hidden: /\bhidden\b/.test(attrs), open: false, textContent: '', children: [], + classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) }, + addEventListener(event, fn) { (handlers[event] ??= []).push(fn); }, + fire(event) { for (const fn of handlers[event] ?? []) fn.call(this); }, + focus() { this.focused = true; }, + set innerHTML(value) { markup = value; this.children = []; }, + get innerHTML() { return markup || this.textContent.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); }, + appendChild(child) { this.children.push(child); if (child.selected || this.children.length === 1) this.value = child.value; }, + checkValidity() { + if (!this.value) return !/\brequired\b/.test(attrs); + const n = Number(this.value), step = attr('step'); + return Number.isFinite(n) && (attr('min') == null || n >= Number(attr('min'))) && + (attr('max') == null || n <= Number(attr('max'))) && (step !== '1' || Number.isInteger(n)); + }, + }; + } + for (const match of html.matchAll(/<([a-z-]+)\b([^>]*\bid="([^"]+)"[^>]*)>/g)) { + elements[match[3]] = element(match[2]); + } + for (const match of html.matchAll(/