diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 648a65c0d30..e5ddf4c6a38 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -2,9 +2,12 @@ from __future__ import annotations +from collections.abc import Sequence import logging from typing import Any +from pydaikin.daikin_base import Appliance + from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_MODE, @@ -21,6 +24,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -29,12 +33,19 @@ from .const import ( ATTR_STATE_OFF, ATTR_STATE_ON, ATTR_TARGET_TEMPERATURE, + DOMAIN, + ZONE_NAME_UNCONFIGURED, ) from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity _LOGGER = logging.getLogger(__name__) +type DaikinZone = Sequence[str | int] + +DAIKIN_ZONE_TEMP_HEAT = "lztemp_h" +DAIKIN_ZONE_TEMP_COOL = "lztemp_c" + HA_STATE_TO_DAIKIN = { HVACMode.FAN_ONLY: "fan", @@ -78,6 +89,70 @@ HA_ATTR_TO_DAIKIN = { } DAIKIN_ATTR_ADVANCED = "adv" +ZONE_TEMPERATURE_WINDOW = 2 + + +def _zone_error( + translation_key: str, placeholders: dict[str, str] | None = None +) -> HomeAssistantError: + """Return a Home Assistant error with Daikin translation info.""" + return HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + + +def _zone_is_configured(zone: DaikinZone) -> bool: + """Return True if the Daikin zone represents a configured zone.""" + if not zone: + return False + return zone[0] != ZONE_NAME_UNCONFIGURED + + +def _zone_temperature_lists(device: Appliance) -> tuple[list[str], list[str]]: + """Return the decoded zone temperature lists.""" + try: + heating = device.represent(DAIKIN_ZONE_TEMP_HEAT)[1] + cooling = device.represent(DAIKIN_ZONE_TEMP_COOL)[1] + except AttributeError: + return ([], []) + return (list(heating or []), list(cooling or [])) + + +def _supports_zone_temperature_control(device: Appliance) -> bool: + """Return True if the device exposes zone temperature settings.""" + zones = device.zones + if not zones: + return False + heating, cooling = _zone_temperature_lists(device) + return bool( + heating + and cooling + and len(heating) >= len(zones) + and len(cooling) >= len(zones) + ) + + +def _system_target_temperature(device: Appliance) -> float | None: + """Return the system target temperature when available.""" + target = device.target_temperature + if target is None: + return None + try: + return float(target) + except TypeError, ValueError: + return None + + +def _zone_temperature_from_list(values: list[str], zone_id: int) -> float | None: + """Return the parsed temperature for a zone from a Daikin list.""" + if zone_id >= len(values): + return None + try: + return float(values[zone_id]) + except TypeError, ValueError: + return None async def async_setup_entry( @@ -86,8 +161,16 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Daikin climate based on config_entry.""" - daikin_api = entry.runtime_data - async_add_entities([DaikinClimate(daikin_api)]) + coordinator = entry.runtime_data + entities: list[ClimateEntity] = [DaikinClimate(coordinator)] + if _supports_zone_temperature_control(coordinator.device): + zones = coordinator.device.zones or [] + entities.extend( + DaikinZoneClimate(coordinator, zone_id) + for zone_id, zone in enumerate(zones) + if _zone_is_configured(zone) + ) + async_add_entities(entities) def format_target_temperature(target_temperature: float) -> str: @@ -284,3 +367,130 @@ class DaikinClimate(DaikinEntity, ClimateEntity): {HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE]: HA_STATE_TO_DAIKIN[HVACMode.OFF]} ) await self.coordinator.async_refresh() + + +class DaikinZoneClimate(DaikinEntity, ClimateEntity): + """Representation of a Daikin zone temperature controller.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = 1 + + def __init__(self, coordinator: DaikinCoordinator, zone_id: int) -> None: + """Initialize the zone climate entity.""" + super().__init__(coordinator) + self._zone_id = zone_id + self._attr_unique_id = f"{self.device.mac}-zone{zone_id}-temperature" + zone_name = self.device.zones[self._zone_id][0] + self._attr_name = f"{zone_name} temperature" + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the hvac modes (mirrors the main unit).""" + return [self.hvac_mode] + + @property + def hvac_mode(self) -> HVACMode: + """Return the current HVAC mode.""" + daikin_mode = self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE])[1] + return DAIKIN_TO_HA_STATE.get(daikin_mode, HVACMode.HEAT_COOL) + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + return HA_STATE_TO_CURRENT_HVAC.get(self.hvac_mode) + + @property + def target_temperature(self) -> float | None: + """Return the zone target temperature for the active mode.""" + heating, cooling = _zone_temperature_lists(self.device) + mode = self.hvac_mode + if mode == HVACMode.HEAT: + return _zone_temperature_from_list(heating, self._zone_id) + if mode == HVACMode.COOL: + return _zone_temperature_from_list(cooling, self._zone_id) + return None + + @property + def min_temp(self) -> float: + """Return the minimum selectable temperature.""" + target = _system_target_temperature(self.device) + if target is None: + return super().min_temp + return target - ZONE_TEMPERATURE_WINDOW + + @property + def max_temp(self) -> float: + """Return the maximum selectable temperature.""" + target = _system_target_temperature(self.device) + if target is None: + return super().max_temp + return target + ZONE_TEMPERATURE_WINDOW + + @property + def available(self) -> bool: + """Return if the entity is available.""" + return ( + super().available + and _supports_zone_temperature_control(self.device) + and _system_target_temperature(self.device) is not None + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional metadata.""" + return {"zone_id": self._zone_id} + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the zone temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="zone_temperature_missing", + ) + zones = self.device.zones + if not zones or not _supports_zone_temperature_control(self.device): + raise _zone_error("zone_parameters_unavailable") + + try: + zone = zones[self._zone_id] + except (IndexError, TypeError) as err: + raise _zone_error( + "zone_missing", + { + "zone_id": str(self._zone_id), + "max_zone": str(len(zones) - 1), + }, + ) from err + + if not _zone_is_configured(zone): + raise _zone_error("zone_inactive", {"zone_id": str(self._zone_id)}) + + temperature_value = float(temperature) + target = _system_target_temperature(self.device) + if target is None: + raise _zone_error("zone_parameters_unavailable") + + mode = self.hvac_mode + if mode == HVACMode.HEAT: + zone_key = DAIKIN_ZONE_TEMP_HEAT + elif mode == HVACMode.COOL: + zone_key = DAIKIN_ZONE_TEMP_COOL + else: + raise _zone_error("zone_hvac_mode_unsupported") + + zone_value = str(round(temperature_value)) + try: + await self.device.set_zone(self._zone_id, zone_key, zone_value) + except (AttributeError, KeyError, NotImplementedError, TypeError) as err: + raise _zone_error("zone_set_failed") from err + + await self.coordinator.async_request_refresh() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Disallow changing HVAC mode via zone climate.""" + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="zone_hvac_read_only", + ) diff --git a/homeassistant/components/daikin/const.py b/homeassistant/components/daikin/const.py index f093569ea54..27f0b9ba57d 100644 --- a/homeassistant/components/daikin/const.py +++ b/homeassistant/components/daikin/const.py @@ -24,4 +24,6 @@ ATTR_STATE_OFF = "off" KEY_MAC = "mac" KEY_IP = "ip" +ZONE_NAME_UNCONFIGURED = "-" + TIMEOUT_SEC = 120 diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index 53645b1e7bd..b3326454d37 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -57,5 +57,28 @@ "name": "Power" } } + }, + "exceptions": { + "zone_hvac_mode_unsupported": { + "message": "Zone temperature can only be changed when the main climate mode is heat or cool." + }, + "zone_hvac_read_only": { + "message": "Zone HVAC mode is controlled by the main climate entity." + }, + "zone_inactive": { + "message": "Zone {zone_id} is not active. Enable the zone on your Daikin device first." + }, + "zone_missing": { + "message": "Zone {zone_id} does not exist. Available zones are 0-{max_zone}." + }, + "zone_parameters_unavailable": { + "message": "This device does not expose the required zone temperature parameters." + }, + "zone_set_failed": { + "message": "Failed to set zone temperature. The device may not support this operation." + }, + "zone_temperature_missing": { + "message": "Provide a temperature value when adjusting a zone." + } } } diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 20a56ac321c..20d27e7d3ea 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -8,6 +8,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import ZONE_NAME_UNCONFIGURED from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity @@ -28,7 +29,7 @@ async def async_setup_entry( switches.extend( DaikinZoneSwitch(daikin_api, zone_id) for zone_id, zone in enumerate(zones) - if zone[0] != "-" + if zone[0] != ZONE_NAME_UNCONFIGURED ) if daikin_api.device.support_advanced_modes: # It isn't possible to find out from the API responses if a specific diff --git a/tests/components/daikin/conftest.py b/tests/components/daikin/conftest.py new file mode 100644 index 00000000000..f3ef384add0 --- /dev/null +++ b/tests/components/daikin/conftest.py @@ -0,0 +1,109 @@ +"""Fixtures for Daikin tests.""" + +from __future__ import annotations + +from collections.abc import Callable, Generator +import re +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +import urllib.parse + +import pytest + +type ZoneDefinition = list[str | int] +type ZoneDevice = MagicMock + + +def _decode_zone_values(value: str) -> list[str]: + """Decode a semicolon separated list into zone values.""" + return re.findall(r"[^;]+", urllib.parse.unquote(value)) + + +def configure_zone_device( + zone_device: ZoneDevice, + *, + zones: list[ZoneDefinition], + target_temperature: float | None = 22, + mode: str = "hot", + heating_values: str | None = None, + cooling_values: str | None = None, +) -> None: + """Configure a mocked zone-capable Daikin device for a test.""" + zone_device.target_temperature = target_temperature + zone_device.zones = zones + zone_device._mode = mode + + encoded_zone_temperatures = ";".join(str(zone[2]) for zone in zones) + zone_device.values = { + "name": "Daikin Test", + "model": "TESTMODEL", + "ver": "1_0_0", + "zone_name": ";".join(str(zone[0]) for zone in zones), + "zone_onoff": ";".join(str(zone[1]) for zone in zones), + "lztemp_h": ( + encoded_zone_temperatures if heating_values is None else heating_values + ), + "lztemp_c": ( + encoded_zone_temperatures if cooling_values is None else cooling_values + ), + } + + +@pytest.fixture +def zone_device() -> Generator[ZoneDevice]: + """Return a mocked zone-capable Daikin device and patch its factory.""" + device = MagicMock(name="DaikinZoneDevice") + device.mac = "001122334455" + device.fan_rate = [] + device.swing_modes = [] + device.support_away_mode = False + device.support_advanced_modes = False + device.support_fan_rate = False + device.support_swing_mode = False + device.support_outside_temperature = False + device.support_energy_consumption = False + device.support_humidity = False + device.support_compressor_frequency = False + device.compressor_frequency = 0 + device.inside_temperature = 21.0 + device.outside_temperature = 13.0 + device.humidity = 40 + device.current_total_power_consumption = 0.0 + device.last_hour_cool_energy_consumption = 0.0 + device.last_hour_heat_energy_consumption = 0.0 + device.today_energy_consumption = 0.0 + device.today_total_energy_consumption = 0.0 + + configure_zone_device(device, zones=[["Living", "1", 22]]) + + def _represent(key: str) -> tuple[None, list[str] | str]: + dynamic_values: dict[str, Callable[[], list[str] | str]] = { + "lztemp_h": lambda: _decode_zone_values(device.values["lztemp_h"]), + "lztemp_c": lambda: _decode_zone_values(device.values["lztemp_c"]), + "mode": lambda: device._mode, + "f_rate": lambda: "auto", + "f_dir": lambda: "3d", + "en_hol": lambda: "off", + "adv": lambda: "", + "htemp": lambda: str(device.inside_temperature), + "otemp": lambda: str(device.outside_temperature), + } + return (None, dynamic_values.get(key, lambda: "")()) + + async def _set(values: dict[str, Any]) -> None: + if mode := values.get("mode"): + device._mode = mode + + device.represent = MagicMock(side_effect=_represent) + device.update_status = AsyncMock() + device.set = AsyncMock(side_effect=_set) + device.set_zone = AsyncMock() + device.set_holiday = AsyncMock() + device.set_advanced_mode = AsyncMock() + device.set_streamer = AsyncMock() + + with patch( + "homeassistant.components.daikin.DaikinFactory", + new=AsyncMock(return_value=device), + ): + yield device diff --git a/tests/components/daikin/test_zone_climate.py b/tests/components/daikin/test_zone_climate.py new file mode 100644 index 00000000000..168d0bd5f5b --- /dev/null +++ b/tests/components/daikin/test_zone_climate.py @@ -0,0 +1,353 @@ +"""Tests for Daikin zone climate entities.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.climate import ( + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.components.daikin.const import DOMAIN, KEY_MAC +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + CONF_HOST, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import async_update_entity + +from .conftest import ZoneDevice, configure_zone_device + +from tests.common import MockConfigEntry + +HOST = "127.0.0.1" + + +async def _async_setup_daikin( + hass: HomeAssistant, zone_device: ZoneDevice +) -> MockConfigEntry: + """Set up a Daikin config entry with a mocked library device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=zone_device.mac, + data={CONF_HOST: HOST, KEY_MAC: zone_device.mac}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +def _zone_entity_id( + entity_registry: er.EntityRegistry, zone_device: ZoneDevice, zone_id: int +) -> str | None: + """Return the entity id for a zone climate unique id.""" + return entity_registry.async_get_entity_id( + CLIMATE_DOMAIN, + DOMAIN, + f"{zone_device.mac}-zone{zone_id}-temperature", + ) + + +async def _async_set_zone_temperature( + hass: HomeAssistant, entity_id: str, temperature: float +) -> None: + """Call `climate.set_temperature` for a zone climate.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: temperature, + }, + blocking=True, + ) + + +async def test_setup_entry_adds_zone_climates( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Configured zones create zone climate entities.""" + configure_zone_device( + zone_device, zones=[["-", "0", 0], ["Living", "1", 22], ["Office", "1", 21]] + ) + + await _async_setup_daikin(hass, zone_device) + + assert _zone_entity_id(entity_registry, zone_device, 0) is None + assert _zone_entity_id(entity_registry, zone_device, 1) is not None + assert _zone_entity_id(entity_registry, zone_device, 2) is not None + + +async def test_setup_entry_skips_zone_climates_without_support( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Missing zone temperature lists skip zone climate entities.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + zone_device.values["lztemp_h"] = "" + zone_device.values["lztemp_c"] = "" + + await _async_setup_daikin(hass, zone_device) + + assert _zone_entity_id(entity_registry, zone_device, 0) is None + + +@pytest.mark.parametrize( + ("mode", "expected_zone_key"), + [("hot", "lztemp_h"), ("cool", "lztemp_c")], +) +async def test_zone_climate_sets_temperature_for_active_mode( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, + mode: str, + expected_zone_key: str, +) -> None: + """Setting temperature updates the active mode zone value.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22], ["Office", "1", 21]], + mode=mode, + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + await _async_set_zone_temperature(hass, entity_id, 23) + + zone_device.set_zone.assert_awaited_once_with(0, expected_zone_key, "23") + + +async def test_zone_climate_rejects_out_of_range_temperature( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Service validation rejects values outside the allowed range.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + target_temperature=22, + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + with pytest.raises(ServiceValidationError) as err: + await _async_set_zone_temperature(hass, entity_id, 30) + + assert err.value.translation_key == "temp_out_of_range" + + +async def test_zone_climate_unavailable_without_target_temperature( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Zones are unavailable if system target temperature is missing.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + target_temperature=None, + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_zone_climate_zone_inactive_after_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Inactive zones raise a translated error during service calls.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + zone_device.zones[0][0] = "-" + + with pytest.raises(HomeAssistantError) as err: + await _async_set_zone_temperature(hass, entity_id, 21) + + assert err.value.translation_key == "zone_inactive" + + +async def test_zone_climate_zone_missing_after_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Missing zones raise a translated error during service calls.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22], ["Office", "1", 22]], + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 1) + assert entity_id is not None + zone_device.zones = [["Living", "1", 22]] + + with pytest.raises(HomeAssistantError) as err: + await _async_set_zone_temperature(hass, entity_id, 21) + + assert err.value.translation_key == "zone_missing" + + +async def test_zone_climate_parameters_unavailable( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Missing zone parameter lists make the zone entity unavailable.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + zone_device.values["lztemp_h"] = "" + zone_device.values["lztemp_c"] = "" + + await async_update_entity(hass, entity_id) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_zone_climate_hvac_modes_read_only( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Changing HVAC mode through a zone climate is blocked.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + assert err.value.translation_key == "zone_hvac_read_only" + + +async def test_zone_climate_set_temperature_requires_heat_or_cool( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Setting temperature in unsupported modes raises a translated error.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + mode="auto", + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + with pytest.raises(HomeAssistantError) as err: + await _async_set_zone_temperature(hass, entity_id, 21) + + assert err.value.translation_key == "zone_hvac_mode_unsupported" + + +async def test_zone_climate_properties( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Zone climate exposes expected state attributes.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + target_temperature=24, + mode="cool", + heating_values="20", + cooling_values="18", + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_TEMPERATURE] == 18.0 + assert state.attributes[ATTR_MIN_TEMP] == 22.0 + assert state.attributes[ATTR_MAX_TEMP] == 26.0 + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.COOL] + assert state.attributes["zone_id"] == 0 + + +async def test_zone_climate_target_temperature_inactive_mode( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """In non-heating/cooling modes, zone target temperature is None.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + mode="auto", + heating_values="bad", + cooling_values="19", + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == HVACMode.HEAT_COOL + assert state.attributes[ATTR_TEMPERATURE] is None + + +async def test_zone_climate_set_zone_failed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Service call surfaces backend zone update errors.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + zone_device.set_zone = AsyncMock(side_effect=NotImplementedError) + + with pytest.raises(HomeAssistantError) as err: + await _async_set_zone_temperature(hass, entity_id, 21) + + assert err.value.translation_key == "zone_set_failed"