diff --git a/homeassistant/components/eurotronic_cometblue/__init__.py b/homeassistant/components/eurotronic_cometblue/__init__.py index dd4e8d35741849..c26c0ba4a74db8 100644 --- a/homeassistant/components/eurotronic_cometblue/__init__.py +++ b/homeassistant/components/eurotronic_cometblue/__init__.py @@ -18,6 +18,7 @@ PLATFORMS: list[Platform] = [ Platform.BUTTON, Platform.CLIMATE, + Platform.NUMBER, Platform.SENSOR, ] diff --git a/homeassistant/components/eurotronic_cometblue/icons.json b/homeassistant/components/eurotronic_cometblue/icons.json index ce5f503321444f..5b418f4b2dbff9 100644 --- a/homeassistant/components/eurotronic_cometblue/icons.json +++ b/homeassistant/components/eurotronic_cometblue/icons.json @@ -4,6 +4,17 @@ "sync_time": { "default": "mdi:calendar-clock" } + }, + "number": { + "comfort_setpoint": { + "default": "mdi:thermometer-chevron-up" + }, + "eco_setpoint": { + "default": "mdi:thermometer-chevron-down" + }, + "offset": { + "default": "mdi:thermometer-check" + } } } } diff --git a/homeassistant/components/eurotronic_cometblue/number.py b/homeassistant/components/eurotronic_cometblue/number.py new file mode 100644 index 00000000000000..45aa5b468ffb18 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/number.py @@ -0,0 +1,136 @@ +"""Comet Blue number integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from eurotronic_cometblue_ha import AsyncCometBlue + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import PRECISION_HALVES, EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .climate import MAX_TEMP, MIN_TEMP +from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator +from .entity import CometBlueBluetoothEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class CometBlueRequiredKeysMixin: + """Mixin for required keys.""" + + cometblue_key: str + set_fn: Callable[[AsyncCometBlue], Any] + + +@dataclass(frozen=True, kw_only=True) +class CometBlueNumberEntityDescription( + NumberEntityDescription, CometBlueRequiredKeysMixin +): + """Describes a Comet Blue number entity.""" + + +DESCRIPTIONS = [ + CometBlueNumberEntityDescription( + key="offset", + cometblue_key="tempOffset", + translation_key="offset", + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + set_fn=lambda x: x.set_temperature_async, + native_min_value=-5.0, + native_max_value=5.0, + native_step=PRECISION_HALVES, + entity_registry_enabled_default=False, + ), + CometBlueNumberEntityDescription( + key="eco_setpoint", + cometblue_key="targetTempLow", + translation_key="eco_setpoint", + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + set_fn=lambda x: x.set_temperature_async, + native_min_value=MIN_TEMP, + native_max_value=MAX_TEMP, + native_step=PRECISION_HALVES, + entity_registry_enabled_default=True, + ), + CometBlueNumberEntityDescription( + key="comfort_setpoint", + cometblue_key="targetTempHigh", + translation_key="comfort_setpoint", + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + set_fn=lambda x: x.set_temperature_async, + native_min_value=MIN_TEMP, + native_max_value=MAX_TEMP, + native_step=PRECISION_HALVES, + entity_registry_enabled_default=True, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CometBlueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the client entities.""" + + coordinator = entry.runtime_data + entities: list[CometBlueNumberEntity] = [ + CometBlueNumberEntity(coordinator, description) for description in DESCRIPTIONS + ] + + async_add_entities(entities) + + +class CometBlueNumberEntity(CometBlueBluetoothEntity, NumberEntity): + """Representation of a number.""" + + entity_description: CometBlueNumberEntityDescription + + def __init__( + self, + coordinator: CometBlueDataUpdateCoordinator, + description: CometBlueNumberEntityDescription, + ) -> None: + """Initialize CometBlueNumberEntity.""" + + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.address}-{description.key}" + + @property + def native_value(self) -> float | None: + """Return the entity value to represent the entity state.""" + return self.coordinator.data.temperatures.get( + self.entity_description.cometblue_key + ) + + async def async_set_native_value(self, value: float) -> None: + """Update to the device.""" + + await self.coordinator.send_command( + self.entity_description.set_fn(self.coordinator.device), + { + "values": { + # manual temperature always needs to be set, otherwise TRV will turn OFF + "manualTemp": self.coordinator.data.temperatures["manualTemp"], + self.entity_description.cometblue_key: value, + } + }, + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/eurotronic_cometblue/strings.json b/homeassistant/components/eurotronic_cometblue/strings.json index e69f84a6776809..9722914a088b6d 100644 --- a/homeassistant/components/eurotronic_cometblue/strings.json +++ b/homeassistant/components/eurotronic_cometblue/strings.json @@ -35,6 +35,17 @@ "sync_time": { "name": "Sync time" } + }, + "number": { + "comfort_setpoint": { + "name": "Comfort setpoint" + }, + "eco_setpoint": { + "name": "Eco setpoint" + }, + "offset": { + "name": "Setpoint offset" + } } } } diff --git a/tests/components/eurotronic_cometblue/snapshots/test_number.ambr b/tests/components/eurotronic_cometblue/snapshots/test_number.ambr new file mode 100644 index 00000000000000..a41636fa7ae92e --- /dev/null +++ b/tests/components/eurotronic_cometblue/snapshots/test_number.ambr @@ -0,0 +1,184 @@ +# serializer version: 1 +# name: test_number_state[number.comet_blue_aa_bb_cc_dd_ee_ff_comfort_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 28.5, + 'min': 7.5, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.comet_blue_aa_bb_cc_dd_ee_ff_comfort_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Comfort setpoint', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Comfort setpoint', + 'platform': 'eurotronic_cometblue', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'comfort_setpoint', + 'unique_id': 'aa:bb:cc:dd:ee:ff-comfort_setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_number_state[number.comet_blue_aa_bb_cc_dd_ee_ff_comfort_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Comet Blue aa:bb:cc:dd:ee:ff Comfort setpoint', + 'max': 28.5, + 'min': 7.5, + 'mode': , + 'step': 0.5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.comet_blue_aa_bb_cc_dd_ee_ff_comfort_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.0', + }) +# --- +# name: test_number_state[number.comet_blue_aa_bb_cc_dd_ee_ff_eco_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 28.5, + 'min': 7.5, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.comet_blue_aa_bb_cc_dd_ee_ff_eco_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Eco setpoint', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eco setpoint', + 'platform': 'eurotronic_cometblue', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'eco_setpoint', + 'unique_id': 'aa:bb:cc:dd:ee:ff-eco_setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_number_state[number.comet_blue_aa_bb_cc_dd_ee_ff_eco_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Comet Blue aa:bb:cc:dd:ee:ff Eco setpoint', + 'max': 28.5, + 'min': 7.5, + 'mode': , + 'step': 0.5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.comet_blue_aa_bb_cc_dd_ee_ff_eco_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.0', + }) +# --- +# name: test_number_state[number.comet_blue_aa_bb_cc_dd_ee_ff_setpoint_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 5.0, + 'min': -5.0, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.comet_blue_aa_bb_cc_dd_ee_ff_setpoint_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Setpoint offset', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Setpoint offset', + 'platform': 'eurotronic_cometblue', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'offset', + 'unique_id': 'aa:bb:cc:dd:ee:ff-offset', + 'unit_of_measurement': , + }) +# --- +# name: test_number_state[number.comet_blue_aa_bb_cc_dd_ee_ff_setpoint_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Comet Blue aa:bb:cc:dd:ee:ff Setpoint offset', + 'max': 5.0, + 'min': -5.0, + 'mode': , + 'step': 0.5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.comet_blue_aa_bb_cc_dd_ee_ff_setpoint_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/eurotronic_cometblue/test_number.py b/tests/components/eurotronic_cometblue/test_number.py new file mode 100644 index 00000000000000..e7804b094439a4 --- /dev/null +++ b/tests/components/eurotronic_cometblue/test_number.py @@ -0,0 +1,93 @@ +"""Test the eurotronic_cometblue number platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import FIXTURE_MAC +from .conftest import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + + +def _number_entity_id(entity_registry: er.EntityRegistry, key: str) -> str: + """Resolve a number entity id by unique id.""" + unique_id = f"{FIXTURE_MAC}-{key}" + entity_id = entity_registry.async_get_entity_id( + "number", "eurotronic_cometblue", unique_id + ) + assert entity_id is not None + return entity_id + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test number entity state and registry data.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_number_disabled_by_default_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test disabled-by-default number entities are not available.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER]) + + entity_id = _number_entity_id(entity_registry, "offset") + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.disabled_by is not None + assert hass.states.get(entity_id) is None + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("entity_id", "value", "default_value"), + [ + ("number.comet_blue_aa_bb_cc_dd_ee_ff_eco_setpoint", 18.5, 17.0), + ("number.comet_blue_aa_bb_cc_dd_ee_ff_comfort_setpoint", 23.0, 21.0), + ("number.comet_blue_aa_bb_cc_dd_ee_ff_setpoint_offset", -1.5, 0.0), + ], +) +async def test_set_number_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_id: str, + value: float, + default_value: float, +) -> None: + """Test setting writable number entities.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER]) + + # check the default values + assert (state := hass.states.get(entity_id)) + assert float(state.state) == default_value + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: value, + }, + blocking=True, + ) + + assert (state := hass.states.get(entity_id)) + assert float(state.state) == value