From 53b991fb54fb9aa4f2681d9adb2ac69c3a08e0c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Apr 2025 23:40:54 -1000 Subject: [PATCH] Add preset modes to HKC fans (#142528) --- .../components/homekit_controller/fan.py | 77 +++- .../snapshots/test_init.ambr | 86 ++-- .../components/homekit_controller/test_fan.py | 380 ++++++++++++++++++ 3 files changed, 504 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 9ba476a0ef3..4138277d81c 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -5,6 +5,10 @@ from __future__ import annotations from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics.const import ( + TargetAirPurifierStateValues, + TargetFanStateValues, +) from aiohomekit.model.services import Service, ServicesTypes from propcache.api import cached_property @@ -35,6 +39,8 @@ DIRECTION_TO_HK = { } HK_DIRECTION_TO_HA = {v: k for (k, v) in DIRECTION_TO_HK.items()} +PRESET_AUTO = "auto" + class BaseHomeKitFan(HomeKitEntity, FanEntity): """Representation of a Homekit fan.""" @@ -42,6 +48,9 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): # This must be set in subclasses to the name of a boolean characteristic # that controls whether the fan is on or off. on_characteristic: str + preset_char = CharacteristicsTypes.FAN_STATE_TARGET + preset_manual_value: int = TargetFanStateValues.MANUAL + preset_automatic_value: int = TargetFanStateValues.AUTOMATIC @callback def _async_reconfigure(self) -> None: @@ -51,6 +60,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): "_speed_range", "_min_speed", "_max_speed", + "preset_modes", "speed_count", "supported_features", ) @@ -59,12 +69,15 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" - return [ + types = [ CharacteristicsTypes.SWING_MODE, CharacteristicsTypes.ROTATION_DIRECTION, CharacteristicsTypes.ROTATION_SPEED, self.on_characteristic, ] + if self.service.has(self.preset_char): + types.append(self.preset_char) + return types @property def is_on(self) -> bool: @@ -124,6 +137,9 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): if self.service.has(CharacteristicsTypes.SWING_MODE): features |= FanEntityFeature.OSCILLATE + if self.service.has(self.preset_char): + features |= FanEntityFeature.PRESET_MODE + return features @cached_property @@ -134,6 +150,32 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): / max(1, self.service[CharacteristicsTypes.ROTATION_SPEED].minStep or 0) ) + @cached_property + def preset_modes(self) -> list[str]: + """Return the preset modes.""" + return [PRESET_AUTO] if self.service.has(self.preset_char) else [] + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + if ( + self.service.has(self.preset_char) + and self.service.value(self.preset_char) == self.preset_automatic_value + ): + return PRESET_AUTO + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + if self.service.has(self.preset_char): + await self.async_put_characteristics( + { + self.preset_char: self.preset_automatic_value + if preset_mode == PRESET_AUTO + else self.preset_manual_value + } + ) + async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" await self.async_put_characteristics( @@ -146,13 +188,16 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): await self.async_turn_off() return - await self.async_put_characteristics( - { - CharacteristicsTypes.ROTATION_SPEED: round( - percentage_to_ranged_value(self._speed_range, percentage) - ) - } - ) + characteristics = { + CharacteristicsTypes.ROTATION_SPEED: round( + percentage_to_ranged_value(self._speed_range, percentage) + ) + } + + if FanEntityFeature.PRESET_MODE in self.supported_features: + characteristics[self.preset_char] = self.preset_manual_value + + await self.async_put_characteristics(characteristics) async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" @@ -172,13 +217,17 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): if not self.is_on: characteristics[self.on_characteristic] = True - if ( + if preset_mode == PRESET_AUTO: + characteristics[self.preset_char] = self.preset_automatic_value + elif ( percentage is not None and FanEntityFeature.SET_SPEED in self.supported_features ): characteristics[CharacteristicsTypes.ROTATION_SPEED] = round( percentage_to_ranged_value(self._speed_range, percentage) ) + if FanEntityFeature.PRESET_MODE in self.supported_features: + characteristics[self.preset_char] = self.preset_manual_value if characteristics: await self.async_put_characteristics(characteristics) @@ -200,10 +249,18 @@ class HomeKitFanV2(BaseHomeKitFan): on_characteristic = CharacteristicsTypes.ACTIVE +class HomeKitAirPurifer(HomeKitFanV2): + """Implement air purifier support for public.hap.service.airpurifier.""" + + preset_char = CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET + preset_manual_value = TargetAirPurifierStateValues.MANUAL + preset_automatic_value = TargetAirPurifierStateValues.AUTOMATIC + + ENTITY_TYPES = { ServicesTypes.FAN: HomeKitFanV1, ServicesTypes.FAN_V2: HomeKitFanV2, - ServicesTypes.AIR_PURIFIER: HomeKitFanV2, + ServicesTypes.AIR_PURIFIER: HomeKitAirPurifer, } diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 3bb9eb48106..324040f850f 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -86,7 +86,9 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + 'auto', + ]), }), 'categories': dict({ }), @@ -110,7 +112,7 @@ 'original_name': 'Airversa AP2 1808 AirPurifier', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_32832', 'unit_of_measurement': None, @@ -120,9 +122,11 @@ 'friendly_name': 'Airversa AP2 1808 AirPurifier', 'percentage': 0, 'percentage_step': 20.0, - 'preset_mode': None, - 'preset_modes': None, - 'supported_features': , + 'preset_mode': 'auto', + 'preset_modes': list([ + 'auto', + ]), + 'supported_features': , }), 'entity_id': 'fan.airversa_ap2_1808_airpurifier', 'state': 'off', @@ -10562,7 +10566,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -10597,7 +10602,8 @@ 'percentage': 66, 'percentage_step': 33.333333333333336, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.haa_c718b3', @@ -11248,7 +11254,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -11283,7 +11290,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.ceiling_fan', @@ -11458,7 +11466,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -11494,7 +11503,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.living_room_fan', @@ -11655,7 +11665,9 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + 'auto', + ]), }), 'categories': dict({ }), @@ -11679,7 +11691,7 @@ 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_175', 'unit_of_measurement': None, @@ -11691,8 +11703,10 @@ 'percentage': 33, 'percentage_step': 33.333333333333336, 'preset_mode': None, - 'preset_modes': None, - 'supported_features': , + 'preset_modes': list([ + 'auto', + ]), + 'supported_features': , }), 'entity_id': 'fan.89_living_room', 'state': 'on', @@ -12703,7 +12717,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -12738,7 +12753,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.ceiling_fan', @@ -12913,7 +12929,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -12950,7 +12967,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.living_room_fan', @@ -13129,7 +13147,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -13166,7 +13185,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.living_room_fan', @@ -13336,7 +13356,9 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + 'auto', + ]), }), 'categories': dict({ }), @@ -13360,7 +13382,7 @@ 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_175', 'unit_of_measurement': None, @@ -13372,8 +13394,10 @@ 'percentage': 33, 'percentage_step': 33.333333333333336, 'preset_mode': None, - 'preset_modes': None, - 'supported_features': , + 'preset_modes': list([ + 'auto', + ]), + 'supported_features': , }), 'entity_id': 'fan.89_living_room', 'state': 'on', @@ -17967,7 +17991,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -18002,7 +18027,8 @@ 'percentage': 0, 'percentage_step': 25.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.caseta_r_wireless_fan_speed_control', @@ -21777,7 +21803,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -21813,7 +21840,8 @@ 'percentage': 0, 'percentage_step': 25.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.simpleconnect_fan_06f674_hunter_fan', diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py index 2c498e1a9c1..e012c1be339 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -47,6 +47,26 @@ def create_fanv2_service(accessory: Accessory) -> None: swing_mode.value = 0 +def create_fanv2_service_with_target_state(accessory: Accessory) -> None: + """Define fan v2 characteristics with target as per HAP spec.""" + service = accessory.add_service(ServicesTypes.FAN_V2) + + target_state = service.add_char(CharacteristicsTypes.FAN_STATE_TARGET) + target_state.value = 0 + + cur_state = service.add_char(CharacteristicsTypes.ACTIVE) + cur_state.value = 0 + + direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION) + direction.value = 0 + + speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED) + speed.value = 0 + + swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE) + swing_mode.value = 0 + + def create_fanv2_service_non_standard_rotation_range(accessory: Accessory) -> None: """Define fan v2 with a non-standard rotation range.""" service = accessory.add_service(ServicesTypes.FAN_V2) @@ -93,6 +113,27 @@ def create_fanv2_service_without_rotation_speed(accessory: Accessory) -> None: swing_mode.value = 0 +def create_air_purifier_service(accessory: Accessory) -> None: + """Define air purifier characteristics as per HAP spec.""" + service = accessory.add_service(ServicesTypes.AIR_PURIFIER) + + target_state = service.add_char(CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET) + target_state.value = 0 + + cur_state = service.add_char(CharacteristicsTypes.ACTIVE) + cur_state.value = 0 + + direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION) + direction.value = 0 + + speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED) + speed.value = 0 + speed.minStep = 25 + + swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE) + swing_mode.value = 0 + + async def test_fan_read_state( hass: HomeAssistant, get_next_aid: Callable[[], int] ) -> None: @@ -606,6 +647,70 @@ async def test_v2_set_percentage( ) +async def test_fanv2_set_preset_mode( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set preset mode when target state is available.""" + helper = await setup_test_component( + hass, get_next_aid(), create_fanv2_service_with_target_state + ) + + await helper.async_update(ServicesTypes.FAN_V2, {CharacteristicsTypes.ACTIVE: 1}) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.ROTATION_SPEED: 100.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_preset_mode", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.FAN_STATE_TARGET: 1, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.ROTATION_SPEED: 33.0, + CharacteristicsTypes.FAN_STATE_TARGET: 0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.FAN_STATE_TARGET: 1, + }, + ) + + async def test_v2_set_percentage_with_min_step( hass: HomeAssistant, get_next_aid: Callable[[], int] ) -> None: @@ -847,6 +952,281 @@ async def test_v2_set_percentage_non_standard_rotation_range( ) +async def test_air_purifier_turn_on( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we can turn on an air purifier.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 100, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "percentage": 66}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 75.0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_off", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + +async def test_air_purifier_turn_off( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we can turn an air purifier fan off.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "turn_off", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) + + +async def test_air_purifier_set_speed( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set air purifier fan speed.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 100, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 66}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 75.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 0}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) + + +async def test_air_purifier_set_percentage( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set air purifier fan speed by percentage.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 75}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 75, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 0}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) + + +async def test_air_purifier_set_preset_mode( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set preset mode when target state is available.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 100.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_preset_mode", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: 1, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 25.0, + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: 0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: 1, + }, + ) + + async def test_migrate_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry,