1
0
mirror of https://github.com/home-assistant/core.git synced 2026-08-06 13:26:29 +01:00
Files
core/tests/components/izone/test_climate.py
T
2026-08-01 16:53:37 +02:00

626 lines
20 KiB
Python

"""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
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.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