"""Tests for the Nobø Ecohub climate platform.""" from unittest.mock import MagicMock from pynobo import PynoboError, nobo import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, ATTR_HVAC_MODE, ATTR_PRESET_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, PRESET_AWAY, PRESET_COMFORT, PRESET_ECO, PRESET_NONE, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, HVACMode, ) from homeassistant.components.nobo_hub.const import ( CONF_OVERRIDE_TYPE, DOMAIN, OVERRIDE_TYPE_NOW, ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import entity_unique_ids, fire_hub_update from .conftest import SERIAL from tests.common import MockConfigEntry, snapshot_platform CLIMATE_ENTITY = "climate.living_room_living_room" BEDROOM_ZONE = { "zone_id": "2", "name": "Bedroom", "week_profile_id": "0", "temp_comfort_c": "22", "temp_eco_c": "18", } @pytest.fixture def platforms() -> list[Platform]: """Only set up the climate platform for these tests.""" return [Platform.CLIMATE] @pytest.mark.usefixtures("init_integration") async def test_climate_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, ) -> None: """All climate entities match their snapshot.""" await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( ("zone_mode", "expected_state", "expected_preset"), [ (nobo.API.NAME_OFF, HVACMode.OFF, PRESET_NONE), (nobo.API.NAME_AWAY, HVACMode.AUTO, PRESET_AWAY), (nobo.API.NAME_ECO, HVACMode.AUTO, PRESET_ECO), (nobo.API.NAME_COMFORT, HVACMode.AUTO, PRESET_COMFORT), ], ) @pytest.mark.usefixtures("init_integration") async def test_state_maps_zone_mode( hass: HomeAssistant, mock_nobo_hub: MagicMock, zone_mode: str, expected_state: HVACMode, expected_preset: str, ) -> None: """Zone modes map to the expected HVAC mode and preset.""" mock_nobo_hub.get_current_zone_mode.return_value = zone_mode await fire_hub_update(hass, mock_nobo_hub) state = hass.states.get(CLIMATE_ENTITY) assert state.state == expected_state assert state.attributes[ATTR_PRESET_MODE] == expected_preset @pytest.mark.usefixtures("init_integration") async def test_state_override_forces_heat( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: """A non-normal zone override maps to HVACMode.HEAT.""" # Any non-NORMAL override value suffices; NAME_COMFORT is arbitrary. mock_nobo_hub.get_zone_override_mode.return_value = nobo.API.NAME_COMFORT await fire_hub_update(hass, mock_nobo_hub) assert hass.states.get(CLIMATE_ENTITY).state == HVACMode.HEAT @pytest.mark.usefixtures("init_integration") async def test_current_temperature_unknown_when_missing( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: """A missing current temperature surfaces as None.""" mock_nobo_hub.get_current_zone_temperature.return_value = None await fire_hub_update(hass, mock_nobo_hub) assert hass.states.get(CLIMATE_ENTITY).attributes[ATTR_CURRENT_TEMPERATURE] is None @pytest.mark.parametrize( ("hvac_mode", "expected_override"), [ (HVACMode.AUTO, nobo.API.OVERRIDE_MODE_NORMAL), (HVACMode.HEAT, nobo.API.OVERRIDE_MODE_COMFORT), ], ) @pytest.mark.usefixtures("init_integration") async def test_set_hvac_mode( hass: HomeAssistant, mock_nobo_hub: MagicMock, hvac_mode: HVACMode, expected_override: str, ) -> None: """Each HVAC mode maps to the expected zone override.""" await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: CLIMATE_ENTITY, ATTR_HVAC_MODE: hvac_mode}, blocking=True, ) mock_nobo_hub.async_create_override.assert_called_once_with( expected_override, nobo.API.OVERRIDE_TYPE_CONSTANT, nobo.API.OVERRIDE_TARGET_ZONE, "1", ) @pytest.mark.parametrize( ("preset", "expected_mode"), [ (PRESET_NONE, nobo.API.OVERRIDE_MODE_NORMAL), (PRESET_COMFORT, nobo.API.OVERRIDE_MODE_COMFORT), (PRESET_ECO, nobo.API.OVERRIDE_MODE_ECO), (PRESET_AWAY, nobo.API.OVERRIDE_MODE_AWAY), ], ) @pytest.mark.usefixtures("init_integration") async def test_set_preset_mode( hass: HomeAssistant, mock_nobo_hub: MagicMock, preset: str, expected_mode: str, ) -> None: """Each preset maps to the expected override mode.""" await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: CLIMATE_ENTITY, ATTR_PRESET_MODE: preset}, blocking=True, ) mock_nobo_hub.async_create_override.assert_called_once_with( expected_mode, nobo.API.OVERRIDE_TYPE_CONSTANT, nobo.API.OVERRIDE_TARGET_ZONE, "1", ) @pytest.mark.parametrize( "config_entry_options", [{CONF_OVERRIDE_TYPE: OVERRIDE_TYPE_NOW}], ) @pytest.mark.usefixtures("init_integration") async def test_set_preset_with_override_type_now( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: """The override_type option flows into the zone override call.""" await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: CLIMATE_ENTITY, ATTR_PRESET_MODE: PRESET_COMFORT}, blocking=True, ) mock_nobo_hub.async_create_override.assert_called_once_with( nobo.API.OVERRIDE_MODE_COMFORT, nobo.API.OVERRIDE_TYPE_NOW, nobo.API.OVERRIDE_TARGET_ZONE, "1", ) @pytest.mark.usefixtures("init_integration") async def test_zone_removed_removes_entity( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: """Removing a zone via the Nobø app must not crash and removes the entity.""" mock_nobo_hub.zones.pop("1") await fire_hub_update(hass, mock_nobo_hub) assert hass.states.get(CLIMATE_ENTITY) is None @pytest.mark.usefixtures("init_integration") async def test_set_temperature_updates_zone( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: """Setting target temperatures updates the zone on the hub.""" await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: CLIMATE_ENTITY, ATTR_TARGET_TEMP_LOW: 16.4, ATTR_TARGET_TEMP_HIGH: 21.6, }, blocking=True, ) mock_nobo_hub.async_update_zone.assert_called_once_with( "1", temp_comfort_c=22, temp_eco_c=16 ) @pytest.mark.parametrize( ("service", "service_data", "mock_attr", "expected_key"), [ ( SERVICE_SET_HVAC_MODE, {ATTR_HVAC_MODE: HVACMode.HEAT}, "async_create_override", "set_hvac_mode_failed", ), ( SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: PRESET_COMFORT}, "async_create_override", "set_preset_mode_failed", ), ( SERVICE_SET_TEMPERATURE, {ATTR_TARGET_TEMP_LOW: 17, ATTR_TARGET_TEMP_HIGH: 21}, "async_update_zone", "set_temperature_failed", ), ], ids=["set_hvac_mode", "set_preset_mode", "set_temperature"], ) @pytest.mark.usefixtures("init_integration") async def test_climate_action_wraps_library_error( hass: HomeAssistant, mock_nobo_hub: MagicMock, service: str, service_data: dict[str, object], mock_attr: str, expected_key: str, ) -> None: """Library errors during climate actions are raised as HomeAssistantError.""" getattr(mock_nobo_hub, mock_attr).side_effect = PynoboError("boom") with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( CLIMATE_DOMAIN, service, {ATTR_ENTITY_ID: CLIMATE_ENTITY, **service_data}, blocking=True, ) assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == expected_key @pytest.mark.usefixtures("init_integration") async def test_new_zone_adds_entity( hass: HomeAssistant, mock_nobo_hub: MagicMock, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, ) -> None: """A zone added on the hub at runtime creates a climate entity.""" entry_id = mock_config_entry.entry_id assert f"{SERIAL}:2" not in entity_unique_ids(entity_registry, entry_id) mock_nobo_hub.zones["2"] = BEDROOM_ZONE await fire_hub_update(hass, mock_nobo_hub) assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) @pytest.mark.usefixtures("init_integration") async def test_readded_zone_reappears( hass: HomeAssistant, mock_nobo_hub: MagicMock, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, ) -> None: """A zone removed and re-added under the same id (the hub reuses ids) reappears.""" entry_id = mock_config_entry.entry_id mock_nobo_hub.zones["2"] = BEDROOM_ZONE await fire_hub_update(hass, mock_nobo_hub) assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) del mock_nobo_hub.zones["2"] await fire_hub_update(hass, mock_nobo_hub) assert f"{SERIAL}:2" not in entity_unique_ids(entity_registry, entry_id) mock_nobo_hub.zones["2"] = BEDROOM_ZONE await fire_hub_update(hass, mock_nobo_hub) assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id)