diff --git a/homeassistant/components/niko_home_control/__init__.py b/homeassistant/components/niko_home_control/__init__.py index 650d9f05b84..51e908490e5 100644 --- a/homeassistant/components/niko_home_control/__init__.py +++ b/homeassistant/components/niko_home_control/__init__.py @@ -12,7 +12,12 @@ from homeassistant.helpers import entity_registry as er from .const import _LOGGER -PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT, Platform.SCENE] +PLATFORMS: list[Platform] = [ + Platform.CLIMATE, + Platform.COVER, + Platform.LIGHT, + Platform.SCENE, +] type NikoHomeControlConfigEntry = ConfigEntry[NHCController] diff --git a/homeassistant/components/niko_home_control/climate.py b/homeassistant/components/niko_home_control/climate.py new file mode 100644 index 00000000000..ac19f0bf11f --- /dev/null +++ b/homeassistant/components/niko_home_control/climate.py @@ -0,0 +1,100 @@ +"""Support for Niko Home Control thermostats.""" + +from typing import Any + +from nhc.const import THERMOSTAT_MODES, THERMOSTAT_MODES_REVERSE +from nhc.thermostat import NHCThermostat + +from homeassistant.components.climate import ( + PRESET_ECO, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.sensor import UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NikoHomeControlConfigEntry +from .const import ( + NIKO_HOME_CONTROL_THERMOSTAT_MODES_MAP, + NikoHomeControlThermostatModes, +) +from .entity import NikoHomeControlEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NikoHomeControlConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Niko Home Control thermostat entry.""" + controller = entry.runtime_data + + async_add_entities( + NikoHomeControlClimate(thermostat, controller, entry.entry_id) + for thermostat in controller.thermostats.values() + ) + + +class NikoHomeControlClimate(NikoHomeControlEntity, ClimateEntity): + """Representation of a Niko Home Control thermostat.""" + + _attr_supported_features: ClimateEntityFeature = ( + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_name = None + _action: NHCThermostat + + _attr_translation_key = "nhc_thermostat" + + _attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.AUTO] + + _attr_preset_modes = [ + "day", + "night", + PRESET_ECO, + "prog1", + "prog2", + "prog3", + ] + + def _get_niko_mode(self, mode: str) -> int: + """Return the Niko mode.""" + return THERMOSTAT_MODES_REVERSE.get(mode, NikoHomeControlThermostatModes.OFF) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if ATTR_TEMPERATURE in kwargs: + await self._action.set_temperature(kwargs.get(ATTR_TEMPERATURE)) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self._action.set_mode(self._get_niko_mode(preset_mode)) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + await self._action.set_mode(NIKO_HOME_CONTROL_THERMOSTAT_MODES_MAP[hvac_mode]) + + async def async_turn_off(self) -> None: + """Turn thermostat off.""" + await self._action.set_mode(NikoHomeControlThermostatModes.OFF) + + def update_state(self) -> None: + """Update the state of the entity.""" + if self._action.state == NikoHomeControlThermostatModes.OFF: + self._attr_hvac_mode = HVACMode.OFF + self._attr_preset_mode = None + elif self._action.state == NikoHomeControlThermostatModes.COOL: + self._attr_hvac_mode = HVACMode.COOL + self._attr_preset_mode = None + else: + self._attr_hvac_mode = HVACMode.AUTO + self._attr_preset_mode = THERMOSTAT_MODES[self._action.state] + + self._attr_target_temperature = self._action.setpoint + self._attr_current_temperature = self._action.measured diff --git a/homeassistant/components/niko_home_control/const.py b/homeassistant/components/niko_home_control/const.py index 82b7ce7ed38..cdd6598f02d 100644 --- a/homeassistant/components/niko_home_control/const.py +++ b/homeassistant/components/niko_home_control/const.py @@ -1,6 +1,23 @@ """Constants for niko_home_control integration.""" +from enum import IntEnum import logging +from homeassistant.components.climate import HVACMode + DOMAIN = "niko_home_control" _LOGGER = logging.getLogger(__name__) + +NIKO_HOME_CONTROL_THERMOSTAT_MODES_MAP = { + HVACMode.OFF: 3, + HVACMode.COOL: 4, + HVACMode.AUTO: 5, +} + + +class NikoHomeControlThermostatModes(IntEnum): + """Enum for Niko Home Control thermostat modes.""" + + OFF = 3 + COOL = 4 + AUTO = 5 diff --git a/homeassistant/components/niko_home_control/icons.json b/homeassistant/components/niko_home_control/icons.json new file mode 100644 index 00000000000..93d4784ad29 --- /dev/null +++ b/homeassistant/components/niko_home_control/icons.json @@ -0,0 +1,20 @@ +{ + "entity": { + "climate": { + "nhc_thermostat": { + "state_attributes": { + "preset_mode": { + "default": "mdi:calendar-clock", + "state": { + "day": "mdi:weather-sunny", + "night": "mdi:weather-night", + "prog1": "mdi:numeric-1", + "prog2": "mdi:numeric-2", + "prog3": "mdi:numeric-3" + } + } + } + } + } + } +} diff --git a/homeassistant/components/niko_home_control/strings.json b/homeassistant/components/niko_home_control/strings.json index 135c59eada5..e6dbacccd12 100644 --- a/homeassistant/components/niko_home_control/strings.json +++ b/homeassistant/components/niko_home_control/strings.json @@ -26,5 +26,23 @@ "description": "Set up your Niko Home Control instance." } } + }, + "entity": { + "climate": { + "nhc_thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "day": "Day", + "eco": "Eco", + "night": "Night", + "prog1": "Program 1", + "prog2": "Program 2", + "prog3": "Program 3" + } + } + } + } + } } } diff --git a/tests/components/niko_home_control/conftest.py b/tests/components/niko_home_control/conftest.py index 19890bf8d49..1a76b15f72c 100644 --- a/tests/components/niko_home_control/conftest.py +++ b/tests/components/niko_home_control/conftest.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch from nhc.cover import NHCCover from nhc.light import NHCLight from nhc.scene import NHCScene +from nhc.thermostat import NHCThermostat import pytest from homeassistant.components.niko_home_control.const import DOMAIN @@ -62,6 +63,22 @@ def cover() -> NHCCover: return mock +@pytest.fixture +def climate() -> NHCThermostat: + """Return a thermostat mock.""" + mock = AsyncMock(spec=NHCThermostat) + mock.id = 5 + mock.name = "thermostat" + mock.suggested_area = "room" + mock.state = 0 + mock.measured = 180 + mock.setpoint = 200 + mock.overrule = 0 + mock.overruletime = 0 + mock.ecosave = 0 + return mock + + @pytest.fixture def scene() -> NHCScene: """Return a scene mock.""" @@ -76,7 +93,11 @@ def scene() -> NHCScene: @pytest.fixture def mock_niko_home_control_connection( - light: NHCLight, dimmable_light: NHCLight, cover: NHCCover, scene: NHCScene + light: NHCLight, + dimmable_light: NHCLight, + cover: NHCCover, + climate: NHCThermostat, + scene: NHCScene, ) -> Generator[AsyncMock]: """Mock a NHC client.""" with ( @@ -92,6 +113,7 @@ def mock_niko_home_control_connection( client = mock_client.return_value client.lights = [light, dimmable_light] client.covers = [cover] + client.thermostats = {"thermostat-5": climate} client.scenes = [scene] client.connect = AsyncMock(return_value=True) yield client diff --git a/tests/components/niko_home_control/snapshots/test_climate.ambr b/tests/components/niko_home_control/snapshots/test_climate.ambr new file mode 100644 index 00000000000..abd5b5306aa --- /dev/null +++ b/tests/components/niko_home_control/snapshots/test_climate.ambr @@ -0,0 +1,84 @@ +# serializer version: 1 +# name: test_entities[climate.thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'day', + 'night', + 'eco', + 'prog1', + 'prog2', + 'prog3', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'niko_home_control', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'nhc_thermostat', + 'unique_id': '01JFN93M7KRA38V5AMPCJ2JYYV-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 180, + 'friendly_name': 'thermostat', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': 'day', + 'preset_modes': list([ + 'day', + 'night', + 'eco', + 'prog1', + 'prog2', + 'prog3', + ]), + 'supported_features': , + 'temperature': 200, + }), + 'context': , + 'entity_id': 'climate.thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/niko_home_control/test_climate.py b/tests/components/niko_home_control/test_climate.py new file mode 100644 index 00000000000..a4bca0ac7c7 --- /dev/null +++ b/tests/components/niko_home_control/test_climate.py @@ -0,0 +1,116 @@ +"""Tests for the Niko Home Control Climate platform.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ATTR_HVAC_MODE, ATTR_PRESET_MODE, HVACMode +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import find_update_callback, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.niko_home_control.PLATFORMS", [Platform.CLIMATE] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "service_parameters", "api_method", "api_parameters"), + [ + ("set_temperature", {"temperature": 25}, "set_temperature", (25,)), + ("set_preset_mode", {ATTR_PRESET_MODE: "eco"}, "set_mode", (2,)), + ("set_hvac_mode", {ATTR_HVAC_MODE: HVACMode.COOL}, "set_mode", (4,)), + ("set_hvac_mode", {ATTR_HVAC_MODE: HVACMode.OFF}, "set_mode", (3,)), + ("set_hvac_mode", {ATTR_HVAC_MODE: HVACMode.AUTO}, "set_mode", (5,)), + ], +) +async def test_set( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + climate: AsyncMock, + service: str, + service_parameters: dict[str, Any], + api_method: str, + api_parameters: tuple[Any, ...], +) -> None: + """Test setting a value on the climate entity.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + service, + {ATTR_ENTITY_ID: "climate.thermostat"} | service_parameters, + blocking=True, + ) + getattr( + mock_niko_home_control_connection.thermostats["thermostat-5"], + api_method, + ).assert_called_once_with(*api_parameters) + + +async def test_updating( + hass: HomeAssistant, + mock_niko_home_control_connection: AsyncMock, + mock_config_entry: MockConfigEntry, + climate: AsyncMock, +) -> None: + """Test updating the thermostat.""" + await setup_integration(hass, mock_config_entry) + + climate.state = 0 + await find_update_callback(mock_niko_home_control_connection, 5)(0) + assert hass.states.get("climate.thermostat").attributes.get("preset_mode") == "day" + assert hass.states.get("climate.thermostat").state == "auto" + + climate.state = 1 + await find_update_callback(mock_niko_home_control_connection, 5)(1) + assert ( + hass.states.get("climate.thermostat").attributes.get("preset_mode") == "night" + ) + assert hass.states.get("climate.thermostat").state == "auto" + + climate.state = 2 + await find_update_callback(mock_niko_home_control_connection, 5)(2) + assert hass.states.get("climate.thermostat").state == "auto" + assert hass.states.get("climate.thermostat").attributes["preset_mode"] == "eco" + + climate.state = 3 + await find_update_callback(mock_niko_home_control_connection, 5)(3) + assert hass.states.get("climate.thermostat").state == "off" + + climate.state = 4 + await find_update_callback(mock_niko_home_control_connection, 5)(4) + assert hass.states.get("climate.thermostat").state == "cool" + + climate.state = 5 + await find_update_callback(mock_niko_home_control_connection, 5)(5) + assert hass.states.get("climate.thermostat").state == "auto" + assert hass.states.get("climate.thermostat").attributes["preset_mode"] == "prog1" + + climate.state = 6 + await find_update_callback(mock_niko_home_control_connection, 5)(6) + assert hass.states.get("climate.thermostat").state == "auto" + assert hass.states.get("climate.thermostat").attributes["preset_mode"] == "prog2" + + climate.state = 7 + await find_update_callback(mock_niko_home_control_connection, 5)(7) + assert hass.states.get("climate.thermostat").state == "auto" + assert hass.states.get("climate.thermostat").attributes["preset_mode"] == "prog3"