mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Add number platform for kostal_plenticore (#64927)
This commit is contained in:
parent
9b60b0c23f
commit
305dff0dc1
@ -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:
|
||||
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
|
@ -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."""
|
||||
|
157
homeassistant/components/kostal_plenticore/number.py
Normal file
157
homeassistant/components/kostal_plenticore/number.py
Normal 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()
|
@ -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)
|
||||
|
||||
|
||||
|
197
tests/components/kostal_plenticore/test_number.py
Normal file
197
tests/components/kostal_plenticore/test_number.py
Normal 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")
|
Loading…
x
Reference in New Issue
Block a user