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/ui-reported-issues.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Settings no longer shows a second Easee password, and a newly added device is scrolled into view. Charging without a schedule no longer says the ready time has passed. A site with no devices does not show a legacy plan. The update dialog shows measured bytes, including when the total is unknown, and says when that measurement has stopped.
3 changes: 3 additions & 0 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2883,6 +2883,9 @@
text = "Waiting for tomorrow's electricity prices — until they arrive (~13:00) the car charges from PV surplus only.";
} else if (lp.commanded_known && !lp.commanded_w && lp.commanded_reason === "pv_surplus_pause") {
text = "Paused: waiting for PV surplus — solar is below the charger's minimum step right now." + kwPlanned;
} else if (lp.commanded_known && !lp.commanded_w && lp.commanded_reason === "no_plan_budget" && !hasSchedule) {
text = "No schedule set. Create a schedule or charge manually.";
tone = "var(--text)";
Comment on lines +2886 to +2888

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize one-shot targets before declaring no schedule

A supported POST /api/loadpoints/{id}/target can set target_soc and target_time without creating lp.schedule. For such an externally supplied one-shot goal, hasSchedule is false, so this new branch preempts the deadline-aware branch and incorrectly says no schedule exists even while Core is planning toward that target. Treat a valid target/deadline as an active goal here, reserving this copy for loadpoints with neither a schedule nor a one-shot target.

AGENTS.md reference: AGENTS.md:L28-L30

Useful? React with 👍 / 👎.

} else if (lp.commanded_known && !lp.commanded_w && lp.commanded_reason === "no_plan_budget") {
var deadlineMs = lp.target_time ? Date.parse(lp.target_time) : NaN;
if (isFinite(deadlineMs) && deadlineMs <= Date.now()) {
Expand Down
2 changes: 2 additions & 0 deletions web/ev-commanded-reason.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ test('every pause reason has its own sentence', () => {
assert.match(source, /Paused for safety: site-meter data is stale/);
assert.match(source, /commanded_reason === "pv_surplus_pause"/);
assert.match(source, /Paused: waiting for PV surplus/);
assert.match(source, /commanded_reason === "no_plan_budget" && !hasSchedule/);
assert.match(source, /No schedule set\. Create a schedule or charge manually\./);
assert.match(source, /commanded_reason === "no_plan_budget"/);
assert.match(source, /The ready time has passed, so FTW is not charging/);
assert.match(source, /Battery level is assumed, not read from the car/);
Expand Down
11 changes: 11 additions & 0 deletions web/plan-empty.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";

const source = readFileSync(new URL("./plan.js", import.meta.url), "utf8");

test("a site with no devices does not show a legacy plan", () => {
assert.match(source, /function configuredDeviceCount\(status\)/);
assert.match(source, /if \(configuredDeviceCount\(state\.status\) === 0\)/);
assert.match(source, /No devices yet — add a device in Settings, and the plan starts once FTW can see your site\./);
});
16 changes: 16 additions & 0 deletions web/plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,14 @@ import {
: 'Mathematical optimizer unavailable. This plan uses the built-in Go fallback.' + reason;
}

function configuredDeviceCount(status) {
const drivers = status && status.drivers;
if (!drivers) return 0;
if (Array.isArray(drivers)) return drivers.length;
if (typeof drivers === 'object') return Object.keys(drivers).length;
return 0;
Comment on lines +324 to +329

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count non-Lua devices before showing the empty state

On an OCPP-only installation, OCPP telemetry is stored without a DriverHealth entry, while /api/status.drivers is assembled from AllHealth() plus configured Lua drivers. Consequently this helper returns zero even though a charger and loadpoint exist, causing the plan to be replaced with “No devices yet” and hiding the valid charging plan. Include configured loadpoints/OCPP chargers, or derive this state from configuration rather than only status.drivers.

AGENTS.md reference: AGENTS.md:L24-L25

Useful? React with 👍 / 👎.

}

function render() {
const canvas = document.getElementById('plan-chart');
if (!canvas) return;
Expand All @@ -345,6 +353,14 @@ import {
const { tMin, tMax } = horizonBounds(state.horizon);
const xScale = t => pad.l + (t - tMin) / (tMax - tMin) * plotW;
const plan = state.plan;
if (configuredDeviceCount(state.status) === 0) {
ctx.fillStyle = C.dim;
ctx.font = '14px sans-serif';
ctx.fillText('No devices yet. Add one in Settings.', pad.l, pad.t + 28);
const summary = document.getElementById('plan-summary');
if (summary) summary.textContent = 'No devices yet — add a device in Settings, and the plan starts once FTW can see your site.';
return;
Comment on lines +356 to +362

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear all plan views before returning from the empty state

When a hot config reload removes the last driver, this branch updates only the canvas and summary before returning. It skips renderPlanBrief, renderCarPlans, fallback-alert cleanup, and the later priceBarBounds reset, so the Overview card, plan badge/action, EV timeline, and hover tooltip can continue showing the previous plan while the header says there are no devices. Reset or explicitly render those dependent views in this branch before returning.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

}

// Layout: price bars (top) | mode band (thin strip) | power bars (middle) | SoC (bottom)
const modeBandH = 10;
Expand Down
18 changes: 18 additions & 0 deletions web/settings/devices-add.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";

const source = readFileSync(new URL("./tabs/devices.js", import.meta.url), "utf8");

test("a cloud password is not rendered again in Secrets", () => {
assert.match(source, /querySelector\('\[data-path="drivers\.' \+ dIdx \+ '\.config\.password"\]'\)/);
assert.match(source, /secrets = secrets\.filter\(function \(k\) \{ return k !== 'password'; \}\)/);
});

test("every add path scrolls the new device into view and focuses a connection field", () => {
assert.match(source, /data-device-idx="' \+ idx \+ '"/);
assert.match(source, /function revealAddedDevice\(idx\)/);
assert.match(source, /card\.scrollIntoView\(\{ block: "center" \}\)/);
const calls = source.match(/revealAddedDevice\(/g) || [];
assert.ok(calls.length >= 4, "catalog, mqtt and modbus adds must reveal the new card");
});
32 changes: 26 additions & 6 deletions web/settings/tabs/devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -1005,7 +1005,7 @@
var entryForDriver = catalogEntryForLua(d.lua);
var supportsBattery = catalogHasCapability(d.lua, "battery") &&
!(entryForDriver && entryForDriver.read_only);
html += '<div class="device-item">' +
html += '<div class="device-item" data-device-idx="' + idx + '">' +
'<div class="device-item-header">' +
'<strong>' + escHtml(d.name) + '</strong>' +
'<span class="device-meta">lua · ' + protocol + ' · ' + escHtml(driverFile) + '</span>' +
Expand Down Expand Up @@ -1644,6 +1644,12 @@
return k !== 'client_secret' && k !== 'refresh_token';
});
}
// Cloud credentials already render config.password. A second
// Secrets field bound to the same path (Easee, Zaptec) saves
// whichever input is read last and shows the wrong hint.
if (bodyEl.querySelector('[data-path="drivers.' + dIdx + '.config.password"]')) {
secrets = secrets.filter(function (k) { return k !== 'password'; });
}
if (secrets.length === 0) return;
var fs = '<fieldset><legend>Secrets</legend>';
secrets.forEach(function (key) {
Expand Down Expand Up @@ -1863,11 +1869,7 @@
config.drivers.push(driver);
if (S.chargerSetup) S.chargerSetupPending = driver.name;
ctx.renderTab("devices");
if (S.chargerSetup) {
var connection = bodyEl.querySelector('[data-path="drivers.' + (config.drivers.length - 1) + '.config.email"]') ||
bodyEl.querySelector('[data-path="drivers.' + (config.drivers.length - 1) + '.config.host"]');
if (connection) { connection.scrollIntoView({ block: 'center' }); connection.focus(); }
}
revealAddedDevice(config.drivers.length - 1);
};
if (chosen.dataset.channel !== "beta") {
finishAdd();
Expand Down Expand Up @@ -2182,6 +2184,22 @@
// Add/remove-device buttons.
var addMqtt = document.getElementById("add-mqtt");
var addModbus = document.getElementById("add-modbus");
function revealAddedDevice(idx) {
var card = bodyEl.querySelector('.device-item[data-device-idx="' + idx + '"]');
if (!card) return;
card.style.outline = "2px solid var(--accent, #888)";
card.style.scrollMargin = "1rem";
if (card.scrollIntoView) card.scrollIntoView({ block: "center" });
var field = card.querySelector(
'[data-path="drivers.' + idx + '.config.email"],' +
'[data-path="drivers.' + idx + '.config.host"],' +
'[data-path="drivers.' + idx + '.config.ip"],' +
'[data-path="drivers.' + idx + '.capabilities.mqtt.host"],' +
'[data-path="drivers.' + idx + '.capabilities.modbus.host"]'
);
if (field && field.focus) field.focus();
window.setTimeout(function () { card.style.outline = ""; }, 4000);
}
if (addMqtt) addMqtt.addEventListener("click", function () {
ctx.captureCurrentTab();
config.drivers.push({
Expand All @@ -2192,6 +2210,7 @@
mqtt: { host: "", port: 1883, username: "", password: "" },
});
ctx.renderTab("devices");
revealAddedDevice(config.drivers.length - 1);
});
if (addModbus) addModbus.addEventListener("click", function () {
ctx.captureCurrentTab();
Expand All @@ -2203,6 +2222,7 @@
modbus: { host: "", port: 502, unit_id: 1 },
});
ctx.renderTab("devices");
revealAddedDevice(config.drivers.length - 1);
});
bodyEl.querySelectorAll("[data-remove-idx]").forEach(function (rmBtn) {
rmBtn.addEventListener("click", function () {
Expand Down
19 changes: 16 additions & 3 deletions web/update-badge.js
Original file line number Diff line number Diff line change
Expand Up @@ -1178,16 +1178,29 @@
const progress = operationProgress(st, action);
const phaseStarted = st.phase_started_at ? Date.parse(st.phase_started_at) : 0;
const phaseElapsed = Math.max(0, Math.round((Date.now() - (phaseStarted > 0 ? phaseStarted : this._updateStartedAt)) / 1000));
const byteProgress = st.progress_unit === "bytes" && st.progress_total > 0
? `<p class="dim">${escapeHTML(formatBytes(st.progress_current || 0))} / ${escapeHTML(formatBytes(st.progress_total))}</p>`
const bytesNow = Number(st.progress_current) || 0;
const bytesTotal = Number(st.progress_total) || 0;
if (st.progress_unit === "bytes" && bytesNow !== this._measuredBytes) {
this._measuredBytes = bytesNow;
this._measuredAt = Date.now();
}
const byteProgress = st.progress_unit === "bytes" && (bytesNow > 0 || bytesTotal > 0)
? (bytesTotal > 0
? `<p class="dim">${escapeHTML(formatBytes(bytesNow))} / ${escapeHTML(formatBytes(bytesTotal))}</p>`
: `<p class="dim">${escapeHTML(formatBytes(bytesNow))} written, total unknown</p>`)
: "";
const quietFor = this._measuredAt ? Date.now() - this._measuredAt : 0;
const stalled = st.progress_unit === "bytes" && bytesNow > 0 && quietFor > 20000
? `<p class="dim">No new measured progress for ${escapeHTML(formatElapsed(Math.round(quietFor / 1000)))}. The clock above is only how long this step has been open.</p>`
: "";
const progressHTML = failed ? "" : `
<div class="update-progress" role="progressbar" aria-valuemin="0" aria-valuemax="${progress.total}" aria-valuenow="${progress.step}">
<span style="width:${progress.percent}%"></span>
</div>
<p class="update-step">Step ${progress.step} of ${progress.total} · ${escapeHTML(label)}</p>
<p class="dim">This step: ${escapeHTML(formatElapsed(phaseElapsed))}</p>
${byteProgress}`;
${byteProgress}
${stalled}`;

const body = failed
? `<p class="err">${escapeHTML(st.message || "Update failed")}</p>
Expand Down
3 changes: 3 additions & 0 deletions web/update-progress.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ test("update UI resumes work and shows each server phase", () => {
assert.match(badge, /progress_total/);
assert.match(badge, /case "checking":\s+return "Checking service health"/);
assert.match(badge, /This step:/);
assert.match(badge, /written, total unknown/);
assert.match(badge, /No new measured progress for/);
assert.doesNotMatch(badge, /Large history databases can take several minutes/);
assert.match(badge, /Total:/);
assert.match(badge, /Saving rollback point \(settings and config; history stays in place\)/);
assert.doesNotMatch(badge, /full history backup/);
Expand Down
Loading