From 0a647517dfb1a12380d3b1034e651ba0ed25a1d9 Mon Sep 17 00:00:00 2001 From: Danny Zenger Date: Sat, 8 Aug 2026 10:12:51 +0200 Subject: [PATCH] Fix Home Assistant 2026.8 compatibility --- .github/workflows/tests.yml | 27 +++ .gitignore | 3 + README.md | 7 +- custom_components/pv_device_split/__init__.py | 1 - .../pv_device_split/config_flow.py | 45 +++-- custom_components/pv_device_split/const.py | 4 +- .../pv_device_split/discovery.py | 7 +- .../pv_device_split/manifest.json | 10 +- custom_components/pv_device_split/sensor.py | 178 +++++++----------- .../pv_device_split/strings.json | 27 +-- .../pv_device_split/translations/de.json | 27 +-- .../pv_device_split/translations/en.json | 27 +-- pyproject.toml | 11 ++ requirements_test.txt | 4 + tests/conftest.py | 1 + tests/test_config_flow.py | 83 ++++++++ tests/test_manifest.py | 14 ++ tests/test_sensor.py | 148 +++++++++++++++ 18 files changed, 452 insertions(+), 172 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 requirements_test.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_config_flow.py create mode 100644 tests/test_manifest.py create mode 100644 tests/test_sensor.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3002041 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,27 @@ +name: Tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements_test.txt + - name: Install test dependencies + run: python -m pip install -r requirements_test.txt + - name: Lint + run: ruff check . + - name: Check formatting + run: ruff format --check . + - name: Test + run: pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b267296 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +.pytest_cache/ +.ruff_cache/ diff --git a/README.md b/README.md index dc1f66b..78db887 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ Die Einrichtung laeuft komplett ueber die Home-Assistant-Oberflaeche. YAML ist nicht noetig. +Version `1.0.1` ist mit Home Assistant `2026.8` kompatibel und gegen `2026.8.1` +getestet. + ## Was Macht Die Integration? Du richtest einmal deinen Netzbezug-/Einspeise-Leistungssensor ein. Danach kann Solar Load Split passende Geraete-Leistungssensoren erkennen oder manuell hinzufuegen. Optional kannst du pro Geraet auch einen Energiezaehler des gleichen Zwischensteckers oder Shelly auswaehlen. @@ -251,7 +254,9 @@ Anzeige der Sensorwerte. - Die Integration ist HACS-kompatibel. - Deutsch und Englisch werden ueber Home-Assistant-Translations unterstuetzt. - Jeder Split-Eintrag hat stabile Unique IDs. -- Bestehende Entitaetsnamen werden von Home Assistant im Entity Registry gespeichert. Wenn du Namen nach einem Update testen willst, loesche den betroffenen Eintrag und fuege ihn neu hinzu oder setze die Namen manuell zurueck. +- Eigene Entitaetsnamen bleiben bei Updates erhalten. Alte automatisch erzeugte Namen + werden einmalig auf das aktuelle Home-Assistant-Uebersetzungssystem migriert; Entity-IDs + und Automationen bleiben dabei unveraendert. ## English Short Version diff --git a/custom_components/pv_device_split/__init__.py b/custom_components/pv_device_split/__init__.py index 23d4ab5..1b39352 100644 --- a/custom_components/pv_device_split/__init__.py +++ b/custom_components/pv_device_split/__init__.py @@ -28,7 +28,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = entry.data async_schedule_power_discovery(hass) - async_schedule_power_discovery_retries(hass) if CONF_DEVICE_POWER not in entry.data: return True diff --git a/custom_components/pv_device_split/config_flow.py b/custom_components/pv_device_split/config_flow.py index 821abff..3a02d2b 100644 --- a/custom_components/pv_device_split/config_flow.py +++ b/custom_components/pv_device_split/config_flow.py @@ -5,7 +5,6 @@ from typing import Any import voluptuous as vol - from homeassistant import config_entries from homeassistant.const import CONF_NAME from homeassistant.core import callback @@ -84,9 +83,7 @@ async def async_step_manual_device( """Manually add a device power sensor.""" grid_source_entries = _grid_source_entries(self.hass) if user_input is not None: - return await self._async_create_split_entry( - _with_grid_defaults(self.hass, user_input) - ) + return await self._async_create_split_entry(_with_grid_defaults(self.hass, user_input)) return self.async_show_form( step_id="manual_device", @@ -211,9 +208,7 @@ def _hub_schema(defaults: dict[str, Any] | None = None) -> vol.Schema: vol.Required( CONF_GRID_POWER, default=defaults.get(CONF_GRID_POWER, vol.UNDEFINED), - ): selector.EntitySelector( - selector.EntitySelectorConfig(domain="sensor") - ), + ): selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor")), vol.Optional( CONF_INVERT_GRID, default=defaults.get(CONF_INVERT_GRID, False), @@ -264,15 +259,11 @@ def _discovery_schema(defaults: dict[str, Any]) -> vol.Schema: vol.Required( CONF_DEVICE_POWER, default=defaults.get(CONF_DEVICE_POWER, vol.UNDEFINED), - ): selector.EntitySelector( - selector.EntitySelectorConfig(domain="sensor") - ), + ): selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor")), vol.Optional( CONF_DEVICE_ENERGY, default=defaults.get(CONF_DEVICE_ENERGY, vol.UNDEFINED), - ): selector.EntitySelector( - selector.EntitySelectorConfig(domain="sensor") - ), + ): selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor")), } ) @@ -291,9 +282,7 @@ def _manual_device_schema(defaults: dict[str, Any]) -> vol.Schema: vol.Optional( CONF_DEVICE_ENERGY, default=defaults.get(CONF_DEVICE_ENERGY, vol.UNDEFINED), - ): selector.EntitySelector( - selector.EntitySelectorConfig(domain="sensor") - ), + ): selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor")), vol.Required( CONF_GRID_POWER, default=defaults.get(CONF_GRID_POWER, vol.UNDEFINED), @@ -406,14 +395,24 @@ async def async_step_init( **self._config_entry.data, **user_input, } + unique_id = _entry_unique_id(data) + if any( + entry.entry_id != self._config_entry.entry_id and entry.unique_id == unique_id + for entry in self.hass.config_entries.async_entries(DOMAIN) + ): + return self.async_show_form( + step_id="init", + data_schema=_options_schema(data), + errors={"base": "already_configured"}, + ) + self.hass.config_entries.async_update_entry( self._config_entry, title=data.get(CONF_NAME, DEFAULT_NAME), data=data, + unique_id=unique_id, ) - self.hass.create_task( - self.hass.config_entries.async_reload(self._config_entry.entry_id) - ) + self.hass.config_entries.async_schedule_reload(self._config_entry.entry_id) return self.async_create_entry(title="", data={}) return self.async_show_form( @@ -485,3 +484,11 @@ def _options_schema(defaults: dict[str, Any]) -> vol.Schema: ] = selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor")) return vol.Schema(schema) + + +@callback +def _entry_unique_id(data: dict[str, Any]) -> str: + """Return the unique ID matching the current entry configuration.""" + if CONF_DEVICE_POWER in data: + return _pair_unique_id(data[CONF_DEVICE_POWER], data[CONF_GRID_POWER]) + return f"grid_{data[CONF_GRID_POWER]}" diff --git a/custom_components/pv_device_split/const.py b/custom_components/pv_device_split/const.py index c40a09e..406a5f6 100644 --- a/custom_components/pv_device_split/const.py +++ b/custom_components/pv_device_split/const.py @@ -2,6 +2,8 @@ from __future__ import annotations +from homeassistant.const import Platform + DOMAIN = "pv_device_split" CONF_DEVICE_POWER = "device_power" @@ -16,4 +18,4 @@ DEFAULT_GRID_BUFFER_SECONDS = 0 DEFAULT_GRID_DEADBAND_WATTS = 100 -PLATFORMS = ["sensor"] +PLATFORMS: list[Platform] = [Platform.SENSOR] diff --git a/custom_components/pv_device_split/discovery.py b/custom_components/pv_device_split/discovery.py index 24afa01..1adb42d 100644 --- a/custom_components/pv_device_split/discovery.py +++ b/custom_components/pv_device_split/discovery.py @@ -18,9 +18,9 @@ from .const import ( CONF_DEVICE_POWER, CONF_ENABLE_DISCOVERY, - CONF_GRID_POWER, CONF_GRID_BUFFER_SECONDS, CONF_GRID_DEADBAND_WATTS, + CONF_GRID_POWER, CONF_INVERT_GRID, DEFAULT_GRID_BUFFER_SECONDS, DEFAULT_GRID_DEADBAND_WATTS, @@ -54,7 +54,10 @@ class PowerCandidate: @callback def async_schedule_power_discovery(hass: HomeAssistant) -> None: """Schedule a scan for useful power sensors.""" - hass.create_task(_async_discover_power_pair(hass)) + hass.async_create_task( + _async_discover_power_pair(hass), + "Discover Solar Load Split power sensors", + ) @callback diff --git a/custom_components/pv_device_split/manifest.json b/custom_components/pv_device_split/manifest.json index c6579cc..ba17ecc 100644 --- a/custom_components/pv_device_split/manifest.json +++ b/custom_components/pv_device_split/manifest.json @@ -1,11 +1,13 @@ { "domain": "pv_device_split", "name": "Solar Load Split", - "codeowners": [], + "codeowners": ["@dr-apple"], "config_flow": true, "dependencies": [], - "documentation": "https://github.com/custom-components/pv_device_split", - "iot_class": "local_push", + "documentation": "https://github.com/dr-apple/Solar-Load-Split", + "integration_type": "helper", + "iot_class": "calculated", + "issue_tracker": "https://github.com/dr-apple/Solar-Load-Split/issues", "requirements": [], - "version": "1.0.0" + "version": "1.0.1" } diff --git a/custom_components/pv_device_split/sensor.py b/custom_components/pv_device_split/sensor.py index dbb448f..7952bca 100644 --- a/custom_components/pv_device_split/sensor.py +++ b/custom_components/pv_device_split/sensor.py @@ -2,13 +2,12 @@ from __future__ import annotations +import logging +import math from collections.abc import Callable from dataclasses import dataclass -from dataclasses import replace -from datetime import datetime -from datetime import timedelta +from datetime import datetime, timedelta from enum import StrEnum -import logging from homeassistant.components.sensor import ( SensorDeviceClass, @@ -24,9 +23,9 @@ UnitOfPower, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import ( async_track_state_change_event, async_track_time_interval, @@ -84,7 +83,7 @@ class SplitSensorKey(StrEnum): SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SplitSensorKey.PV_POWER, - name="PV Power", + translation_key=SplitSensorKey.PV_POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -92,7 +91,7 @@ class SplitSensorKey(StrEnum): ), SensorEntityDescription( key=SplitSensorKey.GRID_POWER, - name="Grid Power", + translation_key=SplitSensorKey.GRID_POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -100,7 +99,7 @@ class SplitSensorKey(StrEnum): ), SensorEntityDescription( key=SplitSensorKey.PV_ENERGY, - name="PV Energy", + translation_key=SplitSensorKey.PV_ENERGY, icon="mdi:solar-power", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -109,7 +108,7 @@ class SplitSensorKey(StrEnum): ), SensorEntityDescription( key=SplitSensorKey.GRID_ENERGY, - name="Grid Energy", + translation_key=SplitSensorKey.GRID_ENERGY, icon="mdi:transmission-tower", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -118,7 +117,7 @@ class SplitSensorKey(StrEnum): ), SensorEntityDescription( key=SplitSensorKey.PV_ENERGY_DAILY, - name="PV Daily Energy", + translation_key=SplitSensorKey.PV_ENERGY_DAILY, icon="mdi:solar-power", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -127,7 +126,7 @@ class SplitSensorKey(StrEnum): ), SensorEntityDescription( key=SplitSensorKey.GRID_ENERGY_DAILY, - name="Grid Daily Energy", + translation_key=SplitSensorKey.GRID_ENERGY_DAILY, icon="mdi:transmission-tower", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -136,7 +135,7 @@ class SplitSensorKey(StrEnum): ), SensorEntityDescription( key=SplitSensorKey.PV_ENERGY_WEEKLY, - name="PV Weekly Energy", + translation_key=SplitSensorKey.PV_ENERGY_WEEKLY, icon="mdi:solar-power", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -145,7 +144,7 @@ class SplitSensorKey(StrEnum): ), SensorEntityDescription( key=SplitSensorKey.GRID_ENERGY_WEEKLY, - name="Grid Weekly Energy", + translation_key=SplitSensorKey.GRID_ENERGY_WEEKLY, icon="mdi:transmission-tower", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -154,7 +153,7 @@ class SplitSensorKey(StrEnum): ), SensorEntityDescription( key=SplitSensorKey.PV_ENERGY_MONTHLY, - name="PV Monthly Energy", + translation_key=SplitSensorKey.PV_ENERGY_MONTHLY, icon="mdi:solar-power", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -163,7 +162,7 @@ class SplitSensorKey(StrEnum): ), SensorEntityDescription( key=SplitSensorKey.GRID_ENERGY_MONTHLY, - name="Grid Monthly Energy", + translation_key=SplitSensorKey.GRID_ENERGY_MONTHLY, icon="mdi:transmission-tower", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -172,7 +171,7 @@ class SplitSensorKey(StrEnum): ), SensorEntityDescription( key=SplitSensorKey.PV_ENERGY_YEARLY, - name="PV Yearly Energy", + translation_key=SplitSensorKey.PV_ENERGY_YEARLY, icon="mdi:solar-power", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -181,7 +180,7 @@ class SplitSensorKey(StrEnum): ), SensorEntityDescription( key=SplitSensorKey.GRID_ENERGY_YEARLY, - name="Grid Yearly Energy", + translation_key=SplitSensorKey.GRID_ENERGY_YEARLY, icon="mdi:transmission-tower", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -190,7 +189,7 @@ class SplitSensorKey(StrEnum): ), ) -SHORT_ENTITY_NAMES = ( +LEGACY_ENTITY_NAME_SUFFIXES = ( "PV Power", "Grid Power", "PV Energy", @@ -257,9 +256,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self.pv_energy_kwh = 0.0 self.grid_energy_kwh = 0.0 self._last_device_energy_kwh: float | None = None - self.period_energy_kwh: dict[str, float] = { - key: 0.0 for key in PERIOD_SENSOR_KEYS - } + self.period_energy_kwh: dict[str, float] = {key: 0.0 for key in PERIOD_SENSOR_KEYS} self.period_markers: dict[str, str] = {} self.powers = SplitPower(0.0, 0.0) self.last_update: datetime | None = None @@ -355,10 +352,13 @@ def _update(self, now: datetime) -> None: if self.last_update is not None: self._reset_periods_if_needed(now) if not self._apply_device_energy_delta(device_energy_kwh, current_powers): - elapsed_hours = max( - (now - self.last_update).total_seconds(), - 0.0, - ) / 3600 + elapsed_hours = ( + max( + (now - self.last_update).total_seconds(), + 0.0, + ) + / 3600 + ) self.pv_energy_kwh += self.powers.pv_power_kw * elapsed_hours self.grid_energy_kwh += self.powers.grid_power_kw * elapsed_hours self._add_period_energy("pv", self.powers.pv_power_kw, elapsed_hours) @@ -453,8 +453,7 @@ def _buffered_grid_power(self, now: datetime, grid_power_w: float) -> float: if ( self._pending_grid_since is not None - and (now - self._pending_grid_since).total_seconds() - >= self.grid_buffer_seconds + and (now - self._pending_grid_since).total_seconds() >= self.grid_buffer_seconds ): self._stable_grid_is_export = grid_is_export self._pending_grid_is_export = None @@ -530,7 +529,7 @@ def _add_period_energy_delta(self, source: str, delta_kwh: float) -> None: async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Solar Load Split sensors from a config entry.""" runtime = PVDeviceSplitRuntime(hass, entry) @@ -539,15 +538,15 @@ async def async_setup_entry( PVDeviceSplitSensor(runtime, description) for description in SENSOR_DESCRIPTIONS ] - async_add_entities(entities) await runtime.async_start() + async_add_entities(entities) entry.async_on_unload(runtime.stop) class PVDeviceSplitSensor(SensorEntity, RestoreEntity): """Solar Load Split sensor.""" - _attr_has_entity_name = False + _attr_has_entity_name = True def __init__( self, @@ -556,16 +555,7 @@ def __init__( ) -> None: """Initialize the sensor.""" self.runtime = runtime - self.entity_description = replace( - description, - name=_localized_entity_name( - runtime.hass.config.language, - runtime.name, - description.key, - ), - ) - self._attr_name = self.entity_description.name - self._full_name = self.entity_description.name + self.entity_description = description self._attr_unique_id = f"{runtime.entry.entry_id}_{description.key}" self._attr_device_info = runtime.device_info self._attr_native_value: float | None = None @@ -581,55 +571,60 @@ def available(self) -> bool: async def async_added_to_hass(self) -> None: """Restore energy totals and subscribe to runtime updates.""" await super().async_added_to_hass() - self._async_update_registry_name() + self._async_migrate_legacy_registry_name() - if self.entity_description.key in ( - SplitSensorKey.PV_ENERGY, - SplitSensorKey.GRID_ENERGY, - *PERIOD_SENSOR_KEYS, + if ( + self.entity_description.key + in ( + SplitSensorKey.PV_ENERGY, + SplitSensorKey.GRID_ENERGY, + *PERIOD_SENSOR_KEYS, + ) + and (last_state := await self.async_get_last_state()) is not None ): - if (last_state := await self.async_get_last_state()) is not None: - try: - value = float(last_state.state) - except (TypeError, ValueError): - _LOGGER.debug( - "Could not restore %s from state %s", + try: + value = float(last_state.state) + except (TypeError, ValueError): + _LOGGER.debug( + "Could not restore %s from state %s", + self.entity_description.key, + last_state.state, + ) + else: + if self.entity_description.key == SplitSensorKey.PV_ENERGY: + self.runtime.pv_energy_kwh = value + elif self.entity_description.key == SplitSensorKey.GRID_ENERGY: + self.runtime.grid_energy_kwh = value + else: + self.runtime.restore_period_energy( self.entity_description.key, - last_state.state, + value, + last_state.last_updated, ) - else: - if self.entity_description.key == SplitSensorKey.PV_ENERGY: - self.runtime.pv_energy_kwh = value - elif self.entity_description.key == SplitSensorKey.GRID_ENERGY: - self.runtime.grid_energy_kwh = value - else: - self.runtime.restore_period_energy( - self.entity_description.key, - value, - last_state.last_updated, - ) self._update_native_value() self._remove_listener = self.runtime.add_listener(self._handle_runtime_update) @callback - def _async_update_registry_name(self) -> None: - """Update old registry names from previous versions.""" + def _async_migrate_legacy_registry_name(self) -> None: + """Clear only names that an older version generated automatically.""" registry = er.async_get(self.hass) entity_entry = registry.async_get(self.entity_id) if entity_entry is None: return - if entity_entry.name == self._full_name: + legacy_names = {f"{self.runtime.name} {suffix}" for suffix in LEGACY_ENTITY_NAME_SUFFIXES} + if entity_entry.name not in legacy_names: return - registry.async_update_entity(self.entity_id, name=self._full_name) + registry.async_update_entity(self.entity_id, name=None) async def async_will_remove_from_hass(self) -> None: """Unsubscribe from runtime updates.""" if self._remove_listener is not None: self._remove_listener() self._remove_listener = None + await super().async_will_remove_from_hass() @callback def _handle_runtime_update(self) -> None: @@ -669,12 +664,16 @@ def _state_as_power_watts(hass: HomeAssistant, entity_id: str) -> float | None: value = float(state.state) except (TypeError, ValueError): return None + if not math.isfinite(value): + return None unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if unit == UnitOfPower.KILO_WATT or str(unit).casefold() == "kw": return value * 1000 + if unit == UnitOfPower.WATT or str(unit).casefold() == "w": + return value - return value + return None def _state_as_energy_kwh(hass: HomeAssistant, entity_id: str | None) -> float | None: @@ -690,6 +689,8 @@ def _state_as_energy_kwh(hass: HomeAssistant, entity_id: str | None) -> float | value = float(state.state) except (TypeError, ValueError): return None + if not math.isfinite(value): + return None unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) normalized = str(unit).casefold() @@ -701,45 +702,6 @@ def _state_as_energy_kwh(hass: HomeAssistant, entity_id: str | None) -> float | return None -def _localized_entity_name( - language: str | None, - device_name: str, - key: str, -) -> str: - """Return a stable full entity name for the current Home Assistant language.""" - german_names = { - SplitSensorKey.PV_POWER: "PV Leistung", - SplitSensorKey.GRID_POWER: "Netz Leistung", - SplitSensorKey.PV_ENERGY: "PV Energie", - SplitSensorKey.GRID_ENERGY: "Netz Energie", - SplitSensorKey.PV_ENERGY_DAILY: "PV Tagesenergie", - SplitSensorKey.GRID_ENERGY_DAILY: "Netz Tagesenergie", - SplitSensorKey.PV_ENERGY_WEEKLY: "PV Wochenenergie", - SplitSensorKey.GRID_ENERGY_WEEKLY: "Netz Wochenenergie", - SplitSensorKey.PV_ENERGY_MONTHLY: "PV Monatsenergie", - SplitSensorKey.GRID_ENERGY_MONTHLY: "Netz Monatsenergie", - SplitSensorKey.PV_ENERGY_YEARLY: "PV Jahresenergie", - SplitSensorKey.GRID_ENERGY_YEARLY: "Netz Jahresenergie", - } - english_names = { - SplitSensorKey.PV_POWER: "PV Power", - SplitSensorKey.GRID_POWER: "Grid Power", - SplitSensorKey.PV_ENERGY: "PV Energy", - SplitSensorKey.GRID_ENERGY: "Grid Energy", - SplitSensorKey.PV_ENERGY_DAILY: "PV Daily Energy", - SplitSensorKey.GRID_ENERGY_DAILY: "Grid Daily Energy", - SplitSensorKey.PV_ENERGY_WEEKLY: "PV Weekly Energy", - SplitSensorKey.GRID_ENERGY_WEEKLY: "Grid Weekly Energy", - SplitSensorKey.PV_ENERGY_MONTHLY: "PV Monthly Energy", - SplitSensorKey.GRID_ENERGY_MONTHLY: "Grid Monthly Energy", - SplitSensorKey.PV_ENERGY_YEARLY: "PV Yearly Energy", - SplitSensorKey.GRID_ENERGY_YEARLY: "Grid Yearly Energy", - } - - names = german_names if (language or "").startswith("de") else english_names - return f"{device_name} {names[key]}" - - def _period_marker(timestamp: datetime, period: str) -> str: """Return a local period marker for a timestamp.""" local = dt_util.as_local(timestamp) diff --git a/custom_components/pv_device_split/strings.json b/custom_components/pv_device_split/strings.json index f9969a5..ee06a4b 100644 --- a/custom_components/pv_device_split/strings.json +++ b/custom_components/pv_device_split/strings.json @@ -55,6 +55,9 @@ } }, "options": { + "error": { + "already_configured": "Dieses Geräte- und Netzsensor-Paar ist bereits eingerichtet." + }, "step": { "init": { "title": "Solar Load Split konfigurieren", @@ -75,40 +78,40 @@ "entity": { "sensor": { "pv_power": { - "name": "{device_name} PV Leistung" + "name": "PV Leistung" }, "grid_power": { - "name": "{device_name} Netz Leistung" + "name": "Netz Leistung" }, "pv_energy": { - "name": "{device_name} PV Energie" + "name": "PV Energie" }, "grid_energy": { - "name": "{device_name} Netz Energie" + "name": "Netz Energie" }, "pv_energy_daily": { - "name": "{device_name} PV Tagesenergie" + "name": "PV Tagesenergie" }, "grid_energy_daily": { - "name": "{device_name} Netz Tagesenergie" + "name": "Netz Tagesenergie" }, "pv_energy_weekly": { - "name": "{device_name} PV Wochenenergie" + "name": "PV Wochenenergie" }, "grid_energy_weekly": { - "name": "{device_name} Netz Wochenenergie" + "name": "Netz Wochenenergie" }, "pv_energy_monthly": { - "name": "{device_name} PV Monatsenergie" + "name": "PV Monatsenergie" }, "grid_energy_monthly": { - "name": "{device_name} Netz Monatsenergie" + "name": "Netz Monatsenergie" }, "pv_energy_yearly": { - "name": "{device_name} PV Jahresenergie" + "name": "PV Jahresenergie" }, "grid_energy_yearly": { - "name": "{device_name} Netz Jahresenergie" + "name": "Netz Jahresenergie" } } } diff --git a/custom_components/pv_device_split/translations/de.json b/custom_components/pv_device_split/translations/de.json index 4ffd451..bb586bd 100644 --- a/custom_components/pv_device_split/translations/de.json +++ b/custom_components/pv_device_split/translations/de.json @@ -55,6 +55,9 @@ } }, "options": { + "error": { + "already_configured": "Dieses Geräte- und Netzsensor-Paar ist bereits eingerichtet." + }, "step": { "init": { "title": "Solar Load Split konfigurieren", @@ -75,40 +78,40 @@ "entity": { "sensor": { "pv_power": { - "name": "{device_name} PV Leistung" + "name": "PV Leistung" }, "grid_power": { - "name": "{device_name} Netz Leistung" + "name": "Netz Leistung" }, "pv_energy": { - "name": "{device_name} PV Energie" + "name": "PV Energie" }, "grid_energy": { - "name": "{device_name} Netz Energie" + "name": "Netz Energie" }, "pv_energy_daily": { - "name": "{device_name} PV Tagesenergie" + "name": "PV Tagesenergie" }, "grid_energy_daily": { - "name": "{device_name} Netz Tagesenergie" + "name": "Netz Tagesenergie" }, "pv_energy_weekly": { - "name": "{device_name} PV Wochenenergie" + "name": "PV Wochenenergie" }, "grid_energy_weekly": { - "name": "{device_name} Netz Wochenenergie" + "name": "Netz Wochenenergie" }, "pv_energy_monthly": { - "name": "{device_name} PV Monatsenergie" + "name": "PV Monatsenergie" }, "grid_energy_monthly": { - "name": "{device_name} Netz Monatsenergie" + "name": "Netz Monatsenergie" }, "pv_energy_yearly": { - "name": "{device_name} PV Jahresenergie" + "name": "PV Jahresenergie" }, "grid_energy_yearly": { - "name": "{device_name} Netz Jahresenergie" + "name": "Netz Jahresenergie" } } } diff --git a/custom_components/pv_device_split/translations/en.json b/custom_components/pv_device_split/translations/en.json index 8e19103..e14c8df 100644 --- a/custom_components/pv_device_split/translations/en.json +++ b/custom_components/pv_device_split/translations/en.json @@ -55,6 +55,9 @@ } }, "options": { + "error": { + "already_configured": "This device and grid sensor pair is already configured." + }, "step": { "init": { "title": "Configure Solar Load Split", @@ -75,40 +78,40 @@ "entity": { "sensor": { "pv_power": { - "name": "{device_name} PV Power" + "name": "PV Power" }, "grid_power": { - "name": "{device_name} Grid Power" + "name": "Grid Power" }, "pv_energy": { - "name": "{device_name} PV Energy" + "name": "PV Energy" }, "grid_energy": { - "name": "{device_name} Grid Energy" + "name": "Grid Energy" }, "pv_energy_daily": { - "name": "{device_name} PV Daily Energy" + "name": "PV Daily Energy" }, "grid_energy_daily": { - "name": "{device_name} Grid Daily Energy" + "name": "Grid Daily Energy" }, "pv_energy_weekly": { - "name": "{device_name} PV Weekly Energy" + "name": "PV Weekly Energy" }, "grid_energy_weekly": { - "name": "{device_name} Grid Weekly Energy" + "name": "Grid Weekly Energy" }, "pv_energy_monthly": { - "name": "{device_name} PV Monthly Energy" + "name": "PV Monthly Energy" }, "grid_energy_monthly": { - "name": "{device_name} Grid Monthly Energy" + "name": "Grid Monthly Energy" }, "pv_energy_yearly": { - "name": "{device_name} PV Yearly Energy" + "name": "PV Yearly Energy" }, "grid_energy_yearly": { - "name": "{device_name} Grid Yearly Energy" + "name": "Grid Yearly Energy" } } } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1532825 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" +pythonpath = ["."] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "SIM", "UP"] diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..517d730 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,4 @@ +homeassistant==2026.8.1 +pytest==9.0.3 +pytest-homeassistant-custom-component==0.13.355 +ruff==0.16.2 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..395e04b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +"""Fixtures for Solar Load Split tests.""" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..e00c1e2 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,83 @@ +"""Tests for the Solar Load Split config and options flows.""" + +from unittest.mock import patch + +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.pv_device_split.const import ( + CONF_DEVICE_POWER, + CONF_GRID_BUFFER_SECONDS, + CONF_GRID_DEADBAND_WATTS, + CONF_GRID_POWER, + CONF_INVERT_GRID, + DOMAIN, +) + + +def _data(device: str, grid: str = "sensor.grid_power") -> dict: + return { + CONF_NAME: "Washer", + CONF_DEVICE_POWER: device, + CONF_GRID_POWER: grid, + CONF_INVERT_GRID: False, + CONF_GRID_BUFFER_SECONDS: 0, + CONF_GRID_DEADBAND_WATTS: 0, + } + + +async def test_options_updates_unique_id( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Changing source entities keeps the entry unique ID in sync.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Washer", + unique_id="sensor.old_power_sensor.grid_power", + data=_data("sensor.old_power"), + ) + entry.add_to_hass(hass) + + with patch.object(hass.config_entries, "async_schedule_reload") as schedule_reload: + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=_data("sensor.new_power"), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert entry.unique_id == "sensor.new_power_sensor.grid_power" + assert entry.data[CONF_DEVICE_POWER] == "sensor.new_power" + schedule_reload.assert_called_once_with(entry.entry_id) + + +async def test_options_rejects_duplicate_pair( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Options cannot change an entry to a pair owned by another entry.""" + existing = MockConfigEntry( + domain=DOMAIN, + unique_id="sensor.existing_sensor.grid_power", + data=_data("sensor.existing"), + ) + existing.add_to_hass(hass) + edited = MockConfigEntry( + domain=DOMAIN, + unique_id="sensor.old_sensor.grid_power", + data=_data("sensor.old"), + ) + edited.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(edited.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=_data("sensor.existing"), + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "already_configured"} + assert edited.unique_id == "sensor.old_sensor.grid_power" diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..b0f358f --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,14 @@ +"""Manifest compatibility tests.""" + +import json +from pathlib import Path + + +def test_manifest_metadata() -> None: + """The custom integration declares current Home Assistant metadata.""" + manifest = json.loads(Path("custom_components/pv_device_split/manifest.json").read_text()) + + assert manifest["integration_type"] == "helper" + assert manifest["iot_class"] == "calculated" + assert manifest["codeowners"] == ["@dr-apple"] + assert manifest["documentation"] == "https://github.com/dr-apple/Solar-Load-Split" diff --git a/tests/test_sensor.py b/tests/test_sensor.py new file mode 100644 index 0000000..bcd3948 --- /dev/null +++ b/tests/test_sensor.py @@ -0,0 +1,148 @@ +"""Tests for Solar Load Split sensors.""" + +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + CONF_NAME, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.pv_device_split.const import ( + CONF_DEVICE_POWER, + CONF_GRID_BUFFER_SECONDS, + CONF_GRID_DEADBAND_WATTS, + CONF_GRID_POWER, + CONF_INVERT_GRID, + DOMAIN, +) +from custom_components.pv_device_split.sensor import ( + _state_as_energy_kwh, + _state_as_power_watts, +) + + +def _entry(name: str = "Washer") -> MockConfigEntry: + return MockConfigEntry( + domain=DOMAIN, + title=name, + unique_id="sensor.washer_power_sensor.grid_power", + data={ + CONF_NAME: name, + CONF_DEVICE_POWER: "sensor.washer_power", + CONF_GRID_POWER: "sensor.grid_power", + CONF_INVERT_GRID: False, + CONF_GRID_BUFFER_SECONDS: 0, + CONF_GRID_DEADBAND_WATTS: 0, + }, + ) + + +def test_power_unit_conversion_and_validation(hass: HomeAssistant) -> None: + """Power states are converted only from supported finite units.""" + hass.states.async_set("sensor.watts", "125", {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}) + hass.states.async_set( + "sensor.kilowatts", "1.25", {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} + ) + hass.states.async_set("sensor.volts", "230", {ATTR_UNIT_OF_MEASUREMENT: "V"}) + hass.states.async_set("sensor.nan", "nan", {ATTR_UNIT_OF_MEASUREMENT: "W"}) + + assert _state_as_power_watts(hass, "sensor.watts") == 125 + assert _state_as_power_watts(hass, "sensor.kilowatts") == 1250 + assert _state_as_power_watts(hass, "sensor.volts") is None + assert _state_as_power_watts(hass, "sensor.nan") is None + + +def test_energy_unit_conversion_and_validation(hass: HomeAssistant) -> None: + """Energy states are converted only from supported finite units.""" + hass.states.async_set("sensor.wh", "1250", {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.WATT_HOUR}) + hass.states.async_set( + "sensor.kwh", "1.25", {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR} + ) + hass.states.async_set("sensor.inf", "inf", {ATTR_UNIT_OF_MEASUREMENT: "kWh"}) + + assert _state_as_energy_kwh(hass, "sensor.wh") == 1.25 + assert _state_as_energy_kwh(hass, "sensor.kwh") == 1.25 + assert _state_as_energy_kwh(hass, "sensor.inf") is None + + +async def test_setup_creates_sensors_and_single_entry_device( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """A split entry loads twelve sensors attached to its own device.""" + entry = _entry() + entry.add_to_hass(hass) + hass.states.async_set( + "sensor.washer_power", "1000", {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT} + ) + hass.states.async_set("sensor.grid_power", "400", {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_entries = er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) + assert len(entity_entries) == 12 + assert hass.states.get("sensor.washer_pv_power").state == "0.6" + assert hass.states.get("sensor.washer_grid_power").state == "0.4" + + devices = dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id) + assert len(devices) == 1 + assert devices[0].config_entry_id == entry.entry_id + + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_preserves_custom_registry_name( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Loading the integration does not overwrite a user-defined name.""" + entry = _entry() + entry.add_to_hass(hass) + registry = er.async_get(hass) + entity = registry.async_get_or_create( + "sensor", + DOMAIN, + f"{entry.entry_id}_pv_power", + suggested_object_id="washer_pv_power", + ) + registry.async_update_entity(entity.entity_id, name="My custom solar") + hass.states.async_set( + "sensor.washer_power", "1000", {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT} + ) + hass.states.async_set("sensor.grid_power", "400", {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert registry.async_get(entity.entity_id).name == "My custom solar" + + +async def test_clears_only_legacy_generated_registry_name( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """An old generated full name is migrated to entity translations.""" + entry = _entry() + entry.add_to_hass(hass) + registry = er.async_get(hass) + entity = registry.async_get_or_create( + "sensor", + DOMAIN, + f"{entry.entry_id}_pv_power", + suggested_object_id="washer_pv_power", + ) + registry.async_update_entity(entity.entity_id, name="Washer PV Power") + hass.states.async_set( + "sensor.washer_power", "1000", {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT} + ) + hass.states.async_set("sensor.grid_power", "400", {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert registry.async_get(entity.entity_id).name is None