"""Tests for iZone climate platform.""" import logging from unittest.mock import AsyncMock, Mock, PropertyMock from freezegun.api import FrozenDateTimeFactory from pizone import Controller, ControllerCommandError, Zone import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, ATTR_FAN_MODES, ATTR_HVAC_MODE, ATTR_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE, ClimateEntityFeature, HVACMode, ) from homeassistant.components.izone.climate import ( ATTR_AIRFLOW, IZONE_SERVICE_AIRFLOW_MAX, IZONE_SERVICE_AIRFLOW_MIN, ) from homeassistant.components.izone.const import DOMAIN from homeassistant.components.izone.coordinator import UPDATE_INTERVAL from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.device_registry as dr import homeassistant.helpers.entity_registry as er from . import setup_integration from .conftest import create_mock_controller, create_mock_zone from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform CONTROLLER_ENTITY = "climate.izone_controller_000000001" ZONE_ENTITY = "climate.living_room" def _raise_on_controller_property( mock_controller: Mock, name: str, message: str ) -> None: """Make a controller property raise ValueError without affecting other mocks.""" # Unique type so PropertyMock does not affect other Mock instances. raising_type = type( f"Raising{name.title()}Controller_{id(mock_controller)}", (Mock,), {} ) mock_controller.__class__ = raising_type setattr(type(mock_controller), name, PropertyMock(side_effect=ValueError(message))) @pytest.mark.usefixtures("init_integration") async def test_climate_entities( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Controller and zone climate entities are created from the coordinator.""" await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) # Snapshot covers registry + state payloads; also assert the stable entity_ids # used by the rest of this module still resolve after setup. assert hass.states.get(CONTROLLER_ENTITY) is not None assert hass.states.get(ZONE_ENTITY) is not None @pytest.mark.usefixtures("init_integration") async def test_zone_device_linked_to_controller( device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry, ) -> None: """The zone device is linked to the controller device via via_device_id.""" controller_device = device_registry.async_get_device_by_identifier( (DOMAIN, "000000001"), mock_config_entry.entry_id ) zone_device = device_registry.async_get_device_by_identifier( (DOMAIN, "000000001", 0), # type:ignore[arg-type] mock_config_entry.entry_id, ) assert controller_device is not None assert zone_device is not None assert zone_device.via_device_id == controller_device.id @pytest.mark.parametrize( "mock_controller", [create_mock_controller(ras_mode="RAS", zones_total=1)], ) async def test_set_controller_temperature_ras( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_create_discovery: AsyncMock, mock_controller: Mock, mock_zones: list[Mock], ) -> None: """RAS-mode controller accepts target temperature commands.""" mock_controller.zones = mock_zones await setup_integration(hass, mock_config_entry) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_TEMPERATURE: 23.5}, blocking=True, ) mock_controller.set_temp_setpoint.assert_awaited_once_with(23.5) @pytest.mark.usefixtures("init_integration") async def test_set_controller_hvac_and_fan( hass: HomeAssistant, mock_controller: Mock, ) -> None: """HVAC and fan mode services call through to the controller.""" await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) mock_controller.set_mode.assert_awaited_once_with(Controller.Mode.HEAT) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_FAN_MODE: "high"}, blocking=True, ) mock_controller.set_fan.assert_awaited_once_with(Controller.Fan.HIGH) async def test_unknown_fan_mode_reports_unknown( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_create_discovery: AsyncMock, mock_controller: Mock, mock_zones: list[Mock], caplog: pytest.LogCaptureFixture, ) -> None: """Unknown SysFan from the library surfaces as display-only fan_mode unknown.""" mock_controller.zones = mock_zones _raise_on_controller_property(mock_controller, "fan", "quiet") with caplog.at_level(logging.WARNING): await setup_integration(hass, mock_config_entry) state = hass.states.get(CONTROLLER_ENTITY) assert state is not None assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_FAN_MODE] == "unknown" assert "unknown" not in (state.attributes.get(ATTR_FAN_MODES) or []) assert "Unknown SysFan from iZone controller" in caplog.text assert "quiet" in caplog.text assert "attach diagnostics" in caplog.text async def test_unknown_mode_keeps_entity_available( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_create_discovery: AsyncMock, mock_controller: Mock, mock_zones: list[Mock], caplog: pytest.LogCaptureFixture, ) -> None: """Unknown SysMode leaves HVAC state unknown without marking unavailable.""" mock_controller.zones = mock_zones _raise_on_controller_property(mock_controller, "mode", "turbo") with caplog.at_level(logging.WARNING): await setup_integration(hass, mock_config_entry) state = hass.states.get(CONTROLLER_ENTITY) assert state is not None assert state.state == STATE_UNKNOWN assert state.attributes[ATTR_CURRENT_TEMPERATURE] == mock_controller.temp_return assert "Unknown SysMode from iZone controller" in caplog.text assert "turbo" in caplog.text assert "attach diagnostics" in caplog.text @pytest.mark.usefixtures("init_integration") async def test_set_zone_mode( hass: HomeAssistant, mock_zones: list[Mock], ) -> None: """Zone HVAC mode changes call the zone library API.""" zone = mock_zones[0] await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ZONE_ENTITY, ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) zone.set_mode.assert_awaited_once_with(Zone.Mode.CLOSE) @pytest.mark.parametrize( "mock_controller", [create_mock_controller(ras_mode="RAS")], ) async def test_target_temperature_feature_ras_mode( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_create_discovery: AsyncMock, mock_controller: Mock, mock_zones: list[Mock], ) -> None: """TARGET_TEMPERATURE is enabled in RAS mode.""" mock_controller.zones = mock_zones await setup_integration(hass, mock_config_entry) entity = hass.states.get(CONTROLLER_ENTITY) assert entity is not None assert ( entity.attributes["supported_features"] & ClimateEntityFeature.TARGET_TEMPERATURE ) == ClimateEntityFeature.TARGET_TEMPERATURE @pytest.mark.parametrize( ("mock_controller", "mock_zones"), [ ( create_mock_controller(zone_ctrl=1, zones_total=2), [ create_mock_zone(index=0, name="Living Room", temp_current=22.5), create_mock_zone(index=1, name="Bedroom", temp_current=None), ], ) ], ) async def test_target_temperature_when_zone_missing_sensor( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_create_discovery: AsyncMock, mock_controller: Mock, mock_zones: list[Mock], ) -> None: """TARGET_TEMPERATURE is enabled when any zone lacks a temperature sensor.""" mock_controller.zones = mock_zones await setup_integration(hass, mock_config_entry) entity = hass.states.get(CONTROLLER_ENTITY) assert entity is not None assert ( entity.attributes["supported_features"] & ClimateEntityFeature.TARGET_TEMPERATURE ) == ClimateEntityFeature.TARGET_TEMPERATURE @pytest.mark.usefixtures("init_integration") async def test_refresh_failure_makes_entities_unavailable( hass: HomeAssistant, mock_controller: Mock, freezer: FrozenDateTimeFactory, ) -> None: """Coordinator refresh failure marks controller and zone unavailable.""" mock_controller.refresh_all = AsyncMock(side_effect=ConnectionError("offline")) freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE assert hass.states.get(ZONE_ENTITY).state == STATE_UNAVAILABLE @pytest.mark.usefixtures("init_integration") async def test_soft_fault_marks_entities_unavailable_until_reconnect( hass: HomeAssistant, mock_controller: Mock, freezer: FrozenDateTimeFactory, ) -> None: """Successful refresh with connected=False marks entities unavailable.""" assert hass.states.get(CONTROLLER_ENTITY).state == HVACMode.COOL assert hass.states.get(ZONE_ENTITY).state == HVACMode.HEAT_COOL mock_controller.connected = False freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE assert hass.states.get(ZONE_ENTITY).state == STATE_UNAVAILABLE mock_controller.connected = True freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(CONTROLLER_ENTITY).state == HVACMode.COOL assert hass.states.get(ZONE_ENTITY).state == HVACMode.HEAT_COOL @pytest.mark.parametrize( ("command_error", "expect_unavailable", "translation_key"), [ pytest.param( ControllerCommandError("rejected"), False, "command_rejected", id="command_error_stays_available", ), pytest.param( ConnectionError("disconnected"), True, "unable_to_connect", id="connection_error_marks_unavailable", ), ], ) @pytest.mark.usefixtures("init_integration") async def test_controller_command_error_handling( hass: HomeAssistant, mock_controller: Mock, command_error: Exception, expect_unavailable: bool, translation_key: str, ) -> None: """Rejected and transport failures raise HomeAssistantError; only transport clears availability.""" assert hass.states.get(CONTROLLER_ENTITY).state == HVACMode.COOL mock_controller.set_fan = AsyncMock(side_effect=command_error) with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_FAN_MODE: "low"}, blocking=True, ) assert err.value.translation_domain == DOMAIN assert err.value.translation_key == translation_key is_unavailable = hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE assert is_unavailable is expect_unavailable @pytest.mark.usefixtures("init_integration") async def test_controller_rejected_command_does_not_log_warning( hass: HomeAssistant, mock_controller: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Rejected commands raise HomeAssistantError without a duplicate warning log.""" mock_controller.set_fan = AsyncMock(side_effect=ControllerCommandError("rejected")) with ( caplog.at_level(logging.WARNING), pytest.raises(HomeAssistantError) as err, ): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_FAN_MODE: "low"}, blocking=True, ) assert err.value.translation_key == "command_rejected" assert not any( record.name.startswith("homeassistant.components.izone") for record in caplog.records ) @pytest.mark.parametrize( ("command_error", "expect_unavailable", "translation_key"), [ pytest.param( ControllerCommandError("rejected"), False, "command_rejected", id="command_error_stays_available", ), pytest.param( ConnectionError("disconnected"), True, "unable_to_connect", id="connection_error_marks_unavailable", ), ], ) @pytest.mark.usefixtures("init_integration") async def test_zone_command_error_handling( hass: HomeAssistant, mock_zones: list[Mock], command_error: Exception, expect_unavailable: bool, translation_key: str, ) -> None: """Zone command errors raise HomeAssistantError; only transport clears availability.""" assert hass.states.get(CONTROLLER_ENTITY).state == HVACMode.COOL assert hass.states.get(ZONE_ENTITY) is not None mock_zones[0].set_mode = AsyncMock(side_effect=command_error) with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ZONE_ENTITY, ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) assert err.value.translation_domain == DOMAIN assert err.value.translation_key == translation_key assert ( hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE ) is expect_unavailable assert ( hass.states.get(ZONE_ENTITY).state == STATE_UNAVAILABLE ) is expect_unavailable @pytest.mark.parametrize( "mock_controller", [ create_mock_controller( free_air_enabled=True, free_air=True, zone_ctrl=1, zones_total=1, ) ], ) async def test_set_hvac_mode_free_air_noop_when_on( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_create_discovery: AsyncMock, mock_controller: Mock, mock_zones: list[Mock], ) -> None: """Free-air fan_only does not send commands when the system is already on.""" mock_controller.zones = mock_zones await setup_integration(hass, mock_config_entry) mock_controller.set_mode = AsyncMock() mock_controller.set_on = AsyncMock() await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) mock_controller.set_mode.assert_not_called() mock_controller.set_on.assert_not_called() @pytest.mark.parametrize( "mock_controller", [ create_mock_controller( free_air_enabled=True, free_air=True, zone_ctrl=1, zones_total=1, ) ], ) async def test_set_hvac_mode_free_air_noop_when_unavailable( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_create_discovery: AsyncMock, mock_controller: Mock, mock_zones: list[Mock], ) -> None: """Free-air fan_only while unavailable must not restore availability.""" mock_controller.zones = mock_zones await setup_integration(hass, mock_config_entry) mock_controller.set_fan = AsyncMock(side_effect=ConnectionError("disconnected")) with pytest.raises(HomeAssistantError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_FAN_MODE: "low"}, blocking=True, ) assert hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE mock_controller.set_mode = AsyncMock() mock_controller.set_on = AsyncMock() # Service calls are rejected for unavailable entities; call the entity method # directly so the free-air early-return path is still covered. entity = hass.data[CLIMATE_DOMAIN].get_entity(CONTROLLER_ENTITY) assert entity is not None await entity.async_set_hvac_mode(HVACMode.FAN_ONLY) mock_controller.set_mode.assert_not_called() mock_controller.set_on.assert_not_called() assert hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE @pytest.mark.parametrize( "mock_controller", [ create_mock_controller( free_air_enabled=True, free_air=True, is_on=False, zone_ctrl=1, zones_total=1, ) ], ) async def test_set_hvac_mode_free_air_turns_on_when_off( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_create_discovery: AsyncMock, mock_controller: Mock, mock_zones: list[Mock], ) -> None: """Free-air fan_only turns the system on before skipping the mode change.""" mock_controller.zones = mock_zones await setup_integration(hass, mock_config_entry) mock_controller.set_on = AsyncMock() mock_controller.set_mode = AsyncMock() await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) mock_controller.set_on.assert_awaited_once_with(True) mock_controller.set_mode.assert_not_called() @pytest.mark.parametrize( "mock_controller", [ create_mock_controller( free_air_enabled=True, free_air=True, zone_ctrl=1, zones_total=1, ) ], ) async def test_set_hvac_mode_off_while_free_air( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_create_discovery: AsyncMock, mock_controller: Mock, mock_zones: list[Mock], ) -> None: """Off is sent before the free-air early return.""" mock_controller.zones = mock_zones await setup_integration(hass, mock_config_entry) mock_controller.set_on = AsyncMock() mock_controller.set_mode = AsyncMock() await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) mock_controller.set_on.assert_awaited_once_with(False) mock_controller.set_mode.assert_not_called() @pytest.mark.parametrize( "mock_controller", [create_mock_controller(is_on=False, zone_ctrl=1, zones_total=1)], ) async def test_set_hvac_mode_connection_error_on_turn_on_skips_set_mode( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_create_discovery: AsyncMock, mock_controller: Mock, mock_zones: list[Mock], ) -> None: """A transport failure turning on does not continue to set_mode.""" mock_controller.zones = mock_zones await setup_integration(hass, mock_config_entry) mock_controller.set_on = AsyncMock(side_effect=ConnectionError("disconnected")) mock_controller.set_mode = AsyncMock() with pytest.raises(HomeAssistantError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_HVAC_MODE: HVACMode.COOL}, blocking=True, ) mock_controller.set_on.assert_awaited_once_with(True) mock_controller.set_mode.assert_not_called() assert hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE @pytest.mark.usefixtures("init_integration") async def test_command_connection_error_recovers_on_coordinator_refresh( hass: HomeAssistant, mock_controller: Mock, freezer: FrozenDateTimeFactory, ) -> None: """Entities recover when a later coordinator refresh succeeds after a transport failure.""" mock_controller.set_fan = AsyncMock(side_effect=ConnectionError("disconnected")) with pytest.raises(HomeAssistantError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_FAN_MODE: "low"}, blocking=True, ) assert hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE assert hass.states.get(ZONE_ENTITY).state == STATE_UNAVAILABLE mock_controller.refresh_all = AsyncMock() freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(CONTROLLER_ENTITY).state == HVACMode.COOL assert hass.states.get(ZONE_ENTITY).state == HVACMode.HEAT_COOL @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( ("service", "airflow"), [ pytest.param(IZONE_SERVICE_AIRFLOW_MIN, 41, id="min-int"), pytest.param(IZONE_SERVICE_AIRFLOW_MAX, 41, id="max-int"), pytest.param(IZONE_SERVICE_AIRFLOW_MIN, 40.9, id="min-float"), pytest.param(IZONE_SERVICE_AIRFLOW_MAX, 40.9, id="max-float"), ], ) async def test_airflow_rejects_non_multiples_of_five( hass: HomeAssistant, mock_zones: list[Mock], service: str, airflow: float, ) -> None: """Airflow services reject values that are not multiples of 5.""" with pytest.raises(vol.Invalid): await hass.services.async_call( DOMAIN, service, {ATTR_ENTITY_ID: ZONE_ENTITY, ATTR_AIRFLOW: airflow}, blocking=True, ) mock_zones[0].set_airflow_min.assert_not_called() mock_zones[0].set_airflow_max.assert_not_called()