diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 83735cc..a85c5d2 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -51,3 +51,15 @@ jobs: enable-cache: true - run: uv sync - run: uv run pytest + + js-tests: + name: js tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + - run: npm ci + - run: npm test diff --git a/.gitignore b/.gitignore index dab786f..4e03d75 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ dist/ # Claude Code — local-only, not shared .claude/settings.local.json .claude/worktrees/ + +# Node / frontend test tooling +node_modules/ diff --git a/README.md b/README.md index b028829..7f93901 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,24 @@ If the dashboard schema is updated in a future release, the integration raises a The generated YAML is a snapshot of your entity IDs at the moment it runs. Home Assistant 2026.6 onwards builds entity IDs from the device's area (so a device in "Loft" gets `sensor.loft_givenergy_inverter_…`), and Home Assistant doesn't rewrite existing dashboards when entities are renamed. So if you move a device between areas, rename entities, or use **Recreate entity IDs**, just run `generate_dashboard` again afterwards to re-point the cards. +### Dashboard strategy (live, self-maintaining) + +To avoid that snapshot problem entirely, there's also a dashboard *strategy* that builds the same six-tab layout but resolves every entity from the registry each time the dashboard loads — so it doesn't go stale when a device moves area or an entity is renamed. I added it because the static YAML kept silently rotting on my own install after area reassignments. To use it, create a new dashboard, open the **raw configuration editor**, and set the whole config to: + +```yaml +strategy: + type: custom:givenergy + mode: classic # the only mode in this release + 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 +``` + +The strategy and the bundled cell-heatmap card are served by the integration itself, so there's nothing extra to install for them. `power-flow-card-plus` and `apexcharts-card` are still needed for the Overview/Energy charts (install them via **HACS → Frontend**); where they're missing the strategy shows a short placeholder rather than a broken card. `generate_dashboard` remains available as an editable static starting point if you'd rather hand-tweak a copy. + +One caveat worth knowing: on a **hard refresh** (Ctrl/Cmd+Shift+R, which bypasses the browser cache) the dashboard may occasionally show "Error loading the dashboard strategy: Timeout waiting for strategy element …". This is a Home Assistant limitation common to all network-loaded dashboard strategies — HA gives the strategy module a fixed 5-second window to register, and a cold re-fetch can lose that race when it's queued behind other custom-card resources. A normal reload serves the module from cache and isn't affected, so it doesn't bite in day-to-day use; if you do hit it, reload again. + +This is new in this release and currently reproduces the `classic` layout only; the broader set of modes explored in [the redesign brief](docs/design/dashboard-redesign-brief.md) is still to come. + ### Voice assistants & LLM access Home Assistant's voice assistants (Assist) and LLM tools (Claude / OpenAI via MCP) can only see entities that are explicitly **exposed**. HA auto-exposes a curated allowlist of sensor device classes — `temperature`, `humidity`, and a few others — but `power`, `energy`, and `battery` are **not** on that list, so none of this integration's headline sensors are visible to voice or LLM queries by default. Asking "what's my battery at?" silently returns nothing until you fix it. diff --git a/custom_components/givenergy_local/__init__.py b/custom_components/givenergy_local/__init__.py index 6ad1ba6..4f3b8ca 100644 --- a/custom_components/givenergy_local/__init__.py +++ b/custom_components/givenergy_local/__init__.py @@ -66,13 +66,14 @@ _DASHBOARD_STORAGE_KEY = f"{DOMAIN}.dashboard" _DASHBOARD_STORAGE_VERSION = 1 -# Bundled cell-balance heatmap card, served from this integration's package and -# auto-loaded on the frontend so the generated dashboard's custom:ge-cell-heatmap -# resolves without a manual HACS/resource install. Bump _CARD_VERSION whenever -# the JS changes, to bust the browser cache. -_CARD_FILENAME = "ge-cell-heatmap.js" -_CARD_URL = f"/{DOMAIN}/{_CARD_FILENAME}" -_CARD_VERSION = "2" +# Bundled frontend module: the dashboard strategy (custom:givenergy) and the +# cell-balance heatmap card (custom:ge-cell-heatmap) are shipped together in a +# single JS file, served from this integration's package and auto-loaded so both +# resolve on any install without a manual HACS/resource registration. Bump +# _STRATEGY_VERSION whenever the JS changes, to bust the browser cache. +_STRATEGY_FILENAME = "ge-strategy.js" +_STRATEGY_URL = f"/{DOMAIN}/{_STRATEGY_FILENAME}" +_STRATEGY_VERSION = "3" # Per-config-entry topology cache. PlantCapabilities is persisted as # `to_dict()` directly (no envelope) following HA Core's Store convention — @@ -245,12 +246,12 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_register_frontend_card(hass: HomeAssistant) -> None: - """Serve and auto-load the bundled cell-heatmap card. + """Serve and auto-load the bundled frontend module (strategy + heatmap card). - The card module ships inside this integration's ``www/`` dir; we expose it - at a stable URL and register it as an extra frontend module so the generated - dashboard's ``custom:ge-cell-heatmap`` resolves on any dashboard without a - manual HACS/resource install. + The single JS file ships inside this integration's ``www/`` dir; we expose + it at a stable URL and register it as an extra frontend module so both + ``custom:givenergy`` (the dashboard strategy) and ``custom:ge-cell-heatmap`` + resolve on any install without a manual HACS/resource registration. Called once from :func:`async_setup` (component scope), so the static-path registration happens a single time for the integration regardless of how @@ -262,16 +263,16 @@ async def _async_register_frontend_card(hass: HomeAssistant) -> None: # skips where there is nothing to serve from anyway. return try: - card_path = Path(__file__).parent / "www" / _CARD_FILENAME + module_path = Path(__file__).parent / "www" / _STRATEGY_FILENAME await hass.http.async_register_static_paths( - [StaticPathConfig(_CARD_URL, str(card_path), False)] + [StaticPathConfig(_STRATEGY_URL, str(module_path), False)] ) - add_extra_js_url(hass, f"{_CARD_URL}?v={_CARD_VERSION}") + add_extra_js_url(hass, f"{_STRATEGY_URL}?v={_STRATEGY_VERSION}") except Exception as exc: # noqa: BLE001 - # The bundled card is cosmetic (a dashboard heatmap). Registering it once - # at component scope means a failure here is genuinely unexpected, but it - # must still never take down the integration — log and carry on. - _LOGGER.warning("Could not register the bundled cell-heatmap card: %s", exc) + # The bundled module is cosmetic (dashboard frontend). Registering it + # once at component scope means a failure here is genuinely unexpected, + # but it must still never take down the integration — log and carry on. + _LOGGER.warning("Could not register the bundled frontend module: %s", exc) async def _build_capture_header( @@ -663,7 +664,13 @@ async def handle_generate_dashboard(call: ServiceCall) -> None: "message": ( f"Dashboard ready — [download YAML]({url})\n\n" "Go to **Settings → Dashboards → Add Dashboard** " - "and paste the contents into the raw config editor." + warning + "and paste the contents into the raw config editor.\n\n" + "**Tip:** for a dashboard that resolves entities live " + "(and so survives renames and area moves), use the " + "`custom:givenergy` strategy instead — set the raw " + "config to `strategy: { type: custom:givenergy, " + "mode: classic }`. This static YAML stays available as " + "an editable starting point." + warning ), "notification_id": f"givenergy_dashboard_{inv}", }, diff --git a/custom_components/givenergy_local/www/ge-cell-heatmap.js b/custom_components/givenergy_local/www/ge-cell-heatmap.js deleted file mode 100644 index e1d7f95..0000000 --- a/custom_components/givenergy_local/www/ge-cell-heatmap.js +++ /dev/null @@ -1,106 +0,0 @@ -// GivEnergy cell-balance heatmap card (bundled with the givenergy_local -// integration and auto-registered as a frontend module - no manual install). -// -// Renders one row per battery pack: each of the 16 cell voltages coloured by -// its mV deviation from that pack's own mean (so imbalance is visible at any -// charge level), plus the pack mean (V) and spread (max-min, mV). -// -// Config: -// type: custom:ge-cell-heatmap -// batteries: [, ...] # required -// cells: 16 # optional, default 16 -// span_mv: 15 # optional colour-scale half-range, default 15 -// title: "..." # optional card header -// -// NOTE: text is emitted as HTML entities (not raw Unicode) on purpose - the -// inline-resource serving path mangles multibyte UTF-8, and ASCII-only source -// is immune to that class of bug. -class GeCellHeatmap extends HTMLElement { - setConfig(cfg) { - if (!cfg || !Array.isArray(cfg.batteries) || !cfg.batteries.length) - throw new Error("ge-cell-heatmap: 'batteries: [serial, ...]' is required"); - this._cfg = cfg; - } - set hass(hass) { this._hass = hass; this._render(); } - getCardSize() { return (this._cfg && this._cfg.batteries.length || 1) + 1; } - - _render() { - const cfg = this._cfg, hass = this._hass; - if (!hass) return; - const nCells = cfg.cells || 16; - // `set hass` fires on every HA state change, not just ours; skip the DOM - // rebuild unless one of our cells (or the config) actually changed. - const sig = - (cfg.batteries || []) - .map((s) => { - const lo = s.toLowerCase(); - let cells = ""; - for (let n = 1; n <= nCells; n++) { - const st = hass.states[`sensor.givenergy_battery_${lo}_cell_${n}_voltage`]; - cells += (st ? st.state : "?") + ","; - } - return lo + ":" + cells; - }) - .join("|") + - "#" + (cfg.title || "") + "/" + (cfg.span_mv != null ? cfg.span_mv : 15); - if (sig === this._sig) return; - this._sig = sig; - const esc = (s) => String(s).replace(/[&<>"']/g, (c) => - ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); - const span = (cfg.span_mv != null ? cfg.span_mv : 15) / 1000; - const valOf = (s, n) => { - const st = hass.states[`sensor.givenergy_battery_${s.toLowerCase()}_cell_${n}_voltage`]; - const v = st ? Number(st.state) : NaN; - return Number.isFinite(v) ? v : null; - }; - const colour = (d) => { - if (d == null) return "var(--disabled-color, #9e9e9e)"; - const t = Math.max(-1, Math.min(1, d / span)); - const f = Math.round(255 * (1 - Math.abs(t) * 0.85)); - return t >= 0 ? `rgb(255,${f},${f})` : `rgb(${f},${f},255)`; - }; - const head = `#${Array.from({length: nCells}, (_, i) => `${i + 1}`).join("")}mΔ`; - const rows = cfg.batteries.map((s, bi) => { - const vals = Array.from({length: nCells}, (_, i) => valOf(s, i + 1)); - const present = vals.filter((v) => v != null); - const mean = present.length ? present.reduce((a, b) => a + b, 0) / present.length : null; - const dmv = present.length ? Math.round((Math.max(...present) - Math.min(...present)) * 1000) : null; - const cells = vals.map((v) => { - const d = (v != null && mean != null) ? v - mean : null; - const dv = d != null ? Math.round(d * 1000) : null; - const txt = dv != null ? (dv > 0 ? `+${dv}` : `${dv}`) : ""; - const title = v != null ? `${v.toFixed(3)} V` : "no data"; - return `${txt}`; - }).join(""); - const meanTxt = mean != null ? mean.toFixed(2) : "—"; - const dTxt = dmv != null ? `${dmv}` : "—"; - return `${bi + 1}${cells}${meanTxt}${dTxt}`; - }).join(""); - const packMap = cfg.batteries.map((s, bi) => `${bi + 1} = ${esc(s.toUpperCase())}`).join(" · "); - this.innerHTML = ` - - -
- ${head}${rows}
-
Packs: ${packMap}
- Colour = each cell's mV deviation from its own pack's mean (±${cfg.span_mv != null ? cfg.span_mv : 15} mV scale) — imbalance shows regardless of charge level:
- below · - mean · - above.
- m = pack mean cell voltage (V); Δ = spread (max−min) in mV.
-
-
`; - } -} -if (!customElements.get("ge-cell-heatmap")) { - customElements.define("ge-cell-heatmap", GeCellHeatmap); -} diff --git a/custom_components/givenergy_local/www/ge-strategy.js b/custom_components/givenergy_local/www/ge-strategy.js new file mode 100644 index 0000000..1ac1dc7 --- /dev/null +++ b/custom_components/givenergy_local/www/ge-strategy.js @@ -0,0 +1,1116 @@ +// GivEnergy dashboard strategy (bundled with the givenergy_local integration +// and auto-registered as a frontend module - no manual install). +// +// Registers a Lovelace *dashboard strategy* `custom:givenergy` that generates +// the dashboard from the live registry on every render, so it never goes stale. +// v1 ships `mode: classic` - a faithful reproduction of the six-tab dashboard +// the `givenergy_local.generate_dashboard` service emits as static YAML, but +// resolved from the registry instead of frozen entity-id strings. +// +// strategy: +// type: custom:givenergy +// mode: classic # only mode in v1; unknown/absent -> classic +// max_power_kw: 10 # Overview 24h chart y-axis envelope (+/- kW) +// serial: SA2114G047 # optional inverter pin; default = sole/first plant +// +// Resolution rule (the fix for the dangling-ids rot): every entity is found in +// the entity registry by its stable `unique_id` (`{serial}_{key}`), then its +// *current* `entity_id` is read back. unique_id never changes on rename or area +// reassignment, so the `loft_` area-prefix bug cannot recur. We never construct +// or parse an entity_id string. +// +// NOTE: ASCII-only source on purpose - the /givenergy_local/ static serving path +// mangles multibyte UTF-8 (same lesson as ge-cell-heatmap.js), so card titles use +// "-"/"deg" rather than em-dash / degree-sign. + +(function () { + "use strict"; + + // Register the strategy element immediately -- before any var assignments -- + // so customElements.whenDefined() resolves the instant this script is + // evaluated, beating HA's timeout regardless of whether the module was served + // from cache or freshly fetched. Function declarations (generateDashboard, + // etc.) are hoisted in JS, so generate() can safely call generateDashboard + // even though its textual definition appears later in the file. + if (typeof customElements !== "undefined" && + !customElements.get("ll-strategy-dashboard-givenergy")) { + customElements.define( + "ll-strategy-dashboard-givenergy", + class GivEnergyDashboardStrategy extends HTMLElement { + static async generate(config, hass) { + return generateDashboard(config, hass); + } + } + ); + } + + var DOMAIN = "givenergy_local"; + + // Battery Health palettes (mirrors dashboard.py _PACK_COLOURS / _TEMP_COLOURS). + var PACK_COLOURS = ["#1e88e5", "#fb8c00", "#43a047", "#6d4c41", "#3949ab", "#c0ca33"]; + var TEMP_COLOURS = ["#00897b", "#8e24aa", "#d81b60", "#00838f", "#7cb342", "#5e35b1"]; + + // Implausible single-sample rejection (mirrors dashboard.py). parseFloat (not + // Number) so blank/unknown -> NaN -> gap rather than a spurious 0. + var VOLT_FILTER = "const v = parseFloat(x); return (!isNaN(v) && v > 2.0 && v < 4.0) ? v : null;"; + var TEMP_FILTER = "const v = parseFloat(x); return (!isNaN(v) && v > -40 && v < 100) ? v : null;"; + var POWER_FILTER = "const v = parseFloat(x); return (!isNaN(v) && v > -20000 && v < 20000) ? v : null;"; + + // The BMS samples one thermistor per 4-cell group; key suffix per group. + var TEMP_GROUPS = [ + { key: "t_cells_01_04", lo: 1, hi: 4 }, + { key: "t_cells_05_08", lo: 5, hi: 8 }, + { key: "t_cells_09_12", lo: 9, hi: 12 }, + { key: "t_cells_13_16", lo: 13, hi: 16 }, + ]; + + // ----- registry resolution ------------------------------------------------- + + function classify(keys) { + // By entity key, not device name - robust against user device renames. + if (keys.has("v_cell_01") || keys.has("num_cycles") || keys.has("soc")) return "battery"; + if (keys.has("ems_plant_enable")) return "ems"; + if (keys.has("p_pv")) return "inverter"; + return "other"; + } + + // Resolve the plant topology and a key->entity_id map per device from the + // registry. Returns { plants: [...], byDevice: Map> }. + async function buildPlant(hass, opts) { + var res; + try { + res = await Promise.all([ + hass.callWS({ type: "config/entity_registry/list" }), + hass.callWS({ type: "config/device_registry/list" }), + ]); + } catch (err) { + // These list commands aren't admin-gated, but the connection can still + // fail (transient drop, reconnect in progress). Surface a friendly + // notice rather than letting the whole strategy render throw. + return { target: null, batteries: [], registryError: true }; + } + var entities = res[0] || []; + var devices = res[1] || []; + + // givenergy devices: deviceId -> { serial, viaDeviceId } + var geDevices = new Map(); + for (var i = 0; i < devices.length; i++) { + var d = devices[i]; + var ident = (d.identifiers || []).find(function (pair) { + return pair[0] === DOMAIN; + }); + if (!ident) continue; + geDevices.set(d.id, { serial: ident[1], viaDeviceId: d.via_device_id || null }); + } + + // Two per-device maps, both keyed by stripping the `{serial}_` prefix off + // each entity's unique_id. `allKeys` records every registered key incl. + // disabled ones, used only for classification so disabling a single marker + // entity (p_pv, ems_plant_enable, ...) can't make a whole device vanish. + // `byDevice` holds only enabled entities' key->entity_id; disabled entities + // have no state and would dangle if rendered. + var byDevice = new Map(); + var allKeys = new Map(); + for (var j = 0; j < entities.length; j++) { + var e = entities[j]; + if (e.platform !== DOMAIN) continue; + var dev = geDevices.get(e.device_id); + if (!dev || !e.unique_id) continue; + var prefix = dev.serial + "_"; + if (e.unique_id.lastIndexOf(prefix, 0) !== 0) continue; // startsWith + var key = e.unique_id.slice(prefix.length); + var ks = allKeys.get(e.device_id); + if (!ks) { + ks = new Set(); + allKeys.set(e.device_id, ks); + } + ks.add(key); + if (e.disabled_by) continue; // keep disabled entities out of the renderable map + var m = byDevice.get(e.device_id); + if (!m) { + m = new Map(); + byDevice.set(e.device_id, m); + } + if (!m.has(key)) m.set(key, e.entity_id); + } + + // classify each device against its FULL key set; collect inverters/ems + batteries + var inverters = []; + var batteries = []; + geDevices.forEach(function (dev, deviceId) { + var kind = classify(allKeys.get(deviceId) || new Set()); + var rec = { + deviceId: deviceId, + serial: dev.serial, + viaDeviceId: dev.viaDeviceId, + keys: byDevice.get(deviceId) || new Map(), + }; + if (kind === "battery") batteries.push(rec); + else if (kind === "inverter" || kind === "ems") { + rec.isEms = kind === "ems"; + inverters.push(rec); + } + }); + + inverters.sort(function (a, b) { + return a.serial < b.serial ? -1 : a.serial > b.serial ? 1 : 0; + }); + batteries.sort(function (a, b) { + return a.serial < b.serial ? -1 : a.serial > b.serial ? 1 : 0; + }); + + // pick the target plant. An explicit serial pin that doesn't match must NOT + // silently fall back to another plant - that would mis-target the + // Maintenance buttons - so leave target null and let the no-plant notice + // fire (naming the missing serial). The first-inverter default only applies + // when no serial was supplied. + var target = null; + var unmatchedSerial = null; + if (opts.serial) { + target = + inverters.find(function (p) { + return String(p.serial).toUpperCase() === String(opts.serial).toUpperCase(); + }) || null; + if (!target) unmatchedSerial = opts.serial; + } else { + target = inverters[0] || null; + } + + // batteries belonging to the target inverter (by via_device). Only fall + // back to all batteries when the registry exposes no via_device links at + // all; when links exist, an empty match is genuine (this inverter has no + // batteries) and must stay empty so we don't show another plant's packs. + var anyViaLinks = batteries.some(function (b) { + return b.viaDeviceId; + }); + var ownBatteries = batteries.filter(function (b) { + return target && b.viaDeviceId === target.deviceId; + }); + if (!ownBatteries.length && !anyViaLinks) ownBatteries = batteries; + + return { target: target, batteries: ownBatteries, unmatchedSerial: unmatchedSerial }; + } + + // ----- small helpers ------------------------------------------------------- + + function haveCard(name) { + try { + return typeof customElements !== "undefined" && !!customElements.get(name); + } catch (e) { + return false; + } + } + + function pad2(n) { + return n < 10 ? "0" + n : "" + n; + } + + // entities-card row list: drop rows whose entity didn't resolve, then tidy + // dividers (no leading/trailing/double dividers left dangling). + function cleanRows(rows) { + var kept = rows.filter(function (r) { + return r && (r.type === "divider" || r.type === "button" || r.entity); + }); + var out = []; + for (var i = 0; i < kept.length; i++) { + var r = kept[i]; + if (r.type === "divider") { + var prev = out[out.length - 1]; + if (!prev || prev.type === "divider") continue; // skip leading/double + } + out.push(r); + } + while (out.length && out[out.length - 1].type === "divider") out.pop(); + return out; + } + + function placeholder(cardName) { + return { + type: "markdown", + content: + "**" + + cardName + + "** is not installed. Install it via **HACS > Frontend**, then reload this dashboard.", + }; + } + + // ----- classic mode -------------------------------------------------------- + + // `inv(key)` / `bat(rec, key)` return the resolved entity_id or null. + function makeAccessors(plant) { + var invKeys = (plant.target && plant.target.keys) || new Map(); + return { + inv: function (key) { + return invKeys.get(key) || null; + }, + bat: function (rec, key) { + return rec.keys.get(key) || null; + }, + }; + } + + function row(entity, name) { + return entity ? { entity: entity, name: name } : { entity: null }; + } + + function classicViews(plant, opts) { + var a = makeAccessors(plant); + var views = []; + if (plant.target && plant.target.isEms) { + views.push(emsControlsView(plant, a)); + views.push(emsDiagnosticsView(plant, a)); + return views; + } + views.push(overviewView(plant, a, opts)); + views.push(energyView(plant, a)); + views.push(batteriesView(plant, a)); + if (plant.batteries.length) views.push(batteryHealthView(plant, a)); + views.push(controlsView(plant, a)); + views.push(diagnosticsView(plant, a, opts)); + return views; + } + + function overviewView(plant, a, opts) { + var cap = (opts.maxPowerKw || 10) * 1000; + var cards = []; + + if (haveCard("power-flow-card-plus")) { + var ents = {}; + if (a.inv("p_pv")) ents.solar = { entity: a.inv("p_pv"), display_zero_state: true }; + if (a.inv("p_battery")) { + ents.battery = { entity: a.inv("p_battery") }; + if (a.inv("battery_soc")) ents.battery.state_of_charge = a.inv("battery_soc"); + } + if (a.inv("grid_power")) ents.grid = { entity: a.inv("grid_power") }; + if (a.inv("p_load_demand")) ents.home = { entity: a.inv("p_load_demand") }; + cards.push({ type: "custom:power-flow-card-plus", entities: ents }); + } else { + cards.push(placeholder("power-flow-card-plus")); + } + + cards.push({ + type: "glance", + title: "Status", + columns: 4, + entities: cleanRows([ + row(a.inv("status"), "Inverter"), + row(a.inv("battery_soc"), "Battery SOC"), + row(a.inv("battery_pause_mode"), "Pause Mode"), + row(a.inv("t_battery"), "Battery Temp"), + row(a.inv("battery_out_of_spec"), "Battery OOS"), + ]), + }); + + cards.push({ + type: "glance", + title: "Today", + columns: 6, + entities: cleanRows([ + row(a.inv("e_pv_day"), "PV"), + row(a.inv("e_battery_charge_day"), "Charged"), + row(a.inv("e_battery_discharge_day"), "Discharged"), + row(a.inv("e_grid_in_day"), "Imported"), + row(a.inv("e_grid_out_day"), "Exported"), + row(a.inv("e_consumption_today"), "Consumed"), + ]), + }); + + var series = [ + { entity: a.inv("p_pv"), name: "PV", color: "#FFB300" }, + { entity: a.inv("p_battery"), name: "Battery", color: "#42A5F5" }, + { entity: a.inv("grid_power"), name: "Grid", color: "#66BB6A" }, + { entity: a.inv("p_load_demand"), name: "Load", color: "#EF5350" }, + ].filter(function (s) { + return s.entity; + }); + if (haveCard("apexcharts-card")) { + cards.push({ + type: "custom:apexcharts-card", + header: { show: true, title: "Power - Last 24 Hours" }, + graph_span: "24h", + yaxis: [{ min: -cap, max: cap }], + series: series, + }); + } else { + cards.push(placeholder("apexcharts-card")); + } + + return { title: "Overview", path: "overview", icon: "mdi:solar-power-variant", cards: cards }; + } + + function colSeries(entity, name, color) { + return { + entity: entity, + name: name, + color: color, + type: "column", + statistics: { type: "state", period: "hour" }, + group_by: { func: "max", duration: "1d" }, + }; + } + + function energyView(plant, a) { + var cards = []; + function apexPair(title, s1, s2) { + var series = [s1, s2].filter(function (s) { + return s.entity; + }); + if (!series.length) return null; + if (!haveCard("apexcharts-card")) return placeholder("apexcharts-card"); + return { + type: "custom:apexcharts-card", + header: { show: true, title: title }, + graph_span: "30d", + series: series, + }; + } + + [ + apexPair( + "Daily Generation vs Consumption - Last 30 Days", + colSeries(a.inv("e_pv_day"), "PV Generated", "#FFB300"), + colSeries(a.inv("e_consumption_today"), "Consumed", "#EF5350") + ), + apexPair( + "Grid Import vs Export - Last 30 Days", + colSeries(a.inv("e_grid_out_day"), "Exported", "#66BB6A"), + colSeries(a.inv("e_grid_in_day"), "Imported", "#EF5350") + ), + apexPair( + "Battery Charge vs Discharge - Last 30 Days", + colSeries(a.inv("e_battery_charge_day"), "Charged", "#42A5F5"), + colSeries(a.inv("e_battery_discharge_day"), "Discharged", "#7E57C2") + ), + ].forEach(function (c) { + if (c) cards.push(c); + }); + + cards.push({ + type: "entities", + title: "All-Time Totals", + entities: cleanRows([ + row(a.inv("e_pv_total"), "PV Generated"), + row(a.inv("e_battery_throughput"), "Battery Throughput"), + row(a.inv("e_battery_charge_total"), "Battery Charged"), + row(a.inv("e_battery_discharge_total"), "Battery Discharged"), + row(a.inv("e_grid_out_total"), "Grid Exported"), + row(a.inv("e_grid_in_total"), "Grid Imported"), + row(a.inv("e_pv_generation_total"), "PV Generation Total"), + row(a.inv("e_inverter_in_total"), "Charged from Grid"), + row(a.inv("e_discharge_year"), "Discharged This Year"), + row(a.inv("e_solar_diverter"), "Solar Diverter Energy"), + ]), + }); + + return { title: "Energy", path: "energy", icon: "mdi:lightning-bolt", cards: cards }; + } + + function batteriesView(plant, a) { + var sections = plant.batteries.map(function (rec) { + var cards = []; + if (a.bat(rec, "soc")) { + cards.push({ + type: "gauge", + entity: a.bat(rec, "soc"), + name: String(rec.serial).toUpperCase(), + min: 0, + max: 100, + needle: true, + severity: { red: 0, yellow: 20, green: 40 }, + }); + } + cards.push({ + type: "entities", + title: "Pack Details", + entities: cleanRows([ + row(a.bat(rec, "soc"), "SOC"), + row(a.bat(rec, "v_out"), "Voltage"), + row(a.bat(rec, "t_max"), "Temp Max"), + row(a.bat(rec, "t_min"), "Temp Min"), + row(a.bat(rec, "t_bms_mosfet"), "BMS MOSFET Temp"), + { type: "divider" }, + row(a.bat(rec, "num_cycles"), "Charge Cycles"), + row(a.bat(rec, "cap_remaining"), "Remaining Capacity"), + row(a.bat(rec, "cap_calibrated"), "Calibrated Capacity"), + row(a.bat(rec, "cap_design"), "Design Capacity"), + row(a.bat(rec, "v_cells_sum"), "Cell Voltages Sum"), + row(a.bat(rec, "num_cells"), "Cell Count"), + ]), + }); + cards.push({ + type: "entities", + title: "Cell Temperatures", + entities: cleanRows([ + row(a.bat(rec, "t_cells_01_04"), "Cells 1-4"), + row(a.bat(rec, "t_cells_05_08"), "Cells 5-8"), + row(a.bat(rec, "t_cells_09_12"), "Cells 9-12"), + row(a.bat(rec, "t_cells_13_16"), "Cells 13-16"), + ]), + }); + var bms = [ + row(a.bat(rec, "bms_firmware_version"), "BMS Firmware"), + row(a.bat(rec, "usb_device_inserted"), "USB Device"), + row(a.bat(rec, "cap_design2"), "Design Capacity Alt"), + { type: "divider" }, + ]; + for (var s = 1; s <= 7; s++) bms.push(row(a.bat(rec, "status_" + s), "Status " + s)); + bms.push({ type: "divider" }); + bms.push(row(a.bat(rec, "warning_1"), "Warning 1")); + bms.push(row(a.bat(rec, "warning_2"), "Warning 2")); + cards.push({ type: "entities", title: "BMS Diagnostics", entities: cleanRows(bms) }); + + return { type: "grid", cards: cards }; + }); + + return { + title: "Batteries", + path: "battery", + type: "sections", + icon: "mdi:battery-high", + sections: sections, + }; + } + + function healthAnnotations() { + var amber = "#f9a825"; + function warnLine(y, text) { + return { + y: y, + borderColor: amber, + strokeDashArray: 0, + label: { + text: text, + position: "left", + textAnchor: "start", + borderColor: amber, + style: { color: "#000", background: amber }, + }, + }; + } + return [ + { y: 3.5, y2: 3.6, fillColor: amber, opacity: 0.14 }, + { y: 2.9, y2: 3.0, fillColor: amber, opacity: 0.14 }, + warnLine(3.5, "warn - 3.50 V / 45 degC"), + warnLine(3.0, "warn - 3.00 V / 10 degC"), + ]; + } + + function batteryHealthView(plant, a) { + var voltSeries = []; + var tempSeries = []; + var socSeries = []; + plant.batteries.forEach(function (rec, index) { + var tag = "B" + (index + 1); + var packColour = PACK_COLOURS[index % PACK_COLOURS.length]; + var tempColour = TEMP_COLOURS[index % TEMP_COLOURS.length]; + for (var cell = 1; cell <= 16; cell++) { + var ve = a.bat(rec, "v_cell_" + pad2(cell)); + if (!ve) continue; + voltSeries.push({ + entity: ve, + name: tag + " " + cell, + color: packColour, + stroke_width: 1, + yaxis_id: "v", + transform: VOLT_FILTER, + }); + } + TEMP_GROUPS.forEach(function (g) { + var tempEid = a.bat(rec, g.key); + if (!tempEid) return; + tempSeries.push({ + entity: tempEid, + name: tag + " T" + g.lo + "-" + g.hi, + color: tempColour, + stroke_width: 1, + yaxis_id: "temp", + transform: TEMP_FILTER, + }); + }); + var se = a.bat(rec, "soc"); + if (se) { + socSeries.push({ + entity: se, + name: tag + " SoC", + color: packColour, + stroke_width: 1, + yaxis_id: "soc", + }); + } + }); + + var note = { + type: "markdown", + content: + "## Battery health\n" + + "Cross-pack cell diagnostics. **Heatmap**: each cell coloured by its mV " + + "deviation from its own pack's mean (imbalance shows at any charge level). " + + "**Cell voltages + temperatures**: every cell (left) with cell-group " + + "temperatures (right) on a shared warn band. **Power + SoC**: the " + + "charge/discharge rate driving each pack's state of charge. Implausible " + + "single-sample reads are filtered to gaps.", + }; + var heatmap = { + type: "custom:ge-cell-heatmap", + title: "Cell balance - deviation from pack mean", + batteries: plant.batteries.map(function (rec) { + return rec.serial; + }), + }; + var cards = [note, heatmap]; + + if (voltSeries.length || tempSeries.length) { + cards.push({ + type: "custom:apexcharts-card", + header: { + show: true, + title: + "Cell voltages (left, V) + cell-group temps (right, degC - warn bands shared) - 24h", + }, + graph_span: "24h", + chart_type: "line", + yaxis: [ + { id: "v", min: 2.9, max: 3.6, decimals: 2 }, + { id: "temp", opposite: true, min: 3, max: 52, decimals: 0 }, + ], + apex_config: { + legend: { show: false }, + annotations: { yaxis: healthAnnotations() }, + chart: { height: 330 }, + }, + series: voltSeries.concat(tempSeries), + }); + } + + var powerEntity = a.inv("p_battery"); + var powerSeries = []; + if (powerEntity) { + powerSeries.push({ + entity: powerEntity, + name: "Battery power", + color: "#8e24aa", + stroke_width: 1, + transform: POWER_FILTER, + yaxis_id: "w", + group_by: { duration: "2m", func: "avg" }, + }); + } + powerSeries = powerSeries.concat(socSeries); + if (powerSeries.length) { + cards.push({ + type: "custom:apexcharts-card", + header: { show: true, title: "Battery power (left, W, 2-min avg) + pack SoC (right) - 24h" }, + graph_span: "24h", + chart_type: "line", + apex_config: { + legend: { show: false }, + annotations: { + yaxis: [ + { + y: 0, + borderColor: "#616161", + strokeDashArray: 3, + label: { + text: "0 W (idle)", + position: "left", + textAnchor: "start", + style: { color: "#000", background: "#e0e0e0" }, + }, + }, + ], + }, + chart: { height: 330 }, + }, + series: powerSeries, + yaxis: [ + { id: "w", decimals: 0 }, + { id: "soc", opposite: true, min: 0, max: 100, decimals: 0 }, + ], + }); + } + + // ApexCharts may be absent; swap the chart cards for a single placeholder. + if (!haveCard("apexcharts-card")) { + cards = [note, heatmap, placeholder("apexcharts-card")]; + } + cards.forEach(function (c) { + c.grid_options = { columns: "full" }; + }); + + return { + title: "Battery Health", + path: "battery-health", + type: "sections", + icon: "mdi:heart-pulse", + sections: [{ type: "grid", cards: cards }], + }; + } + + function controlsView(plant, a) { + var cards = []; + + cards.push({ + type: "entities", + title: "Mode", + entities: cleanRows([ + row(a.inv("battery_power_mode"), "Battery Power Mode"), + row(a.inv("battery_pause_mode"), "Pause Mode"), + row(a.inv("enable_rtc"), "Real Time Control"), + row(a.inv("active_power_rate"), "Inverter Max Output Power"), + row(a.inv("battery_calibration_stage"), "Calibration Stage"), + ]), + }); + + cards.push({ + type: "entities", + title: "Charging", + entities: cleanRows([ + row(a.inv("enable_charge"), "Enable Charge"), + row(a.inv("charge_target_soc"), "Charge Target SOC"), + row(a.inv("battery_soc_reserve"), "SOC Reserve"), + row(a.inv("battery_charge_limit"), "Charge Power Limit"), + { type: "divider" }, + row(a.inv("charge_slot_1_start"), "Slot 1 Start"), + row(a.inv("charge_slot_1_end"), "Slot 1 End"), + row(a.inv("charge_slot_2_start"), "Slot 2 Start"), + row(a.inv("charge_slot_2_end"), "Slot 2 End"), + ]), + }); + + cards.push({ + type: "entities", + title: "Discharging", + entities: cleanRows([ + row(a.inv("enable_discharge"), "Enable Discharge"), + row(a.inv("battery_discharge_limit"), "Discharge Power Limit"), + row(a.inv("battery_discharge_min_power_reserve"), "Min Power Reserve"), + { type: "divider" }, + row(a.inv("discharge_slot_1_start"), "Slot 1 Start"), + row(a.inv("discharge_slot_1_end"), "Slot 1 End"), + row(a.inv("discharge_slot_2_start"), "Slot 2 Start"), + row(a.inv("discharge_slot_2_end"), "Slot 2 End"), + ]), + }); + + // AC-coupled controls exist only when the plant carries the HR(300-359) block + // (the integration creates these entities conditionally) - feature-detect. + if (a.inv("export_priority")) { + cards.push({ + type: "entities", + title: "AC-Coupled", + entities: cleanRows([ + row(a.inv("export_priority"), "Export Priority"), + row(a.inv("enable_eps"), "EPS Enable"), + row(a.inv("battery_charge_limit_ac"), "AC Charge Limit"), + row(a.inv("battery_discharge_limit_ac"), "AC Discharge Limit"), + ]), + }); + } + + // Smart Load slots exist on non-EMS inverters - feature-detect on slot 1. + if (a.inv("smart_load_slot_1_start")) { + var sl = []; + for (var idx = 1; idx <= 10; idx++) { + if (idx > 1) sl.push({ type: "divider" }); + sl.push(row(a.inv("smart_load_slot_" + idx + "_start"), "Slot " + idx + " Start")); + sl.push(row(a.inv("smart_load_slot_" + idx + "_end"), "Slot " + idx + " End")); + } + cards.push({ type: "entities", title: "Smart Load", entities: cleanRows(sl) }); + } + + var serial = String(plant.target.serial).toUpperCase(); + cards.push({ + type: "entities", + title: "Maintenance", + entities: [ + { + type: "button", + name: "Redetect Plant", + icon: "mdi:radar", + tap_action: { + action: "perform-action", + perform_action: "givenergy_local.redetect_plant", + confirmation: { + text: + "This will reload the GivEnergy integration and force a full " + + "hardware detection sweep. Continue?", + }, + data: { serial: serial }, + }, + }, + { + type: "button", + name: "Sync Inverter Clock", + icon: "mdi:clock-sync-outline", + tap_action: { + action: "perform-action", + perform_action: "givenergy_local.set_system_datetime", + data: { serial: serial }, + }, + }, + ], + }); + + return { title: "Controls", path: "controls", icon: "mdi:tune", cards: cards }; + } + + function integrationHealthCard(a) { + return { + type: "entities", + title: "Integration Health", + entities: cleanRows([ + row(a.inv("last_successful_refresh"), "Last Successful Refresh"), + row(a.inv("consecutive_failures"), "Consecutive Failures"), + row(a.inv("partial_failures"), "Partial Failures"), + row(a.inv("total_failures"), "Total Failures"), + ]), + }; + } + + function diagnosticsView(plant, a, opts) { + var cards = []; + cards.push(integrationHealthCard(a)); + + cards.push({ + type: "entities", + title: "Faults & Warnings", + entities: cleanRows([ + row(a.inv("status"), "Inverter Status"), + row(a.inv("battery_out_of_spec"), "Battery Out Of Spec"), + row(a.inv("fault_code"), "Fault Code"), + row(a.inv("inverter_fault_messages"), "Fault Messages"), + row(a.inv("inverter_errors"), "Inverter Errors"), + row(a.inv("charger_warning_code"), "Charger Warning Code"), + row(a.inv("charge_status"), "Charge Status (raw)"), + row(a.inv("system_mode"), "System Mode (raw)"), + ]), + }); + + cards.push({ + type: "entities", + title: "Temperatures", + entities: cleanRows([ + row(a.inv("t_battery"), "Battery"), + row(a.inv("t_inverter_heatsink"), "Inverter Heatsink"), + row(a.inv("t_charger"), "Charger"), + ]), + }); + + cards.push({ + type: "entities", + title: "Electrical", + entities: cleanRows([ + row(a.inv("v_ac1"), "AC Voltage (input)"), + row(a.inv("f_ac1"), "AC Frequency (input)"), + row(a.inv("v_ac1_output"), "AC Voltage (output)"), + row(a.inv("f_ac1_output"), "AC Frequency (output)"), + row(a.inv("i_ac1"), "AC Current (output)"), + row(a.inv("v_battery"), "Battery Voltage"), + row(a.inv("i_battery"), "Battery Current"), + row(a.inv("i_grid_port"), "Grid Port Current"), + row(a.inv("v_p_bus"), "Positive DC Bus"), + row(a.inv("v_n_bus"), "Negative DC Bus"), + row(a.inv("p_grid_apparent"), "Grid Apparent Power"), + row(a.inv("pf_inverter_output_now"), "Inverter Power Factor"), + row(a.inv("p_grid_out_ph1"), "Grid Power Phase 1"), + row(a.inv("p_backup"), "Backup Power"), + row(a.inv("p_combined_generation"), "Combined Generation Power"), + ]), + }); + + cards.push({ + type: "entities", + title: "PV Strings", + entities: cleanRows([ + row(a.inv("v_pv1"), "String 1 Voltage"), + row(a.inv("i_pv1"), "String 1 Current"), + row(a.inv("p_pv1"), "String 1 Power"), + row(a.inv("v_pv2"), "String 2 Voltage"), + row(a.inv("i_pv2"), "String 2 Current"), + row(a.inv("p_pv2"), "String 2 Power"), + { type: "divider" }, + row(a.inv("e_pv1_day"), "String 1 Energy Today"), + row(a.inv("e_pv2_day"), "String 2 Energy Today"), + ]), + }); + + cards.push({ + type: "entities", + title: "Hardware & Firmware", + entities: cleanRows([ + row(a.inv("battery_maintenance_mode"), "Battery Maintenance Mode"), + row(a.inv("arm_firmware_version"), "ARM Firmware"), + row(a.inv("dsp_firmware_version"), "DSP Firmware"), + row(a.inv("modbus_version"), "Modbus Version"), + row(a.inv("work_time_total"), "Work Time"), + row(a.inv("device_type_code"), "Device Type Code"), + row(a.inv("num_mppt"), "MPPT Count"), + row(a.inv("num_phases"), "Phase Count"), + row(a.inv("battery_type"), "Battery Type"), + row(a.inv("meter_type"), "Meter Type"), + row(a.inv("usb_device_inserted"), "USB Device"), + row(a.inv("battery_capacity_kwh"), "Nominal Capacity (kWh)"), + row(a.inv("battery_capacity_ah"), "Capacity (Ah)"), + ]), + }); + + cards.push({ + type: "entities", + title: "Integration", + entities: [ + { + type: "button", + name: "Regenerate Dashboard", + icon: "mdi:view-dashboard-refresh", + action_name: "Run", + tap_action: { + action: "perform-action", + perform_action: "givenergy_local.generate_dashboard", + data: { max_power_kw: opts.maxPowerKw || 10 }, + }, + }, + { + type: "button", + name: "Capture Debug Frames (60 s)", + icon: "mdi:bug-outline", + action_name: "Run", + tap_action: { + action: "perform-action", + perform_action: "givenergy_local.capture_frames", + data: { duration: 60 }, + }, + }, + ], + }); + + return { title: "Diagnostics", path: "diagnostics", icon: "mdi:wrench", cards: cards }; + } + + // ----- EMS plant views ----------------------------------------------------- + + function emsSlotCard(a, kind, title) { + var rows = []; + for (var idx = 1; idx <= 3; idx++) { + if (idx > 1) rows.push({ type: "divider" }); + rows.push(row(a.inv("ems_" + kind + "_slot_" + idx + "_start"), "Slot " + idx + " Start")); + rows.push(row(a.inv("ems_" + kind + "_slot_" + idx + "_end"), "Slot " + idx + " End")); + rows.push( + row(a.inv("ems_" + kind + "_target_soc_" + idx), "Slot " + idx + " Target SOC") + ); + } + return { type: "entities", title: title, entities: cleanRows(rows) }; + } + + function emsControlsView(plant, a) { + var cards = [ + { + type: "entities", + title: "Plant", + entities: cleanRows([row(a.inv("ems_plant_enable"), "Flexi EMS Control")]), + }, + emsSlotCard(a, "charge", "Charge Slots"), + emsSlotCard(a, "discharge", "Discharge Slots"), + emsSlotCard(a, "export", "Export Slots"), + ]; + return { title: "EMS Controls", path: "ems-controls", icon: "mdi:tune", cards: cards }; + } + + function emsDiagnosticsView(plant, a) { + return { + title: "Diagnostics", + path: "diagnostics", + icon: "mdi:wrench", + cards: [integrationHealthCard(a)], + }; + } + + // ----- entry point --------------------------------------------------------- + + async function generateDashboard(config, hass) { + config = config || {}; + var opts = { + maxPowerKw: config.max_power_kw != null ? config.max_power_kw : 10, + serial: config.serial || null, + }; + var plant = await buildPlant(hass, opts); + if (!plant.target) { + var notice; + if (plant.registryError) { + notice = + "Could not read the entity registry from Home Assistant - usually a " + + "transient connection issue. Try reloading the dashboard."; + } else if (plant.unmatchedSerial) { + notice = + "No GivEnergy inverter matches the pinned serial **" + + String(plant.unmatchedSerial).toUpperCase() + + "**. Check the `serial:` in this dashboard's strategy config."; + } else { + notice = + "No GivEnergy plant found in the registry. Is the **givenergy_local** " + + "integration set up and connected?"; + } + return { + title: "GivEnergy", + views: [ + { + title: "GivEnergy", + cards: [{ type: "markdown", content: notice }], + }, + ], + }; + } + // v1: only `classic`. Unknown/absent mode falls back to classic. + var views = classicViews(plant, opts); + return { title: "GivEnergy", views: views }; + } + + var API = { + buildPlant: buildPlant, + classicViews: classicViews, + generateDashboard: generateDashboard, + }; + + // Browser: register remaining custom elements. The strategy element + // (ll-strategy-dashboard-givenergy) is already registered at the top of this + // IIFE. The heatmap card is defined here (inside the guard) so that Node + // (vitest) never evaluates `extends HTMLElement`, which doesn't exist there. + if (typeof customElements !== "undefined") { + // custom:ge-cell-heatmap — merged from ge-cell-heatmap.js. + // Renders one row per battery pack: each of the 16 cell voltages coloured + // by its mV deviation from that pack's own mean (imbalance visible at any + // charge level), plus the pack mean (V) and spread (max-min, mV). + // Config: type / batteries (required) / cells / span_mv / title + if (!customElements.get("ge-cell-heatmap")) { + customElements.define("ge-cell-heatmap", class extends HTMLElement { + setConfig(cfg) { + if (!cfg || !Array.isArray(cfg.batteries) || !cfg.batteries.length) + throw new Error("ge-cell-heatmap: 'batteries: [serial, ...]' is required"); + this._cfg = cfg; + } + set hass(hass) { this._hass = hass; this._render(); } + getCardSize() { return (this._cfg && this._cfg.batteries.length || 1) + 1; } + + _render() { + const cfg = this._cfg, hass = this._hass; + if (!hass || !hass.states) return; + const nCells = cfg.cells || 16; + // HA 2026.6+ prefixes entity_ids with the device's area slug + // (e.g. "sensor.loft_givenergy_battery_..."). Resolve each canonical + // id to the actual (possibly area-prefixed) id once and cache it on + // the instance: `set hass` fires on every global state change, so a + // full Object.entries(hass.states) scan per render would be costly on + // large installs. A stale cache entry (entity_id changed) self-heals + // - its state lookup misses, so we fall through and re-scan. + this._cellIdCache = this._cellIdCache || {}; + const cellState = (s, n) => { + const canonical = `sensor.givenergy_battery_${s.toLowerCase()}_cell_${n}_voltage`; + const cached = this._cellIdCache[canonical]; + if (cached && hass.states[cached]) return hass.states[cached]; + if (hass.states[canonical]) { + this._cellIdCache[canonical] = canonical; + return hass.states[canonical]; + } + for (const eid of Object.keys(hass.states)) { + const i = eid.indexOf("givenergy_battery_"); + if (i > 0 && `sensor.${eid.slice(i)}` === canonical) { + this._cellIdCache[canonical] = eid; + return hass.states[eid]; + } + } + return null; + }; + // `set hass` fires on every HA state change; skip the DOM rebuild + // unless one of our cells (or the config) actually changed. + const sig = + (cfg.batteries || []) + .map((s) => { + const lo = s.toLowerCase(); + let cells = ""; + for (let n = 1; n <= nCells; n++) { + const st = cellState(lo, n); + cells += (st ? st.state : "?") + ","; + } + return lo + ":" + cells; + }) + .join("|") + + "#" + (cfg.title || "") + "/" + (cfg.span_mv != null ? cfg.span_mv : 15); + if (sig === this._sig) return; + this._sig = sig; + const esc = (s) => String(s).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + const span = (cfg.span_mv != null ? cfg.span_mv : 15) / 1000; + const valOf = (s, n) => { + const st = cellState(s, n); + const v = st ? Number(st.state) : NaN; + return Number.isFinite(v) ? v : null; + }; + const colour = (d) => { + if (d == null) return "var(--disabled-color, #9e9e9e)"; + const t = Math.max(-1, Math.min(1, d / span)); + const f = Math.round(255 * (1 - Math.abs(t) * 0.85)); + return t >= 0 ? `rgb(255,${f},${f})` : `rgb(${f},${f},255)`; + }; + const head = `#${Array.from({length: nCells}, (_, i) => `${i + 1}`).join("")}mΔ`; + const rows = cfg.batteries.map((s, bi) => { + const vals = Array.from({length: nCells}, (_, i) => valOf(s, i + 1)); + const present = vals.filter((v) => v != null); + const mean = present.length ? present.reduce((a, b) => a + b, 0) / present.length : null; + const dmv = present.length ? Math.round((Math.max(...present) - Math.min(...present)) * 1000) : null; + const cells = vals.map((v) => { + const d = (v != null && mean != null) ? v - mean : null; + const dv = d != null ? Math.round(d * 1000) : null; + const txt = dv != null ? (dv > 0 ? `+${dv}` : `${dv}`) : ""; + const title = v != null ? `${v.toFixed(3)} V` : "no data"; + return `${txt}`; + }).join(""); + const meanTxt = mean != null ? mean.toFixed(2) : "—"; + const dTxt = dmv != null ? `${dmv}` : "—"; + return `${bi + 1}${cells}${meanTxt}${dTxt}`; + }).join(""); + const packMap = cfg.batteries.map((s, bi) => `${bi + 1} = ${esc(s.toUpperCase())}`).join(" · "); + this.innerHTML = ` + + +
+ ${head}${rows}
+
Packs: ${packMap}
+ Colour = each cell's mV deviation from its own pack's mean (±${cfg.span_mv != null ? cfg.span_mv : 15} mV scale) — imbalance shows regardless of charge level:
+ below · + mean · + above.
+ m = pack mean cell voltage (V); Δ = spread (max−min) in mV.
+
+
`; + } + }); + } + + // Discoverability in the "Community dashboards" picker (HA 2026.5+). Harmless + // where unsupported. + try { + window.customStrategies = window.customStrategies || []; + window.customStrategies.push({ + type: "givenergy", + strategyType: "dashboard", + name: "GivEnergy", + description: "Registry-driven GivEnergy dashboard (classic mode).", + }); + } catch (e) { + /* non-fatal */ + } + } + + // Node (vitest): export the builders for unit testing. + if (typeof module !== "undefined" && module.exports) { + module.exports = API; + } +})(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2073be7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1458 @@ +{ + "name": "givenergy-hass-frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "givenergy-hass-frontend", + "devDependencies": { + "vitest": "^2.1.9" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..79ecc5d --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "givenergy-hass-frontend", + "private": true, + "description": "Frontend assets (cards + dashboard strategy) for the GivEnergy Local integration", + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^2.1.9" + } +} diff --git a/tests/js/ge-strategy.test.js b/tests/js/ge-strategy.test.js new file mode 100644 index 0000000..d7d3bbc --- /dev/null +++ b/tests/js/ge-strategy.test.js @@ -0,0 +1,259 @@ +// Unit tests for the GivEnergy dashboard strategy (classic mode). Mirrors the +// structural-parity discipline of tests/test_dashboard.py on the JS side, and +// guards the registry-resolution behaviour that fixes the dangling-ids bug. + +const path = require("path"); +const { makeHass } = require("./mock-hass"); + +// Require once, before any customElements stub exists, so the module's +// browser-registration branch is skipped and only the Node exports are taken. +const GE = require( + path.join(__dirname, "..", "..", "custom_components", "givenergy_local", "www", "ge-strategy.js") +); + +// --- helpers ---------------------------------------------------------------- + +async function withCards(names, fn) { + const reg = new Set(names); + global.customElements = { get: (n) => (reg.has(n) ? class {} : undefined) }; + try { + return await fn(); + } finally { + delete global.customElements; + } +} + +function collectRefs(node, out) { + out = out || []; + if (Array.isArray(node)) { + node.forEach((n) => collectRefs(n, out)); + } else if (node && typeof node === "object") { + for (const k of Object.keys(node)) { + const v = node[k]; + if ((k === "entity" || k === "state_of_charge") && typeof v === "string") out.push(v); + else collectRefs(v, out); + } + } + return out; +} + +function hasNullEntity(node) { + if (Array.isArray(node)) return node.some(hasNullEntity); + if (node && typeof node === "object") { + if ("entity" in node && node.entity === null) return true; + return Object.keys(node).some((k) => hasNullEntity(node[k])); + } + return false; +} + +async function regSet(hass) { + const ents = await hass.callWS({ type: "config/entity_registry/list" }); + return new Set(ents.filter((e) => e.platform === "givenergy_local").map((e) => e.entity_id)); +} + +const titles = (dash) => dash.views.map((v) => v.title); +const view = (dash, title) => dash.views.find((v) => v.title === title); +const card = (v, pred) => (v.cards || []).find(pred); +const byTitle = (t) => (c) => c.title === t; + +// --- tests ------------------------------------------------------------------ + +describe("classic dashboard structure", () => { + it("emits the six classic views for an inverter + battery plant", async () => { + const hass = makeHass({ batterySerials: ["BAT1", "BAT2"], acCoupled: true }); + const dash = await GE.generateDashboard({ mode: "classic" }, hass); + expect(titles(dash)).toEqual([ + "Overview", + "Energy", + "Batteries", + "Battery Health", + "Controls", + "Diagnostics", + ]); + }); + + it("omits Battery Health when there are no batteries", async () => { + const hass = makeHass({ batterySerials: [] }); + const dash = await GE.generateDashboard({}, hass); + expect(titles(dash)).toEqual(["Overview", "Energy", "Batteries", "Controls", "Diagnostics"]); + }); + + it("renders one Batteries section per pack", async () => { + const hass = makeHass({ batterySerials: ["BAT1", "BAT2", "BAT3"] }); + const dash = await GE.generateDashboard({}, hass); + expect(view(dash, "Batteries").sections.length).toBe(3); + }); +}); + +describe("registry resolution", () => { + it("resolves every entity from the registry and survives the loft_ area prefix", async () => { + const hass = makeHass({ batterySerials: ["BAT1"], acCoupled: true, areaPrefix: "loft_" }); + const dash = await GE.generateDashboard({}, hass); + const refs = collectRefs(dash); + const registry = await regSet(hass); + + expect(refs.length).toBeGreaterThan(50); + for (const r of refs) { + expect(registry.has(r)).toBe(true); // came from the registry, not constructed + expect(r).toContain("loft_"); // proves the current (area-prefixed) id was read + } + }); + + it("ignores entities from other integrations", async () => { + const hass = makeHass({ batterySerials: ["BAT1"] }); + const dash = await GE.generateDashboard({}, hass); + expect(collectRefs(dash)).not.toContain("sensor.kitchen_temperature"); + }); + + it("omits a missing entity gracefully instead of leaving a dangling row", async () => { + const hass = makeHass({ batterySerials: ["BAT1"], omitKeys: ["p_backup", "t_charger"] }); + const dash = await GE.generateDashboard({}, hass); + expect(hasNullEntity(dash)).toBe(false); + + const diag = view(dash, "Diagnostics"); + const names = (c) => (c.entities || []).map((e) => e.name); + expect(names(card(diag, byTitle("Electrical")))).not.toContain("Backup Power"); + expect(names(card(diag, byTitle("Temperatures")))).not.toContain("Charger"); + }); +}); + +describe("strategy options", () => { + it("honours max_power_kw on the Overview 24h chart", async () => { + await withCards(["power-flow-card-plus", "apexcharts-card"], async () => { + const hass = makeHass({ batterySerials: ["BAT1"] }); + const dash = await GE.generateDashboard({ max_power_kw: 7 }, hass); + const chart = card( + view(dash, "Overview"), + (c) => c.type === "custom:apexcharts-card" + ); + expect(chart.yaxis[0]).toEqual({ min: -7000, max: 7000 }); + }); + }); + + it("defaults the chart envelope to +/-10 kW", async () => { + await withCards(["apexcharts-card"], async () => { + const hass = makeHass({}); + const dash = await GE.generateDashboard({}, hass); + const chart = card(view(dash, "Overview"), (c) => c.type === "custom:apexcharts-card"); + expect(chart.yaxis[0]).toEqual({ min: -10000, max: 10000 }); + }); + }); + + it("selects the pinned serial among multiple plants", async () => { + const hass = makeHass({ inverterSerial: "INV123", extraInverterSerial: "INV999" }); + const dash = await GE.generateDashboard({ serial: "INV999" }, hass); + const refs = collectRefs(dash); + expect(refs.some((r) => r.includes("inv999"))).toBe(true); + expect(refs.some((r) => r.includes("inv123"))).toBe(false); + }); + + it("defaults to the first plant (sorted) when no serial is pinned", async () => { + const hass = makeHass({ inverterSerial: "INV123", extraInverterSerial: "INV999" }); + const dash = await GE.generateDashboard({}, hass); + const refs = collectRefs(dash); + expect(refs.some((r) => r.includes("inv123"))).toBe(true); + expect(refs.some((r) => r.includes("inv999"))).toBe(false); + }); +}); + +describe("feature detection", () => { + it("falls back to a markdown placeholder when power-flow / apexcharts are absent", async () => { + const hass = makeHass({ batterySerials: ["BAT1"] }); + const dash = await GE.generateDashboard({}, hass); // no customElements stub + const overview = view(dash, "Overview"); + expect(overview.cards[0].type).toBe("markdown"); + expect(overview.cards[0].content).toContain("power-flow-card-plus"); + }); + + it("uses the real cards when registered", async () => { + await withCards(["power-flow-card-plus", "apexcharts-card"], async () => { + const hass = makeHass({ batterySerials: ["BAT1"] }); + const dash = await GE.generateDashboard({}, hass); + const overview = view(dash, "Overview"); + expect(overview.cards[0].type).toBe("custom:power-flow-card-plus"); + expect(card(overview, (c) => c.type === "custom:apexcharts-card")).toBeTruthy(); + }); + }); + + it("shows AC-coupled and Smart Load cards only when those entities exist", async () => { + const plain = await GE.generateDashboard({}, makeHass({ smartLoad: false })); + const controls = view(plain, "Controls"); + expect(card(controls, byTitle("AC-Coupled"))).toBeUndefined(); + expect(card(controls, byTitle("Smart Load"))).toBeUndefined(); + + const full = await GE.generateDashboard({}, makeHass({ acCoupled: true, smartLoad: true })); + const controls2 = view(full, "Controls"); + expect(card(controls2, byTitle("AC-Coupled"))).toBeTruthy(); + expect(card(controls2, byTitle("Smart Load"))).toBeTruthy(); + }); +}); + +describe("EMS plant", () => { + it("emits the EMS view set and resolves the plant switch", async () => { + const hass = makeHass({ ems: true }); + const dash = await GE.generateDashboard({}, hass); + expect(titles(dash)).toEqual(["EMS Controls", "Diagnostics"]); + + const plant = card(view(dash, "EMS Controls"), byTitle("Plant")); + const refs = collectRefs(plant); + expect(refs.some((r) => r.endsWith("ems_plant_enable"))).toBe(true); + }); +}); + +describe("no GivEnergy plant", () => { + it("returns a friendly notice rather than throwing", async () => { + const empty = { + callWS: (msg) => + Promise.resolve(msg.type === "config/entity_registry/list" ? [] : []), + }; + const dash = await GE.generateDashboard({}, empty); + expect(dash.views[0].cards[0].type).toBe("markdown"); + expect(dash.views[0].cards[0].content).toContain("No GivEnergy plant"); + }); +}); + +describe("multi-plant safety", () => { + it("does not borrow another plant's batteries when the target has none", async () => { + // INV123 has no batteries; INV999 owns BAT9 via via_device. With no serial + // pin the target is INV123 (sorted first) - its battery match is genuinely + // empty and must stay empty rather than showing INV999's pack. + const hass = makeHass({ + inverterSerial: "INV123", + batterySerials: [], + extraInverterSerial: "INV999", + extraBatterySerials: ["BAT9"], + }); + const dash = await GE.generateDashboard({}, hass); + expect(titles(dash)).not.toContain("Battery Health"); + expect(collectRefs(dash).some((r) => r.includes("bat9"))).toBe(false); + }); + + it("shows the no-plant notice (naming the serial) for an unmatched pin", async () => { + // A typo'd / stale serial pin must NOT silently fall back to another plant, + // or the Maintenance buttons would target the wrong inverter. + const hass = makeHass({ inverterSerial: "INV123", extraInverterSerial: "INV999" }); + const dash = await GE.generateDashboard({ serial: "NOPE404" }, hass); + expect(dash.views[0].cards[0].type).toBe("markdown"); + expect(dash.views[0].cards[0].content).toContain("NOPE404"); + expect(collectRefs(dash).some((r) => r.includes("inv123") || r.includes("inv999"))).toBe(false); + }); + + it("still classifies an inverter when its marker entity is disabled", async () => { + // Disabling p_pv (the inverter marker) must not make the whole device + // vanish - classification uses the full key set, not just enabled entities. + const hass = makeHass({ batterySerials: ["BAT1"], disabledKeys: ["p_pv"] }); + const dash = await GE.generateDashboard({}, hass); + expect(titles(dash)).toContain("Overview"); // inverter did not disappear + // ...but the disabled entity itself is never rendered. + expect(collectRefs(dash).some((r) => r.endsWith("_p_pv"))).toBe(false); + }); +}); + +describe("registry read failure", () => { + it("returns a friendly notice instead of crashing the render", async () => { + const failing = { callWS: () => Promise.reject(new Error("disconnected")) }; + const dash = await GE.generateDashboard({}, failing); + expect(dash.views[0].cards[0].type).toBe("markdown"); + expect(dash.views[0].cards[0].content).toContain("Could not read the entity registry"); + }); +}); diff --git a/tests/js/mock-hass.js b/tests/js/mock-hass.js new file mode 100644 index 0000000..343e462 --- /dev/null +++ b/tests/js/mock-hass.js @@ -0,0 +1,219 @@ +// Synthetic `hass` for the dashboard-strategy tests. Its `callWS` answers the +// two registry list commands the strategy issues, built from a compact plant +// description so each test can vary topology (batteries, EMS, AC-coupled, +// smart-load) and inject an area prefix on entity_ids to prove resolution is by +// unique_id, not by a constructed entity_id string. + +const INVERTER_KEYS = [ + // status / glance / power-flow + "status", "battery_soc", "battery_pause_mode", "t_battery", "battery_out_of_spec", + "e_pv_day", "e_battery_charge_day", "e_battery_discharge_day", "e_grid_in_day", + "e_grid_out_day", "e_consumption_today", "p_pv", "p_battery", "grid_power", + "p_load_demand", + // energy totals + "e_pv_total", "e_battery_throughput", "e_battery_charge_total", + "e_battery_discharge_total", "e_grid_out_total", "e_grid_in_total", + "e_pv_generation_total", "e_inverter_in_total", "e_discharge_year", + "e_solar_diverter", + // controls + "battery_power_mode", "enable_rtc", "active_power_rate", "battery_calibration_stage", + "enable_charge", "charge_target_soc", "battery_soc_reserve", "battery_charge_limit", + "charge_slot_1_start", "charge_slot_1_end", "charge_slot_2_start", "charge_slot_2_end", + "enable_discharge", "battery_discharge_limit", "battery_discharge_min_power_reserve", + "discharge_slot_1_start", "discharge_slot_1_end", "discharge_slot_2_start", + "discharge_slot_2_end", + // diagnostics + "last_successful_refresh", "consecutive_failures", "partial_failures", "total_failures", + "fault_code", "inverter_fault_messages", "inverter_errors", "charger_warning_code", + "charge_status", "system_mode", "t_inverter_heatsink", "t_charger", "v_ac1", "f_ac1", + "v_ac1_output", "f_ac1_output", "i_ac1", "v_battery", "i_battery", "i_grid_port", + "v_p_bus", "v_n_bus", "p_grid_apparent", "pf_inverter_output_now", "p_grid_out_ph1", + "p_backup", "p_combined_generation", "v_pv1", "i_pv1", "p_pv1", "v_pv2", "i_pv2", + "p_pv2", "e_pv1_day", "e_pv2_day", "battery_maintenance_mode", "arm_firmware_version", + "dsp_firmware_version", "modbus_version", "work_time_total", "device_type_code", + "num_mppt", "num_phases", "battery_type", "meter_type", "usb_device_inserted", + "battery_capacity_kwh", "battery_capacity_ah", +]; + +const BATTERY_KEYS = (function () { + const keys = [ + "soc", "v_out", "t_max", "t_min", "t_bms_mosfet", "num_cycles", "cap_remaining", + "cap_calibrated", "cap_design", "v_cells_sum", "num_cells", "t_cells_01_04", + "t_cells_05_08", "t_cells_09_12", "t_cells_13_16", "bms_firmware_version", + "usb_device_inserted", "cap_design2", "warning_1", "warning_2", + ]; + for (let s = 1; s <= 7; s++) keys.push("status_" + s); + for (let c = 1; c <= 16; c++) keys.push("v_cell_" + (c < 10 ? "0" + c : "" + c)); + return keys; +})(); + +function emsKeys() { + const keys = ["ems_plant_enable"]; + ["charge", "discharge", "export"].forEach(function (kind) { + for (let i = 1; i <= 3; i++) { + keys.push("ems_" + kind + "_slot_" + i + "_start"); + keys.push("ems_" + kind + "_slot_" + i + "_end"); + keys.push("ems_" + kind + "_target_soc_" + i); + } + }); + // EMS controller also carries the coordinator health sensors + return keys.concat([ + "last_successful_refresh", "consecutive_failures", "partial_failures", "total_failures", + ]); +} + +const SMART_LOAD_KEYS = (function () { + const keys = []; + for (let i = 1; i <= 10; i++) { + keys.push("smart_load_slot_" + i + "_start"); + keys.push("smart_load_slot_" + i + "_end"); + } + return keys; +})(); + +const AC_COUPLED_KEYS = [ + "export_priority", "enable_eps", "battery_charge_limit_ac", "battery_discharge_limit_ac", +]; + +// entity_id marker: includes the (optional) area prefix so tests can assert the +// returned config used the registry's *current* id, not a reconstructed one. +function entityId(prefix, serial, key) { + return "sensor." + prefix + "ge_" + String(serial).toLowerCase() + "_" + key; +} + +function entitiesFor(deviceId, serial, keys, prefix, omit, disabled) { + return keys + .filter(function (key) { + return !(omit && omit.indexOf(key) !== -1); + }) + .map(function (key) { + return { + entity_id: entityId(prefix, serial, key), + platform: "givenergy_local", + device_id: deviceId, + unique_id: serial + "_" + key, + area_id: prefix ? "loft" : null, + disabled_by: disabled && disabled.indexOf(key) !== -1 ? "user" : null, + }; + }); +} + +// opts: { inverterSerial, batterySerials[], ems, acCoupled, smartLoad, areaPrefix, +// extraInverterSerial } +function makeHass(opts) { + opts = opts || {}; + const prefix = opts.areaPrefix || ""; + const invSerial = opts.inverterSerial || "INV123"; + const bats = opts.batterySerials || []; + const ems = !!opts.ems; + + const devices = []; + const entities = []; + + if (ems) { + devices.push({ + id: "dev_ems", + identifiers: [["givenergy_local", invSerial]], + name: "GivEnergy EMS " + invSerial, + via_device_id: null, + }); + entities.push.apply(entities, entitiesFor("dev_ems", invSerial, emsKeys(), prefix, opts.omitKeys)); + } else { + devices.push({ + id: "dev_inv", + identifiers: [["givenergy_local", invSerial]], + name: "GivEnergy Inverter " + invSerial, + via_device_id: null, + }); + let invKeys = INVERTER_KEYS.slice(); + if (opts.smartLoad !== false) invKeys = invKeys.concat(SMART_LOAD_KEYS); + if (opts.acCoupled) invKeys = invKeys.concat(AC_COUPLED_KEYS); + entities.push.apply( + entities, + entitiesFor("dev_inv", invSerial, invKeys, prefix, opts.omitKeys, opts.disabledKeys) + ); + + bats.forEach(function (serial, i) { + const id = "dev_bat" + (i + 1); + devices.push({ + id: id, + identifiers: [["givenergy_local", serial]], + name: "GivEnergy Battery " + serial, + via_device_id: "dev_inv", + }); + entities.push.apply( + entities, + entitiesFor(id, serial, BATTERY_KEYS, prefix, opts.omitKeys, opts.disabledKeys) + ); + }); + } + + // a second inverter plant, to exercise the serial pin / sole-plant selection + if (opts.extraInverterSerial) { + devices.push({ + id: "dev_inv2", + identifiers: [["givenergy_local", opts.extraInverterSerial]], + name: "GivEnergy Inverter " + opts.extraInverterSerial, + via_device_id: null, + }); + entities.push.apply( + entities, + entitiesFor("dev_inv2", opts.extraInverterSerial, INVERTER_KEYS, prefix, opts.omitKeys, opts.disabledKeys) + ); + + // batteries belonging to the SECOND plant (via_device -> dev_inv2), to prove + // the first plant never borrows them. + (opts.extraBatterySerials || []).forEach(function (serial, i) { + const id = "dev_inv2_bat" + (i + 1); + devices.push({ + id: id, + identifiers: [["givenergy_local", serial]], + name: "GivEnergy Battery " + serial, + via_device_id: "dev_inv2", + }); + entities.push.apply( + entities, + entitiesFor(id, serial, BATTERY_KEYS, prefix, opts.omitKeys, opts.disabledKeys) + ); + }); + } + + // a foreign-integration entity that must be ignored + entities.push({ + entity_id: "sensor.kitchen_temperature", + platform: "other_integration", + device_id: "dev_other", + unique_id: "OTHER_temp", + area_id: null, + }); + + return { + callWS: function (msg) { + if (msg.type === "config/entity_registry/list") return Promise.resolve(entities); + if (msg.type === "config/device_registry/list") return Promise.resolve(devices); + return Promise.reject(new Error("unexpected callWS: " + msg.type)); + }, + }; +} + +// haveCard() reads the global customElements; install/remove a stub that reports +// the given element names as registered. +function withCards(names, fn) { + const reg = new Set(names); + global.customElements = { get: (n) => (reg.has(n) ? class {} : undefined) }; + try { + return fn(); + } finally { + delete global.customElements; + } +} + +module.exports = { + makeHass, + withCards, + entityId, + INVERTER_KEYS, + BATTERY_KEYS, + SMART_LOAD_KEYS, + AC_COUPLED_KEYS, +}; diff --git a/tests/test_init.py b/tests/test_init.py index 48dad80..7b9b3ce 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -12,8 +12,8 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.givenergy_local import ( - _CARD_URL, - _CARD_VERSION, + _STRATEGY_URL, + _STRATEGY_VERSION, _missing_dashboard_cards, async_setup, ) @@ -65,8 +65,9 @@ async def test_missing_dashboard_cards_swallows_registry_errors(): assert await _missing_dashboard_cards(hass) == [] -async def test_frontend_card_served_and_autoloaded(): - """The bundled heatmap card is served + auto-loaded at component setup.""" +async def test_frontend_modules_served_and_autoloaded(): + """The bundled frontend module (strategy + heatmap card) is served + + auto-loaded at component setup.""" hass = MagicMock() hass.data = {} hass.http.async_register_static_paths = AsyncMock() @@ -78,9 +79,9 @@ async def test_frontend_card_served_and_autoloaded(): assert await async_setup(hass, {}) is True hass.http.async_register_static_paths.assert_awaited_once() - (paths,) = hass.http.async_register_static_paths.call_args[0] - assert paths[0].url_path == _CARD_URL - add_js.assert_called_once_with(hass, f"{_CARD_URL}?v={_CARD_VERSION}") + served_url = hass.http.async_register_static_paths.call_args.args[0][0].url_path + assert served_url == _STRATEGY_URL + add_js.assert_called_once_with(hass, f"{_STRATEGY_URL}?v={_STRATEGY_VERSION}") async def test_frontend_card_registered_once_across_multiple_entries(hass, mock_client): diff --git a/tests/test_strategy_manifest.py b/tests/test_strategy_manifest.py new file mode 100644 index 0000000..fefdcc5 --- /dev/null +++ b/tests/test_strategy_manifest.py @@ -0,0 +1,92 @@ +"""Parity guard: the JS dashboard strategy resolves entities by their integration +`key` (unique_id = ``{serial}_{key}``). This test pins every key the strategy +references to a real ``EntityDescription.key``, so renaming a key in a platform +file fails loudly here instead of silently dangling a card — the same discipline +``test_script_entity_refs.py`` applies to the Python YAML generator. + +Static ``a.inv("key")`` / ``a.bat(rec, "key")`` literals are scraped straight +from ``ge-strategy.js``; the handful of dynamically-built key families (cells, +BMS status, smart-load and EMS slots) are enumerated explicitly below. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from custom_components.givenergy_local import ( + binary_sensor, + number, + select, + sensor, + switch, +) +from custom_components.givenergy_local import ( + time as ge_time, +) + +_STRATEGY_JS = ( + Path(__file__).parent.parent + / "custom_components" + / "givenergy_local" + / "www" + / "ge-strategy.js" +) + +# Literal keys referenced as a.inv("...") or a.bat(rec, "..."). Dynamic refs +# (string concatenation) are intentionally not matched here — see _DYNAMIC_KEYS. +_LITERAL_REF = re.compile(r'\.(?:inv|bat)\((?:rec, )?"([a-z0-9_]+)"\)') + +# Key families the strategy builds at runtime via string concatenation. +_DYNAMIC_KEYS: set[str] = set() +for _c in range(1, 17): + _DYNAMIC_KEYS.add(f"v_cell_{_c:02d}") +for _s in range(1, 8): + _DYNAMIC_KEYS.add(f"status_{_s}") +for _i in range(1, 11): + _DYNAMIC_KEYS.add(f"smart_load_slot_{_i}_start") + _DYNAMIC_KEYS.add(f"smart_load_slot_{_i}_end") +for _kind in ("charge", "discharge", "export"): + for _i in range(1, 4): + _DYNAMIC_KEYS.add(f"ems_{_kind}_slot_{_i}_start") + _DYNAMIC_KEYS.add(f"ems_{_kind}_slot_{_i}_end") + _DYNAMIC_KEYS.add(f"ems_{_kind}_target_soc_{_i}") + + +# Some entities (e.g. the battery-out-of-spec binary sensor) build a hardcoded +# unique_id suffix rather than carrying an EntityDescription; pick those up too. +_HARDCODED_UID = re.compile(r'unique_id\s*=\s*f"\{serial\}_([a-z0-9_]+)"') + + +def _real_keys() -> set[str]: + """Every key the integration can register as a ``{serial}_{key}`` unique_id.""" + keys: set[str] = set() + for module in (sensor, binary_sensor, number, select, switch, ge_time): + for value in vars(module).values(): + if isinstance(value, tuple) and value and hasattr(value[0], "key"): + keys.update(d.key for d in value) + source = Path(module.__file__).read_text() + keys.update(_HARDCODED_UID.findall(source)) + return keys + + +def _strategy_keys() -> set[str]: + text = _STRATEGY_JS.read_text() + return set(_LITERAL_REF.findall(text)) | _DYNAMIC_KEYS + + +def test_strategy_keys_are_all_real_entity_keys() -> None: + real = _real_keys() + referenced = _strategy_keys() + unknown = sorted(k for k in referenced if k not in real) + assert not unknown, ( + "ge-strategy.js references keys with no matching EntityDescription.key " + f"(renamed or removed?): {unknown}" + ) + + +def test_strategy_literal_scrape_found_references() -> None: + # Guard against the regex silently matching nothing (e.g. if the accessor + # naming changes), which would make the parity test vacuously pass. + literals = set(_LITERAL_REF.findall(_STRATEGY_JS.read_text())) + assert len(literals) > 40, f"expected many literal key refs, found {len(literals)}" diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..88eaef8 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,9 @@ +// CommonJS config — the repo's frontend assets (ge-strategy.js) are plain +// classic scripts, not ES modules, so package.json has no "type": "module". +module.exports = { + test: { + globals: true, + environment: "node", + include: ["tests/js/**/*.test.js"], + }, +};