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 =
+ '' +
+ '| Source | kWh | % |
' +
+ '' +
+ ledgerRow("PV generation", pvToday, sumSources) +
+ ledgerRow("Battery discharge", dischargeToday, sumSources) +
+ ledgerRow("Grid import", importToday, sumSources) +
+ ledgerTotalRow("Sources total", sumSources, "#d4a85a") +
+ '
' +
+ '' +
+ '| Sink | kWh | % |
' +
+ '' +
+ ledgerRow("House consumption", houseToday, sumSinks) +
+ ledgerRow("Battery charge", chargeToday, sumSinks) +
+ ledgerRow("Grid export", exportToday, sumSinks) +
+ ledgerTotalRow("Sinks total", sumSinks, "#5bbb6a") +
+ '
';
+
+ // ---- 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
+ ? '' +
+ '| Diagnostic | Value |
' +
+ '' + diagRowsHtml + '
'
+ : "";
+
+ // ---- 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"]);
+ });
+});