diff --git a/.changeset/stale-meter-live-stats.md b/.changeset/stale-meter-live-stats.md new file mode 100644 index 00000000..0312db03 --- /dev/null +++ b/.changeset/stale-meter-live-stats.md @@ -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. diff --git a/web/app.js b/web/app.js index d02c1e85..fefd366f 100644 --- a/web/app.js +++ b/web/app.js @@ -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 - // 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 = {}; @@ -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; diff --git a/web/components/energy-flow-readings.js b/web/components/energy-flow-readings.js index 509c5cb7..2e176cae 100644 --- a/web/components/energy-flow-readings.js +++ b/web/components/energy-flow-readings.js @@ -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)); } @@ -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(); } diff --git a/web/dashboard-simplification.test.mjs b/web/dashboard-simplification.test.mjs index 69eae132..1b7c5350 100644 --- a/web/dashboard-simplification.test.mjs +++ b/web/dashboard-simplification.test.mjs @@ -82,12 +82,15 @@ 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", () => { @@ -95,6 +98,14 @@ describe("simplified dashboard overview", () => { 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", diff --git a/web/energy-flow-readings.test.mjs b/web/energy-flow-readings.test.mjs index d03e6873..ba9769cc 100644 --- a/web/energy-flow-readings.test.mjs +++ b/web/energy-flow-readings.test.mjs @@ -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,