diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index cde9aa732c3..e5ef4b14448 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -101,6 +101,7 @@ CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.MUTE: "switch", CharacteristicsTypes.FILTER_LIFE_LEVEL: "sensor", CharacteristicsTypes.VENDOR_AIRVERSA_SLEEP_MODE: "switch", + CharacteristicsTypes.TEMPERATURE_UNITS: "select", } STARTUP_EXCEPTIONS = ( diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index c99142da475..877c687f33e 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.0.3"], + "requirements": ["aiohomekit==3.0.4"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/select.py b/homeassistant/components/homekit_controller/select.py index 76067aea061..09bb57923c6 100644 --- a/homeassistant/components/homekit_controller/select.py +++ b/homeassistant/components/homekit_controller/select.py @@ -1,18 +1,54 @@ """Support for Homekit select entities.""" from __future__ import annotations -from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes +from dataclasses import dataclass +from enum import IntEnum -from homeassistant.components.select import SelectEntity +from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes +from aiohomekit.model.characteristics.const import TemperatureDisplayUnits + +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import KNOWN_DEVICES from .connection import HKDevice from .entity import CharacteristicEntity + +@dataclass +class HomeKitSelectEntityDescriptionRequired: + """Required fields for HomeKitSelectEntityDescription.""" + + choices: dict[str, IntEnum] + + +@dataclass +class HomeKitSelectEntityDescription( + SelectEntityDescription, HomeKitSelectEntityDescriptionRequired +): + """A generic description of a select entity backed by a single characteristic.""" + + name: str | None = None + + +SELECT_ENTITIES: dict[str, HomeKitSelectEntityDescription] = { + CharacteristicsTypes.TEMPERATURE_UNITS: HomeKitSelectEntityDescription( + key="temperature_display_units", + translation_key="temperature_display_units", + name="Temperature Display Units", + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + choices={ + "celsius": TemperatureDisplayUnits.CELSIUS, + "fahrenheit": TemperatureDisplayUnits.FAHRENHEIT, + }, + ), +} + _ECOBEE_MODE_TO_TEXT = { 0: "home", 1: "sleep", @@ -21,7 +57,58 @@ _ECOBEE_MODE_TO_TEXT = { _ECOBEE_MODE_TO_NUMBERS = {v: k for (k, v) in _ECOBEE_MODE_TO_TEXT.items()} -class EcobeeModeSelect(CharacteristicEntity, SelectEntity): +class BaseHomeKitSelect(CharacteristicEntity, SelectEntity): + """Base entity for select entities backed by a single characteristics.""" + + +class HomeKitSelect(BaseHomeKitSelect): + """Representation of a select control on a homekit accessory.""" + + entity_description: HomeKitSelectEntityDescription + + def __init__( + self, + conn: HKDevice, + info: ConfigType, + char: Characteristic, + description: HomeKitSelectEntityDescription, + ) -> None: + """Initialise a HomeKit select control.""" + self.entity_description = description + + self._choice_to_enum = self.entity_description.choices + self._enum_to_choice = { + v: k for (k, v) in self.entity_description.choices.items() + } + + self._attr_options = list(self.entity_description.choices.keys()) + + super().__init__(conn, info, char) + + def get_characteristic_types(self) -> list[str]: + """Define the homekit characteristics the entity cares about.""" + return [self._char.type] + + @property + def name(self) -> str | None: + """Return the name of the device if any.""" + if name := self.accessory.name: + return f"{name} {self.entity_description.name}" + return self.entity_description.name + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + return self._enum_to_choice.get(self._char.value) + + async def async_select_option(self, option: str) -> None: + """Set the current option.""" + await self.async_put_characteristics( + {self._char.type: self._choice_to_enum[option]} + ) + + +class EcobeeModeSelect(BaseHomeKitSelect): """Represents a ecobee mode select entity.""" _attr_options = ["home", "sleep", "away"] @@ -64,14 +151,23 @@ async def async_setup_entry( @callback def async_add_characteristic(char: Characteristic) -> bool: - if char.type == CharacteristicsTypes.VENDOR_ECOBEE_CURRENT_MODE: - info = {"aid": char.service.accessory.aid, "iid": char.service.iid} - entity = EcobeeModeSelect(conn, info, char) + entities: list[BaseHomeKitSelect] = [] + info = {"aid": char.service.accessory.aid, "iid": char.service.iid} + + if description := SELECT_ENTITIES.get(char.type): + entities.append(HomeKitSelect(conn, info, char, description)) + elif char.type == CharacteristicsTypes.VENDOR_ECOBEE_CURRENT_MODE: + entities.append(EcobeeModeSelect(conn, info, char)) + + if not entities: + return False + + for entity in entities: conn.async_migrate_unique_id( entity.old_unique_id, entity.unique_id, Platform.SELECT ) - async_add_entities([entity]) - return True - return False + + async_add_entities(entities) + return True conn.add_char_factory(async_add_characteristic) diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index 901378c8cb9..bc61b6fd42e 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -102,6 +102,12 @@ "home": "[%key:common::state::home%]", "sleep": "Sleep" } + }, + "temperature_display_units": { + "state": { + "celsius": "Celsius", + "fahrenheit": "Fahrenheit" + } } }, "sensor": { diff --git a/requirements_all.txt b/requirements_all.txt index 4111319c2d5..05844f2ba20 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -250,7 +250,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.3 +aiohomekit==3.0.4 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c49f2b1ee57..a3f898342af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -228,7 +228,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.3 +aiohomekit==3.0.4 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/tests/components/homekit_controller/test_select.py b/tests/components/homekit_controller/test_select.py index d9feebafc76..9cfa0bccda3 100644 --- a/tests/components/homekit_controller/test_select.py +++ b/tests/components/homekit_controller/test_select.py @@ -1,6 +1,7 @@ """Basic checks for HomeKit select entities.""" from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics.const import TemperatureDisplayUnits from aiohomekit.model.services import ServicesTypes from homeassistant.core import HomeAssistant @@ -22,6 +23,16 @@ def create_service_with_ecobee_mode(accessory: Accessory): return service +def create_service_with_temperature_units(accessory: Accessory): + """Define a thermostat with ecobee mode characteristics.""" + service = accessory.add_service(ServicesTypes.TEMPERATURE_SENSOR, add_required=True) + + units = service.add_char(CharacteristicsTypes.TEMPERATURE_UNITS) + units.value = 0 + + return service + + async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: """Test we can migrate a select unique id.""" entity_registry = er.async_get(hass) @@ -125,3 +136,76 @@ async def test_write_current_mode(hass: HomeAssistant, utcnow) -> None: ServicesTypes.THERMOSTAT, {CharacteristicsTypes.VENDOR_ECOBEE_SET_HOLD_SCHEDULE: 2}, ) + + +async def test_read_select(hass: HomeAssistant, utcnow) -> None: + """Test the generic select can read the current value.""" + helper = await setup_test_component(hass, create_service_with_temperature_units) + + # Helper will be for the primary entity, which is the service. Make a helper for the sensor. + select_entity = Helper( + hass, + "select.testdevice_temperature_display_units", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + state = await select_entity.async_update( + ServicesTypes.TEMPERATURE_SENSOR, + { + CharacteristicsTypes.TEMPERATURE_UNITS: 0, + }, + ) + assert state.state == "celsius" + + state = await select_entity.async_update( + ServicesTypes.TEMPERATURE_SENSOR, + { + CharacteristicsTypes.TEMPERATURE_UNITS: 1, + }, + ) + assert state.state == "fahrenheit" + + +async def test_write_select(hass: HomeAssistant, utcnow) -> None: + """Test can set a value.""" + helper = await setup_test_component(hass, create_service_with_temperature_units) + helper.accessory.services.first(service_type=ServicesTypes.THERMOSTAT) + + # Helper will be for the primary entity, which is the service. Make a helper for the sensor. + current_mode = Helper( + hass, + "select.testdevice_temperature_display_units", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.testdevice_temperature_display_units", + "option": "fahrenheit", + }, + blocking=True, + ) + current_mode.async_assert_service_values( + ServicesTypes.TEMPERATURE_SENSOR, + {CharacteristicsTypes.TEMPERATURE_UNITS: TemperatureDisplayUnits.FAHRENHEIT}, + ) + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.testdevice_temperature_display_units", + "option": "celsius", + }, + blocking=True, + ) + current_mode.async_assert_service_values( + ServicesTypes.TEMPERATURE_SENSOR, + {CharacteristicsTypes.TEMPERATURE_UNITS: TemperatureDisplayUnits.CELSIUS}, + )