From 2a375f71306bdeceb023b9ea004be7cf7115e9e8 Mon Sep 17 00:00:00 2001 From: Dewet Diener Date: Thu, 4 Jun 2026 07:12:01 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20dashboard=20PR=20A=20=E2=80=94=20Sm?= =?UTF-8?q?art=20Load,=20AC-Coupled,=20mode=20controls,=20battery=20totals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes high-priority gaps between exposed entities and the generated dashboard: - Smart Load slots 1–10 on Controls (`has_smart_load` kwarg; defaults open until givenergy-modbus exposes a capability we can gate on per #181 / 2.1.3 — wired through with a TODO). - AC-Coupled card on Controls (export priority, EPS, AC charge/discharge limits) gated by `has_ac_config_block` from PlantCapabilities. - Mode card additions: Real Time Control, Inverter Max Output Power. - Energy → All-Time Totals: Battery Charged / Battery Discharged. - Bumps DASHBOARD_VERSION to 8 so the Repairs prompt fires on existing installs. Refs #52, #76, #83, #89, #90, #106. --- custom_components/givenergy_local/__init__.py | 15 ++- .../givenergy_local/dashboard.py | 102 +++++++++++++++--- dashboard/template.yaml | 43 ++++++++ tests/test_dashboard.py | 65 ++++++++++- 4 files changed, 209 insertions(+), 16 deletions(-) diff --git a/custom_components/givenergy_local/__init__.py b/custom_components/givenergy_local/__init__.py index 1013747..2b1f6f6 100644 --- a/custom_components/givenergy_local/__init__.py +++ b/custom_components/givenergy_local/__init__.py @@ -565,7 +565,20 @@ async def handle_generate_dashboard(call: ServiceCall) -> None: inv = coordinator.data.inverter.serial_number.lower() bats = [b.serial_number.lower() for b in coordinator.data.batteries] is_ems = coordinator.data.ems is not None - yaml = generate_dashboard(inv, bats, max_power_kw=max_power_kw, is_ems=is_ems) + caps = coordinator.data.capabilities + has_ac_config_block = bool(caps and caps.has_ac_config_block) + # TODO: source from `caps.has_smart_load` once givenergy-modbus + # exposes the capability (#181, targeted at 2.1.3). Until then + # always emit; rows render as unavailable on non-Smart-Load installs. + has_smart_load = not is_ems + yaml = generate_dashboard( + inv, + bats, + max_power_kw=max_power_kw, + is_ems=is_ems, + has_ac_config_block=has_ac_config_block, + has_smart_load=has_smart_load, + ) filename = f"dashboard_givenergy_{inv}.yaml" www_dir = Path(hass.config.path("www")) await hass.async_add_executor_job(lambda d=www_dir: d.mkdir(exist_ok=True)) diff --git a/custom_components/givenergy_local/dashboard.py b/custom_components/givenergy_local/dashboard.py index c68bb1c..8139cde 100644 --- a/custom_components/givenergy_local/dashboard.py +++ b/custom_components/givenergy_local/dashboard.py @@ -9,7 +9,7 @@ # 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 = 7 +DASHBOARD_VERSION = 8 class _NoAliasDumper(yaml.SafeDumper): @@ -78,20 +78,35 @@ def warn_line(y: float, text: str) -> dict: def generate_dashboard( - inv: str, bats: list[str], max_power_kw: int = 10, *, is_ems: bool = False + inv: str, + bats: list[str], + max_power_kw: int = 10, + *, + is_ems: bool = False, + has_ac_config_block: bool = False, + has_smart_load: bool = True, ) -> str: """Return a complete Lovelace dashboard YAML string. Args: - inv: Inverter (or EMS controller) serial number (lowercase). - bats: Battery serial number(s) (lowercase). - max_power_kw: Vertical envelope (±kW) applied to the Overview power chart's - y-axis. Defaults to 10 kW — suitable for single-phase hybrid - and AC-coupled inverters. Raise for larger 3-phase systems. - is_ems: True when generating for an EMS plant controller. An EMS has no - PV/battery/grid/load sensors or inverter controls, so the - inverter-centric views would render blank — instead emit a - tailored view set (EMS scheduling controls + integration health). + inv: Inverter (or EMS controller) serial number (lowercase). + bats: Battery serial number(s) (lowercase). + max_power_kw: Vertical envelope (±kW) applied to the Overview power chart's + y-axis. Defaults to 10 kW — suitable for single-phase hybrid + and AC-coupled inverters. Raise for larger 3-phase systems. + is_ems: True when generating for an EMS plant controller. An EMS has no + PV/battery/grid/load sensors or inverter controls, so the + inverter-centric views would render blank — instead emit a + tailored view set (EMS scheduling controls + integration health). + has_ac_config_block: True for AC-coupled / All-in-One plants that expose the + HR(300–359) AC-output config block (export priority, EPS, + AC charge/discharge limits). Surfaces the AC-Coupled + controls card; hidden on hybrids that don't carry the block. + has_smart_load: True when Smart Load slot scheduling is available. Defaults + to True so the section appears on inverter installs by + default; once givenergy-modbus exposes a `has_smart_load` + capability (#181, targeted at 2.1.3) the caller in + __init__.py should source the value from there. """ if is_ems: views = "\n".join([_ems_controls_view(inv), _ems_diagnostics_view(inv)]) @@ -104,7 +119,11 @@ def generate_dashboard( # 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), + _controls_view( + inv, + has_ac_config_block=has_ac_config_block, + has_smart_load=has_smart_load, + ), _diagnostics_view(inv, max_power_kw), ] ) @@ -280,6 +299,10 @@ def col_series(entity: str, name: str, color: str) -> str: name: PV Generated - entity: {_i(inv, "battery_throughput_total")} name: Battery Throughput + - entity: {_i(inv, "battery_charge_total")} + name: Battery Charged + - entity: {_i(inv, "battery_discharge_total")} + name: Battery Discharged - entity: {_i(inv, "grid_export_total")} name: Grid Exported - entity: {_i(inv, "grid_import_total")} @@ -541,7 +564,14 @@ def _battery_health_view(inv: str, bats: list[str]) -> str: return header + textwrap.indent(body, " ").rstrip("\n") -def _controls_view(inv: str) -> str: +def _controls_view( + inv: str, + *, + has_ac_config_block: bool = False, + has_smart_load: bool = True, +) -> str: + ac_coupled_card = _ac_coupled_card(inv) if has_ac_config_block else "" + smart_load_card = _smart_load_card(inv) if has_smart_load else "" return f"""\ # ── Controls ────────────────────────────────────────────────────────────── - title: Controls @@ -556,6 +586,10 @@ def _controls_view(inv: str) -> str: name: Battery Power Mode - entity: select.givenergy_inverter_{inv}_battery_pause_mode name: Pause Mode + - entity: switch.givenergy_inverter_{inv}_real_time_control + name: Real Time Control + - entity: number.givenergy_inverter_{inv}_inverter_max_output_active_power + name: Inverter Max Output Power - entity: {_i(inv, "battery_calibration_stage")} name: Calibration Stage @@ -598,7 +632,7 @@ def _controls_view(inv: str) -> str: name: Slot 2 Start - entity: time.givenergy_inverter_{inv}_discharge_slot_2_end name: Slot 2 End - +{ac_coupled_card}{smart_load_card} - type: entities title: Maintenance entities: @@ -625,6 +659,46 @@ def _controls_view(inv: str) -> str: """ +def _ac_coupled_card(inv: str) -> str: + """AC-coupled / All-in-One controls. Only emitted when the plant carries + `capabilities.has_ac_config_block` — hybrids without HR(300–359) skip this.""" + return f""" + - type: entities + title: AC-Coupled + entities: + - entity: select.givenergy_inverter_{inv}_export_priority + name: Export Priority + - entity: switch.givenergy_inverter_{inv}_emergency_power_supply_eps + name: EPS Enable + - entity: number.givenergy_inverter_{inv}_battery_ac_charge_limit + name: AC Charge Limit + - entity: number.givenergy_inverter_{inv}_battery_ac_discharge_limit + name: AC Discharge Limit +""" + + +def _smart_load_card(inv: str) -> str: + """Smart Load slot scheduling (HR 554–573). Currently always emitted on + inverter installs; rows render as 'unavailable' on plants without Smart + Load hardware until givenergy-modbus exposes a capability we can gate on + (modbus #181, targeted at 2.1.3).""" + lines: list[str] = [ + " - type: entities", + " title: Smart Load", + " entities:", + ] + for idx in range(1, 11): + if idx > 1: + lines.append(" - type: divider") + lines.append( + f" - entity: time.givenergy_inverter_{inv}_smart_load_slot_{idx}_start" + ) + lines.append(f" name: Slot {idx} Start") + lines.append(f" - entity: time.givenergy_inverter_{inv}_smart_load_slot_{idx}_end") + lines.append(f" name: Slot {idx} End") + return "\n" + "\n".join(lines) + "\n" + + def _integration_health_card(inv: str, kind: str = "inverter") -> str: """The coordinator-level poll-health card — applies to any plant (incl. EMS). diff --git a/dashboard/template.yaml b/dashboard/template.yaml index d519f59..37b1d76 100644 --- a/dashboard/template.yaml +++ b/dashboard/template.yaml @@ -163,6 +163,10 @@ views: name: PV Generated - entity: sensor.givenergy_inverter_INVERTER_SERIAL_battery_throughput_total name: Battery Throughput + - entity: sensor.givenergy_inverter_INVERTER_SERIAL_battery_charge_total + name: Battery Charged + - entity: sensor.givenergy_inverter_INVERTER_SERIAL_battery_discharge_total + name: Battery Discharged - entity: sensor.givenergy_inverter_INVERTER_SERIAL_grid_export_total name: Grid Exported - entity: sensor.givenergy_inverter_INVERTER_SERIAL_grid_import_total @@ -373,6 +377,10 @@ views: name: Battery Power Mode - entity: select.givenergy_inverter_INVERTER_SERIAL_battery_pause_mode name: Pause Mode + - entity: switch.givenergy_inverter_INVERTER_SERIAL_real_time_control + name: Real Time Control + - entity: number.givenergy_inverter_INVERTER_SERIAL_inverter_max_output_active_power + name: Inverter Max Output Power - entity: sensor.givenergy_inverter_INVERTER_SERIAL_battery_calibration_stage name: Calibration Stage @@ -416,6 +424,41 @@ views: - entity: time.givenergy_inverter_INVERTER_SERIAL_discharge_slot_2_end name: Slot 2 End + # AC-Coupled / All-in-One only — remove this card on hybrids without the + # HR(300–359) AC-output config block. + - type: entities + title: AC-Coupled + entities: + - entity: select.givenergy_inverter_INVERTER_SERIAL_export_priority + name: Export Priority + - entity: switch.givenergy_inverter_INVERTER_SERIAL_emergency_power_supply_eps + name: EPS Enable + - entity: number.givenergy_inverter_INVERTER_SERIAL_battery_ac_charge_limit + name: AC Charge Limit + - entity: number.givenergy_inverter_INVERTER_SERIAL_battery_ac_discharge_limit + name: AC Discharge Limit + + # Smart Load slot scheduling (HR 554–573). Rows render as 'unavailable' on + # plants without Smart Load hardware — remove this card if not relevant. + - type: entities + title: Smart Load + entities: + - entity: time.givenergy_inverter_INVERTER_SERIAL_smart_load_slot_1_start + name: Slot 1 Start + - entity: time.givenergy_inverter_INVERTER_SERIAL_smart_load_slot_1_end + name: Slot 1 End + - type: divider + - entity: time.givenergy_inverter_INVERTER_SERIAL_smart_load_slot_2_start + name: Slot 2 Start + - entity: time.givenergy_inverter_INVERTER_SERIAL_smart_load_slot_2_end + name: Slot 2 End + - type: divider + - entity: time.givenergy_inverter_INVERTER_SERIAL_smart_load_slot_3_start + name: Slot 3 Start + - entity: time.givenergy_inverter_INVERTER_SERIAL_smart_load_slot_3_end + name: Slot 3 End + # Slots 4–10 follow the same pattern; duplicate as needed. + - type: entities title: Maintenance entities: diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 7b28fd3..c294b2c 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -32,7 +32,7 @@ def test_dashboard_is_valid_yaml_with_expected_views(): def test_dashboard_version_is_current(): - assert DASHBOARD_VERSION == 7 + assert DASHBOARD_VERSION == 8 def test_battery_health_is_full_width_sections(): @@ -178,6 +178,69 @@ def test_ems_dashboard_covers_all_slot_kinds_and_indices(): assert f"ems_{kind}_slot_{idx}_target_soc" in out +# --- PR A additions: Mode card extras, AC-Coupled gating, Smart Load, Energy totals --- + + +def test_mode_card_surfaces_active_power_rate_and_rtc(): + out = generate_dashboard(INV, BATS) + for must in ( + f"switch.givenergy_inverter_{INV}_real_time_control", + f"number.givenergy_inverter_{INV}_inverter_max_output_active_power", + ): + assert must in out, f"Mode card missing {must}" + + +def test_ac_coupled_card_hidden_when_capability_absent(): + out = generate_dashboard(INV, BATS, has_ac_config_block=False) + assert "AC-Coupled" not in out + for absent in ( + f"select.givenergy_inverter_{INV}_export_priority", + f"switch.givenergy_inverter_{INV}_emergency_power_supply_eps", + f"number.givenergy_inverter_{INV}_battery_ac_charge_limit", + f"number.givenergy_inverter_{INV}_battery_ac_discharge_limit", + ): + assert absent not in out + + +def test_ac_coupled_card_emitted_when_capability_present(): + out = generate_dashboard(INV, BATS, has_ac_config_block=True) + assert "AC-Coupled" in out + for must in ( + f"select.givenergy_inverter_{INV}_export_priority", + f"switch.givenergy_inverter_{INV}_emergency_power_supply_eps", + f"number.givenergy_inverter_{INV}_battery_ac_charge_limit", + f"number.givenergy_inverter_{INV}_battery_ac_discharge_limit", + ): + assert must in out + + +def test_smart_load_card_emits_ten_slots_by_default(): + out = generate_dashboard(INV, BATS) + assert "Smart Load" in out + for idx in range(1, 11): + assert f"time.givenergy_inverter_{INV}_smart_load_slot_{idx}_start" in out + assert f"time.givenergy_inverter_{INV}_smart_load_slot_{idx}_end" in out + + +def test_smart_load_card_suppressible(): + out = generate_dashboard(INV, BATS, has_smart_load=False) + assert "Smart Load" not in out + assert "smart_load_slot_1_start" not in out + + +def test_ems_dashboard_omits_smart_load_and_ac_coupled_cards(): + out = generate_dashboard(EMS, [], is_ems=True, has_ac_config_block=True, has_smart_load=True) + assert "Smart Load" not in out + assert "AC-Coupled" not in out + + +def test_energy_view_lists_battery_charge_discharge_totals(): + views = _views() + energy_str = yaml.safe_dump(views["Energy"]) + assert "battery_charge_total" in energy_str + assert "battery_discharge_total" in energy_str + + def test_controls_view_has_maintenance_section(): """The Controls view must include a Maintenance section with the Redetect button.""" views = _views() From b273e0a268cadc7c8f1b5c5b4bdc63d56035daab Mon Sep 17 00:00:00 2001 From: Dewet Diener Date: Thu, 4 Jun 2026 07:30:17 +0100 Subject: [PATCH 2/2] fix: align has_ac_config_block gating with entity registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exclude three-phase systems from the AC-coupled card — switch.py and number.py both gate AC-coupled entities on `not caps.is_three_phase`, so the dashboard flag must match. Also replace EN DASH characters in docstrings with plain ASCII hyphens (ruff RUF002). Co-Authored-By: Claude Opus 4.7 (1M context) --- custom_components/givenergy_local/__init__.py | 4 +++- custom_components/givenergy_local/dashboard.py | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/custom_components/givenergy_local/__init__.py b/custom_components/givenergy_local/__init__.py index 2b1f6f6..af66884 100644 --- a/custom_components/givenergy_local/__init__.py +++ b/custom_components/givenergy_local/__init__.py @@ -566,7 +566,9 @@ async def handle_generate_dashboard(call: ServiceCall) -> None: bats = [b.serial_number.lower() for b in coordinator.data.batteries] is_ems = coordinator.data.ems is not None caps = coordinator.data.capabilities - has_ac_config_block = bool(caps and caps.has_ac_config_block) + has_ac_config_block = bool( + caps and caps.has_ac_config_block and not caps.is_three_phase + ) # TODO: source from `caps.has_smart_load` once givenergy-modbus # exposes the capability (#181, targeted at 2.1.3). Until then # always emit; rows render as unavailable on non-Smart-Load installs. diff --git a/custom_components/givenergy_local/dashboard.py b/custom_components/givenergy_local/dashboard.py index 8139cde..ad74b50 100644 --- a/custom_components/givenergy_local/dashboard.py +++ b/custom_components/givenergy_local/dashboard.py @@ -99,7 +99,7 @@ def generate_dashboard( inverter-centric views would render blank — instead emit a tailored view set (EMS scheduling controls + integration health). has_ac_config_block: True for AC-coupled / All-in-One plants that expose the - HR(300–359) AC-output config block (export priority, EPS, + HR(300-359) AC-output config block (export priority, EPS, AC charge/discharge limits). Surfaces the AC-Coupled controls card; hidden on hybrids that don't carry the block. has_smart_load: True when Smart Load slot scheduling is available. Defaults @@ -661,7 +661,7 @@ def _controls_view( def _ac_coupled_card(inv: str) -> str: """AC-coupled / All-in-One controls. Only emitted when the plant carries - `capabilities.has_ac_config_block` — hybrids without HR(300–359) skip this.""" + `capabilities.has_ac_config_block` - hybrids without HR(300-359) skip this.""" return f""" - type: entities title: AC-Coupled @@ -678,7 +678,7 @@ def _ac_coupled_card(inv: str) -> str: def _smart_load_card(inv: str) -> str: - """Smart Load slot scheduling (HR 554–573). Currently always emitted on + """Smart Load slot scheduling (HR 554-573). Currently always emitted on inverter installs; rows render as 'unavailable' on plants without Smart Load hardware until givenergy-modbus exposes a capability we can gate on (modbus #181, targeted at 2.1.3)."""