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/simple-electrical-setup.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions go/cmd/ftw/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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 {
Expand Down Expand Up @@ -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()))
}
Expand Down
34 changes: 34 additions & 0 deletions go/cmd/ftw/bootstrap_zones_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
}
69 changes: 69 additions & 0 deletions web/settings-shell.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
55 changes: 46 additions & 9 deletions web/settings/tabs/control.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.") +
'</div></div>' +
'</fieldset>' +
'<fieldset><legend>Fuse</legend>' +
'<fieldset><legend>Electricity connection</legend>' +
'<label for="fuse-connection">Connection</label>' +
'<select id="fuse-connection">' +
'<option value="3">Three-phase</option>' +
'<option value="1">Single-phase</option>' +
'<option value="custom">Custom connection</option>' +
'</select>' +
'<p id="fuse-summary" role="status"></p>' +
field("Main fuse (A)", "fuse.max_amps", "number", 16,
"Use the rating on your electricity network contract, not a fuse for one appliance.") +
'<details id="fuse-advanced"><summary>Advanced connection settings</summary>' +
'<p>Standard three-phase uses 230 V from phase to neutral and 400 V between phases. ' +
'FTW needs the phase-to-neutral value. The standard choices above set it to 230 V. ' +
'Changing these values does not add support for a different grid type.</p>' +
'<div class="field-row"><div>' +
field("Max amps (A)", "fuse.max_amps", "number", 16) +
'</div><div>' +
field("Phases", "fuse.phases", "number", 3) +
'</div></div>' +
'<div class="field-row"><div>' +
field("Phase-to-neutral voltage (V)", "fuse.voltage", "number", 230,
"Use phase-to-neutral voltage, normally 230 V on a 400/230 V three-phase supply. FTW multiplies this by the number of phases.") +
'</div><div>' +
field("Phase-to-neutral voltage (V)", "fuse.voltage", "number", 230) +
'</div></div>' +
decimalField("Safety margin (A)", "fuse.safety_margin_a", 0.5,
"Headroom below max amps so the inverter's own per-phase limiter doesn't trip first. Defaults to 0.5 A.",
"0.1") +
'</div></div>' +
'</fieldset>';
'</details></fieldset>';
},
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();
},
};
})();
161 changes: 161 additions & 0 deletions web/setup-connection.test.mjs
Original file line number Diff line number Diff line change
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;'); },
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(/<select\b[^>]*id="([^"]+)"[^>]*>([\s\S]*?)<\/select>/g)) {
const options = [...match[2].matchAll(/<option\b([^>]*)>/g)];
const selected = options.find(o => /\bselected\b/.test(o[1])) ?? options[0];
if (!selected) continue;
elements[match[1]].value = selected[1].match(/value="([^"]*)"/)?.[1] ?? '';
}
const sandbox = {
location: { search }, scrollTo() {}, URLSearchParams, setTimeout() {}, console,
document: {
getElementById: id => elements[id], createElement: () => element(),
querySelectorAll: selector => selector === '.step' ? Object.entries(elements).filter(([id]) => /^step-\d+$/.test(id)).map(([, el]) => el) : [],
},
fetch(path, opts) {
requests.push({ path, opts });
if (zonesFail && path === '/api/prices/zones') return Promise.reject(new Error('offline'));
return Promise.resolve({ ok: true, json: async () => path === '/api/prices/zones' ? { zones: [
{ country: 'Sweden', code: 'SE3', currency: 'SEK' },
{ country: 'Belgium', code: 'BE', currency: 'EUR' },
] } : {} });
},
};
sandbox.window = sandbox;
vm.runInNewContext(source, sandbox);
return { elements, requests, go: sandbox.goStep, save: sandbox.saveConfig,
choose(id, value, event = 'change') { elements[id].value = value; elements[id].fire(event); },
posted: () => JSON.parse(requests.find(r => r.opts?.method === 'POST').opts.body),
visible: n => elements['step-' + n].classList.contains('visible'),
};
}
const settled = () => new Promise(resolve => setImmediate(resolve));

describe('setup electricity connection', () => {
it('requires the main fuse instead of silently saving 16 A', () => {
const rig = wizard('?step=2');
assert.equal(rig.elements['fuse-amps'].value, '');
rig.go(3);
assert.equal(rig.visible(2), true);
assert.equal(rig.elements['connection-error'].hidden, false);
rig.save();
assert.equal(rig.requests.some(r => r.opts?.method === 'POST'), false);
});

for (const choice of ['3', '1']) {
it('saves ' + choice + ' phases at 230 V and the entered fuse rating', () => {
const rig = wizard('?step=2');
rig.choose('site-connection', choice);
rig.elements['fuse-amps'].value = '20';
rig.go(3);
assert.equal(rig.visible(3), true);
rig.save();
assert.deepEqual(rig.posted().fuse, { phases: Number(choice), voltage: 230, max_amps: 20 });
});
}

it('shows help for an unknown connection and blocks navigation/save', () => {
const rig = wizard('?step=2');
rig.elements['fuse-amps'].value = '20';
rig.choose('site-connection', 'unknown');
assert.match(rig.elements['connection-help'].textContent, /network operator/);
rig.go(3);
rig.save();
assert.equal(rig.visible(2), true);
assert.equal(rig.requests.some(r => r.opts?.method === 'POST'), false);
rig.choose('site-connection', '1');
rig.go(3);
assert.equal(rig.visible(3), true);
});

for (const amps of ['', '0', '-1', '101', '1.5', 'NaN']) {
it('rejects invalid fuse rating ' + JSON.stringify(amps) + ' even from a review deep link', () => {
const rig = wizard('?step=8');
rig.elements['fuse-amps'].value = amps;
rig.save();
assert.equal(rig.visible(2), true);
assert.equal(rig.requests.some(r => r.opts?.method === 'POST'), false);
});
}

it('keeps custom values across country changes, back/forward and save', async () => {
const rig = wizard('?step=2');
await settled();
rig.elements['fuse-amps'].value = '25';
rig.choose('site-connection', 'custom');
rig.choose('fuse-phases', '2');
rig.choose('fuse-voltage', '240', 'input');
rig.choose('price-country', 'Belgium');
assert.equal(rig.elements['site-connection'].value, 'custom');
assert.match(rig.elements['connection-help'].textContent, /240 V/);
rig.go(3); rig.go(2); rig.go(8); rig.save();
assert.deepEqual(rig.posted().fuse, { phases: 2, voltage: 240, max_amps: 25 });
assert.equal(rig.posted().price.currency, 'EUR');
assert.match(rig.elements['review-content'].innerHTML, /Custom connection.*240 V/);
});

it('only restores 230 V when a standard connection is explicitly selected', () => {
const rig = wizard('?step=2');
rig.choose('fuse-voltage', '400', 'input');
assert.equal(rig.elements['site-connection'].value, 'custom');
rig.choose('price-country', 'Norway');
assert.equal(rig.elements['fuse-voltage'].value, '400');
rig.choose('site-connection', '3');
assert.equal(rig.elements['fuse-voltage'].value, '230');
assert.match(rig.elements['connection-help'].textContent, /without neutral/);
});

it('rejects an empty expert voltage and opens the field with an explanation', () => {
const rig = wizard('?step=8');
rig.elements['fuse-amps'].value = '20';
rig.choose('fuse-voltage', '', 'input');
rig.save();
assert.equal(rig.visible(2), true);
assert.equal(rig.elements['connection-advanced'].open, true);
assert.equal(rig.requests.some(r => r.opts?.method === 'POST'), false);
});

it('keeps the Swedish fallback usable if the country lookup fails', async () => {
const rig = wizard('?step=2', true);
await settled();
rig.elements['fuse-amps'].value = '16';
rig.save();
assert.equal(rig.posted().price.zone, 'SE3');
assert.deepEqual(rig.posted().fuse, { phases: 3, voltage: 230, max_amps: 16 });
});
});
Loading
Loading