Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion custom_components/givenergy_local/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,22 @@ 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 and not caps.is_three_phase
)
# TODO: source from `caps.has_smart_load` once givenergy-modbus
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# 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))
Expand Down
102 changes: 88 additions & 14 deletions custom_components/givenergy_local/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"""
if is_ems:
views = "\n".join([_ems_controls_view(inv), _ems_diagnostics_view(inv)])
Expand All @@ -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),
]
)
Expand Down Expand Up @@ -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")}
Expand Down Expand Up @@ -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 ""
Comment on lines +573 to +574

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent inconsistent spacing and empty lines in the generated YAML when some cards are disabled (or when both are disabled), we can dynamically collect the active cards in a list, strip any leading/trailing newlines, and join them with double newlines. This ensures that there is always exactly one empty line separating the cards.

    cards = []
    if has_ac_config_block:
        cards.append(_ac_coupled_card(inv).strip("\n"))
    if has_smart_load:
        cards.append(_smart_load_card(inv).strip("\n"))
    extra_cards = "\n" + "\n\n".join(cards) + "\n" if cards else "\n"

return f"""\
# ── Controls ──────────────────────────────────────────────────────────────
- title: Controls
Expand All @@ -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

Expand Down Expand Up @@ -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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the dynamically joined extra_cards variable to ensure clean and consistent spacing between the preceding and following cards.

Suggested change
{ac_coupled_card}{smart_load_card}
{extra_cards}

- type: entities
title: Maintenance
entities:
Expand All @@ -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).

Expand Down
43 changes: 43 additions & 0 deletions dashboard/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
65 changes: 64 additions & 1 deletion tests/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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()
Expand Down
Loading