diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index de774fbf500..10532c11da8 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -15,6 +15,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.components.http import HomeAssistantView +from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -25,6 +26,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PORT, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) @@ -53,6 +55,7 @@ from .const import ( CONF_FILTER, CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, + CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_SAFE_MODE, CONF_ZEROCONF_DEFAULT_INTERFACE, @@ -475,6 +478,7 @@ class HomeKit: (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_BATTERY_CHARGING), (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_MOTION), (SENSOR_DOMAIN, DEVICE_CLASS_BATTERY), + (SENSOR_DOMAIN, DEVICE_CLASS_HUMIDITY), } ) @@ -549,6 +553,7 @@ class HomeKit: type_sensors, type_switches, type_thermostats, + type_humidifiers, ) for state in bridged_states: @@ -618,6 +623,15 @@ class HomeKit: CONF_LINKED_MOTION_SENSOR, motion_binary_sensor_entity_id, ) + if state.entity_id.startswith(f"{HUMIDIFIER_DOMAIN}."): + current_humidity_sensor_entity_id = device_lookup[ + ent_reg_ent.device_id + ].get((SENSOR_DOMAIN, DEVICE_CLASS_HUMIDITY)) + if current_humidity_sensor_entity_id: + self._config.setdefault(state.entity_id, {}).setdefault( + CONF_LINKED_HUMIDITY_SENSOR, current_humidity_sensor_entity_id, + ) + async def _async_set_device_info_attributes(self, ent_reg_ent, dev_reg, entity_id): """Set attributes that will be used for homekit device info.""" ent_cfg = self._config.setdefault(entity_id, {}) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 0077f0bb018..65e9b7cc822 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -162,6 +162,9 @@ def get_accessory(hass, driver, state, aid, config): elif state.domain == "fan": a_type = "Fan" + elif state.domain == "humidifier": + a_type = "HumidifierDehumidifier" + elif state.domain == "light": a_type = "Light" diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 4cd6b9ffd78..2d0d2df40b7 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -47,6 +47,7 @@ SUPPORTED_DOMAINS = [ "demo", "device_tracker", "fan", + "humidifier", "input_boolean", "light", "lock", @@ -65,6 +66,7 @@ DEFAULT_DOMAINS = [ "alarm_control_panel", "climate", "cover", + "humidifier", "light", "lock", "media_player", diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 75a3ad5520b..ead5179b5dc 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -42,6 +42,7 @@ CONF_FILTER = "filter" CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor" CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor" CONF_LINKED_MOTION_SENSOR = "linked_motion_sensor" +CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor" CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold" CONF_MAX_FPS = "max_fps" CONF_MAX_HEIGHT = "max_height" @@ -111,6 +112,7 @@ SERV_CARBON_MONOXIDE_SENSOR = "CarbonMonoxideSensor" SERV_CONTACT_SENSOR = "ContactSensor" SERV_FANV2 = "Fanv2" SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" +SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier" SERV_HUMIDITY_SENSOR = "HumiditySensor" SERV_INPUT_SOURCE = "InputSource" SERV_LEAK_SENSOR = "LeakSensor" @@ -151,15 +153,18 @@ CHAR_COOLING_THRESHOLD_TEMPERATURE = "CoolingThresholdTemperature" CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel" CHAR_CURRENT_DOOR_STATE = "CurrentDoorState" CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState" +CHAR_CURRENT_HUMIDIFIER_DEHUMIDIFIER = "CurrentHumidifierDehumidifierState" CHAR_CURRENT_POSITION = "CurrentPosition" CHAR_CURRENT_HUMIDITY = "CurrentRelativeHumidity" CHAR_CURRENT_SECURITY_STATE = "SecuritySystemCurrentState" CHAR_CURRENT_TEMPERATURE = "CurrentTemperature" CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle" CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState" +CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY = "RelativeHumidityDehumidifierThreshold" CHAR_FIRMWARE_REVISION = "FirmwareRevision" CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature" CHAR_HUE = "Hue" +CHAR_HUMIDIFIER_THRESHOLD_HUMIDITY = "RelativeHumidityHumidifierThreshold" CHAR_IDENTIFIER = "Identifier" CHAR_IN_USE = "InUse" CHAR_INPUT_SOURCE_TYPE = "InputSourceType" @@ -190,6 +195,7 @@ CHAR_SWING_MODE = "SwingMode" CHAR_TARGET_DOOR_STATE = "TargetDoorState" CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState" CHAR_TARGET_POSITION = "TargetPosition" +CHAR_TARGET_HUMIDIFIER_DEHUMIDIFIER = "TargetHumidifierDehumidifierState" CHAR_TARGET_HUMIDITY = "TargetRelativeHumidity" CHAR_TARGET_SECURITY_STATE = "SecuritySystemTargetState" CHAR_TARGET_TEMPERATURE = "TargetTemperature" @@ -207,6 +213,7 @@ PROP_MAX_VALUE = "maxValue" PROP_MIN_VALUE = "minValue" PROP_MIN_STEP = "minStep" PROP_CELSIUS = {"minValue": -273, "maxValue": 999} +PROP_VALID_VALUES = "ValidValues" # #### Device Classes #### DEVICE_CLASS_CO = "co" diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py new file mode 100644 index 00000000000..f59015a392d --- /dev/null +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -0,0 +1,241 @@ +"""Class to hold all thermostat accessories.""" +import logging + +from pyhap.const import CATEGORY_HUMIDIFIER + +from homeassistant.components.humidifier.const import ( + ATTR_HUMIDITY, + ATTR_MAX_HUMIDITY, + ATTR_MIN_HUMIDITY, + DEFAULT_MAX_HUMIDITY, + DEFAULT_MIN_HUMIDITY, + DEVICE_CLASS_DEHUMIDIFIER, + DEVICE_CLASS_HUMIDIFIER, + DOMAIN, + SERVICE_SET_HUMIDITY, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, + UNIT_PERCENTAGE, +) +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_state_change_event + +from .accessories import TYPES, HomeAccessory +from .const import ( + CHAR_ACTIVE, + CHAR_CURRENT_HUMIDIFIER_DEHUMIDIFIER, + CHAR_CURRENT_HUMIDITY, + CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY, + CHAR_HUMIDIFIER_THRESHOLD_HUMIDITY, + CHAR_TARGET_HUMIDIFIER_DEHUMIDIFIER, + CONF_LINKED_HUMIDITY_SENSOR, + PROP_MAX_VALUE, + PROP_MIN_STEP, + PROP_MIN_VALUE, + SERV_HUMIDIFIER_DEHUMIDIFIER, +) + +_LOGGER = logging.getLogger(__name__) + +HC_HUMIDIFIER = 1 +HC_DEHUMIDIFIER = 2 + +HC_HASS_TO_HOMEKIT_DEVICE_CLASS = { + DEVICE_CLASS_HUMIDIFIER: HC_HUMIDIFIER, + DEVICE_CLASS_DEHUMIDIFIER: HC_DEHUMIDIFIER, +} + +HC_HASS_TO_HOMEKIT_DEVICE_CLASS_NAME = { + DEVICE_CLASS_HUMIDIFIER: "Humidifier", + DEVICE_CLASS_DEHUMIDIFIER: "Dehumidifier", +} + +HC_DEVICE_CLASS_TO_TARGET_CHAR = { + HC_HUMIDIFIER: CHAR_HUMIDIFIER_THRESHOLD_HUMIDITY, + HC_DEHUMIDIFIER: CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY, +} + +HC_STATE_INACTIVE = 0 +HC_STATE_IDLE = 1 +HC_STATE_HUMIDIFYING = 2 +HC_STATE_DEHUMIDIFYING = 3 + + +@TYPES.register("HumidifierDehumidifier") +class HumidifierDehumidifier(HomeAccessory): + """Generate a HumidifierDehumidifier accessory for a humidifier.""" + + def __init__(self, *args): + """Initialize a HumidifierDehumidifier accessory object.""" + super().__init__(*args, category=CATEGORY_HUMIDIFIER) + self.chars = [] + state = self.hass.states.get(self.entity_id) + device_class = state.attributes.get(ATTR_DEVICE_CLASS, DEVICE_CLASS_HUMIDIFIER) + self._hk_device_class = HC_HASS_TO_HOMEKIT_DEVICE_CLASS[device_class] + + self._target_humidity_char_name = HC_DEVICE_CLASS_TO_TARGET_CHAR[ + self._hk_device_class + ] + self.chars.append(self._target_humidity_char_name) + + serv_humidifier_dehumidifier = self.add_preload_service( + SERV_HUMIDIFIER_DEHUMIDIFIER, self.chars + ) + + # Current and target mode characteristics + self.char_current_humidifier_dehumidifier = serv_humidifier_dehumidifier.configure_char( + CHAR_CURRENT_HUMIDIFIER_DEHUMIDIFIER, value=0 + ) + self.char_target_humidifier_dehumidifier = serv_humidifier_dehumidifier.configure_char( + CHAR_TARGET_HUMIDIFIER_DEHUMIDIFIER, + value=self._hk_device_class, + valid_values={ + HC_HASS_TO_HOMEKIT_DEVICE_CLASS_NAME[ + device_class + ]: self._hk_device_class + }, + ) + + # Current and target humidity characteristics + self.char_current_humidity = serv_humidifier_dehumidifier.configure_char( + CHAR_CURRENT_HUMIDITY, value=0 + ) + + max_humidity = state.attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY) + max_humidity = round(max_humidity) + max_humidity = min(max_humidity, 100) + + min_humidity = state.attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) + min_humidity = round(min_humidity) + min_humidity = max(min_humidity, 0) + + self.char_target_humidity = serv_humidifier_dehumidifier.configure_char( + self._target_humidity_char_name, + value=45, + properties={ + PROP_MIN_VALUE: min_humidity, + PROP_MAX_VALUE: max_humidity, + PROP_MIN_STEP: 1, + }, + ) + + # Active/inactive characteristics + self.char_active = serv_humidifier_dehumidifier.configure_char( + CHAR_ACTIVE, value=False + ) + + self.async_update_state(state) + + serv_humidifier_dehumidifier.setter_callback = self._set_chars + + self.linked_humidity_sensor = self.config.get(CONF_LINKED_HUMIDITY_SENSOR) + if self.linked_humidity_sensor: + humidity_state = self.hass.states.get(self.linked_humidity_sensor) + if humidity_state: + self._async_update_current_humidity(humidity_state) + + async def run_handler(self): + """Handle accessory driver started event. + + Run inside the Home Assistant event loop. + """ + if self.linked_humidity_sensor: + async_track_state_change_event( + self.hass, + [self.linked_humidity_sensor], + self.async_update_current_humidity_event, + ) + + await super().run_handler() + + @callback + def async_update_current_humidity_event(self, event): + """Handle state change event listener callback.""" + self._async_update_current_humidity(event.data.get("new_state")) + + @callback + def _async_update_current_humidity(self, new_state): + """Handle linked humidity sensor state change to update HomeKit value.""" + if new_state is None: + _LOGGER.error( + "%s: Unable to update from linked humidity sensor %s: the entity state is None", + self.entity_id, + self.linked_humidity_sensor, + ) + return + try: + current_humidity = float(new_state.state) + if self.char_current_humidity.value != current_humidity: + _LOGGER.debug( + "%s: Linked humidity sensor %s changed to %d", + self.entity_id, + self.linked_humidity_sensor, + current_humidity, + ) + self.char_current_humidity.set_value(current_humidity) + except ValueError as ex: + _LOGGER.error( + "%s: Unable to update from linked humidity sensor %s: %s", + self.entity_id, + self.linked_humidity_sensor, + ex, + ) + + def _set_chars(self, char_values): + _LOGGER.debug("HumidifierDehumidifier _set_chars: %s", char_values) + + if CHAR_TARGET_HUMIDIFIER_DEHUMIDIFIER in char_values: + hk_value = char_values[CHAR_TARGET_HUMIDIFIER_DEHUMIDIFIER] + if self._hk_device_class != hk_value: + _LOGGER.error( + "%s is not supported", CHAR_TARGET_HUMIDIFIER_DEHUMIDIFIER + ) + + if CHAR_ACTIVE in char_values: + self.call_service( + DOMAIN, + SERVICE_TURN_ON if char_values[CHAR_ACTIVE] else SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: self.entity_id}, + f"{CHAR_ACTIVE} to {char_values[CHAR_ACTIVE]}", + ) + + if self._target_humidity_char_name in char_values: + humidity = round(char_values[self._target_humidity_char_name]) + self.call_service( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: self.entity_id, ATTR_HUMIDITY: humidity}, + f"{self._target_humidity_char_name} to " + f"{char_values[self._target_humidity_char_name]}{UNIT_PERCENTAGE}", + ) + + @callback + def async_update_state(self, new_state): + """Update state without rechecking the device features.""" + is_active = new_state.state == STATE_ON + + # Update active state + if self.char_active.value != is_active: + self.char_active.set_value(is_active) + + # Set current state + if is_active: + if self._hk_device_class == HC_HUMIDIFIER: + current_state = HC_STATE_HUMIDIFYING + else: + current_state = HC_STATE_DEHUMIDIFYING + else: + current_state = HC_STATE_INACTIVE + if self.char_current_humidifier_dehumidifier.value != current_state: + self.char_current_humidifier_dehumidifier.set_value(current_state) + + # Update target humidity + target_humidity = new_state.attributes.get(ATTR_HUMIDITY) + if isinstance(target_humidity, (int, float)): + if self.char_target_humidity.value != target_humidity: + self.char_target_humidity.set_value(target_humidity) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 0465e33388d..c578e3ea76c 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -34,6 +34,7 @@ from .const import ( CONF_FEATURE_LIST, CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, + CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_LOW_BATTERY_THRESHOLD, CONF_MAX_FPS, @@ -124,6 +125,10 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend( } ) +HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend( + {vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)} +) + CODE_SCHEMA = BASIC_INFO_SCHEMA.extend( {vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string)} ) @@ -230,6 +235,9 @@ def validate_entity_config(values): elif domain == "switch": config = SWITCH_TYPE_SCHEMA(config) + elif domain == "humidifier": + config = HUMIDIFIER_SCHEMA(config) + else: config = BASIC_INFO_SCHEMA(config) diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index 3bda3ed7491..301e60fad67 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -18,6 +18,7 @@ def _mock_config_entry_with_options_populated(): "filter": { "include_domains": [ "fan", + "humidifier", "vacuum", "media_player", "climate", @@ -134,7 +135,8 @@ async def test_options_flow_advanced(hass): assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={"include_domains": ["fan", "vacuum", "climate"]}, + result["flow_id"], + user_input={"include_domains": ["fan", "vacuum", "climate", "humidifier"]}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -157,7 +159,7 @@ async def test_options_flow_advanced(hass): "filter": { "exclude_domains": [], "exclude_entities": ["climate.old"], - "include_domains": ["fan", "vacuum", "climate"], + "include_domains": ["fan", "vacuum", "climate", "humidifier"], "include_entities": [], }, "safe_mode": True, @@ -332,6 +334,7 @@ async def test_options_flow_blocked_when_from_yaml(hass): "filter": { "include_domains": [ "fan", + "humidifier", "vacuum", "media_player", "climate", diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 11827c2ce4f..85f517f28be 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -91,6 +91,7 @@ def test_customize_options(config, name): {ATTR_SUPPORTED_FEATURES: climate.SUPPORT_TARGET_TEMPERATURE_RANGE}, {}, ), + ("HumidifierDehumidifier", "humidifier.test", "auto", {}, {}), ("WaterHeater", "water_heater.test", "auto", {}, {}), ], ) diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 954758fe3f5..b1f968eab10 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -42,13 +42,16 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_UNIT_OF_MEASUREMENT, CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_ON, + UNIT_PERCENTAGE, ) from homeassistant.core import State from homeassistant.helpers import device_registry @@ -1094,3 +1097,80 @@ async def test_homekit_finds_linked_motion_sensors( "linked_motion_sensor": "binary_sensor.camera_motion_sensor", }, ) + + +async def test_homekit_finds_linked_humidity_sensors( + hass, hk_driver, debounce_patcher, device_reg, entity_reg +): + """Test HomeKit start method.""" + entry = await async_init_integration(hass) + + homekit = HomeKit( + hass, + None, + None, + None, + {}, + {"humidifier.humidifier": {}}, + DEFAULT_SAFE_MODE, + advertise_ip=None, + entry_id=entry.entry_id, + ) + homekit.driver = hk_driver + homekit._filter = Mock(return_value=True) + homekit.bridge = HomeBridge(hass, hk_driver, "mock_bridge") + + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + sw_version="0.16.1", + model="Smart Brainy Clever Humidifier", + manufacturer="Home Assistant", + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + humidity_sensor = entity_reg.async_get_or_create( + "sensor", + "humidifier", + "humidity_sensor", + device_id=device_entry.id, + device_class=DEVICE_CLASS_HUMIDITY, + ) + humidifier = entity_reg.async_get_or_create( + "humidifier", "humidifier", "demo", device_id=device_entry.id + ) + + hass.states.async_set( + humidity_sensor.entity_id, + "42", + { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE, + }, + ) + hass.states.async_set(humidifier.entity_id, STATE_ON) + + def _mock_get_accessory(*args, **kwargs): + return [None, "acc", None] + + with patch.object(homekit.bridge, "add_accessory"), patch( + f"{PATH_HOMEKIT}.show_setup_message" + ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( + "pyhap.accessory_driver.AccessoryDriver.start" + ): + await homekit.async_start() + await hass.async_block_till_done() + + mock_get_acc.assert_called_with( + hass, + hk_driver, + ANY, + ANY, + { + "manufacturer": "Home Assistant", + "model": "Smart Brainy Clever Humidifier", + "sw_version": "0.16.1", + "linked_humidity_sensor": "sensor.humidifier_humidity_sensor", + }, + ) diff --git a/tests/components/homekit/test_type_humidifiers.py b/tests/components/homekit/test_type_humidifiers.py new file mode 100644 index 00000000000..34f61a5c0df --- /dev/null +++ b/tests/components/homekit/test_type_humidifiers.py @@ -0,0 +1,419 @@ +"""Test different accessory types: HumidifierDehumidifier.""" +from pyhap.const import ( + CATEGORY_HUMIDIFIER, + HAP_REPR_AID, + HAP_REPR_CHARS, + HAP_REPR_IID, + HAP_REPR_VALUE, +) + +from homeassistant.components.homekit.const import ( + ATTR_VALUE, + CONF_LINKED_HUMIDITY_SENSOR, + PROP_MAX_VALUE, + PROP_MIN_STEP, + PROP_MIN_VALUE, + PROP_VALID_VALUES, +) +from homeassistant.components.homekit.type_humidifiers import HumidifierDehumidifier +from homeassistant.components.humidifier.const import ( + ATTR_HUMIDITY, + ATTR_MAX_HUMIDITY, + ATTR_MIN_HUMIDITY, + DEFAULT_MAX_HUMIDITY, + DEFAULT_MIN_HUMIDITY, + DEVICE_CLASS_DEHUMIDIFIER, + DEVICE_CLASS_HUMIDIFIER, + DOMAIN, + SERVICE_SET_HUMIDITY, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_HUMIDITY, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + UNIT_PERCENTAGE, +) + +from tests.common import async_mock_service + + +async def test_humidifier(hass, hk_driver, events): + """Test if humidifier accessory and HA are updated accordingly.""" + entity_id = "humidifier.test" + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + acc = HumidifierDehumidifier( + hass, hk_driver, "HumidifierDehumidifier", entity_id, 1, None + ) + hk_driver.add_accessory(acc) + + await acc.run_handler() + await hass.async_block_till_done() + + assert acc.aid == 1 + assert acc.category == CATEGORY_HUMIDIFIER + + assert acc.char_current_humidifier_dehumidifier.value == 0 + assert acc.char_target_humidifier_dehumidifier.value == 1 + assert acc.char_current_humidity.value == 0 + assert acc.char_target_humidity.value == 45.0 + assert acc.char_active.value == 0 + + assert acc.char_target_humidity.properties[PROP_MAX_VALUE] == DEFAULT_MAX_HUMIDITY + assert acc.char_target_humidity.properties[PROP_MIN_VALUE] == DEFAULT_MIN_HUMIDITY + assert acc.char_target_humidity.properties[PROP_MIN_STEP] == 1.0 + assert acc.char_target_humidifier_dehumidifier.properties[PROP_VALID_VALUES] == { + "Humidifier": 1 + } + + hass.states.async_set( + entity_id, STATE_ON, {ATTR_HUMIDITY: 47}, + ) + await hass.async_block_till_done() + assert acc.char_target_humidity.value == 47.0 + assert acc.char_current_humidifier_dehumidifier.value == 2 + assert acc.char_target_humidifier_dehumidifier.value == 1 + assert acc.char_active.value == 1 + + hass.states.async_set( + entity_id, + STATE_OFF, + {ATTR_HUMIDITY: 42, ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDIFIER}, + ) + await hass.async_block_till_done() + assert acc.char_target_humidity.value == 42.0 + assert acc.char_current_humidifier_dehumidifier.value == 0 + assert acc.char_target_humidifier_dehumidifier.value == 1 + assert acc.char_active.value == 0 + + # Set from HomeKit + call_set_humidity = async_mock_service(hass, DOMAIN, SERVICE_SET_HUMIDITY) + + char_target_humidity_iid = acc.char_target_humidity.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_humidity_iid, + HAP_REPR_VALUE: 39.0, + }, + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert len(call_set_humidity) == 1 + assert call_set_humidity[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_humidity[0].data[ATTR_HUMIDITY] == 39.0 + assert acc.char_target_humidity.value == 39.0 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == "RelativeHumidityHumidifierThreshold to 39.0%" + + +async def test_dehumidifier(hass, hk_driver, events): + """Test if dehumidifier accessory and HA are updated accordingly.""" + entity_id = "humidifier.test" + + hass.states.async_set( + entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: DEVICE_CLASS_DEHUMIDIFIER} + ) + await hass.async_block_till_done() + acc = HumidifierDehumidifier( + hass, hk_driver, "HumidifierDehumidifier", entity_id, 1, None + ) + hk_driver.add_accessory(acc) + + await acc.run_handler() + await hass.async_block_till_done() + + assert acc.aid == 1 + assert acc.category == CATEGORY_HUMIDIFIER + + assert acc.char_current_humidifier_dehumidifier.value == 0 + assert acc.char_target_humidifier_dehumidifier.value == 2 + assert acc.char_current_humidity.value == 0 + assert acc.char_target_humidity.value == 45.0 + assert acc.char_active.value == 0 + + assert acc.char_target_humidity.properties[PROP_MAX_VALUE] == DEFAULT_MAX_HUMIDITY + assert acc.char_target_humidity.properties[PROP_MIN_VALUE] == DEFAULT_MIN_HUMIDITY + assert acc.char_target_humidity.properties[PROP_MIN_STEP] == 1.0 + assert acc.char_target_humidifier_dehumidifier.properties[PROP_VALID_VALUES] == { + "Dehumidifier": 2 + } + + hass.states.async_set( + entity_id, STATE_ON, {ATTR_HUMIDITY: 30}, + ) + await hass.async_block_till_done() + assert acc.char_target_humidity.value == 30.0 + assert acc.char_current_humidifier_dehumidifier.value == 3 + assert acc.char_target_humidifier_dehumidifier.value == 2 + assert acc.char_active.value == 1 + + hass.states.async_set( + entity_id, STATE_OFF, {ATTR_HUMIDITY: 42}, + ) + await hass.async_block_till_done() + assert acc.char_target_humidity.value == 42.0 + assert acc.char_current_humidifier_dehumidifier.value == 0 + assert acc.char_target_humidifier_dehumidifier.value == 2 + assert acc.char_active.value == 0 + + # Set from HomeKit + call_set_humidity = async_mock_service(hass, DOMAIN, SERVICE_SET_HUMIDITY) + + char_target_humidity_iid = acc.char_target_humidity.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_humidity_iid, + HAP_REPR_VALUE: 39.0, + }, + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert len(call_set_humidity) == 1 + assert call_set_humidity[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_humidity[0].data[ATTR_HUMIDITY] == 39.0 + assert acc.char_target_humidity.value == 39.0 + assert len(events) == 1 + assert ( + events[-1].data[ATTR_VALUE] == "RelativeHumidityDehumidifierThreshold to 39.0%" + ) + + +async def test_hygrostat_power_state(hass, hk_driver, events): + """Test if accessory and HA are updated accordingly.""" + entity_id = "humidifier.test" + + hass.states.async_set( + entity_id, STATE_ON, {ATTR_HUMIDITY: 43}, + ) + await hass.async_block_till_done() + acc = HumidifierDehumidifier( + hass, hk_driver, "HumidifierDehumidifier", entity_id, 1, None + ) + hk_driver.add_accessory(acc) + + await acc.run_handler() + await hass.async_block_till_done() + + assert acc.char_current_humidifier_dehumidifier.value == 2 + assert acc.char_target_humidifier_dehumidifier.value == 1 + assert acc.char_active.value == 1 + + hass.states.async_set( + entity_id, STATE_OFF, {ATTR_HUMIDITY: 43}, + ) + await hass.async_block_till_done() + assert acc.char_current_humidifier_dehumidifier.value == 0 + assert acc.char_target_humidifier_dehumidifier.value == 1 + assert acc.char_active.value == 0 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + + char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_active_iid, + HAP_REPR_VALUE: 1, + }, + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert len(call_turn_on) == 1 + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_active.value == 1 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == "Active to 1" + + call_turn_off = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF) + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_active_iid, + HAP_REPR_VALUE: 0, + }, + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert len(call_turn_off) == 1 + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_active.value == 0 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == "Active to 0" + + +async def test_hygrostat_get_humidity_range(hass, hk_driver): + """Test if humidity range is evaluated correctly.""" + entity_id = "humidifier.test" + + hass.states.async_set( + entity_id, STATE_OFF, {ATTR_MIN_HUMIDITY: 40, ATTR_MAX_HUMIDITY: 45} + ) + await hass.async_block_till_done() + acc = HumidifierDehumidifier( + hass, hk_driver, "HumidifierDehumidifier", entity_id, 1, None + ) + hk_driver.add_accessory(acc) + + await acc.run_handler() + await hass.async_block_till_done() + + assert acc.char_target_humidity.properties[PROP_MAX_VALUE] == 45 + assert acc.char_target_humidity.properties[PROP_MIN_VALUE] == 40 + + +async def test_humidifier_with_linked_humidity_sensor(hass, hk_driver): + """Test a humidifier with a linked humidity sensor can update.""" + humidity_sensor_entity_id = "sensor.bedroom_humidity" + + hass.states.async_set( + humidity_sensor_entity_id, + "42.0", + { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE, + }, + ) + await hass.async_block_till_done() + entity_id = "humidifier.test" + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + acc = HumidifierDehumidifier( + hass, + hk_driver, + "HumidifierDehumidifier", + entity_id, + 1, + {CONF_LINKED_HUMIDITY_SENSOR: humidity_sensor_entity_id}, + ) + hk_driver.add_accessory(acc) + + await acc.run_handler() + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 42.0 + + hass.states.async_set( + humidity_sensor_entity_id, + "43.0", + { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE, + }, + ) + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 43.0 + + hass.states.async_set( + humidity_sensor_entity_id, + STATE_UNAVAILABLE, + { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE, + }, + ) + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 43.0 + + hass.states.async_remove(humidity_sensor_entity_id) + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 43.0 + + +async def test_humidifier_with_a_missing_linked_humidity_sensor(hass, hk_driver): + """Test a humidifier with a configured linked motion sensor that is missing.""" + humidity_sensor_entity_id = "sensor.bedroom_humidity" + entity_id = "humidifier.test" + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + acc = HumidifierDehumidifier( + hass, + hk_driver, + "HumidifierDehumidifier", + entity_id, + 1, + {CONF_LINKED_HUMIDITY_SENSOR: humidity_sensor_entity_id}, + ) + hk_driver.add_accessory(acc) + + await acc.run_handler() + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 0 + + +async def test_humidifier_as_dehumidifier(hass, hk_driver, events, caplog): + """Test an invalid char_target_humidifier_dehumidifier from HomeKit.""" + entity_id = "humidifier.test" + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + acc = HumidifierDehumidifier( + hass, hk_driver, "HumidifierDehumidifier", entity_id, 1, None + ) + hk_driver.add_accessory(acc) + + await acc.run_handler() + await hass.async_block_till_done() + + assert acc.char_target_humidifier_dehumidifier.value == 1 + + # Set from HomeKit + char_target_humidifier_dehumidifier_iid = acc.char_target_humidifier_dehumidifier.to_HAP()[ + HAP_REPR_IID + ] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_humidifier_dehumidifier_iid, + HAP_REPR_VALUE: 0, + }, + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert "TargetHumidifierDehumidifierState is not supported" in caplog.text + assert len(events) == 0