diff --git a/homeassistant/components/kostal_plenticore/coordinator.py b/homeassistant/components/kostal_plenticore/coordinator.py index d312130bb54..c3a49359fc4 100644 --- a/homeassistant/components/kostal_plenticore/coordinator.py +++ b/homeassistant/components/kostal_plenticore/coordinator.py @@ -237,14 +237,23 @@ class SettingDataUpdateCoordinator( """Implementation of PlenticoreUpdateCoordinator for settings data.""" async def _async_update_data(self) -> Mapping[str, Mapping[str, str]]: - client = self._plenticore.client - - if not self._fetch or client is None: + if (client := self._plenticore.client) is None: return {} - _LOGGER.debug("Fetching %s for %s", self.name, self._fetch) + fetch = defaultdict(set) - return await client.get_setting_values(self._fetch) + for module_id, data_ids in self._fetch.items(): + fetch[module_id].update(data_ids) + + for module_id, data_id in self.async_contexts(): + fetch[module_id].add(data_id) + + if not fetch: + return {} + + _LOGGER.debug("Fetching %s for %s", self.name, fetch) + + return await client.get_setting_values(fetch) class PlenticoreSelectUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): diff --git a/homeassistant/components/kostal_plenticore/diagnostics.py b/homeassistant/components/kostal_plenticore/diagnostics.py index 4d4d61f56a7..a583770379c 100644 --- a/homeassistant/components/kostal_plenticore/diagnostics.py +++ b/homeassistant/components/kostal_plenticore/diagnostics.py @@ -34,6 +34,29 @@ async def async_get_config_entry_diagnostics( }, } + # Add important information how the inverter is configured + string_count_setting = await plenticore.client.get_setting_values( + "devices:local", "Properties:StringCnt" + ) + try: + string_count = int( + string_count_setting["devices:local"]["Properties:StringCnt"] + ) + except ValueError: + string_count = 0 + + configuration_settings = await plenticore.client.get_setting_values( + "devices:local", + ( + "Properties:StringCnt", + *(f"Properties:String{idx}Features" for idx in range(string_count)), + ), + ) + + data["configuration"] = { + **configuration_settings, + } + device_info = {**plenticore.device_info} device_info[ATTR_IDENTIFIERS] = REDACTED # contains serial number data["device"] = device_info diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index feeb4bc5bb5..85a5cdf8fe7 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -5,12 +5,13 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any +from typing import Any, Final from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -66,7 +67,7 @@ async def async_setup_entry( """Add kostal plenticore Switch.""" plenticore = entry.runtime_data - entities = [] + entities: list[Entity] = [] available_settings_data = await plenticore.client.get_settings() settings_data_update_coordinator = SettingDataUpdateCoordinator( @@ -103,6 +104,57 @@ async def async_setup_entry( ) ) + # add shadow management switches for strings which support it + string_count_setting = await plenticore.client.get_setting_values( + "devices:local", "Properties:StringCnt" + ) + try: + string_count = int( + string_count_setting["devices:local"]["Properties:StringCnt"] + ) + except ValueError: + string_count = 0 + + dc_strings = tuple(range(string_count)) + dc_string_feature_ids = tuple( + PlenticoreShadowMgmtSwitch.DC_STRING_FEATURE_DATA_ID % dc_string + for dc_string in dc_strings + ) + + dc_string_features = await plenticore.client.get_setting_values( + PlenticoreShadowMgmtSwitch.MODULE_ID, + dc_string_feature_ids, + ) + + for dc_string, dc_string_feature_id in zip( + dc_strings, dc_string_feature_ids, strict=True + ): + try: + dc_string_feature = int( + dc_string_features[PlenticoreShadowMgmtSwitch.MODULE_ID][ + dc_string_feature_id + ] + ) + except ValueError: + dc_string_feature = 0 + + if dc_string_feature == PlenticoreShadowMgmtSwitch.SHADOW_MANAGEMENT_SUPPORT: + entities.append( + PlenticoreShadowMgmtSwitch( + settings_data_update_coordinator, + dc_string, + entry.entry_id, + entry.title, + plenticore.device_info, + ) + ) + else: + _LOGGER.debug( + "Skipping shadow management for DC string %d, not supported (Feature: %d)", + dc_string + 1, + dc_string_feature, + ) + async_add_entities(entities) @@ -136,7 +188,6 @@ class PlenticoreDataSwitch( self.off_value = description.off_value self.off_label = description.off_label self._attr_unique_id = f"{entry_id}_{description.module_id}_{description.key}" - self._attr_device_info = device_info @property @@ -189,3 +240,98 @@ class PlenticoreDataSwitch( f"{self.platform_name} {self._name} {self.off_label}" ) return bool(self.coordinator.data[self.module_id][self.data_id] == self._is_on) + + +class PlenticoreShadowMgmtSwitch( + CoordinatorEntity[SettingDataUpdateCoordinator], SwitchEntity +): + """Representation of a Plenticore Switch for shadow management. + + The shadow management switch can be controlled for each DC string separately. The DC string is + coded as bit in a single settings value, bit 0 for DC string 1, bit 1 for DC string 2, etc. + + Not all DC strings are available for shadown management, for example if one of them is used + for a battery. + """ + + _attr_entity_category = EntityCategory.CONFIG + entity_description: SwitchEntityDescription + + MODULE_ID: Final = "devices:local" + + SHADOW_DATA_ID: Final = "Generator:ShadowMgmt:Enable" + """Settings id for the bit coded shadow management.""" + + DC_STRING_FEATURE_DATA_ID: Final = "Properties:String%dFeatures" + """Settings id pattern for the DC string features.""" + + SHADOW_MANAGEMENT_SUPPORT: Final = 1 + """Feature value for shadow management support in the DC string features.""" + + def __init__( + self, + coordinator: SettingDataUpdateCoordinator, + dc_string: int, + entry_id: str, + platform_name: str, + device_info: DeviceInfo, + ) -> None: + """Create a new Switch Entity for Plenticore shadow management.""" + super().__init__(coordinator, context=(self.MODULE_ID, self.SHADOW_DATA_ID)) + + self._mask: Final = 1 << dc_string + + self.entity_description = SwitchEntityDescription( + key=f"ShadowMgmt{dc_string}", + name=f"Shadow Management DC string {dc_string + 1}", + entity_registry_enabled_default=False, + ) + + self.platform_name = platform_name + self._attr_name = f"{platform_name} {self.entity_description.name}" + self._attr_unique_id = ( + f"{entry_id}_{self.MODULE_ID}_{self.SHADOW_DATA_ID}_{dc_string}" + ) + self._attr_device_info = device_info + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.coordinator.data is not None + and self.MODULE_ID in self.coordinator.data + and self.SHADOW_DATA_ID in self.coordinator.data[self.MODULE_ID] + ) + + def _get_shadow_mgmt_value(self) -> int: + """Return the current shadow management value for all strings as integer.""" + try: + return int(self.coordinator.data[self.MODULE_ID][self.SHADOW_DATA_ID]) + except ValueError: + return 0 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn shadow management on.""" + shadow_mgmt_value = self._get_shadow_mgmt_value() + shadow_mgmt_value |= self._mask + + if await self.coordinator.async_write_data( + self.MODULE_ID, {self.SHADOW_DATA_ID: str(shadow_mgmt_value)} + ): + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn shadow management off.""" + shadow_mgmt_value = self._get_shadow_mgmt_value() + shadow_mgmt_value &= ~self._mask + + if await self.coordinator.async_write_data( + self.MODULE_ID, {self.SHADOW_DATA_ID: str(shadow_mgmt_value)} + ): + await self.coordinator.async_request_refresh() + + @property + def is_on(self) -> bool: + """Return true if shadow management is on.""" + return (self._get_shadow_mgmt_value() & self._mask) != 0 diff --git a/tests/components/kostal_plenticore/conftest.py b/tests/components/kostal_plenticore/conftest.py index bedcea4ddc2..22343e59067 100644 --- a/tests/components/kostal_plenticore/conftest.py +++ b/tests/components/kostal_plenticore/conftest.py @@ -2,18 +2,67 @@ from __future__ import annotations -from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, patch +from collections.abc import Generator, Iterable +import copy +from unittest.mock import patch -from pykoplenti import MeData, VersionData +from pykoplenti import ExtendedApiClient, MeData, SettingsData, VersionData import pytest -from homeassistant.components.kostal_plenticore.coordinator import Plenticore from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from tests.common import MockConfigEntry +DEFAULT_SETTING_VALUES = { + "devices:local": { + "Properties:StringCnt": "2", + "Properties:String0Features": "1", + "Properties:String1Features": "1", + "Properties:SerialNo": "42", + "Branding:ProductName1": "PLENTICORE", + "Branding:ProductName2": "plus 10", + "Properties:VersionIOC": "01.45", + "Properties:VersionMC": "01.46", + "Battery:MinSoc": "5", + "Battery:MinHomeComsumption": "50", + }, + "scb:network": {"Hostname": "scb"}, +} + +DEFAULT_SETTINGS = { + "devices:local": [ + SettingsData( + min="5", + max="100", + default=None, + access="readwrite", + unit="%", + id="Battery:MinSoc", + type="byte", + ), + SettingsData( + min="50", + max="38000", + default=None, + access="readwrite", + unit="W", + id="Battery:MinHomeComsumption", + type="byte", + ), + ], + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", + ) + ], +} + @pytest.fixture def mock_config_entry() -> MockConfigEntry: @@ -42,37 +91,67 @@ def mock_installer_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_plenticore() -> Generator[Plenticore]: - """Set up a Plenticore mock with some default values.""" +def mock_get_settings() -> dict[str, list[SettingsData]]: + """Add setting data to mock_plenticore_client. + + Returns a dictionary with setting data which can be mutated by test cases. + """ + return copy.deepcopy(DEFAULT_SETTINGS) + + +@pytest.fixture +def mock_get_setting_values() -> dict[str, dict[str, str]]: + """Add setting values to mock_plenticore_client. + + Returns a dictionary with setting values which can be mutated by test cases. + """ + # Add default settings values - this values are always retrieved by the integration on startup + return copy.deepcopy(DEFAULT_SETTING_VALUES) + + +@pytest.fixture +def mock_plenticore_client( + mock_get_settings: dict[str, list[SettingsData]], + mock_get_setting_values: dict[str, dict[str, str]], +) -> Generator[ExtendedApiClient]: + """Return a patched ExtendedApiClient.""" with patch( - "homeassistant.components.kostal_plenticore.Plenticore", autospec=True - ) as mock_api_class: - # setup - plenticore = mock_api_class.return_value - plenticore.async_setup = AsyncMock() - plenticore.async_setup.return_value = True + "homeassistant.components.kostal_plenticore.coordinator.ExtendedApiClient", + autospec=True, + ) as plenticore_client_class: - plenticore.device_info = DeviceInfo( - configuration_url="http://192.168.1.2", - identifiers={("kostal_plenticore", "12345")}, - manufacturer="Kostal", - model="PLENTICORE plus 10", - name="scb", - sw_version="IOC: 01.45 MC: 01.46", - ) + def default_settings_data(*args): + # the get_setting_values method can be called with different argument types and numbers + match args: + case (str() as module_id, str() as data_id): + request = {module_id: [data_id]} + case (str() as module_id, Iterable() as data_ids): + request = {module_id: data_ids} + case ({},): + request = args[0] + case _: + raise NotImplementedError - plenticore.client = MagicMock() + result = {} + for module_id, data_ids in request.items(): + if (values := mock_get_setting_values.get(module_id)) is not None: + result[module_id] = {} + for data_id in data_ids: + if data_id in values: + result[module_id][data_id] = values[data_id] + else: + raise ValueError( + f"Missing data_id {data_id} in module {module_id}" + ) + else: + raise ValueError(f"Missing module_id {module_id}") - plenticore.client.get_version = AsyncMock() - plenticore.client.get_version.return_value = VersionData( - api_version="0.2.0", - hostname="scb", - name="PUCK RESTful API", - sw_version="01.16.05025", - ) + return result - plenticore.client.get_me = AsyncMock() - plenticore.client.get_me.return_value = MeData( + client = plenticore_client_class.return_value + client.get_setting_values.side_effect = default_settings_data + client.get_settings.return_value = mock_get_settings + client.get_me.return_value = MeData( locked=False, active=True, authenticated=True, @@ -80,11 +159,14 @@ def mock_plenticore() -> Generator[Plenticore]: anonymous=False, role="USER", ) + client.get_version.return_value = VersionData( + api_version="0.2.0", + hostname="scb", + name="PUCK RESTful API", + sw_version="01.16.05025", + ) - plenticore.client.get_process_data = AsyncMock() - plenticore.client.get_settings = AsyncMock() - - yield plenticore + yield client @pytest.fixture diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index 3a99a7f681d..626a9aa93aa 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -1,9 +1,8 @@ """Test Kostal Plenticore diagnostics.""" -from pykoplenti import SettingsData +from unittest.mock import Mock from homeassistant.components.diagnostics import REDACTED -from homeassistant.components.kostal_plenticore.coordinator import Plenticore from homeassistant.core import HomeAssistant from tests.common import ANY, MockConfigEntry @@ -14,30 +13,16 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - mock_plenticore: Plenticore, + mock_plenticore_client: Mock, init_integration: MockConfigEntry, ) -> None: """Test config entry diagnostics.""" - # set some test process and settings data for the diagnostics output - mock_plenticore.client.get_process_data.return_value = { + # set some test process data for the diagnostics output + mock_plenticore_client.get_process_data.return_value = { "devices:local": ["HomeGrid_P", "HomePv_P"] } - mock_plenticore.client.get_settings.return_value = { - "devices:local": [ - SettingsData( - min="5", - max="100", - default=None, - access="readwrite", - unit="%", - id="Battery:MinSoc", - type="byte", - ) - ] - } - assert await get_diagnostics_for_config_entry( hass, hass_client, init_integration ) == { @@ -65,8 +50,19 @@ async def test_entry_diagnostics( "available_process_data": {"devices:local": ["HomeGrid_P", "HomePv_P"]}, "available_settings_data": { "devices:local": [ - "min='5' max='100' default=None access='readwrite' unit='%' id='Battery:MinSoc' type='byte'" - ] + "min='5' max='100' default=None access='readwrite' unit='%' id='Battery:MinSoc' type='byte'", + "min='50' max='38000' default=None access='readwrite' unit='W' id='Battery:MinHomeComsumption' type='byte'", + ], + "scb:network": [ + "min='1' max='63' default=None access='readwrite' unit=None id='Hostname' type='string'" + ], + }, + }, + "configuration": { + "devices:local": { + "Properties:StringCnt": "2", + "Properties:String0Features": "1", + "Properties:String1Features": "1", }, }, "device": { @@ -78,3 +74,28 @@ async def test_entry_diagnostics( "sw_version": "IOC: 01.45 MC: 01.46", }, } + + +async def test_entry_diagnostics_invalid_string_count( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_plenticore_client: Mock, + mock_get_setting_values: Mock, + init_integration: MockConfigEntry, +) -> None: + """Test config entry diagnostics if string count is invalid.""" + + # set some test process data for the diagnostics output + mock_plenticore_client.get_process_data.return_value = { + "devices:local": ["HomeGrid_P", "HomePv_P"] + } + + mock_get_setting_values["devices:local"]["Properties:StringCnt"] = "invalid" + + diagnostic_data = await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) + + assert diagnostic_data["configuration"] == { + "devices:local": {"Properties:StringCnt": "invalid"} + } diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index 586129c486d..64dc7d3c80c 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -1,8 +1,6 @@ """Test Kostal Plenticore number.""" -from collections.abc import Generator from datetime import timedelta -from unittest.mock import patch from pykoplenti import ApiClient, SettingsData import pytest @@ -21,75 +19,9 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed - -@pytest.fixture -def mock_plenticore_client() -> Generator[ApiClient]: - """Return a patched ExtendedApiClient.""" - with patch( - "homeassistant.components.kostal_plenticore.coordinator.ExtendedApiClient", - autospec=True, - ) as plenticore_client_class: - yield plenticore_client_class.return_value - - -@pytest.fixture -def mock_get_setting_values(mock_plenticore_client: ApiClient) -> list: - """Add a setting value to the given Plenticore client. - - Returns a list with setting values which can be extended by test cases. - """ - - mock_plenticore_client.get_settings.return_value = { - "devices:local": [ - SettingsData( - min="5", - max="100", - default=None, - access="readwrite", - unit="%", - id="Battery:MinSoc", - type="byte", - ), - SettingsData( - min="50", - max="38000", - default=None, - access="readwrite", - unit="W", - id="Battery:MinHomeComsumption", - type="byte", - ), - ], - "scb:network": [ - SettingsData( - min="1", - max="63", - default=None, - access="readwrite", - unit=None, - id="Hostname", - type="string", - ) - ], - } - - # this values are always retrieved by the integration on startup - setting_values = [ - { - "devices:local": { - "Properties:SerialNo": "42", - "Branding:ProductName1": "PLENTICORE", - "Branding:ProductName2": "plus 10", - "Properties:VersionIOC": "01.45", - "Properties:VersionMC": " 01.46", - }, - "scb:network": {"Hostname": "scb"}, - } - ] - - mock_plenticore_client.get_setting_values.side_effect = setting_values - - return setting_values +pytestmark = [ + pytest.mark.usefixtures("mock_plenticore_client"), +] @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -97,8 +29,6 @@ async def test_setup_all_entries( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, - mock_plenticore_client: ApiClient, - mock_get_setting_values: list, ) -> None: """Test if all available entries are setup.""" @@ -118,25 +48,27 @@ async def test_setup_no_entries( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, - mock_plenticore_client: ApiClient, - mock_get_setting_values: list, + mock_get_settings: dict[str, list[SettingsData]], ) -> None: """Test that no entries are setup if Plenticore does not provide data.""" # remove all settings except hostname which is used during setup - mock_plenticore_client.get_settings.return_value = { - "scb:network": [ - SettingsData( - min="1", - max="63", - default=None, - access="readwrite", - unit=None, - id="Hostname", - type="string", - ) - ], - } + mock_get_settings.clear() + mock_get_settings.update( + { + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", + ) + ] + } + ) mock_config_entry.add_to_hass(hass) @@ -151,12 +83,11 @@ async def test_setup_no_entries( async def test_number_has_value( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_plenticore_client: ApiClient, - mock_get_setting_values: list, + mock_get_setting_values: dict[str, dict[str, str]], ) -> None: """Test if number has a value if data is provided on update.""" - mock_get_setting_values.append({"devices:local": {"Battery:MinSoc": "42"}}) + mock_get_setting_values["devices:local"]["Battery:MinSoc"] = "42" mock_config_entry.add_to_hass(hass) @@ -176,11 +107,12 @@ async def test_number_has_value( async def test_number_is_unavailable( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_plenticore_client: ApiClient, - mock_get_setting_values: list, + mock_get_setting_values: dict[str, dict[str, str]], ) -> None: """Test if number is unavailable if no data is provided on update.""" + del mock_get_setting_values["devices:local"]["Battery:MinSoc"] + mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -198,11 +130,11 @@ async def test_set_value( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, - mock_get_setting_values: list, + mock_get_setting_values: dict[str, dict[str, str]], ) -> None: """Test if a new value could be set.""" - mock_get_setting_values.append({"devices:local": {"Battery:MinSoc": "42"}}) + mock_get_setting_values["devices:local"]["Battery:MinSoc"] = "42" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/kostal_plenticore/test_select.py b/tests/components/kostal_plenticore/test_select.py index e3fc136a3fb..af6c12d596f 100644 --- a/tests/components/kostal_plenticore/test_select.py +++ b/tests/components/kostal_plenticore/test_select.py @@ -1,24 +1,28 @@ """Test the Kostal Plenticore Solar Inverter select platform.""" from pykoplenti import SettingsData +import pytest -from homeassistant.components.kostal_plenticore.coordinator import Plenticore from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry +pytestmark = [ + pytest.mark.usefixtures("mock_plenticore_client"), +] + async def test_select_battery_charging_usage_available( hass: HomeAssistant, - mock_plenticore: Plenticore, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, + mock_get_settings: dict[str, list[SettingsData]], ) -> None: """Test that the battery charging usage select entity is added if the settings are available.""" - mock_plenticore.client.get_settings.return_value = { - "devices:local": [ + mock_get_settings["devices:local"].extend( + [ SettingsData( min=None, max=None, @@ -38,7 +42,7 @@ async def test_select_battery_charging_usage_available( type="string", ), ] - } + ) mock_config_entry.add_to_hass(hass) @@ -47,10 +51,63 @@ async def test_select_battery_charging_usage_available( assert entity_registry.async_is_registered("select.battery_charging_usage_mode") + entity = entity_registry.async_get("select.battery_charging_usage_mode") + assert entity.capabilities.get("options") == [ + "None", + "Battery:SmartBatteryControl:Enable", + "Battery:TimeControl:Enable", + ] + + +async def test_select_battery_charging_usage_excess_energy_available( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_get_settings: dict[str, list[SettingsData]], + mock_get_setting_values: dict[str, dict[str, str]], +) -> None: + """Test that the battery charging usage select entity contains the option for excess AC energy.""" + + mock_get_settings["devices:local"].extend( + [ + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:SmartBatteryControl:Enable", + type="string", + ), + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:TimeControl:Enable", + type="string", + ), + ] + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_is_registered("select.battery_charging_usage_mode") + + entity = entity_registry.async_get("select.battery_charging_usage_mode") + assert entity.capabilities.get("options") == [ + "None", + "Battery:SmartBatteryControl:Enable", + "Battery:TimeControl:Enable", + ] + async def test_select_battery_charging_usage_not_available( hass: HomeAssistant, - mock_plenticore: Plenticore, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: diff --git a/tests/components/kostal_plenticore/test_switch.py b/tests/components/kostal_plenticore/test_switch.py index 0dd4c958fd5..d1b25442be6 100644 --- a/tests/components/kostal_plenticore/test_switch.py +++ b/tests/components/kostal_plenticore/test_switch.py @@ -1,35 +1,52 @@ """Test the Kostal Plenticore Solar Inverter switch platform.""" -from pykoplenti import SettingsData +from datetime import timedelta +from unittest.mock import Mock -from homeassistant.components.kostal_plenticore.coordinator import Plenticore +from pykoplenti import SettingsData +import pytest + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed + +pytestmark = [ + pytest.mark.usefixtures("mock_plenticore_client"), +] async def test_installer_setting_not_available( hass: HomeAssistant, - mock_plenticore: Plenticore, + mock_get_settings: dict[str, list[SettingsData]], mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test that the manual charge setting is not available when not using the installer login.""" - - mock_plenticore.client.get_settings.return_value = { - "devices:local": [ - SettingsData( - min=None, - max=None, - default=None, - access="readwrite", - unit=None, - id="Battery:ManualCharge", - type="bool", - ) - ] - } + mock_get_settings.update( + { + "devices:local": [ + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:ManualCharge", + type="bool", + ) + ] + } + ) mock_config_entry.add_to_hass(hass) @@ -41,25 +58,26 @@ async def test_installer_setting_not_available( async def test_installer_setting_available( hass: HomeAssistant, - mock_plenticore: Plenticore, + mock_get_settings: dict[str, list[SettingsData]], mock_installer_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test that the manual charge setting is available when using the installer login.""" - - mock_plenticore.client.get_settings.return_value = { - "devices:local": [ - SettingsData( - min=None, - max=None, - default=None, - access="readwrite", - unit=None, - id="Battery:ManualCharge", - type="bool", - ) - ] - } + mock_get_settings.update( + { + "devices:local": [ + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:ManualCharge", + type="bool", + ) + ] + } + ) mock_installer_config_entry.add_to_hass(hass) @@ -67,3 +85,112 @@ async def test_installer_setting_available( await hass.async_block_till_done() assert entity_registry.async_is_registered("switch.scb_battery_manual_charge") + + +async def test_invalid_string_count_value( + hass: HomeAssistant, + mock_get_setting_values: dict[str, dict[str, str]], + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that an invalid string count value is handled correctly.""" + mock_get_setting_values["devices:local"].update({"Properties:StringCnt": "invalid"}) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # ensure no shadow management switch entities were registered + assert [ + name + for name, _ in entity_registry.entities.items() + if name.startswith("switch.scb_shadow_management_dc_string_") + ] == [] + + +@pytest.mark.parametrize( + ("shadow_mgmt", "string"), + [ + ("0", (STATE_OFF, STATE_OFF)), + ("1", (STATE_ON, STATE_OFF)), + ("2", (STATE_OFF, STATE_ON)), + ("3", (STATE_ON, STATE_ON)), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_shadow_management_switch_state( + hass: HomeAssistant, + mock_get_setting_values: dict[str, dict[str, str]], + mock_config_entry: MockConfigEntry, + shadow_mgmt: str, + string: tuple[str, str], +) -> None: + """Test that the state of the shadow management switch is correct.""" + mock_get_setting_values["devices:local"].update( + {"Properties:StringCnt": "2", "Generator:ShadowMgmt:Enable": shadow_mgmt} + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=300)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("switch.scb_shadow_management_dc_string_1") + assert state is not None + assert state.state == string[0] + + state = hass.states.get("switch.scb_shadow_management_dc_string_2") + assert state is not None + assert state.state == string[1] + + +@pytest.mark.parametrize( + ("initial_shadow_mgmt", "dc_string", "service", "shadow_mgmt"), + [ + ("0", 1, SERVICE_TURN_ON, "1"), + ("0", 2, SERVICE_TURN_ON, "2"), + ("2", 1, SERVICE_TURN_ON, "3"), + ("1", 2, SERVICE_TURN_ON, "3"), + ("1", 1, SERVICE_TURN_OFF, "0"), + ("2", 2, SERVICE_TURN_OFF, "0"), + ("3", 1, SERVICE_TURN_OFF, "2"), + ("3", 2, SERVICE_TURN_OFF, "1"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_shadow_management_switch_action( + hass: HomeAssistant, + mock_get_setting_values: dict[str, dict[str, str]], + mock_plenticore_client: Mock, + mock_config_entry: MockConfigEntry, + initial_shadow_mgmt: str, + dc_string: int, + service: str, + shadow_mgmt: str, +) -> None: + """Test that the shadow management can be switch on/off.""" + mock_get_setting_values["devices:local"].update( + { + "Properties:StringCnt": "2", + "Generator:ShadowMgmt:Enable": initial_shadow_mgmt, + } + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=300)) + await hass.async_block_till_done(wait_background_tasks=True) + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + target={ATTR_ENTITY_ID: f"switch.scb_shadow_management_dc_string_{dc_string}"}, + blocking=True, + ) + + mock_plenticore_client.set_setting_values.assert_called_with( + "devices:local", {"Generator:ShadowMgmt:Enable": shadow_mgmt} + )