diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index b3c55ba36a9..c3af1033148 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -25,6 +25,7 @@ HOMEKIT_ACCESSORY_DISPATCH = { "motion": "binary_sensor", "carbon-dioxide": "sensor", "humidity": "sensor", + "humidifier-dehumidifier": "humidifier", "light": "sensor", "temperature": "sensor", "battery": "sensor", diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py new file mode 100644 index 00000000000..10ffee198e4 --- /dev/null +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -0,0 +1,304 @@ +"""Support for HomeKit Controller humidifier.""" +from typing import List, Optional + +from aiohomekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.humidifier import HumidifierEntity +from homeassistant.components.humidifier.const import ( + DEVICE_CLASS_DEHUMIDIFIER, + DEVICE_CLASS_HUMIDIFIER, + MODE_AUTO, + MODE_NORMAL, + SUPPORT_MODES, +) +from homeassistant.core import callback + +from . import KNOWN_DEVICES, HomeKitEntity + +SUPPORT_FLAGS = 0 + +HK_MODE_TO_HA = { + 0: "off", + 1: MODE_AUTO, + 2: "humidifying", + 3: "dehumidifying", +} + +HA_MODE_TO_HK = { + MODE_AUTO: 0, + "humidifying": 1, + "dehumidifying": 2, +} + + +class HomeKitHumidifier(HomeKitEntity, HumidifierEntity): + """Representation of a HomeKit Controller Humidifier.""" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.ACTIVE, + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT, + CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE, + CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE, + CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD, + ] + + @property + def device_class(self) -> str: + """Return the device class of the device.""" + return DEVICE_CLASS_HUMIDIFIER + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS | SUPPORT_MODES + + @property + def is_on(self): + """Return true if device is on.""" + return self.service.value(CharacteristicsTypes.ACTIVE) + + async def async_turn_on(self, **kwargs): + """Turn the specified valve on.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: True}) + + async def async_turn_off(self, **kwargs): + """Turn the specified valve off.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: False}) + + @property + def target_humidity(self) -> Optional[int]: + """Return the humidity we try to reach.""" + return self.service.value( + CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD + ) + + @property + def mode(self) -> Optional[str]: + """Return the current mode, e.g., home, auto, baby. + + Requires SUPPORT_MODES. + """ + mode = self.service.value( + CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE + ) + return MODE_AUTO if mode == 1 else MODE_NORMAL + + @property + def available_modes(self) -> Optional[List[str]]: + """Return a list of available modes. + + Requires SUPPORT_MODES. + """ + available_modes = [ + MODE_NORMAL, + MODE_AUTO, + ] + + return available_modes + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + await self.async_put_characteristics( + {CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD: humidity} + ) + + async def async_set_mode(self, mode: str) -> None: + """Set new mode.""" + if mode == MODE_AUTO: + await self.async_put_characteristics( + { + CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE: 0, + CharacteristicsTypes.ACTIVE: True, + } + ) + else: + await self.async_put_characteristics( + { + CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE: 1, + CharacteristicsTypes.ACTIVE: True, + } + ) + + @property + def min_humidity(self) -> int: + """Return the minimum humidity.""" + return self.service[ + CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD + ].minValue + + @property + def max_humidity(self) -> int: + """Return the maximum humidity.""" + return self.service[ + CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD + ].maxValue + + +class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): + """Representation of a HomeKit Controller Humidifier.""" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.ACTIVE, + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT, + CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE, + CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE, + CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD, + CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD, + ] + + @property + def device_class(self) -> str: + """Return the device class of the device.""" + return DEVICE_CLASS_DEHUMIDIFIER + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS | SUPPORT_MODES + + @property + def is_on(self): + """Return true if device is on.""" + return self.service.value(CharacteristicsTypes.ACTIVE) + + async def async_turn_on(self, **kwargs): + """Turn the specified valve on.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: True}) + + async def async_turn_off(self, **kwargs): + """Turn the specified valve off.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: False}) + + @property + def target_humidity(self) -> Optional[int]: + """Return the humidity we try to reach.""" + return self.service.value( + CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD + ) + + @property + def mode(self) -> Optional[str]: + """Return the current mode, e.g., home, auto, baby. + + Requires SUPPORT_MODES. + """ + mode = self.service.value( + CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE + ) + return MODE_AUTO if mode == 1 else MODE_NORMAL + + @property + def available_modes(self) -> Optional[List[str]]: + """Return a list of available modes. + + Requires SUPPORT_MODES. + """ + available_modes = [ + MODE_NORMAL, + MODE_AUTO, + ] + + return available_modes + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + await self.async_put_characteristics( + {CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD: humidity} + ) + + async def async_set_mode(self, mode: str) -> None: + """Set new mode.""" + if mode == MODE_AUTO: + await self.async_put_characteristics( + { + CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE: 0, + CharacteristicsTypes.ACTIVE: True, + } + ) + else: + await self.async_put_characteristics( + { + CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE: 2, + CharacteristicsTypes.ACTIVE: True, + } + ) + + @property + def min_humidity(self) -> int: + """Return the minimum humidity.""" + return self.service[ + CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD + ].minValue + + @property + def max_humidity(self) -> int: + """Return the maximum humidity.""" + return self.service[ + CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD + ].maxValue + + @property + def unique_id(self) -> str: + """Return the ID of this device.""" + serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) + return f"homekit-{serial}-{self._iid}-{self.device_class}" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit humidifer.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + def get_accessory(conn, aid): + for acc in conn.accessories: + if acc.get("aid") == aid: + return acc + return None + + def get_service(acc, iid): + for serv in acc.get("services"): + if serv.get("iid") == iid: + return serv + return None + + def get_char(serv, iid): + try: + type_name = CharacteristicsTypes[iid] + type_uuid = CharacteristicsTypes.get_uuid(type_name) + for char in serv.get("characteristics"): + if char.get("type") == type_uuid: + return char + except KeyError: + return None + return None + + @callback + def async_add_service(aid, service): + if service["stype"] != "humidifier-dehumidifier": + return False + info = {"aid": aid, "iid": service["iid"]} + + acc = get_accessory(conn, aid) + serv = get_service(acc, service["iid"]) + + if ( + get_char(serv, CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD) + is not None + ): + async_add_entities([HomeKitHumidifier(conn, info)], True) + + if ( + get_char( + serv, CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD + ) + is not None + ): + async_add_entities([HomeKitDehumidifier(conn, info)], True) + + return True + + conn.add_listener(async_add_service) diff --git a/tests/components/homekit_controller/test_humidifier.py b/tests/components/homekit_controller/test_humidifier.py new file mode 100644 index 00000000000..0af795e2ce9 --- /dev/null +++ b/tests/components/homekit_controller/test_humidifier.py @@ -0,0 +1,333 @@ +"""Basic checks for HomeKit Humidifier/Dehumidifier.""" +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + +from homeassistant.components.humidifier import DOMAIN +from homeassistant.components.humidifier.const import MODE_AUTO, MODE_NORMAL + +from tests.components.homekit_controller.common import setup_test_component + +ACTIVE = ("humidifier-dehumidifier", "active") +CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE = ( + "humidifier-dehumidifier", + "humidifier-dehumidifier.state.current", +) +TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE = ( + "humidifier-dehumidifier", + "humidifier-dehumidifier.state.target", +) +RELATIVE_HUMIDITY_CURRENT = ("humidifier-dehumidifier", "relative-humidity.current") +RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD = ( + "humidifier-dehumidifier", + "relative-humidity.humidifier-threshold", +) +RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD = ( + "humidifier-dehumidifier", + "relative-humidity.dehumidifier-threshold", +) + + +def create_humidifier_service(accessory): + """Define a humidifier characteristics as per page 219 of HAP spec.""" + service = accessory.add_service(ServicesTypes.HUMIDIFIER_DEHUMIDIFIER) + + service.add_char(CharacteristicsTypes.ACTIVE, value=False) + + cur_state = service.add_char(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) + cur_state.value = 0 + + cur_state = service.add_char( + CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE + ) + cur_state.value = -1 + + targ_state = service.add_char( + CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE + ) + targ_state.value = 0 + + cur_state = service.add_char( + CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD + ) + cur_state.value = 0 + + return service + + +def create_dehumidifier_service(accessory): + """Define a dehumidifier characteristics as per page 219 of HAP spec.""" + service = accessory.add_service(ServicesTypes.HUMIDIFIER_DEHUMIDIFIER) + + service.add_char(CharacteristicsTypes.ACTIVE, value=False) + + cur_state = service.add_char(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) + cur_state.value = 0 + + cur_state = service.add_char( + CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE + ) + cur_state.value = -1 + + targ_state = service.add_char( + CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE + ) + targ_state.value = 0 + + targ_state = service.add_char( + CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD + ) + targ_state.value = 0 + + return service + + +async def test_humidifier_active_state(hass, utcnow): + """Test that we can turn a HomeKit humidifier on and off again.""" + helper = await setup_test_component(hass, create_humidifier_service) + + await hass.services.async_call( + DOMAIN, "turn_on", {"entity_id": helper.entity_id}, blocking=True + ) + + assert helper.characteristics[ACTIVE].value == 1 + + await hass.services.async_call( + DOMAIN, "turn_off", {"entity_id": helper.entity_id}, blocking=True + ) + + assert helper.characteristics[ACTIVE].value == 0 + + +async def test_dehumidifier_active_state(hass, utcnow): + """Test that we can turn a HomeKit dehumidifier on and off again.""" + helper = await setup_test_component(hass, create_dehumidifier_service) + + await hass.services.async_call( + DOMAIN, "turn_on", {"entity_id": helper.entity_id}, blocking=True + ) + + assert helper.characteristics[ACTIVE].value == 1 + + await hass.services.async_call( + DOMAIN, "turn_off", {"entity_id": helper.entity_id}, blocking=True + ) + + assert helper.characteristics[ACTIVE].value == 0 + + +async def test_humidifier_read_humidity(hass, utcnow): + """Test that we can read the state of a HomeKit humidifier accessory.""" + helper = await setup_test_component(hass, create_humidifier_service) + + helper.characteristics[ACTIVE].value = True + helper.characteristics[RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD].value = 75 + state = await helper.poll_and_get_state() + assert state.state == "on" + assert state.attributes["humidity"] == 75 + + helper.characteristics[ACTIVE].value = False + helper.characteristics[RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD].value = 10 + state = await helper.poll_and_get_state() + assert state.state == "off" + assert state.attributes["humidity"] == 10 + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 3 + state = await helper.poll_and_get_state() + assert state.attributes["humidity"] == 10 + + +async def test_dehumidifier_read_humidity(hass, utcnow): + """Test that we can read the state of a HomeKit dehumidifier accessory.""" + helper = await setup_test_component(hass, create_dehumidifier_service) + + helper.characteristics[ACTIVE].value = True + helper.characteristics[RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD].value = 75 + state = await helper.poll_and_get_state() + assert state.state == "on" + assert state.attributes["humidity"] == 75 + + helper.characteristics[ACTIVE].value = False + helper.characteristics[RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD].value = 40 + state = await helper.poll_and_get_state() + assert state.state == "off" + assert state.attributes["humidity"] == 40 + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 2 + state = await helper.poll_and_get_state() + assert state.attributes["humidity"] == 40 + + +async def test_humidifier_set_humidity(hass, utcnow): + """Test that we can set the state of a HomeKit humidifier accessory.""" + helper = await setup_test_component(hass, create_humidifier_service) + + await hass.services.async_call( + DOMAIN, + "set_humidity", + {"entity_id": helper.entity_id, "humidity": 20}, + blocking=True, + ) + assert helper.characteristics[RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD].value == 20 + + +async def test_dehumidifier_set_humidity(hass, utcnow): + """Test that we can set the state of a HomeKit dehumidifier accessory.""" + helper = await setup_test_component(hass, create_dehumidifier_service) + + await hass.services.async_call( + DOMAIN, + "set_humidity", + {"entity_id": helper.entity_id, "humidity": 20}, + blocking=True, + ) + assert helper.characteristics[RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD].value == 20 + + +async def test_humidifier_set_mode(hass, utcnow): + """Test that we can set the mode of a HomeKit humidifier accessory.""" + helper = await setup_test_component(hass, create_humidifier_service) + + await hass.services.async_call( + DOMAIN, + "set_mode", + {"entity_id": helper.entity_id, "mode": MODE_AUTO}, + blocking=True, + ) + assert helper.characteristics[TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE].value == 0 + assert helper.characteristics[ACTIVE].value == 1 + + await hass.services.async_call( + DOMAIN, + "set_mode", + {"entity_id": helper.entity_id, "mode": MODE_NORMAL}, + blocking=True, + ) + assert helper.characteristics[TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE].value == 1 + assert helper.characteristics[ACTIVE].value == 1 + + +async def test_dehumidifier_set_mode(hass, utcnow): + """Test that we can set the mode of a HomeKit dehumidifier accessory.""" + helper = await setup_test_component(hass, create_dehumidifier_service) + + await hass.services.async_call( + DOMAIN, + "set_mode", + {"entity_id": helper.entity_id, "mode": MODE_AUTO}, + blocking=True, + ) + assert helper.characteristics[TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE].value == 0 + assert helper.characteristics[ACTIVE].value == 1 + + await hass.services.async_call( + DOMAIN, + "set_mode", + {"entity_id": helper.entity_id, "mode": MODE_NORMAL}, + blocking=True, + ) + assert helper.characteristics[TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE].value == 2 + assert helper.characteristics[ACTIVE].value == 1 + + +async def test_humidifier_read_only_mode(hass, utcnow): + """Test that we can read the state of a HomeKit humidifier accessory.""" + helper = await setup_test_component(hass, create_humidifier_service) + + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "normal" + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 0 + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "normal" + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 1 + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "auto" + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 2 + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "normal" + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 3 + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "normal" + + +async def test_dehumidifier_read_only_mode(hass, utcnow): + """Test that we can read the state of a HomeKit dehumidifier accessory.""" + helper = await setup_test_component(hass, create_dehumidifier_service) + + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "normal" + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 0 + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "normal" + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 1 + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "auto" + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 2 + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "normal" + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 3 + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "normal" + + +async def test_humidifier_target_humidity_modes(hass, utcnow): + """Test that we can read the state of a HomeKit humidifier accessory.""" + helper = await setup_test_component(hass, create_humidifier_service) + + helper.characteristics[RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD].value = 37 + helper.characteristics[RELATIVE_HUMIDITY_CURRENT].value = 51 + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 1 + + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "auto" + assert state.attributes["humidity"] == 37 + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 3 + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "normal" + assert state.attributes["humidity"] == 37 + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 2 + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "normal" + assert state.attributes["humidity"] == 37 + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 0 + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "normal" + assert state.attributes["humidity"] == 37 + + +async def test_dehumidifier_target_humidity_modes(hass, utcnow): + """Test that we can read the state of a HomeKit dehumidifier accessory.""" + helper = await setup_test_component(hass, create_dehumidifier_service) + + helper.characteristics[RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD].value = 73 + helper.characteristics[RELATIVE_HUMIDITY_CURRENT].value = 51 + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 1 + + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "auto" + assert state.attributes["humidity"] == 73 + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 3 + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "normal" + assert state.attributes["humidity"] == 73 + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 2 + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "normal" + assert state.attributes["humidity"] == 73 + + helper.characteristics[CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE].value = 0 + state = await helper.poll_and_get_state() + assert state.attributes["mode"] == "normal" + assert state.attributes["humidity"] == 73