diff --git a/README.md b/README.md index 7abc4dd..c36d545 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ To avoid that snapshot problem entirely, there's also a dashboard *strategy* tha ```yaml strategy: type: custom:givenergy - mode: classic # classic (default) | flow — see below + mode: classic # classic (default) | flow | glance | analyst | all — see below max_power_kw: 10 # optional; default 10; Overview 24h chart y-axis envelope (kW) serial: SA2114G047 # optional; pin one inverter on a multi-plant install ``` @@ -255,7 +255,41 @@ strategy: The Flow view is rendered as a `panel: true` view. If you have the **kiosk-mode** custom integration installed (HACS), the strategy adds hints to hide the header and sidebar for a true full-screen display; without it, the view simply renders inside the normal HA chrome. The card is responsive (container-query based), so it works as a wall-tablet kiosk and reflows for a phone webview. -The remaining directions from [the redesign brief](docs/design/dashboard-redesign-brief.md) — `glance`, `analyst`, and the tariff-aware `coach` — are still to come. +The tariff-aware `coach` direction from [the redesign brief](docs/design/dashboard-redesign-brief.md) is still to come. + +#### `mode: glance` + +`mode: glance` leads the dashboard with a calm, full-width **Glance** view: a single-sentence system summary, three large numbers (solar generated today, battery SOC, house consumption today), and a row of health pills showing battery count, import and export totals for the day, and per-string PV generation when active. It's built around a bundled `custom:givenergy-glance` card — nothing extra to install. + +```yaml +strategy: + type: custom:givenergy + mode: glance +``` + +The status sentence is derived from the live signs of grid, battery, and solar power — covering states like self-sufficient, exporting, solar-and-grid importing, battery-only overnight, and so on. The dot to its left pulses green when the system is self-sufficient or exporting, amber when importing from the grid or when battery SOC drops below 20%. The full classic view set follows the Glance panel, so the detailed tabs are still one tap away. Like `flow`, the Glance view is `panel: true` and picks up kiosk-mode hints when the integration is present. + +#### `mode: analyst` + +`mode: analyst` leads the dashboard with a dense **Analyst** view aimed at optimisation and debugging: a live metrics strip (PV, load, battery, grid), an energy ledger breaking down today's sources and sinks as kWh and percentages, a diagnostics table (temperatures, grid frequency, power factor, work time, consecutive failures), a 24-hour power overlay chart (requires `apexcharts-card`), and per-pack cell heatmaps. Nothing extra to install beyond the apexcharts card for the chart. + +```yaml +strategy: + type: custom:givenergy + mode: analyst +``` + +The Analyst view is a standard (non-panel) multi-card view, so the full classic tab set still follows it. + +#### `mode: all` + +`mode: all` stacks all four views — Glance, Flow, Analyst, and the classic tab set — into a single dashboard. Useful if you want to switch between display styles without maintaining separate dashboards. + +```yaml +strategy: + type: custom:givenergy + mode: all +``` ### Voice assistants & LLM access diff --git a/custom_components/givenergy_local/__init__.py b/custom_components/givenergy_local/__init__.py index a1583e7..67e1944 100644 --- a/custom_components/givenergy_local/__init__.py +++ b/custom_components/givenergy_local/__init__.py @@ -77,7 +77,7 @@ # served from the same package dir so they resolve offline without a CDN. _FONTS_DIRNAME = "fonts" _FONTS_URL = f"/{DOMAIN}/{_FONTS_DIRNAME}" -_STRATEGY_VERSION = "5" +_STRATEGY_VERSION = "8" # Per-config-entry topology cache. PlantCapabilities is persisted as # `to_dict()` directly (no envelope) following HA Core's Store convention — diff --git a/custom_components/givenergy_local/www/ge-strategy.js b/custom_components/givenergy_local/www/ge-strategy.js index 7c8bc21..5a87ab8 100644 --- a/custom_components/givenergy_local/www/ge-strategy.js +++ b/custom_components/givenergy_local/www/ge-strategy.js @@ -333,6 +333,162 @@ return view; } + function glanceViews(plant, opts) { + var a = makeAccessors(plant); + // Glance is inverter-centric; an EMS plant has no PV/battery/grid data, so + // fall back to the classic (EMS) view set with no glance panel. + if (plant.target && plant.target.isEms) return classicViews(plant, opts); + return [glanceView(plant, a, opts)].concat(classicViews(plant, opts)); + } + + function glanceView(plant, a, opts) { + var cfg = { type: "custom:givenergy-glance" }; + if (a.inv("p_pv")) cfg.solar = a.inv("p_pv"); + var strings = [a.inv("p_pv1"), a.inv("p_pv2")].filter(Boolean); + if (strings.length) cfg.solar_strings = strings; + if (a.inv("grid_power")) cfg.grid = a.inv("grid_power"); + if (a.inv("p_load_demand")) cfg.load = a.inv("p_load_demand"); + if (a.inv("p_battery")) cfg.battery_power = a.inv("p_battery"); + if (a.inv("battery_soc")) cfg.battery_soc = a.inv("battery_soc"); + + var packs = plant.batteries + .map(function (b) { + return { name: String(b.serial).toUpperCase(), soc: a.bat(b, "soc") }; + }) + .filter(function (p) { return p.soc; }); + if (packs.length) cfg.packs = packs; + + // Totals: subset of the flow card's slots (no charge/discharge here). + var totalKeys = { + pv_today: "e_pv_day", + import_today: "e_grid_in_day", + export_today: "e_grid_out_day", + house_today: "e_consumption_today", + }; + var totals = {}; + Object.keys(totalKeys).forEach(function (slot) { + var eid = a.inv(totalKeys[slot]); + if (eid) totals[slot] = eid; + }); + if (Object.keys(totals).length) cfg.totals = totals; + + var view = { + title: "Glance", + path: "glance", + icon: "mdi:eye-outline", + panel: true, + cards: [cfg], + }; + if (haveCard("kiosk-mode")) { + view.kiosk_mode = { hide_header: true, hide_sidebar: true }; + } + return view; + } + + // mode: all -- Glance + Flow + Analyst panels followed by the classic tab set. + // Not intended as a permanent user-facing mode; remove when modes split into + // separate dashboards. + function allViews(plant, opts) { + var a = makeAccessors(plant); + if (plant.target && plant.target.isEms) return classicViews(plant, opts); + return [glanceView(plant, a, opts), flowView(plant, a, opts), analystView(plant, a, opts)].concat(classicViews(plant, opts)); + } + + // mode: analyst -- dense terminal-aesthetic view for diagnostics / debugging / + // optimisation. Non-panel multi-card view: givenergy-analyst card (live metrics + + // energy ledger + diagnostics table), apexcharts 24h power overlay, and one + // ge-cell-heatmap per battery pack. Analyst is inverter-centric; falls back + // to classic for EMS plants. + function analystViews(plant, opts) { + var a = makeAccessors(plant); + if (plant.target && plant.target.isEms) return classicViews(plant, opts); + return [analystView(plant, a, opts)].concat(classicViews(plant, opts)); + } + + function analystView(plant, a, opts) { + var cards = []; + + // Card 1: custom element handles live metrics, energy ledger, diagnostics. + var cfg = { type: "custom:givenergy-analyst" }; + if (a.inv("p_pv")) cfg.solar = a.inv("p_pv"); + var strings = [a.inv("p_pv1"), a.inv("p_pv2")].filter(Boolean); + if (strings.length) cfg.solar_strings = strings; + if (a.inv("grid_power")) cfg.grid = a.inv("grid_power"); + if (a.inv("p_load_demand")) cfg.load = a.inv("p_load_demand"); + if (a.inv("p_battery")) cfg.battery_power = a.inv("p_battery"); + if (a.inv("battery_soc")) cfg.battery_soc = a.inv("battery_soc"); + + var totalKeyMap = { + pv_today: "e_pv_day", + discharge_today: "e_battery_discharge_day", + import_today: "e_grid_in_day", + house_today: "e_consumption_today", + charge_today: "e_battery_charge_day", + export_today: "e_grid_out_day", + }; + var totals = {}; + Object.keys(totalKeyMap).forEach(function (slot) { + var eid = a.inv(totalKeyMap[slot]); + if (eid) totals[slot] = eid; + }); + if (Object.keys(totals).length) cfg.totals = totals; + + var diagKeyMap = { + t_inverter_heatsink: "t_inverter_heatsink", + t_charger: "t_charger", + f_ac1: "f_ac1", + pf_inverter: "pf_inverter_output_now", + work_time_total: "work_time_total", + consecutive_failures: "consecutive_failures", + last_refresh: "last_successful_refresh", + }; + var diag = {}; + Object.keys(diagKeyMap).forEach(function (slot) { + var eid = a.inv(diagKeyMap[slot]); + if (eid) diag[slot] = eid; + }); + if (Object.keys(diag).length) cfg.diag = diag; + + cards.push(cfg); + + // Card 2: 24h power overlay. + var powerSeries = [ + { entity: a.inv("p_pv"), name: "PV", color: "#d4a85a" }, + { entity: a.inv("p_load_demand"), name: "Load", color: "#cfcfca" }, + { entity: a.inv("p_battery"), name: "Battery", color: "#5bbb6a" }, + { entity: a.inv("grid_power"), name: "Grid", color: "#4a9fd4" }, + ].filter(function (s) { return s.entity; }); + + if (haveCard("apexcharts-card") && powerSeries.length) { + var cap = (opts.maxPowerKw || 10) * 1000; + cards.push({ + type: "custom:apexcharts-card", + header: { show: true, title: "Power - last 24 h" }, + graph_span: "24h", + yaxis: [{ min: -cap, max: cap }], + series: powerSeries, + }); + } else if (powerSeries.length) { + cards.push(placeholder("apexcharts-card")); + } + + // Card 3+: ge-cell-heatmap per battery pack. + plant.batteries.forEach(function (b) { + cards.push({ + type: "custom:ge-cell-heatmap", + title: "Cell balance - " + String(b.serial).toUpperCase(), + batteries: [b.serial], + }); + }); + + return { + title: "Analyst", + path: "analyst", + icon: "mdi:chart-line", + cards: cards, + }; + } + function overviewView(plant, a, opts) { var cap = (opts.maxPowerKw || 10) * 1000; var cards = []; @@ -1024,7 +1180,12 @@ }; } // Mode dispatch. Unknown/absent mode falls back to classic. - var views = opts.mode === "flow" ? flowViews(plant, opts) : classicViews(plant, opts); + var views = + opts.mode === "flow" ? flowViews(plant, opts) : + opts.mode === "glance" ? glanceViews(plant, opts) : + opts.mode === "analyst" ? analystViews(plant, opts) : + opts.mode === "all" ? allViews(plant, opts) : + classicViews(plant, opts); return { title: "GivEnergy", views: views }; } @@ -1455,6 +1616,465 @@ }); } + // custom:givenergy-glance - the Glance mode centrepiece. Calm-tech ambient + // panel: a natural-language status sentence, three large numbers (solar today / + // battery SOC / house today), and health pills. Entity_ids arrive pre-resolved + // from the strategy; the card only reads hass.states. + if (!customElements.get("givenergy-glance")) { + customElements.define("givenergy-glance", class extends HTMLElement { + setConfig(cfg) { + this._cfg = cfg || {}; + } + set hass(hass) { + this._hass = hass; + this._render(); + } + getCardSize() { + return 5; + } + + _render() { + var cfg = this._cfg, hass = this._hass; + if (!cfg || !hass || !hass.states) return; + + var num = function (eid) { + var st = eid && hass.states[eid]; + var v = st ? parseFloat(st.state) : NaN; + return isFinite(v) ? v : null; + }; + var esc = function (s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + }; + var fmtKw = function (w) { + if (w == null) return "—"; + var k = w / 1000; + return Math.abs(k) < 10 ? k.toFixed(2) : k.toFixed(1); + }; + var fmtKwh = function (v) { + if (v == null) return "—"; + return Math.abs(v) < 10 ? v.toFixed(1) : Math.round(v).toString(); + }; + + var solar = num(cfg.solar); + var grid = num(cfg.grid); // + = export, - = import + var batt = num(cfg.battery_power); // + = discharge, - = charge + var soc = num(cfg.battery_soc); + var load = num(cfg.load); + var strings = (cfg.solar_strings || []).map(num); + var packs = (cfg.packs || []).map(function (p) { + return { name: p.name, soc: num(p.soc) }; + }); + var totals = cfg.totals || {}; + + // Signature guard: skip DOM rebuild if nothing changed. + var sig = [solar, grid, batt, soc, load].join(",") + + "|" + strings.join(",") + + "|" + packs.map(function (p) { return p.name + ":" + p.soc; }).join(",") + + "|" + Object.keys(totals).map(function (k) { return k + "=" + num(totals[k]); }).join(","); + if (sig === this._sig) return; + this._sig = sig; + + // Flow booleans with hysteresis (Schmitt-trigger style): THRESH_ON to + // enter a state, THRESH_OFF to leave it. Prevents the sentence from + // flipping between adjacent states when readings are near a threshold + // (sensor timing skew, end-of-day grazing, etc.). + var THRESH_ON = 200; // W -- must exceed this to enter a new state + var THRESH_OFF = 80; // W -- must drop below this to leave a state + var prev = this._flowState || {}; + var solarOn = solar != null && (solar > THRESH_ON || (prev.solarOn && solar > THRESH_OFF)); + var exporting = grid != null && (grid > THRESH_ON || (prev.exporting && grid > THRESH_OFF)); + var importing = grid != null && (grid < -THRESH_ON || (prev.importing && grid < -THRESH_OFF)); + var charging = batt != null && (batt < -THRESH_ON || (prev.charging && batt < -THRESH_OFF)); + var discharging = batt != null && (batt > THRESH_ON || (prev.discharging && batt > THRESH_OFF)); + this._flowState = { solarOn: solarOn, exporting: exporting, importing: importing, + charging: charging, discharging: discharging }; + + // Natural-language status sentence (ASCII-only). + // Structure: check net grid direction (exporting/importing/idle) first + // within the solarOn group so the sentence is correct even when solar + // is present but insufficient to cover demand or the source of battery + // charge is ambiguous. + var sentence; + if (solarOn) { + if (exporting) { + if (charging) + sentence = "Solar covering the house and charging the battery, exporting the surplus."; + else if (discharging) + sentence = "Solar and battery exporting to the grid."; + else + sentence = "Solar ahead of demand - exporting to the grid."; + } else if (importing) { + if (charging) + sentence = "Solar and grid supplying the house and charging the battery."; + else if (discharging) + sentence = "Solar, battery, and grid supplying the house."; + else + sentence = "Solar and grid supplying the house."; + } else { + // Grid approximately idle: solar balanced against load and battery. + if (charging) + sentence = "Solar covering the house and charging the battery."; + else if (discharging) + sentence = "Solar and battery covering the house."; + else + sentence = "Solar covering the house."; + } + } else if (discharging && !importing) { + sentence = "Battery powering the house."; + } else if (discharging && importing) { + sentence = "Battery and grid supplying the house."; + } else if (importing) { + sentence = "Drawing from the grid."; + } else { + sentence = "System idle."; + } + + // Status dot: amber when importing or battery low, green otherwise. + var dotColor = (importing || (soc != null && soc < 20)) ? "#d4a85a" : "#5bbb6a"; + + // ---- Big-3 sub-lines ---- + var solarSub; + if (strings.length) { + solarSub = strings.map(function (w, i) { + return "String " + (i + 1) + ": " + (w == null ? "—" : fmtKw(w)) + " kW"; + }).join(" · "); + } else if (solar != null) { + solarSub = fmtKw(solar) + " kW now"; + } else { + solarSub = "—"; + } + + var battSub; + if (packs.length > 1) { + battSub = packs.map(function (p) { + return esc(p.name) + ": " + (p.soc == null ? "—" : Math.round(p.soc) + "%"); + }).join(" · "); + } else if (charging) { + battSub = "Charging · " + fmtKw(-batt) + " kW"; + } else if (discharging) { + battSub = "Discharging · " + fmtKw(batt) + " kW"; + } else { + battSub = "Idle"; + } + + var houseSub; + if (exporting) { + houseSub = "Exporting " + fmtKw(grid) + " kW"; + } else if (importing) { + houseSub = "Importing " + fmtKw(-grid) + " kW"; + } else if (solar != null || batt != null) { + houseSub = "Self-sufficient"; + } else { + houseSub = "—"; + } + + // ---- Health pills ---- + var GREEN = "#5bbb6a", AMBER = "#d4a85a", BLUE = "#4a9fd4"; + var pills = []; + // Battery count. + if (packs.length > 0) { + var battWord = packs.length === 1 ? "battery" : "batteries"; + pills.push({ label: packs.length + " " + battWord + " online", color: GREEN }); + // Per-pack SOC if more than one. + if (packs.length > 1) { + packs.forEach(function (p) { + var pc = (p.soc != null && p.soc < 20) ? AMBER : GREEN; + pills.push({ label: esc(p.name) + ": " + (p.soc == null ? "—" : Math.round(p.soc)) + "%", color: pc }); + }); + } + } else if (soc != null) { + pills.push({ label: "1 battery online", color: GREEN }); + } + // Import / export today. + var importKwh = num(totals.import_today); + if (importKwh != null && importKwh >= 0.05) { + pills.push({ label: fmtKwh(importKwh) + " kWh imported today", color: importKwh > 1 ? AMBER : BLUE }); + } + var exportKwh = num(totals.export_today); + if (exportKwh != null && exportKwh >= 0.05) { + pills.push({ label: fmtKwh(exportKwh) + " kWh exported today", color: GREEN }); + } + // Per-string generation pills (only when solar is active). + if (solarOn && strings.length > 0) { + strings.forEach(function (w, i) { + if (w != null) { + pills.push({ label: "String " + (i + 1) + ": " + fmtKw(w) + " kW", color: BLUE }); + } + }); + } + + var pillsHtml = pills.map(function (p) { + return '' + p.label + ''; + }).join(""); + + // ---- Render ---- + var MONO = "'GE Geist Mono',ui-monospace,monospace"; + var SERIF = "'GE Fraunces',Georgia,serif"; + var SANS = "'Roboto',system-ui,sans-serif"; + + this.innerHTML = + '
' + + "" + + '
' + + '
' + + '
' + sentence + '
' + + '
' + + '
' + + '
SOLAR TODAY
' + + '
' + fmtKwh(num(totals.pv_today)) + 'kWh
' + + '
' + solarSub + '
' + + '
BATTERY
' + + '
' + (soc == null ? "—" : Math.round(soc)) + '%
' + + '
' + battSub + '
' + + '
HOUSE TODAY
' + + '
' + fmtKwh(num(totals.house_today)) + 'kWh
' + + '
' + houseSub + '
' + + '
' + + '
' + pillsHtml + '
' + + '
'; + } + }); + } + + // custom:givenergy-analyst - the Analyst mode centrepiece. Dense terminal- + // aesthetic card: live power metrics strip, energy ledger (sources vs sinks + // with kWh and percentages), and an inverter diagnostics table. + // Entity_ids arrive pre-resolved from the strategy; the card only reads hass.states. + if (!customElements.get("givenergy-analyst")) { + customElements.define("givenergy-analyst", class extends HTMLElement { + setConfig(cfg) { + this._cfg = cfg || {}; + } + set hass(hass) { + this._hass = hass; + this._render(); + } + getCardSize() { + return 8; + } + + _render() { + var cfg = this._cfg, hass = this._hass; + if (!cfg || !hass || !hass.states) return; + + var num = function (eid) { + var st = eid && hass.states[eid]; + var v = st ? parseFloat(st.state) : NaN; + return isFinite(v) ? v : null; + }; + var str = function (eid) { + var st = eid && hass.states[eid]; + return st ? st.state : null; + }; + var esc = function (s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + }; + // fmtW: integer watts for |w| < 1000, else kW to 2dp. + var fmtW = function (w) { + if (w == null) return "—"; + var abs = Math.abs(w); + if (abs < 1000) return Math.round(w) + " W"; + return (w / 1000).toFixed(2) + " kW"; + }; + var fmtKwh = function (v) { + if (v == null) return "—"; + return Math.abs(v) < 10 ? v.toFixed(1) : Math.round(v).toString(); + }; + + var solar = num(cfg.solar); + var grid = num(cfg.grid); // + = export, - = import + var batt = num(cfg.battery_power); // + = discharge, - = charge + var soc = num(cfg.battery_soc); + var load = num(cfg.load); + var totals = cfg.totals || {}; + var diag = cfg.diag || {}; + + // Signature guard: skip full re-render when nothing changed. + var totVals = Object.keys(totals).map(function (k) { return num(totals[k]); }).join(","); + var diagVals = Object.keys(diag).map(function (k) { return str(diag[k]); }).join(","); + var sig = [solar, grid, batt, soc, load].join(",") + "|" + totVals + "|" + diagVals; + if (sig === this._sig) return; + this._sig = sig; + + // ---- live metrics strip ---- + var metricCell = function (label, value, sub, borderColor) { + return '
' + + '
' + label + '
' + + '
' + value + '
' + + '
' + sub + '
' + + '
'; + }; + + // PV cell + var pvVal = fmtW(solar); + var pvSub = ""; + if (cfg.solar_strings && cfg.solar_strings.length) { + pvSub = cfg.solar_strings.map(function (eid, i) { + var v = num(eid); + return "S" + (i + 1) + " " + (v == null ? "---" : Math.round(v) + " W"); + }).join(" / "); + } + + // Battery cell + var battAbs = batt == null ? null : Math.abs(batt); + var battDir = batt == null ? "idle" : + (batt < -10 ? "charging" : (batt > 10 ? "discharging" : "idle")); + var battSub = battDir + (soc != null ? " | " + Math.round(soc) + "%" : ""); + var battColor = battDir === "charging" ? "#4a9fd4" : + battDir === "discharging" ? "#5bbb6a" : + "var(--divider-color)"; + + // Grid cell + var gridAbs = grid == null ? null : Math.abs(grid); + var gridDir = grid == null ? "idle" : + (grid < -10 ? "importing" : (grid > 10 ? "exporting" : "idle")); + var gridColor = gridDir === "exporting" ? "#5bbb6a" : + gridDir === "importing" ? "#e55555" : + "var(--divider-color)"; + + var metricsHtml = + metricCell("PV", fmtW(solar), pvSub, "#d4a85a") + + metricCell("LOAD", fmtW(load), "", "var(--divider-color)") + + metricCell("BATTERY", fmtW(battAbs), battSub, battColor) + + metricCell("GRID", fmtW(gridAbs), gridDir, gridColor); + + // ---- energy ledger ---- + var pvToday = num(totals.pv_today); + var dischargeToday = num(totals.discharge_today); + var importToday = num(totals.import_today); + var houseToday = num(totals.house_today); + var chargeToday = num(totals.charge_today); + var exportToday = num(totals.export_today); + + var sumSources = (pvToday || 0) + (dischargeToday || 0) + (importToday || 0); + var sumSinks = (houseToday || 0) + (chargeToday || 0) + (exportToday || 0); + + var pct = function (v, total) { + if (v == null || !total) return "—"; + return Math.round(v / total * 100) + "%"; + }; + + var ledgerRow = function (label, val, total) { + return '' + label + '' + + '' + fmtKwh(val) + '' + + '' + pct(val, total) + ''; + }; + var ledgerTotalRow = function (label, val, color) { + return '' + + '' + label + '' + + '' + fmtKwh(val || null) + '' + + '' + (val ? "100%" : "—") + ''; + }; + + var ledgerHtml = + '' + + '' + + '' + + ledgerRow("PV generation", pvToday, sumSources) + + ledgerRow("Battery discharge", dischargeToday, sumSources) + + ledgerRow("Grid import", importToday, sumSources) + + ledgerTotalRow("Sources total", sumSources, "#d4a85a") + + '
SourcekWh%
' + + '' + + '' + + '' + + ledgerRow("House consumption", houseToday, sumSinks) + + ledgerRow("Battery charge", chargeToday, sumSinks) + + ledgerRow("Grid export", exportToday, sumSinks) + + ledgerTotalRow("Sinks total", sumSinks, "#5bbb6a") + + '
SinkkWh%
'; + + // ---- diagnostics table ---- + var diagDefs = [ + { slot: "t_inverter_heatsink", label: "Heatsink", unit: " deg C", isNum: true }, + { slot: "t_charger", label: "Charger", unit: " deg C", isNum: true }, + { slot: "f_ac1", label: "Grid freq", unit: " Hz", isNum: true }, + // scale: 0.0001 is a stopgap (/10,000 only; correct formula is /10,000 - 1). + // Tracked at dewet22/givenergy-modbus#209. + { slot: "pf_inverter", label: "Power factor", unit: "", isNum: true, scale: 0.0001 }, + { slot: "work_time_total", label: "Work time", unit: " h", isNum: true }, + { slot: "last_refresh", label: "Last refresh", unit: "", isNum: false }, + { slot: "consecutive_failures", label: "Consec. failures", unit: "", isNum: true }, + ]; + var diagRowsHtml = diagDefs.map(function (d) { + var eid = diag[d.slot]; + if (!eid) return ""; + var raw = d.isNum ? num(eid) : str(eid); + if (raw != null && d.scale) raw = parseFloat((raw * d.scale).toFixed(4)); + var display = raw == null ? "—" : esc(String(raw)) + esc(d.unit); + return '' + d.label + '' + display + ''; + }).join(""); + var diagHtml = diagRowsHtml + ? '' + + '' + + '' + diagRowsHtml + '
DiagnosticValue
' + : ""; + + // ---- compose ---- + this.innerHTML = + '' + + '' + + '
' + + '
' + metricsHtml + '
' + + '
' + + '
' + ledgerHtml + '
' + + (diagHtml ? '
' + diagHtml + '
' : '') + + '
' + + '
' + + '
'; + } + }); + } + // Discoverability in the "Community dashboards" picker (HA 2026.5+). Harmless // where unsupported. try { @@ -1463,7 +2083,7 @@ type: "givenergy", strategyType: "dashboard", name: "GivEnergy", - description: "Registry-driven GivEnergy dashboard (classic / flow modes).", + description: "Registry-driven GivEnergy dashboard (classic / flow / glance / analyst / all modes).", }); } catch (e) { /* non-fatal */ diff --git a/tests/js/ge-strategy.test.js b/tests/js/ge-strategy.test.js index 50df3e2..5cfd4a0 100644 --- a/tests/js/ge-strategy.test.js +++ b/tests/js/ge-strategy.test.js @@ -338,3 +338,157 @@ describe("flow mode", () => { expect(titles(dash)).toEqual(["EMS Controls", "Diagnostics"]); }); }); + +describe("glance mode", () => { + const glanceCard = (dash) => view(dash, "Glance").cards[0]; + + it("leads with a panel Glance view, then the full classic view set", async () => { + const hass = makeHass({ batterySerials: ["BAT1"], acCoupled: true }); + const dash = await GE.generateDashboard({ mode: "glance" }, hass); + expect(titles(dash)).toEqual([ + "Glance", + "Overview", + "Energy", + "Batteries", + "Battery Health", + "Controls", + "Diagnostics", + ]); + const gl = view(dash, "Glance"); + expect(gl.panel).toBe(true); + expect(gl.cards.length).toBe(1); + expect(gl.cards[0].type).toBe("custom:givenergy-glance"); + }); + + it("resolves every glance slot from the registry and survives the loft_ prefix", async () => { + const hass = makeHass({ batterySerials: ["BAT1", "BAT2"], areaPrefix: "loft_" }); + const dash = await GE.generateDashboard({ mode: "glance" }, hass); + const c = glanceCard(dash); + const registry = await regSet(hass); + + const slots = [c.solar, c.grid, c.load, c.battery_power, c.battery_soc] + .concat(c.solar_strings || []) + .concat(Object.values(c.totals || {})) + .concat((c.packs || []).map((p) => p.soc)); + + expect(slots.length).toBeGreaterThan(8); + for (const eid of slots) { + expect(registry.has(eid)).toBe(true); + expect(eid).toContain("loft_"); + } + expect((c.packs || []).map((p) => p.name)).toEqual(["BAT1", "BAT2"]); + }); + + it("omits totals whose entities are missing rather than emitting null", async () => { + const hass = makeHass({ batterySerials: ["BAT1"], omitKeys: ["e_grid_out_day", "e_grid_in_day"] }); + const c = glanceCard(await GE.generateDashboard({ mode: "glance" }, hass)); + expect(c.totals.export_today).toBeUndefined(); + expect(c.totals.import_today).toBeUndefined(); + expect(c.totals.pv_today).toBeTruthy(); + expect(hasNullEntity(c)).toBe(false); + }); + + it("does not emit a Glance panel for an EMS plant", async () => { + const hass = makeHass({ ems: true }); + const dash = await GE.generateDashboard({ mode: "glance" }, hass); + expect(titles(dash)).toEqual(["EMS Controls", "Diagnostics"]); + }); +}); + +describe("all mode", () => { + it("leads with Glance then Flow then Analyst then the full classic view set", async () => { + const hass = makeHass({ batterySerials: ["BAT1"], acCoupled: true }); + const dash = await GE.generateDashboard({ mode: "all" }, hass); + expect(titles(dash)).toEqual([ + "Glance", + "Flow", + "Analyst", + "Overview", + "Energy", + "Batteries", + "Battery Health", + "Controls", + "Diagnostics", + ]); + expect(view(dash, "Glance").cards[0].type).toBe("custom:givenergy-glance"); + expect(view(dash, "Flow").cards[0].type).toBe("custom:givenergy-flow"); + expect(view(dash, "Analyst").cards[0].type).toBe("custom:givenergy-analyst"); + }); + + it("falls back to classic for an EMS plant", async () => { + const hass = makeHass({ ems: true }); + const dash = await GE.generateDashboard({ mode: "all" }, hass); + expect(titles(dash)).toEqual(["EMS Controls", "Diagnostics"]); + }); +}); + +describe("analyst mode", () => { + const analystCard = (dash) => view(dash, "Analyst").cards[0]; + + it("leads with a non-panel Analyst view with givenergy-analyst, apexcharts placeholder, and heatmaps", async () => { + const hass = makeHass({ batterySerials: ["BAT1", "BAT2"], acCoupled: true }); + const dash = await GE.generateDashboard({ mode: "analyst" }, hass); + expect(titles(dash)).toEqual([ + "Analyst", + "Overview", + "Energy", + "Batteries", + "Battery Health", + "Controls", + "Diagnostics", + ]); + const av = view(dash, "Analyst"); + expect(av.panel).toBeUndefined(); // non-panel + expect(av.cards[0].type).toBe("custom:givenergy-analyst"); + // apexcharts not registered -> placeholder + expect(av.cards[1].type).toBe("markdown"); + expect(av.cards[1].content).toContain("apexcharts-card"); + // one heatmap per battery pack + expect(av.cards[2].type).toBe("custom:ge-cell-heatmap"); + expect(av.cards[3].type).toBe("custom:ge-cell-heatmap"); + }); + + it("resolves all analyst entity slots from the registry and survives the loft_ prefix", async () => { + const hass = makeHass({ batterySerials: ["BAT1"], areaPrefix: "loft_" }); + const dash = await GE.generateDashboard({ mode: "analyst" }, hass); + const c = analystCard(dash); + const registry = await regSet(hass); + + const liveSlots = [c.solar, c.grid, c.load, c.battery_power, c.battery_soc] + .concat(c.solar_strings || []) + .filter(Boolean); + const totalSlots = Object.values(c.totals || {}); + const diagSlots = Object.values(c.diag || {}); + const allSlots = liveSlots.concat(totalSlots).concat(diagSlots); + + expect(liveSlots.length).toBeGreaterThan(4); + expect(totalSlots.length).toBe(6); // all 6 energy totals + expect(diagSlots.length).toBeGreaterThan(4); + + for (const eid of allSlots) { + expect(registry.has(eid)).toBe(true); + expect(eid).toContain("loft_"); + } + expect(hasNullEntity(c)).toBe(false); + }); + + it("omits missing totals and diag entities gracefully rather than emitting null", async () => { + const hass = makeHass({ + batterySerials: ["BAT1"], + omitKeys: ["e_grid_out_day", "e_grid_in_day", "consecutive_failures"], + }); + const c = analystCard(await GE.generateDashboard({ mode: "analyst" }, hass)); + expect(c.totals.export_today).toBeUndefined(); + expect(c.totals.import_today).toBeUndefined(); + expect(c.totals.pv_today).toBeTruthy(); + expect(c.diag.consecutive_failures).toBeUndefined(); + expect(c.diag.t_inverter_heatsink).toBeTruthy(); + expect(hasNullEntity(c)).toBe(false); + }); + + it("does not emit an Analyst view for an EMS plant", async () => { + const hass = makeHass({ ems: true }); + const dash = await GE.generateDashboard({ mode: "analyst" }, hass); + expect(titles(dash)).toEqual(["EMS Controls", "Diagnostics"]); + }); +});