1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-13 01:27:57 +01:00

homekit: add native HeaterCooler accessory (#148231)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Nitay Ben-Zvi
2026-07-10 21:31:22 +03:00
committed by GitHub
parent 798888125a
commit f5accf6f02
19 changed files with 5517 additions and 99 deletions
+45 -4
View File
@@ -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(
+157 -29
View File
@@ -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:
+89 -2
View File
@@ -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
@@ -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
)
)
@@ -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)
+149 -25
View File
@@ -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, [])
@@ -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"
+17 -4
View File
@@ -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.",
@@ -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
@@ -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
+20
View File
@@ -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)
+46 -1
View File
@@ -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)
@@ -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)
+155
View File
@@ -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
+242 -1
View File
@@ -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)
@@ -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"),
[
File diff suppressed because it is too large Load Diff
@@ -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(
+9
View File
@@ -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",