From b8086f3c21537309f4e39aeec6b28a533723c85e Mon Sep 17 00:00:00 2001 From: Marco Garzola Date: Mon, 21 Aug 2023 17:10:24 +0200 Subject: [PATCH] Map heatercooler rotation speed as 3 level fan speed in homekit controller (#98291) Co-authored-by: J. Nick Koston --- .../components/homekit_controller/climate.py | 58 +++++++ .../fixtures/homespan_daikin_bridge.json | 161 ++++++++++++++++++ .../test_homespan_daikin_bridge.py | 51 ++++++ .../homekit_controller/test_climate.py | 100 +++++++++++ 4 files changed, 370 insertions(+) create mode 100644 tests/components/homekit_controller/fixtures/homespan_daikin_bridge.json create mode 100644 tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index df43d8929e9..d3e9a0f13a6 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -23,6 +23,10 @@ from homeassistant.components.climate import ( DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, FAN_ON, SWING_OFF, SWING_VERTICAL, @@ -35,6 +39,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) from . import KNOWN_DEVICES from .connection import HKDevice @@ -86,6 +94,16 @@ SWING_MODE_HASS_TO_HOMEKIT = {v: k for k, v in SWING_MODE_HOMEKIT_TO_HASS.items( DEFAULT_MIN_STEP: Final = 1.0 +ROTATION_SPEED_LOW = 33 +ROTATION_SPEED_MEDIUM = 66 +ROTATION_SPEED_HIGH = 100 + +HASS_FAN_MODE_TO_HOMEKIT_ROTATION = { + FAN_LOW: ROTATION_SPEED_LOW, + FAN_MEDIUM: ROTATION_SPEED_MEDIUM, + FAN_HIGH: ROTATION_SPEED_HIGH, +} + async def async_setup_entry( hass: HomeAssistant, @@ -170,8 +188,45 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD, CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD, CharacteristicsTypes.SWING_MODE, + CharacteristicsTypes.ROTATION_SPEED, ] + def _get_rotation_speed_range(self) -> tuple[float, float]: + rotation_speed = self.service[CharacteristicsTypes.ROTATION_SPEED] + return round(rotation_speed.minValue or 0) + 1, round( + rotation_speed.maxValue or 100 + ) + + @property + def fan_modes(self) -> list[str]: + """Return the available fan modes.""" + return [FAN_OFF, FAN_LOW, FAN_MEDIUM, FAN_HIGH] + + @property + def fan_mode(self) -> str | None: + """Return the current fan mode.""" + speed_range = self._get_rotation_speed_range() + speed_percentage = ranged_value_to_percentage( + speed_range, self.service.value(CharacteristicsTypes.ROTATION_SPEED) + ) + # homekit value 0 33 66 100 + if speed_percentage > ROTATION_SPEED_MEDIUM: + return FAN_HIGH + if speed_percentage > ROTATION_SPEED_LOW: + return FAN_MEDIUM + if speed_percentage > 0: + return FAN_LOW + return FAN_OFF + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + rotation = HASS_FAN_MODE_TO_HOMEKIT_ROTATION.get(fan_mode, 0) + speed_range = self._get_rotation_speed_range() + speed = round(percentage_to_ranged_value(speed_range, rotation)) + await self.async_put_characteristics( + {CharacteristicsTypes.ROTATION_SPEED: speed} + ) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temp = kwargs.get(ATTR_TEMPERATURE) @@ -387,6 +442,9 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): if self.service.has(CharacteristicsTypes.SWING_MODE): features |= ClimateEntityFeature.SWING_MODE + if self.service.has(CharacteristicsTypes.ROTATION_SPEED): + features |= ClimateEntityFeature.FAN_MODE + return features diff --git a/tests/components/homekit_controller/fixtures/homespan_daikin_bridge.json b/tests/components/homekit_controller/fixtures/homespan_daikin_bridge.json new file mode 100644 index 00000000000..b3dd6f8a84e --- /dev/null +++ b/tests/components/homekit_controller/fixtures/homespan_daikin_bridge.json @@ -0,0 +1,161 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr", "ev"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000053-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Hardware Revision", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "Garzola Marco", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pr"], + "format": "string", + "value": "Daikin-fwec3a-esp32-homekit-bridge", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 7, + "perms": ["pr"], + "format": "string", + "value": "Air Conditioner", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "00000001", + "description": "Serial Number", + "maxLen": 64 + } + ] + }, + { + "iid": 9, + "type": "000000BC-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "000000B0-0000-1000-8000-0026BB765291", + "iid": 10, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 1, + "description": "Active" + }, + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 11, + "perms": ["pr", "ev"], + "format": "float", + "value": 27.9, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 99, + "minStep": 0.5 + }, + { + "type": "000000B1-0000-1000-8000-0026BB765291", + "iid": 12, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 3, + "description": "Current Heater Cooler State" + }, + { + "type": "000000B2-0000-1000-8000-0026BB765291", + "iid": 13, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 2, + "description": "Target Heater Cooler State" + }, + { + "type": "0000000D-0000-1000-8000-0026BB765291", + "iid": 14, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 24.5, + "description": "Cooling Threshold Temperature", + "unit": "celsius", + "minValue": 18, + "maxValue": 32, + "minStep": 0.5 + }, + { + "type": "00000012-0000-1000-8000-0026BB765291", + "iid": 15, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 24.5, + "description": "Heating Threshold Temperature", + "unit": "celsius", + "minValue": 13, + "maxValue": 27, + "minStep": 0.5 + }, + { + "type": "00000029-0000-1000-8000-0026BB765291", + "iid": 16, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 100, + "description": "Rotation Speed", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 17, + "perms": ["pr"], + "format": "string", + "value": "SlaveID 1", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py b/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py new file mode 100644 index 00000000000..5bb7003e58b --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py @@ -0,0 +1,51 @@ +"""Tests for handling accessories on a Homespan esp32 daikin bridge.""" +from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.core import HomeAssistant + +from ..common import ( + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_homespan_daikin_bridge_setup(hass: HomeAssistant) -> None: + """Test that aHomespan esp32 daikin bridge can be correctly setup in HA via HomeKit.""" + accessories = await setup_accessories_from_file(hass, "homespan_daikin_bridge.json") + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Air Conditioner", + model="Daikin-fwec3a-esp32-homekit-bridge", + manufacturer="Garzola Marco", + sw_version="1.0.0", + hw_version="1.0.0", + serial_number="00000001", + devices=[], + entities=[ + EntityTestInfo( + entity_id="climate.air_conditioner_slaveid_1", + friendly_name="Air Conditioner SlaveID 1", + unique_id="00:00:00:00:00:00_1_9", + supported_features=( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + ), + capabilities={ + "hvac_modes": ["heat_cool", "heat", "cool", "off"], + "min_temp": 18, + "max_temp": 32, + "target_temp_step": 0.5, + "fan_modes": ["off", "low", "medium", "high"], + }, + state="cool", + ), + ], + ), + ) diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 27c675b78ec..0f6a3633bd4 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -691,6 +691,9 @@ def create_heater_cooler_service(accessory): char = service.add_char(CharacteristicsTypes.SWING_MODE) char.value = 0 + char = service.add_char(CharacteristicsTypes.ROTATION_SPEED) + char.value = 100 + # Test heater-cooler devices def create_heater_cooler_service_min_max(accessory): @@ -867,6 +870,103 @@ async def test_heater_cooler_change_thermostat_temperature( ) +async def test_heater_cooler_change_fan_speed(hass: HomeAssistant, utcnow) -> None: + """Test that we can change the target fan speed.""" + helper = await setup_test_component(hass, create_heater_cooler_service) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.COOL}, + blocking=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + {"entity_id": "climate.testdevice", "fan_mode": "low"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 33, + }, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + {"entity_id": "climate.testdevice", "fan_mode": "medium"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 66, + }, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + {"entity_id": "climate.testdevice", "fan_mode": "high"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 100, + }, + ) + + +async def test_heater_cooler_read_fan_speed(hass: HomeAssistant, utcnow) -> None: + """Test that we can read the state of a HomeKit thermostat accessory.""" + helper = await setup_test_component(hass, create_heater_cooler_service) + + # Simulate that fan speed is off + await helper.async_update( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 0, + }, + ) + + state = await helper.poll_and_get_state() + assert state.attributes["fan_mode"] == "off" + + # Simulate that fan speed is low + await helper.async_update( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 33, + }, + ) + + state = await helper.poll_and_get_state() + assert state.attributes["fan_mode"] == "low" + + # Simulate that fan speed is medium + await helper.async_update( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 66, + }, + ) + + state = await helper.poll_and_get_state() + assert state.attributes["fan_mode"] == "medium" + + # Simulate that fan speed is high + await helper.async_update( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 100, + }, + ) + + state = await helper.poll_and_get_state() + assert state.attributes["fan_mode"] == "high" + + async def test_heater_cooler_read_thermostat_state(hass: HomeAssistant, utcnow) -> None: """Test that we can read the state of a HomeKit thermostat accessory.""" helper = await setup_test_component(hass, create_heater_cooler_service)