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:
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user