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
8 changes: 8 additions & 0 deletions .changeset/preserve-unchanged-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"ftw": patch
---

Keep unchanged form defaults out of saved settings. Opening Planner and saving
now preserves an unset SoC limit, including when saving from another tab.
Show the planner's 95% default maximum instead of 90%.
Keep an untouched driver profile absent when its choices arrive after the form.
130 changes: 128 additions & 2 deletions web/settings-shell.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function loadShell(saveResponse, ok = true) {
for (const id of ELEMENT_IDS) elements[id] = stubElement();

const requests = [];
const responses = {};
const sandbox = {
window: { FTWSettings: { tabs: {} } },
document: {
Expand All @@ -47,7 +48,7 @@ function loadShell(saveResponse, ok = true) {
},
fetch(path, opts) {
requests.push({ path, opts });
return Promise.resolve({ ok, status: ok ? 200 : 400, json: () => Promise.resolve(saveResponse) });
return Promise.resolve({ ok, status: ok ? 200 : 400, headers: { get: () => '"config-7"' }, json: () => Promise.resolve(responses[path] ?? saveResponse) });
},
// The shell only uses timers to clear the "Saved" status and to poll after
// a restart; neither is what these tests are about.
Expand All @@ -57,12 +58,137 @@ function loadShell(saveResponse, ok = true) {
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(source, sandbox);
return { elements, requests, tabs: sandbox.window.FTWSettings.tabs };
return { elements, requests, responses, tabs: sandbox.window.FTWSettings.tabs,
loadTab: file => vm.runInContext(readFileSync(new URL(file, import.meta.url), "utf8"), sandbox) };
}

// One turn of the event loop, which is all the save chain needs to settle.
const settled = () => new Promise((resolve) => setImmediate(resolve));

async function formShell(original = { site: { name: "Home" }, planner: { enabled: true }, hidden: { keep: 17 } }) {
const rig = loadShell(structuredClone(original));
const field = (path, type, value) => Object.assign(stubElement(), { dataset: { path }, type, value });
const fields = {
min: field("planner.soc_min", "number", "0.1"),
max: field("planner.soc_max", "number", "0.95"),
engine: field("planner.engine", "select-one", ""),
checkbox: Object.assign(stubElement(), { dataset: { checkboxPath: "optional.enabled" }, checked: false }),
name: field("site.name", "text", "Home"),
};
let visible = [], context;
rig.elements["settings-body"].querySelectorAll = selector => visible.filter(input =>
selector === "[data-path]" ? input.dataset.path : selector === "[data-checkbox-path]" && input.dataset.checkboxPath);
rig.tabs.control = { render: ctx => { context = ctx; visible = [fields.name]; return ""; } };
rig.tabs.planner = { render: () => { visible = [fields.min, fields.max, fields.engine, fields.checkbox]; return ""; } };
rig.elements["settings-btn"].handlers.click();
await settled();
return { ...rig, original, fields, context, addField: input => visible.push(input), saved: () => JSON.parse(rig.requests.filter(r => r.opts?.method === "POST").at(-1).opts.body) };
}

describe("unchanged settings fields", () => {
for (const visitPlanner of [false, true]) {
it("preserves absent values on save, visit Planner=" + visitPlanner, async () => {
const rig = await formShell();
if (visitPlanner) rig.context.navigateTab("planner");
await rig.context.saveConfig();
assert.deepEqual(rig.saved(), rig.original);
assert.equal(rig.requests.at(-1).opts.headers["If-Match"], '"config-7"');
});
}

it("keeps Planner defaults absent after visiting it and saving another tab", async () => {
const rig = await formShell();
rig.context.navigateTab("planner");
rig.context.navigateTab("control");
rig.fields.name.value = "New name";
await rig.context.saveConfig();
assert.deepEqual(rig.saved(), { ...rig.original, site: { name: "New name" } });
});

it("saves explicit number, select and checkbox edits and a later return to the rendered value", async () => {
const rig = await formShell();
rig.context.navigateTab("planner");
rig.fields.max.value = "0.8";
rig.fields.engine.value = "core";
rig.fields.checkbox.checked = true;
await rig.context.saveConfig();
assert.deepEqual(rig.saved().planner, { enabled: true, soc_max: 0.8, engine: "core" });
assert.deepEqual(rig.saved().optional, { enabled: true });
rig.fields.max.value = "0.95";
await rig.context.saveConfig();
assert.equal(rig.saved().planner.soc_max, 0.95);
});

it("preserves a late secret input until its value changes", async () => {
const rig = await formShell();
const secret = Object.assign(stubElement(), { dataset: { path: "device.secret" }, type: "password", defaultValue: "", value: "" });
rig.addField(secret);
await rig.context.saveConfig();
assert.deepEqual(rig.saved(), rig.original);
secret.value = "local-test-value";
await rig.context.saveConfig();
assert.equal(rig.saved().device.secret, "local-test-value");
});
});

// Run the real asynchronous Devices catalog callback. The shim parses the
// select it inserts; like HTMLSelectElement, it has no defaultValue property.
async function profileShell() {
const original = { site: { name: "Home" }, drivers: [{ name: "GoodWe", lua: "drivers/goodwe.lua",
capabilities: { modbus: { unit_id: 1 } }, config: {} }] };
const rig = await formShell(original);
rig.loadTab("./settings/tabs/devices.js");
rig.responses["/api/drivers/catalog"] = { entries: [{ id: "goodwe", path: "drivers/goodwe.lua", version: "1.0.2" }] };
const unit = Object.assign(stubElement(), { dataset: { path: "drivers.0.capabilities.modbus.unit_id" },
type: "number", defaultValue: "1", value: "1" });
rig.addField(unit);
let select;
const slot = {
getAttribute: () => "0",
set innerHTML(html) {
const options = [...html.matchAll(/<option value="([^"]*)"([^>]*)>/g)]
.map(([, value, attrs]) => ({ value, defaultSelected: /\bselected\b/.test(attrs) }));
select = Object.assign(stubElement(), { type: "select-one", options,
dataset: { path: html.match(/data-path="([^"]*)"/)[1] },
value: (options.find(option => option.defaultSelected) || options[0]).value });
rig.addField(select);
},
querySelector: () => select,
};
const body = rig.elements["settings-body"];
const fields = body.querySelectorAll;
body.querySelectorAll = selector => selector === ".drv-profile-slot" ? [slot] : fields(selector);
body.querySelector = selector => selector === '[data-path="drivers.0.capabilities.modbus.unit_id"]' ? unit : null;
rig.tabs.devices.after({ ...rig.context, escHtml: String, help: () => "" });
assert.equal(select, undefined, "the catalog has not arrived during render");
await settled();
assert.equal("defaultValue" in select, false);
return { ...rig, select, unit };
}

describe("late driver profile fields", () => {
for (const switchTab of [false, true]) {
it("keeps an untouched profile absent, switch tab=" + switchTab, async () => {
const rig = await profileShell();
if (switchTab) rig.context.navigateTab("control");
await rig.context.saveConfig();
assert.deepEqual(rig.saved(), rig.original);
});
}

it("saves an explicit profile and its programmatic Unit ID, including a later return", async () => {
const rig = await profileShell();
for (const [profile, unitID] of [["gw8kn-et-hk3000", 247], ["community-v1", 1]]) {
rig.select.value = profile;
rig.select.handlers.change();
assert.equal(rig.unit.value, String(unitID));
await rig.context.saveConfig();
assert.deepEqual(rig.saved().drivers[0], { ...rig.original.drivers[0],
capabilities: { modbus: { unit_id: unitID } }, config: { profile } });
}
});
});

describe("the settings shell after a save", () => {
it("calls the tab back so it can ask the box again", async () => {
const { elements, tabs } = loadShell({ restart_required: false, restart_reasons: [] });
Expand Down
21 changes: 20 additions & 1 deletion web/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
//
// ctx is built fresh on each render and exposes the shell's helpers
// (field, selectField, help, escHtml, getByPath, setByPath,
// captureCurrentTab, renderTab, bodyEl, config).
// captureCurrentTab, rememberFieldValue, renderTab, bodyEl, config).
(function () {
"use strict";

Expand All @@ -44,6 +44,7 @@
var currentConfig = null;
var configETag = null;
var currentTab = "control";
var fieldValues = new WeakMap();

openBtn.addEventListener("click", function () {
apiFetch("/api/config")
Expand Down Expand Up @@ -220,9 +221,21 @@
statusEl.className = "settings-status" + (kind ? " " + kind : "");
}

// Async tabs register a field when they insert it, before the user can edit it.
function rememberFieldValue(input) {
fieldValues.set(input, input.dataset.checkboxPath ? input.checked : input.value);
}

function fieldValue(input, defaultValue) {
// Late inputs can also use their DOM default; selects have no defaultValue.
return fieldValues.has(input) ? fieldValues.get(input) : defaultValue;
}

function captureCurrentTab() {
var inputs = bodyEl.querySelectorAll("[data-path]");
inputs.forEach(function (input) {
// A displayed default must not become a saved setting on an unchanged form.
if (fieldValue(input, input.defaultValue) === input.value) return;
var path = input.dataset.path;
var val = input.type === "number" ? parseFloat(input.value) : input.value;
if (input.type === "number" && isNaN(val)) val = 0;
Expand All @@ -232,9 +245,12 @@
// Preserve a stored password when the user hasn't typed over it.
if (input.type === "password" && val === "" && getByPath(currentConfig, path, "")) return;
setByPath(currentConfig, path, val);
fieldValues.set(input, input.value);
});
bodyEl.querySelectorAll("[data-checkbox-path]").forEach(function (input) {
if (fieldValue(input, input.defaultChecked) === input.checked) return;
setByPath(currentConfig, input.dataset.checkboxPath, input.checked);
fieldValues.set(input, input.checked);
});
}

Expand Down Expand Up @@ -301,6 +317,7 @@
getByPath: getByPath,
setByPath: setByPath,
captureCurrentTab: captureCurrentTab,
rememberFieldValue: rememberFieldValue,
renderTab: renderTab,
navigateTab: navigateTab,
saveConfig: saveSettings,
Expand All @@ -314,9 +331,11 @@
console.error("tab render:", tab, e);
}
bodyEl.innerHTML = html;
bodyEl.querySelectorAll("[data-path]").forEach(rememberFieldValue);

// Generic handler for data-checkbox-path — shared across every tab.
bodyEl.querySelectorAll("[data-checkbox-path]").forEach(function (cb) {
rememberFieldValue(cb);
cb.addEventListener("change", function () {
setByPath(currentConfig, cb.dataset.checkboxPath, cb.checked);
});
Expand Down
1 change: 1 addition & 0 deletions web/settings/tabs/devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -1537,6 +1537,7 @@
fs += '</select></fieldset>';
slot.innerHTML = fs;
var select = slot.querySelector(".drv-profile-select");
if (select) ctx.rememberFieldValue(select);
if (select) select.addEventListener("change", function () {
var selected = profileByValue(profiles, select.value);
var unitInput = bodyEl.querySelector('[data-path="drivers.' + dIdx + '.capabilities.modbus.unit_id"]');
Expand Down
9 changes: 4 additions & 5 deletions web/settings/tabs/planner.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,7 @@
S.tabs.planner = {
render: function (ctx) {
var field = ctx.field, selectField = ctx.selectField, help = ctx.help, config = ctx.config;
if (!config.planner) config.planner = {};
var planner = config.planner;
var planner = config.planner || {};
if (planner.soc_min == null && planner.soc_min_pct != null) {
planner.soc_min = planner.soc_min_pct / 100;
}
Expand All @@ -86,14 +85,14 @@
'<div id="planner-hedge-line" style="display:none;color:var(--text-dim);font-size:0.8rem;margin-top:4px"></div>';
}
return '<fieldset><legend>MPC Planner</legend>' +
'<label><input type="checkbox" data-checkbox-path="planner.enabled"' + (config.planner.enabled ? ' checked' : '') + '> Enabled ' +
'<label><input type="checkbox" data-checkbox-path="planner.enabled"' + (planner.enabled ? ' checked' : '') + '> Enabled ' +
help('Enable the MPC planner. When active it overrides manual mode with an optimised schedule.') + '</label>' +
'<div class="field-row"><div>' +
field("House reserve (min SoC, 0–1)", "planner.soc_min", "number", 0.10,
"Lowest SoC the planner will discharge to, so the house keeps a reserve. 0.10 = 10%.") +
'</div><div>' +
field("Max SoC (0–1)", "planner.soc_max", "number", 0.90,
"Highest SoC the planner will charge to. 0.90 = 90%.") +
field("Max SoC (0–1)", "planner.soc_max", "number", 0.95,
"Highest SoC the planner will charge to. The default is 0.95 = 95%.") +
'</div></div>' +
'</fieldset>' +
'<details class="engine-details">' +
Expand Down
11 changes: 11 additions & 0 deletions web/settings/tabs/planner.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,17 @@ describe("render", () => {
assert.ok(!html.includes("planner.soc_max_pct"));
});

it("shows Core's default bounds without adding a planner config", () => {
const ctx = stubCtx();
ctx.config = {};
const defaults = {};
ctx.field = (label, path, type, value) => { defaults[path] = value; return ""; };
tab.render(ctx);
assert.equal(defaults["planner.soc_min"], 0.1);
assert.equal(defaults["planner.soc_max"], 0.95);
assert.deepEqual(ctx.config, {});
});

it("promotes legacy soc_min_pct / soc_max_pct into 0–1 fields", () => {
const ctx = stubCtx();
ctx.config.planner = { soc_min_pct: 10, soc_max_pct: 90 };
Expand Down