Add number platform for kostal_plenticore (#64927)

This commit is contained in:
stegm 2022-06-29 06:29:21 +02:00 committed by GitHub
parent 9b60b0c23f
commit 305dff0dc1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 430 additions and 71 deletions

View File

@ -12,7 +12,7 @@ from .helper import Plenticore
_LOGGER = logging.getLogger(__name__) _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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View File

@ -1,6 +1,8 @@
"""Constants for the Kostal Plenticore Solar Inverter integration.""" """Constants for the Kostal Plenticore Solar Inverter integration."""
from dataclasses import dataclass
from typing import NamedTuple from typing import NamedTuple
from homeassistant.components.number import NumberEntityDescription
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
ATTR_STATE_CLASS, ATTR_STATE_CLASS,
SensorDeviceClass, SensorDeviceClass,
@ -16,6 +18,7 @@ from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
POWER_WATT, POWER_WATT,
) )
from homeassistant.helpers.entity import EntityCategory
DOMAIN = "kostal_plenticore" DOMAIN = "kostal_plenticore"
@ -790,31 +793,54 @@ SENSOR_PROCESS_DATA = [
), ),
] ]
# Defines all entities for settings.
# @dataclass
# Each entry is defined with a tuple of these values: class PlenticoreNumberEntityDescriptionMixin:
# - module id (str) """Define an entity description mixin for number entities."""
# - process data id (str)
# - entity name suffix (str) module_id: str
# - sensor properties (dict) data_id: str
# - value formatter (str) fmt_from: str
SENSOR_SETTINGS_DATA = [ fmt_to: str
(
"devices:local",
"Battery:MinHomeComsumption", @dataclass
"Battery min Home Consumption", class PlenticoreNumberEntityDescription(
{ NumberEntityDescription, PlenticoreNumberEntityDescriptionMixin
ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, ):
ATTR_DEVICE_CLASS: SensorDeviceClass.POWER, """Describes a Plenticore number entity."""
},
"format_round",
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",
), ),
( PlenticoreNumberEntityDescription(
"devices:local", key="battery_min_home_consumption",
"Battery:MinSoc", device_class=SensorDeviceClass.POWER,
"Battery min Soc", entity_category=EntityCategory.CONFIG,
{ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, ATTR_ICON: "mdi:battery-negative"}, entity_registry_enabled_default=False,
"format_round", 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",
), ),
] ]

View File

@ -3,9 +3,10 @@ from __future__ import annotations
import asyncio import asyncio
from collections import defaultdict from collections import defaultdict
from collections.abc import Iterable from collections.abc import Callable, Iterable
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from typing import Any
from aiohttp.client_exceptions import ClientError from aiohttp.client_exceptions import ClientError
from kostal.plenticore import ( from kostal.plenticore import (
@ -122,7 +123,7 @@ class DataUpdateCoordinatorMixin:
"""Base implementation for read and write data.""" """Base implementation for read and write data."""
async def async_read_data(self, module_id: str, data_id: str) -> list[str, bool]: 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: if (client := self._plenticore.client) is None:
return False return False
@ -138,6 +139,10 @@ class DataUpdateCoordinatorMixin:
if (client := self._plenticore.client) is None: if (client := self._plenticore.client) is None:
return False return False
_LOGGER.debug(
"Setting value for %s in module %s to %s", self.name, module_id, value
)
try: try:
await client.set_setting_values(module_id, value) await client.set_setting_values(module_id, value)
except PlenticoreApiException: except PlenticoreApiException:
@ -328,7 +333,7 @@ class PlenticoreDataFormatter:
} }
@classmethod @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 a callable formatter of the given name."""
return getattr(cls, name) return getattr(cls, name)
@ -340,6 +345,21 @@ class PlenticoreDataFormatter:
except (TypeError, ValueError): except (TypeError, ValueError):
return state 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 @staticmethod
def format_float(state: str) -> int | str: def format_float(state: str) -> int | str:
"""Return the given state value as float rounded to three decimal places.""" """Return the given state value as float rounded to three decimal places."""

View File

@ -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()

View File

@ -14,17 +14,8 @@ from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ( from .const import ATTR_ENABLED_DEFAULT, DOMAIN, SENSOR_PROCESS_DATA
ATTR_ENABLED_DEFAULT, from .helper import PlenticoreDataFormatter, ProcessDataUpdateCoordinator
DOMAIN,
SENSOR_PROCESS_DATA,
SENSOR_SETTINGS_DATA,
)
from .helper import (
PlenticoreDataFormatter,
ProcessDataUpdateCoordinator,
SettingDataUpdateCoordinator,
)
_LOGGER = logging.getLogger(__name__) _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) async_add_entities(entities)

View File

@ -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")