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/stale-meter-live-stats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

The live stats strip and today's self-powered share stay honest when a meter or inverter is offline: no fake 0 W, and a day that already happened still shows its percentage.
126 changes: 78 additions & 48 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -497,11 +497,74 @@
// The component registry loads as a deferred module while this classic
// dashboard script starts immediately at the end of the document. A fast
// first /api/status response can therefore arrive before <ftw-energy-flow>
// has upgraded. Cache the derived payload and replay it when the element is
// ready so switching to Flow never reveals placeholders for one poll cycle.
// has upgraded, and before energy-flow-readings.js has assigned the
// mapper. Cache the raw payload (not only the derived readings) and
// replay when either the mapper or the element is ready, so the hero
// does not sit in its loading skeleton until the next two-second poll.
var lastFlowStatus = null;
var lastFlowReadings = null;
var flowUpgradeReplayQueued = false;

window.ftwOnFlowMapperReady = function () {
if (lastFlowStatus) paintEnergyFlow(lastFlowStatus);
};

function energyFlowOpts(data) {
var byDriver = {};
(data.dispatch || []).forEach(function (d) {
if (d && d.driver) byDriver[d.driver] = Number(d.target_w) || 0;
});
return {
idleW: (typeof window.FTW_FLOW_IDLE_W === "number" && window.FTW_FLOW_IDLE_W) || 42,
batterySub: function (name, d) {
if (d.observe_only) return "observe only";
return batteryTargetLine(byDriver[name]);
},
};
}

function queueFlowReplay() {
if (flowUpgradeReplayQueued) return;
flowUpgradeReplayQueued = true;
if (window.customElements && typeof window.customElements.whenDefined === "function") {
window.customElements.whenDefined("ftw-energy-flow").then(function () {
if (lastFlowStatus) paintEnergyFlow(lastFlowStatus);
});
}
}

function paintEnergyFlow(data) {
lastFlowStatus = data;
var flowEl = document.getElementById("energy-flow");
if (!flowEl) return;
if (typeof window.ftwFlowReadingsFromStatus !== "function") {
queueFlowReplay();
return;
}
lastFlowReadings = window.ftwFlowReadingsFromStatus(data, energyFlowOpts(data));
(lastFlowReadings.planets || []).forEach(function (p) {
if (p.role !== "ev" || p.placeholder || !p.name) return;
var lpEv = loadpointsByDriver && loadpointsByDriver[p.name];
if (!lpEv) return;
if (lpEv.vehicle_soc > 0) {
p.soc = lpEv.vehicle_soc * 100;
p.socSource = "vehicle";
} else if (lpEv.current_soc > 0) {
p.soc = lpEv.current_soc * 100;
p.socSource = lpEv.soc_source || "inferred";
}
if (lpEv.vehicle_charge_limit > 0) {
p.chargeLimit = lpEv.vehicle_charge_limit * 100;
}
p.socStale = !!lpEv.vehicle_stale;
});
if (typeof flowEl.setReadings === "function") {
flowEl.setReadings(lastFlowReadings);
} else {
queueFlowReplay();
}
}

// ---- Render ----
function render(data) {
var batteryTargetsByDriver = {};
Expand Down Expand Up @@ -695,57 +758,24 @@
// the Plan card's information density. Each cell colours by sign
// / direction so an operator scans the row and immediately sees
// who's importing, exporting, charging, or idle.
// Same honesty as the Values tiles: site-level pv_w / bat_w / bat_soc
// become 0 when every reporter of that role is offline. 0 W here would
// undo the "—" those tiles just drew.
var pvStat = pvConfigured && !pvLive ? null : -(data.pv_w || 0);
var batStat = batConfigured && !batLive ? null : data.bat_w;
var socStat = batConfigured && !batLive ? null : data.bat_soc;
updateLiveStat("grid", data.grid_w, signClass("grid", data.grid_w));
updateLiveStat("pv", -data.pv_w, "is-export"); // PV is site-signed negative; show as positive generation
updateLiveStat("pv", pvStat, pvStat == null ? "is-neutral" : "is-export");
updateLiveStat("load", data.load_w, "is-neutral");
updateLiveStat("bat", data.bat_w, signClass("bat", data.bat_w));
updateLiveSocStat(data.bat_soc);
updateLiveStat("bat", batStat, batStat == null ? "is-neutral" : signClass("bat", batStat));
updateLiveSocStat(socStat);

// Hero energy-flow diagram. Mapping lives in energy-flow-readings.js
// so a stale meter cannot be drawn as 0 W "balanced" and a quiet
// hybrid inverter cannot vanish from the X. EV SoC is overlayed here
// because it comes from the loadpoint table, not /api/status.
var flowEl = document.getElementById("energy-flow");
if (flowEl && typeof window.ftwFlowReadingsFromStatus === "function") {
lastFlowReadings = window.ftwFlowReadingsFromStatus(data, {
idleW: (typeof window.FTW_FLOW_IDLE_W === "number" && window.FTW_FLOW_IDLE_W) || 42,
batterySub: function (name, d) {
if (d.observe_only) return "observe only";
return batteryTargetLine(batteryTargetsByDriver[name]);
},
});
(lastFlowReadings.planets || []).forEach(function (p) {
if (p.role !== "ev" || p.placeholder || !p.name) return;
var lpEv = loadpointsByDriver && loadpointsByDriver[p.name];
if (!lpEv) return;
if (lpEv.vehicle_soc > 0) {
p.soc = lpEv.vehicle_soc * 100;
p.socSource = "vehicle";
} else if (lpEv.current_soc > 0) {
p.soc = lpEv.current_soc * 100;
p.socSource = lpEv.soc_source || "inferred";
}
if (lpEv.vehicle_charge_limit > 0) {
p.chargeLimit = lpEv.vehicle_charge_limit * 100;
}
p.socStale = !!lpEv.vehicle_stale;
});
if (typeof flowEl.setReadings === "function") {
flowEl.setReadings(lastFlowReadings);
} else if (!flowUpgradeReplayQueued &&
window.customElements &&
typeof window.customElements.whenDefined === "function") {
flowUpgradeReplayQueued = true;
window.customElements.whenDefined("ftw-energy-flow").then(function () {
var readyFlow = document.getElementById("energy-flow");
if (readyFlow &&
typeof readyFlow.setReadings === "function" &&
lastFlowReadings) {
readyFlow.setReadings(lastFlowReadings);
}
});
}
}
// hybrid inverter cannot vanish from the X. EV SoC is overlayed in
// paintEnergyFlow because it comes from the loadpoint table, not
// /api/status.
paintEnergyFlow(data);

// Mode buttons — primary (strategy) + advanced (manual)
currentMode = data.mode;
Expand Down
9 changes: 8 additions & 1 deletion web/components/energy-flow-readings.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,11 @@ export function flowReadingsFromStatus(status, opts) {
}

const loadW = num(status && status.load_w);
// Today's share is kWh already in the box, not the live meter. A
// quiet meter blanks % SELF-POWERED NOW (unknown instantaneous load)
// but must not hide a day that already happened.
let selfPoweredPctToday = null;
if (loadKwhTotal > 0.001 && loadW !== null) {
if (loadKwhTotal > 0.001) {
selfPoweredPctToday = Math.max(0, Math.min(100, (1 - importKwh / loadKwhTotal) * 100));
}

Expand All @@ -225,4 +228,8 @@ export function flowReadingsFromStatus(status, opts) {
if (typeof window !== "undefined") {
window.ftwFlowReadingsFromStatus = flowReadingsFromStatus;
window.ftwDriverOnline = driverOnline;
// The dashboard script starts before this module. If a status payload
// arrived in that window, tell it the mapper exists so the hero can
// paint without waiting for the next poll.
if (typeof window.ftwOnFlowMapperReady === "function") window.ftwOnFlowMapperReady();
}
15 changes: 13 additions & 2 deletions web/dashboard-simplification.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,19 +82,30 @@ describe("simplified dashboard overview", () => {
assert.match(flow, /:host\(\[embedded\]\) \.title/);
});

it("replays the first live payload when the Flow component finishes upgrading", () => {
it("replays the first live payload when the Flow mapper or component becomes ready", () => {
assert.match(app, /lastFlowStatus/);
assert.match(app, /lastFlowReadings/);
assert.match(app, /ftwOnFlowMapperReady/);
assert.match(
app,
/customElements\.whenDefined\("ftw-energy-flow"\)[\s\S]*?setReadings\(lastFlowReadings\)/,
/customElements\.whenDefined\("ftw-energy-flow"\)[\s\S]*?paintEnergyFlow\(lastFlowStatus\)/,
);
assert.match(app, /setReadings\(lastFlowReadings\)/);
});

it("builds the hero from the shared status mapper, not inline 0 W defaults", () => {
assert.match(app, /ftwFlowReadingsFromStatus/);
assert.doesNotMatch(app, /var gkw = \(data\.grid_w \|\| 0\) \/ 1000/);
});

it("does not feed the live stats strip 0 W when configured solar or battery is offline", () => {
assert.match(app, /updateLiveStat\("pv", pvStat/);
assert.match(app, /pvConfigured && !pvLive \? null/);
assert.match(app, /updateLiveStat\("bat", batStat/);
assert.match(app, /batConfigured && !batLive \? null/);
assert.match(app, /updateLiveSocStat\(socStat\)/);
});

it("keeps each live telemetry rendering target singular", () => {
for (const id of [
"grid-w",
Expand Down
13 changes: 13 additions & 0 deletions web/energy-flow-readings.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,19 @@ describe("flowReadingsFromStatus", () => {
assert.ok(Math.abs(r.selfPoweredPctToday - (1 - 5.2 / 14) * 100) < 1e-6);
});

it("keeps today's self-powered share when the meter is currently quiet", () => {
const r = flowReadingsFromStatus({
grid_w: null,
load_w: null,
energy: LIVE.energy,
drivers: {
ferroamp: { status: "offline", pv_w: -3400, bat_w: 900, bat_soc: 0.62 },
},
});
assert.equal(r.load, null);
assert.ok(Math.abs(r.selfPoweredPctToday - (1 - 5.2 / 14) * 100) < 1e-6);
});

it("keeps battery sign so two discharging packs do not look like charging", () => {
const r = flowReadingsFromStatus({
grid_w: 0,
Expand Down