diff --git a/homeassistant/components/kostal_plenticore/__init__.py b/homeassistant/components/kostal_plenticore/__init__.py index a42ad0a64ff..b431960caef 100644 --- a/homeassistant/components/kostal_plenticore/__init__.py +++ b/homeassistant/components/kostal_plenticore/__init__.py @@ -12,7 +12,7 @@ from .helper import Plenticore _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SELECT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.NUMBER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/kostal_plenticore/const.py b/homeassistant/components/kostal_plenticore/const.py index ac2ecb44fd5..ba850ed58bd 100644 --- a/homeassistant/components/kostal_plenticore/const.py +++ b/homeassistant/components/kostal_plenticore/const.py @@ -1,6 +1,8 @@ """Constants for the Kostal Plenticore Solar Inverter integration.""" +from dataclasses import dataclass from typing import NamedTuple +from homeassistant.components.number import NumberEntityDescription from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, @@ -16,6 +18,7 @@ from homeassistant.const import ( PERCENTAGE, POWER_WATT, ) +from homeassistant.helpers.entity import EntityCategory DOMAIN = "kostal_plenticore" @@ -790,31 +793,54 @@ SENSOR_PROCESS_DATA = [ ), ] -# Defines all entities for settings. -# -# Each entry is defined with a tuple of these values: -# - module id (str) -# - process data id (str) -# - entity name suffix (str) -# - sensor properties (dict) -# - value formatter (str) -SENSOR_SETTINGS_DATA = [ - ( - "devices:local", - "Battery:MinHomeComsumption", - "Battery min Home Consumption", - { - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: SensorDeviceClass.POWER, - }, - "format_round", + +@dataclass +class PlenticoreNumberEntityDescriptionMixin: + """Define an entity description mixin for number entities.""" + + module_id: str + data_id: str + fmt_from: str + fmt_to: str + + +@dataclass +class PlenticoreNumberEntityDescription( + NumberEntityDescription, PlenticoreNumberEntityDescriptionMixin +): + """Describes a Plenticore number entity.""" + + +NUMBER_SETTINGS_DATA = [ + PlenticoreNumberEntityDescription( + key="battery_min_soc", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:battery-negative", + name="Battery min SoC", + unit_of_measurement=PERCENTAGE, + max_value=100, + min_value=5, + step=5, + module_id="devices:local", + data_id="Battery:MinSoc", + fmt_from="format_round", + fmt_to="format_round_back", ), - ( - "devices:local", - "Battery:MinSoc", - "Battery min Soc", - {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, ATTR_ICON: "mdi:battery-negative"}, - "format_round", + PlenticoreNumberEntityDescription( + key="battery_min_home_consumption", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + name="Battery min Home Consumption", + unit_of_measurement=POWER_WATT, + max_value=38000, + min_value=50, + step=1, + module_id="devices:local", + data_id="Battery:MinHomeComsumption", + fmt_from="format_round", + fmt_to="format_round_back", ), ] diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index e047c0dafba..c87d96161a4 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -3,9 +3,10 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Iterable +from collections.abc import Callable, Iterable from datetime import datetime, timedelta import logging +from typing import Any from aiohttp.client_exceptions import ClientError from kostal.plenticore import ( @@ -122,7 +123,7 @@ class DataUpdateCoordinatorMixin: """Base implementation for read and write data.""" async def async_read_data(self, module_id: str, data_id: str) -> list[str, bool]: - """Write settings back to Plenticore.""" + """Read data from Plenticore.""" if (client := self._plenticore.client) is None: return False @@ -138,6 +139,10 @@ class DataUpdateCoordinatorMixin: if (client := self._plenticore.client) is None: return False + _LOGGER.debug( + "Setting value for %s in module %s to %s", self.name, module_id, value + ) + try: await client.set_setting_values(module_id, value) except PlenticoreApiException: @@ -328,7 +333,7 @@ class PlenticoreDataFormatter: } @classmethod - def get_method(cls, name: str) -> callable: + def get_method(cls, name: str) -> Callable[[Any], Any]: """Return a callable formatter of the given name.""" return getattr(cls, name) @@ -340,6 +345,21 @@ class PlenticoreDataFormatter: except (TypeError, ValueError): return state + @staticmethod + def format_round_back(value: float) -> str: + """Return a rounded integer value from a float.""" + try: + if isinstance(value, float) and value.is_integer(): + int_value = int(value) + elif isinstance(value, int): + int_value = value + else: + int_value = round(value) + + return str(int_value) + except (TypeError, ValueError): + return "" + @staticmethod def format_float(state: str) -> int | str: """Return the given state value as float rounded to three decimal places.""" diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py new file mode 100644 index 00000000000..33d431d6396 --- /dev/null +++ b/homeassistant/components/kostal_plenticore/number.py @@ -0,0 +1,157 @@ +"""Platform for Kostal Plenticore numbers.""" +from __future__ import annotations + +from abc import ABC +from datetime import timedelta +from functools import partial +import logging + +from kostal.plenticore import SettingsData + +from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, NUMBER_SETTINGS_DATA, PlenticoreNumberEntityDescription +from .helper import PlenticoreDataFormatter, SettingDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Kostal Plenticore Number entities.""" + plenticore = hass.data[DOMAIN][entry.entry_id] + + entities = [] + + available_settings_data = await plenticore.client.get_settings() + settings_data_update_coordinator = SettingDataUpdateCoordinator( + hass, + _LOGGER, + "Settings Data", + timedelta(seconds=30), + plenticore, + ) + + for description in NUMBER_SETTINGS_DATA: + if ( + description.module_id not in available_settings_data + or description.data_id + not in ( + setting.id for setting in available_settings_data[description.module_id] + ) + ): + _LOGGER.debug( + "Skipping non existing setting data %s/%s", + description.module_id, + description.data_id, + ) + continue + + setting_data = next( + filter( + partial(lambda id, sd: id == sd.id, description.data_id), + available_settings_data[description.module_id], + ) + ) + + entities.append( + PlenticoreDataNumber( + settings_data_update_coordinator, + entry.entry_id, + entry.title, + plenticore.device_info, + description, + setting_data, + ) + ) + + async_add_entities(entities) + + +class PlenticoreDataNumber(CoordinatorEntity, NumberEntity, ABC): + """Representation of a Kostal Plenticore Number entity.""" + + entity_description: PlenticoreNumberEntityDescription + coordinator: SettingDataUpdateCoordinator + + def __init__( + self, + coordinator: SettingDataUpdateCoordinator, + entry_id: str, + platform_name: str, + device_info: DeviceInfo, + description: PlenticoreNumberEntityDescription, + setting_data: SettingsData, + ) -> None: + """Initialize the Plenticore Number entity.""" + super().__init__(coordinator) + + self.entity_description = description + self.entry_id = entry_id + + self._attr_device_info = device_info + self._attr_unique_id = f"{self.entry_id}_{self.module_id}_{self.data_id}" + self._attr_name = f"{platform_name} {description.name}" + self._attr_mode = NumberMode.BOX + + self._formatter = PlenticoreDataFormatter.get_method(description.fmt_from) + self._formatter_back = PlenticoreDataFormatter.get_method(description.fmt_to) + + # overwrite from retrieved setting data + if setting_data.min is not None: + self._attr_min_value = self._formatter(setting_data.min) + if setting_data.max is not None: + self._attr_max_value = self._formatter(setting_data.max) + + @property + def module_id(self) -> str: + """Return the plenticore module id of this entity.""" + return self.entity_description.module_id + + @property + def data_id(self) -> str: + """Return the plenticore data id for this entity.""" + return self.entity_description.data_id + + @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.data_id in self.coordinator.data[self.module_id] + ) + + async def async_added_to_hass(self) -> None: + """Register this entity on the Update Coordinator.""" + await super().async_added_to_hass() + self.coordinator.start_fetch_data(self.module_id, self.data_id) + + async def async_will_remove_from_hass(self) -> None: + """Unregister this entity from the Update Coordinator.""" + self.coordinator.stop_fetch_data(self.module_id, self.data_id) + await super().async_will_remove_from_hass() + + @property + def value(self) -> float | None: + """Return the current value.""" + if self.available: + raw_value = self.coordinator.data[self.module_id][self.data_id] + return self._formatter(raw_value) + + return None + + async def async_set_value(self, value: float) -> None: + """Set a new value.""" + str_value = self._formatter_back(value) + await self.coordinator.async_write_data( + self.module_id, {self.data_id: str_value} + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 0b6b01aca71..5f8fb47e85a 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -14,17 +14,8 @@ from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - ATTR_ENABLED_DEFAULT, - DOMAIN, - SENSOR_PROCESS_DATA, - SENSOR_SETTINGS_DATA, -) -from .helper import ( - PlenticoreDataFormatter, - ProcessDataUpdateCoordinator, - SettingDataUpdateCoordinator, -) +from .const import ATTR_ENABLED_DEFAULT, DOMAIN, SENSOR_PROCESS_DATA +from .helper import PlenticoreDataFormatter, ProcessDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -70,38 +61,6 @@ async def async_setup_entry( ) ) - available_settings_data = await plenticore.client.get_settings() - settings_data_update_coordinator = SettingDataUpdateCoordinator( - hass, - _LOGGER, - "Settings Data", - timedelta(seconds=300), - plenticore, - ) - for module_id, data_id, name, sensor_data, fmt in SENSOR_SETTINGS_DATA: - if module_id not in available_settings_data or data_id not in ( - setting.id for setting in available_settings_data[module_id] - ): - _LOGGER.debug( - "Skipping non existing setting data %s/%s", module_id, data_id - ) - continue - - entities.append( - PlenticoreDataSensor( - settings_data_update_coordinator, - entry.entry_id, - entry.title, - module_id, - data_id, - name, - sensor_data, - PlenticoreDataFormatter.get_method(fmt), - plenticore.device_info, - EntityCategory.DIAGNOSTIC, - ) - ) - async_add_entities(entities) diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py new file mode 100644 index 00000000000..43d693642d9 --- /dev/null +++ b/tests/components/kostal_plenticore/test_number.py @@ -0,0 +1,197 @@ +"""Test Kostal Plenticore number.""" + +from unittest.mock import AsyncMock, MagicMock + +from kostal.plenticore import SettingsData +import pytest + +from homeassistant.components.kostal_plenticore.const import ( + PlenticoreNumberEntityDescription, +) +from homeassistant.components.kostal_plenticore.number import PlenticoreDataNumber +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import async_get + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_coordinator() -> MagicMock: + """Return a mocked coordinator for tests.""" + coordinator = MagicMock() + coordinator.async_write_data = AsyncMock() + coordinator.async_refresh = AsyncMock() + return coordinator + + +@pytest.fixture +def mock_number_description() -> PlenticoreNumberEntityDescription: + """Return a PlenticoreNumberEntityDescription for tests.""" + return PlenticoreNumberEntityDescription( + key="mock key", + module_id="moduleid", + data_id="dataid", + min_value=0, + max_value=1000, + fmt_from="format_round", + fmt_to="format_round_back", + ) + + +@pytest.fixture +def mock_setting_data() -> SettingsData: + """Return a default SettingsData for tests.""" + return SettingsData( + { + "default": None, + "min": None, + "access": None, + "max": None, + "unit": None, + "type": None, + "id": "data_id", + } + ) + + +async def test_setup_all_entries( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_plenticore: MagicMock +): + """Test if all available entries are setup up.""" + mock_plenticore.client.get_settings.return_value = { + "devices:local": [ + SettingsData({"id": "Battery:MinSoc", "min": None, "max": None}), + SettingsData( + {"id": "Battery:MinHomeComsumption", "min": None, "max": None} + ), + ] + } + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + ent_reg = async_get(hass) + assert ent_reg.async_get("number.scb_battery_min_soc") is not None + assert ent_reg.async_get("number.scb_battery_min_home_consumption") is not None + + +async def test_setup_no_entries( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_plenticore: MagicMock +): + """Test that no entries are setup up.""" + mock_plenticore.client.get_settings.return_value = [] + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + ent_reg = async_get(hass) + assert ent_reg.async_get("number.scb_battery_min_soc") is None + assert ent_reg.async_get("number.scb_battery_min_home_consumption") is None + + +def test_number_returns_value_if_available( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if value property on PlenticoreDataNumber returns an int if available.""" + + mock_coordinator.data = {"moduleid": {"dataid": "42"}} + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + assert entity.value == 42 + assert type(entity.value) == int + + +def test_number_returns_none_if_unavailable( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if value property on PlenticoreDataNumber returns none if unavailable.""" + + mock_coordinator.data = {} # makes entity not available + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + assert entity.value is None + + +async def test_set_value( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if set value calls coordinator with new value.""" + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + await entity.async_set_value(42) + + mock_coordinator.async_write_data.assert_called_once_with( + "moduleid", {"dataid": "42"} + ) + mock_coordinator.async_refresh.assert_called_once() + + +async def test_minmax_overwrite( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, +): + """Test if min/max value is overwritten from retrieved settings data.""" + + setting_data = SettingsData( + { + "min": "5", + "max": "100", + } + ) + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, setting_data + ) + + assert entity.min_value == 5 + assert entity.max_value == 100 + + +async def test_added_to_hass( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if coordinator starts fetching after added to hass.""" + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + await entity.async_added_to_hass() + + mock_coordinator.start_fetch_data.assert_called_once_with("moduleid", "dataid") + + +async def test_remove_from_hass( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if coordinator stops fetching after remove from hass.""" + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + await entity.async_will_remove_from_hass() + + mock_coordinator.stop_fetch_data.assert_called_once_with("moduleid", "dataid")