mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 03:03:17 +01:00
Add zone temperature support to Daikin integration (#152642)
This commit is contained in:
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from pydaikin.daikin_base import Appliance
|
||||||
|
|
||||||
from homeassistant.components.climate import (
|
from homeassistant.components.climate import (
|
||||||
ATTR_FAN_MODE,
|
ATTR_FAN_MODE,
|
||||||
ATTR_HVAC_MODE,
|
ATTR_HVAC_MODE,
|
||||||
@@ -21,6 +24,7 @@ from homeassistant.components.climate import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
@@ -29,12 +33,19 @@ from .const import (
|
|||||||
ATTR_STATE_OFF,
|
ATTR_STATE_OFF,
|
||||||
ATTR_STATE_ON,
|
ATTR_STATE_ON,
|
||||||
ATTR_TARGET_TEMPERATURE,
|
ATTR_TARGET_TEMPERATURE,
|
||||||
|
DOMAIN,
|
||||||
|
ZONE_NAME_UNCONFIGURED,
|
||||||
)
|
)
|
||||||
from .coordinator import DaikinConfigEntry, DaikinCoordinator
|
from .coordinator import DaikinConfigEntry, DaikinCoordinator
|
||||||
from .entity import DaikinEntity
|
from .entity import DaikinEntity
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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 = {
|
HA_STATE_TO_DAIKIN = {
|
||||||
HVACMode.FAN_ONLY: "fan",
|
HVACMode.FAN_ONLY: "fan",
|
||||||
@@ -78,6 +89,70 @@ HA_ATTR_TO_DAIKIN = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DAIKIN_ATTR_ADVANCED = "adv"
|
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(
|
async def async_setup_entry(
|
||||||
@@ -86,8 +161,16 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Daikin climate based on config_entry."""
|
"""Set up Daikin climate based on config_entry."""
|
||||||
daikin_api = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
async_add_entities([DaikinClimate(daikin_api)])
|
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:
|
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]}
|
{HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE]: HA_STATE_TO_DAIKIN[HVACMode.OFF]}
|
||||||
)
|
)
|
||||||
await self.coordinator.async_refresh()
|
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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -24,4 +24,6 @@ ATTR_STATE_OFF = "off"
|
|||||||
KEY_MAC = "mac"
|
KEY_MAC = "mac"
|
||||||
KEY_IP = "ip"
|
KEY_IP = "ip"
|
||||||
|
|
||||||
|
ZONE_NAME_UNCONFIGURED = "-"
|
||||||
|
|
||||||
TIMEOUT_SEC = 120
|
TIMEOUT_SEC = 120
|
||||||
|
|||||||
@@ -57,5 +57,28 @@
|
|||||||
"name": "Power"
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from homeassistant.components.switch import SwitchEntity
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
from .const import ZONE_NAME_UNCONFIGURED
|
||||||
from .coordinator import DaikinConfigEntry, DaikinCoordinator
|
from .coordinator import DaikinConfigEntry, DaikinCoordinator
|
||||||
from .entity import DaikinEntity
|
from .entity import DaikinEntity
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ async def async_setup_entry(
|
|||||||
switches.extend(
|
switches.extend(
|
||||||
DaikinZoneSwitch(daikin_api, zone_id)
|
DaikinZoneSwitch(daikin_api, zone_id)
|
||||||
for zone_id, zone in enumerate(zones)
|
for zone_id, zone in enumerate(zones)
|
||||||
if zone[0] != "-"
|
if zone[0] != ZONE_NAME_UNCONFIGURED
|
||||||
)
|
)
|
||||||
if daikin_api.device.support_advanced_modes:
|
if daikin_api.device.support_advanced_modes:
|
||||||
# It isn't possible to find out from the API responses if a specific
|
# It isn't possible to find out from the API responses if a specific
|
||||||
|
|||||||
109
tests/components/daikin/conftest.py
Normal file
109
tests/components/daikin/conftest.py
Normal file
@@ -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
|
||||||
353
tests/components/daikin/test_zone_climate.py
Normal file
353
tests/components/daikin/test_zone_climate.py
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user