diff --git a/homeassistant/components/homekit_controller/button.py b/homeassistant/components/homekit_controller/button.py index efb13fc3496..8fe7d8e8c72 100644 --- a/homeassistant/components/homekit_controller/button.py +++ b/homeassistant/components/homekit_controller/button.py @@ -53,6 +53,7 @@ BUTTON_ENTITIES: dict[str, HomeKitButtonEntityDescription] = { ), } + # For legacy reasons, "built-in" characteristic types are in their short form # And vendor types don't have a short form # This means long and short forms get mixed up in this dict, and comparisons @@ -74,10 +75,17 @@ async def async_setup_entry( @callback def async_add_characteristic(char: Characteristic): - if not (description := BUTTON_ENTITIES.get(char.type)): - return False + entities = [] info = {"aid": char.service.accessory.aid, "iid": char.service.iid} - async_add_entities([HomeKitButton(conn, info, char, description)], True) + + if description := BUTTON_ENTITIES.get(char.type): + entities.append(HomeKitButton(conn, info, char, description)) + elif entity_type := BUTTON_ENTITY_CLASSES.get(char.type): + entities.append(entity_type(conn, info, char)) + else: + return False + + async_add_entities(entities, True) return True conn.add_char_factory(async_add_characteristic) @@ -115,3 +123,37 @@ class HomeKitButton(CharacteristicEntity, ButtonEntity): key = self.entity_description.key val = self.entity_description.write_value return await self.async_put_characteristics({key: val}) + + +class HomeKitEcobeeClearHoldButton(CharacteristicEntity, ButtonEntity): + """Representation of a Button control for Ecobee clear hold request.""" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [] + + @property + def name(self) -> str: + """Return the name of the device if any.""" + prefix = "" + if name := super().name: + prefix = name + return f"{prefix} Clear Hold" + + async def async_press(self) -> None: + """Press the button.""" + key = self._char.type + + # If we just send true, the request doesn't always get executed by ecobee. + # Sending false value then true value will ensure that the hold gets cleared + # and schedule resumed. + # Ecobee seems to cache the state and not update it correctly, which + # causes the request to be ignored if it thinks it has no effect. + + for val in (False, True): + await self.async_put_characteristics({key: val}) + + +BUTTON_ENTITY_CLASSES: dict[str, type] = { + CharacteristicsTypes.Vendor.ECOBEE_CLEAR_HOLD: HomeKitEcobeeClearHoldButton, +} diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 8c20afcd06f..d88f896af31 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -74,6 +74,9 @@ CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY_2: "sensor", CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: "number", CharacteristicsTypes.Vendor.VOCOLINC_OUTLET_ENERGY: "sensor", + CharacteristicsTypes.Vendor.ECOBEE_CLEAR_HOLD: "button", + CharacteristicsTypes.Vendor.ECOBEE_FAN_WRITE_SPEED: "number", + CharacteristicsTypes.Vendor.ECOBEE_SET_HOLD_SCHEDULE: "number", CharacteristicsTypes.TEMPERATURE_CURRENT: "sensor", CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT: "sensor", CharacteristicsTypes.AIR_QUALITY: "sensor", diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py index 9c76adf52a9..135124cb207 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -91,10 +91,17 @@ async def async_setup_entry( @callback def async_add_characteristic(char: Characteristic): - if not (description := NUMBER_ENTITIES.get(char.type)): - return False + entities = [] info = {"aid": char.service.accessory.aid, "iid": char.service.iid} - async_add_entities([HomeKitNumber(conn, info, char, description)], True) + + if description := NUMBER_ENTITIES.get(char.type): + entities.append(HomeKitNumber(conn, info, char, description)) + elif entity_type := NUMBER_ENTITY_CLASSES.get(char.type): + entities.append(entity_type(conn, info, char)) + else: + return False + + async_add_entities(entities, True) return True conn.add_char_factory(async_add_characteristic) @@ -152,3 +159,72 @@ class HomeKitNumber(CharacteristicEntity, NumberEntity): self._char.type: value, } ) + + +class HomeKitEcobeeFanModeNumber(CharacteristicEntity, NumberEntity): + """Representation of a Number control for Ecobee Fan Mode request.""" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [self._char.type] + + @property + def name(self) -> str: + """Return the name of the device if any.""" + prefix = "" + if name := super().name: + prefix = name + return f"{prefix} Fan Mode" + + @property + def min_value(self) -> float: + """Return the minimum value.""" + return self._char.minValue + + @property + def max_value(self) -> float: + """Return the maximum value.""" + return self._char.maxValue + + @property + def step(self) -> float: + """Return the increment/decrement step.""" + return self._char.minStep + + @property + def value(self) -> float: + """Return the current characteristic value.""" + return self._char.value + + async def async_set_value(self, value: float): + """Set the characteristic to this value.""" + + # Sending the fan mode request sometimes ends up getting ignored by ecobee + # and this might be because it the older value instead of newer, and ecobee + # thinks there is nothing to do. + # So in order to make sure that the request is executed by ecobee, we need + # to send a different value before sending the target value. + # Fan mode value is a value from 0 to 100. We send a value off by 1 first. + + if value > self.min_value: + other_value = value - 1 + else: + other_value = self.min_value + 1 + + if value != other_value: + await self.async_put_characteristics( + { + self._char.type: other_value, + } + ) + + await self.async_put_characteristics( + { + self._char.type: value, + } + ) + + +NUMBER_ENTITY_CLASSES: dict[str, type] = { + CharacteristicsTypes.Vendor.ECOBEE_FAN_WRITE_SPEED: HomeKitEcobeeFanModeNumber, +} diff --git a/tests/components/homekit_controller/test_button.py b/tests/components/homekit_controller/test_button.py index 020f303ffaa..c501a9e6fb0 100644 --- a/tests/components/homekit_controller/test_button.py +++ b/tests/components/homekit_controller/test_button.py @@ -20,6 +20,21 @@ def create_switch_with_setup_button(accessory): return service +def create_switch_with_ecobee_clear_hold_button(accessory): + """Define setup button characteristics.""" + service = accessory.add_service(ServicesTypes.OUTLET) + + setup = service.add_char(CharacteristicsTypes.Vendor.ECOBEE_CLEAR_HOLD) + + setup.value = "" + setup.format = "string" + + cur_state = service.add_char(CharacteristicsTypes.ON) + cur_state.value = True + + return service + + async def test_press_button(hass): """Test a switch service that has a button characteristic is correctly handled.""" helper = await setup_test_component(hass, create_switch_with_setup_button) @@ -43,3 +58,30 @@ async def test_press_button(hass): blocking=True, ) assert setup.value == "#HAA@trcmd" + + +async def test_ecobee_clear_hold_press_button(hass): + """Test ecobee clear hold button characteristic is correctly handled.""" + helper = await setup_test_component( + hass, create_switch_with_ecobee_clear_hold_button + ) + + # Helper will be for the primary entity, which is the outlet. Make a helper for the button. + energy_helper = Helper( + hass, + "button.testdevice_clear_hold", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + outlet = energy_helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + setup = outlet[CharacteristicsTypes.Vendor.ECOBEE_CLEAR_HOLD] + + await hass.services.async_call( + "button", + "press", + {"entity_id": "button.testdevice_clear_hold"}, + blocking=True, + ) + assert setup.value is True diff --git a/tests/components/homekit_controller/test_number.py b/tests/components/homekit_controller/test_number.py index 8eebcbda8f5..d83b4195241 100644 --- a/tests/components/homekit_controller/test_number.py +++ b/tests/components/homekit_controller/test_number.py @@ -25,6 +25,26 @@ def create_switch_with_spray_level(accessory): return service +def create_switch_with_ecobee_fan_mode(accessory): + """Define battery level characteristics.""" + service = accessory.add_service(ServicesTypes.OUTLET) + + ecobee_fan_mode = service.add_char( + CharacteristicsTypes.Vendor.ECOBEE_FAN_WRITE_SPEED + ) + + ecobee_fan_mode.value = 0 + ecobee_fan_mode.minStep = 1 + ecobee_fan_mode.minValue = 0 + ecobee_fan_mode.maxValue = 100 + ecobee_fan_mode.format = "float" + + cur_state = service.add_char(CharacteristicsTypes.ON) + cur_state.value = True + + return service + + async def test_read_number(hass, utcnow): """Test a switch service that has a sensor characteristic is correctly handled.""" helper = await setup_test_component(hass, create_switch_with_spray_level) @@ -85,3 +105,61 @@ async def test_write_number(hass, utcnow): blocking=True, ) assert spray_level.value == 3 + + +async def test_write_ecobee_fan_mode_number(hass, utcnow): + """Test a switch service that has a sensor characteristic is correctly handled.""" + helper = await setup_test_component(hass, create_switch_with_ecobee_fan_mode) + outlet = helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + + # Helper will be for the primary entity, which is the outlet. Make a helper for the sensor. + energy_helper = Helper( + hass, + "number.testdevice_fan_mode", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + outlet = energy_helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + ecobee_fan_mode = outlet[CharacteristicsTypes.Vendor.ECOBEE_FAN_WRITE_SPEED] + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.testdevice_fan_mode", "value": 1}, + blocking=True, + ) + assert ecobee_fan_mode.value == 1 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.testdevice_fan_mode", "value": 2}, + blocking=True, + ) + assert ecobee_fan_mode.value == 2 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.testdevice_fan_mode", "value": 99}, + blocking=True, + ) + assert ecobee_fan_mode.value == 99 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.testdevice_fan_mode", "value": 100}, + blocking=True, + ) + assert ecobee_fan_mode.value == 100 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.testdevice_fan_mode", "value": 0}, + blocking=True, + ) + assert ecobee_fan_mode.value == 0