From f5accf6f0261ff58bc6de596fac6d85e42fea06a Mon Sep 17 00:00:00 2001 From: Nitay Ben-Zvi Date: Fri, 10 Jul 2026 21:31:22 +0300 Subject: [PATCH] homekit: add native HeaterCooler accessory (#148231) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- homeassistant/components/homekit/__init__.py | 49 +- .../components/homekit/accessories.py | 186 +- .../components/homekit/aidmanager.py | 91 +- .../components/homekit/climate_base.py | 82 +- .../components/homekit/climate_util.py | 20 +- .../components/homekit/config_flow.py | 174 +- homeassistant/components/homekit/const.py | 6 + homeassistant/components/homekit/strings.json | 21 +- .../components/homekit/type_heater_coolers.py | 734 ++++ .../components/homekit/type_thermostats.py | 5 +- homeassistant/components/homekit/util.py | 20 + tests/components/homekit/test_accessories.py | 47 +- .../components/homekit/test_accessory_type.py | 399 +++ tests/components/homekit/test_aidmanager.py | 155 + tests/components/homekit/test_config_flow.py | 243 +- .../homekit/test_get_accessories.py | 243 +- .../homekit/test_type_heater_coolers.py | 3113 +++++++++++++++++ .../homekit/test_type_thermostats.py | 19 +- tests/components/homekit/test_util.py | 9 + 19 files changed, 5517 insertions(+), 99 deletions(-) create mode 100644 homeassistant/components/homekit/type_heater_coolers.py create mode 100644 tests/components/homekit/test_accessory_type.py create mode 100644 tests/components/homekit/test_type_heater_coolers.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 0626071eeaff..55f2ae2d3bc8 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -88,6 +88,7 @@ from . import ( # noqa: F401 type_cameras, type_covers, type_fans, + type_heater_coolers, type_humidifiers, type_lights, type_locks, @@ -98,7 +99,13 @@ from . import ( # noqa: F401 type_switches, type_thermostats, ) -from .accessories import HomeAccessory, HomeBridge, HomeDriver, get_accessory +from .accessories import ( + HomeAccessory, + HomeBridge, + HomeDriver, + async_resolve_accessory_type, + get_accessory, +) from .aidmanager import AccessoryAidStorage from .const import ( ATTR_INTEGRATION, @@ -572,6 +579,10 @@ class HomeKit: self.bridge: HomeBridge | None = None self._reset_lock = asyncio.Lock() self._cancel_reload_dispatcher: CALLBACK_TYPE | None = None + # True while running the first ever start of this entry (no + # persisted pairing state yet); accessory mode uses it to tell a + # brand new entry from one that predates the HeaterCooler. + self._first_ever_start = False def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> bool: """Set up bridge and accessory driver. @@ -669,8 +680,10 @@ class HomeKit: removed: list[str] = [] acc: HomeAccessory | None for entity_id in entity_ids: - aid = self.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) - if aid not in self.bridge.accessories: + # A lookup must not allocate; an allocation marks the entity as + # previously bridged, which would suppress the automatic routing. + aid = self.aid_storage.get_allocated_aid_for_entity_id(entity_id) + if aid is None or aid not in self.bridge.accessories: continue if acc := self.async_remove_bridge_accessory(aid): self._async_shutdown_accessory(acc) @@ -753,8 +766,14 @@ class HomeKit: assert self.aid_storage is not None assert self.bridge is not None - aid = self.aid_storage.get_or_allocate_aid_for_entity_id(state.entity_id) conf = self._config.get(state.entity_id, {}).copy() + # Must run before the aid is allocated below so a never bridged + # entity is still recognizable as new. + pending_type = async_resolve_accessory_type( + self.aid_storage, state, conf, allow_auto=True + ) + newly_allocated = not self.aid_storage.entity_is_allocated(state.entity_id) + aid = self.aid_storage.get_or_allocate_aid_for_entity_id(state.entity_id) # If an accessory cannot be created or added due to an exception # of any kind (usually in pyhap) it should not prevent # the rest of the accessories from being created @@ -762,11 +781,19 @@ class HomeKit: acc = get_accessory(self.hass, self.driver, state, aid, conf) if acc is not None: self.bridge.add_accessory(acc) + if pending_type: + self.aid_storage.async_set_accessory_type( + state.entity_id, pending_type + ) return acc except Exception: _LOGGER.exception( "Failed to create a HomeKit accessory for %s", state.entity_id ) + if newly_allocated: + # A failed first attempt must not classify the entity as + # existing on the next try. + self.aid_storage.async_delete_aid_for_entity_id(state.entity_id) return None def _would_exceed_max_devices(self, name: str | None) -> bool: @@ -882,6 +909,7 @@ class HomeKit: self.setup, async_zc_instance, uuid ) assert self.driver is not None + self._first_ever_start = not loaded_from_disk if not await self._async_create_accessories(): return @@ -894,6 +922,9 @@ class HomeKit: # need to make sure its persisted to disk. async with self.hass.data[PERSIST_LOCK_DATA]: await self.hass.async_add_executor_job(self.driver.persist) + # The pairing state is persisted now, so later reloads treat the + # entry as existing. + self._first_ever_start = False self.status = STATUS_RUNNING if self.driver.state.paired: @@ -1000,6 +1031,13 @@ class HomeKit: return None state = entity_states[0] conf = self._config.get(state.entity_id, {}).copy() + # Accessory mode has no aid allocation to tell new from existing, + # so only a brand new pairing picks its type automatically; anything + # else keeps its current accessory. + assert self.aid_storage is not None + pending_type = async_resolve_accessory_type( + self.aid_storage, state, conf, allow_auto=self._first_ever_start + ) acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf) if acc is None: _LOGGER.error( @@ -1007,6 +1045,9 @@ class HomeKit: self._name, self._filter.config, ) + return None + if pending_type: + self.aid_storage.async_set_accessory_type(state.entity_id, pending_type) return acc async def _async_create_bridge_accessory( diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 186be4ce8c0c..5215c4128cb7 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -12,6 +12,10 @@ from pyhap.iid_manager import IIDManager from pyhap.service import Service from pyhap.util import callback as pyhap_callback +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + ClimateEntityFeature, +) from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.lawn_mower import LawnMowerEntityFeature from homeassistant.components.media_player import MediaPlayerDeviceClass @@ -56,6 +60,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_state_change_event from homeassistant.util.decorator import Registry +from .aidmanager import AccessoryAidStorage +from .climate_util import ( + get_fan_modes_and_speeds, + get_swing_on_mode, + has_swing_off_mode, +) from .const import ( ATTR_DISPLAY_NAME, ATTR_INTEGRATION, @@ -87,10 +97,12 @@ from .const import ( TYPE_AIR_PURIFIER, TYPE_FAN, TYPE_FAUCET, + TYPE_HEATER_COOLER, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, + TYPE_THERMOSTAT, TYPE_VALVE, ) from .iidmanager import AccessoryIIDStorage @@ -117,6 +129,10 @@ FAN_TYPES = { TYPE_AIR_PURIFIER: "AirPurifier", TYPE_FAN: "Fan", } +CLIMATE_TYPES = { + TYPE_HEATER_COOLER: "HeaterCooler", + TYPE_THERMOSTAT: "Thermostat", +} TYPES: Registry[str, type[HomeAccessory]] = Registry() RELOAD_ON_CHANGE_ATTRS = ( @@ -126,6 +142,94 @@ RELOAD_ON_CHANGE_ATTRS = ( ) +def climate_controls_target_humidity(state: State) -> bool: + """Return True when a climate entity exposes a humidity setpoint. + + HeaterCooler cannot control a humidity setpoint; entities that + expose one (e.g. econet) stay on the Thermostat, which can. + """ + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + return bool(features & ClimateEntityFeature.TARGET_HUMIDITY) + + +def climate_supports_heater_cooler(state: State) -> bool: + """Return True when a climate entity fits the HeaterCooler accessory.""" + attributes = state.attributes + features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) + # Timing fan modes like auto or circulate do not count as speeds. + has_fan = bool(features & ClimateEntityFeature.FAN_MODE) and ( + len(get_fan_modes_and_speeds(attributes)[1]) >= 2 + ) + # The binary swing control writes the off mode back, so automatic + # routing requires the entity to advertise one. + has_swing = bool(features & ClimateEntityFeature.SWING_MODE) and ( + get_swing_on_mode(attributes) is not None and has_swing_off_mode(attributes) + ) + return (has_fan or has_swing) and not ( + features & ClimateEntityFeature.TARGET_HUMIDITY + ) + + +@ha_callback +def async_resolve_accessory_type( + aid_storage: AccessoryAidStorage, + state: State, + conf: dict[str, Any], + *, + allow_auto: bool, +) -> str | None: + """Resolve which accessory an entity uses into conf. + + Some domains can be represented by more than one HomeKit accessory; + climate is the only such domain today. Returns the accessory type the + caller must record with async_set_accessory_type once the accessory is + successfully created, so a failed creation is not sticky across + restarts; a stored routing the entity can no longer support is dropped + immediately instead. + """ + if state.domain != CLIMATE_DOMAIN: + return None + return _async_resolve_climate_type(aid_storage, state, conf, allow_auto=allow_auto) + + +@ha_callback +def _async_resolve_climate_type( + aid_storage: AccessoryAidStorage, + state: State, + conf: dict[str, Any], + *, + allow_auto: bool, +) -> str | None: + """Resolve which accessory a climate entity uses into conf. + + An explicit type in the entity config always wins, even for entities + with a humidity setpoint, and updates the stored routing, so switching + back to automatic keeps the accessory the entity already uses. In + bridge mode an entity that has never been bridged gets the HeaterCooler + when capable. Anything else keeps the Thermostat; the accessory type + can be changed at any time from the bridge options. + """ + entity_id = state.entity_id + if climate_type := conf.get(CONF_TYPE): + # The explicit type is recorded by the caller like the automatic + # one, so every path that sets a type defers persistence until + # the accessory exists. + return cast(str, climate_type) + if aid_storage.get_accessory_type(entity_id) == TYPE_HEATER_COOLER: + if not climate_controls_target_humidity(state): + conf[CONF_TYPE] = TYPE_HEATER_COOLER + return None + # A humidity setpoint gained since the choice was stored cannot + # be represented by the HeaterCooler, so the routing is dropped. + aid_storage.async_set_accessory_type(entity_id, None) + if not climate_supports_heater_cooler(state): + return None + if allow_auto and not aid_storage.entity_is_allocated(entity_id): + conf[CONF_TYPE] = TYPE_HEATER_COOLER + return TYPE_HEATER_COOLER + return None + + def get_accessory( # noqa: C901 hass: HomeAssistant, driver: HomeDriver, state: State, aid: int | None, config: dict ) -> HomeAccessory | None: @@ -151,7 +255,8 @@ def get_accessory( # noqa: C901 a_type = "BinarySensor" elif state.domain == "climate": - a_type = "Thermostat" + # The type is resolved by the bridge before the accessory is created. + a_type = CLIMATE_TYPES[config.get(CONF_TYPE, TYPE_THERMOSTAT)] elif state.domain == "cover": device_class = state.attributes.get(ATTR_DEVICE_CLASS) @@ -637,6 +742,25 @@ class HomeAccessory(Accessory): # type: ignore[misc] value: Any | None = None, ) -> None: """Fire event and call service for changes from HomeKit.""" + self.hass.async_create_task( + self.async_call_service_and_wait(domain, service, service_data, value), + eager_start=True, + ) + + async def async_call_service_and_wait( + self, + domain: str, + service: str, + service_data: dict[str, Any], + value: Any | None = None, + ) -> bool: + """Fire event and call service, returning True when it succeeded. + + blocking=True so the handler's exception reaches us (the + non-blocking path swallows it); on failure we resync so pyhap's + optimistic target characteristic doesn't strand the tile on the + requested action. + """ event_data = { ATTR_ENTITY_ID: service_data.get(ATTR_ENTITY_ID, self.entity_id), ATTR_DISPLAY_NAME: self.display_name, @@ -647,36 +771,40 @@ class HomeAccessory(Accessory): # type: ignore[misc] self.hass.bus.async_fire(EVENT_HOMEKIT_CHANGED, event_data, context=context) - async def _call() -> None: - # blocking=True so the handler's exception reaches us (the - # non-blocking path swallows it); on failure we resync so pyhap's - # optimistic target characteristic doesn't strand the tile on the - # requested action. - try: - await self.hass.services.async_call( - domain, service, service_data, blocking=True, context=context - ) - except HomeAssistantError as err: - _LOGGER.warning( - "%s: %s.%s failed (%s); re-syncing HomeKit state", - self.entity_id, - domain, - service, - err, - ) - except Exception: - _LOGGER.exception( - "%s: %s.%s raised unexpectedly; re-syncing HomeKit state", - self.entity_id, - domain, - service, - ) - else: - return + try: + await self.hass.services.async_call( + domain, service, service_data, blocking=True, context=context + ) + except HomeAssistantError as err: + _LOGGER.warning( + "%s: %s.%s failed (%s); re-syncing HomeKit state", + self.entity_id, + domain, + service, + err, + ) + except Exception: + _LOGGER.exception( + "%s: %s.%s raised unexpectedly; re-syncing HomeKit state", + self.entity_id, + domain, + service, + ) + else: + return True + # This coroutine often runs fire-and-forget, so failures must be + # logged here instead of by the loop's default task handler. + try: if (state := self.hass.states.get(self.entity_id)) is not None: self.async_update_state(state) - - self.hass.async_create_task(_call(), eager_start=True) + else: + _LOGGER.debug( + "%s: cannot re-sync HomeKit state; entity has no state", + self.entity_id, + ) + except Exception: + _LOGGER.exception("%s: re-syncing HomeKit state failed", self.entity_id) + return False @ha_callback def async_reload(self) -> None: diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index c76232f65f91..abcfb03575cd 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -25,6 +25,7 @@ AID_MANAGER_SAVE_DELAY = 2 ALLOCATIONS_KEY = "allocations" UNIQUE_IDS_KEY = "unique_ids" +ACCESSORY_TYPES_KEY = "accessory_types" INVALID_AIDS = (0, 1) @@ -69,6 +70,7 @@ class AccessoryAidStorage: self.hass = hass self.allocations: dict[str, int] = {} self.allocated_aids: set[int] = set() + self.accessory_types: dict[str, str] = {} self._entry_id = entry_id self.store: Store | None = None self._entity_registry = er.async_get(hass) @@ -84,6 +86,59 @@ class AccessoryAidStorage: assert isinstance(raw_storage, dict) self.allocations = raw_storage.get(ALLOCATIONS_KEY, {}) self.allocated_aids = set(self.allocations.values()) + self.accessory_types = raw_storage.get(ACCESSORY_TYPES_KEY, {}) + + def _stable_storage_keys(self, entity_id: str) -> tuple[str, ...]: + """Return the keys the entity's stable identity can resolve to. + + The preferred key comes first, matching the aid allocation + preference for the system unique id over the entity id. + """ + if not (entry := self._entity_registry.async_get(entity_id)): + return (entity_id,) + keys = [get_system_unique_id(entry, entry.unique_id)] + if previous_unique_id := entry.previous_unique_id: + keys.append(get_system_unique_id(entry, previous_unique_id)) + keys.append(entity_id) + return tuple(keys) + + @callback + def async_set_accessory_type( + self, entity_id: str, accessory_type: str | None + ) -> None: + """Persist the accessory type an entity resolved to, None clears it. + + The choice is stored by the same stable identity as the aid + allocation, so it survives entity id renames and unique id changes. + """ + if accessory_type == self.get_accessory_type(entity_id): + return + types = self.accessory_types + keys = self._stable_storage_keys(entity_id) + for key in keys: + types.pop(key, None) + if accessory_type is not None: + types[keys[0]] = accessory_type + self.async_schedule_save() + + @callback + def get_accessory_type(self, entity_id: str) -> str | None: + """Return the stored accessory type for the entity, if any. + + A type found under an outdated identity moves to the current one + and schedules a save, since only the latest previous unique id + stays resolvable; the read is loop bound because of that. + """ + types = self.accessory_types + keys = self._stable_storage_keys(entity_id) + for key in keys: + if (accessory_type := types.get(key)) is not None: + if key != keys[0]: + del types[key] + types[keys[0]] = accessory_type + self.async_schedule_save() + return accessory_type + return None def get_or_allocate_aid_for_entity_id(self, entity_id: str) -> int: """Generate a stable aid for an entity id.""" @@ -94,6 +149,15 @@ class AccessoryAidStorage: self._migrate_unique_id_aid_assignment_if_needed(sys_unique_id, entry) return self.get_or_allocate_aid(sys_unique_id, entity_id) + def entity_is_allocated(self, entity_id: str) -> bool: + """Return True when the entity already has an allocated aid. + + Checks every key get_or_allocate_aid_for_entity_id could resolve to, + without allocating, so callers can tell a previously bridged entity + from a new one. + """ + return self.get_allocated_aid_for_entity_id(entity_id) is not None + def _migrate_unique_id_aid_assignment_if_needed( self, sys_unique_id: str, entry: er.RegistryEntry ) -> None: @@ -129,6 +193,24 @@ class AccessoryAidStorage: f"Unable to generate unique aid allocation for {entity_id} [{unique_id}]" ) + def get_allocated_aid_for_entity_id(self, entity_id: str) -> int | None: + """Return the entity's allocated aid without allocating one.""" + allocations = self.allocations + return next( + ( + allocations[key] + for key in self._stable_storage_keys(entity_id) + if key in allocations + ), + None, + ) + + @callback + def async_delete_aid_for_entity_id(self, entity_id: str) -> None: + """Remove the aid allocation for an entity.""" + for key in self._stable_storage_keys(entity_id): + self.delete_aid(key) + def delete_aid(self, storage_key: str) -> None: """Delete an aid allocation.""" if storage_key not in self.allocations: @@ -150,6 +232,11 @@ class AccessoryAidStorage: return await self.store.async_save(self._data_to_save()) @callback - def _data_to_save(self) -> dict[str, dict[str, int]]: + def _data_to_save(self) -> dict[str, dict[str, int] | dict[str, str]]: """Return data of entity map to store in a file.""" - return {ALLOCATIONS_KEY: self.allocations} + data: dict[str, dict[str, int] | dict[str, str]] = { + ALLOCATIONS_KEY: self.allocations + } + if self.accessory_types: + data[ACCESSORY_TYPES_KEY] = self.accessory_types + return data diff --git a/homeassistant/components/homekit/climate_base.py b/homeassistant/components/homekit/climate_base.py index 93a3b8b6fa4f..401f5409d0d8 100644 --- a/homeassistant/components/homekit/climate_base.py +++ b/homeassistant/components/homekit/climate_base.py @@ -33,7 +33,12 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import State, callback from homeassistant.util.percentage import percentage_to_ordered_list_item @@ -45,6 +50,7 @@ from .climate_util import ( get_swing_off_mode, get_swing_on_mode, get_temperature_range_from_state, + has_swing_off_mode, is_swing_on, resolve_target_temp_range, temperature_attribute_to_homekit, @@ -69,6 +75,9 @@ FAN_STATE_INACTIVE = 0 FAN_STATE_IDLE = 1 FAN_STATE_ACTIVE = 2 +# States in which a climate entity is inactive rather than idle +CLIMATE_INACTIVE_STATES = frozenset({HVACMode.OFF, STATE_UNAVAILABLE, STATE_UNKNOWN}) + HC_HASS_TO_HOMEKIT_FAN_STATE = { HVACAction.OFF: FAN_STATE_INACTIVE, HVACAction.IDLE: FAN_STATE_IDLE, @@ -116,7 +125,11 @@ class HomeKitClimateAccessory(HomeAccessory): self.swing_on_mode: str | None = None self.swing_off_mode: str = SWING_OFF - if features & ClimateEntityFeature.SWING_MODE: + # The binary swing toggle writes the off mode back, so it is only + # usable when the entity advertises one. + if features & ClimateEntityFeature.SWING_MODE and has_swing_off_mode( + attributes + ): self.swing_on_mode = get_swing_on_mode(attributes) self.swing_off_mode = get_swing_off_mode(attributes) @@ -165,6 +178,20 @@ class HomeKitClimateAccessory(HomeAccessory): char.allow_invalid_client_values = True return char + def _reject_char_write(self, char: Characteristic, value: Any) -> None: + """Flip a characteristic back after rejecting a client write.""" + char.value = value + char.notify() + + def _dispatch_climate_write(self, service: str, params: dict[str, Any]) -> None: + """Send a climate write from a characteristic setter. + + Subclasses can override this to serialize their writes. + """ + self.async_call_service( + CLIMATE_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id, **params} + ) + def _update_temperature_char( self, char: Characteristic, state: State, attr: str ) -> None: @@ -213,29 +240,32 @@ class HomeKitClimateAccessory(HomeAccessory): """Convert a temperature in the HomeKit unit to the entity's unit.""" return temperature_to_states(temp, self._unit) - def _set_fan_speed(self, speed: int) -> None: - """Send the climate fan mode for a HomeKit rotation speed.""" + def _fan_speed_params(self, speed: int) -> dict[str, Any] | None: + """Return the set_fan_mode data for a HomeKit rotation speed.""" _LOGGER.debug("%s: Set fan speed to %s", self.entity_id, speed) if not self.ordered_fan_speeds or not 0 < speed <= 100: - return + return None mode = fan_speed_to_mode(self.ordered_fan_speeds, self.fan_modes, speed) - self.async_call_service( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode}, - ) + return {ATTR_FAN_MODE: mode} + + def _set_fan_speed(self, speed: int) -> None: + """Send the climate fan mode for a HomeKit rotation speed.""" + if (params := self._fan_speed_params(speed)) is not None: + self._dispatch_climate_write(SERVICE_SET_FAN_MODE, params) + + def _swing_mode_params(self, swing_on: int) -> dict[str, Any] | None: + """Return the set_swing_mode data for a HomeKit swing toggle.""" + if self.swing_on_mode is None: + return None + _LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on) + return { + ATTR_SWING_MODE: self.swing_on_mode if swing_on else self.swing_off_mode + } def _set_swing_mode(self, swing_on: int) -> None: """Send the climate swing mode for a HomeKit swing toggle.""" - if self.swing_on_mode is None: - return - _LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on) - mode = self.swing_on_mode if swing_on else self.swing_off_mode - self.async_call_service( - CLIMATE_DOMAIN, - SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: self.entity_id, ATTR_SWING_MODE: mode}, - ) + if (params := self._swing_mode_params(swing_on)) is not None: + self._dispatch_climate_write(SERVICE_SET_SWING_MODE, params) def _update_fan_speed_char(self, attributes: Mapping[str, Any]) -> None: """Update the rotation speed characteristic from the current fan mode.""" @@ -310,12 +340,10 @@ class HomeKitClimateAccessory(HomeAccessory): _LOGGER.debug( "%s: Fan does not support off, resetting to on", self.entity_id ) - self.char_fan_active.value = 1 - self.char_fan_active.notify() + self._reject_char_write(self.char_fan_active, 1) return mode = self._get_on_mode() if active else self.fan_modes[FAN_OFF] - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} - self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) + self._dispatch_climate_write(SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: mode}) def _set_fan_auto(self, auto: int) -> None: """Send the climate fan mode for a HomeKit fan auto toggle. @@ -325,8 +353,7 @@ class HomeKitClimateAccessory(HomeAccessory): """ _LOGGER.debug("%s: Set fan auto to %s", self.entity_id, auto) mode = self.fan_modes[FAN_AUTO] if auto else self._get_on_mode() - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} - self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) + self._dispatch_climate_write(SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: mode}) @callback def _async_update_fan_service(self, new_state: State) -> None: @@ -349,5 +376,8 @@ class HomeKitClimateAccessory(HomeAccessory): ) self.char_fan_active.set_value( - int(new_state.state != HVACMode.OFF and fan_mode_lower != FAN_OFF) + int( + new_state.state not in CLIMATE_INACTIVE_STATES + and fan_mode_lower != FAN_OFF + ) ) diff --git a/homeassistant/components/homekit/climate_util.py b/homeassistant/components/homekit/climate_util.py index 6731217fa1b6..92a5bfce06b8 100644 --- a/homeassistant/components/homekit/climate_util.py +++ b/homeassistant/components/homekit/climate_util.py @@ -1,6 +1,7 @@ """Shared fan, swing, and temperature helpers for the climate accessory types.""" from collections.abc import Iterable +import math from typing import Any from homeassistant.components.climate import ( @@ -85,6 +86,12 @@ def get_swing_off_mode(attributes: dict[str, Any]) -> str: return _lower_to_original(swing_modes).get(SWING_OFF, SWING_OFF) +def has_swing_off_mode(attributes: dict[str, Any]) -> bool: + """Return whether the entity advertises a swing off mode.""" + swing_modes = attributes.get(ATTR_SWING_MODES) or [] + return SWING_OFF in _lower_to_original(swing_modes) + + def fan_speed_to_mode( ordered_fan_speeds: list[str], fan_modes: dict[str, str], speed: int ) -> str: @@ -125,18 +132,27 @@ def get_temperature_range_from_state( because the Home app crashes on negative bounds. """ if (min_temp := state.attributes.get(ATTR_MIN_TEMP)) is not None: - min_temp = round(temperature_to_homekit(min_temp, unit) * 2) / 2 + min_temp = temperature_to_homekit(min_temp, unit) else: min_temp = default_min if (max_temp := state.attributes.get(ATTR_MAX_TEMP)) is not None: - max_temp = round(temperature_to_homekit(max_temp, unit) * 2) / 2 + max_temp = temperature_to_homekit(max_temp, unit) else: max_temp = default_max # Handle a reversed temperature range min_temp, max_temp = get_min_max(min_temp, max_temp) + # Round inward to the characteristic's 0.1 step so the slider cannot + # produce a write the entity's own limit validation rejects; a range + # too narrow to hold a step keeps the exact limits rather than + # expanding beyond them. + rounded_min = math.ceil(min_temp * 10) / 10 + rounded_max = math.floor(max_temp * 10) / 10 + if rounded_min <= rounded_max: + min_temp, max_temp = rounded_min, rounded_max + min_temp = max(min_temp, 0) max_temp = max(max_temp, min_temp) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 0d3294a1365e..fe50616a732b 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.components import device_automation from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN @@ -31,6 +32,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_NAME, CONF_PORT, + CONF_TYPE, ) from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers import ( @@ -55,14 +57,24 @@ from .const import ( HOMEKIT_MODE_BRIDGE, HOMEKIT_MODES, SHORT_BRIDGE_NAME, + TYPE_HEATER_COOLER, + TYPE_THERMOSTAT, VIDEO_CODEC_COPY, ) +from .models import HomeKitEntryData from .util import async_find_next_available_port, state_needs_accessory_mode CONF_CAMERA_AUDIO = "camera_audio" CONF_CAMERA_COPY = "camera_copy" CONF_INCLUDE_EXCLUDE_MODE = "include_exclude_mode" +CLIMATE_TYPE_AUTOMATIC = "automatic" +# Display names for the accessory classes a climate entity can use +CLIMATE_ACCESSORY_NAMES = { + "Thermostat": "Thermostat", + "HeaterCooler": "Heater Cooler", +} + MODE_INCLUDE = "include" MODE_EXCLUDE = "exclude" @@ -179,14 +191,29 @@ def _async_build_entities_filter( ) -def _async_cameras_from_entities(entities: list[str]) -> list[str]: +def _async_entities_in_domain(entities: list[str], domain: str) -> list[str]: return [ - entity_id - for entity_id in entities - if entity_id.startswith(CAMERA_ENTITY_PREFIX) + entity_id for entity_id in entities if split_entity_id(entity_id)[0] == domain ] +@callback +def _async_included_domain_entities( + hass: HomeAssistant, + entity_filter: EntityFilterDict, + entities: list[str], + domain: str, +) -> list[str]: + """Return a domain's included entities, expanding a whole domain include. + + The whole domain is included when none of its entities are selected + explicitly. + """ + if domain in entity_filter[CONF_INCLUDE_DOMAINS]: + return _async_get_matching_entities(hass, [domain]) + return _async_entities_in_domain(entities, domain) + + async def _async_name_to_type_map(hass: HomeAssistant) -> dict[str, str]: """Create a mapping of types of devices/entities HomeKit can support.""" integrations = await async_get_integrations(hass, SUPPORTED_DOMAINS) @@ -374,6 +401,97 @@ class OptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.hk_options: dict[str, Any] = {} self.included_cameras: list[str] = [] + self.included_climates: list[str] = [] + # Maps the displayed climate field label back to its entity id. + self._climate_choices: dict[str, str] = {} + + async def async_step_climate( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose the accessory type for climate entities.""" + if not self.included_climates: + return await self.async_step_bridged_device_triggers() + + hk_options = self.hk_options + all_entity_config: dict[str, dict[str, Any]] + + if user_input is not None: + all_entity_config = hk_options[CONF_ENTITY_CONFIG] + for label, entity_id in self._climate_choices.items(): + entity_config = all_entity_config.setdefault(entity_id, {}) + + if (choice := user_input[label]) == CLIMATE_TYPE_AUTOMATIC: + entity_config.pop(CONF_TYPE, None) + else: + entity_config[CONF_TYPE] = choice + + if not entity_config: + all_entity_config.pop(entity_id) + + if not all_entity_config: + del hk_options[CONF_ENTITY_CONFIG] + + return await self.async_step_bridged_device_triggers() + + # Field labels come from the schema keys, so key the form by the + # friendly name and map back to the entity id on submit. The + # accessory a bridged entity currently uses is shown so Automatic + # is not a mystery. + current_accessories = self._async_current_climate_accessories() + self._climate_choices = {} + for entity_id in self.included_climates: + state = self.hass.states.get(entity_id) + label = f"{state.name} ({entity_id})" if state else entity_id + if current := current_accessories.get(entity_id): + label = f"{label} [{current}]" + self._climate_choices[label] = entity_id + + all_entity_config = hk_options.setdefault(CONF_ENTITY_CONFIG, {}) + type_selector = selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + CLIMATE_TYPE_AUTOMATIC, + TYPE_THERMOSTAT, + TYPE_HEATER_COOLER, + ], + translation_key="climate_accessory_type", + ) + ) + data_schema = vol.Schema( + { + vol.Required( + label, + default=all_entity_config.get(entity_id, {}).get( + CONF_TYPE, CLIMATE_TYPE_AUTOMATIC + ), + ): type_selector + for label, entity_id in self._climate_choices.items() + } + ) + return self.async_show_form(step_id="climate", data_schema=data_schema) + + @callback + def _async_current_climate_accessories(self) -> dict[str, str]: + """Map bridged climate entities to their current accessory name.""" + entry_data: HomeKitEntryData | None = getattr( + self.config_entry, "runtime_data", None + ) + if entry_data is None: + return {} + homekit = entry_data.homekit + accessories: Iterable[Any] + if homekit.bridge is not None: + accessories = homekit.bridge.accessories.values() + elif homekit.driver is not None and homekit.driver.accessory is not None: + accessories = [homekit.driver.accessory] + else: + return {} + return { + entity_id: CLIMATE_ACCESSORY_NAMES[accessory_name] + for accessory in accessories + if (entity_id := getattr(accessory, "entity_id", None)) is not None + and (accessory_name := type(accessory).__name__) in CLIMATE_ACCESSORY_NAMES + } async def async_step_yaml( self, user_input: dict[str, Any] | None = None @@ -426,6 +544,9 @@ class OptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Choose camera config.""" + if not self.included_cameras: + return await self.async_step_climate() + hk_options = self.hk_options all_entity_config: dict[str, dict[str, Any]] @@ -447,7 +568,7 @@ class OptionsFlowHandler(OptionsFlow): if not entity_config: all_entity_config.pop(entity_id) - return await self.async_step_bridged_device_triggers() + return await self.async_step_climate() cameras_with_audio = [] cameras_with_copy = [] @@ -492,11 +613,10 @@ class OptionsFlowHandler(OptionsFlow): if user_input is not None: entities = cv.ensure_list(user_input[CONF_ENTITIES]) entity_filter = _async_build_entities_filter(domains, entities) - self.included_cameras = _async_cameras_from_entities(entities) + self.included_cameras = _async_entities_in_domain(entities, CAMERA_DOMAIN) + self.included_climates = _async_entities_in_domain(entities, CLIMATE_DOMAIN) hk_options[CONF_FILTER] = entity_filter - if self.included_cameras: - return await self.async_step_cameras() - return await self.async_step_bridged_device_triggers() + return await self.async_step_cameras() entity_filter = hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) @@ -536,13 +656,17 @@ class OptionsFlowHandler(OptionsFlow): domains = hk_options[CONF_DOMAINS] if user_input is not None: entities = cv.ensure_list(user_input[CONF_ENTITIES]) - self.included_cameras = _async_cameras_from_entities(entities) - hk_options[CONF_FILTER] = _async_build_entities_filter(domains, entities) - if self.included_cameras: - return await self.async_step_cameras() - return await self.async_step_bridged_device_triggers() + entity_filter = _async_build_entities_filter(domains, entities) + self.included_cameras = _async_included_domain_entities( + self.hass, entity_filter, entities, CAMERA_DOMAIN + ) + self.included_climates = _async_included_domain_entities( + self.hass, entity_filter, entities, CLIMATE_DOMAIN + ) + hk_options[CONF_FILTER] = entity_filter + return await self.async_step_cameras() - entity_filter: EntityFilterDict = hk_options.get(CONF_FILTER, {}) + entity_filter = hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) all_supported_entities = _async_get_matching_entities( self.hass, domains, include_entity_category=True, include_hidden=True @@ -579,23 +703,23 @@ class OptionsFlowHandler(OptionsFlow): domains = hk_options[CONF_DOMAINS] if user_input is not None: - self.included_cameras = [] entities = cv.ensure_list(user_input[CONF_ENTITIES]) - if CAMERA_DOMAIN in domains: - camera_entities = _async_get_matching_entities( - self.hass, [CAMERA_DOMAIN] - ) - self.included_cameras = [ + + def _remaining_in_domain(domain: str) -> list[str]: + if domain not in domains: + return [] + return [ entity_id - for entity_id in camera_entities + for entity_id in _async_get_matching_entities(self.hass, [domain]) if entity_id not in entities ] + + self.included_cameras = _remaining_in_domain(CAMERA_DOMAIN) + self.included_climates = _remaining_in_domain(CLIMATE_DOMAIN) hk_options[CONF_FILTER] = _make_entity_filter( include_domains=domains, exclude_entities=entities ) - if self.included_cameras: - return await self.async_step_cameras() - return await self.async_step_bridged_device_triggers() + return await self.async_step_cameras() entity_filter = self.hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 5d5a8efc0e2a..0f69c4350f27 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -128,6 +128,9 @@ TYPE_VALVE = "valve" TYPE_FAN = "fan" TYPE_AIR_PURIFIER = "air_purifier" +TYPE_HEATER_COOLER = "heater_cooler" +TYPE_THERMOSTAT = "thermostat" + # #### Categories #### CATEGORY_RECEIVER = 34 @@ -145,6 +148,7 @@ SERV_DOORBELL = "Doorbell" SERV_FANV2 = "Fanv2" SERV_FILTER_MAINTENANCE = "FilterMaintenance" SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" +SERV_HEATER_COOLER = "HeaterCooler" SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier" SERV_HUMIDITY_SENSOR = "HumiditySensor" SERV_INPUT_SOURCE = "InputSource" @@ -193,6 +197,7 @@ CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel" CHAR_CURRENT_AIR_PURIFIER_STATE = "CurrentAirPurifierState" CHAR_CURRENT_DOOR_STATE = "CurrentDoorState" CHAR_CURRENT_FAN_STATE = "CurrentFanState" +CHAR_CURRENT_HEATER_COOLER_STATE = "CurrentHeaterCoolerState" CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState" CHAR_CURRENT_HUMIDIFIER_DEHUMIDIFIER = "CurrentHumidifierDehumidifierState" CHAR_CURRENT_POSITION = "CurrentPosition" @@ -245,6 +250,7 @@ CHAR_STREAMING_STRATUS = "StreamingStatus" CHAR_SWING_MODE = "SwingMode" CHAR_TARGET_AIR_PURIFIER_STATE = "TargetAirPurifierState" CHAR_TARGET_DOOR_STATE = "TargetDoorState" +CHAR_TARGET_HEATER_COOLER_STATE = "TargetHeaterCoolerState" CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState" CHAR_TARGET_POSITION = "TargetPosition" CHAR_TARGET_FAN_STATE = "TargetFanState" diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 3bb8625cba5d..6c93b3f65356 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -5,7 +5,7 @@ }, "step": { "pairing": { - "description": "To complete pairing follow the instructions in \u201cNotifications\u201d under \u201cHomeKit Pairing\u201d.", + "description": "To complete pairing follow the instructions in “Notifications” under “HomeKit Pairing”.", "title": "Pair HomeKit" }, "user": { @@ -40,18 +40,22 @@ "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single-board computers.", "title": "Camera configuration" }, + "climate": { + "description": "Choose which accessory each climate entity uses in HomeKit. Heater Cooler puts the mode, temperature, and the supported fan speed and swing controls on one air conditioner style tile; Thermostat is the classic temperature dial with a separate fan. Automatic keeps the accessory the entity already uses and picks the best fit when it is bridged for the first time. Changing the accessory type keeps the room and name, but HomeKit scenes or automations that used the old controls may need to be recreated.", + "title": "Climate accessory configuration" + }, "exclude": { "data": { "entities": "[%key:component::homekit::options::step::include::data::entities%]" }, - "description": "All \u201c{domains}\u201d entities will be included except for the excluded entities and categorized entities.", + "description": "All “{domains}” entities will be included except for the excluded entities and categorized entities.", "title": "Select the entities to be excluded" }, "include": { "data": { "entities": "Entities" }, - "description": "Select entities from each domain in \u201c{domains}\u201d. The include will cover the entire domain if you do not select any entities for a given domain.", + "description": "Select entities from each domain in “{domains}”. The include will cover the entire domain if you do not select any entities for a given domain.", "title": "Select the entities to be included" }, "init": { @@ -60,7 +64,7 @@ "include_exclude_mode": "Inclusion mode", "mode": "HomeKit mode" }, - "description": "HomeKit can be configured to expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", + "description": "HomeKit can be configured to expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the “Domains to include” will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", "title": "Select mode and domains." }, "yaml": { @@ -69,6 +73,15 @@ } } }, + "selector": { + "climate_accessory_type": { + "options": { + "automatic": "Automatic", + "heater_cooler": "Heater Cooler", + "thermostat": "Thermostat" + } + } + }, "services": { "reload": { "description": "Reloads HomeKit and re-processes the YAML-configuration.", diff --git a/homeassistant/components/homekit/type_heater_coolers.py b/homeassistant/components/homekit/type_heater_coolers.py new file mode 100644 index 000000000000..64f726911985 --- /dev/null +++ b/homeassistant/components/homekit/type_heater_coolers.py @@ -0,0 +1,734 @@ +"""Class to hold all heater cooler accessories.""" + +import asyncio +from collections.abc import Callable, Coroutine +import functools +import logging +from typing import Any, Concatenate, NamedTuple, override + +from pyhap.characteristic import Characteristic +from pyhap.const import CATEGORY_AIR_CONDITIONER, CATEGORY_HEATER + +from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, + FAN_ON, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES +from homeassistant.core import State, callback +from homeassistant.util.enum import try_parse_enum + +from .accessories import TYPES +from .climate_base import CLIMATE_INACTIVE_STATES, HomeKitClimateAccessory +from .climate_util import temperature_attribute_to_homekit +from .const import ( + CHAR_ACTIVE, + CHAR_COOLING_THRESHOLD_TEMPERATURE, + CHAR_CURRENT_FAN_STATE, + CHAR_CURRENT_HEATER_COOLER_STATE, + CHAR_CURRENT_HUMIDITY, + CHAR_CURRENT_TEMPERATURE, + CHAR_HEATING_THRESHOLD_TEMPERATURE, + CHAR_NAME, + CHAR_ROTATION_SPEED, + CHAR_SWING_MODE, + CHAR_TARGET_FAN_STATE, + CHAR_TARGET_HEATER_COOLER_STATE, + PROP_MAX_VALUE, + PROP_MIN_STEP, + PROP_MIN_VALUE, + SERV_HEATER_COOLER, + SERV_HUMIDITY_SENSOR, +) + +_LOGGER = logging.getLogger(__name__) + +# HomeKit CurrentHeaterCoolerState values (per HomeKit spec) +HC_INACTIVE, HC_IDLE, HC_HEATING, HC_COOLING = range(4) + +# HomeKit TargetHeaterCoolerState valid values: Auto=0, Heat=1, Cool=2 +HC_TARGET_AUTO, HC_TARGET_HEAT, HC_TARGET_COOL = range(3) + +# Off is intentionally not mapped: when the entity is off the target +# characteristic keeps the last active mode so it stays in sync with +# _last_known_mode, which is what turning Active back on restores. +HC_HASS_TO_HOMEKIT_TARGET = { + HVACMode.HEAT: HC_TARGET_HEAT, + HVACMode.COOL: HC_TARGET_COOL, + HVACMode.HEAT_COOL: HC_TARGET_AUTO, + HVACMode.AUTO: HC_TARGET_AUTO, +} + +# HomeKit's CurrentHeaterCoolerState has no drying or fan-only value. Those +# actions map to Cooling rather than Idle so the tile still shows the unit is +# doing something, which also matches the Thermostat's action mapping. +HC_HASS_TO_HOMEKIT_ACTION = { + HVACAction.OFF: HC_INACTIVE, + HVACAction.IDLE: HC_IDLE, + HVACAction.HEATING: HC_HEATING, + HVACAction.PREHEATING: HC_HEATING, + HVACAction.COOLING: HC_COOLING, + HVACAction.DRYING: HC_COOLING, + HVACAction.FAN: HC_COOLING, + HVACAction.DEFROSTING: HC_HEATING, +} + +# Hysteresis band in Celsius used when the entity omits hvac_action +ACTION_HYSTERESIS = 0.25 + + +class ClimateServiceCall(NamedTuple): + """A queued climate write and the modes to apply once accepted.""" + + service: str + data: dict[str, Any] + # Remembered as the Active on restore target + commit_mode: HVACMode | None = None + # Bridges resolution until the entity reports a mode change + pending_mode: HVACMode | None = None + + +def _locked_write[**_P]( + func: Callable[Concatenate[HeaterCooler, _P], Coroutine[Any, Any, None]], +) -> Callable[Concatenate[HeaterCooler, _P], Coroutine[Any, Any, None]]: + """Run the write coroutine under the accessory's write lock.""" + + @functools.wraps(func) + async def _wrapper(self: HeaterCooler, *args: _P.args, **kwargs: _P.kwargs) -> None: + async with self._write_lock: + await func(self, *args, **kwargs) + + return _wrapper + + +# Modes that drive both a heating and a cooling threshold +RANGE_MODES = (HVACMode.HEAT_COOL, HVACMode.AUTO) + + +@TYPES.register("HeaterCooler") +class HeaterCooler(HomeKitClimateAccessory): + """Generate a HeaterCooler accessory for a climate entity.""" + + # Configured only when the entity accepts a target temperature. + char_cool: Characteristic + char_heat: Characteristic + + # Configured only when the entity reports a current humidity. + char_current_humidity: Characteristic + + def __init__(self, *args: Any) -> None: + """Initialize a HeaterCooler accessory object.""" + super().__init__(*args) + + state = self.hass.states.get(self.entity_id) + assert state + attributes = state.attributes + features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + # The thresholds double as the setpoints, so only expose them when the + # entity accepts a target temperature; a fan/dry-only entity otherwise + # gets sliders that dispatch set_temperature it cannot honor. + has_thresholds = bool( + features + & ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + ) + + hvac_modes = attributes.get(ATTR_HVAC_MODES, []) + current_mode = try_parse_enum(HVACMode, state.state) + + self._supports_off = HVACMode.OFF in hvac_modes + supports_auto = HVACMode.AUTO in hvac_modes or current_mode == HVACMode.AUTO + supports_heat_cool = ( + HVACMode.HEAT_COOL in hvac_modes or current_mode == HVACMode.HEAT_COOL + ) + + can_cool = HVACMode.COOL in hvac_modes or supports_auto or supports_heat_cool + can_heat = HVACMode.HEAT in hvac_modes or supports_auto or supports_heat_cool + + # Standalone pairings advertise the category in the QR code and mDNS + # metadata, so pick the one matching the device instead of Thermostat; + # only a heat only device is a heater, everything else including the + # fan or dry only case is an air conditioner. + self.category = ( + CATEGORY_HEATER if can_heat and not can_cool else CATEGORY_AIR_CONDITIONER + ) + + # Per the HomeKit spec a heater must include the heating threshold and a + # cooler the cooling one, so a one sided device gets a single slider. A + # device with neither side (e.g. dry only with a setpoint) keeps both, + # and a range only device always needs both sides in a write, so the + # setpoints stay controllable. + if (not can_cool and not can_heat) or not ( + features & ClimateEntityFeature.TARGET_TEMPERATURE + ): + can_cool = can_heat = True + self._has_cool_threshold = has_thresholds and can_cool + self._has_heat_threshold = has_thresholds and can_heat + + # Only expose the targets the entity actually supports so HomeKit does + # not offer a mode the climate service would reject. Auto is backed by + # a range mode, preferring HEAT_COOL whose thresholds stay adjustable + # over AUTO, which may follow a schedule, like the thermostat does. + self._hk_to_ha_target: dict[int, HVACMode] = {} + if HVACMode.HEAT in hvac_modes: + self._hk_to_ha_target[HC_TARGET_HEAT] = HVACMode.HEAT + if HVACMode.COOL in hvac_modes: + self._hk_to_ha_target[HC_TARGET_COOL] = HVACMode.COOL + if supports_heat_cool: + self._hk_to_ha_target[HC_TARGET_AUTO] = HVACMode.HEAT_COOL + elif supports_auto: + self._hk_to_ha_target[HC_TARGET_AUTO] = HVACMode.AUTO + if not self._hk_to_ha_target: + # Entities exposing neither heat, cool, nor a range mode (e.g. + # fan-only) still need a valid target; map Auto to the first mode the + # entity actually supports so the control does something. A degenerate + # off-only entity has no active mode, so fall back to off rather than + # an unsupported Auto. + fallback_mode = next( + (mode for mode in hvac_modes if mode != HVACMode.OFF), HVACMode.OFF + ) + self._hk_to_ha_target[HC_TARGET_AUTO] = fallback_mode + + chars = [ + CHAR_ACTIVE, + CHAR_CURRENT_HEATER_COOLER_STATE, + CHAR_TARGET_HEATER_COOLER_STATE, + CHAR_CURRENT_TEMPERATURE, + ] + if self._has_cool_threshold: + chars.append(CHAR_COOLING_THRESHOLD_TEMPERATURE) + if self._has_heat_threshold: + chars.append(CHAR_HEATING_THRESHOLD_TEMPERATURE) + + # The HeaterCooler service has no auto fan control, so when the entity + # exposes an auto fan mode (and a manual mode to switch back to) the fan + # is exposed through a full linked fan service instead; the rotation + # speed then lives there, since per the HomeKit spec it only belongs on + # the HeaterCooler when the fan cannot be independently controlled. + if FAN_AUTO in self.fan_modes and ( + FAN_ON in self.fan_modes or self.ordered_fan_speeds + ): + self.fan_chars.append(CHAR_TARGET_FAN_STATE) + if self.ordered_fan_speeds: + self.fan_chars.append(CHAR_ROTATION_SPEED) + if attributes.get(ATTR_HVAC_ACTION) is not None: + self.fan_chars.append(CHAR_CURRENT_FAN_STATE) + + # Fan/swing modes are detected in the base class; only advertise the + # characteristics when the entity exposes predefined modes. + if self.ordered_fan_speeds and not self.fan_chars: + chars.append(CHAR_ROTATION_SPEED) + if self.swing_on_mode is not None: + chars.append(CHAR_SWING_MODE) + + serv = self.add_preload_service(SERV_HEATER_COOLER, chars) + + self.char_active = serv.configure_char(CHAR_ACTIVE, value=0) + self.char_current_state = serv.configure_char( + CHAR_CURRENT_HEATER_COOLER_STATE, value=HC_INACTIVE + ) + # Also the reverse lookup for modes only selectable through the + # Auto fallback, so every selectable mode is representable. + self._ha_to_hk_target = { + ha_mode: hk_state for hk_state, ha_mode in self._hk_to_ha_target.items() + } + if HC_TARGET_AUTO in self._hk_to_ha_target: + default_target = HC_TARGET_AUTO + else: + default_target = next(iter(self._hk_to_ha_target)) + self.char_target_state = self._configure_target_mode_char( + serv, + CHAR_TARGET_HEATER_COOLER_STATE, + default_target, + self._ha_to_hk_target, + ) + self._configure_current_temperature_char(serv) + + if self._has_cool_threshold or self._has_heat_threshold: + min_temp_hk, max_temp_hk = self.get_temperature_range(state) + temp_properties = { + PROP_MIN_VALUE: min_temp_hk, + PROP_MAX_VALUE: max_temp_hk, + # We do not set PROP_MIN_STEP here and instead use the HomeKit + # default of 0.1 in order to have enough precision to convert + # temperature units and avoid setting 73F resulting in 74F + } + # Placeholder value within the configured range; async_update_state + # overwrites it from the entity immediately. + default_temp = min(max(21.0, min_temp_hk), max_temp_hk) + if self._has_cool_threshold: + self.char_cool = serv.configure_char( + CHAR_COOLING_THRESHOLD_TEMPERATURE, + value=default_temp, + properties=temp_properties, + ) + if self._has_heat_threshold: + self.char_heat = serv.configure_char( + CHAR_HEATING_THRESHOLD_TEMPERATURE, + value=default_temp, + properties=temp_properties, + ) + + if self.ordered_fan_speeds and not self.fan_chars: + self.char_speed = serv.configure_char( + CHAR_ROTATION_SPEED, + value=100, + properties={PROP_MIN_STEP: 100 / len(self.ordered_fan_speeds)}, + ) + if self.swing_on_mode is not None: + self.char_swing = serv.configure_char(CHAR_SWING_MODE, value=0) + + if self.fan_chars: + self._configure_fan_service(serv) + + # The Heater Cooler service has no humidity characteristic, so surface a + # reported current humidity through a linked humidity sensor. Like the + # Thermostat, this is decided once at setup and not a reload attribute: + # current humidity changes on every update, so reloading on it would + # thrash the accessory. + self._has_humidity = ATTR_CURRENT_HUMIDITY in attributes + if self._has_humidity: + humidity_serv = self.add_preload_service(SERV_HUMIDITY_SENSOR, CHAR_NAME) + serv.add_linked_service(humidity_serv) + humidity_serv.configure_char( + CHAR_NAME, value=f"{self.display_name} Humidity" + ) + self.char_current_humidity = humidity_serv.configure_char( + CHAR_CURRENT_HUMIDITY, value=50 + ) + + # Every service exists now, so they all get an explicit primary + # flag; without one the Home app can pick its own tile service. + self.set_primary_service(serv) + + # Fall back to the displayed target mode so turning Active on for a device + # that was off at startup activates the mode HomeKit is showing rather than + # an arbitrary one. + # Modes without a HomeKit target representation, like dry or fan + # only, are not remembered so turning Active on brings back the + # mode the tile is showing instead of one it cannot display. + self._last_known_mode: HVACMode + if current_mode and self._hk_target_mode(current_mode) is not None: + self._last_known_mode = current_mode + else: + self._last_known_mode = self._hk_to_ha_target[default_target] + + self._write_lock = asyncio.Lock() + # A mode the entity accepted but does not report yet; push + # integrations can return from the service before their state + # callback arrives. + self._pending_mode: HVACMode | None = None + self._last_reported_mode = current_mode + + self.async_update_state(state) + + # A single service-level callback batches every characteristic write. + serv.setter_callback = self._set_chars + + def _set_chars(self, char_values: dict[str, Any]) -> None: + """Handle writes to multiple HeaterCooler characteristics at once.""" + _LOGGER.debug("HeaterCooler _set_chars: %s", char_values) + # Batches are resolved and applied under the accessory lock so a + # batch sees the outcome of the one before it and cannot overtake + # or interleave with it. + self.hass.async_create_task( + self._async_apply_batch(char_values), eager_start=True + ) + + @_locked_write + async def _async_apply_batch(self, char_values: dict[str, Any]) -> None: + """Resolve one characteristic batch and apply its writes in order. + + A failed write aborts the rest of the batch, since the tile was + already re-synced and later writes would target a mode the entity + refused to enter. + """ + service_calls: list[ClimateServiceCall] = [] + current_state = self.hass.states.get(self.entity_id) + active = char_values.get(CHAR_ACTIVE) + + # A mode written in the batch wins over the entity state, which + # still holds the pre-change mode. + requested_mode: HVACMode | None = None + if ( + target_mode := char_values.get(CHAR_TARGET_HEATER_COOLER_STATE) + ) is not None: + requested_mode = self._hk_to_ha_target.get(target_mode) + elif active == 1: + # Turning on activates the last known mode, so setpoints in + # the same batch resolve against it instead of the off state. + requested_mode = self._last_known_mode + + # Active/mode changes are handled first as they gate the others. + if self._handle_active_mode_changes( + active, target_mode, service_calls, current_state, requested_mode + ): + # A just accepted mode stays effective for the setpoints until + # the entity reports it, so a following batch does not resolve + # against the pre-switch state. + self._handle_temperature_changes( + char_values, + service_calls, + current_state, + requested_mode or self._pending_mode, + ) + # Fan and swing are queued last so they follow the mode switch. + self._queue_fan_swing_changes(char_values, service_calls) + + for call in service_calls: + reported_mode = self._last_reported_mode + known_mode = self._last_known_mode + if not await self.async_call_service_and_wait( + CLIMATE_DOMAIN, + call.service, + {ATTR_ENTITY_ID: self.entity_id, **call.data}, + ): + return + # A state callback during the blocking call is fresher than the + # queued values, so each one only applies when its counterpart + # was not updated while awaiting. The remembered mode mirrors + # the accepted target, so a rejected mode is not restored later. + if call.pending_mode and self._last_reported_mode == reported_mode: + self._pending_mode = call.pending_mode + if call.commit_mode and self._last_known_mode == known_mode: + self._last_known_mode = call.commit_mode + + @override + def _dispatch_climate_write(self, service: str, params: dict[str, Any]) -> None: + """Serialize the write behind any batch still being applied.""" + self.hass.async_create_task( + self._async_apply_locked_write(service, params), eager_start=True + ) + + @_locked_write + async def _async_apply_locked_write( + self, service: str, params: dict[str, Any] + ) -> None: + """Await one write under the accessory lock.""" + await self.async_call_service_and_wait( + CLIMATE_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id, **params} + ) + + def _queue_fan_swing_changes( + self, + char_values: dict[str, Any], + service_calls: list[ClimateServiceCall], + ) -> None: + """Queue fan speed and swing mode changes.""" + if ( + CHAR_ROTATION_SPEED in char_values + and (params := self._fan_speed_params(char_values[CHAR_ROTATION_SPEED])) + is not None + ): + service_calls.append(ClimateServiceCall(SERVICE_SET_FAN_MODE, params)) + if ( + CHAR_SWING_MODE in char_values + and (params := self._swing_mode_params(char_values[CHAR_SWING_MODE])) + is not None + ): + service_calls.append(ClimateServiceCall(SERVICE_SET_SWING_MODE, params)) + + def _handle_active_mode_changes( + self, + active: int | None, + target_mode: int | None, + service_calls: list[ClimateServiceCall], + current_state: State | None, + requested_mode: HVACMode | None, + ) -> bool: + """Handle active and mode changes. + + Returns False when an off write terminates the batch; a rejected + off leaves the entity running, so the rest still applies. + """ + if target_mode is not None and requested_mode is None: + # The write already changed the characteristic to a target the + # entity cannot enter, so put it back on the last mode. + if (restore := self._hk_target_mode(self._last_known_mode)) is not None: + self._reject_char_write(self.char_target_state, restore) + + if active == 0: + # climate.turn_off raises for entities without an OFF mode; set the + # OFF mode directly and only when it is supported, like the thermostat. + if self._supports_off: + # A target bundled with off is already on the tile rather + # than sent, so it is committed with the accepted off write + # and restored by the next Active on. + service_calls.append( + ClimateServiceCall( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.OFF}, + commit_mode=requested_mode, + pending_mode=HVACMode.OFF, + ) + ) + return False + _LOGGER.debug( + "%s: Ignoring off request; entity has no off mode", + self.entity_id, + ) + # The write already flipped the characteristic; flip it back so + # HomeKit keeps showing the unit as on, and let the rest of the + # batch apply since the entity keeps running. + self._reject_char_write(self.char_active, 1) + if requested_mode and ( + target_mode is not None + or current_state is None + or self._pending_mode == HVACMode.OFF + or current_state.state in CLIMATE_INACTIVE_STATES + ): + # An explicit target always goes out; Active on sends the last + # known mode only when the entity is not already running, where + # a pending off write means it is about to stop running. + service_calls.append( + ClimateServiceCall( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: requested_mode}, + commit_mode=requested_mode, + pending_mode=requested_mode, + ) + ) + return True + + def _handle_temperature_changes( + self, + char_values: dict[str, Any], + service_calls: list[ClimateServiceCall], + current_state: State | None, + requested_mode: HVACMode | None, + ) -> None: + """Handle temperature changes.""" + cooling_temp = char_values.get(CHAR_COOLING_THRESHOLD_TEMPERATURE) + heating_temp = char_values.get(CHAR_HEATING_THRESHOLD_TEMPERATURE) + + if cooling_temp is None and heating_temp is None: + return + + # Entities that support both single and range targets publish the + # range keys even when they are unset, so the effective mode decides + # between a range and a single setpoint write; entities that only + # take a range always get one. + attributes = current_state.attributes if current_state else {} + effective_mode: HVACMode | str | None = requested_mode or ( + current_state.state if current_state else None + ) + use_range = ( + self._has_cool_threshold + and self._has_heat_threshold + and ( + ATTR_TARGET_TEMP_HIGH in attributes + or ATTR_TARGET_TEMP_LOW in attributes + ) + and (effective_mode in RANGE_MODES or ATTR_TEMPERATURE not in attributes) + ) + + if use_range: + service_calls.append( + ClimateServiceCall( + SERVICE_SET_TEMPERATURE, + self._dual_setpoint_params( + self.char_cool, self.char_heat, cooling_temp, heating_temp + ), + ) + ) + else: + self._handle_single_temp_changes( + service_calls, cooling_temp, heating_temp, current_state, effective_mode + ) + + def _handle_single_temp_changes( + self, + service_calls: list[ClimateServiceCall], + cooling_temp: float | None, + heating_temp: float | None, + current_state: State | None, + effective_mode: HVACMode | str | None, + ) -> None: + """Handle temperature changes for single-temperature entities.""" + if not current_state: + return + + # For a single setpoint the effective mode decides which threshold is + # the setpoint; Cool uses the cooling side and Heat the heating side, + # so a write to the other side is ignored. Range and other modes fall + # back to whichever threshold moved, and Auto picks the one furthest + # from the current setpoint. + selected_temp = None + if effective_mode == HVACMode.COOL: + selected_temp = cooling_temp + elif effective_mode == HVACMode.HEAT: + selected_temp = heating_temp + elif ( + effective_mode in RANGE_MODES + and cooling_temp is not None + and heating_temp is not None + ): + # Pick whichever threshold moved further from the entity's existing + # target setpoint. The thresholds are in HomeKit units, so convert + # the target setpoint before comparing. + target_temp = current_state.attributes.get(ATTR_TEMPERATURE) + if target_temp is None: + selected_temp = heating_temp + else: + target_temp_hk = self._temperature_to_homekit(target_temp) + if abs(cooling_temp - target_temp_hk) > abs( + heating_temp - target_temp_hk + ): + selected_temp = cooling_temp + else: + selected_temp = heating_temp + elif cooling_temp is not None: + selected_temp = cooling_temp + elif heating_temp is not None: + selected_temp = heating_temp + + if selected_temp is not None: + ha_temp = self._temperature_to_states(selected_temp) + service_calls.append( + ClimateServiceCall(SERVICE_SET_TEMPERATURE, {ATTR_TEMPERATURE: ha_temp}) + ) + + def _hk_target_mode(self, mode: HVACMode) -> int | None: + """Map HA hvac_mode to a HomeKit target heater-cooler state.""" + # HomeKit's HeaterCooler target only has Auto/Heat/Cool, so modes like + # dry and fan_only have no representation; they are intentionally + # collapsed to the Auto target (see the fallback in __init__) and cannot + # be selected or reflected individually from the Home app. + hk_value = HC_HASS_TO_HOMEKIT_TARGET.get(mode) + if hk_value is not None and hk_value in self._hk_to_ha_target: + return hk_value + return self._ha_to_hk_target.get(mode) + + @callback + @override + def async_update_state(self, new_state: State) -> None: + """Update state without rechecking the device features.""" + attributes = new_state.attributes + current_mode = try_parse_enum(HVACMode, new_state.state) + if current_mode is not None: + if current_mode != self._last_reported_mode: + # The entity moved to a new mode, so its state is + # authoritative again; re-reports of the pre-switch mode, + # like attribute updates mid transition, keep the bridge. + self._pending_mode = None + self._last_reported_mode = current_mode + # While a write is pending, the accepted mode stays the displayed + # and restore target so a stale re-report cannot flip the tile back. + display_mode = self._pending_mode or current_mode + if display_mode and (tgt := self._hk_target_mode(display_mode)) is not None: + self._last_known_mode = display_mode + self.char_target_state.set_value(tgt) + + if new_state.state in CLIMATE_INACTIVE_STATES: + # An off or unavailable entity is inactive, not idle. + self.char_active.set_value(0) + self.char_current_state.set_value(HC_INACTIVE) + else: + self.char_active.set_value(1) + action = attributes.get(ATTR_HVAC_ACTION) or self._derive_action( + new_state, current_mode + ) + self.char_current_state.set_value( + HC_HASS_TO_HOMEKIT_ACTION.get(action, HC_INACTIVE) + ) + + self._update_current_temperature_char(new_state) + self._update_temperature_thresholds(new_state) + if self._has_humidity and isinstance( + (humidity := attributes.get(ATTR_CURRENT_HUMIDITY)), (int, float) + ): + self.char_current_humidity.set_value(humidity) + if self.fan_chars: + self._async_update_fan_service(new_state) + else: + # The base char updaters no-op when the entity exposes no fan/swing. + self._update_fan_speed_char(attributes) + self._update_swing_char(attributes) + + def _update_temperature_thresholds(self, state: State) -> None: + """Update HomeKit temperature thresholds based on HA state.""" + if not self._has_cool_threshold and not self._has_heat_threshold: + return + attributes = state.attributes + # Dual capable entities publish the range keys even in single + # setpoint modes, so only values decide what is displayed. + supports_dual_temp = ( + attributes.get(ATTR_TARGET_TEMP_HIGH) is not None + or attributes.get(ATTR_TARGET_TEMP_LOW) is not None + ) + + if supports_dual_temp: + if self._has_cool_threshold: + self._update_temperature_char( + self.char_cool, state, ATTR_TARGET_TEMP_HIGH + ) + if self._has_heat_threshold: + self._update_temperature_char( + self.char_heat, state, ATTR_TARGET_TEMP_LOW + ) + elif ( + target_temp := temperature_attribute_to_homekit( + state, ATTR_TEMPERATURE, self._unit + ) + ) is not None: + if self._has_cool_threshold: + self.char_cool.set_value(target_temp) + if self._has_heat_threshold: + self.char_heat.set_value(target_temp) + + def _derive_action(self, state: State, mode: HVACMode | None) -> HVACAction: + """Infer heating / cooling when integration omits hvac_action.""" + attributes = state.attributes + cur = attributes.get(ATTR_CURRENT_TEMPERATURE) + if cur is None or mode is None: + return HVACAction.IDLE + + # Resolve the cool-above and heat-below setpoints for the active mode. + # Range modes have independent thresholds; single-target modes only + # drive one side. Any other mode (e.g. dry, fan_only) stays idle. + if mode in RANGE_MODES: + cool_above = attributes.get(ATTR_TARGET_TEMP_HIGH) + heat_below = attributes.get(ATTR_TARGET_TEMP_LOW) + if cool_above is None and heat_below is None: + # Some integrations run auto from a single setpoint. + cool_above = heat_below = attributes.get(ATTR_TEMPERATURE) + elif mode == HVACMode.COOL: + cool_above = attributes.get(ATTR_TEMPERATURE) + heat_below = None + elif mode == HVACMode.HEAT: + cool_above = None + heat_below = attributes.get(ATTR_TEMPERATURE) + else: + return HVACAction.IDLE + + # Compare in Celsius so the hysteresis band is unit independent. + cur_c = self._temperature_to_homekit(cur) + if ( + cool_above is not None + and cur_c > self._temperature_to_homekit(cool_above) + ACTION_HYSTERESIS + ): + return HVACAction.COOLING + if ( + heat_below is not None + and cur_c < self._temperature_to_homekit(heat_below) - ACTION_HYSTERESIS + ): + return HVACAction.HEATING + return HVACAction.IDLE diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 1b570c8e4279..72352dde87b0 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -189,7 +189,6 @@ class Thermostat(HomeKitClimateAccessory): self.chars.append(CHAR_TARGET_HUMIDITY) serv_thermostat = self.add_preload_service(SERV_THERMOSTAT, self.chars) - self.set_primary_service(serv_thermostat) # Current mode characteristics self.char_current_heat_cool = serv_thermostat.configure_char( @@ -276,6 +275,10 @@ class Thermostat(HomeKitClimateAccessory): self.fan_chars.append(CHAR_CURRENT_FAN_STATE) self._configure_fan_service(serv_thermostat) + # Every service exists now, so they all get an explicit primary + # flag; without one the Home app can pick its own tile service. + self.set_primary_service(serv_thermostat) + self.async_update_state(state) serv_thermostat.setter_callback = self._set_chars diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 97269181d58a..bc1b65a365c9 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -106,10 +106,12 @@ from .const import ( TYPE_AIR_PURIFIER, TYPE_FAN, TYPE_FAUCET, + TYPE_HEATER_COOLER, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, + TYPE_THERMOSTAT, TYPE_VALVE, VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, @@ -225,6 +227,21 @@ COVER_SCHEMA = BASIC_INFO_SCHEMA.extend( } ) +# No default so an unset type keeps the automatic Thermostat/HeaterCooler routing. +CLIMATE_SCHEMA = BASIC_INFO_SCHEMA.extend( + { + vol.Optional(CONF_TYPE): vol.All( + cv.string, + vol.In( + ( + TYPE_HEATER_COOLER, + TYPE_THERMOSTAT, + ) + ), + ), + } +) + CODE_SCHEMA = BASIC_INFO_SCHEMA.extend( {vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string)} ) @@ -360,6 +377,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]: elif domain == "humidifier": config = HUMIDIFIER_SCHEMA(config) + elif domain == "climate": + config = CLIMATE_SCHEMA(config) + elif domain == "cover": config = COVER_SCHEMA(config) diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 8a3999f9514b..583853177644 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -740,7 +740,7 @@ async def test_call_service( ], ) async def test_call_service_resyncs_on_failure( - hass: HomeAssistant, hk_driver, raise_exception: Exception + hass: HomeAssistant, hk_driver: HomeDriver, raise_exception: Exception ) -> None: """When the dispatched service raises, re-push the entity's current state. @@ -768,6 +768,51 @@ async def test_call_service_resyncs_on_failure( assert pushed_state.state == STATE_OFF +async def test_call_service_resync_failure_is_logged( + hass: HomeAssistant, hk_driver: HomeDriver, caplog: pytest.LogCaptureFixture +) -> None: + """Test a failing resync is logged instead of hitting the task handler.""" + entity_id = "homekit.accessory" + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + + acc = HomeAccessory(hass, hk_driver, "Home Accessory", entity_id, 2, {}) + async_mock_service( + hass, "cover", "open_cover", raise_exception=HomeAssistantError("nope") + ) + + with patch.object( + acc, "async_update_state", side_effect=RuntimeError("resync boom") + ): + acc.async_call_service( + "cover", "open_cover", {ATTR_ENTITY_ID: entity_id}, "value" + ) + await hass.async_block_till_done() + + assert "re-syncing HomeKit state failed" in caplog.text + + +async def test_call_service_resync_skip_is_logged( + hass: HomeAssistant, hk_driver: HomeDriver, caplog: pytest.LogCaptureFixture +) -> None: + """Test a resync skipped for a missing entity state is diagnosable.""" + entity_id = "homekit.accessory" + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + + acc = HomeAccessory(hass, hk_driver, "Home Accessory", entity_id, 2, {}) + async_mock_service( + hass, "cover", "open_cover", raise_exception=HomeAssistantError("nope") + ) + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + acc.async_call_service("cover", "open_cover", {ATTR_ENTITY_ID: entity_id}, "value") + await hass.async_block_till_done() + + assert "cannot re-sync HomeKit state" in caplog.text + + def test_home_bridge(hk_driver) -> None: """Test HomeBridge class.""" bridge = HomeBridge("hass", hk_driver, BRIDGE_NAME) diff --git a/tests/components/homekit/test_accessory_type.py b/tests/components/homekit/test_accessory_type.py new file mode 100644 index 000000000000..bb019a5137ef --- /dev/null +++ b/tests/components/homekit/test_accessory_type.py @@ -0,0 +1,399 @@ +"""Test the HomeKit accessory type routing.""" + +import asyncio +from collections.abc import Generator +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate import ( + ATTR_FAN_MODES, + ATTR_HVAC_MODES, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.homekit import HomeKit +from homeassistant.components.homekit.const import ( + DEFAULT_PORT, + DOMAIN, + HOMEKIT_MODE_ACCESSORY, + HOMEKIT_MODE_BRIDGE, + PERSIST_LOCK_DATA, +) +from homeassistant.components.homekit.util import get_aid_storage_filename_for_entry_id +from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entityfilter import ( + CONF_EXCLUDE_DOMAINS, + CONF_EXCLUDE_ENTITIES, + CONF_EXCLUDE_ENTITY_GLOBS, + CONF_INCLUDE_DOMAINS, + CONF_INCLUDE_ENTITIES, + CONF_INCLUDE_ENTITY_GLOBS, + convert_filter, +) + +from .util import PATH_HOMEKIT + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def patch_source_ip() -> Generator[None]: + """Patch homeassistant and pyhap functions for getting local address.""" + with patch("pyhap.util.get_local_address", return_value="10.10.10.10"): + yield + + +ENTITY_ID = "climate.demo" +CAPABLE_ATTRS = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], +} + + +HUMIDITY_ATTRS = { + **CAPABLE_ATTRS, + ATTR_SUPPORTED_FEATURES: ( + CAPABLE_ATTRS[ATTR_SUPPORTED_FEATURES] | ClimateEntityFeature.TARGET_HUMIDITY + ), +} + + +async def _async_stop_bridge(homekit: HomeKit) -> None: + """Stop the bridge and flush the delayed aid storage save. + + The flush lets a following start read the stored routing choices. + """ + with patch("pyhap.accessory_driver.AccessoryDriver.async_stop"): + await homekit.async_stop() + assert homekit.aid_storage is not None + await homekit.aid_storage.async_save() + + +async def _async_start_bridge( + hass: HomeAssistant, + entry: MockConfigEntry, + entity_config: dict[str, Any] | None = None, + homekit_mode: str = HOMEKIT_MODE_BRIDGE, + existing_pairing: bool = False, +) -> HomeKit: + """Start a HomeKit instance exposing the demo climate entity.""" + hass.data.setdefault(PERSIST_LOCK_DATA, asyncio.Lock()) + entity_filter = convert_filter( + { + CONF_INCLUDE_DOMAINS: [], + CONF_INCLUDE_ENTITIES: [ENTITY_ID], + CONF_EXCLUDE_DOMAINS: [], + CONF_EXCLUDE_ENTITIES: [], + CONF_INCLUDE_ENTITY_GLOBS: [], + CONF_EXCLUDE_ENTITY_GLOBS: [], + } + ) + homekit = HomeKit( + hass=hass, + name="mock_name", + port=DEFAULT_PORT, + ip_address=None, + entity_filter=entity_filter, + exclude_accessory_mode=False, + entity_config=entity_config or {}, + homekit_mode=homekit_mode, + advertise_ips=None, + entry_id=entry.entry_id, + entry_title=entry.title, + ) + original_setup = HomeKit.setup + + def _setup(homekit: HomeKit, async_zeroconf_instance: Any, uuid: str) -> bool: + """Run the real driver setup, faking persisted pairing state if asked.""" + loaded = original_setup(homekit, async_zeroconf_instance, uuid) + return loaded or existing_pairing + + with ( + patch(f"{PATH_HOMEKIT}.async_show_setup_message"), + patch("pyhap.accessory_driver.AccessoryDriver.async_start"), + patch(f"{PATH_HOMEKIT}.HomeKit.setup", _setup), + ): + await homekit.async_start() + await hass.async_block_till_done() + return homekit + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_existing_entity_stays_thermostat( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test a previously bridged entity keeps the Thermostat.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass_storage[get_aid_storage_filename_for_entry_id(entry.entry_id)] = { + "version": 1, + "data": {"allocations": {ENTITY_ID: 1234567}}, + } + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + + accessories = list(homekit.bridge.accessories.values()) + assert len(accessories) == 1 + assert type(accessories[0]).__name__ == "Thermostat" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_new_entity_routes_to_heater_cooler( + hass: HomeAssistant, +) -> None: + """Test a never bridged entity gets the HeaterCooler.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + + accessories = list(homekit.bridge.accessories.values()) + assert len(accessories) == 1 + assert type(accessories[0]).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_reset_does_not_allocate_for_unbridged_entities( + hass: HomeAssistant, +) -> None: + """Test resetting an entity another bridge owns does not allocate.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + + # The reset service fans out to every instance, so an allocation here + # would wrongly mark the entity as previously bridged + await homekit.async_reset_accessories(["climate.not_bridged_here"]) + assert homekit.aid_storage is not None + assert ( + homekit.aid_storage.get_allocated_aid_for_entity_id("climate.not_bridged_here") + is None + ) + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_failed_accessory_creation_is_not_recorded( + hass: HomeAssistant, +) -> None: + """Test a failed accessory creation does not persist the auto routing.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + with patch(f"{PATH_HOMEKIT}.get_accessory", side_effect=ValueError): + homekit = await _async_start_bridge(hass, entry) + + assert homekit.aid_storage is not None + assert homekit.aid_storage.get_accessory_type(ENTITY_ID) is None + # The aid allocation is rolled back so the entity is still new + assert not homekit.aid_storage.allocations + await _async_stop_bridge(homekit) + + # The next start treats the entity as never bridged and retries + # the automatic choice + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_heater_cooler_choice_survives_restart( + hass: HomeAssistant, +) -> None: + """Test the automatic HeaterCooler choice persists across restarts.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + # The entity now has an aid allocation, so only the stored choice + # keeps it on the HeaterCooler after a restart. + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_gained_humidity_setpoint_drops_stored_choice( + hass: HomeAssistant, +) -> None: + """Test a stored HeaterCooler choice is dropped for a humidity setpoint.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + # The entity gains a humidity setpoint, which the HeaterCooler cannot + # control, so the stored routing is dropped + hass.states.async_set( + ENTITY_ID, + HVACMode.COOL, + HUMIDITY_ATTRS, + ) + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "Thermostat" + assert homekit.aid_storage is not None + assert homekit.aid_storage.get_accessory_type(ENTITY_ID) is None + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_automatic_keeps_explicit_choice( + hass: HomeAssistant, +) -> None: + """Test an explicit type updates the stored routing for automatic.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + # A new entity picks the HeaterCooler and the choice is stored + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + # An explicit Thermostat overrides and updates the stored routing + homekit = await _async_start_bridge( + hass, entry, {ENTITY_ID: {CONF_TYPE: "thermostat"}} + ) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "Thermostat" + await _async_stop_bridge(homekit) + + # Back on automatic the entity keeps the Thermostat instead of + # flipping back to the HeaterCooler + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "Thermostat" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_accessory_mode_existing_pairing_stays_thermostat( + hass: HomeAssistant, +) -> None: + """Test an existing accessory mode pairing keeps the Thermostat.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge( + hass, entry, homekit_mode=HOMEKIT_MODE_ACCESSORY, existing_pairing=True + ) + + assert type(homekit.driver.accessory).__name__ == "Thermostat" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_accessory_mode_new_pairing_routes_heater_cooler( + hass: HomeAssistant, +) -> None: + """Test a brand new accessory mode pairing picks the HeaterCooler.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge( + hass, entry, homekit_mode=HOMEKIT_MODE_ACCESSORY + ) + + assert type(homekit.driver.accessory).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_explicit_heater_cooler_wins_over_humidity_safeguard( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test an explicit heater_cooler type wins for a humidity entity.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass_storage[get_aid_storage_filename_for_entry_id(entry.entry_id)] = { + "version": 1, + "data": {"allocations": {ENTITY_ID: 1234567}}, + } + hass.states.async_set( + ENTITY_ID, + HVACMode.COOL, + HUMIDITY_ATTRS, + ) + + homekit = await _async_start_bridge( + hass, entry, {ENTITY_ID: {CONF_TYPE: "heater_cooler"}} + ) + + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_explicit_type_wins_for_existing_entity( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test an entity with a configured type uses it.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass_storage[get_aid_storage_filename_for_entry_id(entry.entry_id)] = { + "version": 1, + "data": {"allocations": {ENTITY_ID: 1234567}}, + } + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge( + hass, entry, {ENTITY_ID: {CONF_TYPE: "thermostat"}} + ) + + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "Thermostat" + await _async_stop_bridge(homekit) diff --git a/tests/components/homekit/test_aidmanager.py b/tests/components/homekit/test_aidmanager.py index 6dbac422f079..1a12cfb1636d 100644 --- a/tests/components/homekit/test_aidmanager.py +++ b/tests/components/homekit/test_aidmanager.py @@ -647,3 +647,158 @@ async def test_handle_unique_id_change( # Verify that the old unique id is removed from the allocations # and that the new unique id assumes the old aid assert aid_storage.allocations == {"demo.light.new_unique": 4202023227} + + +async def test_entity_is_allocated( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test detecting whether an entity already has an allocated aid.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + light_ent = entity_registry.async_get_or_create( + "light", "device", "unique_id", device_id=device_entry.id + ) + hass.states.async_set(light_ent.entity_id, "on") + hass.states.async_set("remote.has_no_unique_id", "on") + + with patch( + "homeassistant.components.homekit.aidmanager.AccessoryAidStorage.async_schedule_save" + ): + aid_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await aid_storage.async_initialize() + + # Nothing allocated yet + assert not aid_storage.entity_is_allocated(light_ent.entity_id) + assert not aid_storage.entity_is_allocated("remote.has_no_unique_id") + + # Allocation is keyed by the system unique id for registered entities + aid_storage.get_or_allocate_aid_for_entity_id(light_ent.entity_id) + assert aid_storage.entity_is_allocated(light_ent.entity_id) + + # Unregistered entities are keyed by entity id + aid_storage.get_or_allocate_aid_for_entity_id("remote.has_no_unique_id") + assert aid_storage.entity_is_allocated("remote.has_no_unique_id") + + # A changed unique id is still recognized through previous_unique_id + entity_registry.async_update_entity( + light_ent.entity_id, new_unique_id="new_unique_id" + ) + assert aid_storage.entity_is_allocated(light_ent.entity_id) + + +async def test_accessory_type_round_trip(hass: HomeAssistant) -> None: + """Test the stored accessory type persists through storage.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + aid_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await aid_storage.async_initialize() + assert aid_storage.get_accessory_type("climate.demo") is None + + # Setting the current value again is a no-op + aid_storage.async_set_accessory_type("climate.demo", None) + assert aid_storage.get_accessory_type("climate.demo") is None + + aid_storage.async_set_accessory_type("climate.demo", "heater_cooler") + aid_storage.async_set_accessory_type("climate.demo", "heater_cooler") + await aid_storage.async_save() + + fresh_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await fresh_storage.async_initialize() + assert fresh_storage.get_accessory_type("climate.demo") == "heater_cooler" + + fresh_storage.async_set_accessory_type("climate.demo", None) + await fresh_storage.async_save() + + final_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await final_storage.async_initialize() + assert final_storage.get_accessory_type("climate.demo") is None + + +async def test_accessory_type_survives_repeated_unique_id_changes( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test reads heal the stored key so a second migration cannot orphan it.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + climate_ent = entity_registry.async_get_or_create( + "climate", "device", "u1", device_id=device_entry.id + ) + + aid_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await aid_storage.async_initialize() + aid_storage.async_set_accessory_type(climate_ent.entity_id, "heater_cooler") + + # Only the latest previous unique id stays resolvable, so the read + # moves the entry forward after each migration + entity_registry.async_update_entity(climate_ent.entity_id, new_unique_id="u2") + await hass.async_block_till_done() + assert aid_storage.get_accessory_type(climate_ent.entity_id) == "heater_cooler" + assert aid_storage.accessory_types == {"device.climate.u2": "heater_cooler"} + + entity_registry.async_update_entity(climate_ent.entity_id, new_unique_id="u3") + await hass.async_block_till_done() + assert aid_storage.get_accessory_type(climate_ent.entity_id) == "heater_cooler" + assert aid_storage.accessory_types == {"device.climate.u3": "heater_cooler"} + + +async def test_accessory_type_survives_entity_renames( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the stored accessory type follows the entity through renames.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + climate_ent = entity_registry.async_get_or_create( + "climate", "device", "unique_id", device_id=device_entry.id + ) + + aid_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await aid_storage.async_initialize() + + aid_storage.async_set_accessory_type(climate_ent.entity_id, "heater_cooler") + assert aid_storage.get_accessory_type(climate_ent.entity_id) == "heater_cooler" + # Registered entities are stored by the system unique id + assert aid_storage.accessory_types == {"device.climate.unique_id": "heater_cooler"} + + # An entity id rename keeps the choice through the stable identity + entity_registry.async_update_entity( + climate_ent.entity_id, new_entity_id="climate.renamed" + ) + await hass.async_block_till_done() + assert aid_storage.get_accessory_type("climate.renamed") == "heater_cooler" + + # A unique id change is still recognized through previous_unique_id + entity_registry.async_update_entity( + "climate.renamed", new_unique_id="new_unique_id" + ) + await hass.async_block_till_done() + assert aid_storage.get_accessory_type("climate.renamed") == "heater_cooler" + + # Allocating migrates the stored choice to the new unique id + aid_storage.get_or_allocate_aid_for_entity_id("climate.renamed") + assert aid_storage.accessory_types == { + "device.climate.new_unique_id": "heater_cooler" + } + assert aid_storage.get_accessory_type("climate.renamed") == "heater_cooler" + + # Clearing the choice removes the stored identity + aid_storage.async_set_accessory_type("climate.renamed", None) + assert aid_storage.get_accessory_type("climate.renamed") is None + assert not aid_storage.accessory_types diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index a1d4b0dd0517..613f6876f63b 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -337,6 +337,12 @@ async def test_options_flow_exclude_mode(hass: HomeAssistant) -> None: user_input={"entities": ["climate.old"]}, ) assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "bridged_device_triggers" with patch("homeassistant.components.homekit.async_setup_entry", return_value=True): @@ -391,7 +397,7 @@ async def test_options_flow_devices( demo_config_entry.add_to_hass(hass) with patch("homeassistant.components.homekit.HomeKit") as mock_homekit: - mock_homekit.return_value = homekit = Mock() + mock_homekit.return_value = homekit = Mock(bridge=None, driver=None) type(homekit).async_start = AsyncMock() assert await async_setup_component(hass, DOMAIN, {"homekit": {}}) assert await async_setup_component(hass, "homeassistant", {}) @@ -428,6 +434,12 @@ async def test_options_flow_devices( }, ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "bridged_device_triggers" # The stale "notexist" device must be stripped from the form @@ -467,6 +479,12 @@ async def test_options_flow_devices( result["flow_id"], user_input={"entities": ["climate.old"]}, ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) assert result2["step_id"] == "bridged_device_triggers" assert result2["data_schema"]({})["devices"] == [device_id] @@ -519,6 +537,12 @@ async def test_options_flow_include_mode_with_non_existant_entity( }, ) assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "bridged_device_triggers" result3 = await hass.config_entries.options.async_configure( @@ -639,6 +663,12 @@ async def test_options_flow_include_mode_basic(hass: HomeAssistant) -> None: user_input={"entities": ["climate.new"]}, ) assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "bridged_device_triggers" result3 = await hass.config_entries.options.async_configure( @@ -818,6 +848,13 @@ async def test_options_flow_include_mode_with_cameras(hass: HomeAssistant) -> No user_input={"camera_copy": ["camera.native_h264"]}, ) assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "climate" + + result3 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={}, + ) + assert result3["type"] is FlowResultType.FORM assert result3["step_id"] == "bridged_device_triggers" result4 = await hass.config_entries.options.async_configure( @@ -962,6 +999,13 @@ async def test_options_flow_with_camera_audio(hass: HomeAssistant) -> None: user_input={"camera_audio": ["camera.audio"]}, ) assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "climate" + + result3 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={}, + ) + assert result3["type"] is FlowResultType.FORM assert result3["step_id"] == "bridged_device_triggers" result4 = await hass.config_entries.options.async_configure( @@ -1611,3 +1655,200 @@ async def test_options_flow_include_mode_allows_hidden_entities( } await hass.async_block_till_done() await hass.config_entries.async_unload(config_entry.entry_id) + + +async def test_options_flow_climate_accessory_type_round_trip( + hass: HomeAssistant, +) -> None: + """Test setting and clearing the climate accessory type.""" + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("climate.new", "off") + await hass.async_block_till_done() + + async def _configure(choice: str) -> None: + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "domains": ["climate"], + "include_exclude_mode": "include", + }, + ) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"entities": ["climate.new"]}, + ) + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"new (climate.new)": choice}, + ) + assert result2["step_id"] == "bridged_device_triggers" + with patch( + "homeassistant.components.homekit.async_setup_entry", return_value=True + ): + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + + await _configure("heater_cooler") + assert config_entry.options["entity_config"]["climate.new"]["type"] == ( + "heater_cooler" + ) + + await _configure("thermostat") + assert config_entry.options["entity_config"]["climate.new"]["type"] == "thermostat" + + await _configure("automatic") + assert "entity_config" not in config_entry.options + + +async def test_options_flow_cameras_step_with_whole_domain_included( + hass: HomeAssistant, +) -> None: + """Test the cameras step is offered for a whole camera domain include.""" + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("camera.native_h264", "off") + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "domains": ["fan", "camera"], + "include_exclude_mode": "include", + }, + ) + assert result["step_id"] == "include" + + # No camera is selected explicitly, so the whole domain is included + # and the camera options are still offered + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"entities": []}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "cameras" + await hass.config_entries.async_unload(config_entry.entry_id) + + +@pytest.mark.parametrize("homekit_mode", ["bridge", "accessory"]) +async def test_options_flow_climate_step_shows_current_accessory( + hass: HomeAssistant, homekit_mode: str +) -> None: + """Test the climate labels show the accessory the entity uses now.""" + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("climate.new", "off") + await hass.async_block_till_done() + + # A loaded entry exposes the bridged accessories through its runtime + # data; accessory mode reads the single accessory from the driver + thermostat = type("Thermostat", (), {"entity_id": "climate.new"})() + if homekit_mode == "bridge": + homekit = Mock(bridge=Mock(accessories={2: thermostat})) + else: + homekit = Mock(bridge=None, driver=Mock(accessory=thermostat)) + config_entry.runtime_data = Mock(homekit=homekit) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "domains": ["climate"], + "include_exclude_mode": "include", + }, + ) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"entities": ["climate.new"]}, + ) + assert result2["step_id"] == "climate" + assert [str(key) for key in result2["data_schema"].schema] == [ + "new (climate.new) [Thermostat]" + ] + + # The annotated label still round trips to the entity id + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"new (climate.new) [Thermostat]": "heater_cooler"}, + ) + assert result2["step_id"] == "bridged_device_triggers" + with patch("homeassistant.components.homekit.async_setup_entry", return_value=True): + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options["entity_config"]["climate.new"]["type"] == ( + "heater_cooler" + ) + + +async def test_options_flow_climate_step_with_whole_domain_included( + hass: HomeAssistant, +) -> None: + """Test the climate step lists all climate entities for a domain include.""" + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("climate.new", "off") + hass.states.async_set("climate.old", "off") + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "domains": ["fan", "climate"], + "include_exclude_mode": "include", + }, + ) + assert result["step_id"] == "include" + + # No climate entity is selected explicitly, so the whole domain is + # included and every climate entity is offered in the climate step. + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"entities": []}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + assert [str(key) for key in result2["data_schema"].schema] == [ + "new (climate.new)", + "old (climate.old)", + ] + + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={ + "new (climate.new)": "heater_cooler", + "old (climate.old)": "automatic", + }, + ) + assert result2["step_id"] == "bridged_device_triggers" + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == { + "devices": [], + "mode": "bridge", + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": ["climate", "fan"], + "include_entities": [], + }, + "entity_config": {"climate.new": {"type": "heater_cooler"}}, + } + await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 91b192d283f2..a6247c9a293c 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -4,20 +4,26 @@ from unittest.mock import Mock, patch import pytest -from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.components.climate import ATTR_CURRENT_HUMIDITY, ClimateEntityFeature from homeassistant.components.cover import CoverEntityFeature from homeassistant.components.homekit import TYPE_AIR_PURIFIER -from homeassistant.components.homekit.accessories import TYPES, get_accessory +from homeassistant.components.homekit.accessories import ( + TYPES, + climate_supports_heater_cooler, + get_accessory, +) from homeassistant.components.homekit.const import ( ATTR_INTEGRATION, CONF_FEATURE_LIST, FEATURE_ON_OFF, TYPE_FAN, TYPE_FAUCET, + TYPE_HEATER_COOLER, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, + TYPE_THERMOSTAT, TYPE_VALVE, ) from homeassistant.components.homekit.type_sensors import ( @@ -455,6 +461,239 @@ def test_type_camera(type_name, entity_id, state, attrs) -> None: assert mock_type.called +@pytest.mark.parametrize( + ("type_name", "entity_id", "state", "attrs"), + [ + # Basic climate without fan/swing support -> Thermostat + ("Thermostat", "climate.basic", "heat", {}), + # Climate with only FAN_MODE feature but no fan_modes -> Thermostat + ( + "Thermostat", + "climate.fan_feature_only", + "heat", + {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE}, + ), + # Climate with only SWING_MODE feature but no swing_modes -> Thermostat + ( + "Thermostat", + "climate.swing_feature_only", + "heat", + {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE}, + ), + # Climate with FAN_MODE feature and fan_modes list -> HeaterCooler + ( + "HeaterCooler", + "climate.with_fan", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["low", "medium", "high"], + }, + ), + # Timing fan modes (auto/on/off/circulate) are not predefined speeds, so + # an entity exposing only those has zero speeds -> Thermostat + ( + "Thermostat", + "climate.timing_fan_modes_only", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["off", "auto", "on", "circulate"], + }, + ), + # Those timing modes plus a single predefined speed still count as one + # speed, which cannot drive the slider -> Thermostat + ( + "Thermostat", + "climate.single_fan_speed", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["off", "auto", "on", "circulate", "high"], + }, + ), + # Timing modes are ignored, but two real speeds among them qualify + ( + "HeaterCooler", + "climate.two_speeds_with_timing_modes", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["off", "auto", "on", "circulate", "low", "high"], + }, + ), + # A single predefined fan speed plus a swing mode -> HeaterCooler + ( + "HeaterCooler", + "climate.single_fan_speed_with_swing", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE, + "fan_modes": ["auto", "high"], + "swing_modes": ["on", "off"], + }, + ), + # Climate with SWING_MODE feature and swing_modes list -> HeaterCooler + ( + "HeaterCooler", + "climate.with_swing", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + "swing_modes": ["on", "off"], + }, + ), + # Climate with both FAN_MODE and SWING_MODE features and modes -> HeaterCooler + ( + "HeaterCooler", + "climate.with_fan_and_swing", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE, + "fan_modes": ["low", "high"], + "swing_modes": ["on", "off"], + }, + ), + # Climate with FAN_MODE feature and empty fan_modes list -> Thermostat + ( + "Thermostat", + "climate.empty_fan_modes", + "heat", + {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, "fan_modes": []}, + ), + # Climate with SWING_MODE feature and empty swing_modes list -> Thermostat + ( + "Thermostat", + "climate.empty_swing_modes", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + "swing_modes": [], + }, + ), + # Climate with only custom (non-predefined) fan modes -> Thermostat + ( + "Thermostat", + "climate.custom_fan_modes", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["quiet", "turbo"], + }, + ), + # Climate with only custom (non-predefined) swing modes -> Thermostat + ( + "Thermostat", + "climate.custom_swing_modes", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + "swing_modes": ["off", "custom"], + }, + ), + # Swing without an advertised off mode -> Thermostat (off writes + # would be rejected by the entity) + ( + "Thermostat", + "climate.swing_without_off", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + "swing_modes": ["vertical"], + }, + ), + # Climate with other features but no fan/swing -> Thermostat + ( + "Thermostat", + "climate.other_features", + "heat", + { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + }, + ), + # Fan speeds with a humidity setpoint -> Thermostat (controls humidity) + ( + "Thermostat", + "climate.fan_and_target_humidity", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TARGET_HUMIDITY, + "fan_modes": ["low", "high"], + }, + ), + # Fan speeds with display-only humidity -> HeaterCooler (kept via sensor) + ( + "HeaterCooler", + "climate.fan_and_current_humidity", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["low", "high"], + ATTR_CURRENT_HUMIDITY: 45, + }, + ), + ], +) +def test_climate_supports_heater_cooler( + type_name: str, entity_id: str, state: str, attrs: dict[str, object] +) -> None: + """Test the capability predicate behind automatic HeaterCooler routing.""" + entity_state = State(entity_id, state, attrs) + assert climate_supports_heater_cooler(entity_state) is (type_name == "HeaterCooler") + + +def test_climate_without_configured_type_is_thermostat() -> None: + """Test a climate entity without a configured type gets the Thermostat.""" + attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["low", "high"], + } + mock_type = Mock() + with patch.dict(TYPES, {"Thermostat": mock_type}): + entity_state = State("climate.test", "heat", attrs) + get_accessory(None, None, entity_state, 2, {}) + assert mock_type.called + + +@pytest.mark.parametrize( + ("config_type", "attrs", "type_name"), + [ + # Would auto-route to Thermostat, but the config forces HeaterCooler + ( + TYPE_HEATER_COOLER, + {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE}, + "HeaterCooler", + ), + # Would auto-route to HeaterCooler, but the config forces Thermostat + ( + TYPE_THERMOSTAT, + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE, + "fan_modes": ["low", "high"], + "swing_modes": ["on", "off"], + }, + "Thermostat", + ), + ], +) +def test_climate_accessory_type_override( + config_type: str, attrs: dict[str, object], type_name: str +) -> None: + """Test a configured type overrides the capability based routing.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State("climate.test", "heat", attrs) + get_accessory(None, None, entity_state, 2, {CONF_TYPE: config_type}) + assert mock_type.called + + @pytest.mark.parametrize( ("expected_type", "entity_id", "attrs"), [ diff --git a/tests/components/homekit/test_type_heater_coolers.py b/tests/components/homekit/test_type_heater_coolers.py new file mode 100644 index 000000000000..a36feaf42b5b --- /dev/null +++ b/tests/components/homekit/test_type_heater_coolers.py @@ -0,0 +1,3113 @@ +"""Test different accessory types: HeaterCooler.""" + +import asyncio +from typing import Any +from unittest.mock import patch + +from pyhap.const import ( + CATEGORY_AIR_CONDITIONER, + CATEGORY_HEATER, + HAP_REPR_AID, + HAP_REPR_CHARS, + HAP_REPR_IID, + HAP_REPR_VALUE, +) +import pytest + +from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_SWING_MODE, + ATTR_SWING_MODES, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.homekit.accessories import HomeDriver +from homeassistant.components.homekit.climate_base import ( + FAN_STATE_ACTIVE, + FAN_STATE_IDLE, +) +from homeassistant.components.homekit.const import ( + CHAR_ACTIVE, + CHAR_COOLING_THRESHOLD_TEMPERATURE, + CHAR_CURRENT_FAN_STATE, + CHAR_HEATING_THRESHOLD_TEMPERATURE, + CHAR_NAME, + CHAR_ROTATION_SPEED, + CHAR_SWING_MODE, + CHAR_TARGET_FAN_STATE, + CHAR_TARGET_HEATER_COOLER_STATE, + PROP_MAX_VALUE, + PROP_MIN_STEP, + PROP_MIN_VALUE, + SERV_HEATER_COOLER, + SERV_HUMIDITY_SENSOR, +) +from homeassistant.components.homekit.type_heater_coolers import ( + HC_COOLING, + HC_HEATING, + HC_IDLE, + HC_INACTIVE, + HC_TARGET_AUTO, + HC_TARGET_COOL, + HC_TARGET_HEAT, + HeaterCooler, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from tests.common import async_mock_service + + +def _write_chars( + hk_driver: HomeDriver, acc: HeaterCooler, char_values: dict[str, float] +) -> None: + """Write characteristic values through the HAP client path.""" + serv = acc.get_service(SERV_HEATER_COOLER) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: serv.get_characteristic(name).to_HAP()[HAP_REPR_IID], + HAP_REPR_VALUE: value, + } + for name, value in char_values.items() + ] + }, + "mock_addr", + ) + + +async def test_heatercooler_basic(hass: HomeAssistant, hk_driver: HomeDriver) -> None: + """Test basic HeaterCooler functionality.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + ATTR_MIN_TEMP: 10.0, + ATTR_MAX_TEMP: 30.0, + ATTR_TEMPERATURE: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.aid == 1 + assert acc.category == CATEGORY_AIR_CONDITIONER + + # Check initial state (OFF) + assert acc.char_active.value == 0 + assert acc.char_current_state.value == HC_INACTIVE # OFF reports Inactive + assert acc.char_target_state.value == HC_TARGET_AUTO + assert acc.char_current_temp.value == 18.0 + assert acc.char_cool.value == 20.0 + assert acc.char_heat.value == 20.0 + + # Check temperature properties + assert acc.char_cool.properties[PROP_MIN_VALUE] == 10.0 + assert acc.char_cool.properties[PROP_MAX_VALUE] == 30.0 + assert acc.char_heat.properties[PROP_MIN_VALUE] == 10.0 + assert acc.char_heat.properties[PROP_MAX_VALUE] == 30.0 + + # The mode and range attributes must trigger an accessory reload so the + # characteristic set stays in sync with the device. + assert set(acc._reload_on_change_attrs) >= { + ATTR_MIN_TEMP, + ATTR_MAX_TEMP, + ATTR_FAN_MODES, + ATTR_SWING_MODES, + ATTR_HVAC_MODES, + } + + +async def test_heatercooler_with_fan_and_swing( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HeaterCooler with fan and swing mode support.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + ATTR_FAN_MODES: [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH], + ATTR_SWING_MODES: ["off", "vertical", "horizontal", "both"], + ATTR_FAN_MODE: FAN_LOW, + ATTR_SWING_MODE: "off", + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Check that fan and swing characteristics are present + assert hasattr(acc, "char_speed") + assert hasattr(acc, "char_swing") + assert acc.char_speed.value == 25 # FAN_LOW maps to 25% (index 0 of 4 speeds) + assert acc.char_swing.value == 0 # off + + +@pytest.mark.parametrize( + ("hvac_modes", "expected_auto_mode"), + [ + pytest.param( + [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + HVACMode.AUTO, + id="auto_only", + ), + pytest.param( + [HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL, HVACMode.OFF], + HVACMode.HEAT_COOL, + id="heat_cool_only", + ), + # HEAT_COOL keeps its thresholds adjustable, AUTO may follow a + # schedule, so HEAT_COOL backs the HomeKit Auto target + pytest.param( + [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.AUTO, + HVACMode.OFF, + ], + HVACMode.HEAT_COOL, + id="heat_cool_preferred_over_auto", + ), + ], +) +async def test_heatercooler_auto_target_backing_mode( + hass: HomeAssistant, + hk_driver: HomeDriver, + hvac_modes: list[HVACMode], + expected_auto_mode: HVACMode, +) -> None: + """Test which range mode backs the HomeKit Auto target.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: hvac_modes, + } + + hass.states.async_set(entity_id, hvac_modes[0], base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc._hk_to_ha_target[HC_TARGET_AUTO] == expected_auto_mode + + +async def test_heatercooler_off_with_bundled_target_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a target bundled with off is remembered for the next power on.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Turning off with a new target only sends the off write + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars( + hk_driver, + acc, + {CHAR_ACTIVE: 0, CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}, + ) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 1 + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.OFF + + # Power on activates the mode the tile displays + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_off_with_unsupported_bundled_target( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an unsupported target bundled with off is put back on the tile.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars( + hk_driver, + acc, + {CHAR_ACTIVE: 0, CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}, + ) + await hass.async_block_till_done() + + assert acc.char_target_state.value == HC_TARGET_COOL + + +async def test_heatercooler_rejected_mode_is_not_remembered( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a rejected mode write does not become the restore mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + # The entity rejects the mode write while off + async_mock_service( + hass, + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + raise_exception=HomeAssistantError("mode rejected"), + ) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + await hass.async_block_till_done() + + assert acc._last_known_mode == HVACMode.HEAT + + # Turning Active on retries the last accepted mode, not the rejected one + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_modes_heat_cool_only_no_auto( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test that Auto is not offered for entities without a range mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Auto has no backing mode, so it must not be mapped or offered to HomeKit + assert HC_TARGET_AUTO not in acc._hk_to_ha_target + assert ( + HC_TARGET_AUTO not in acc.char_target_state.properties["ValidValues"].values() + ) + assert acc.char_target_state.value == HC_TARGET_HEAT + + +async def test_heatercooler_cooling_only_no_heat_target( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a cooling-only entity does not expose the Heat target.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Heat has no backing mode, so HomeKit must not offer it + assert HC_TARGET_HEAT not in acc._hk_to_ha_target + valid_values = acc.char_target_state.properties["ValidValues"].values() + assert HC_TARGET_HEAT not in valid_values + assert HC_TARGET_AUTO not in valid_values + assert acc.char_target_state.value == HC_TARGET_COOL + + +async def test_heatercooler_temperature_step( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HeaterCooler relies on the HomeKit default temperature step.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # No explicit min step so HomeKit keeps its 0.1 default for unit precision + assert acc.char_cool.properties[PROP_MIN_STEP] == 0.1 + assert acc.char_heat.properties[PROP_MIN_STEP] == 0.1 + + +async def test_heatercooler_fahrenheit( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HeaterCooler with Fahrenheit units.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_MIN_TEMP: 45.0, # Fahrenheit + ATTR_MAX_TEMP: 95.0, # Fahrenheit + ATTR_TEMPERATURE: 68.0, # Fahrenheit + ATTR_CURRENT_TEMPERATURE: 65.0, + } + + hass.config.units = US_CUSTOMARY_SYSTEM + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Target and current temperatures are converted from F to C for HomeKit + assert acc.char_heat.value == 20.0 # 68F + assert acc.char_cool.value == 20.0 # 68F + assert acc.char_current_temp.value == pytest.approx(18.3, abs=0.1) # 65F + + +async def test_heatercooler_fahrenheit_default_temp_range( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the Celsius default range is not reconverted in Fahrenheit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # No min/max temp, so the Celsius defaults (7/35) are used as-is + ATTR_TEMPERATURE: 68.0, + } + + hass.config.units = US_CUSTOMARY_SYSTEM + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # The default bounds stay 7/35 C rather than being misread as Fahrenheit + assert acc.char_cool.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_cool.properties[PROP_MAX_VALUE] == 35.0 + + +async def test_heatercooler_state_updates( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test state updates from Home Assistant to HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Test heating mode + hass.states.async_set( + entity_id, + HVACMode.HEAT, + { + **base_attrs, + ATTR_HVAC_ACTION: HVACAction.HEATING, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 19.0, + }, + ) + await hass.async_block_till_done() + + assert acc.char_active.value == 1 + assert acc.char_target_state.value == HC_TARGET_HEAT + assert acc.char_current_state.value == HC_HEATING + assert acc.char_heat.value == 22.0 + assert acc.char_cool.value == 22.0 + assert acc.char_current_temp.value == 19.0 + + # Test cooling mode + hass.states.async_set( + entity_id, + HVACMode.COOL, + { + **base_attrs, + ATTR_HVAC_ACTION: HVACAction.COOLING, + ATTR_TEMPERATURE: 18.0, + ATTR_CURRENT_TEMPERATURE: 21.0, + }, + ) + await hass.async_block_till_done() + + assert acc.char_active.value == 1 + assert acc.char_target_state.value == HC_TARGET_COOL + assert acc.char_current_state.value == HC_COOLING + assert acc.char_heat.value == 18.0 + assert acc.char_cool.value == 18.0 + + # Test auto mode + hass.states.async_set( + entity_id, + HVACMode.AUTO, + { + **base_attrs, + ATTR_HVAC_ACTION: HVACAction.IDLE, + ATTR_TEMPERATURE: 20.0, + }, + ) + await hass.async_block_till_done() + + assert acc.char_active.value == 1 + assert acc.char_target_state.value == HC_TARGET_AUTO + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_dual_temperature_support( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HeaterCooler with dual temperature support (TARGET_TEMP_HIGH/LOW).""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 18.0, + ATTR_CURRENT_TEMPERATURE: 21.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.char_cool.value == 24.0 # TARGET_TEMP_HIGH + assert acc.char_heat.value == 18.0 # TARGET_TEMP_LOW + + # Update temperatures + hass.states.async_set( + entity_id, + HVACMode.HEAT_COOL, + { + **base_attrs, + ATTR_TARGET_TEMP_HIGH: 26.0, + ATTR_TARGET_TEMP_LOW: 16.0, + }, + ) + await hass.async_block_till_done() + + assert acc.char_cool.value == 26.0 + assert acc.char_heat.value == 16.0 + + +async def test_heatercooler_fan_speed_updates( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test fan speed updates.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH], + ATTR_FAN_MODE: FAN_LOW, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Test different fan speeds + fan_speed_tests = [ + (FAN_LOW, 25), # low -> 25% (index 0) + (FAN_MIDDLE, 50), # middle -> 50% (index 1) + (FAN_MEDIUM, 75), # medium -> 75% (index 2) + (FAN_HIGH, 100), # high -> 100% (index 3) + ] + + for fan_mode, expected_percentage in fan_speed_tests: + hass.states.async_set( + entity_id, HVACMode.COOL, {**base_attrs, ATTR_FAN_MODE: fan_mode} + ) + await hass.async_block_till_done() + assert acc.char_speed.value == expected_percentage + + +async def test_heatercooler_no_swing_toggle_without_off_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the swing toggle is not exposed without an advertised off mode.""" + entity_id = "climate.test" + # Routed through the fan speeds; the swing list has no off mode, so + # the toggle's off write would be rejected by the entity + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: [FAN_LOW, FAN_HIGH], + ATTR_SWING_MODES: ["vertical"], + ATTR_SWING_MODE: "vertical", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.swing_on_mode is None + serv = acc.get_service(SERV_HEATER_COOLER) + assert serv.get_characteristic(CHAR_ROTATION_SPEED) is not None + with pytest.raises(ValueError): + serv.get_characteristic(CHAR_SWING_MODE) + + +async def test_heatercooler_swing_mode_updates( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test swing mode updates.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: ["off", "vertical", "horizontal", "both"], + ATTR_SWING_MODE: "off", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Test swing mode on/off + swing_tests = [ + ("off", 0), + ("vertical", 1), + ("horizontal", 1), + ("both", 1), + ] + + for swing_mode, expected_value in swing_tests: + hass.states.async_set( + entity_id, HVACMode.COOL, {**base_attrs, ATTR_SWING_MODE: swing_mode} + ) + await hass.async_block_till_done() + assert acc.char_swing.value == expected_value + + +async def test_heatercooler_unavailable_states( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test handling of unavailable and unknown states.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Test unavailable state + hass.states.async_set(entity_id, STATE_UNAVAILABLE, base_attrs) + await hass.async_block_till_done() + + # Manually trigger state update since the test might not automatically trigger callbacks + unavailable_state = hass.states.get(entity_id) + acc.async_update_state(unavailable_state) + + assert acc.char_active.value == 0 + + # Test unknown state + hass.states.async_set(entity_id, STATE_UNKNOWN, base_attrs) + await hass.async_block_till_done() + + unknown_state = hass.states.get(entity_id) + acc.async_update_state(unknown_state) + + assert acc.char_active.value == 0 + + +async def test_heatercooler_action_derivation( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test action derivation when hvac_action is not provided.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TEMPERATURE: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, # 2 degrees below target + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Should derive heating action (current < target - delta) + assert acc.char_current_state.value == HC_HEATING + + # Test cooling derivation + hass.states.async_set( + entity_id, + HVACMode.COOL, + { + **base_attrs, + ATTR_CURRENT_TEMPERATURE: 22.0, # 2 degrees above target + }, + ) + await hass.async_block_till_done() + + assert acc.char_current_state.value == HC_COOLING + + # Test idle state (within delta) + hass.states.async_set( + entity_id, + HVACMode.COOL, + { + **base_attrs, + ATTR_CURRENT_TEMPERATURE: 20.1, # Within 0.25 delta + }, + ) + await hass.async_block_till_done() + + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_set_active_off( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting active to off via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # Turning off alongside a temperature write should only set the OFF mode + _write_chars( + hk_driver, acc, {CHAR_ACTIVE: 0, CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0} + ) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 1 + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.OFF + assert len(call_set_temperature) == 0 + + +async def test_heatercooler_set_active_off_no_off_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test turning off an entity without an OFF mode issues no service call.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.COOL], # no OFF mode + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc._supports_off is False + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 0}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 0 + # The rejected write must not leave HomeKit showing the unit as off + assert acc.char_active.value == 1 + + +async def test_heatercooler_unsupported_target_mode_write_restored( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an unsupported target mode write restores the characteristic.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.char_target_state.value == HC_TARGET_COOL + + # Heat is not supported; the write must not reach Home Assistant and + # the characteristic must return to the cool target + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 0 + assert acc.char_target_state.value == HC_TARGET_COOL + + +async def test_heatercooler_unsupported_target_write_dry_only_entity( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the restore falls back to the default target for dry only entities.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.DRY, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.DRY, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Dry has no target representation, so the last known mode cannot be + # restored; the write must fall back to the default target instead of + # leaving the unsupported value on the tile + assert acc.char_target_state.value == HC_TARGET_AUTO + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 0 + assert acc.char_target_state.value == HC_TARGET_AUTO + + +async def test_heatercooler_set_active_on( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting active to on via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + # Start in OFF state + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Set last known mode for test + acc._last_known_mode = HVACMode.HEAT + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + + # Set active to 1 (on) when currently off + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 1 + assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_set_active_on_heat_only( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test turning a heat-only entity on uses a supported mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Off at startup must fall back to a supported mode, not COOL + assert acc._last_known_mode == HVACMode.HEAT + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 1 + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_power_on_with_thresholds_uses_activated_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test thresholds batched with Active on resolve against the new mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ), + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 18.0, + ATTR_TEMPERATURE: None, + } + + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + # Power on restores heat cool, so the thresholds go out as a range + # write instead of a single setpoint picked from the off state + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars( + hk_driver, + acc, + { + CHAR_ACTIVE: 1, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 26.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 16.0, + }, + ) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == pytest.approx( + 26.0, abs=0.1 + ) + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == pytest.approx( + 16.0, abs=0.1 + ) + + +async def test_heatercooler_double_off_does_not_corrupt_mode_memory( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test repeated off writes never make off the restore mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The entity accepts the calls but reports its state late + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 0}) + await hass.async_block_till_done() + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 0}) + await hass.async_block_till_done() + + assert acc._last_known_mode == HVACMode.COOL + + # Turning back on works even though the state still reads cool, since + # the pending off write means the entity is about to stop running + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.COOL + + +async def test_heatercooler_no_off_entity_applies_rest_of_batch( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a rejected off write does not drop the bundled writes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL], # no off + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars( + hk_driver, + acc, + { + CHAR_ACTIVE: 0, + CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 21.0, + }, + ) + await hass.async_block_till_done() + + # The off is rejected, so the entity keeps running and the bundled + # mode and setpoint writes still apply + assert acc.char_active.value == 1 + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 21.0, abs=0.1 + ) + + +async def test_heatercooler_pending_mode_does_not_mask_target_reject( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an unsupported target is rejected while a mode is pending.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Turning on leaves heat pending; the entity reports late + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + # Cool is not supported, so the characteristic is put back on heat + # instead of the pending mode masking the rejection + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + await hass.async_block_till_done() + + assert acc.char_target_state.value == HC_TARGET_HEAT + + +async def test_heatercooler_range_only_one_sided_entity( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a range only entity with one sided modes gets range writes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.OFF], + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Both sides exist so the write can carry the full range + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 21.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == pytest.approx( + 21.0, abs=0.1 + ) + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == pytest.approx( + 22.0, abs=0.1 + ) + assert ATTR_TEMPERATURE not in call_set_temperature[0].data + + +async def test_heatercooler_state_callback_during_write_wins( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a state reported during the call beats the queued mode values.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The entity normalizes the requested mode and reports it before the + # blocking service call returns, like a synchronous integration + async def _hvac(call: ServiceCall) -> None: + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + + hass.services.async_register(CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, _hvac) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + await hass.async_block_till_done() + + # The reported mode is fresher than the queued cool, so it stays + assert acc._pending_mode is None + assert acc._last_known_mode == HVACMode.HEAT_COOL + + +async def test_heatercooler_pending_mode_bridges_slow_state_updates( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an accepted mode stays effective until the entity reports it.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The mode service is accepted but the entity state is not updated, + # like a push integration that reports later + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + await hass.async_block_till_done() + + # A threshold batch resolves against the accepted cool mode, not the + # stale heat state, so the cooling side is not dropped + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == pytest.approx( + 22.0, abs=0.1 + ) + + # A mid transition update still reporting the old mode keeps the + # bridge, the displayed target, and the restore mode + hass.states.async_set( + entity_id, HVACMode.HEAT, {**base_attrs, ATTR_CURRENT_TEMPERATURE: 23.0} + ) + await hass.async_block_till_done() + assert acc._pending_mode == HVACMode.COOL + assert acc.char_target_state.value == HC_TARGET_COOL + assert acc._last_known_mode == HVACMode.COOL + + # Once the entity reports a mode change, its state is authoritative again + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + assert acc._pending_mode is None + + +async def test_heatercooler_batch_resolves_after_prior_batch( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a batch sees the mode applied by the batch before it.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + gate = asyncio.Event() + + async def _slow_hvac(call: ServiceCall) -> None: + await gate.wait() + hass.states.async_set(entity_id, call.data[ATTR_HVAC_MODE], base_attrs) + + hass.services.async_register(CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, _slow_hvac) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # The threshold batch arrives while the mode switch is still pending; + # it must resolve against COOL, not the stale HEAT state + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}) + gate.set() + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == pytest.approx( + 22.0, abs=0.1 + ) + + +async def test_heatercooler_failed_write_aborts_batch( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a rejected mode write aborts the rest of the batch.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + ATTR_FAN_MODE: "low", + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + async_mock_service( + hass, + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + raise_exception=HomeAssistantError("mode rejected"), + ) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + + _write_chars( + hk_driver, + acc, + { + CHAR_ACTIVE: 1, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0, + CHAR_ROTATION_SPEED: 100, + }, + ) + await hass.async_block_till_done() + + # The rejected mode change stops the temperature and fan writes + assert len(call_set_temperature) == 0 + assert len(call_set_fan_mode) == 0 + + +async def test_heatercooler_write_batches_are_serialized( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a second batch waits for the first to finish.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + order: list[str] = [] + gate = asyncio.Event() + + async def _slow_hvac(call: ServiceCall) -> None: + order.append(f"hvac:{call.data[ATTR_HVAC_MODE]}") + if len(order) == 1: + await gate.wait() + + async def _temp(call: ServiceCall) -> None: + order.append("temp") + + hass.services.async_register(CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, _slow_hvac) + hass.services.async_register(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, _temp) + + # The first batch blocks in its mode write while the second arrives + _write_chars( + hk_driver, + acc, + { + CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0, + }, + ) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}) + gate.set() + await hass.async_block_till_done() + + # The first batch finishes its temperature write before the second + # batch's mode write starts + assert order == ["hvac:cool", "temp", "hvac:heat"] + + +async def test_heatercooler_set_chars_dispatch_order( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test fan writes are dispatched after the mode and temperature writes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + ATTR_FAN_MODE: "low", + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + services: list[str] = [] + + async def _record_and_wait( + domain: str, service: str, data: dict[str, Any], value: Any | None = None + ) -> bool: + services.append(service) + return True + + with patch.object(acc, "async_call_service_and_wait", _record_and_wait): + # Turning on activates heat, so the heating threshold applies + _write_chars( + hk_driver, + acc, + { + CHAR_ACTIVE: 1, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 22.0, + CHAR_ROTATION_SPEED: 100, + }, + ) + await hass.async_block_till_done() + + # Fan is dispatched after both the hvac mode and the temperature write + assert services.index(SERVICE_SET_FAN_MODE) > services.index(SERVICE_SET_HVAC_MODE) + assert services.index(SERVICE_SET_FAN_MODE) > services.index( + SERVICE_SET_TEMPERATURE + ) + + +async def test_heatercooler_set_target_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting target heater cooler state via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + + # Test setting different target modes + mode_tests = [ + (HC_TARGET_HEAT, HVACMode.HEAT), + (HC_TARGET_COOL, HVACMode.COOL), + (HC_TARGET_AUTO, HVACMode.AUTO), + ] + + for hk_mode, _expected_ha_mode in mode_tests: + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: hk_mode}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 3 + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.HEAT + assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVACMode.COOL + assert call_set_hvac_mode[2].data[ATTR_HVAC_MODE] == HVACMode.AUTO + + +async def test_heatercooler_set_temperature_single( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting temperature for single-temperature entities.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # Set heating temperature in HEAT mode + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 22.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == 22.0 + + # Change to COOL mode and set cooling temperature + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 18.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 2 + assert call_set_temperature[1].data[ATTR_TEMPERATURE] == 18.0 + + +async def test_heatercooler_set_temperature_dual( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting temperature for dual-temperature entities.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 18.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # Set both temperatures + _write_chars( + hk_driver, + acc, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 26.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 16.0, + }, + ) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 26.0 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 16.0 + + +async def test_heatercooler_dual_capable_entity_in_single_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test dual capable entities in a single setpoint mode use temperature.""" + entity_id = "climate.test" + # Entities like ecobee publish the range keys even in heat or cool + # mode, where only the single setpoint carries a value + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ), + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TARGET_TEMP_HIGH: None, + ATTR_TARGET_TEMP_LOW: None, + ATTR_TEMPERATURE: 22.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The single setpoint is displayed instead of stale placeholder values + assert acc.char_heat.value == pytest.approx(22.0, abs=0.1) + + # A threshold write in heat mode sends the single setpoint + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 21.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == pytest.approx( + 21.0, abs=0.1 + ) + assert ATTR_TARGET_TEMP_HIGH not in call_set_temperature[0].data + + # Switching to Auto in the same batch sends a range write instead + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars( + hk_driver, + acc, + { + CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_AUTO, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 26.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 16.0, + }, + ) + await hass.async_block_till_done() + + assert call_set_temperature[-1].data[ATTR_TARGET_TEMP_HIGH] == pytest.approx( + 26.0, abs=0.1 + ) + assert call_set_temperature[-1].data[ATTR_TARGET_TEMP_LOW] == pytest.approx( + 16.0, abs=0.1 + ) + + +async def test_heatercooler_range_keys_without_range_capability( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a cool only entity reporting range keys still gets single writes.""" + entity_id = "climate.test" + # A contradictory device config: range keys without any range mode + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 18.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Only the cooling threshold exists, so a range write is impossible + # and the setpoint goes out as a single temperature + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == pytest.approx( + 22.0, abs=0.1 + ) + assert ATTR_TARGET_TEMP_HIGH not in call_set_temperature[0].data + + +async def test_heatercooler_set_fan_speed( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting fan speed via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH], + ATTR_FAN_MODE: FAN_LOW, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + + # A None mode means the write is ignored and dispatches no service call + speed_tests = [ + (0, None), + (25, FAN_LOW), + (50, FAN_MIDDLE), + (75, FAN_MEDIUM), + (100, FAN_HIGH), + ] + + for speed_percent, _ in speed_tests: + _write_chars(hk_driver, acc, {CHAR_ROTATION_SPEED: speed_percent}) + await hass.async_block_till_done() + + expected_calls = [mode for _, mode in speed_tests if mode is not None] + assert len(call_set_fan_mode) == len(expected_calls) + for call, expected_mode in zip(call_set_fan_mode, expected_calls, strict=True): + assert call.data[ATTR_FAN_MODE] == expected_mode + + +async def test_heatercooler_set_swing_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting swing mode via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: ["off", "vertical", "horizontal", "both"], + ATTR_SWING_MODE: "off", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_swing_mode = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE + ) + + # Test swing on + _write_chars(hk_driver, acc, {CHAR_SWING_MODE: 1}) + await hass.async_block_till_done() + + assert len(call_set_swing_mode) == 1 + assert call_set_swing_mode[0].data[ATTR_SWING_MODE] == "both" # swing_on_mode + + # Test swing off + _write_chars(hk_driver, acc, {CHAR_SWING_MODE: 0}) + await hass.async_block_till_done() + + assert len(call_set_swing_mode) == 2 + assert call_set_swing_mode[1].data[ATTR_SWING_MODE] == "off" # SWING_OFF + + +async def test_heatercooler_capitalized_fan_modes( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test capitalized fan modes are sent back to the service unchanged.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["Auto", "Low", "Medium", "High"], + ATTR_FAN_MODE: "Low", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + + # The auto mode puts the speed on the fan service; the rotation speed + # writes must use the entity's original casing + acc.char_speed.client_update_value(100) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "High" + + acc.char_speed.client_update_value(25) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "Low" + + +async def test_heatercooler_capitalized_swing_modes( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test capitalized swing modes are detected and preserved.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: ["Off", "On", "Both"], + ATTR_SWING_MODE: "Off", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Predefined swing modes are detected despite the capitalization + assert acc.swing_on_mode == "On" + assert acc.swing_off_mode == "Off" + + # On/off writes preserve the entity's original casing + call_set_swing_mode = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE + ) + _write_chars(hk_driver, acc, {CHAR_SWING_MODE: 1}) + await hass.async_block_till_done() + assert call_set_swing_mode[0].data[ATTR_SWING_MODE] == "On" + + _write_chars(hk_driver, acc, {CHAR_SWING_MODE: 0}) + await hass.async_block_till_done() + assert call_set_swing_mode[1].data[ATTR_SWING_MODE] == "Off" + + # A capitalized current swing value still reads as on + hass.states.async_set( + entity_id, HVACMode.COOL, {**base_attrs, ATTR_SWING_MODE: "On"} + ) + await hass.async_block_till_done() + assert acc.char_swing.value == 1 + + +async def test_heatercooler_swing_mode_fallback( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test swing mode fallback when no swing modes are available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: [], # Empty swing modes + ATTR_SWING_MODE: None, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_swing_mode = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE + ) + + # Test setting swing mode off when no swing modes are available + # This should not make a service call because there are no swing modes + acc._set_swing_mode(0) + await hass.async_block_till_done() + + assert len(call_set_swing_mode) == 0 + + +async def test_heatercooler_fan_speed_no_fan_modes( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test fan speed handling when no fan modes are available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # No fan modes + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + + # Test setting fan speed when no fan modes are available + acc._set_fan_speed(50) + await hass.async_block_till_done() + + # No service call should be made + assert len(call_set_fan_mode) == 0 + + +async def test_heatercooler_swing_mode_no_swing_attribute( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test swing mode handling when swing_on_mode attribute is not available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # No swing mode support + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_swing_mode = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE + ) + + # Test setting swing mode when swing_on_mode attribute is not available + acc._set_swing_mode(1) + await hass.async_block_till_done() + + # No service call should be made + assert len(call_set_swing_mode) == 0 + + +async def test_heatercooler_single_temp_no_entity_state( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test single temperature handling when entity state is not available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 21.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # Remove entity state; a threshold write must not call the service + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 0 + + +@pytest.mark.parametrize( + ("state", "current_temp", "chars", "expected"), + [ + # Cool mode uses the cooling threshold; heat mode uses the heating one. + pytest.param( + HVACMode.COOL, + 20.0, + {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}, + 22.0, + id="cool_uses_cooling_threshold", + ), + pytest.param( + HVACMode.HEAT, + 20.0, + {CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0}, + 18.0, + id="heat_uses_heating_threshold", + ), + # HEAT_COOL with both thresholds picks the one further from the temp. + pytest.param( + HVACMode.HEAT_COOL, + 19.0, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 25.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + 25.0, # |25-19| > |18-19| + id="heat_cool_picks_further_cooling", + ), + pytest.param( + HVACMode.HEAT_COOL, + 24.0, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 25.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + 18.0, # |18-24| > |25-24| + id="heat_cool_picks_further_heating", + ), + # AUTO behaves like HEAT_COOL for a single set point. + pytest.param( + HVACMode.AUTO, + 24.0, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 25.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + 18.0, # |18-24| > |25-24| + id="auto_picks_further_heating", + ), + pytest.param( + HVACMode.AUTO, + 19.0, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 25.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + 25.0, # |25-19| > |18-19| + id="auto_picks_further_cooling", + ), + # HEAT_COOL with a single threshold falls back to it. + pytest.param( + HVACMode.HEAT_COOL, + 20.0, + {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}, + 22.0, + id="heat_cool_single_cooling", + ), + pytest.param( + HVACMode.HEAT_COOL, + 20.0, + {CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0}, + 18.0, + id="heat_cool_single_heating", + ), + # An unknown mode falls back to whichever threshold was written. + pytest.param( + "unknown_mode", + 20.0, + {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}, + 22.0, + id="unknown_mode_cooling", + ), + pytest.param( + "unknown_mode", + 20.0, + {CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0}, + 18.0, + id="unknown_mode_heating", + ), + ], +) +async def test_heatercooler_complex_temperature_selection( + hass: HomeAssistant, + hk_driver: HomeDriver, + state: str, + current_temp: float, + chars: dict[str, float], + expected: float, +) -> None: + """Test single set point selection driven by threshold characteristic writes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.AUTO, + HVACMode.OFF, + ], + ATTR_TEMPERATURE: current_temp, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + hass.states.async_set(entity_id, state, base_attrs) + await hass.async_block_till_done() + + _write_chars(hk_driver, acc, chars) + await hass.async_block_till_done() + + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + expected, abs=0.1 + ) + + +@pytest.mark.parametrize( + "state", + [STATE_UNKNOWN, STATE_UNAVAILABLE, "invalid_mode", HVACMode.AUTO], +) +async def test_heatercooler_target_state_unchanged_for_unusable_states( + hass: HomeAssistant, hk_driver: HomeDriver, state: str +) -> None: + """Test the target state is left unchanged for states with no mapping.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # A known Cool target is established from the current state + assert acc.char_target_state.value == HC_TARGET_COOL + + # Unknown, unavailable, invalid, and unsupported Auto have no mapping, so + # the target characteristic keeps its previous value. + hass.states.async_set(entity_id, state, base_attrs) + await hass.async_block_till_done() + assert acc.char_target_state.value == HC_TARGET_COOL + + +async def test_heatercooler_derive_action_edge_case( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the derived current state for modes without a heating/cooling action.""" + entity_id = "climate.test" + features = ClimateEntityFeature.TARGET_TEMPERATURE + hvac_modes = [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.OFF, + ] + base_attrs = { + ATTR_SUPPORTED_FEATURES: features, + ATTR_HVAC_MODES: hvac_modes, + ATTR_TEMPERATURE: 21.0, + ATTR_CURRENT_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Dry and fan modes have no heating/cooling action, so the state is idle + hass.states.async_set(entity_id, HVACMode.DRY, base_attrs) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + hass.states.async_set(entity_id, HVACMode.FAN_ONLY, base_attrs) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + # Cool mode without a target temperature cannot derive an action + hass.states.async_set( + entity_id, + HVACMode.COOL, + { + ATTR_SUPPORTED_FEATURES: features, + ATTR_HVAC_MODES: hvac_modes, + ATTR_CURRENT_TEMPERATURE: 20.0, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + # Heat mode already at temperature is idle, not heating + hass.states.async_set( + entity_id, + HVACMode.HEAT, + {**base_attrs, ATTR_TEMPERATURE: 20.0, ATTR_CURRENT_TEMPERATURE: 21.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_heat_cool_no_current_temp_diff( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HEAT_COOL mode temperature selection when no current temperature available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + # No current temperature + } + + # Create entity first + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + # HEAT_COOL with both thresholds but no current temp defaults to heating + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + await hass.async_block_till_done() + + _write_chars( + hk_driver, + acc, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + ) + await hass.async_block_till_done() + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 18.0, abs=0.1 + ) + + +async def test_heatercooler_derive_action_auto_without_thresholds( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test auto mode action derivation without a target temperature range.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Without any setpoint the derived state is idle + hass.states.async_set( + entity_id, HVACMode.AUTO, {**base_attrs, ATTR_CURRENT_TEMPERATURE: 21.1} + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + # A single setpoint drives both sides of the hysteresis band + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_TEMPERATURE: 21.0, ATTR_CURRENT_TEMPERATURE: 23.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_COOLING + + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_TEMPERATURE: 21.0, ATTR_CURRENT_TEMPERATURE: 19.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_HEATING + + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_TEMPERATURE: 21.0, ATTR_CURRENT_TEMPERATURE: 21.1}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_off_at_startup_activates_displayed_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test turning on an off-at-startup entity activates the displayed mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The tile shows Auto, so turning on must activate Auto, not the first mode. + assert acc.char_target_state.value == HC_TARGET_AUTO + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.AUTO + + +@pytest.mark.parametrize( + "mode_sequence", + [ + pytest.param( + [HVACMode.COOL, HVACMode.DRY, HVACMode.OFF], id="mode_change_to_dry" + ), + pytest.param([HVACMode.DRY, HVACMode.OFF], id="starts_in_dry"), + ], +) +async def test_heatercooler_unrepresentable_mode_not_restored( + hass: HomeAssistant, hk_driver: HomeDriver, mode_sequence: list[HVACMode] +) -> None: + """Test a mode without a HomeKit target is not restored by Active.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.COOL, + HVACMode.DRY, + HVACMode.OFF, + ], + } + + hass.states.async_set(entity_id, mode_sequence[0], base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + for mode in mode_sequence[1:]: + hass.states.async_set(entity_id, mode, base_attrs) + await hass.async_block_till_done() + + # Dry has no HomeKit target, so the tile shows Cool and turning + # Active on must restore Cool, not Dry. + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.COOL + + +async def test_heatercooler_power_on_restores_last_active_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test going off keeps the last mode on the tile and power-on restores it.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.char_target_state.value == HC_TARGET_HEAT + + # Going off keeps the Heat target on the tile rather than flipping to Auto. + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + assert acc.char_target_state.value == HC_TARGET_HEAT + + # Turning back on restores the mode the tile is showing. + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_cool_mode_ignores_heating_threshold( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a heating-threshold write is ignored for a single-setpoint cool entity.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 22.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + # Cool mode uses the cooling threshold; a heating-threshold write is ignored. + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0}) + await hass.async_block_till_done() + assert len(call_set_temperature) == 0 + + +async def test_heatercooler_batched_mode_decides_setpoint_side( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a mode written in the same batch decides the setpoint side.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 22.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # The entity is still heating, but the batch switches to Cool, so the + # cooling threshold is the setpoint rather than being ignored. + _write_chars( + hk_driver, + acc, + { + CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 24.0, + }, + ) + await hass.async_block_till_done() + + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.COOL + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 24.0, abs=0.1 + ) + + +async def test_heatercooler_fan_only_target_falls_back( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a fan-only entity maps Auto to its mode and hides the thresholds.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + ATTR_HVAC_MODES: [HVACMode.FAN_ONLY, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.FAN_ONLY, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # No heat/cool/range mode, so Auto maps to the first supported mode + valid_values = acc.char_target_state.properties["ValidValues"] + assert valid_values == {HVACMode.FAN_ONLY: HC_TARGET_AUTO} + assert acc.char_target_state.value == HC_TARGET_AUTO + + # No target temperature support, so the threshold sliders are not exposed + assert not hasattr(acc, "char_cool") + assert not hasattr(acc, "char_heat") + assert acc.char_speed.value == 100 + + +async def test_heatercooler_off_only_target_falls_back_to_off( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a degenerate off-only entity maps Auto to off, not an unsupported mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + ATTR_HVAC_MODES: [HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The only mode is off, so Auto maps to off rather than an unsupported Auto + valid_values = acc.char_target_state.properties["ValidValues"] + assert valid_values == {HVACMode.OFF: HC_TARGET_AUTO} + + +async def test_heatercooler_derive_action_cooling_triggered( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test _derive_action returns COOLING for auto mode when temp is significantly higher than target.""" + entity_id = "climate.test" + + # Test entity in auto mode with current temp higher than target + delta + # Target will be ATTR_TARGET_TEMP_HIGH (20.0), current temp should be > target + 0.25 (delta) + hass.states.async_set( + entity_id, + HVACMode.AUTO, + { + ATTR_HVAC_MODES: [HVACMode.AUTO, HVACMode.OFF], + ATTR_CURRENT_TEMPERATURE: 22.0, # 2°C higher than target + ATTR_TARGET_TEMP_HIGH: 20.0, # Target (becomes the target in _derive_action) + # Note: No ATTR_HVAC_ACTION so _derive_action gets called + }, + ) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "HeaterCooler", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Current 22°C is above target 20°C plus the hysteresis band, so the + # derived action is cooling. + assert acc.char_current_state.value == HC_COOLING + + +async def test_heatercooler_zero_min_temp_preserved( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a reported min temperature of 0 is used, not the default floor.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_MIN_TEMP: 0.0, + ATTR_MAX_TEMP: 30.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.char_cool.properties[PROP_MIN_VALUE] == 0.0 + + +async def test_heatercooler_reports_humidity_via_linked_sensor( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a reported current humidity is exposed via a linked sensor.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + ATTR_CURRENT_HUMIDITY: 55, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 55 + + # Every service carries an explicit primary flag so the Home app shows + # the HeaterCooler tile, not the linked humidity sensor + serv = acc.get_service(SERV_HEATER_COOLER) + assert serv.is_primary_service is True + humidity_serv = acc.get_service(SERV_HUMIDITY_SENSOR) + assert humidity_serv.is_primary_service is False + assert humidity_serv.get_characteristic(CHAR_NAME).value == "Climate Humidity" + + hass.states.async_set( + entity_id, HVACMode.COOL, {**base_attrs, ATTR_CURRENT_HUMIDITY: 60} + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + + +async def test_heatercooler_custom_fan_modes_no_rotation_speed( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test entities with only custom fan modes skip the rotation speed char.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # Non-standard names that do not intersect the predefined speeds + ATTR_FAN_MODES: ["quiet", "turbo"], + ATTR_FAN_MODE: "quiet", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + # This must not raise ZeroDivisionError during init + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.ordered_fan_speeds == [] + assert not hasattr(acc, "char_speed") + + +async def test_heatercooler_custom_swing_modes_no_swing_char( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test entities with only custom swing modes skip the swing char.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: ["off", "custom"], + ATTR_SWING_MODE: "off", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.swing_on_mode is None + assert not hasattr(acc, "char_swing") + + +async def test_heatercooler_derive_action_fahrenheit( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test derived action uses a unit independent hysteresis band.""" + entity_id = "climate.test" + hass.config.units = US_CUSTOMARY_SYSTEM + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # hvac_action intentionally omitted so the action is derived + ATTR_TEMPERATURE: 68.0, + ATTR_CURRENT_TEMPERATURE: 72.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # 72F current vs 68F target in cool mode is well past the 0.25C band + assert acc.char_current_state.value == HC_COOLING + + # Within the hysteresis band (0.2F ~= 0.11C < 0.25C) stays idle + hass.states.async_set( + entity_id, + HVACMode.COOL, + {**base_attrs, ATTR_TEMPERATURE: 68.0, ATTR_CURRENT_TEMPERATURE: 68.2}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_derive_action_auto_with_thresholds( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test derived action in auto mode using the target temperature range.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO, HVACMode.OFF], + # hvac_action intentionally omitted so the action is derived, and no + # ATTR_TEMPERATURE so it falls back to the thresholds. + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 26.0, + } + + hass.states.async_set(entity_id, HVACMode.AUTO, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # 26°C is above the high threshold, so the derived action is cooling + assert acc.char_current_state.value == HC_COOLING + + # Below the low threshold the derived action is heating + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_CURRENT_TEMPERATURE: 18.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_HEATING + + # Comfortably between the thresholds it stays idle, not heating + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_CURRENT_TEMPERATURE: 22.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_auto_fan_mode_linked_fan_service( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an auto fan mode exposes the fan through a linked fan service.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["auto", "low", "high"], + ATTR_FAN_MODE: "auto", + ATTR_HVAC_ACTION: HVACAction.COOLING, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The rotation speed lives on the fan service, not the HeaterCooler + assert CHAR_TARGET_FAN_STATE in acc.fan_chars + assert CHAR_ROTATION_SPEED in acc.fan_chars + assert CHAR_CURRENT_FAN_STATE in acc.fan_chars + assert acc.char_target_fan_state.value == 1 + assert acc.char_current_fan_state.value == FAN_STATE_ACTIVE + assert acc.char_fan_active.value == 1 + + # Leaving auto selects the middle manual speed; re-enabling restores auto + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + acc.char_target_fan_state.client_update_value(0) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "low" + + acc.char_target_fan_state.client_update_value(1) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "auto" + + # A manual fan mode is reflected back into the fan service chars + hass.states.async_set( + entity_id, + HVACMode.COOL, + {**base_attrs, ATTR_FAN_MODE: "high", ATTR_HVAC_ACTION: HVACAction.IDLE}, + ) + await hass.async_block_till_done() + assert acc.char_target_fan_state.value == 0 + assert acc.char_speed.value == 100 + assert acc.char_current_fan_state.value == FAN_STATE_IDLE + + # Turning the unit off turns the fan inactive + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + assert acc.char_fan_active.value == 0 + + # An entity that is unavailable when the accessory is created starts + # with the fan inactive; state change propagation filters unavailable + # in the base accessory, so only the creation path sees it. + hass.states.async_set(entity_id, STATE_UNAVAILABLE, base_attrs) + await hass.async_block_till_done() + acc_unavailable = HeaterCooler(hass, hk_driver, "Climate", entity_id, 2, None) + assert acc_unavailable.char_fan_active.value == 0 + + +@pytest.mark.parametrize( + ("hvac_modes", "expected_category"), + [ + pytest.param( + [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + CATEGORY_AIR_CONDITIONER, + id="cooling_capable", + ), + pytest.param( + [HVACMode.HEAT_COOL, HVACMode.OFF], + CATEGORY_AIR_CONDITIONER, + id="heat_cool", + ), + pytest.param( + [HVACMode.HEAT, HVACMode.OFF], + CATEGORY_HEATER, + id="heat_only", + ), + pytest.param( + [HVACMode.DRY, HVACMode.OFF], + CATEGORY_AIR_CONDITIONER, + id="dry_only_is_not_a_heater", + ), + ], +) +async def test_heatercooler_category( + hass: HomeAssistant, + hk_driver: HomeDriver, + hvac_modes: list[HVACMode], + expected_category: int, +) -> None: + """Test the advertised category matches the device capabilities.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: hvac_modes, + } + + hass.states.async_set(entity_id, hvac_modes[0], base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + assert acc.category == expected_category + + +async def test_heatercooler_auto_fan_service_without_speeds( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test auto plus on fan modes get a fan service without a speed slider.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["auto", "on"], + ATTR_FAN_MODE: "on", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert CHAR_TARGET_FAN_STATE in acc.fan_chars + assert CHAR_ROTATION_SPEED not in acc.fan_chars + assert not hasattr(acc, "char_speed") + assert acc.char_target_fan_state.value == 0 + + # Leaving auto falls back to the on mode + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + acc.char_target_fan_state.client_update_value(0) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "on" + + +async def test_heatercooler_fan_active_resets_without_off_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a fan off write resets to on when the fan has no off mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["auto", "low", "high"], + ATTR_FAN_MODE: "low", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + acc.char_fan_active.client_update_value(0) + await hass.async_block_till_done() + assert len(call_set_fan_mode) == 0 + assert acc.char_fan_active.value == 1 + + +async def test_heatercooler_fan_active_toggles_with_off_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the fan active toggle maps to the fan off and on modes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["auto", "off", "low", "high"], + ATTR_FAN_MODE: "low", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + acc.char_fan_active.client_update_value(0) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "off" + + acc.char_fan_active.client_update_value(1) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "low" + + +async def test_heatercooler_cool_only_single_threshold( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a cool-only entity exposes only the cooling threshold.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 22.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert not hasattr(acc, "char_heat") + assert acc.char_cool.value == pytest.approx(22.0, abs=0.1) + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 24.0}) + await hass.async_block_till_done() + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 24.0, abs=0.1 + ) + + +async def test_heatercooler_heat_only_single_threshold( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a heat-only entity exposes only the heating threshold.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.OFF], + ATTR_TEMPERATURE: 21.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert not hasattr(acc, "char_cool") + assert acc.char_heat.value == pytest.approx(21.0, abs=0.1) + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 19.0}) + await hass.async_block_till_done() + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 19.0, abs=0.1 + ) + + +async def test_heatercooler_dry_only_keeps_both_thresholds( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a dry-only entity with a setpoint keeps both thresholds.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.DRY, HVACMode.OFF], + ATTR_SWING_MODES: ["off", "vertical"], + ATTR_TEMPERATURE: 23.0, + } + + hass.states.async_set(entity_id, HVACMode.DRY, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Neither side is capable, so both stay to keep the setpoint controllable + assert acc.char_cool.value == pytest.approx(23.0, abs=0.1) + assert acc.char_heat.value == pytest.approx(23.0, abs=0.1) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index a311ef1ae21b..32c83e76715d 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -1000,7 +1000,20 @@ async def test_thermostat_get_temperature_range(hass: HomeAssistant, hk_driver) await hass.async_block_till_done() state = hass.states.get(entity_id) assert state - assert acc.get_temperature_range(state) == (15.5, 21.0) + # 60F is 15.56C, rounded inward to the 0.1 step so the slider cannot + # go below the entity's own minimum + assert acc.get_temperature_range(state) == (15.6, 21.1) + + # A range too narrow to hold a step keeps the exact limits instead + # of expanding beyond them + acc._unit = UnitOfTemperature.CELSIUS + hass.states.async_set( + entity_id, HVACMode.OFF, {ATTR_MIN_TEMP: 20.11, ATTR_MAX_TEMP: 20.14} + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state + assert acc.get_temperature_range(state) == (20.11, 20.14) async def test_thermostat_temperature_step_whole( @@ -1973,7 +1986,9 @@ async def test_water_heater_get_temperature_range( state = hass.states.get(entity_id) assert state await hass.async_block_till_done() - assert acc.get_temperature_range(state) == (15.5, 21.0) + # 60F is 15.56C, rounded inward to the 0.1 step so the slider cannot + # go below the entity's own minimum + assert acc.get_temperature_range(state) == (15.6, 21.1) async def test_water_heater_restore( diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index b5e273237a3d..05d09b89848a 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -47,10 +47,12 @@ from homeassistant.components.homekit.const import ( FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, TYPE_FAUCET, + TYPE_HEATER_COOLER, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, + TYPE_THERMOSTAT, TYPE_VALVE, ) from homeassistant.components.homekit.models import HomeKitEntryData @@ -140,6 +142,7 @@ def test_validate_entity_config() -> None: } }, {"fan.test": {CONF_TYPE: "invalid_type"}}, + {"climate.test": {CONF_TYPE: "invalid_type"}}, { "valve.test": { # Must be sensor (timestamp) entity @@ -236,6 +239,12 @@ def test_validate_entity_config() -> None: assert vec({"switch.demo": {CONF_TYPE: TYPE_VALVE}}) == { "switch.demo": {CONF_TYPE: TYPE_VALVE, CONF_LOW_BATTERY_THRESHOLD: 20} } + assert vec({"climate.demo": {CONF_TYPE: TYPE_HEATER_COOLER}}) == { + "climate.demo": {CONF_TYPE: TYPE_HEATER_COOLER, CONF_LOW_BATTERY_THRESHOLD: 20} + } + assert vec({"climate.demo": {CONF_TYPE: TYPE_THERMOSTAT}}) == { + "climate.demo": {CONF_TYPE: TYPE_THERMOSTAT, CONF_LOW_BATTERY_THRESHOLD: 20} + } config = { CONF_TYPE: TYPE_SPRINKLER, CONF_LINKED_VALVE_DURATION: "input_number.valve_duration",