diff --git a/custom_components/givenergy_local/__init__.py b/custom_components/givenergy_local/__init__.py index 19f7772..b143f19 100644 --- a/custom_components/givenergy_local/__init__.py +++ b/custom_components/givenergy_local/__init__.py @@ -6,7 +6,9 @@ import voluptuous as vol from givenergy_modbus.client import commands from givenergy_modbus.model.plant import PlantCapabilities +from homeassistant.components.frontend import add_extra_js_url from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.http import StaticPathConfig from homeassistant.components.persistent_notification import ( async_create as async_create_notification, ) @@ -45,6 +47,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" + # Per-config-entry topology cache. PlantCapabilities is persisted as # `to_dict()` directly (no envelope) following HA Core's Store convention — # future shape changes go through `Store._async_migrate_func` on a subclass, @@ -149,7 +159,63 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def _async_register_frontend_card(hass: HomeAssistant) -> None: + """Serve and auto-load the bundled cell-heatmap card (once per instance). + + 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. Guarded so repeat config entries don't + re-register the static path (which raises on a duplicate). + """ + data = hass.data.setdefault(DOMAIN, {}) + if data.get("_frontend_registered"): + return + if hass.http is None: + # http isn't initialised (e.g. the test harness has no web server). In + # production it's a bootstrap dependency and always present, so this only + # skips where there is nothing to serve from anyway. + return + card_path = Path(__file__).parent / "www" / _CARD_FILENAME + await hass.http.async_register_static_paths( + [StaticPathConfig(_CARD_URL, str(card_path), False)] + ) + add_extra_js_url(hass, f"{_CARD_URL}?v={_CARD_VERSION}") + data["_frontend_registered"] = True + + +# External HACS cards the generated dashboard depends on (the bundled +# ge-cell-heatmap is served by us and needs no check). Keep in sync with the +# custom: cards emitted in dashboard.py. +_REQUIRED_HACS_CARDS = ("apexcharts-card", "power-flow-card-plus") + + +async def _missing_dashboard_cards(hass: HomeAssistant) -> list[str]: + """Best-effort list of required HACS cards with no registered Lovelace resource. + + Returns [] when all are present *or* when the resource registry can't be + read — we warn only on a confident miss, never cry wolf. Only storage-mode + resources are enumerable; YAML-mode users register resources in + configuration.yaml and won't appear here, so the warning is advisory. + """ + try: + resources = getattr(hass.data.get("lovelace"), "resources", None) + if resources is None: + return [] + items = resources.async_items() + if not items and hasattr(resources, "async_load"): + await resources.async_load() + items = resources.async_items() + urls = " ".join(str(item.get("url", "")) for item in items) + except Exception as exc: # noqa: BLE001 - advisory check must never break generation + _LOGGER.debug("Could not read Lovelace resources for pre-flight check: %s", exc) + return [] + return [card for card in _REQUIRED_HACS_CARDS if card not in urls] + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + await _async_register_frontend_card(hass) + # Persisted topology lets the coordinator skip the cold-detect sweep on # most reconnects/restarts. Client.detect(prior=...) accepts the cached # topology as a hint and only re-probes slots the prior asserts non-empty; @@ -252,6 +318,16 @@ async def handle_generate_dashboard(call: ServiceCall) -> None: from .dashboard import generate_dashboard max_power_kw = call.data["max_power_kw"] + missing = await _missing_dashboard_cards(hass) + warning = "" + if missing: + warning = ( + "\n\n**Note:** these cards the dashboard needs don't appear to be " + "installed — affected cards will show \"Custom element doesn't " + 'exist" until you add them via **HACS → Frontend**:\n' + + "\n".join(f"- `{card}`" for card in missing) + + "\n\n(If you register Lovelace resources via YAML, ignore this.)" + ) for coordinator in hass.data.get(DOMAIN, {}).values(): if coordinator.data is None: continue @@ -272,7 +348,7 @@ 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." + "and paste the contents into the raw config editor." + warning ), "notification_id": f"givenergy_dashboard_{inv}", }, diff --git a/custom_components/givenergy_local/dashboard.py b/custom_components/givenergy_local/dashboard.py index 1e2343b..eba4f40 100644 --- a/custom_components/givenergy_local/dashboard.py +++ b/custom_components/givenergy_local/dashboard.py @@ -2,10 +2,79 @@ from __future__ import annotations +import textwrap + +import yaml + # Increment whenever the generated YAML layout changes in a meaningful way. # __init__.py compares this against the last-generated version stored in HA's # persistent Store and raises a Repairs issue when they diverge. -DASHBOARD_VERSION = 3 +DASHBOARD_VERSION = 4 + + +class _NoAliasDumper(yaml.SafeDumper): + """Expand repeated nodes inline rather than emitting YAML anchors/aliases. + + The Battery Health view reuses the same colour and JS-filter strings across + dozens of series; without this, PyYAML would replace the repeats with ``*id`` + references — valid YAML, but unreadable in a file users may inspect or edit. + """ + + def ignore_aliases(self, data: object) -> bool: + return True + + +# Battery Health palettes. Pack-identity colours (voltage + SoC traces) avoid +# the red/amber of the danger bands and the teal/purple of the temperature +# traces; the temperature palette is parallel (one hue per pack) so a pack's +# temperature reads as a distinct metric from its voltage. Indexed modulo length +# so 7+ packs cycle rather than crash. +_PACK_COLOURS = ("#1e88e5", "#fb8c00", "#43a047", "#6d4c41", "#3949ab", "#c0ca33") +_TEMP_COLOURS = ("#00897b", "#8e24aa", "#d81b60", "#00838f", "#7cb342", "#5e35b1") + +# Reject physically-impossible single-sample reads (dongle garbage, see +# givenergy-modbus#78) so a stray 0 V / 6.5 V / 0 °C spike becomes a gap instead +# of wrecking the fixed y-scale. A debounced alert is the real fix; this is the +# chart-readability stopgap until the library filters them upstream. +# parseFloat (not Number) is deliberate: Number(null) and Number('') are 0, +# which is *inside* the temp/power ranges, so a blank/unknown state would plot a +# spurious 0; parseFloat yields NaN for those and falls through to a gap. +_VOLT_FILTER = "const v = parseFloat(x); return (!isNaN(v) && v > 2.0 && v < 4.0) ? v : null;" +_TEMP_FILTER = "const v = parseFloat(x); return (!isNaN(v) && v > -40 && v < 100) ? v : null;" +_POWER_FILTER = "const v = parseFloat(x); return (!isNaN(v) && v > -20000 && v < 20000) ? v : null;" + +# The BMS samples one thermistor per 4-cell group. +_TEMP_GROUPS = ((1, 4), (5, 8), (9, 12), (13, 16)) + + +def _health_annotations() -> list[dict]: + """LFP warn bands, shared by the voltage (left) and temperature (right) axes. + + The right axis is scaled so 3.00 V <-> 10 °C and 3.50 V <-> 45 °C coincide, + so the single amber band is meaningful for both metrics at once. + """ + amber = "#f9a825" + + def warn_line(y: float, text: str) -> dict: + 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}, + warn_line(3.5, "warn · 3.50 V / 45 °C"), + warn_line(3.0, "warn · 3.00 V / 10 °C"), + ] def generate_dashboard(inv: str, bats: list[str], max_power_kw: int = 10) -> str: @@ -23,6 +92,9 @@ def generate_dashboard(inv: str, bats: list[str], max_power_kw: int = 10) -> str _overview_view(inv, max_power_kw), _energy_view(inv), _battery_view(bats), + # Skip the cross-pack health view on inverter-only installs — the + # heatmap card rejects an empty battery list. + *([_battery_health_view(inv, bats)] if bats else []), _controls_view(inv), _diagnostics_view(inv, max_power_kw), ] @@ -36,8 +108,9 @@ def generate_dashboard(inv: str, bats: list[str], max_power_kw: int = 10) -> str f"#\n" f"# Required custom cards (install via HACS → Frontend):\n" f"# - power-flow-card-plus (flixlix/power-flow-card-plus)\n" - f"# - mini-graph-card (kalkih/mini-graph-card)\n" f"# - apexcharts-card (RomRider/apexcharts-card)\n" + f"# The Battery Health view's cell-balance heatmap (custom:ge-cell-heatmap) is\n" + f"# bundled with this integration and served automatically — no install needed.\n" f"\n" f"title: GivEnergy\n" f"views:\n" @@ -228,7 +301,6 @@ def _battery_section(serial: str) -> str: ("cell_count", "Cell Count"), ], ) - cells = _bat_entity_rows(serial, [(f"cell_{i}_voltage", f"Cell {i}") for i in range(1, 17)]) temps = _bat_entity_rows( serial, [ @@ -271,11 +343,6 @@ def _battery_section(serial: str) -> str: entities: {pack} - - type: entities - title: Cell Voltages - entities: -{cells} - - type: entities title: Cell Temperatures entities: @@ -288,6 +355,169 @@ def _battery_section(serial: str) -> str: """ +def _battery_health_view(inv: str, bats: list[str]) -> str: + """Plant-level cell diagnostics: balance heatmap + cell/temp & power/SoC charts. + + Cross-pack by nature (the heatmap and line charts span every pack), so this + is its own ``sections`` view rather than per-battery sections. It depends on + the bundled ``custom:ge-cell-heatmap`` card (served automatically by this + integration) plus ``apexcharts-card``. Each card is given full width via + ``grid_options`` since the time-series read best wide. + + Built as Python dicts and serialised with PyYAML — the apexcharts cards carry + 40+ series with quoting-sensitive JS transforms, which hand-written YAML + f-strings would mangle. + """ + volt_series: list[dict] = [] + temp_series: list[dict] = [] + soc_series: list[dict] = [] + for index, serial in enumerate(bats): + # Pack ordinal (B1, B2, ...) keeps labels unique and matches the heatmap's + # 1..N pack numbering; serials are too long for tooltips and collide when + # packs share a model prefix. + tag = f"B{index + 1}" + pack_colour = _PACK_COLOURS[index % len(_PACK_COLOURS)] + temp_colour = _TEMP_COLOURS[index % len(_TEMP_COLOURS)] + for cell in range(1, 17): + volt_series.append( + { + "entity": f"sensor.givenergy_battery_{serial}_cell_{cell}_voltage", + "name": f"{tag} {cell}", + "color": pack_colour, + "stroke_width": 1, + "yaxis_id": "v", + "transform": _VOLT_FILTER, + } + ) + for lo, hi in _TEMP_GROUPS: + temp_series.append( + { + "entity": f"sensor.givenergy_battery_{serial}_cells_{lo}_{hi}_temperature", + "name": f"{tag} T{lo}-{hi}", + "color": temp_colour, + "stroke_width": 1, + "yaxis_id": "temp", + "transform": _TEMP_FILTER, + } + ) + soc_series.append( + { + "entity": f"sensor.givenergy_battery_{serial}_soc", + "name": f"{tag} SoC", + "color": pack_colour, + "stroke_width": 1, + "yaxis_id": "soc", + } + ) + + 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." + ), + } + heatmap = { + "type": "custom:ge-cell-heatmap", + "title": "Cell balance — deviation from pack mean", + "batteries": list(bats), + } + cell_chart = { + "type": "custom:apexcharts-card", + "header": { + "show": True, + "title": ( + "Cell voltages (left, V) + cell-group temps (right, °C — 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": _health_annotations()}, + "chart": {"height": 330}, + }, + "series": volt_series + temp_series, + } + power_chart = { + "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": [ + { + "entity": f"sensor.givenergy_inverter_{inv}_battery_power", + "name": "Battery power", + "color": "#8e24aa", + "stroke_width": 1, + "transform": _POWER_FILTER, + "yaxis_id": "w", + "group_by": {"duration": "2m", "func": "avg"}, + }, + *soc_series, + ], + "yaxis": [ + {"id": "w", "decimals": 0}, + {"id": "soc", "opposite": True, "min": 0, "max": 100, "decimals": 0}, + ], + } + + cards: list[dict] = [note, heatmap, cell_chart, power_chart] + for card in cards: + card["grid_options"] = {"columns": "full"} + + view = { + "title": "Battery Health", + "path": "battery-health", + "type": "sections", + "icon": "mdi:heart-pulse", + "sections": [{"type": "grid", "cards": cards}], + } + + body = yaml.dump( + [view], + Dumper=_NoAliasDumper, + sort_keys=False, + allow_unicode=True, + default_flow_style=False, + indent=2, + width=10**9, + ) + header = " # ── Battery Health ──────────────────────────────────────────────────────────\n" + return header + textwrap.indent(body, " ").rstrip("\n") + + def _controls_view(inv: str) -> str: return f"""\ # ── Controls ────────────────────────────────────────────────────────────── diff --git a/custom_components/givenergy_local/manifest.json b/custom_components/givenergy_local/manifest.json index 883d27a..6727696 100644 --- a/custom_components/givenergy_local/manifest.json +++ b/custom_components/givenergy_local/manifest.json @@ -1,6 +1,10 @@ { "domain": "givenergy_local", "name": "GivEnergy Local", + "after_dependencies": [ + "frontend", + "http" + ], "codeowners": [ "@dewet22" ], diff --git a/custom_components/givenergy_local/www/ge-cell-heatmap.js b/custom_components/givenergy_local/www/ge-cell-heatmap.js new file mode 100644 index 0000000..e1d7f95 --- /dev/null +++ b/custom_components/givenergy_local/www/ge-cell-heatmap.js @@ -0,0 +1,106 @@ +// 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/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..c825178 --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,124 @@ +"""Tests for the generated dashboard YAML, focused on the Battery Health view.""" + +import yaml + +from custom_components.givenergy_local.dashboard import ( + DASHBOARD_VERSION, + generate_dashboard, +) + +INV = "sa2114g047" +BATS = ["bg2134g007", "dz2228g532"] + + +def _views(inv: str = INV, bats: list[str] | None = None) -> dict[str, dict]: + """Generate, parse, and index the dashboard's views by title.""" + out = generate_dashboard(inv, bats if bats is not None else BATS) + doc = yaml.safe_load(out) # also asserts the YAML is well-formed + return {v["title"]: v for v in doc["views"]} + + +def _health_cards(bats: list[str] | None = None) -> list[dict]: + view = _views(bats=bats)["Battery Health"] + return view["sections"][0]["cards"] + + +def test_dashboard_is_valid_yaml_with_expected_views(): + views = _views() + assert "Battery Health" in views + # Battery Health sits between Batteries and Controls. + titles = list(views) + assert titles.index("Battery Health") == titles.index("Batteries") + 1 + + +def test_dashboard_version_is_current(): + assert DASHBOARD_VERSION == 4 + + +def test_battery_health_is_full_width_sections(): + view = _views()["Battery Health"] + assert view["type"] == "sections" + cards = view["sections"][0]["cards"] + assert [c["type"] for c in cards] == [ + "markdown", + "custom:ge-cell-heatmap", + "custom:apexcharts-card", + "custom:apexcharts-card", + ] + assert all(c["grid_options"] == {"columns": "full"} for c in cards) + + +def test_heatmap_lists_all_battery_serials(): + heatmap = _health_cards()[1] + assert heatmap["batteries"] == BATS + + +def test_series_scale_with_battery_count(): + for bats in (["bg2134g007"], BATS, ["a1", "b2", "c3"]): + cards = _health_cards(bats=bats) + cell_chart, power_chart = cards[2], cards[3] + n = len(bats) + volt = [s for s in cell_chart["series"] if s["yaxis_id"] == "v"] + temp = [s for s in cell_chart["series"] if s["yaxis_id"] == "temp"] + assert len(volt) == 16 * n + assert len(temp) == 4 * n + # power series is 1 inverter power + one SoC per pack + assert len(power_chart["series"]) == 1 + n + + +def test_health_series_reference_correct_entities(): + cards = _health_cards() + cell_chart, power_chart = cards[2], cards[3] + entities = {s["entity"] for s in cell_chart["series"]} + assert "sensor.givenergy_battery_bg2134g007_cell_1_voltage" in entities + assert "sensor.givenergy_battery_dz2228g532_cells_13_16_temperature" in entities + power_entities = {s["entity"] for s in power_chart["series"]} + assert f"sensor.givenergy_inverter_{INV}_battery_power" in power_entities + assert "sensor.givenergy_battery_dz2228g532_soc" in power_entities + + +def test_packs_get_distinct_colours(): + cards = _health_cards() + volt = [s for s in cards[2]["series"] if s["yaxis_id"] == "v"] + bg_colour = next(s["color"] for s in volt if s["entity"].count("bg2134g007")) + dz_colour = next(s["color"] for s in volt if s["entity"].count("dz2228g532")) + assert bg_colour != dz_colour + + +def test_same_model_packs_get_distinct_series_labels(): + # two packs sharing a model prefix ("BG") must not collide in hover labels + cards = _health_cards(bats=["bg1111a001", "bg2222a002"]) + names = [s["name"] for s in cards[2]["series"] if s["yaxis_id"] == "v"] + assert len(names) == len(set(names)) # all 32 labels unique across packs + + +def test_chart_filters_reject_blank_states(): + # Number(null)/Number("") are 0 in JS — for the temp/power ranges 0 is valid, + # so the filter must reject blank/null inputs explicitly (else spurious zeros). + from custom_components.givenergy_local.dashboard import ( + _POWER_FILTER, + _TEMP_FILTER, + _VOLT_FILTER, + ) + + for flt in (_VOLT_FILTER, _TEMP_FILTER, _POWER_FILTER): + assert "parseFloat" in flt and "isNaN" in flt + + +def test_no_battery_health_view_for_inverter_only(): + # No batteries (inverter-only install): skip the view rather than emit a + # heatmap with batteries: [] (which the card's setConfig rejects). + views = _views(bats=[]) + assert "Battery Health" not in views + # the rest of the dashboard still generates fine + assert "Overview" in views + assert "Batteries" in views + + +def test_cell_voltages_list_removed_from_batteries_view(): + view = _views()["Batteries"] + titles = [c.get("title") for s in view["sections"] for c in s["cards"]] + assert "Cell Voltages" not in titles + # the per-pack detail we keep is still there + assert "Cell Temperatures" in titles + assert "Pack Details" in titles diff --git a/tests/test_init.py b/tests/test_init.py index 05541ea..3d4ae17 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,6 +1,6 @@ """Tests for integration setup, unload, and config-entry migration.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from givenergy_modbus.exceptions import PlantTopologyMismatch from givenergy_modbus.model.inverter import Model @@ -11,6 +11,12 @@ from homeassistant.helpers import issue_registry as ir from pytest_homeassistant_custom_component.common import MockConfigEntry +from custom_components.givenergy_local import ( + _CARD_URL, + _CARD_VERSION, + _async_register_frontend_card, + _missing_dashboard_cards, +) from custom_components.givenergy_local.const import ( CONF_RETRIES, CONF_TIMEOUT_TOLERANCE, @@ -21,6 +27,71 @@ ) +def _hass_with_resources(urls: list[str]) -> MagicMock: + hass = MagicMock() + resources = MagicMock() + resources.async_items = MagicMock(return_value=[{"url": u} for u in urls]) + hass.data = {"lovelace": MagicMock(resources=resources)} + return hass + + +async def test_missing_dashboard_cards_flags_absent_only(): + hass = _hass_with_resources(["/hacsfiles/apexcharts-card/apexcharts-card.js"]) + assert await _missing_dashboard_cards(hass) == ["power-flow-card-plus"] + + +async def test_missing_dashboard_cards_empty_when_all_present(): + hass = _hass_with_resources( + [ + "/hacsfiles/apexcharts-card/apexcharts-card.js", + "/hacsfiles/power-flow-card-plus/power-flow-card-plus.js", + ] + ) + assert await _missing_dashboard_cards(hass) == [] + + +async def test_missing_dashboard_cards_silent_when_registry_absent(): + hass = MagicMock() + hass.data = {} # no lovelace data at all + assert await _missing_dashboard_cards(hass) == [] + + +async def test_missing_dashboard_cards_swallows_registry_errors(): + hass = MagicMock() + resources = MagicMock() + resources.async_items = MagicMock(side_effect=RuntimeError("boom")) + hass.data = {"lovelace": MagicMock(resources=resources)} + assert await _missing_dashboard_cards(hass) == [] + + +async def test_frontend_card_served_and_autoloaded_once(): + """The bundled heatmap card is served + auto-loaded, exactly once.""" + hass = MagicMock() + hass.data = {} + hass.http.async_register_static_paths = AsyncMock() + + with patch("custom_components.givenergy_local.add_extra_js_url") as add_js: + await _async_register_frontend_card(hass) + await _async_register_frontend_card(hass) # idempotent: must no-op + + 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}") + + +async def test_frontend_card_skipped_when_http_unavailable(): + """No http server (minimal env / tests) -> skip cleanly, never raise.""" + hass = MagicMock() + hass.data = {} + hass.http = None + + with patch("custom_components.givenergy_local.add_extra_js_url") as add_js: + await _async_register_frontend_card(hass) + + add_js.assert_not_called() + + async def test_migrate_v1_entry_strips_retries_and_tolerance(hass, mock_client): """A pre-v2 entry that stored retries/tolerance has those fields dropped on migration; the version bumps to 2; setup proceeds normally."""